aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.rst1
-rw-r--r--docs/index.rst27
-rw-r--r--setup.cfg50
-rw-r--r--youtube-podcaster.json.example21
-rw-r--r--youtube_podcaster/__init__.py44
-rw-r--r--youtube_podcaster/__main__.py8
-rw-r--r--youtube_podcaster/config.py64
-rw-r--r--youtube_podcaster/podcastfeeder.py4
-rw-r--r--youtube_podcaster/podcastupdater.py34
-rw-r--r--youtube_podcaster/youtube-podcaster.json.sample10
-rw-r--r--youtube_podcaster/youtube/downloader.py23
12 files changed, 171 insertions, 116 deletions
diff --git a/.gitignore b/.gitignore
index 55cf740..6296ae8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,4 +44,3 @@ MANIFEST
44*.xml 44*.xml
45*.save 45*.save
46downloads/ 46downloads/
47youtube-podcaster.json
diff --git a/README.rst b/README.rst
index b485fbb..9b66292 100644
--- a/README.rst
+++ b/README.rst
@@ -1,3 +1,4 @@
1=================
1YouTube Podcaster 2YouTube Podcaster
2================= 3=================
3 4
diff --git a/docs/index.rst b/docs/index.rst
index 62a305d..73e63f8 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,29 +1,4 @@
1================= 1.. include:: ../README.rst
2youtube-podcaster
3=================
4
5This is the documentation of **youtube-podcaster**.
6
7.. note::
8
9 This is the main page of your project's `Sphinx <http://sphinx-doc.org/>`_
10 documentation. It is formatted in `reStructuredText
11 <http://sphinx-doc.org/rest.html>`__. Add additional pages by creating
12 rst-files in ``docs`` and adding them to the `toctree
13 <http://sphinx-doc.org/markup/toctree.html>`_ below. Use then
14 `references <http://sphinx-doc.org/markup/inline.html>`__ in order to link
15 them from this page, e.g. :ref:`authors <authors>` and :ref:`changes`.
16 It is also possible to refer to the documentation of other Python packages
17 with the `Python domain syntax
18 <http://sphinx-doc.org/domains.html#the-python-domain>`__. By default you
19 can reference the documentation of `Sphinx <http://sphinx.pocoo.org>`__,
20 `Python <http://docs.python.org/>`__, `matplotlib
21 <http://matplotlib.sourceforge.net>`__, `NumPy
22 <http://docs.scipy.org/doc/numpy>`__, `Scikit-Learn
23 <http://scikit-learn.org/stable>`__, `Pandas
24 <http://pandas.pydata.org/pandas-docs/stable>`__, `SciPy
25 <http://docs.scipy.org/doc/scipy/reference/>`__. You can add more by
26 extending the ``intersphinx_mapping`` in your Sphinx's ``conf.py``.
27 2
28Contents 3Contents
29======== 4========
diff --git a/setup.cfg b/setup.cfg
index f1b8f8c..e559f20 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,53 +6,35 @@ author-email = t0m.vd.l33@gmail.com
6license = MIT License 6license = MIT License
7home-page = http://github.com/tomvanderlee/youtube-podcaster 7home-page = http://github.com/tomvanderlee/youtube-podcaster
8description-file = README.rst 8description-file = README.rst
9# Add here all kinds of additional classifiers as defined under
10# https://pypi.python.org/pypi?%3Aaction=list_classifiers
11classifiers = Development Status :: 3 - Alpha, 9classifiers = Development Status :: 3 - Alpha,
12 License :: OSI Approved :: MIT License 10 License :: OSI Approved :: MIT License
13 Programming Language :: Python :: 3 :: Only 11 Programming Language :: Python :: 3 :: Only
14 12
15[entry_points] 13[entry_points]
16console_scripts = 14console_scripts =
17 youtube-podcaster = youtube_podcaster:main 15 youtube-podcaster = youtube_podcaster:main
18# Add here console scripts like:
19# console_scripts =
20# hello_world = youtube_podcaster.module:function
21# as well as other entry_points.
22
23 16
24[files] 17[files]
25# Add here 'data_files', 'packages' or 'namespace_packages'. 18packages =
26# Additional data files are defined as key value pairs of source and target: 19 youtube_podcaster
27packages = 20data_files =
28 youtube_podcaster 21 share/man/man1/ = docs/_build/man/*
29# data_files = 22 share/youtube-podcaster/ = youtube-podcaster.json.example
30# share/youtube_podcaster_docs = docs/*
31 23
32[extras] 24[extras]
33# Add here additional requirements for extra features, like:
34# PDF =
35# ReportLab>=1.2
36# RXP
37 25
38[test] 26[test]
39# py.test options when running `python setup.py test`
40addopts = tests 27addopts = tests
41 28
42[pytest] 29[pytest]
43# Options for py.test: 30addopts =
44# Specify command line options as you would do when invoking py.test directly. 31 --cov youtube_podcaster --cov-report term-missing
45# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml 32 --verbose
46# in order to write a coverage file that can be read by Jenkins.
47addopts =
48 --cov youtube_podcaster --cov-report term-missing
49 --verbose
50 33
51[aliases] 34[aliases]
52docs = build_sphinx 35docs = build_sphinx
53 36
54[bdist_wheel] 37[bdist_wheel]
55# Use this option if your package is pure-python
56universal = 1 38universal = 1
57 39
58[build_sphinx] 40[build_sphinx]
@@ -60,17 +42,11 @@ source_dir = docs
60build_dir = docs/_build 42build_dir = docs/_build
61 43
62[pbr] 44[pbr]
63# Let pbr run sphinx-apidoc
64autodoc_tree_index_modules = True 45autodoc_tree_index_modules = True
65# autodoc_tree_excludes = ...
66# Let pbr itself generate the apidoc
67# autodoc_index_modules = True
68# autodoc_exclude_modules = ...
69# Convert warnings to errors
70# warnerrors = True
71 46
72[devpi:upload] 47[devpi:upload]
73# Options for the devpi: PyPI serer and packaging tool
74# VCS export must be deactivated since we are using setuptools-scm
75no-vcs = 1 48no-vcs = 1
76format = bdist_wheel 49format = bdist_wheel
50
51[easy_install]
52
diff --git a/youtube-podcaster.json.example b/youtube-podcaster.json.example
new file mode 100644
index 0000000..d9b48af
--- /dev/null
+++ b/youtube-podcaster.json.example
@@ -0,0 +1,21 @@
1{
2 "server" : {
3 "interface": "0.0.0.0",
4 "port": 8888
5 },
6
7 "youtube": {
8 "api-key": ""
9 },
10
11 "podcasts": [{
12 "username": "",
13 "playlists": [""]
14 }],
15
16 "downloads": {
17 "format": "vorbis",
18 "path": "/tmp/downloads/",
19 "url": "localhost"
20 }
21}
diff --git a/youtube_podcaster/__init__.py b/youtube_podcaster/__init__.py
index 32aee7c..77ab9cc 100644
--- a/youtube_podcaster/__init__.py
+++ b/youtube_podcaster/__init__.py
@@ -1,27 +1,47 @@
1#!/usr/bin/env python3 1#!/usr/bin/env python3
2 2
3import json 3import argparse
4 4
5from http.server import ( 5from http.server import HTTPServer
6 HTTPServer,
7)
8 6
9from . import ( 7from .podcastfeeder import create_feeder
10 youtube, 8from .config import (
9 Config,
10 ConfigException
11) 11)
12 12
13from .podcastfeeder import ( 13"""
14 create_feeder 14Start the program
15) 15"""
16 16
17 17
18def main(): 18def main():
19 config = json.load(open("youtube-podcaster.json")) 19 arg_parser = argparse.ArgumentParser(prog="youtube-podcaster",
20 description="Converts youtube \
21 playlists to RSS-feeds")
22 arg_parser.add_argument("-c", "--config",
23 dest="config",
24 help="Use CONFIG as the config file")
25 arg_parser.add_argument("-i", "--interface",
26 dest="interface",
27 help="The interface the http server will listen on")
28 arg_parser.add_argument("-p", "--port",
29 dest="port",
30 help="The port the http server will listen on")
31 arg_parser.add_argument("--api-key",
32 dest="apikey",
33 help="The YouTube API v3 key")
34 args = arg_parser.parse_args()
20 35
21 try: 36 try:
22 PodcastFeeder = create_feeder(config["youtube"], config["podcasts"]) 37 config = Config.parse_config(args)
23 server = HTTPServer(("", 8888), PodcastFeeder) 38
39 PodcastFeeder = create_feeder(config)
40
41 server = HTTPServer(config.get_server_address(), PodcastFeeder)
24 server.serve_forever() 42 server.serve_forever()
43 except ConfigException as e:
44 print(e)
25 except KeyboardInterrupt: 45 except KeyboardInterrupt:
26 server.socket.close() 46 server.socket.close()
27 47
diff --git a/youtube_podcaster/__main__.py b/youtube_podcaster/__main__.py
deleted file mode 100644
index 941a79a..0000000
--- a/youtube_podcaster/__main__.py
+++ /dev/null
@@ -1,8 +0,0 @@
1#!/usr/bin/env python3
2
3import youtube_podcaster
4
5if __name__ == "__main__":
6 youtube_podcaster.main()
7
8# vim: set ts=8 sw=4 tw=0 et :
diff --git a/youtube_podcaster/config.py b/youtube_podcaster/config.py
new file mode 100644
index 0000000..bfce902
--- /dev/null
+++ b/youtube_podcaster/config.py
@@ -0,0 +1,64 @@
1import sys
2import os
3import json
4
5
6class ConfigException(Exception):
7 def __init__(self, msg):
8 super(ConfigException, self).__init__("Config exception: %s" % (msg))
9
10
11class Config:
12 instance = None
13
14 def parse_config(config_file=None):
15 if not Config.instance:
16 Config.instance = Config(config_file)
17
18 return Config.instance
19
20 def __init__(self, args):
21 if args.config:
22 config = args.config
23 else:
24 config_file = "youtube-podcaster.json"
25
26 if sys.platform == "linux" and not hasattr(sys, "real_prefix"):
27 config = "/etc/%s" % (config_file)
28 else:
29 config = "%s/etc/%s" % (sys.prefix, config_file)
30
31 if not os.path.isfile(config):
32 raise ConfigException("%s not found" % (config))
33
34 try:
35 config = json.load(open(config))
36 except json.decoder.JSONDecodeError as e:
37 raise ConfigException("%s is not valid json: %s" % (
38 config, str(e)))
39
40 try:
41 self.server = config["server"]
42 self.youtube = config["youtube"]
43 self.podcasts = config["podcasts"]
44 except KeyError as e:
45 raise ConfigException("Missing %s-section in %s" % (
46 str(e), config))
47
48 for arg, value in vars(args).items():
49 if not value:
50 continue
51
52 if arg == "interface":
53 self.server["interface"] = value
54 elif arg == "port":
55 self.server["port"] = value
56 elif arg == "apikey":
57 self.youtube["api-key"] = value
58
59 def get_server_address(self):
60 interface = str(self.server["interface"])
61 port = int(self.server["port"])
62 return interface, port
63
64# vim: set ts=8 sw=4 tw=0 et :
diff --git a/youtube_podcaster/podcastfeeder.py b/youtube_podcaster/podcastfeeder.py
index 3f752d2..4181d6c 100644
--- a/youtube_podcaster/podcastfeeder.py
+++ b/youtube_podcaster/podcastfeeder.py
@@ -5,10 +5,10 @@ from .podcastupdater import (
5) 5)
6 6
7 7
8def create_feeder(youtube_config, podcast_config): 8def create_feeder(config):
9 class PodcastFeeder(BaseHTTPRequestHandler): 9 class PodcastFeeder(BaseHTTPRequestHandler):
10 def __init__(self, request, client_address, server): 10 def __init__(self, request, client_address, server):
11 self.updater = PodcastUpdater(youtube_config, podcast_config) 11 self.updater = PodcastUpdater(config)
12 super(PodcastFeeder, self).__init__(request, client_address, server) 12 super(PodcastFeeder, self).__init__(request, client_address, server)
13 13
14 def do_GET(self): 14 def do_GET(self):
diff --git a/youtube_podcaster/podcastupdater.py b/youtube_podcaster/podcastupdater.py
index 4a0a017..75b8b10 100644
--- a/youtube_podcaster/podcastupdater.py
+++ b/youtube_podcaster/podcastupdater.py
@@ -2,6 +2,7 @@ import pickle
2import os 2import os
3import time 3import time
4import hashlib 4import hashlib
5import sys
5 6
6from feedgen.feed import FeedGenerator 7from feedgen.feed import FeedGenerator
7 8
@@ -11,11 +12,19 @@ from . import (
11 12
12 13
13class PodcastUpdater: 14class PodcastUpdater:
14 def __init__(self, youtube_config, podcast_config): 15 def __init__(self, config):
15 self.podcasts = podcast_config 16 self.podcasts = config.podcasts
16 self.youtube = youtube.Youtube(youtube_config["api-key"]) 17 self.youtube = youtube.Youtube(config.youtube["api-key"])
18
19 if sys.platform == "linux" and not hasattr(sys, "real_prefix"):
20 self.data_dir = "/var/lib/youtube-podcaster"
21 else:
22 self.data_dir = "%s/var/lib/youtube-podcaster" % (sys.prefix)
23
24 os.makedirs(self.data_dir, 0o755, True)
25
26 self.feeds_file = "%s/feeds.dump" % (self.data_dir)
17 27
18 self.feeds_file = "feeds.save"
19 if os.path.isfile(self.feeds_file): 28 if os.path.isfile(self.feeds_file):
20 with open(self.feeds_file, "rb") as feeds: 29 with open(self.feeds_file, "rb") as feeds:
21 self.feeds = pickle.load(feeds) 30 self.feeds = pickle.load(feeds)
@@ -42,6 +51,8 @@ class PodcastUpdater:
42 51
43 def update_podcast(self, channel, playlist): 52 def update_podcast(self, channel, playlist):
44 feed_id = hashlib.sha1(bytes("%s %s" % (channel, playlist), "UTF-8")).hexdigest() 53 feed_id = hashlib.sha1(bytes("%s %s" % (channel, playlist), "UTF-8")).hexdigest()
54 feed_file = "%s/%s.xml" % (self.data_dir, feed_id)
55
45 yt_channel = self.youtube.get_channel(channel)[0] 56 yt_channel = self.youtube.get_channel(channel)[0]
46 yt_playlists = self.youtube.get_playlists(yt_channel, 50) 57 yt_playlists = self.youtube.get_playlists(yt_channel, 50)
47 58
@@ -58,17 +69,16 @@ class PodcastUpdater:
58 69
59 if feed.last_updated < time.time() - 600: 70 if feed.last_updated < time.time() - 600:
60 self.populate_feed(feed, feed_id, yt_playlist) 71 self.populate_feed(feed, feed_id, yt_playlist)
72 feed.rss_file(feed_file)
61 73
62 feed_file = "%s.xml" % (feed_id) 74 with open(self.feeds_file, "wb") as feeds:
63 self.feeds[feed_id].rss_file(feed_file) 75 pickle.dump(self.feeds, feeds)
64
65 with open(self.feeds_file, "wb") as feed:
66 pickle.dump(self.feeds, feed)
67 76
68 return "%s.xml" % (feed_id) 77 return feed_file
69 78
70 def add_feed(self, feed_id, yt_playlist): 79 def add_feed(self, feed_id, yt_playlist):
71 feed = FeedGenerator() 80 feed = FeedGenerator()
81
72 feed.load_extension("podcast") 82 feed.load_extension("podcast")
73 feed.id(feed_id) 83 feed.id(feed_id)
74 feed.title(yt_playlist["snippet"]["title"]) 84 feed.title(yt_playlist["snippet"]["title"])
@@ -78,7 +88,9 @@ class PodcastUpdater:
78 feed.link(href="https://www.youtube.com/playlist?list=%s" % (yt_playlist["id"])) 88 feed.link(href="https://www.youtube.com/playlist?list=%s" % (yt_playlist["id"]))
79 feed.rss_str(pretty=True) 89 feed.rss_str(pretty=True)
80 feed.last_updated = 0 90 feed.last_updated = 0
91
81 self.feeds[feed_id] = feed 92 self.feeds[feed_id] = feed
93
82 return feed 94 return feed
83 95
84 def populate_feed(self, feed, feed_id, yt_playlist, max_results=5): 96 def populate_feed(self, feed, feed_id, yt_playlist, max_results=5):
@@ -93,7 +105,9 @@ class PodcastUpdater:
93 break 105 break
94 else: 106 else:
95 url, size, mime = downloader.download(video, video_id, feed_id) 107 url, size, mime = downloader.download(video, video_id, feed_id)
108
96 feed_entry = feed.add_entry() 109 feed_entry = feed.add_entry()
110
97 feed_entry.id(video_id) 111 feed_entry.id(video_id)
98 feed_entry.guid(video_id) 112 feed_entry.guid(video_id)
99 feed_entry.title(video["snippet"]["title"]) 113 feed_entry.title(video["snippet"]["title"])
diff --git a/youtube_podcaster/youtube-podcaster.json.sample b/youtube_podcaster/youtube-podcaster.json.sample
deleted file mode 100644
index d9d078f..0000000
--- a/youtube_podcaster/youtube-podcaster.json.sample
+++ /dev/null
@@ -1,10 +0,0 @@
1{
2 "youtube": {
3 "api-key": "youtube-api-v3-key-here"
4 },
5
6 "podcasts": [{
7 "username": "youtube-username-here",
8 "playlists": ["playlist-name-here"]
9 }]
10}
diff --git a/youtube_podcaster/youtube/downloader.py b/youtube_podcaster/youtube/downloader.py
index ca1327b..529902a 100644
--- a/youtube_podcaster/youtube/downloader.py
+++ b/youtube_podcaster/youtube/downloader.py
@@ -1,41 +1,44 @@
1#!/usr/bin/env python3 1#!/usr/bin/env python3
2 2
3import youtube_dl
4import os 3import os
4import mimetypes
5
6import youtube_dl
5 7
6 8
7class Downloader: 9class Downloader:
8 instance = None 10 instance = None
9 11
10 def get_instance(file_format, location, base_url): 12 def get_instance(file_format, location, base_url):
11 if Downloader.instance: 13 if not Downloader.instance:
12 return Downloader.instance
13 else:
14 Downloader.instance = Downloader(file_format, location, base_url) 14 Downloader.instance = Downloader(file_format, location, base_url)
15 return Downloader.instance 15 return Downloader.instance
16 16
17 def __init__(self, file_format, location, base_url): 17 def __init__(self, file_format, location, base_url):
18 self.file_format = file_format 18 self.file_format = file_format
19 self.location = location 19 self.location = location
20 self.base_url = base_url 20 self.base_url = base_url
21 21
22 if file_format == "vorbis":
23 self.extension = "ogg"
24
22 def download(self, video, video_id, feed_id): 25 def download(self, video, video_id, feed_id):
23 output = "%s/%s/%s.ogg" % (self.location, feed_id, video_id) 26 output = "%s/%s/%s.%s" % (self.location, feed_id, video_id, self.extension)
24 options = {"format": "bestaudio/best", 27 options = {"format": "bestaudio/best",
25 "outtmpl": output, 28 "outtmpl": output,
26 "postprocessors": [{ 29 "postprocessors": [{
27 "key": "FFmpegExtractAudio", 30 "key": "FFmpegExtractAudio",
28 "preferredcodec": self.file_format 31 "preferredcodec": self.file_format
29 }], 32 }],
30 "nooverwrites": True 33 "nooverwrites": True}
31 } 34
32 35
33 video_url = "https://www.youtube.com/watch?v=%s" % (video["snippet"]["resourceId"]["videoId"]) 36 video_url = "https://www.youtube.com/watch?v=%s" % (video["snippet"]["resourceId"]["videoId"])
34 youtube_dl.YoutubeDL(options).download([video_url]) 37 youtube_dl.YoutubeDL(options).download([video_url])
35 38
36 url = "%s/%s/%s.ogg" % (self.base_url, feed_id, video_id) 39 url = "%s/%s/%s.%s" % (self.base_url, feed_id, video_id, self.extension)
37 size = str(os.path.getsize(output)) 40 size = str(os.path.getsize(output))
38 mime = "audio/ogg" 41 mime = mimetypes.guess_type(output)[0]
39 42
40 return (url, size, mime) 43 return (url, size, mime)
41 44