From 2df5addacd82ac7463ff6d3ec6754b21dab71737 Mon Sep 17 00:00:00 2001 From: Jordan Date: Sun, 5 Apr 2020 20:20:41 -0700 Subject: initial commit --- run.py | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100755 run.py (limited to 'run.py') 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) -- cgit v1.2.3-54-g00ecf