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

+1
View File
@@ -7,3 +7,4 @@ sessions.json
progress*.json
*.tmp
README.md
tools
+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
WORKDIR /app
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-180.png icon-192.png icon-512.png ./
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-*.png ./
USER app
VOLUME /data
EXPOSE 8080
+20
View File
@@ -44,3 +44,23 @@ Tap the ↓ on a book (or "Download" in the player) to save it on the device. Do
- **To open the app itself offline, serve it over HTTPS.** Browsers only allow the service worker (`sw.js`) on secure
origins. Put a reverse proxy with a certificate in front of port 8080 (and a domain name). Over plain HTTP, downloads
still play but the page can't load without a connection.
## Troubleshooting playback
Run this on the player server (or `docker exec -it audiobook-player python server.py check`):
```bash
python3 server.py check
```
It logs in to OpenAudible, lists a few books' files, and tests whether each file can stream to a phone. iPhones will
only play audio from a server that answers byte-range requests; if OpenAudible doesn't, the player server emulates
them (seeking in very large books is slower). The container's log (`docker logs audiobook-player`) notes when that happens
and records any upstream errors.
The player also recovers by itself from dropped connections and stalls (it reloads from the same spot, up to four times),
and your position is saved every few seconds, on pause, and when the page is closed or backgrounded.
## Icons
`python3 tools/make_icons.py` regenerates the app icons (needs Pillow).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+109 -21
View File
@@ -11,6 +11,7 @@
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icon-180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
<title>Audiobook Player</title>
<style>
:root{--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--acc2:#ff8a3d;--bd:rgba(0,0,0,.08);--sh:0 8px 30px rgba(0,0,0,.12)}
@@ -58,8 +59,11 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
.card b{display:block;margin-top:8px;font-size:13.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card span{color:var(--mut);font-size:12.5px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cov .dlb{position:absolute;right:8px;bottom:12px;min-width:30px;height:30px;padding:0 8px;border-radius:99px;background:rgba(0,0,0,.62);color:#fff;font-size:13px;font-weight:700;display:grid;place-items:center;backdrop-filter:blur(6px);cursor:pointer}
.cov .dlb.rm{background:rgba(0,0,0,.72);font-size:12px;padding:0 12px}
.cov .dlb.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0}
#dlbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
#offline{margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
.pp.buf{animation:pulse 1s infinite}
.skel .cov{animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.5}}
#msg{padding:60px 0;text-align:center;color:var(--mut)}
@@ -138,6 +142,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
<div id="offline" class="hide">You're offline or OpenAudible isn't reachable. Showing your downloaded books.</div>
<div id="msg" class="hide"></div>
<section id="contSec" class="hide"><h2>Continue listening</h2><div id="cont" class="shelf"></div></section>
<div id="dlbar" class="hide"><span id="dlinfo"></span><button class="pill" id="rmall" style="padding:7px 14px">Remove all</button></div>
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
</main>
@@ -223,12 +228,13 @@ async function initOffline() {
try { for (const m of await OFF.list()) { downloaded.set(m.id, m); setCoverUrl(m); } } catch {}
}
const mb = n => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : Math.round(n / 1e6) + ' MB';
const dlLabel = id => { const st = dlState.get(id); return st ? (st.pct ? Math.round(st.pct * 100) + '%' : st.got ? mb(st.got) : '…') : downloaded.has(id) ? '✓' : '↓'; };
const inDlView = () => $('sort').value === 'dl';
const dlLabel = id => { const st = dlState.get(id); return st ? (st.pct ? Math.round(st.pct * 100) + '%' : st.got ? mb(st.got) : '…') : downloaded.has(id) ? (inDlView() ? '✕ Remove' : '✓') : '↓'; };
function uiDl() {
document.querySelectorAll('.dlb').forEach(el => { const id = el.dataset.id; el.textContent = dlLabel(id); el.classList.toggle('busy', dlState.has(id)); el.classList.toggle('done', !dlState.has(id) && downloaded.has(id)); });
document.querySelectorAll('.dlb').forEach(el => { const id = el.dataset.id; el.textContent = dlLabel(id); el.classList.toggle('busy', dlState.has(id)); el.classList.toggle('done', !dlState.has(id) && downloaded.has(id)); el.classList.toggle('rm', !dlState.has(id) && downloaded.has(id) && inDlView()); });
if (!current) return;
const id = bookId(current), st = dlState.get(id);
$('dl').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded' : '↓ Download';
$('dl').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded · tap to remove' : '↓ Download';
$('dl').classList.toggle('on', !!st || downloaded.has(id));
}
async function download(b) {
@@ -269,11 +275,24 @@ async function download(b) {
} finally { dlState.delete(id); uiDl(); render(); }
}
async function removeDl(id) {
if (current && bookId(current) === id && curObjUrl) userPause(); // it's playing from the copy we're about to delete
await OFF.remove(id).catch(() => {});
downloaded.delete(id);
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
uiDl(); render(); toast('Removed download');
}
async function removeAll() {
if (!downloaded.size) return;
if (!confirm(`Remove all ${downloaded.size} downloaded books from this device? They stay in your library and can be downloaded again.`)) return;
if (current && curObjUrl) userPause();
for (const id of [...downloaded.keys()]) {
await OFF.remove(id).catch(() => {});
downloaded.delete(id);
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
}
uiDl(); render(); toast('Removed all downloads');
}
$('rmall').onclick = removeAll;
function toggleDl(b) {
const id = bookId(b), st = dlState.get(id);
if (st) return st.ctl.abort();
@@ -320,7 +339,7 @@ function card(b) {
d.querySelector('b').textContent = title(b);
d.querySelector('span').textContent = author(b);
const badge = document.createElement('span');
badge.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' : ''); badge.dataset.id = id; badge.textContent = dlLabel(id);
badge.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' + (inDlView() ? ' rm' : '') : ''); badge.dataset.id = id; badge.textContent = dlLabel(id);
badge.setAttribute('role', 'button'); badge.setAttribute('aria-label', 'Download for offline listening');
badge.onclick = e => { e.stopPropagation(); toggleDl(b); };
d.querySelector('.cov').append(badge);
@@ -359,8 +378,15 @@ function render() {
$('grid').className = 'grid';
$('grid').replaceChildren(...list.map(card));
}
const showBar = sort === 'dl' && downloaded.size > 0;
$('dlbar').classList.toggle('hide', !showBar);
if (showBar) {
const used = [...downloaded.values()].reduce((a, m) => a + (m.size || 0), 0);
$('dlinfo').textContent = `${downloaded.size} downloaded · ${mb(used)} on this device`;
try { navigator.storage.estimate().then(e => { if (e.quota && sort === $('sort').value) $('dlinfo').textContent += ` · room for about ${mb(Math.max(0, e.quota - e.usage))} more`; }); } catch {}
}
if (!list.length && books.length) { $('msg').textContent = sort === 'dl' ? 'Nothing downloaded yet. Tap the ↓ on a book to save it.' : 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; })
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return (sort !== 'dl' || downloaded.has(bookId(b))) && p && p.pos > 10 && p.dur && p.pos < p.dur - 30; })
.sort((a, b) => last(b) - last(a)).slice(0, 12);
$('contSec').classList.toggle('hide', !cont.length || !!q);
$('cont').replaceChildren(...cont.map(card));
@@ -375,9 +401,12 @@ async function findFile(id) {
const names = arr.map(f => typeof f === 'string' ? f : pick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => PLAYABLE.test(n));
}
let playTok = 0, playIntent = false, retries = 0, curSrc = '', stallT, loadT, recoverT, needOnline = false;
const wait = ms => new Promise(r => setTimeout(r, ms));
const setBuf = on => { $('play').classList.toggle('buf', on); $('mplay').classList.toggle('buf', on); };
async function play(b, open) {
if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; }
const id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
const tok = ++playTok, id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
let src;
if (off) {
try {
@@ -391,6 +420,10 @@ async function play(b, open) {
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
}
if (tok !== playTok) { if (off) URL.revokeObjectURL(src); return; } // a newer tap took over
await Promise.race([pull(), wait(2500)]); // newest position from other devices, but never wait long
await unlockP;
if (tok !== playTok) { if (off) URL.revokeObjectURL(src); return; }
saveProgress();
if (curObjUrl) URL.revokeObjectURL(curObjUrl);
curObjUrl = off ? src : null;
@@ -401,12 +434,10 @@ async function play(b, open) {
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
$('mtitle').textContent = $('ftitle').textContent = title(b);
$('msub').textContent = $('fsub').textContent = author(b);
await unlockP;
audio.src = src;
playIntent = true; retries = 0; needOnline = false;
clearTimeout(stallT); clearTimeout(recoverT); setBuf(true);
startSource(src, (store.get('prog:' + bookId(b)) || {}).pos);
uiDl();
await pull();
const saved = (store.get('prog:' + bookId(b)) || {}).pos;
audio.addEventListener('loadedmetadata', () => { if (saved && saved < audio.duration - 30) audio.currentTime = saved; audio.playbackRate = speeds[spIdx]; audio.play().catch(() => {}); }, { once: true });
loadChapters(id, off);
render();
if (open) openFull();
@@ -415,9 +446,39 @@ async function play(b, open) {
ms.metadata = new MediaMetadata({ title: title(b), artist: author(b), artwork: [{ src: cover(b) }] });
ms.setActionHandler('seekbackward', () => skip(-30)); ms.setActionHandler('seekforward', () => skip(30));
ms.setActionHandler('seekto', e => { audio.currentTime = e.seekTime; });
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
ms.setActionHandler('play', userPlay); ms.setActionHandler('pause', userPause);
}
}
// The listener is attached BEFORE the source is set, so a fast source can't fire loadedmetadata unseen.
function startSource(src, at, bust) {
clearTimeout(loadT);
curSrc = src;
audio.addEventListener('loadedmetadata', () => {
clearTimeout(loadT);
const d = audio.duration;
if (at && isFinite(d) && at < d - 30) audio.currentTime = at;
audio.playbackRate = speeds[spIdx];
if (playIntent) audio.play().catch(e => { setBuf(false); if (e && e.name === 'NotAllowedError') { playIntent = false; toast('Tap ▶ to start'); } });
}, { once: true });
// browsers remember a failed address, so a retry asks for a fresh one (the server strips the extra parameter)
audio.src = bust && !src.startsWith('blob:') ? src + (src.includes('?') ? '&' : '?') + '_r=' + Date.now() : src;
audio.load();
loadT = setTimeout(() => { if (audio.readyState < 1) recover('no response from the server'); }, 20000);
}
// Something went wrong (network error, stall, timeout): reload the same source and carry on from where we were.
function recover(why) {
clearTimeout(stallT); clearTimeout(loadT);
if (!current) return;
if (!navigator.onLine && !downloaded.has(bookId(current))) {
needOnline = true; setBuf(true); toast('Offline. Playback resumes when the connection is back.'); return;
}
if (retries >= 4) { setBuf(false); playIntent = false; toast(`Playback failed (${why}). Tap ▶ to try again.`); return; }
retries++;
const at = audio.currentTime > 1 ? audio.currentTime : (store.get('prog:' + bookId(current)) || {}).pos || 0;
setBuf(true);
clearTimeout(recoverT);
recoverT = setTimeout(() => startSource(curSrc, at, true), Math.min(500 * 2 ** (retries - 1), 4000));
}
async function loadChapters(id, off) {
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
try {
@@ -443,18 +504,44 @@ function jump(d) {
if (d < 0 && audio.currentTime - chapters[Math.max(chIdx, 0)].start > 4) audio.currentTime = chapters[Math.max(chIdx, 0)].start;
else audio.currentTime = chapters[Math.max(0, Math.min(chapters.length - 1, chIdx + d))].start;
}
const toggle = () => audio.paused ? audio.play() : audio.pause();
function userPlay() {
if (!current) return;
playIntent = true; needOnline = false;
if (audio.error || audio.networkState === 3 || audio.readyState === 0) { // errored out earlier: start over from here
retries = 0; setBuf(true);
return startSource(curSrc, audio.currentTime > 1 ? audio.currentTime : (store.get('prog:' + bookId(current)) || {}).pos, true);
}
audio.play().catch(e => { setBuf(false); if (e && e.name !== 'AbortError') toast('Could not start playback: ' + e.message); });
}
function userPause() { playIntent = false; audio.pause(); }
const toggle = () => audio.paused ? userPlay() : userPause();
for (const id of ['play', 'mplay']) $(id).onclick = e => { e.stopPropagation(); toggle(); };
for (const id of ['back', 'mback']) $(id).onclick = e => { e.stopPropagation(); skip(-30); };
for (const id of ['fwd', 'mfwd']) $(id).onclick = e => { e.stopPropagation(); skip(30); }; $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1);
const setSession = st => { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = st; };
audio.onplay = () => { ico('play', 'pause'); ico('mplay', 'pause'); };
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play'); saveProgress(); };
audio.onended = () => { if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play');
// iOS: play even with the silent switch on, and keep audio going in the background
try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} setSession('paused'); saveProgress(); };
audio.addEventListener('playing', () => {
clearTimeout(stallT); clearTimeout(loadT); retries = 0; needOnline = false; setBuf(false);
audio.playbackRate = speeds[spIdx]; // some browsers reset the speed on a new source
setSession('playing');
});
audio.addEventListener('waiting', () => { // out of buffered data: if it doesn't recover on its own, reload from here
if (!playIntent) return;
setBuf(true); clearTimeout(stallT); stallT = setTimeout(() => recover('stalled'), 12000);
});
audio.addEventListener('canplay', () => { if (audio.paused) setBuf(false); });
audio.onended = () => { playIntent = false; setBuf(false); if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
audio.onerror = () => {
const c = audio.error && audio.error.code, why = { 1: 'aborted', 2: 'network error', 3: 'could not decode', 4: 'format or stream not supported' }[c] || 'unknown';
if (!current) return;
const c = audio.error && audio.error.code;
if (c === 1) return; // aborted: we changed the source ourselves
console.error('audio error', c, audio.error && audio.error.message, audio.src);
toast(`Playback failed: ${why}${current ? ' · ' + (audio.src.split('.').pop().slice(0, 5)) : ''}`);
recover({ 2: 'network error', 3: 'could not decode', 4: 'format or stream not supported' }[c] || 'unknown error');
};
window.addEventListener('online', () => { pull(); push(); if (needOnline) { needOnline = false; retries = 0; recover('back online'); } });
$('seek').oninput = e => { audio.currentTime = e.target.value; };
// ---- cross-device sync (progress is kept by server.py; newest timestamp wins) ----
@@ -478,14 +565,14 @@ async function pull() {
push(); render();
} catch {}
}
function saveProgress() { if (current && audio.duration) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
function saveProgress() { if (current && isFinite(audio.duration) && audio.duration > 0) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
let lastSave = 0, lastPos = 0;
audio.ontimeupdate = () => {
const d = audio.duration || 0, t = audio.currentTime;
const d = isFinite(audio.duration) ? audio.duration : 0, t = audio.currentTime;
$('seek').max = d || 1; $('seek').value = t; $('seek').style.setProperty('--p', d ? t / d * 100 + '%' : '0%');
$('mbar').style.width = d ? t / d * 100 + '%' : '0';
if (d && 'mediaSession' in navigator && navigator.mediaSession.setPositionState && Date.now() - lastPos > 1000) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(t, d), playbackRate: audio.playbackRate }); } catch {} }
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt((d - t) / audio.playbackRate);
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt(Math.max(0, d - t) / audio.playbackRate);
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveProgress(); }
let i = -1; chapters.forEach((c, k) => { if (t >= c.start) i = k; });
if (i !== chIdx) {
@@ -504,7 +591,7 @@ $('sleep').onclick = () => {
slIdx = (slIdx + 1) % sleeps.length; clearTimeout(slTimer);
const m = sleeps[slIdx];
$('sleep').textContent = m ? `Sleep: ${m} min` : 'Sleep: off'; $('sleep').classList.toggle('on', !!m);
if (m) slTimer = setTimeout(() => { audio.pause(); slIdx = 0; $('sleep').textContent = 'Sleep: off'; $('sleep').classList.remove('on'); toast('Sleep timer: paused'); }, m * 60000);
if (m) slTimer = setTimeout(() => { userPause(); slIdx = 0; $('sleep').textContent = 'Sleep: off'; $('sleep').classList.remove('on'); toast('Sleep timer: paused'); }, m * 60000);
};
// full-screen player
@@ -520,6 +607,7 @@ document.addEventListener('keydown', e => {
else if (e.code === 'Escape') closeFull();
});
window.addEventListener('beforeunload', () => { saveProgress(); push(true); });
window.addEventListener('pagehide', () => { saveProgress(); push(true); }); // iOS fires this more reliably than beforeunload
document.addEventListener('visibilitychange', () => { if (document.hidden) { saveProgress(); push(true); } else if (audio.paused) pull(); });
ico('play', 'play'); ico('mplay', 'play');
// iOS only lets audio start from a tap. Unlock the element on the first touch (before any click handler runs)
+18 -2
View File
@@ -9,7 +9,23 @@
"background_color": "#0f0f12",
"theme_color": "#0f0f12",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"}
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+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:
+2 -2
View File
@@ -1,7 +1,7 @@
// Keeps the app shell so the player opens with no connection. Audio is stored separately by the page (IndexedDB).
// Service workers only run on HTTPS (or localhost).
const CACHE = 'shell-v1';
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png'];
const CACHE = 'shell-v2';
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png', '/icon-32.png'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting()));
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Draws the app icons (headphones on an orange gradient). Run from the repo root: python3 tools/make_icons.py"""
from PIL import Image, ImageDraw, ImageFilter
S = 1024
def gradient():
# diagonal orange gradient with a soft highlight, built small and scaled up for smoothness
n = 256
img = Image.new("RGB", (n, n))
px = img.load()
for y in range(n):
for x in range(n):
t = (x + y) / (2 * n)
r = 255 + (214 - 255) * t
g = 142 + (66 - 142) * t
b = 62 + (6 - 62) * t
d = ((x - n * .25) ** 2 + (y - n * .2) ** 2) ** .5 / n # highlight at top-left
k = max(0, .22 - d * .35)
px[x, y] = (min(255, int(r + 255 * k)), min(255, int(g + 255 * k)), min(255, int(b + 255 * k)))
return img.resize((S, S), Image.BICUBIC)
def headphones():
"""White headphones on a transparent 1024 canvas, centered."""
layer = Image.new("RGBA", (S, S), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
white, pad = (255, 255, 255, 255), (214, 74, 10, 255)
cx, cy, ro, w = 512, 530, 330, 76
d.arc((cx - ro, cy - ro, cx + ro, cy + ro), 180, 360, fill=white, width=w) # headband
for side in (-1, 1):
ex = cx + side * (ro - w / 2) # band end
d.rounded_rectangle((ex - 80, cy - 4, ex + 80, cy + 290), 78, fill=white) # ear cup
ix = ex - side * 8 + (-side) * 0
px0, px1 = (ex + 8, ex + 80) if side == -1 else (ex - 80, ex - 8)
d.rounded_rectangle((px0, cy + 44, px1, cy + 246), 34, fill=pad) # cushion
# content box ~ (130..894, 200..820)
return layer
def compose(scale, shadow=True):
bg = gradient().convert("RGBA")
hp = headphones()
if scale != 1:
size = int(S * scale)
hp = hp.resize((size, size), Image.LANCZOS)
full = Image.new("RGBA", (S, S), (0, 0, 0, 0))
full.paste(hp, ((S - size) // 2, (S - size) // 2 + int(S * (1 - scale) * .05)), hp)
hp = full
if shadow:
sh = Image.new("RGBA", (S, S), (0, 0, 0, 0))
sh.putalpha(hp.getchannel("A").filter(ImageFilter.GaussianBlur(22)).point(lambda a: int(a * .35)))
offset = Image.new("RGBA", (S, S), (90, 25, 0, 0))
offset.putalpha(sh.getchannel("A"))
shifted = Image.new("RGBA", (S, S), (0, 0, 0, 0)); shifted.paste(offset, (0, 18))
bg = Image.alpha_composite(bg, shifted)
return Image.alpha_composite(bg, hp).convert("RGB")
any_icon = compose(1.0) # 'any' and apple-touch: full-bleed, iOS rounds the corners itself
maskable = compose(.70) # content kept inside the 80% safe zone so Android can crop to any shape
for size, name, src in ((512, "icon-512.png", any_icon), (192, "icon-192.png", any_icon), (180, "icon-180.png", any_icon),
(512, "icon-maskable-512.png", maskable), (32, "icon-32.png", any_icon)):
src.resize((size, size), Image.LANCZOS).save(name, optimize=True)
print("wrote", name)