New headphones app icon set, hardened streaming and playback, download removal controls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f88ab891b
commit
429a72f2c3
13 files changed
+375
-46
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user