From 2f2bd135d3dfd34cfef327b2d35012cfa2062701 Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Sat, 10 Oct 2015 22:06:02 +0200 Subject: Initial code import --- .gitignore | 8 +++ README.md | 20 +++++- youtube-podcaster | 159 ++++++++++++++++++++++++++++++++++++++++++ youtube-podcaster.json.sample | 10 +++ youtube/__init__.py | 6 ++ youtube/downloader.py | 42 +++++++++++ youtube/youtube.py | 44 ++++++++++++ 7 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100755 youtube-podcaster create mode 100644 youtube-podcaster.json.sample create mode 100644 youtube/__init__.py create mode 100644 youtube/downloader.py create mode 100644 youtube/youtube.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d7362c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +downloads/ + +feeds.save +youtube-podcaster.json + +*.pyc +*.xml +*.sw? diff --git a/README.md b/README.md index ad7cc36..be8fbbd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ -# youtube-podcaster -Converts youtube playlists to RSS-feeds +# YouTube Podcaster +Lets you covert YouTube-playlists to RSS podcast feeds and serve them. + +## Features +* Check playlists on HTTP-request +* Downloads videos and converts them to Ogg Vorbis files. +* Serve RSS feed + +## What it doesn't do (at the moment) +* Convert to mp3, mp4 or any other audio/video format +* Serve the downloaded audio files (needs to be done by an other server) + +## To do +* Add comments/documentation +* Support multiple audio/video formats + +## LICENSE +YouTube Podcaster is licensed under the MIT License, see the LICENSE file for more info. diff --git a/youtube-podcaster b/youtube-podcaster new file mode 100755 index 0000000..bb2db26 --- /dev/null +++ b/youtube-podcaster @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import time +import os +import pickle +import json + +from http.server import HTTPServer, BaseHTTPRequestHandler +from hashlib import sha1 + +from feedgen.feed import FeedGenerator + +import youtube + + +class PodcastUpdater: + instance = None + + def get_instance(): + if PodcastUpdater.instance: + return PodcastUpdater.instance + else: + PodcastUpdater.instance = PodcastUpdater() + return PodcastUpdater.instance + + def __init__(self): + self.podcasts = config["podcasts"] + + self.feeds_file = "feeds.save" + if os.path.isfile(self.feeds_file): + with open(self.feeds_file, "rb") as feeds: + self.feeds = pickle.load(feeds) + else: + self.feeds = {} + + def get_xml(self, channel, playlist): + channel = channel.replace('_', ' ') + playlist = playlist.replace('_', ' ') + + for podcast in self.podcasts: + if podcast["username"] == channel: + break + else: + return None + + if playlist not in podcast["playlists"]: + return None + + xml = self.update_podcast(channel, playlist) + + if xml: + return open(xml).read() + + def update_podcast(self, channel, playlist): + feed_id = sha1(bytes("%s %s" % (channel, playlist), "UTF-8")).hexdigest() + yt_channel = yt.get_channel(channel)[0] + yt_playlists = yt.get_playlists(yt_channel, 50) + + for yt_playlist in yt_playlists: + if yt_playlist["snippet"]["title"] == playlist: + break + else: + return None + + if feed_id in self.feeds: + feed = self.feeds[feed_id] + else: + feed = self.add_feed(feed_id, yt_playlist) + + if feed.last_updated < time.time() - 600: + self.populate_feed(feed, feed_id, yt_playlist) + + feed_file = "%s.xml" % (feed_id) + self.feeds[feed_id].rss_file(feed_file) + + with open(self.feeds_file, "wb") as feed: + pickle.dump(self.feeds, feed) + + return "%s.xml" % (feed_id) + + def add_feed(self, feed_id, yt_playlist): + feed = FeedGenerator() + feed.load_extension("podcast") + feed.id(feed_id) + feed.title(yt_playlist["snippet"]["title"]) + feed.author({"name": yt_playlist["snippet"]["channelTitle"]}) + feed.description(yt_playlist["snippet"]["description"]) + feed.logo(yt_playlist["snippet"]["thumbnails"]["standard"]["url"]) + feed.link(href="https://www.youtube.com/playlist?list=%s" % (yt_playlist["id"])) + feed.rss_str(pretty=True) + feed.last_updated = 0 + self.feeds[feed_id] = feed + return feed + + def populate_feed(self, feed, feed_id, yt_playlist, max_results=5): + videos = yt.get_playlist_items(yt_playlist, max_results) + downloader = youtube.Downloader.get_instance("vorbis", "downloads", "192.168.178.100") + + entries = feed.entry() + for video in videos: + video_id = sha1(bytes(video["id"], "UTF-8")).hexdigest() + for entry in entries: + if entry.id() == video_id: + break + else: + url, size, mime = downloader.download(video, video_id, feed_id) + feed_entry = feed.add_entry() + feed_entry.id(video_id) + feed_entry.guid(video_id) + feed_entry.title(video["snippet"]["title"]) + feed_entry.description(video["snippet"]["description"]) + feed_entry.published(video["snippet"]["publishedAt"]) + feed_entry.enclosure(url, size, mime) + + feed.last_updated = time.time() + + +class PodcastFeeder(BaseHTTPRequestHandler): + def do_GET(self): + updater = PodcastUpdater.get_instance() + path = self.path.split('/') + + if len(path) == 3: + channel = path[1] + playlist = path[2] + else: + return self.return_error(404) + + xml = updater.get_xml(channel, playlist) + + if not xml: + return self.return_error(404) + else: + self.send_response(200) + self.send_header("Content-type", "text/xml") + self.end_headers() + self.wfile.write(bytes(xml, 'UTF-8')) + + def return_error(self, code): + self.send_response(code) + self.send_header("Content-type", "text/html") + self.end_headers() + + reponse = "Error: %s" % (code) + self.wfile.write(bytes(reponse, 'UTF-8')) + + +def main(): + try: + server = HTTPServer(("", 8888), PodcastFeeder) + server.serve_forever() + except KeyboardInterrupt: + server.socket.close() + +if __name__ == "__main__": + config = json.load(open("youtube-podcaster.json")) + yt = youtube.Youtube(config["youtube"]["api-key"]) + main() + +# vim: set ts=8 sw=4 tw=0 et : diff --git a/youtube-podcaster.json.sample b/youtube-podcaster.json.sample new file mode 100644 index 0000000..d9d078f --- /dev/null +++ b/youtube-podcaster.json.sample @@ -0,0 +1,10 @@ +{ + "youtube": { + "api-key": "youtube-api-v3-key-here" + }, + + "podcasts": [{ + "username": "youtube-username-here", + "playlists": ["playlist-name-here"] + }] +} diff --git a/youtube/__init__.py b/youtube/__init__.py new file mode 100644 index 0000000..d26ae61 --- /dev/null +++ b/youtube/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from youtube.youtube import Youtube +from youtube.downloader import Downloader + +# vim: set ts=8 sw=4 tw=0 et : diff --git a/youtube/downloader.py b/youtube/downloader.py new file mode 100644 index 0000000..ca1327b --- /dev/null +++ b/youtube/downloader.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import youtube_dl +import os + + +class Downloader: + instance = None + + def get_instance(file_format, location, base_url): + if Downloader.instance: + return Downloader.instance + else: + Downloader.instance = Downloader(file_format, location, base_url) + return Downloader.instance + + def __init__(self, file_format, location, base_url): + self.file_format = file_format + self.location = location + self.base_url = base_url + + def download(self, video, video_id, feed_id): + output = "%s/%s/%s.ogg" % (self.location, feed_id, video_id) + options = {"format": "bestaudio/best", + "outtmpl": output, + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": self.file_format + }], + "nooverwrites": True + } + + video_url = "https://www.youtube.com/watch?v=%s" % (video["snippet"]["resourceId"]["videoId"]) + youtube_dl.YoutubeDL(options).download([video_url]) + + url = "%s/%s/%s.ogg" % (self.base_url, feed_id, video_id) + size = str(os.path.getsize(output)) + mime = "audio/ogg" + + return (url, size, mime) + +# vim: set ts=8 sw=4 tw=0 et : diff --git a/youtube/youtube.py b/youtube/youtube.py new file mode 100644 index 0000000..bcbff21 --- /dev/null +++ b/youtube/youtube.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +from urllib import parse, request +import json + + +class Youtube: + + def __init__(self, api_key): + self.api_key = api_key + + def _api_call(self, section, parameters): + parameters["key"] = self.api_key + data = parse.urlencode(parameters) + api_base = "https://www.googleapis.com/youtube/v3" + response = request.urlopen("%s/%s?%s" % (api_base, section, data)) + return json.loads(response.read().decode("UTF-8"))["items"] + + def search(self, query): + return self._api_call("search", {"part": "snippet", + "q": query}) + + def get_channel(self, username): + return self._api_call("channels", {"part": "snippet", + "forUsername": username}) + + def get_uploads(self, channel): + content_details = self._api_call("channels", {"part": "contentDetails", + "id": channel["id"]}) + uploads_id = content_details[0]["contentDetails"]["relatedPlaylists"]["uploads"] + return self._api_call("playlists", {"part": "snippet", + "id": uploads_id}) + + def get_playlists(self, channel, max_results=5): + return self._api_call("playlists", {"part": "snippet", + "channelId": channel["id"], + "maxResults": max_results}) + + def get_playlist_items(self, playlist, max_results=5): + return self._api_call("playlistItems", {"part": "snippet", + "playlistId": playlist["id"], + "maxResults": max_results}) + +# vim: set ts=8 sw=4 tw=0 et : -- cgit v1.2.3