aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md73
-rw-r--r--lib/books.py34
-rw-r--r--lib/tinytag/tinytag.py2
-rwxr-xr-xroka.py68
-rw-r--r--uwsgi.ini.example1
5 files changed, 73 insertions, 105 deletions
diff --git a/README.md b/README.md
index 93455fe..1164405 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,28 @@
# 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
-
- ```bash
- ./uwsgi.sh
- ```
+4. Execute uwsgi.sh to start the server.
## Static generation
@@ -33,46 +30,34 @@ 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. Set `BASE_URL` in app.cfg to the base url where the static site will be
- uploaded.
+1. Populate `BASE_URL` in `app.cfg` to the base url where the static site will
+ be uploaded.
-2. Run roka.py with the `--generate <output_directory>` parameter, where
+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.
- ```bash
- ./roka.py --generate ./static
- ```
+ ```
+ ./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
-1. Directories contained within config:ROOT_PATH are marked as audiobooks if and
- only if they contain at least one MP3 file
-
-2. Audiobooks are uniquely identifiable by the collective hash of each MP3 file
- contained in the audiobook directory
-
- * Pro: If the directory structure is changed or files are moved, RSS/download
- link integrity is maintained, preserving app-side listening progress and
- history
-
- * Con: Each MP3 file is hashed, which can be slow on spinning rust w/ large
- collections
-
-3. XML pubDate and list order is derived from MP3 track attributes; if not
- present or duplicates exist, tracks are sorted alphanumerically
+1. Directories contained within `ROOT_PATH` are marked as audiobooks if and only
+ if they contain at least one MP3 file.
- 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
+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.
-4. No rebuild endpoint exists; cache-affecting routines are run externally by
- calling roka.py directly
+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.
-5. Configuration can either be placed in a file named `app.cfg`, or it can be
- overridden on the terminal by passing a JSON string as the `--config`
- parameter. I.E. `./roka.py --generate ./static --config '{"ROOT_PATH":
- "/path/to/audiobooks", "BASE_URL": "https://example.com/"}'`
+4. No rebuild endpoint exists; cache-affecting routines are executed by calling
+ `roka.py` directly.
diff --git a/lib/books.py b/lib/books.py
index 95df374..3244d13 100644
--- a/lib/books.py
+++ b/lib/books.py
@@ -9,16 +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')
-
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 = {}
@@ -52,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
@@ -84,10 +76,7 @@ class Books:
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(audiobook_path)
@@ -106,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
@@ -203,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/roka.py b/roka.py
index 252ade9..effdbe2 100755
--- a/roka.py
+++ b/roka.py
@@ -5,71 +5,68 @@ import os
import shutil
import json
from flask import Flask, request, Response, render_template, send_file, templating
-from flask.globals import _app_ctx_stack
+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__)
-config_path = os.path.join(abs_path, 'app.cfg')
-config_exists = os.path.exists(config_path)
-if config_exists or __name__.startswith('uwsgi'):
- app.config.from_pyfile(config_path)
-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.base_url, a, 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)
+ 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
- _app_ctx_stack.push(app.app_context())
- index = render_template('index.html', books=books, static=True)
- _app_ctx_stack.pop()
+ 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)
@@ -114,13 +111,12 @@ if __name__ == '__main__':
config = objectview(json.loads(args.config))
# override app.cfg
app.config.from_object(config)
- elif not config_exists:
+ 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 = Books(CACHE_PATH)
books.scan_books(root_path)
books.write_cache()
elif args.static_path:
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