aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md70
-rw-r--r--app.cfg.example2
-rw-r--r--lib/books.py44
-rw-r--r--lib/tinytag/tinytag.py2
-rw-r--r--lib/util.py7
-rwxr-xr-xroka.py113
-rw-r--r--templates/index.html2
-rw-r--r--uwsgi.ini.example1
9 files changed, 147 insertions, 95 deletions
diff --git a/.gitignore b/.gitignore
index f6e84b2..46b2adb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,6 @@
__pycache__
cache
sandbox
+static
app.cfg
uwsgi.ini
diff --git a/README.md b/README.md
index 75572fe..1164405 100644
--- a/README.md
+++ b/README.md
@@ -1,53 +1,63 @@
# Roka
-Stream directory of audiobooks to podcasting apps via RSS.
-
-A screenshot of the web interface is [available here](screenshots/web.png).
+Stream a directory of audiobooks to podcast apps over an RSS XML feed uniquely
+generated for each audiobook. A screenshot of the web interface is
+[available here](screenshots/web.png).
## Installation
-1. Copy and populate app.cfg and uwsgi.ini from examples
+1. Copy and populate `app.cfg` and `uwsgi.ini` from examples, or pass
+ configuration key/values as a JSON string with the `--config` parameter.
-2. Install python dependencies flask and uwsgi
+2. Install Python dependencies flask and uwsgi.
- ```bash
+ ```
pip install --user flask uwsgi
```
-3. Run roka.py with --scan to populate audiobook JSON cache (can be re-run to
- update cache upon download of new books)
+3. Populate audiobook JSON cache; can be re-run to update cache upon download of
+ new books.
- ```bash
+ ```
./roka.py --scan
```
-4. Execute uwsgi.sh to start the server
+4. Execute uwsgi.sh to start the server.
- ```bash
- ./uwsgi.sh
- ```
+## Static generation
-## Design decisions
+In addition to running as a server, Roka can also generate a static index and
+set of RSS feeds that can be deployed to static hosting. This mode does not
+support a username and password.
-1. Directories contained within config:ROOT_PATH are marked as audiobooks if and
- only if they contain at least one MP3 file
+1. Populate `BASE_URL` in `app.cfg` to the base url where the static site will
+ be uploaded.
-2. Audiobooks are uniquely identifiable by the collective hash of each MP3 file
- contained in the audiobook directory
+2. Run `roka.py` with the `--generate <output_directory>` parameter, where
+ `<output_directory>` is an output directory to place the generated site. All
+ audiobook files will be copied to this location.
- * Pro: If the directory structure is changed or files are moved, RSS/download
- link integrity is maintained, preserving app-side listening progress and
- history
+ ```
+ ./roka.py --generate ./static
+ ```
+
+3. Upload the static site to any static web hosting. Make sure it is accessible
+ at the URL set as `BASE_URL`
+
+## Design decisions
- * Con: Each MP3 file is hashed, which can be slow on spinning rust w/ large
- collections
+1. Directories contained within `ROOT_PATH` are marked as audiobooks if and only
+ if they contain at least one MP3 file.
-3. XML pubDate and list order is derived from MP3 track attributes; if not
- present or duplicates exist, tracks are sorted alphanumerically
+2. Audiobooks are uniquely identified in the web interface by the collective
+ hash of each MP3 file contained in the audiobook directory. If the directory
+ structure is changed or files are moved, RSS/download link integrity is
+ maintained, preserving app-side listening progress and history.
- if a book's track numbers are unique but incorrect, a preference for filename
- sort can be established by creating an 'ignore_tracknum' file in the
- audiobook's path
+3. XML `pubDate` and list order is derived from MP3 track attributes; if not
+ present or duplicates exist, tracks are sorted alphanumerically. If a book's
+ track numbers are unique but incorrect, a preference for filename sort can be
+ established by creating an 'ignore_tracknum' file in the audiobook's path.
-4. No rebuild endpoint exists; cache-affecting routines are run externally by
- calling roka.py directly
+4. No rebuild endpoint exists; cache-affecting routines are executed by calling
+ `roka.py` directly.
diff --git a/app.cfg.example b/app.cfg.example
index 114de0b..05a9144 100644
--- a/app.cfg.example
+++ b/app.cfg.example
@@ -1,4 +1,6 @@
ROOT_PATH = '/path/to/audiobooks'
+# BASE_URL is only used for static generation
+BASE_URL = 'https://example.com/'
USERNAME = 'username'
PASSWORD = 'password'
SHOW_PATH = True
diff --git a/lib/books.py b/lib/books.py
index 1c4b4a5..3244d13 100644
--- a/lib/books.py
+++ b/lib/books.py
@@ -9,20 +9,11 @@ from datetime import timedelta
from flask import Flask
from lib.tinytag import TinyTag
-ABS_PATH = os.path.dirname(os.path.abspath(__file__))
-CACHE_PATH = os.path.join(ABS_PATH, '../', 'cache')
-JSON_PATH = os.path.join(CACHE_PATH, 'audiobooks.json')
-
-# use Flask's config parser, configparser would be hacky
-APP = Flask(__name__)
-APP.config.from_pyfile(os.path.join(ABS_PATH, '../', 'app.cfg'))
-
class Books:
- def __init__(self):
- '''
- Book-related handlers (r/w cache) and track discovery
- '''
- if os.path.exists(JSON_PATH):
+ def __init__(self, cache_path):
+ self.cache_path = cache_path
+ self.json_path = os.path.join(cache_path, 'audiobooks.json')
+ if os.path.exists(self.json_path):
self._cache = self._read_cache()
else:
self._cache = {}
@@ -56,24 +47,21 @@ class Books:
'''
Dump contents of :books: to :json_path:
'''
- if not os.path.exists(CACHE_PATH):
- os.mkdir(CACHE_PATH)
- with open(JSON_PATH, 'w') as cache:
+ if not os.path.exists(self.cache_path):
+ os.mkdir(self.cache_path)
+ with open(self.json_path, 'w') as cache:
json.dump(self.books, cache, indent=4)
def _read_cache(self):
'''
Return dict of existing cache
'''
- with open(JSON_PATH, 'r') as cache:
+ with open(self.json_path, 'r') as cache:
data = json.load(cache)
return data
def _validate(self, v, b):
- '''
- Returns :v: if :v: and v.isspace(), otherwise :b:
- '''
if v and not v.isspace():
return v
@@ -86,15 +74,12 @@ class Books:
now = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
print('%s %s' % (now, msg))
- def scan_books(self):
+ def scan_books(self, audiobook_path=None):
'''
- Discover audiobooks under :root_path: and populate books object
-
- :cache: existing JSON cache, used to determine which content is new
- (existing content is not re-hashed)
+ Discover audiobooks under :root_path: and populate books
'''
ex = self._get_path_hash_dict()
- dirs = self._get_dirs(APP.config['ROOT_PATH'])
+ dirs = self._get_dirs(audiobook_path)
books = dict()
for path in dirs:
@@ -110,8 +95,7 @@ class Books:
def _check_dir(self, path):
'''
- Determine if :path: contains (supported) audio files; return populated
- book dict or None
+ Determine if :path: contains (supported) audio files
'''
ext = ['mp3'] # m4b seems to be unsupported by Apple
is_book = False
@@ -145,7 +129,7 @@ class Books:
# previous conditions met, we've found at least one track
is_book = True
- self._log(f)
+ self._log(os.path.join(path, f))
# hash track (used as a key) and update folder hash
file_hash = hashlib.md5()
@@ -207,5 +191,3 @@ class Books:
book['duration_str'] = duration_str.split('.')[0]
return (folder_hash, book)
-
- return None
diff --git a/lib/tinytag/tinytag.py b/lib/tinytag/tinytag.py
index 2ba79be..40e7a59 100644
--- a/lib/tinytag/tinytag.py
+++ b/lib/tinytag/tinytag.py
@@ -34,7 +34,7 @@
from __future__ import print_function
import re
-from collections import MutableMapping
+from collections.abc import MutableMapping
import codecs
from functools import reduce
import struct
diff --git a/lib/util.py b/lib/util.py
index 1eb6af6..4693ef1 100644
--- a/lib/util.py
+++ b/lib/util.py
@@ -78,9 +78,7 @@ def escape(s):
return s
-def generate_rss(request, books):
- book = request.args.get('a') # audiobook hash
-
+def generate_rss(base_url, book, books, static=False):
# we only make use of the itunes ns, others provided for posterity
namespaces = {
'itunes':'http://www.itunes.com/dtds/podcast-1.0.dtd',
@@ -156,8 +154,9 @@ def generate_rss(request, books):
pub_format = '%a, %d %b %Y %H:%M:%S %z'
pub_date.text = (date(2000, 12, 31) - timedelta(days=idx)).strftime(
pub_format)
+ url_format = '{}{}/{}.mp3' if static else '{}?a={}&f={}'
enc_attr = {
- 'url': '{}?a={}&f={}'.format(request.base_url, book, f),
+ 'url': url_format.format(base_url, book, f),
'length': str(books[book]['files'][f]['size_bytes']),
'type': 'audio/mpeg'
}
diff --git a/roka.py b/roka.py
index ebfd9cb..effdbe2 100755
--- a/roka.py
+++ b/roka.py
@@ -2,67 +2,124 @@
import argparse
import os
-from flask import Flask, request, Response, render_template, send_file
+import shutil
+import json
+from flask import Flask, request, Response, render_template, send_file, templating
+from flask.globals import app_ctx
from lib.books import Books
from lib.util import check_auth, escape, generate_rss, read_cache
-abs_path = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__)
-app.config.from_pyfile(os.path.join(abs_path, 'app.cfg'))
-cache_path = os.path.join(abs_path, 'cache')
-json_path = os.path.join(cache_path, 'audiobooks.json')
+CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app.cfg')
+if os.path.exists(CONFIG_PATH) or __name__.startswith('uwsgi'):
+ app.config.from_pyfile(CONFIG_PATH)
+CACHE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache')
+JSON_PATH = os.path.join(CACHE_PATH, 'audiobooks.json')
@app.route('/')
def list_books():
'''
Book listing and audiobook RSS/file download
-
- :a: audiobook hash; if provided without :f: (file) return RSS
- :f: file hash; requires associated audiobook (:a:) to download
-
- Listing of audiobooks returned if no params provided
'''
- books = read_cache(json_path)
+ books = read_cache(JSON_PATH)
- a = request.args.get('a') # audiobook hash
- f = request.args.get('f') # file hash
+ book = request.args.get('a') # audiobook hash
+ track = request.args.get('f') # file hash
# audiobook and file parameters provided: serve up file
- if a and f:
- if not books.get(a) or not books[a]['files'].get(f):
+ if book and track:
+ if not books.get(book) or not books[book]['files'].get(track):
return 'book or file not found', 404
- f_path = books[a]['files'][f]['path']
- return send_file(f_path, conditional=True)
+ track_path = books[book]['files'][track]['path']
+ return send_file(track_path, conditional=True)
# serve up audiobook RSS feed; only audiobook hash provided
- elif a:
- if not books.get(a):
+ if book:
+ if not books.get(book):
return 'book not found', 404
- rss = generate_rss(request, books)
+ rss = generate_rss(request.base_url, book, books)
return Response(rss, mimetype='text/xml')
- else:
- auth = request.authorization
- if not auth or not check_auth(app, auth.username, auth.password):
- form = {'WWW-Authenticate': 'Basic realm="o/"'}
- return Response('unauthorized', 401, form)
+ # return index if authenticated
+ auth = request.authorization
+ if not auth or not check_auth(app, auth.username, auth.password):
+ form = {'WWW-Authenticate': 'Basic realm="o/"'}
+ return Response('unauthorized', 401, form)
- return render_template('index.html', books=books,
+ return render_template('index.html', books=books,
show_path=app.config.get('SHOW_PATH', True))
+def generate(static_path, base_url, audiobook_dirs):
+ '''
+ Static generation of index pages and RSS feeds
+ '''
+ static_index_path = os.path.join(static_path, 'index.html')
+
+ books = Books()
+ books.scan_books(audiobook_dirs)
+ books.write_cache()
+ books = read_cache(JSON_PATH)
+ # A bit of a hack, but push to the app context stack so we can render a
+ # template outside of a Flask request
+ with app.app_context():
+ app_ctx.push()
+ index = render_template('index.html', books=books, static=True)
+ app_ctx.pop()
+
+ os.makedirs(static_path, exist_ok=True)
+
+ indexfile = open(static_index_path, 'w')
+ indexfile.write(index)
+ indexfile.close()
+
+ for b_key, book in books.items():
+ rss = generate_rss(base_url, b_key, books, static=True)
+ rss_path = os.path.join(static_path, b_key + '.xml')
+ rssfile = open(rss_path, 'w')
+ rssfile.write(rss.decode('utf-8'))
+ rssfile.close()
+
+ book_dir = os.path.join(static_path, b_key)
+ os.makedirs(book_dir, exist_ok=True)
+
+ for f_key, file in book['files'].items():
+ f_path = file['path']
+ copy_path = os.path.join(book_dir, f_key + '.mp3')
+ if not os.path.exists(copy_path):
+ shutil.copyfile(f_path, copy_path)
+
if __name__ == '__main__':
desc = 'roka: listen to audiobooks with podcast apps via RSS'
parser = argparse.ArgumentParser(description=desc)
parser.add_argument('--scan', dest='scan', action='store_true',
help='scan audiobooks directory for new books',
required=False)
+ parser.add_argument('--generate', dest='static_path', type=str, action='store',
+ help='Output directory to generate static files',
+ required=False)
+ parser.add_argument('--config', dest='config', type=str, action='store',
+ help='Json configuration instead of app.cfg',
+ required=False)
args = parser.parse_args()
+ if args.config:
+ class objectview(object):
+ def __init__(self, d):
+ self.__dict__ = d
+ config = objectview(json.loads(args.config))
+ # override app.cfg
+ app.config.from_object(config)
+ elif not os.path.exists(CONFIG_PATH):
+ raise Exception(f"Config file '{config_path}' doesn't exist")
+
+ root_path = os.path.expanduser(app.config['ROOT_PATH'])
if args.scan:
- books = Books()
- books.scan_books()
+ books = Books(CACHE_PATH)
+ books.scan_books(root_path)
books.write_cache()
+ elif args.static_path:
+ generate(args.static_path, app.config['BASE_URL'], root_path)
else:
app.run(host='127.0.0.1', port='8085', threaded=True)
diff --git a/templates/index.html b/templates/index.html
index a31b763..bac87d2 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -38,7 +38,7 @@
</tr>
{% for b, v in books.items() %}
<tr>
- <td><a href="?a={{ b }}">{{ v['title']|escape }}</a></td>
+ <td><a href="{{'?a=' if not static else '/'}}{{ b }}{{'.xml' if static}}">{{ v['title']|escape }}</a></td>
<td>{{ v['author'] }}</td>
{% if show_path %}
<td>{{ v['path']|escape }}</td>
diff --git a/uwsgi.ini.example b/uwsgi.ini.example
index 2a2093f..094329a 100644
--- a/uwsgi.ini.example
+++ b/uwsgi.ini.example
@@ -5,3 +5,4 @@ threads = 4
wsgi-file = roka.py
callable = app
master = true
+wsgi-file = /home/example/roka/roka.py