New headphones app icon set, hardened streaming and playback, download removal controls
build / check (push) Successful in 10s
build / image (push) Successful in 31s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-19 14:11:31 -04:00
1 parent 5f88ab891b
commit 429a72f2c3
13 files changed
+375 -46

No files matched your search

+158 -20
View File
@@ -6,6 +6,7 @@
python3 server.py passwd NAME change a password
python3 server.py deluser NAME remove an account
python3 server.py users list accounts
python3 server.py check test the OpenAudible connection and whether its files can stream to a phone
python3 server.py [serve] run (--port 8080 --upstream http://172.16.16.106:8000)
Each person signs in with their own account and gets their own listening progress.
@@ -24,6 +25,7 @@ import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.cookies import SimpleCookie
from urllib.parse import unquote
@@ -40,8 +42,12 @@ COOKIE = "oa_session"
SESSION_DAYS = 90
# Files the browser fetches without a session (home-screen icon and manifest).
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/icon-180.png": "image/png",
"/icon-192.png": "image/png", "/icon-512.png": "image/png", "/sw.js": "text/javascript"}
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/sw.js": "text/javascript"}
ICON = re.compile(r"^/icon-[a-z0-9]+\.png$")
AUDIO_TYPES = {".mp3": "audio/mpeg", ".m4b": "audio/mp4", ".m4a": "audio/mp4", ".aac": "audio/aac",
".ogg": "audio/ogg", ".opus": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav"}
AUDIO_PATH = re.compile(r"^/api/books/[^/]+/files/([^/]+)$")
RANGE = re.compile(r"^bytes=(\d*)-(\d*)$")
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
ALLOWED = [re.compile(p) for p in (
@@ -58,6 +64,10 @@ lock = threading.Lock()
# ---------- tiny JSON stores ----------
def log(*a):
print(time.strftime("%H:%M:%S"), *a, flush=True)
def read(path, default):
try:
with open(path) as f:
@@ -154,10 +164,64 @@ def cli(argv):
print("\n".join(n + (" (admin)" if users[n].get("admin") else "") for n in sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
def check(argv):
"""Ask OpenAudible what it has and whether it can stream it to a phone."""
ap = argparse.ArgumentParser(prog="server.py check")
ap.add_argument("--upstream", default=os.environ.get("OPENAUDIBLE_URL", "http://172.16.16.106:8000"))
ap.add_argument("--books", type=int, default=3, help="how many books to test")
a = ap.parse_args(argv)
base = a.upstream.rstrip("/")
user, pw = oa_login()
auth = {"Authorization": "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()} if user else {}
def fetch(path, **headers):
return urllib.request.urlopen(urllib.request.Request(base + path, headers={**auth, **headers}), timeout=30)
def step(label, fn):
try:
return fn()
except urllib.error.HTTPError as e:
sys.exit(f"{label}: HTTP {e.code} ({'wrong OpenAudible login? run: python3 server.py setup' if e.code == 401 else e.reason})")
except Exception as e:
sys.exit(f"{label}: {e}")
print(f"OpenAudible at {base}")
books = step("library", lambda: json.load(fetch("/api/books")))
books = books if isinstance(books, list) else list(books.values())
print(f" {len(books)} books")
problems = []
for b in books[: a.books]:
bid = b.get("id") or b.get("asin") or b.get("ASIN") or b.get("book_id")
print(f"\n{b.get('title', bid)} [{bid}]")
files = step("file list", lambda: json.load(fetch(f"/admin/api/books/{bid}/files")))
names = [f if isinstance(f, str) else (f.get("name") or f.get("filename") or f.get("file") or f.get("path") or "") for f in (files if isinstance(files, list) else files.get("files", []))]
names = [n.split("/")[-1] for n in names if n]
if not names:
print(" no files (not downloaded in OpenAudible?)")
problems.append(f"{b.get('title', bid)}: no files")
for n in names:
ext = os.path.splitext(n)[1].lower()
if ext not in AUDIO_TYPES:
print(f" {n}: not playable in a browser ({ext}); convert it to MP3/M4B in OpenAudible")
continue
try:
r = fetch(f"/api/books/{bid}/files/{urllib.parse.quote(n)}", Range="bytes=0-1")
got = r.headers
ranged = r.status == 206 and bool(got.get("Content-Range"))
print(f" {n}: HTTP {r.status}, {got.get('Content-Type')}, Content-Range={got.get('Content-Range')}, Accept-Ranges={got.get('Accept-Ranges')}")
print(" byte ranges:", "supported" if ranged else "NOT supported by OpenAudible; the player server will emulate them (seeking is slower on big files)")
if got.get("Content-Type", "").startswith(("application/octet", "binary/")):
print(" content type is generic; the player server will correct it")
except Exception as e:
print(f" {n}: stream failed: {e}")
problems.append(f"{n}: {e}")
print("\n" + ("Problems found:\n " + "\n ".join(problems) if problems else "OK: OpenAudible is reachable and has playable files."))
# ---------- login page ----------
LOGIN = """<!doctype html><html lang=en><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><title>Sign in</title>
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><link rel=icon href=/icon-32.png><title>Sign in</title>
<style>
:root{color-scheme:light dark;--bg:#f6f6f4;--s:#fff;--fg:#16161a;--m:#74747c;--a:#e8590c;--b:rgba(0,0,0,.1)}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--s:#1a1a1f;--fg:#f2f2f4;--m:#8f8f99;--a:#ff7a2f;--b:rgba(255,255,255,.1)}}
@@ -221,9 +285,9 @@ class Handler(BaseHTTPRequestHandler):
return self.send_body(200, LOGIN, "text/html; charset=utf-8")
if path == "/logout":
return self.logout()
if path in PUBLIC:
if path in PUBLIC or (ICON.match(path) and os.path.exists(os.path.join(HERE, path[1:]))):
with open(os.path.join(HERE, path[1:]), "rb") as f:
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "no-cache" if path in ("/sw.js", "/manifest.webmanifest") else "public, max-age=86400")])
return self.send_body(200, f.read(), PUBLIC.get(path, "image/png"), [("Cache-Control", "no-cache" if path in PUBLIC else "public, max-age=86400")])
user = self.user()
if not user:
return self.deny(path)
@@ -245,6 +309,15 @@ class Handler(BaseHTTPRequestHandler):
return self.proxy()
self.send_error(404)
def do_HEAD(self):
path = self.path.split("?")[0]
if not self.user():
self.send_response(401)
return self.end_headers()
if any(p.match(path) for p in ALLOWED):
return self.proxy(head=True)
self.send_error(404)
def do_POST(self):
path = self.path.split("?")[0]
n = int(self.headers.get("Content-Length", 0) or 0)
@@ -365,34 +438,97 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Location", "/login")
self.end_headers()
def proxy(self):
def proxy(self, head=False):
path = self.path.split("?")[0]
m = AUDIO_PATH.match(path)
name = unquote(m.group(1)) if m else ""
oa_user, oa_pass = oa_login()
req = urllib.request.Request(self.upstream + self.path)
parts = urllib.parse.urlsplit(self.path) # drop the player's own cache-busting parameter before forwarding
q = [kv for kv in urllib.parse.parse_qsl(parts.query, keep_blank_values=True) if kv[0] != "_r"]
up = parts.path + ("?" + urllib.parse.urlencode(q) if q else "")
req = urllib.request.Request(self.upstream + up, method="HEAD" if head else "GET")
if oa_user:
cred = base64.b64encode(f"{oa_user}:{oa_pass}".encode()).decode()
req.add_header("Authorization", "Basic " + cred)
for h in PASS_HEADERS:
if self.headers.get(h):
req.add_header(h, self.headers[h])
try:
resp = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as e:
resp = e
except Exception as e:
return self.json(502, {"error": f"can't reach OpenAudible: {e}"})
resp = None
for attempt in (1, 2): # one quick retry for a flaky connection
try:
resp = urllib.request.urlopen(req, timeout=60 if m else 30)
break
except urllib.error.HTTPError as e:
resp = e
break
except Exception as e:
if attempt == 2:
log("upstream unreachable:", self.path, e)
return self.json(502, {"error": f"can't reach OpenAudible: {e}"})
time.sleep(0.4)
code = getattr(resp, "status", None) or resp.code
if code == 401: # the server-side OpenAudible login is wrong; not the listener's fault
return self.json(502, {"error": "OpenAudible login rejected; run: python3 server.py setup"})
if code >= 400 and m:
log("upstream", code, "for", self.command, path, "range:", self.headers.get("Range"))
h = resp.headers
out = [(k, h[k]) for k in RESP_HEADERS if h.get(k)]
skip, length = 0, None
if m:
# iPhones refuse to play audio unless the server answers byte-range requests.
ctype = (h.get("Content-Type") or "").lower()
if not ctype or ctype.startswith(("application/octet-stream", "binary/", "text/")):
ext = os.path.splitext(name)[1].lower()
if ext in AUDIO_TYPES:
out = [(k, v) for k, v in out if k != "Content-Type"] + [("Content-Type", AUDIO_TYPES[ext])]
if not any(k == "Accept-Ranges" for k, _ in out):
out.append(("Accept-Ranges", "bytes"))
rng = self.headers.get("Range")
cl = h.get("Content-Length")
total = int(cl) if cl and cl.isdigit() else None
if rng and code == 200 and not h.get("Content-Range") and not head:
# OpenAudible ignored the Range header: answer it ourselves by skipping ahead in the stream.
mm = RANGE.match(rng.strip())
if mm and total:
a, b = mm.groups()
if a == "" and b != "":
start, end = max(0, total - int(b)), total - 1
else:
start, end = int(a or 0), min(int(b), total - 1) if b else total - 1
if start >= total:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{total}")
self.send_header("Content-Length", "0")
self.end_headers()
return resp.close()
code, skip, length = 206, start, end - start + 1
out = [(k, v) for k, v in out if k != "Content-Length"] + [
("Content-Range", f"bytes {start}-{end}/{total}"), ("Content-Length", str(length))]
if start:
log("emulating Range for", name, f"(from byte {start}; OpenAudible doesn't support Range)")
self.send_response(code)
for h in RESP_HEADERS:
if resp.headers.get(h):
self.send_header(h, resp.headers[h])
for k, v in out:
self.send_header(k, v)
self.end_headers()
if head:
return resp.close()
try:
while chunk := resp.read(64 * 1024):
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
pass # player seeked or closed the stream
while skip > 0:
d = resp.read(min(65536, skip))
if not d:
return
skip -= len(d)
while length is None or length > 0:
d = resp.read(65536 if length is None else min(65536, length))
if not d:
break
self.wfile.write(d)
if length is not None:
length -= len(d)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError, OSError):
pass # player seeked, closed the stream, or the connection dropped
finally:
resp.close()
def log_message(self, *a):
pass
@@ -437,6 +573,8 @@ if __name__ == "__main__":
if args[0] in ("adduser", "passwd", "deluser") and len(args) < 2:
sys.exit(f"Usage: python3 server.py {args[0]} NAME")
cli(args)
elif args and args[0] == "check":
check(args[1:])
elif args and args[0] in ("-h", "--help"):
print(__doc__)
else: