Audiobook player with user accounts and synced progress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8341c25846
4 files changed
+756
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# secrets and per-user data: never commit
|
||||
config.json
|
||||
users.json
|
||||
sessions.json
|
||||
progress.json
|
||||
progress_users.json
|
||||
*.tmp
|
||||
__pycache__/
|
||||
@@ -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
@@ -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>
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user