Audiobook player with user accounts and synced progress

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 15:28:10 -04:00
commit 8341c25846
4 files changed
+756

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# secrets and per-user data: never commit
config.json
users.json
sessions.json
progress.json
progress_users.json
*.tmp
__pycache__/
+21
View File
@@ -0,0 +1,21 @@
# Audiobook Player
A web player for an [OpenAudible](https://openaudible.org) library, with user accounts, per-user listening progress synced across devices, and an iPhone-friendly layout. Python 3 standard library only.
## Setup
```bash
python3 server.py setup # OpenAudible login the server uses (shared by all accounts)
python3 server.py adduser NAME # create a player account
python3 server.py serve # --upstream http://HOST:8000 --port 8080
```
Then open `http://<server-ip>:8080` and sign in. Other commands: `users`, `passwd NAME`, `deluser NAME`.
Only MP3/M4B files can be played in a browser; convert Audible `.aax`/`.aaxc` books in OpenAudible first.
## Notes
- Credentials and user data live in `config.json`, `users.json`, `sessions.json`, `progress_users.json` (git-ignored).
- Only read-only OpenAudible endpoints are proxied; admin commands and import are blocked.
- Traffic is plain HTTP. Use a VPN (e.g. Tailscale) or a TLS reverse proxy for access outside your home network.
+396
View File
@@ -0,0 +1,396 @@
<!doctype html>
<html lang="en">
<head>
<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">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Audiobooks">
<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)}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--surf:#1a1a1f;--surf2:#26262d;--fg:#f2f2f4;--mut:#8f8f99;--acc:#ff7a2f;--acc2:#ffb067;--bd:rgba(255,255,255,.08);--sh:0 8px 30px rgba(0,0,0,.5)}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0}
body{background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;padding-bottom:calc(100px + env(safe-area-inset-bottom))}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
svg{width:1em;height:1em;fill:currentColor;display:block}
.hide{display:none!important}
/* header */
header{position:sticky;top:0;z-index:5;backdrop-filter:blur(18px) saturate(1.4);background:color-mix(in srgb,var(--bg) 82%,transparent);border-bottom:1px solid var(--bd)}
.hd{max-width:1200px;margin:auto;padding:calc(14px + env(safe-area-inset-top)) 20px 14px;display:flex;gap:12px;align-items:center}
.logo{font-weight:800;font-size:20px;letter-spacing:-.02em;display:flex;align-items:center;gap:8px}
.logo i{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--acc),var(--acc2));display:grid;place-items:center;color:#fff;font-size:16px}
.search{flex:1;position:relative;max-width:520px;margin-left:auto}
.search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--mut);font-size:16px}
#q{width:100%;font-size:16px;padding:10px 14px 10px 38px;border-radius:12px;border:1px solid var(--bd);background:var(--surf);color:var(--fg);font:inherit;outline:0}
#q:focus{border-color:var(--acc)}
#sort{font-size:16px;padding:10px 12px;border-radius:12px;border:1px solid var(--bd);background:var(--surf);color:var(--fg);font:inherit}
main{max-width:1200px;margin:auto;padding:8px 20px}
h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
.ah{display:flex;align-items:baseline;gap:10px;margin:28px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--bd);font-size:17px;letter-spacing:-.01em}
.ah small{color:var(--mut);font-weight:500;font-size:13px}
#grid>section:first-child .ah{margin-top:4px}
.count{color:var(--mut);font-weight:500;font-size:14px;margin-left:6px}
/* cards */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:20px 16px}
.shelf{display:flex;gap:16px;overflow-x:auto;padding-bottom:8px;scroll-snap-type:x proximity;scrollbar-width:thin}
.shelf .card{flex:0 0 150px;scroll-snap-align:start}
.card{cursor:pointer;text-align:left;transition:transform .15s}
.card:hover{transform:translateY(-3px)}
.cov{position:relative;border-radius:12px;overflow:hidden;aspect-ratio:1;background:var(--surf2);box-shadow:0 2px 10px rgba(0,0,0,.15)}
.cov img{width:100%;height:100%;object-fit:cover;display:block}
.cov .ph{position:absolute;inset:0;display:grid;place-items:center;padding:12px;text-align:center;font-weight:700;color:#fff;font-size:13px}
.cov .bar{position:absolute;left:0;right:0;bottom:0;height:4px;background:rgba(0,0,0,.35)}
.cov .bar i{display:block;height:100%;background:var(--acc2)}
.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}
.skel .cov{animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.5}}
#msg{padding:60px 0;text-align:center;color:var(--mut)}
#msg button{margin-top:12px;background:var(--acc);color:#fff;padding:10px 18px;border-radius:10px;font-weight:600}
/* mini player */
#mini{position:fixed;left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom));max-width:760px;margin:auto;z-index:8;background:var(--surf);border:1px solid var(--bd);border-radius:18px;box-shadow:var(--sh);padding:10px 12px;display:flex;align-items:center;gap:12px;cursor:pointer;overflow:hidden}
#mini img{width:48px;height:48px;border-radius:10px;object-fit:cover;background:var(--surf2);flex:none}
#mini .meta{flex:1;min-width:0}
#mini b,#mini span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#mini span{color:var(--mut);font-size:12.5px}
#mini .mbar{position:absolute;left:0;bottom:0;height:3px;background:var(--acc);width:0}
.ib{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;font-size:22px;flex:none}
.ib:hover{background:var(--surf2)}
.pp{background:var(--acc)!important;color:#fff;box-shadow:0 4px 14px color-mix(in srgb,var(--acc) 50%,transparent)}
/* full player */
#full{position:fixed;inset:0;z-index:20;background:var(--bg);transform:translateY(100%);transition:transform .35s cubic-bezier(.2,.8,.2,1);overflow:auto;visibility:hidden}
#full.open{transform:none;visibility:visible}
#fullbg{position:fixed;inset:0;z-index:-1;background-size:cover;background-position:center;filter:blur(70px) saturate(1.5);opacity:.35;transform:scale(1.3)}
.fw{max-width:520px;margin:auto;padding:calc(16px + env(safe-area-inset-top)) 24px calc(28px + env(safe-area-inset-bottom));min-height:100%;display:flex;flex-direction:column}
.top{display:flex;justify-content:space-between;align-items:center}
.top span{color:var(--mut);font-size:12px;letter-spacing:.08em;text-transform:uppercase;font-weight:600}
#bigcov{width:100%;max-width:340px;aspect-ratio:1;margin:18px auto;border-radius:20px;object-fit:cover;box-shadow:0 20px 50px rgba(0,0,0,.35);background:var(--surf2)}
#ftitle{font-size:22px;font-weight:800;letter-spacing:-.02em;text-align:center;margin:4px 0}
#fsub{color:var(--mut);text-align:center}
#fchap{color:var(--acc);text-align:center;font-weight:600;font-size:13px;margin-top:6px;min-height:18px}
.seekw{margin-top:20px}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:6px;border-radius:99px;background:linear-gradient(to right,var(--acc) var(--p,0%),var(--surf2) var(--p,0%));outline:0;margin:10px 0}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 6px rgba(0,0,0,.4);border:2px solid var(--acc)}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:#fff;border:2px solid var(--acc)}
.times{display:flex;justify-content:space-between;color:var(--mut);font-size:12px;font-variant-numeric:tabular-nums}
.ctl{display:flex;justify-content:center;align-items:center;gap:18px;margin:14px 0}
.ctl .ib{width:56px;height:56px;font-size:28px;position:relative}
.ctl .ib small{position:absolute;font-size:10px;font-weight:800;top:50%;left:50%;transform:translate(-50%,-38%)}
.ctl .pp{width:76px;height:76px;font-size:34px}
.opts{display:flex;justify-content:center;gap:10px;margin:6px 0 14px}
.pill{background:var(--surf);border:1px solid var(--bd);border-radius:99px;padding:8px 16px;font-weight:600;font-size:13.5px}
.pill.on{border-color:var(--acc);color:var(--acc)}
.chaps{background:var(--surf);border:1px solid var(--bd);border-radius:14px;overflow:hidden}
.chaps h3{margin:0;padding:12px 16px;font-size:13px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
.chlist{max-height:240px;overflow:auto}
.chlist button{display:flex;gap:12px;width:100%;padding:10px 16px;text-align:left;border-top:1px solid var(--bd)}
.chlist button:hover{background:var(--surf2)}
.chlist .tm{color:var(--mut);font-variant-numeric:tabular-nums;min-width:52px}
.chlist .on{color:var(--acc);font-weight:700}
#toast{position:fixed;left:50%;bottom:110px;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:10px 16px;border-radius:99px;font-weight:600;z-index:30;opacity:0;pointer-events:none;transition:opacity .2s}
#toast.on{opacity:1}
@media(max-width:560px){.logo span{display:none}.hd{padding:calc(12px + env(safe-area-inset-top)) 14px 12px}main{padding:4px 14px}.grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}
</style>
</head>
<body>
<svg style="display:none">
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="M7.4 8.6 12 13.2l4.6-4.6L18 10l-6 6-6-6z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><path d="M15.5 14h-.8l-.3-.3A6.5 6.5 0 1 0 14 15.5l.3.3v.8l5 5 1.5-1.5zm-6 0a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8"/></symbol>
<symbol id="i-fwd" viewBox="0 0 24 24"><path d="M12 5V1l5 5-5 5V7a6 6 0 1 0 6 6h2a8 8 0 1 1-8-8"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M18 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12V2zM9 4h2v5l-1-.75L9 9z"/></symbol>
</svg>
<header><div class="hd">
<div class="logo"><i><svg><use href="#i-book"/></svg></i><span>Audiobooks</span></div>
<div class="search"><svg><use href="#i-search"/></svg><input id="q" type="search" placeholder="Search title, author, series"></div>
<button class="pill" id="who" title="Sign out" style="padding:8px 12px"></button>
<select id="sort" aria-label="Sort"><option value="author">Group by author</option><option value="title">Sort by title</option><option value="recent">Recently played</option></select>
</div></header>
<main>
<div id="msg" class="hide"></div>
<section id="contSec" class="hide"><h2>Continue listening</h2><div id="cont" class="shelf"></div></section>
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
</main>
<div id="mini" class="hide" role="button" aria-label="Open player">
<img id="mimg" alt=""><div class="meta"><b id="mtitle"></b><span id="msub"></span></div>
<button class="ib" id="mback" aria-label="Back 30 seconds"><svg><use href="#i-back"/></svg></button>
<button class="ib pp" id="mplay" aria-label="Play or pause"><svg><use href="#i-play"/></svg></button>
<div class="mbar" id="mbar"></div>
</div>
<div id="full" aria-hidden="true">
<div id="fullbg"></div>
<div class="fw">
<div class="top"><button class="ib" id="close" aria-label="Close"><svg><use href="#i-down"/></svg></button><span>Now playing</span><i style="width:44px"></i></div>
<img id="bigcov" alt="">
<div id="ftitle"></div><div id="fsub"></div><div id="fchap"></div>
<div class="seekw"><input id="seek" type="range" min="0" max="1" step="1" value="0" aria-label="Seek">
<div class="times"><span id="cur">0:00</span><span id="left">-0:00</span></div></div>
<div class="ctl">
<button class="ib" id="pch" aria-label="Previous chapter">⏮</button>
<button class="ib" id="back" aria-label="Back 30 seconds"><svg><use href="#i-back"/></svg><small>30</small></button>
<button class="ib pp" id="play" aria-label="Play or pause"><svg><use href="#i-play"/></svg></button>
<button class="ib" id="fwd" aria-label="Forward 30 seconds"><svg><use href="#i-fwd"/></svg><small>30</small></button>
<button class="ib" id="nch" aria-label="Next chapter">⏭</button>
</div>
<div class="opts"><button class="pill" id="speed">1×</button><button class="pill" id="sleep">Sleep: off</button></div>
<div class="chaps hide" id="chapBox"><h3>Chapters</h3><div class="chlist" id="chlist"></div></div>
</div>
</div>
<div id="toast"></div>
<audio id="audio" preload="metadata"></audio>
<script>
const $ = id => document.getElementById(id);
const audio = $('audio');
let books = [], current = null, chapters = [], chIdx = -1;
// The API schema is read defensively; field names vary between versions.
const pick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; };
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 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) throw new Error(r.status === 401 ? 'Not signed in (401)' : p + ' → ' + r.status); return r.json(); };
const store = {
get: k => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }
};
const ico = (id, name) => { $(id).innerHTML = `<svg><use href="#i-${name}"/></svg>`; };
const hue = s => [...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); };
// ---- library ----
async function load() {
if (!/^https?:$/.test(location.protocol)) {
$('msg').innerHTML = `<div>Open this page through the player server, not as a file.<br><small>Run <code>python3 server.py</code> and visit <b>http://localhost:8080</b></small></div>`;
$('msg').classList.remove('hide'); return;
}
$('grid').innerHTML = Array(12).fill('<div class="card skel"><div class="cov"></div></div>').join('');
$('msg').classList.add('hide');
try {
const data = await get('/api/books');
books = Array.isArray(data) ? data : (data.books || Object.values(data));
render();
pull();
} catch (e) {
$('grid').replaceChildren();
$('msg').innerHTML = `<div>Couldn't load your library.<br><small>${e.message}</small></div><button onclick="load()">Try again</button>`;
$('msg').classList.remove('hide');
}
}
function card(b) {
const id = bookId(b), pr = store.get('prog:' + id);
const d = document.createElement('button');
d.className = 'card';
const pct = pr && pr.dur ? Math.min(100, pr.pos / pr.dur * 100) : 0;
d.innerHTML = `<div class="cov"><img loading="lazy" alt="" src="${cover(b)}">${pct > 1 ? `<div class="bar"><i style="width:${pct}%"></i></div>` : ''}${current && bookId(current) === id ? '<div class="eq">Playing</div>' : ''}</div><b></b><span></span>`;
const img = d.querySelector('img');
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);
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));
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))
: 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') {
const groups = new Map();
for (const b of list) { const a = author(b) || 'Unknown author'; if (!groups.has(a)) groups.set(a, []); groups.get(a).push(b); }
$('grid').className = '';
$('grid').replaceChildren(...[...groups].map(([a, bs]) => {
const s = document.createElement('section');
s.innerHTML = '<h3 class="ah"><span></span><small></small></h3><div class="grid"></div>';
s.querySelector('span').textContent = a;
s.querySelector('small').textContent = bs.length + (bs.length === 1 ? ' book' : ' books');
s.querySelector('.grid').replaceChildren(...bs.map(card));
return s;
}));
} else {
$('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');
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);
$('cont').replaceChildren(...cont.map(card));
}
$('q').oninput = render; $('sort').onchange = render;
// ---- playback ----
const PLAYABLE = /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i;
async function findFile(id) {
const data = await get(`/admin/api/books/${id}/files`);
const arr = Array.isArray(data) ? data : (data.files || Object.values(data));
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));
}
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.');
saveProgress();
current = b;
$('mini').classList.remove('hide');
for (const [i, t] of [['mimg', 'src'], ['bigcov', 'src']]) $(i).src = cover(b);
$('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 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);
render();
if (open) openFull();
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
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('previoustrack', () => jump(-1)); ms.setActionHandler('nexttrack', () => jump(1));
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
}
}
async function loadChapters(id) {
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
try {
const data = 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');
if (s == null) { const ms = pick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; }
return { title: pick(c, 'title', 'name') || 'Chapter', start: Number(s) };
}).filter(c => !isNaN(c.start));
} catch {}
if (!chapters.length) return;
$('chapBox').classList.remove('hide');
$('chlist').replaceChildren(...chapters.map((c, i) => {
const b = document.createElement('button');
b.innerHTML = `<span class="tm">${fmt(c.start)}</span><span></span>`; b.lastChild.textContent = c.title;
b.onclick = () => { audio.currentTime = c.start; }; return b;
}));
}
const skip = s => { audio.currentTime = Math.max(0, Math.min(audio.duration || 1e9, audio.currentTime + s)); };
function jump(d) {
if (!chapters.length) return skip(d * 30);
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();
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); };
$('fwd').onclick = () => skip(30); $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1);
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.onerror = () => toast("Playback failed (this browser can't play that format)");
$('seek').oninput = e => { audio.currentTime = e.target.value; };
// ---- cross-device sync (progress is kept by server.py; newest timestamp wins) ----
let pending = {}, pushTimer;
function setProg(id, v) { store.set('prog:' + id, v); pending[id] = v; clearTimeout(pushTimer); pushTimer = setTimeout(push, 1500); }
async function push(keepalive) {
if (!Object.keys(pending).length) return;
const body = JSON.stringify(pending); pending = {};
try { const r = await fetch('/sync/progress', { method: 'POST', body, keepalive: !!keepalive }); if (!r.ok) throw 0; }
catch { pending = { ...JSON.parse(body), ...pending }; }
}
async function pull() {
try {
const remote = await get('/sync/progress');
for (const [id, v] of Object.entries(remote)) {
const mine = store.get('prog:' + id);
if (!mine || v.t > mine.t) store.set('prog:' + id, v);
else if (mine.t > v.t) pending[id] = mine; // local is newer: send it up
}
if (current) { const r = remote[bookId(current)]; if (r && r.t > lastSave + 1 && audio.paused && Math.abs(r.pos - audio.currentTime) > 5) audio.currentTime = r.pos; }
push(); render();
} catch {}
}
function saveProgress() { if (current && audio.duration) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
let lastSave = 0;
audio.ontimeupdate = () => {
const d = 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';
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt((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) {
chIdx = i;
$('fchap').textContent = i >= 0 ? chapters[i].title : '';
[...$('chlist').children].forEach((el, k) => { el.classList.toggle('on', k === i); if (k === i && $('full').classList.contains('open')) el.scrollIntoView({ block: 'nearest' }); });
}
};
// speed + sleep timer
const speeds = [1, 1.25, 1.5, 1.75, 2, 2.5, 3, 0.75]; let spIdx = Math.max(0, speeds.indexOf(store.get('speed') || 1));
const showSpeed = () => { $('speed').textContent = speeds[spIdx] + '×'; audio.playbackRate = speeds[spIdx]; store.set('speed', speeds[spIdx]); };
$('speed').onclick = () => { spIdx = (spIdx + 1) % speeds.length; showSpeed(); }; showSpeed();
const sleeps = [0, 15, 30, 45, 60]; let slIdx = 0, slTimer;
$('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);
};
// full-screen player
const openFull = () => { $('full').classList.add('open'); $('full').setAttribute('aria-hidden', 'false'); history.pushState({ full: 1 }, ''); };
const closeFull = () => { $('full').classList.remove('open'); $('full').setAttribute('aria-hidden', 'true'); };
$('mini').onclick = openFull;
$('close').onclick = () => { closeFull(); if (history.state && history.state.full) history.back(); };
window.onpopstate = closeFull;
document.addEventListener('keydown', e => {
if (['INPUT', 'SELECT'].includes(e.target.tagName) && e.target.type !== 'range') return;
if (e.code === 'Space' && current) { e.preventDefault(); toggle(); }
else if (e.code === 'ArrowLeft') skip(-30); else if (e.code === 'ArrowRight') skip(30);
else if (e.code === 'Escape') closeFull();
});
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;
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(() => {});
}
};
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true }));
fetch('/me').then(r => r.json()).then(m => { $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {});
load();
</script>
</body>
</html>
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Audiobook player server: user accounts + a safe proxy to OpenAudible.
python3 server.py setup one-time: store the OpenAudible login the server uses
python3 server.py adduser NAME create an account (prompts for a password)
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 [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.
Only the server knows the OpenAudible password; it never reaches the browser.
"""
import argparse
import base64
import getpass
import hashlib
import hmac
import json
import os
import re
import secrets
import sys
import threading
import time
import urllib.error
import urllib.request
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
F_CONFIG = os.path.join(HERE, "config.json") # OpenAudible login (chmod 600)
F_USERS = os.path.join(HERE, "users.json") # name -> salted scrypt hash
F_SESSIONS = os.path.join(HERE, "sessions.json") # token hash -> {user, exp}
F_PROGRESS = os.path.join(HERE, "progress_users.json")
F_LEGACY = os.path.join(HERE, "progress.json") # single-user file from the first version
COOKIE = "oa_session"
SESSION_DAYS = 90
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
ALLOWED = [re.compile(p) for p in (
r"^/api/books$",
r"^/api/books/[^/]+$",
r"^/api/books/[^/]+/(chapters|chapters/flat|cover)$",
r"^/api/books/[^/]+/files/[^/]+$",
r"^/admin/api/books/[^/]+/files$",
)]
PASS_HEADERS = ("Range", "If-None-Match", "If-Modified-Since")
RESP_HEADERS = ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified", "Cache-Control")
lock = threading.Lock()
# ---------- tiny JSON stores ----------
def read(path, default):
try:
with open(path) as f:
return json.load(f)
except (OSError, ValueError):
return default
def write(path, data, private=False):
tmp = path + ".tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if private else 0o644)
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=1)
os.replace(tmp, path)
def hash_pw(password, salt=None):
salt = salt or secrets.token_bytes(16)
h = hashlib.scrypt(password.encode(), salt=salt, n=2 ** 14, r=8, p=1)
return {"salt": salt.hex(), "hash": h.hex()}
def check_pw(password, rec):
h = hash_pw(password, bytes.fromhex(rec["salt"]))["hash"]
return hmac.compare_digest(h, rec["hash"])
def sha(token):
return hashlib.sha256(token.encode()).hexdigest()
# ---------- command-line account management ----------
def ask_password(confirm=True):
while True:
p = getpass.getpass("Password: ")
if len(p) < 6:
print("Use at least 6 characters.")
elif confirm and getpass.getpass("Again: ") != p:
print("Those didn't match.")
else:
return p
def cli(argv):
cmd = argv[0]
users = read(F_USERS, {})
if cmd == "setup":
user = input("OpenAudible username: ")
pw = getpass.getpass("OpenAudible password: ")
write(F_CONFIG, {"user": user, "password": pw}, private=True)
print("Saved to config.json (readable only by you). All player accounts share this login.")
elif cmd == "adduser":
name = argv[1].lower()
if name in users:
sys.exit("That user already exists (use passwd).")
users[name] = hash_pw(ask_password())
write(F_USERS, users, private=True)
if len(users) == 1 and os.path.exists(F_LEGACY): # carry over progress from before accounts
legacy = read(F_LEGACY, {})
if legacy and all(isinstance(v, dict) and "pos" in v for v in legacy.values()):
write(F_PROGRESS, {name: legacy})
print(f"Imported your earlier listening progress into '{name}'.")
print(f"Created '{name}'.")
elif cmd == "passwd":
name = argv[1].lower()
if name not in users:
sys.exit("No such user.")
users[name] = hash_pw(ask_password())
write(F_USERS, users, private=True)
write(F_SESSIONS, {k: v for k, v in read(F_SESSIONS, {}).items() if v["user"] != name}, private=True)
print("Password changed; that user was signed out of all devices.")
elif cmd == "deluser":
name = argv[1].lower()
users.pop(name, None)
write(F_USERS, users, private=True)
write(F_SESSIONS, {k: v for k, v in read(F_SESSIONS, {}).items() if v["user"] != name}, private=True)
print(f"Removed '{name}'.")
elif cmd == "users":
print("\n".join(sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
# ---------- 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"><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)}}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px -apple-system,system-ui,sans-serif;padding:20px}
form{width:100%;max-width:360px;background:var(--s);border:1px solid var(--b);border-radius:20px;padding:28px;box-shadow:0 10px 40px rgba(0,0,0,.15)}
h1{margin:0 0 4px;font-size:24px;letter-spacing:-.02em}p{margin:0 0 20px;color:var(--m)}
input{width:100%;padding:14px;margin-bottom:12px;border-radius:12px;border:1px solid var(--b);background:var(--bg);color:var(--fg);font-size:16px;outline:0}
input:focus{border-color:var(--a)}
button{width:100%;padding:14px;border:0;border-radius:12px;background:var(--a);color:#fff;font-size:16px;font-weight:700}
#e{color:#e03131;min-height:22px;font-size:14px;margin-bottom:6px}
</style>
<form id=f><h1>Audiobooks</h1><p>Sign in to listen.</p>
<input name=u placeholder=Username autocomplete=username autocapitalize=none autocorrect=off required autofocus>
<input name=p type=password placeholder=Password autocomplete=current-password required>
<div id=e></div><button>Sign in</button></form>
<script>
f.onsubmit=async e=>{e.preventDefault();const E=document.getElementById('e');E.textContent='';
const r=await fetch('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:f.u.value,password:f.p.value})});
if(r.ok)location.href='/';else E.textContent=r.status==429?'Too many attempts. Wait a minute.':'Wrong username or password.'}
</script></html>"""
class Handler(BaseHTTPRequestHandler):
upstream = ""
fails = {} # ip -> [timestamps] for simple login throttling
# ----- helpers -----
def send_body(self, code, body, ctype, extra=()):
if isinstance(body, str):
body = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for k, v in extra:
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def json(self, code, obj, extra=()):
self.send_body(code, json.dumps(obj), "application/json", extra)
def user(self):
c = SimpleCookie(self.headers.get("Cookie", ""))
if COOKIE not in c:
return None
s = read(F_SESSIONS, {}).get(sha(c[COOKIE].value))
return s["user"] if s and s["exp"] > time.time() else None
def deny(self, path):
if self.command == "GET" and path in ("/", "/index.html"):
self.send_response(302)
self.send_header("Location", "/login")
self.end_headers()
else:
self.json(401, {"error": "not signed in"})
# ----- routes -----
def do_GET(self):
path = self.path.split("?")[0]
if path == "/login":
return self.send_body(200, LOGIN, "text/html; charset=utf-8")
if path == "/logout":
return self.logout()
user = self.user()
if not user:
return self.deny(path)
if path in ("/", "/index.html"):
with open(os.path.join(HERE, "index.html"), "rb") as f:
return self.send_body(200, f.read(), "text/html; charset=utf-8", [("Cache-Control", "no-cache")])
if path == "/me":
return self.json(200, {"user": user})
if path == "/sync/progress":
return self.json(200, read(F_PROGRESS, {}).get(user, {}))
if any(p.match(path) for p in ALLOWED):
return self.proxy()
self.send_error(404)
def do_POST(self):
path = self.path.split("?")[0]
n = int(self.headers.get("Content-Length", 0) or 0)
if n > 1_000_000:
return self.send_error(413)
try:
body = json.loads(self.rfile.read(n) or b"{}")
except ValueError:
return self.json(400, {"error": "bad json"})
if path == "/login":
return self.login(body)
user = self.user()
if not user:
return self.deny(path)
if path == "/sync/progress":
with lock:
allp = read(F_PROGRESS, {})
mine = allp.setdefault(user, {})
for k, v in body.items(): # newest timestamp wins
if isinstance(v, dict) and v.get("t", 0) >= mine.get(k, {}).get("t", 0):
mine[k] = {"pos": v.get("pos", 0), "dur": v.get("dur", 0), "t": v.get("t", 0)}
write(F_PROGRESS, allp)
return self.json(200, mine)
self.send_error(404)
def login(self, body):
ip = self.client_address[0]
now = time.time()
recent = [t for t in self.fails.get(ip, []) if now - t < 60]
if len(recent) >= 8:
return self.json(429, {"error": "slow down"})
name = str(body.get("username", "")).strip().lower()
rec = read(F_USERS, {}).get(name)
ok = bool(rec) and check_pw(str(body.get("password", "")), rec)
if not ok:
self.fails[ip] = recent + [now]
return self.json(401, {"error": "wrong"})
token = secrets.token_urlsafe(32)
with lock:
sessions = {k: v for k, v in read(F_SESSIONS, {}).items() if v["exp"] > now}
sessions[sha(token)] = {"user": name, "exp": now + SESSION_DAYS * 86400}
write(F_SESSIONS, sessions, private=True)
cookie = f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_DAYS * 86400}"
self.json(200, {"user": name}, [("Set-Cookie", cookie)])
def logout(self):
c = SimpleCookie(self.headers.get("Cookie", ""))
if COOKIE in c:
with lock:
s = read(F_SESSIONS, {})
s.pop(sha(c[COOKIE].value), None)
write(F_SESSIONS, s, private=True)
self.send_response(302)
self.send_header("Set-Cookie", f"{COOKIE}=; Path=/; Max-Age=0")
self.send_header("Location", "/login")
self.end_headers()
def proxy(self):
cfg = read(F_CONFIG, {})
req = urllib.request.Request(self.upstream + self.path)
if cfg.get("user"):
cred = base64.b64encode(f"{cfg['user']}:{cfg.get('password', '')}".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}"})
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"})
self.send_response(code)
for h in RESP_HEADERS:
if resp.headers.get(h):
self.send_header(h, resp.headers[h])
self.end_headers()
try:
while chunk := resp.read(64 * 1024):
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
pass # player seeked or closed the stream
def log_message(self, *a):
pass
def serve(argv):
ap = argparse.ArgumentParser()
ap.add_argument("--upstream", default="http://172.16.16.106:8000")
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--host", default="0.0.0.0")
a = ap.parse_args(argv)
if not read(F_USERS, {}):
sys.exit("No accounts yet. Run: python3 server.py adduser YOURNAME")
if not read(F_CONFIG, {}).get("user"):
print("Note: no OpenAudible login saved. Run `python3 server.py setup` first.")
Handler.upstream = a.upstream.rstrip("/")
print(f"Player: http://localhost:{a.port} (OpenAudible at {Handler.upstream})")
ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()
if __name__ == "__main__":
args = sys.argv[1:]
if args and args[0] in ("setup", "adduser", "passwd", "deluser", "users"):
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] in ("-h", "--help"):
print(__doc__)
else:
serve(args[1:] if args and args[0] == "serve" else args)