aboutsummaryrefslogtreecommitdiff
path: root/run.py
diff options
context:
space:
mode:
Diffstat (limited to 'run.py')
-rwxr-xr-xrun.py189
1 files changed, 189 insertions, 0 deletions
diff --git a/run.py b/run.py
new file mode 100755
index 0000000..095c9ab
--- /dev/null
+++ b/run.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+
+import json
+import mimetypes
+import os
+import re
+import xml.etree.cElementTree as ET
+from datetime import date, timedelta
+from flask import Flask, request, Response, render_template, send_file
+
+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')
+
+# populate books object from JSON cache
+if os.path.exists(json_path):
+ try:
+ with open(json_path, 'r') as cache:
+ books = json.load(cache)
+ except Exception:
+ raise ValueError('error loading JSON cache')
+else:
+ raise ValueError('cache not found, run rebuild.py')
+
+def check_auth(username, password):
+ '''
+ Authenticate against configured user/pass
+ '''
+ ret = (username == app.config['USERNAME'] and
+ password == app.config['PASSWORD'])
+
+ return ret
+
+@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
+ '''
+ a = request.args.get('a') # audiobook hash
+ f = 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):
+ return 'book or file not found', 404
+
+ f_path = books[a]['files'][f]['path']
+
+ # ship the whole file if we don't receive a Range header
+ range_header = request.headers.get('Range', None)
+ if not range_header:
+ return send_file(
+ f_path,
+ mimetype=mimetypes.guess_type(f_path)[0]
+ )
+
+ # partial request handling--certain podcast apps (iOS) and browsers
+ # (Safari) require correct replies to Range requests; if we serve the
+ # entire file, we're treated like a stream (no seek, duration...)
+ size = books[a]['files'][f]['size_bytes']
+
+ # if no lower bound provided, start at beginning
+ byte1, byte2 = 0, None
+ m = re.search(r'(\d+)-(\d*)', range_header)
+ g = m.groups()
+ if g[0]:
+ byte1 = int(g[0])
+ if g[1]:
+ byte2 = int(g[1])
+
+ # if no upper bound provided, serve rest of file
+ length = size - byte1
+ if byte2 is not None:
+ length = byte2 - byte1
+
+ # read file at byte1 for length
+ data = None
+ with open(f_path, 'rb') as f:
+ f.seek(byte1)
+ data = f.read(length)
+
+ # create response with partial data, populate Content-Range
+ response = Response(
+ data,
+ 206,
+ mimetype=mimetypes.guess_type(f_path)[0],
+ direct_passthrough=True
+ )
+ response.headers.add(
+ 'Content-Range',
+ 'bytes {0}-{1}/{2}'.format(byte1, byte1 + length, size)
+ )
+ response.headers.add('Accept-Ranges', 'bytes')
+
+ return response
+
+ # serve up audiobook RSS feed; only audiobook hash provided
+ elif a:
+ if not books.get(a):
+ return 'book not found', 404
+
+ # we only make use of the itunes ns, others provided for posterity
+ namespaces = {
+ 'itunes':'http://www.itunes.com/dtds/podcast-1.0.dtd',
+ 'googleplay':'http://www.google.com/schemas/play-podcasts/1.0',
+ 'atom':'http://www.w3.org/2005/Atom',
+ 'media':'http://search.yahoo.com/mrss/',
+ 'content':'http://purl.org/rss/1.0/modules/content/',
+ }
+
+ rss = ET.Element('rss')
+ for k, v in namespaces.items():
+ rss.set('xmlns:%s' % k, v)
+ rss.set('version', '2.0')
+
+ channel = ET.SubElement(rss, 'channel')
+
+ book_title = ET.SubElement(channel, 'title')
+ book_title.text = books[a]['title']
+
+ # sort by track number, alphanumerically if track is absent
+ track_list = [] # account for duplicates
+ for a_file in books[a]['files']:
+ track = books[a]['files'][a_file]['track']
+ if not track or track in track_list:
+ key = lambda x: books[a]['files'][x]['title']
+ break
+ track_list.append(track)
+ else:
+ key = lambda x: books[a]['files'][x]['track']
+
+ # populate XML attribute values required by Apple podcasts
+ for idx, f in enumerate(sorted(books[a]['files'], key=key)):
+ item = ET.SubElement(channel, 'item')
+
+ title = ET.SubElement(item, 'title')
+ title.text = books[a]['files'][f]['title']
+
+ author = ET.SubElement(item, 'itunes:author')
+ author.text = books[a]['files'][f]['author']
+
+ category = ET.SubElement(item, 'itunes:category')
+ category.text = 'Book'
+
+ explicit = ET.SubElement(item, 'itunes:explicit')
+ explicit.text = 'no'
+
+ summary = ET.SubElement(item, 'itunes:summary')
+ summary.text = 'Audiobook served by audiobook-rss'
+
+ description = ET.SubElement(item, 'description')
+ description.text = 'Audiobook served by audiobook-rss'
+
+ duration = ET.SubElement(item, 'itunes:duration')
+ duration.text = str(books[a]['files'][f]['duration_str'])
+
+ guid = ET.SubElement(item, 'guid')
+ guid.text = f # file hash
+
+ # pubDate descending, day decremented w/ each iteration
+ pub_date = ET.SubElement(item, 'pubDate')
+ pub_date.text = (date(2000, 12, 31) - timedelta(days=idx)).ctime()
+ enc_attr = {
+ 'url': '{}?a={}&f={}'.format( request.base_url, a, f),
+ 'length': str(books[a]['files'][f]['size_bytes']),
+ 'type': 'audio/mpeg'
+ }
+ ET.SubElement(item, 'enclosure', enc_attr)
+
+ return Response(
+ ET.tostring(rss, encoding='utf8', method='xml'),
+ mimetype='text/xml'
+ )
+ else:
+ auth = request.authorization
+ if not auth or not check_auth(auth.username, auth.password):
+ form = {'WWW-Authenticate': 'Basic realm="o/"'}
+ return Response('unauthorized', 401, form)
+ return render_template('index.html', books=books)
+
+if __name__ == '__main__':
+ app.run(host='127.0.0.1', port='8085', threaded=True)