diff --git a/Dockerfile b/Dockerfile index e7b6d55..ae65231 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 icon-180.png icon-192.png icon-512.png ./ +COPY server.py index.html admin.html manifest.webmanifest sw.js icon-180.png icon-192.png icon-512.png ./ USER app VOLUME /data EXPOSE 8080 diff --git a/README.md b/README.md index 3f9a44a..0332d54 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,15 @@ PLAYER_PASSWORD=... `docker compose up -d`, then sign in at port 8080. The default account is only created if it doesn't exist, so changing its password later with `docker exec -it audiobook-player python server.py passwd nate` sticks. Add more people with `adduser`. + +## Offline listening + +Tap the ↓ on a book (or "Download" in the player) to save it on the device. Downloaded books show a ✓, and the +"Downloaded only" view lists them. They play with no connection to the server, and progress syncs when you're back online. + +- Keep the app open while a download runs; iOS pauses pages in the background. +- The audio is stored in the browser (IndexedDB). iOS counts it against the site's storage quota, which is much larger + once the site is added to the Home Screen. +- **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. diff --git a/index.html b/index.html index 0a57fea..aff5377 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,9 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em} .cov .eq{position:absolute;right:8px;top:8px;background:var(--acc);color:#fff;border-radius:99px;font-size:11px;font-weight:700;padding:2px 8px} .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.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0} +#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} .skel .cov{animation:pulse 1.2s infinite} @keyframes pulse{50%{opacity:.5}} #msg{padding:60px 0;text-align:center;color:var(--mut)} @@ -128,10 +131,11 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block} Admin - +
+
You're offline or OpenAudible isn't reachable. Showing your downloaded books.

Continue listening

Library

@@ -160,7 +164,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block} -
+

Chapters

