New headphones app icon set, hardened streaming and playback, download removal controls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -7,3 +7,4 @@ sessions.json
|
|||||||
progress*.json
|
progress*.json
|
||||||
*.tmp
|
*.tmp
|
||||||
README.md
|
README.md
|
||||||
|
tools
|
||||||
@@ -3,7 +3,7 @@ FROM python:3.13-slim
|
|||||||
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
|
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
|
||||||
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
|
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-180.png icon-192.png icon-512.png ./
|
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-*.png ./
|
||||||
USER app
|
USER app
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -44,3 +44,23 @@ Tap the ↓ on a book (or "Download" in the player) to save it on the device. Do
|
|||||||
- **To open the app itself offline, serve it over HTTPS.** Browsers only allow the service worker (`sw.js`) on secure
|
- **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
|
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.
|
still play but the page can't load without a connection.
|
||||||
|
|
||||||
|
## Troubleshooting playback
|
||||||
|
|
||||||
|
Run this on the player server (or `docker exec -it audiobook-player python server.py check`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 server.py check
|
||||||
|
```
|
||||||
|
|
||||||
|
It logs in to OpenAudible, lists a few books' files, and tests whether each file can stream to a phone. iPhones will
|
||||||
|
only play audio from a server that answers byte-range requests; if OpenAudible doesn't, the player server emulates
|
||||||
|
them (seeking in very large books is slower). The container's log (`docker logs audiobook-player`) notes when that happens
|
||||||
|
and records any upstream errors.
|
||||||
|
|
||||||
|
The player also recovers by itself from dropped connections and stalls (it reloads from the same spot, up to four times),
|
||||||
|
and your position is saved every few seconds, on pause, and when the page is closed or backgrounded.
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
`python3 tools/make_icons.py` regenerates the app icons (needs Pillow).
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -11,6 +11,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="apple-touch-icon" href="/icon-180.png">
|
<link rel="apple-touch-icon" href="/icon-180.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
||||||
<title>Audiobook Player</title>
|
<title>Audiobook Player</title>
|
||||||
<style>
|
<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)}
|
:root{--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--acc2:#ff8a3d;--bd:rgba(0,0,0,.08);--sh:0 8px 30px rgba(0,0,0,.12)}
|
||||||
@@ -58,8 +59,11 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
|
|||||||
.card b{display:block;margin-top:8px;font-size:13.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
.card 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}
|
.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{position:absolute;right:8px;bottom:12px;min-width:30px;height:30px;padding:0 8px;border-radius:99px;background:rgba(0,0,0,.62);color:#fff;font-size:13px;font-weight:700;display:grid;place-items:center;backdrop-filter:blur(6px);cursor:pointer}
|
||||||
|
.cov .dlb.rm{background:rgba(0,0,0,.72);font-size:12px;padding:0 12px}
|
||||||
.cov .dlb.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0}
|
.cov .dlb.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0}
|
||||||
|
#dlbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
|
||||||
#offline{margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
|
#offline{margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
|
||||||
|
.pp.buf{animation:pulse 1s infinite}
|
||||||
.skel .cov{animation:pulse 1.2s infinite}
|
.skel .cov{animation:pulse 1.2s infinite}
|
||||||
@keyframes pulse{50%{opacity:.5}}
|
@keyframes pulse{50%{opacity:.5}}
|
||||||
#msg{padding:60px 0;text-align:center;color:var(--mut)}
|
#msg{padding:60px 0;text-align:center;color:var(--mut)}
|
||||||
@@ -138,6 +142,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
|
|||||||
<div id="offline" class="hide">You're offline or OpenAudible isn't reachable. Showing your downloaded books.</div>
|
<div id="offline" class="hide">You're offline or OpenAudible isn't reachable. Showing your downloaded books.</div>
|
||||||
<div id="msg" class="hide"></div>
|
<div id="msg" class="hide"></div>
|
||||||
<section id="contSec" class="hide"><h2>Continue listening</h2><div id="cont" class="shelf"></div></section>
|
<section id="contSec" class="hide"><h2>Continue listening</h2><div id="cont" class="shelf"></div></section>
|
||||||
|
<div id="dlbar" class="hide"><span id="dlinfo"></span><button class="pill" id="rmall" style="padding:7px 14px">Remove all</button></div>
|
||||||
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
|
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -223,12 +228,13 @@ async function initOffline() {
|
|||||||
try { for (const m of await OFF.list()) { downloaded.set(m.id, m); setCoverUrl(m); } } catch {}
|
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 mb = n => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : Math.round(n / 1e6) + ' MB';
|
||||||
const dlLabel = id => { const st = dlState.get(id); return st ? (st.pct ? Math.round(st.pct * 100) + '%' : st.got ? mb(st.got) : '…') : downloaded.has(id) ? '✓' : '↓'; };
|
const inDlView = () => $('sort').value === 'dl';
|
||||||
|
const dlLabel = id => { const st = dlState.get(id); return st ? (st.pct ? Math.round(st.pct * 100) + '%' : st.got ? mb(st.got) : '…') : downloaded.has(id) ? (inDlView() ? '✕ Remove' : '✓') : '↓'; };
|
||||||
function uiDl() {
|
function uiDl() {
|
||||||
document.querySelectorAll('.dlb').forEach(el => { const id = el.dataset.id; el.textContent = dlLabel(id); el.classList.toggle('busy', dlState.has(id)); el.classList.toggle('done', !dlState.has(id) && downloaded.has(id)); });
|
document.querySelectorAll('.dlb').forEach(el => { const id = el.dataset.id; el.textContent = dlLabel(id); el.classList.toggle('busy', dlState.has(id)); el.classList.toggle('done', !dlState.has(id) && downloaded.has(id)); el.classList.toggle('rm', !dlState.has(id) && downloaded.has(id) && inDlView()); });
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const id = bookId(current), st = dlState.get(id);
|
const id = bookId(current), st = dlState.get(id);
|
||||||
$('dl').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded' : '↓ Download';
|
$('dl').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded · tap to remove' : '↓ Download';
|
||||||
$('dl').classList.toggle('on', !!st || downloaded.has(id));
|
$('dl').classList.toggle('on', !!st || downloaded.has(id));
|
||||||
}
|
}
|
||||||
async function download(b) {
|
async function download(b) {
|
||||||
@@ -269,11 +275,24 @@ async function download(b) {
|
|||||||
} finally { dlState.delete(id); uiDl(); render(); }
|
} finally { dlState.delete(id); uiDl(); render(); }
|
||||||
}
|
}
|
||||||
async function removeDl(id) {
|
async function removeDl(id) {
|
||||||
|
if (current && bookId(current) === id && curObjUrl) userPause(); // it's playing from the copy we're about to delete
|
||||||
await OFF.remove(id).catch(() => {});
|
await OFF.remove(id).catch(() => {});
|
||||||
downloaded.delete(id);
|
downloaded.delete(id);
|
||||||
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
|
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
|
||||||
uiDl(); render(); toast('Removed download');
|
uiDl(); render(); toast('Removed download');
|
||||||
}
|
}
|
||||||
|
async function removeAll() {
|
||||||
|
if (!downloaded.size) return;
|
||||||
|
if (!confirm(`Remove all ${downloaded.size} downloaded books from this device? They stay in your library and can be downloaded again.`)) return;
|
||||||
|
if (current && curObjUrl) userPause();
|
||||||
|
for (const id of [...downloaded.keys()]) {
|
||||||
|
await OFF.remove(id).catch(() => {});
|
||||||
|
downloaded.delete(id);
|
||||||
|
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
|
||||||
|
}
|
||||||
|
uiDl(); render(); toast('Removed all downloads');
|
||||||
|
}
|
||||||
|
$('rmall').onclick = removeAll;
|
||||||
function toggleDl(b) {
|
function toggleDl(b) {
|
||||||
const id = bookId(b), st = dlState.get(id);
|
const id = bookId(b), st = dlState.get(id);
|
||||||
if (st) return st.ctl.abort();
|
if (st) return st.ctl.abort();
|
||||||
@@ -320,7 +339,7 @@ function card(b) {
|
|||||||
d.querySelector('b').textContent = title(b);
|
d.querySelector('b').textContent = title(b);
|
||||||
d.querySelector('span').textContent = author(b);
|
d.querySelector('span').textContent = author(b);
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' : ''); badge.dataset.id = id; badge.textContent = dlLabel(id);
|
badge.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' + (inDlView() ? ' rm' : '') : ''); badge.dataset.id = id; badge.textContent = dlLabel(id);
|
||||||
badge.setAttribute('role', 'button'); badge.setAttribute('aria-label', 'Download for offline listening');
|
badge.setAttribute('role', 'button'); badge.setAttribute('aria-label', 'Download for offline listening');
|
||||||
badge.onclick = e => { e.stopPropagation(); toggleDl(b); };
|
badge.onclick = e => { e.stopPropagation(); toggleDl(b); };
|
||||||
d.querySelector('.cov').append(badge);
|
d.querySelector('.cov').append(badge);
|
||||||
@@ -359,8 +378,15 @@ function render() {
|
|||||||
$('grid').className = 'grid';
|
$('grid').className = 'grid';
|
||||||
$('grid').replaceChildren(...list.map(card));
|
$('grid').replaceChildren(...list.map(card));
|
||||||
}
|
}
|
||||||
|
const showBar = sort === 'dl' && downloaded.size > 0;
|
||||||
|
$('dlbar').classList.toggle('hide', !showBar);
|
||||||
|
if (showBar) {
|
||||||
|
const used = [...downloaded.values()].reduce((a, m) => a + (m.size || 0), 0);
|
||||||
|
$('dlinfo').textContent = `${downloaded.size} downloaded · ${mb(used)} on this device`;
|
||||||
|
try { navigator.storage.estimate().then(e => { if (e.quota && sort === $('sort').value) $('dlinfo').textContent += ` · room for about ${mb(Math.max(0, e.quota - e.usage))} more`; }); } catch {}
|
||||||
|
}
|
||||||
if (!list.length && books.length) { $('msg').textContent = sort === 'dl' ? 'Nothing downloaded yet. Tap the ↓ on a book to save it.' : 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
|
if (!list.length && books.length) { $('msg').textContent = sort === 'dl' ? 'Nothing downloaded yet. Tap the ↓ on a book to save it.' : 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
|
||||||
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; })
|
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return (sort !== 'dl' || downloaded.has(bookId(b))) && p && p.pos > 10 && p.dur && p.pos < p.dur - 30; })
|
||||||
.sort((a, b) => last(b) - last(a)).slice(0, 12);
|
.sort((a, b) => last(b) - last(a)).slice(0, 12);
|
||||||
$('contSec').classList.toggle('hide', !cont.length || !!q);
|
$('contSec').classList.toggle('hide', !cont.length || !!q);
|
||||||
$('cont').replaceChildren(...cont.map(card));
|
$('cont').replaceChildren(...cont.map(card));
|
||||||
@@ -375,9 +401,12 @@ async function findFile(id) {
|
|||||||
const names = arr.map(f => typeof f === 'string' ? f : pick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
|
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));
|
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => PLAYABLE.test(n));
|
||||||
}
|
}
|
||||||
|
let playTok = 0, playIntent = false, retries = 0, curSrc = '', stallT, loadT, recoverT, needOnline = false;
|
||||||
|
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
const setBuf = on => { $('play').classList.toggle('buf', on); $('mplay').classList.toggle('buf', on); };
|
||||||
async function play(b, open) {
|
async function play(b, open) {
|
||||||
if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; }
|
if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; }
|
||||||
const id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
|
const tok = ++playTok, id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
|
||||||
let src;
|
let src;
|
||||||
if (off) {
|
if (off) {
|
||||||
try {
|
try {
|
||||||
@@ -391,6 +420,10 @@ async function play(b, open) {
|
|||||||
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
|
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
|
||||||
src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
|
src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
|
||||||
}
|
}
|
||||||
|
if (tok !== playTok) { if (off) URL.revokeObjectURL(src); return; } // a newer tap took over
|
||||||
|
await Promise.race([pull(), wait(2500)]); // newest position from other devices, but never wait long
|
||||||
|
await unlockP;
|
||||||
|
if (tok !== playTok) { if (off) URL.revokeObjectURL(src); return; }
|
||||||
saveProgress();
|
saveProgress();
|
||||||
if (curObjUrl) URL.revokeObjectURL(curObjUrl);
|
if (curObjUrl) URL.revokeObjectURL(curObjUrl);
|
||||||
curObjUrl = off ? src : null;
|
curObjUrl = off ? src : null;
|
||||||
@@ -401,12 +434,10 @@ async function play(b, open) {
|
|||||||
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
|
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
|
||||||
$('mtitle').textContent = $('ftitle').textContent = title(b);
|
$('mtitle').textContent = $('ftitle').textContent = title(b);
|
||||||
$('msub').textContent = $('fsub').textContent = author(b);
|
$('msub').textContent = $('fsub').textContent = author(b);
|
||||||
await unlockP;
|
playIntent = true; retries = 0; needOnline = false;
|
||||||
audio.src = src;
|
clearTimeout(stallT); clearTimeout(recoverT); setBuf(true);
|
||||||
|
startSource(src, (store.get('prog:' + bookId(b)) || {}).pos);
|
||||||
uiDl();
|
uiDl();
|
||||||
await pull();
|
|
||||||
const saved = (store.get('prog:' + bookId(b)) || {}).pos;
|
|
||||||
audio.addEventListener('loadedmetadata', () => { if (saved && saved < audio.duration - 30) audio.currentTime = saved; audio.playbackRate = speeds[spIdx]; audio.play().catch(() => {}); }, { once: true });
|
|
||||||
loadChapters(id, off);
|
loadChapters(id, off);
|
||||||
render();
|
render();
|
||||||
if (open) openFull();
|
if (open) openFull();
|
||||||
@@ -415,9 +446,39 @@ async function play(b, open) {
|
|||||||
ms.metadata = new MediaMetadata({ title: title(b), artist: author(b), artwork: [{ src: cover(b) }] });
|
ms.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('seekbackward', () => skip(-30)); ms.setActionHandler('seekforward', () => skip(30));
|
||||||
ms.setActionHandler('seekto', e => { audio.currentTime = e.seekTime; });
|
ms.setActionHandler('seekto', e => { audio.currentTime = e.seekTime; });
|
||||||
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
|
ms.setActionHandler('play', userPlay); ms.setActionHandler('pause', userPause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The listener is attached BEFORE the source is set, so a fast source can't fire loadedmetadata unseen.
|
||||||
|
function startSource(src, at, bust) {
|
||||||
|
clearTimeout(loadT);
|
||||||
|
curSrc = src;
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
clearTimeout(loadT);
|
||||||
|
const d = audio.duration;
|
||||||
|
if (at && isFinite(d) && at < d - 30) audio.currentTime = at;
|
||||||
|
audio.playbackRate = speeds[spIdx];
|
||||||
|
if (playIntent) audio.play().catch(e => { setBuf(false); if (e && e.name === 'NotAllowedError') { playIntent = false; toast('Tap ▶ to start'); } });
|
||||||
|
}, { once: true });
|
||||||
|
// browsers remember a failed address, so a retry asks for a fresh one (the server strips the extra parameter)
|
||||||
|
audio.src = bust && !src.startsWith('blob:') ? src + (src.includes('?') ? '&' : '?') + '_r=' + Date.now() : src;
|
||||||
|
audio.load();
|
||||||
|
loadT = setTimeout(() => { if (audio.readyState < 1) recover('no response from the server'); }, 20000);
|
||||||
|
}
|
||||||
|
// Something went wrong (network error, stall, timeout): reload the same source and carry on from where we were.
|
||||||
|
function recover(why) {
|
||||||
|
clearTimeout(stallT); clearTimeout(loadT);
|
||||||
|
if (!current) return;
|
||||||
|
if (!navigator.onLine && !downloaded.has(bookId(current))) {
|
||||||
|
needOnline = true; setBuf(true); toast('Offline. Playback resumes when the connection is back.'); return;
|
||||||
|
}
|
||||||
|
if (retries >= 4) { setBuf(false); playIntent = false; toast(`Playback failed (${why}). Tap ▶ to try again.`); return; }
|
||||||
|
retries++;
|
||||||
|
const at = audio.currentTime > 1 ? audio.currentTime : (store.get('prog:' + bookId(current)) || {}).pos || 0;
|
||||||
|
setBuf(true);
|
||||||
|
clearTimeout(recoverT);
|
||||||
|
recoverT = setTimeout(() => startSource(curSrc, at, true), Math.min(500 * 2 ** (retries - 1), 4000));
|
||||||
|
}
|
||||||
async function loadChapters(id, off) {
|
async function loadChapters(id, off) {
|
||||||
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
|
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
|
||||||
try {
|
try {
|
||||||
@@ -443,18 +504,44 @@ function jump(d) {
|
|||||||
if (d < 0 && audio.currentTime - chapters[Math.max(chIdx, 0)].start > 4) audio.currentTime = chapters[Math.max(chIdx, 0)].start;
|
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;
|
else audio.currentTime = chapters[Math.max(0, Math.min(chapters.length - 1, chIdx + d))].start;
|
||||||
}
|
}
|
||||||
const toggle = () => audio.paused ? audio.play() : audio.pause();
|
function userPlay() {
|
||||||
|
if (!current) return;
|
||||||
|
playIntent = true; needOnline = false;
|
||||||
|
if (audio.error || audio.networkState === 3 || audio.readyState === 0) { // errored out earlier: start over from here
|
||||||
|
retries = 0; setBuf(true);
|
||||||
|
return startSource(curSrc, audio.currentTime > 1 ? audio.currentTime : (store.get('prog:' + bookId(current)) || {}).pos, true);
|
||||||
|
}
|
||||||
|
audio.play().catch(e => { setBuf(false); if (e && e.name !== 'AbortError') toast('Could not start playback: ' + e.message); });
|
||||||
|
}
|
||||||
|
function userPause() { playIntent = false; audio.pause(); }
|
||||||
|
const toggle = () => audio.paused ? userPlay() : userPause();
|
||||||
for (const id of ['play', 'mplay']) $(id).onclick = e => { e.stopPropagation(); toggle(); };
|
for (const id of ['play', 'mplay']) $(id).onclick = e => { e.stopPropagation(); toggle(); };
|
||||||
for (const id of ['back', 'mback']) $(id).onclick = e => { e.stopPropagation(); skip(-30); };
|
for (const id of ['back', 'mback']) $(id).onclick = e => { e.stopPropagation(); skip(-30); };
|
||||||
for (const id of ['fwd', 'mfwd']) $(id).onclick = e => { e.stopPropagation(); skip(30); }; $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1);
|
for (const id of ['fwd', 'mfwd']) $(id).onclick = e => { e.stopPropagation(); skip(30); }; $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1);
|
||||||
|
const setSession = st => { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = st; };
|
||||||
audio.onplay = () => { ico('play', 'pause'); ico('mplay', 'pause'); };
|
audio.onplay = () => { ico('play', 'pause'); ico('mplay', 'pause'); };
|
||||||
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play'); saveProgress(); };
|
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play');
|
||||||
audio.onended = () => { if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
|
// iOS: play even with the silent switch on, and keep audio going in the background
|
||||||
|
try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} setSession('paused'); saveProgress(); };
|
||||||
|
audio.addEventListener('playing', () => {
|
||||||
|
clearTimeout(stallT); clearTimeout(loadT); retries = 0; needOnline = false; setBuf(false);
|
||||||
|
audio.playbackRate = speeds[spIdx]; // some browsers reset the speed on a new source
|
||||||
|
setSession('playing');
|
||||||
|
});
|
||||||
|
audio.addEventListener('waiting', () => { // out of buffered data: if it doesn't recover on its own, reload from here
|
||||||
|
if (!playIntent) return;
|
||||||
|
setBuf(true); clearTimeout(stallT); stallT = setTimeout(() => recover('stalled'), 12000);
|
||||||
|
});
|
||||||
|
audio.addEventListener('canplay', () => { if (audio.paused) setBuf(false); });
|
||||||
|
audio.onended = () => { playIntent = false; setBuf(false); if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
const c = audio.error && audio.error.code, why = { 1: 'aborted', 2: 'network error', 3: 'could not decode', 4: 'format or stream not supported' }[c] || 'unknown';
|
if (!current) return;
|
||||||
|
const c = audio.error && audio.error.code;
|
||||||
|
if (c === 1) return; // aborted: we changed the source ourselves
|
||||||
console.error('audio error', c, audio.error && audio.error.message, audio.src);
|
console.error('audio error', c, audio.error && audio.error.message, audio.src);
|
||||||
toast(`Playback failed: ${why}${current ? ' · ' + (audio.src.split('.').pop().slice(0, 5)) : ''}`);
|
recover({ 2: 'network error', 3: 'could not decode', 4: 'format or stream not supported' }[c] || 'unknown error');
|
||||||
};
|
};
|
||||||
|
window.addEventListener('online', () => { pull(); push(); if (needOnline) { needOnline = false; retries = 0; recover('back online'); } });
|
||||||
$('seek').oninput = e => { audio.currentTime = e.target.value; };
|
$('seek').oninput = e => { audio.currentTime = e.target.value; };
|
||||||
|
|
||||||
// ---- cross-device sync (progress is kept by server.py; newest timestamp wins) ----
|
// ---- cross-device sync (progress is kept by server.py; newest timestamp wins) ----
|
||||||
@@ -478,14 +565,14 @@ async function pull() {
|
|||||||
push(); render();
|
push(); render();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
function saveProgress() { if (current && audio.duration) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
|
function saveProgress() { if (current && isFinite(audio.duration) && audio.duration > 0) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
|
||||||
let lastSave = 0, lastPos = 0;
|
let lastSave = 0, lastPos = 0;
|
||||||
audio.ontimeupdate = () => {
|
audio.ontimeupdate = () => {
|
||||||
const d = audio.duration || 0, t = audio.currentTime;
|
const d = isFinite(audio.duration) ? audio.duration : 0, t = audio.currentTime;
|
||||||
$('seek').max = d || 1; $('seek').value = t; $('seek').style.setProperty('--p', d ? t / d * 100 + '%' : '0%');
|
$('seek').max = d || 1; $('seek').value = t; $('seek').style.setProperty('--p', d ? t / d * 100 + '%' : '0%');
|
||||||
$('mbar').style.width = d ? t / d * 100 + '%' : '0';
|
$('mbar').style.width = d ? t / d * 100 + '%' : '0';
|
||||||
if (d && 'mediaSession' in navigator && navigator.mediaSession.setPositionState && Date.now() - lastPos > 1000) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(t, d), playbackRate: audio.playbackRate }); } catch {} }
|
if (d && 'mediaSession' in navigator && navigator.mediaSession.setPositionState && Date.now() - lastPos > 1000) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(t, d), playbackRate: audio.playbackRate }); } catch {} }
|
||||||
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt((d - t) / audio.playbackRate);
|
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt(Math.max(0, d - t) / audio.playbackRate);
|
||||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveProgress(); }
|
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveProgress(); }
|
||||||
let i = -1; chapters.forEach((c, k) => { if (t >= c.start) i = k; });
|
let i = -1; chapters.forEach((c, k) => { if (t >= c.start) i = k; });
|
||||||
if (i !== chIdx) {
|
if (i !== chIdx) {
|
||||||
@@ -504,7 +591,7 @@ $('sleep').onclick = () => {
|
|||||||
slIdx = (slIdx + 1) % sleeps.length; clearTimeout(slTimer);
|
slIdx = (slIdx + 1) % sleeps.length; clearTimeout(slTimer);
|
||||||
const m = sleeps[slIdx];
|
const m = sleeps[slIdx];
|
||||||
$('sleep').textContent = m ? `Sleep: ${m} min` : 'Sleep: off'; $('sleep').classList.toggle('on', !!m);
|
$('sleep').textContent = m ? `Sleep: ${m} min` : 'Sleep: off'; $('sleep').classList.toggle('on', !!m);
|
||||||
if (m) slTimer = setTimeout(() => { audio.pause(); slIdx = 0; $('sleep').textContent = 'Sleep: off'; $('sleep').classList.remove('on'); toast('Sleep timer: paused'); }, m * 60000);
|
if (m) slTimer = setTimeout(() => { userPause(); slIdx = 0; $('sleep').textContent = 'Sleep: off'; $('sleep').classList.remove('on'); toast('Sleep timer: paused'); }, m * 60000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// full-screen player
|
// full-screen player
|
||||||
@@ -520,6 +607,7 @@ document.addEventListener('keydown', e => {
|
|||||||
else if (e.code === 'Escape') closeFull();
|
else if (e.code === 'Escape') closeFull();
|
||||||
});
|
});
|
||||||
window.addEventListener('beforeunload', () => { saveProgress(); push(true); });
|
window.addEventListener('beforeunload', () => { saveProgress(); push(true); });
|
||||||
|
window.addEventListener('pagehide', () => { saveProgress(); push(true); }); // iOS fires this more reliably than beforeunload
|
||||||
document.addEventListener('visibilitychange', () => { if (document.hidden) { saveProgress(); push(true); } else if (audio.paused) pull(); });
|
document.addEventListener('visibilitychange', () => { if (document.hidden) { saveProgress(); push(true); } else if (audio.paused) pull(); });
|
||||||
ico('play', 'play'); ico('mplay', 'play');
|
ico('play', 'play'); ico('mplay', 'play');
|
||||||
// iOS only lets audio start from a tap. Unlock the element on the first touch (before any click handler runs)
|
// iOS only lets audio start from a tap. Unlock the element on the first touch (before any click handler runs)
|
||||||
|
|||||||
@@ -9,7 +9,23 @@
|
|||||||
"background_color": "#0f0f12",
|
"background_color": "#0f0f12",
|
||||||
"theme_color": "#0f0f12",
|
"theme_color": "#0f0f12",
|
||||||
"icons": [
|
"icons": [
|
||||||
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
|
{
|
||||||
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"}
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
python3 server.py passwd NAME change a password
|
python3 server.py passwd NAME change a password
|
||||||
python3 server.py deluser NAME remove an account
|
python3 server.py deluser NAME remove an account
|
||||||
python3 server.py users list accounts
|
python3 server.py users list accounts
|
||||||
|
python3 server.py check test the OpenAudible connection and whether its files can stream to a phone
|
||||||
python3 server.py [serve] run (--port 8080 --upstream http://172.16.16.106:8000)
|
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.
|
Each person signs in with their own account and gets their own listening progress.
|
||||||
@@ -24,6 +25,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -40,8 +42,12 @@ COOKIE = "oa_session"
|
|||||||
SESSION_DAYS = 90
|
SESSION_DAYS = 90
|
||||||
|
|
||||||
# Files the browser fetches without a session (home-screen icon and manifest).
|
# Files the browser fetches without a session (home-screen icon and manifest).
|
||||||
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/icon-180.png": "image/png",
|
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/sw.js": "text/javascript"}
|
||||||
"/icon-192.png": "image/png", "/icon-512.png": "image/png", "/sw.js": "text/javascript"}
|
ICON = re.compile(r"^/icon-[a-z0-9]+\.png$")
|
||||||
|
AUDIO_TYPES = {".mp3": "audio/mpeg", ".m4b": "audio/mp4", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||||
|
".ogg": "audio/ogg", ".opus": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav"}
|
||||||
|
AUDIO_PATH = re.compile(r"^/api/books/[^/]+/files/([^/]+)$")
|
||||||
|
RANGE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||||
|
|
||||||
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
|
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
|
||||||
ALLOWED = [re.compile(p) for p in (
|
ALLOWED = [re.compile(p) for p in (
|
||||||
@@ -58,6 +64,10 @@ lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
# ---------- tiny JSON stores ----------
|
# ---------- tiny JSON stores ----------
|
||||||
|
def log(*a):
|
||||||
|
print(time.strftime("%H:%M:%S"), *a, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def read(path, default):
|
def read(path, default):
|
||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -154,10 +164,64 @@ def cli(argv):
|
|||||||
print("\n".join(n + (" (admin)" if users[n].get("admin") else "") for n in sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
|
print("\n".join(n + (" (admin)" if users[n].get("admin") else "") for n in sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
|
||||||
|
|
||||||
|
|
||||||
|
def check(argv):
|
||||||
|
"""Ask OpenAudible what it has and whether it can stream it to a phone."""
|
||||||
|
ap = argparse.ArgumentParser(prog="server.py check")
|
||||||
|
ap.add_argument("--upstream", default=os.environ.get("OPENAUDIBLE_URL", "http://172.16.16.106:8000"))
|
||||||
|
ap.add_argument("--books", type=int, default=3, help="how many books to test")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
base = a.upstream.rstrip("/")
|
||||||
|
user, pw = oa_login()
|
||||||
|
auth = {"Authorization": "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()} if user else {}
|
||||||
|
|
||||||
|
def fetch(path, **headers):
|
||||||
|
return urllib.request.urlopen(urllib.request.Request(base + path, headers={**auth, **headers}), timeout=30)
|
||||||
|
|
||||||
|
def step(label, fn):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit(f"{label}: HTTP {e.code} ({'wrong OpenAudible login? run: python3 server.py setup' if e.code == 401 else e.reason})")
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"{label}: {e}")
|
||||||
|
|
||||||
|
print(f"OpenAudible at {base}")
|
||||||
|
books = step("library", lambda: json.load(fetch("/api/books")))
|
||||||
|
books = books if isinstance(books, list) else list(books.values())
|
||||||
|
print(f" {len(books)} books")
|
||||||
|
problems = []
|
||||||
|
for b in books[: a.books]:
|
||||||
|
bid = b.get("id") or b.get("asin") or b.get("ASIN") or b.get("book_id")
|
||||||
|
print(f"\n{b.get('title', bid)} [{bid}]")
|
||||||
|
files = step("file list", lambda: json.load(fetch(f"/admin/api/books/{bid}/files")))
|
||||||
|
names = [f if isinstance(f, str) else (f.get("name") or f.get("filename") or f.get("file") or f.get("path") or "") for f in (files if isinstance(files, list) else files.get("files", []))]
|
||||||
|
names = [n.split("/")[-1] for n in names if n]
|
||||||
|
if not names:
|
||||||
|
print(" no files (not downloaded in OpenAudible?)")
|
||||||
|
problems.append(f"{b.get('title', bid)}: no files")
|
||||||
|
for n in names:
|
||||||
|
ext = os.path.splitext(n)[1].lower()
|
||||||
|
if ext not in AUDIO_TYPES:
|
||||||
|
print(f" {n}: not playable in a browser ({ext}); convert it to MP3/M4B in OpenAudible")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = fetch(f"/api/books/{bid}/files/{urllib.parse.quote(n)}", Range="bytes=0-1")
|
||||||
|
got = r.headers
|
||||||
|
ranged = r.status == 206 and bool(got.get("Content-Range"))
|
||||||
|
print(f" {n}: HTTP {r.status}, {got.get('Content-Type')}, Content-Range={got.get('Content-Range')}, Accept-Ranges={got.get('Accept-Ranges')}")
|
||||||
|
print(" byte ranges:", "supported" if ranged else "NOT supported by OpenAudible; the player server will emulate them (seeking is slower on big files)")
|
||||||
|
if got.get("Content-Type", "").startswith(("application/octet", "binary/")):
|
||||||
|
print(" content type is generic; the player server will correct it")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {n}: stream failed: {e}")
|
||||||
|
problems.append(f"{n}: {e}")
|
||||||
|
print("\n" + ("Problems found:\n " + "\n ".join(problems) if problems else "OK: OpenAudible is reachable and has playable files."))
|
||||||
|
|
||||||
|
|
||||||
# ---------- login page ----------
|
# ---------- login page ----------
|
||||||
LOGIN = """<!doctype html><html lang=en><meta charset=utf-8>
|
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=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><title>Sign in</title>
|
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><link rel=icon href=/icon-32.png><title>Sign in</title>
|
||||||
<style>
|
<style>
|
||||||
:root{color-scheme:light dark;--bg:#f6f6f4;--s:#fff;--fg:#16161a;--m:#74747c;--a:#e8590c;--b:rgba(0,0,0,.1)}
|
: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)}}
|
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--s:#1a1a1f;--fg:#f2f2f4;--m:#8f8f99;--a:#ff7a2f;--b:rgba(255,255,255,.1)}}
|
||||||
@@ -221,9 +285,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.send_body(200, LOGIN, "text/html; charset=utf-8")
|
return self.send_body(200, LOGIN, "text/html; charset=utf-8")
|
||||||
if path == "/logout":
|
if path == "/logout":
|
||||||
return self.logout()
|
return self.logout()
|
||||||
if path in PUBLIC:
|
if path in PUBLIC or (ICON.match(path) and os.path.exists(os.path.join(HERE, path[1:]))):
|
||||||
with open(os.path.join(HERE, path[1:]), "rb") as f:
|
with open(os.path.join(HERE, path[1:]), "rb") as f:
|
||||||
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "no-cache" if path in ("/sw.js", "/manifest.webmanifest") else "public, max-age=86400")])
|
return self.send_body(200, f.read(), PUBLIC.get(path, "image/png"), [("Cache-Control", "no-cache" if path in PUBLIC else "public, max-age=86400")])
|
||||||
user = self.user()
|
user = self.user()
|
||||||
if not user:
|
if not user:
|
||||||
return self.deny(path)
|
return self.deny(path)
|
||||||
@@ -245,6 +309,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.proxy()
|
return self.proxy()
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
if not self.user():
|
||||||
|
self.send_response(401)
|
||||||
|
return self.end_headers()
|
||||||
|
if any(p.match(path) for p in ALLOWED):
|
||||||
|
return self.proxy(head=True)
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = self.path.split("?")[0]
|
path = self.path.split("?")[0]
|
||||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
@@ -365,34 +438,97 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_header("Location", "/login")
|
self.send_header("Location", "/login")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
def proxy(self):
|
def proxy(self, head=False):
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
m = AUDIO_PATH.match(path)
|
||||||
|
name = unquote(m.group(1)) if m else ""
|
||||||
oa_user, oa_pass = oa_login()
|
oa_user, oa_pass = oa_login()
|
||||||
req = urllib.request.Request(self.upstream + self.path)
|
parts = urllib.parse.urlsplit(self.path) # drop the player's own cache-busting parameter before forwarding
|
||||||
|
q = [kv for kv in urllib.parse.parse_qsl(parts.query, keep_blank_values=True) if kv[0] != "_r"]
|
||||||
|
up = parts.path + ("?" + urllib.parse.urlencode(q) if q else "")
|
||||||
|
req = urllib.request.Request(self.upstream + up, method="HEAD" if head else "GET")
|
||||||
if oa_user:
|
if oa_user:
|
||||||
cred = base64.b64encode(f"{oa_user}:{oa_pass}".encode()).decode()
|
cred = base64.b64encode(f"{oa_user}:{oa_pass}".encode()).decode()
|
||||||
req.add_header("Authorization", "Basic " + cred)
|
req.add_header("Authorization", "Basic " + cred)
|
||||||
for h in PASS_HEADERS:
|
for h in PASS_HEADERS:
|
||||||
if self.headers.get(h):
|
if self.headers.get(h):
|
||||||
req.add_header(h, self.headers[h])
|
req.add_header(h, self.headers[h])
|
||||||
try:
|
resp = None
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
for attempt in (1, 2): # one quick retry for a flaky connection
|
||||||
except urllib.error.HTTPError as e:
|
try:
|
||||||
resp = e
|
resp = urllib.request.urlopen(req, timeout=60 if m else 30)
|
||||||
except Exception as e:
|
break
|
||||||
return self.json(502, {"error": f"can't reach OpenAudible: {e}"})
|
except urllib.error.HTTPError as e:
|
||||||
|
resp = e
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == 2:
|
||||||
|
log("upstream unreachable:", self.path, e)
|
||||||
|
return self.json(502, {"error": f"can't reach OpenAudible: {e}"})
|
||||||
|
time.sleep(0.4)
|
||||||
code = getattr(resp, "status", None) or resp.code
|
code = getattr(resp, "status", None) or resp.code
|
||||||
if code == 401: # the server-side OpenAudible login is wrong; not the listener's fault
|
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"})
|
return self.json(502, {"error": "OpenAudible login rejected; run: python3 server.py setup"})
|
||||||
|
if code >= 400 and m:
|
||||||
|
log("upstream", code, "for", self.command, path, "range:", self.headers.get("Range"))
|
||||||
|
h = resp.headers
|
||||||
|
out = [(k, h[k]) for k in RESP_HEADERS if h.get(k)]
|
||||||
|
skip, length = 0, None
|
||||||
|
if m:
|
||||||
|
# iPhones refuse to play audio unless the server answers byte-range requests.
|
||||||
|
ctype = (h.get("Content-Type") or "").lower()
|
||||||
|
if not ctype or ctype.startswith(("application/octet-stream", "binary/", "text/")):
|
||||||
|
ext = os.path.splitext(name)[1].lower()
|
||||||
|
if ext in AUDIO_TYPES:
|
||||||
|
out = [(k, v) for k, v in out if k != "Content-Type"] + [("Content-Type", AUDIO_TYPES[ext])]
|
||||||
|
if not any(k == "Accept-Ranges" for k, _ in out):
|
||||||
|
out.append(("Accept-Ranges", "bytes"))
|
||||||
|
rng = self.headers.get("Range")
|
||||||
|
cl = h.get("Content-Length")
|
||||||
|
total = int(cl) if cl and cl.isdigit() else None
|
||||||
|
if rng and code == 200 and not h.get("Content-Range") and not head:
|
||||||
|
# OpenAudible ignored the Range header: answer it ourselves by skipping ahead in the stream.
|
||||||
|
mm = RANGE.match(rng.strip())
|
||||||
|
if mm and total:
|
||||||
|
a, b = mm.groups()
|
||||||
|
if a == "" and b != "":
|
||||||
|
start, end = max(0, total - int(b)), total - 1
|
||||||
|
else:
|
||||||
|
start, end = int(a or 0), min(int(b), total - 1) if b else total - 1
|
||||||
|
if start >= total:
|
||||||
|
self.send_response(416)
|
||||||
|
self.send_header("Content-Range", f"bytes */{total}")
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
return resp.close()
|
||||||
|
code, skip, length = 206, start, end - start + 1
|
||||||
|
out = [(k, v) for k, v in out if k != "Content-Length"] + [
|
||||||
|
("Content-Range", f"bytes {start}-{end}/{total}"), ("Content-Length", str(length))]
|
||||||
|
if start:
|
||||||
|
log("emulating Range for", name, f"(from byte {start}; OpenAudible doesn't support Range)")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
for h in RESP_HEADERS:
|
for k, v in out:
|
||||||
if resp.headers.get(h):
|
self.send_header(k, v)
|
||||||
self.send_header(h, resp.headers[h])
|
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
if head:
|
||||||
|
return resp.close()
|
||||||
try:
|
try:
|
||||||
while chunk := resp.read(64 * 1024):
|
while skip > 0:
|
||||||
self.wfile.write(chunk)
|
d = resp.read(min(65536, skip))
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
if not d:
|
||||||
pass # player seeked or closed the stream
|
return
|
||||||
|
skip -= len(d)
|
||||||
|
while length is None or length > 0:
|
||||||
|
d = resp.read(65536 if length is None else min(65536, length))
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
self.wfile.write(d)
|
||||||
|
if length is not None:
|
||||||
|
length -= len(d)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError, OSError):
|
||||||
|
pass # player seeked, closed the stream, or the connection dropped
|
||||||
|
finally:
|
||||||
|
resp.close()
|
||||||
|
|
||||||
def log_message(self, *a):
|
def log_message(self, *a):
|
||||||
pass
|
pass
|
||||||
@@ -437,6 +573,8 @@ if __name__ == "__main__":
|
|||||||
if args[0] in ("adduser", "passwd", "deluser") and len(args) < 2:
|
if args[0] in ("adduser", "passwd", "deluser") and len(args) < 2:
|
||||||
sys.exit(f"Usage: python3 server.py {args[0]} NAME")
|
sys.exit(f"Usage: python3 server.py {args[0]} NAME")
|
||||||
cli(args)
|
cli(args)
|
||||||
|
elif args and args[0] == "check":
|
||||||
|
check(args[1:])
|
||||||
elif args and args[0] in ("-h", "--help"):
|
elif args and args[0] in ("-h", "--help"):
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Keeps the app shell so the player opens with no connection. Audio is stored separately by the page (IndexedDB).
|
// 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).
|
// Service workers only run on HTTPS (or localhost).
|
||||||
const CACHE = 'shell-v1';
|
const CACHE = 'shell-v2';
|
||||||
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png'];
|
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png', '/icon-32.png'];
|
||||||
|
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting()));
|
e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting()));
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Draws the app icons (headphones on an orange gradient). Run from the repo root: python3 tools/make_icons.py"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
|
||||||
|
S = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def gradient():
|
||||||
|
# diagonal orange gradient with a soft highlight, built small and scaled up for smoothness
|
||||||
|
n = 256
|
||||||
|
img = Image.new("RGB", (n, n))
|
||||||
|
px = img.load()
|
||||||
|
for y in range(n):
|
||||||
|
for x in range(n):
|
||||||
|
t = (x + y) / (2 * n)
|
||||||
|
r = 255 + (214 - 255) * t
|
||||||
|
g = 142 + (66 - 142) * t
|
||||||
|
b = 62 + (6 - 62) * t
|
||||||
|
d = ((x - n * .25) ** 2 + (y - n * .2) ** 2) ** .5 / n # highlight at top-left
|
||||||
|
k = max(0, .22 - d * .35)
|
||||||
|
px[x, y] = (min(255, int(r + 255 * k)), min(255, int(g + 255 * k)), min(255, int(b + 255 * k)))
|
||||||
|
return img.resize((S, S), Image.BICUBIC)
|
||||||
|
|
||||||
|
|
||||||
|
def headphones():
|
||||||
|
"""White headphones on a transparent 1024 canvas, centered."""
|
||||||
|
layer = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(layer)
|
||||||
|
white, pad = (255, 255, 255, 255), (214, 74, 10, 255)
|
||||||
|
cx, cy, ro, w = 512, 530, 330, 76
|
||||||
|
d.arc((cx - ro, cy - ro, cx + ro, cy + ro), 180, 360, fill=white, width=w) # headband
|
||||||
|
for side in (-1, 1):
|
||||||
|
ex = cx + side * (ro - w / 2) # band end
|
||||||
|
d.rounded_rectangle((ex - 80, cy - 4, ex + 80, cy + 290), 78, fill=white) # ear cup
|
||||||
|
ix = ex - side * 8 + (-side) * 0
|
||||||
|
px0, px1 = (ex + 8, ex + 80) if side == -1 else (ex - 80, ex - 8)
|
||||||
|
d.rounded_rectangle((px0, cy + 44, px1, cy + 246), 34, fill=pad) # cushion
|
||||||
|
# content box ~ (130..894, 200..820)
|
||||||
|
return layer
|
||||||
|
|
||||||
|
|
||||||
|
def compose(scale, shadow=True):
|
||||||
|
bg = gradient().convert("RGBA")
|
||||||
|
hp = headphones()
|
||||||
|
if scale != 1:
|
||||||
|
size = int(S * scale)
|
||||||
|
hp = hp.resize((size, size), Image.LANCZOS)
|
||||||
|
full = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||||
|
full.paste(hp, ((S - size) // 2, (S - size) // 2 + int(S * (1 - scale) * .05)), hp)
|
||||||
|
hp = full
|
||||||
|
if shadow:
|
||||||
|
sh = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||||
|
sh.putalpha(hp.getchannel("A").filter(ImageFilter.GaussianBlur(22)).point(lambda a: int(a * .35)))
|
||||||
|
offset = Image.new("RGBA", (S, S), (90, 25, 0, 0))
|
||||||
|
offset.putalpha(sh.getchannel("A"))
|
||||||
|
shifted = Image.new("RGBA", (S, S), (0, 0, 0, 0)); shifted.paste(offset, (0, 18))
|
||||||
|
bg = Image.alpha_composite(bg, shifted)
|
||||||
|
return Image.alpha_composite(bg, hp).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
any_icon = compose(1.0) # 'any' and apple-touch: full-bleed, iOS rounds the corners itself
|
||||||
|
maskable = compose(.70) # content kept inside the 80% safe zone so Android can crop to any shape
|
||||||
|
for size, name, src in ((512, "icon-512.png", any_icon), (192, "icon-192.png", any_icon), (180, "icon-180.png", any_icon),
|
||||||
|
(512, "icon-maskable-512.png", maskable), (32, "icon-32.png", any_icon)):
|
||||||
|
src.resize((size, size), Image.LANCZOS).save(name, optimize=True)
|
||||||
|
print("wrote", name)
|
||||||