@@ -178,7 +182,7 @@ const bookId = b => pick(b, 'id', 'asin', 'ASIN', 'book_id'); const title = b => pick(b, 'title', 'title_short', 'name') || '(untitled)'; const author = b => pick(b, 'author', 'author_name', 'authors', 'author_display') || ''; const series = b => pick(b, 'series_name', 'series') || ''; -const cover = b => `/api/books/${encodeURIComponent(bookId(b))}/cover`; +const cover = b => (typeof coverUrls !== 'undefined' && coverUrls.get(bookId(b))) || `/api/books/${encodeURIComponent(bookId(b))}/cover`; const fmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s/3600), m = Math.floor(s%3600/60), x = s%60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }; const get = async p => { const r = await fetch(p); if (r.status === 401) { location.href = '/login'; throw new Error('Signing in…'); } if (!r.ok) { let m = ''; try { m = (await r.json()).error || ''; } catch {} throw new Error(m || p + ' → ' + r.status); } return r.json(); }; @@ -190,20 +194,116 @@ const ico = (id, name) => { $(id).innerHTML = ` [...s].reduce((a, c) => a * 31 + c.charCodeAt(0), 7) % 360; let toastT; const toast = t => { $('toast').textContent = t; $('toast').classList.add('on'); clearTimeout(toastT); toastT = setTimeout(() => $('toast').classList.remove('on'), 2200); }; +// ---- offline downloads (IndexedDB; books are stored in 8 MB chunks so memory stays low) ---- +const CHUNK = 8 * 1024 * 1024; +const OFF = (() => { + let dbp; + const open = () => dbp || (dbp = new Promise((res, rej) => { + const r = indexedDB.open('audiobooks', 1); + r.onupgradeneeded = () => { r.result.createObjectStore('meta', { keyPath: 'id' }); r.result.createObjectStore('chunks'); }; + r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); + })); + const run = (store, mode, fn) => open().then(d => new Promise((res, rej) => { + const t = d.transaction(store, mode), r = fn(t.objectStore(store)); + t.oncomplete = () => res(r && r.result); t.onerror = t.onabort = () => rej(t.error); + })); + const range = id => IDBKeyRange.bound([id, 0], [id, Infinity]); + return { + list: () => run('meta', 'readonly', s => s.getAll()), + putMeta: m => run('meta', 'readwrite', s => s.put(m)), + putChunk: (id, i, blob) => run('chunks', 'readwrite', s => s.put(blob, [id, i])), + chunks: id => run('chunks', 'readonly', s => s.getAll(range(id))), + remove: async id => { await run('chunks', 'readwrite', s => s.delete(range(id))); await run('meta', 'readwrite', s => s.delete(id)); } + }; +})(); +const downloaded = new Map(), dlState = new Map(), coverUrls = new Map(); +let curObjUrl = null; +const setCoverUrl = m => { if (coverUrls.has(m.id)) URL.revokeObjectURL(coverUrls.get(m.id)); if (m.cover) coverUrls.set(m.id, URL.createObjectURL(m.cover)); }; +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) ? '✓' : '↓'; }; +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)); }); + 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').classList.toggle('on', !!st || downloaded.has(id)); +} +async function download(b) { + const id = bookId(b), eid = encodeURIComponent(id); + if (dlState.has(id)) return; + const ctl = new AbortController(), st = { pct: 0, got: 0, ctl }; + dlState.set(id, st); uiDl(); + toast(window.isSecureContext ? 'Downloading. Keep this screen open until it finishes.' : 'Downloading. (Opening the app offline needs HTTPS.)'); + try { navigator.storage && navigator.storage.persist && navigator.storage.persist(); } catch {} + let n = 0; + try { + const file = await findFile(eid); + if (!file) throw new Error('no playable MP3/M4B for this book'); + const r = await fetch(`/api/books/${eid}/files/${encodeURIComponent(file)}`, { signal: ctl.signal }); + if (!r.ok) throw new Error('server said ' + r.status); + const total = +r.headers.get('Content-Length') || 0; + const type = r.headers.get('Content-Type') || (/\.mp3$/i.test(file) ? 'audio/mpeg' : 'audio/mp4'); + const rd = r.body.getReader(); + let buf = [], size = 0; + const flush = async () => { if (size) { await OFF.putChunk(id, n++, new Blob(buf)); buf = []; size = 0; } }; + for (;;) { + const { done, value } = await rd.read(); + if (done) break; + buf.push(value); size += value.length; st.got += value.length; st.pct = total ? st.got / total : 0; + if (size >= CHUNK) await flush(); + uiDl(); + } + await flush(); + let coverBlob = null, chaps = null; + try { const c = await fetch(cover(b)); if (c.ok) coverBlob = await c.blob(); } catch {} + try { chaps = await get(`/api/books/${eid}/chapters/flat`); } catch {} + const meta = { id, book: b, file, type, chunks: n, size: st.got, cover: coverBlob, chapters: chaps, t: Date.now() }; + await OFF.putMeta(meta); downloaded.set(id, meta); setCoverUrl(meta); + toast(`Downloaded “${title(b)}”`); + } catch (e) { + await OFF.remove(id).catch(() => {}); // never leave half a book behind + if (e.name !== 'AbortError') toast(e.name === 'QuotaExceededError' ? 'Not enough storage on this device.' : 'Download failed: ' + e.message); + } finally { dlState.delete(id); uiDl(); render(); } +} +async function removeDl(id) { + 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'); +} +function toggleDl(b) { + const id = bookId(b), st = dlState.get(id); + if (st) return st.ctl.abort(); + if (downloaded.has(id)) { if (confirm(`Remove the downloaded copy of “${title(b)}”?`)) removeDl(id); return; } + if (!window.indexedDB) return toast("Downloads aren't available in this browser"); + download(b); +} +$('dl').onclick = () => current && toggleDl(current); + // ---- library ---- async function load() { if (!/^https?:$/.test(location.protocol)) { $('msg').innerHTML = `
Open this page through the player server, not as a file.
Run python3 server.py and visit http://localhost:8080
`; $('msg').classList.remove('hide'); return; } + await initOffline(); $('grid').innerHTML = Array(12).fill('
').join(''); $('msg').classList.add('hide'); try { const data = await get('/api/books'); books = Array.isArray(data) ? data : (data.books || Object.values(data)); + $('offline').classList.add('hide'); render(); pull(); } catch (e) { + if (downloaded.size) { // can't reach the library: still let people listen to what they saved + books = [...downloaded.values()].map(m => m.book); + $('sort').value = 'dl'; $('offline').classList.remove('hide'); + render(); return; + } $('grid').replaceChildren(); $('msg').innerHTML = `
Couldn't load your library.
${e.message}
`; $('msg').classList.remove('hide'); @@ -219,18 +319,23 @@ function card(b) { img.onerror = () => { const p = document.createElement('div'); p.className = 'ph'; p.style.background = `linear-gradient(135deg,hsl(${hue(title(b))} 55% 40%),hsl(${hue(title(b)) + 40} 55% 28%))`; p.textContent = title(b); img.replaceWith(p); }; 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.setAttribute('role', 'button'); badge.setAttribute('aria-label', 'Download for offline listening'); + badge.onclick = e => { e.stopPropagation(); toggleDl(b); }; + d.querySelector('.cov').append(badge); d.onclick = () => play(b, true); return d; } function render() { const q = $('q').value.trim().toLowerCase(), sort = $('sort').value; - let list = books.filter(b => !q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q)); + let list = books.filter(b => (sort !== 'dl' || downloaded.has(bookId(b))) && (!q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q))); const last = b => (store.get('prog:' + bookId(b)) || {}).t || 0; - list.sort(sort === 'author' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b)) + list.sort(sort === 'author' || sort === 'dl' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b)) : sort === 'recent' ? (a, b) => last(b) - last(a) : (a, b) => title(a).localeCompare(title(b))); - $('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` : ''; - if (sort === 'author') { + $('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` + (sort === 'dl' ? ' · ' + mb(list.reduce((a, b) => a + ((downloaded.get(bookId(b)) || {}).size || 0), 0)) : '') : ''; + if (sort === 'author' || sort === 'dl') { const groups = new Map(); const labels = new Map(); for (const b of list) { @@ -254,7 +359,7 @@ function render() { $('grid').className = 'grid'; $('grid').replaceChildren(...list.map(card)); } - if (!list.length && books.length) { $('msg').textContent = 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide'); + 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; }) .sort((a, b) => last(b) - last(a)).slice(0, 12); $('contSec').classList.toggle('hide', !cont.length || !!q); @@ -272,11 +377,23 @@ async function findFile(id) { } async function play(b, open) { if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; } - const id = encodeURIComponent(bookId(b)); - let file; - try { file = await findFile(id); } catch (e) { return toast('Could not list files: ' + e.message); } - if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.'); + const id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b)); + let src; + if (off) { + try { + const parts = await OFF.chunks(bookId(b)); + if (parts.length !== off.chunks) throw new Error('the saved copy is incomplete; remove it and download again'); + src = URL.createObjectURL(new Blob(parts, { type: off.type })); + } catch (e) { return toast('Could not open the downloaded copy: ' + e.message); } + } else { + let file; + try { file = await findFile(id); } catch (e) { return toast('Could not list files: ' + e.message); } + if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.'); + src = `/api/books/${id}/files/${encodeURIComponent(file)}`; + } saveProgress(); + if (curObjUrl) URL.revokeObjectURL(curObjUrl); + curObjUrl = off ? src : null; current = b; $('mini').classList.remove('hide'); const fallback = 'data:image/svg+xml,' + encodeURIComponent(`${(title(b)[0] || '?').replace(/[<&]/g, '')}`); @@ -284,11 +401,13 @@ async function play(b, open) { $('fullbg').style.backgroundImage = `url("${cover(b)}")`; $('mtitle').textContent = $('ftitle').textContent = title(b); $('msub').textContent = $('fsub').textContent = author(b); - audio.src = `/api/books/${id}/files/${encodeURIComponent(file)}`; + await unlockP; + audio.src = src; + 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); + loadChapters(id, off); render(); if (open) openFull(); if ('mediaSession' in navigator) { @@ -299,10 +418,10 @@ async function play(b, open) { ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause()); } } -async function loadChapters(id) { +async function loadChapters(id, off) { chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = ''; try { - const data = await get(`/api/books/${id}/chapters/flat`); + const data = (off && off.chapters) || await get(`/api/books/${id}/chapters/flat`); const arr = Array.isArray(data) ? data : (data.chapters || []); chapters = arr.map(c => { let s = pick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec'); @@ -403,19 +522,21 @@ document.addEventListener('keydown', e => { window.addEventListener('beforeunload', () => { saveProgress(); push(true); }); 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 so later play() calls -// (which happen after async fetches) are still allowed. -let unlocked = false; +// iOS only lets audio start from a tap. Unlock the element on the first touch (before any click handler runs) +// so the later play() calls, which happen after async fetches, are still allowed. play() waits for this to finish. +const SILENT = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA='; +let unlocked = false, unlockP = Promise.resolve(); const unlock = () => { if (unlocked) return; unlocked = true; - const keep = audio.src; - if (!keep) { - audio.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA='; - audio.play().then(() => audio.pause()).catch(() => {}); - } + if (audio.src) return; + audio.src = SILENT; + unlockP = audio.play().then(() => audio.pause()).catch(() => {}); }; -['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true })); -fetch('/me').then(r => r.json()).then(m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {}); +['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true, capture: true })); +const showMe = m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').classList.remove('hide'); $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }; +$('who').classList.add('hide'); +fetch('/me').then(r => r.ok ? r.json() : Promise.reject()).then(m => { store.set('me', m); showMe(m); }).catch(() => { const m = store.get('me'); if (m) showMe(m); }); +if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); load(); diff --git a/server.py b/server.py index 22af2eb..89a9deb 100644 --- a/server.py +++ b/server.py @@ -41,7 +41,7 @@ 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"} + "/icon-192.png": "image/png", "/icon-512.png": "image/png", "/sw.js": "text/javascript"} # Only read-only endpoints needed for listening are reachable; admin commands/import are not. ALLOWED = [re.compile(p) for p in ( @@ -223,7 +223,7 @@ class Handler(BaseHTTPRequestHandler): return self.logout() if path in PUBLIC: with open(os.path.join(HERE, path[1:]), "rb") as f: - return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "public, max-age=86400")]) + 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")]) user = self.user() if not user: return self.deny(path) diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..de48609 --- /dev/null +++ b/sw.js @@ -0,0 +1,24 @@ +// 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']; + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting())); +}); +self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim())); +}); +self.addEventListener('fetch', e => { + const r = e.request, url = new URL(r.url); + if (r.method !== 'GET' || url.origin !== location.origin) return; + if (r.mode === 'navigate' && url.pathname === '/') { + // newest page when online, the saved one when not. Never save a sign-in redirect. + e.respondWith(fetch(r).then(res => { + if (res.ok && !res.redirected) { const copy = res.clone(); caches.open(CACHE).then(c => c.put('/', copy)); } + return res; + }).catch(() => caches.match('/'))); + } else if (STATIC.includes(url.pathname)) { + e.respondWith(caches.match(r).then(hit => hit || fetch(r))); + } +});