Offline downloads, service worker, iOS audio unlock fix
build / check (push) Successful in 9s
build / image (push) Successful in 27s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 17:02:39 -04:00
1 parent 7f59c6d27b
commit 5f88ab891b
5 files changed
+186 -29

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
WORKDIR /app
COPY server.py index.html admin.html manifest.webmanifest icon-180.png icon-192.png icon-512.png ./
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-180.png icon-192.png icon-512.png ./
USER app
VOLUME /data
EXPOSE 8080
+12
View File
@@ -32,3 +32,15 @@ PLAYER_PASSWORD=...
`docker compose up -d`, then sign in at port 8080. The default account is only created if it doesn't exist, so changing its password later with
`docker exec -it audiobook-player python server.py passwd nate` sticks. Add more people with `adduser`.
## Offline listening
Tap the ↓ on a book (or "Download" in the player) to save it on the device. Downloaded books show a ✓, and the
"Downloaded only" view lists them. They play with no connection to the server, and progress syncs when you're back online.
- Keep the app open while a download runs; iOS pauses pages in the background.
- The audio is stored in the browser (IndexedDB). iOS counts it against the site's storage quota, which is much larger
once the site is added to the Home Screen.
- **To open the app itself offline, serve it over HTTPS.** Browsers only allow the service worker (`sw.js`) on secure
origins. Put a reverse proxy with a certificate in front of port 8080 (and a domain name). Over plain HTTP, downloads
still play but the page can't load without a connection.
+147 -26
View File
@@ -57,6 +57,9 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
.cov .eq{position:absolute;right:8px;top:8px;background:var(--acc);color:#fff;border-radius:99px;font-size:11px;font-weight:700;padding:2px 8px}
.card b{display:block;margin-top:8px;font-size:13.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.card span{color:var(--mut);font-size:12.5px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cov .dlb{position:absolute;right:8px;bottom:12px;min-width:30px;height:30px;padding:0 8px;border-radius:99px;background:rgba(0,0,0,.62);color:#fff;font-size:13px;font-weight:700;display:grid;place-items:center;backdrop-filter:blur(6px);cursor:pointer}
.cov .dlb.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0}
#offline{margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
.skel .cov{animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.5}}
#msg{padding:60px 0;text-align:center;color:var(--mut)}
@@ -128,10 +131,11 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
<div class="search"><svg><use href="#i-search"/></svg><input id="q" type="search" placeholder="Search title, author, series"></div>
<a class="pill hide" id="adminlink" href="/admin" style="padding:8px 12px">Admin</a>
<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>
<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><option value="dl">Downloaded only</option></select>
</div></header>
<main>
<div id="offline" class="hide">You're offline or OpenAudible isn't reachable. Showing your downloaded books.</div>
<div id="msg" class="hide"></div>
<section id="contSec" class="hide"><h2>Continue listening</h2><div id="cont" class="shelf"></div></section>
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
@@ -160,7 +164,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
<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="opts"><button class="pill" id="dl">↓ Download</button><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>
@@ -178,7 +182,7 @@ const bookId = b => pick(b, 'id', 'asin', 'ASIN', 'book_id');
const title = b => pick(b, 'title', 'title_short', 'name') || '(untitled)';
const author = b => pick(b, 'author', 'author_name', 'authors', 'author_display') || '';
const series = b => pick(b, 'series_name', 'series') || '';
const cover = b => `/api/books/${encodeURIComponent(bookId(b))}/cover`;
const cover = b => (typeof coverUrls !== 'undefined' && coverUrls.get(bookId(b))) || `/api/books/${encodeURIComponent(bookId(b))}/cover`;
const fmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s/3600), m = Math.floor(s%3600/60), x = s%60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
const get = async p => { const r = await fetch(p); if (r.status === 401) { location.href = '/login'; throw new Error('Signing in…'); } if (!r.ok) { let m = ''; try { m = (await r.json()).error || ''; } catch {} throw new Error(m || p + ' → ' + r.status); } return r.json(); };
@@ -190,20 +194,116 @@ const ico = (id, name) => { $(id).innerHTML = `<svg><use href="#i-${name}"/></sv
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); };
// ---- offline downloads (IndexedDB; books are stored in 8 MB chunks so memory stays low) ----
const CHUNK = 8 * 1024 * 1024;
const OFF = (() => {
let dbp;
const open = () => dbp || (dbp = new Promise((res, rej) => {
const r = indexedDB.open('audiobooks', 1);
r.onupgradeneeded = () => { r.result.createObjectStore('meta', { keyPath: 'id' }); r.result.createObjectStore('chunks'); };
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
}));
const run = (store, mode, fn) => open().then(d => new Promise((res, rej) => {
const t = d.transaction(store, mode), r = fn(t.objectStore(store));
t.oncomplete = () => res(r && r.result); t.onerror = t.onabort = () => rej(t.error);
}));
const range = id => IDBKeyRange.bound([id, 0], [id, Infinity]);
return {
list: () => run('meta', 'readonly', s => s.getAll()),
putMeta: m => run('meta', 'readwrite', s => s.put(m)),
putChunk: (id, i, blob) => run('chunks', 'readwrite', s => s.put(blob, [id, i])),
chunks: id => run('chunks', 'readonly', s => s.getAll(range(id))),
remove: async id => { await run('chunks', 'readwrite', s => s.delete(range(id))); await run('meta', 'readwrite', s => s.delete(id)); }
};
})();
const downloaded = new Map(), dlState = new Map(), coverUrls = new Map();
let curObjUrl = null;
const setCoverUrl = m => { if (coverUrls.has(m.id)) URL.revokeObjectURL(coverUrls.get(m.id)); if (m.cover) coverUrls.set(m.id, URL.createObjectURL(m.cover)); };
async function initOffline() {
try { for (const m of await OFF.list()) { downloaded.set(m.id, m); setCoverUrl(m); } } catch {}
}
const mb = n => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : Math.round(n / 1e6) + ' MB';
const dlLabel = id => { const st = dlState.get(id); return st ? (st.pct ? Math.round(st.pct * 100) + '%' : st.got ? mb(st.got) : '…') : downloaded.has(id) ? '✓' : '↓'; };
function uiDl() {
document.querySelectorAll('.dlb').forEach(el => { const id = el.dataset.id; el.textContent = dlLabel(id); el.classList.toggle('busy', dlState.has(id)); el.classList.toggle('done', !dlState.has(id) && downloaded.has(id)); });
if (!current) return;
const id = bookId(current), st = dlState.get(id);
$('dl').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded' : '↓ Download';
$('dl').classList.toggle('on', !!st || downloaded.has(id));
}
async function download(b) {
const id = bookId(b), eid = encodeURIComponent(id);
if (dlState.has(id)) return;
const ctl = new AbortController(), st = { pct: 0, got: 0, ctl };
dlState.set(id, st); uiDl();
toast(window.isSecureContext ? 'Downloading. Keep this screen open until it finishes.' : 'Downloading. (Opening the app offline needs HTTPS.)');
try { navigator.storage && navigator.storage.persist && navigator.storage.persist(); } catch {}
let n = 0;
try {
const file = await findFile(eid);
if (!file) throw new Error('no playable MP3/M4B for this book');
const r = await fetch(`/api/books/${eid}/files/${encodeURIComponent(file)}`, { signal: ctl.signal });
if (!r.ok) throw new Error('server said ' + r.status);
const total = +r.headers.get('Content-Length') || 0;
const type = r.headers.get('Content-Type') || (/\.mp3$/i.test(file) ? 'audio/mpeg' : 'audio/mp4');
const rd = r.body.getReader();
let buf = [], size = 0;
const flush = async () => { if (size) { await OFF.putChunk(id, n++, new Blob(buf)); buf = []; size = 0; } };
for (;;) {
const { done, value } = await rd.read();
if (done) break;
buf.push(value); size += value.length; st.got += value.length; st.pct = total ? st.got / total : 0;
if (size >= CHUNK) await flush();
uiDl();
}
await flush();
let coverBlob = null, chaps = null;
try { const c = await fetch(cover(b)); if (c.ok) coverBlob = await c.blob(); } catch {}
try { chaps = await get(`/api/books/${eid}/chapters/flat`); } catch {}
const meta = { id, book: b, file, type, chunks: n, size: st.got, cover: coverBlob, chapters: chaps, t: Date.now() };
await OFF.putMeta(meta); downloaded.set(id, meta); setCoverUrl(meta);
toast(`Downloaded “${title(b)}”`);
} catch (e) {
await OFF.remove(id).catch(() => {}); // never leave half a book behind
if (e.name !== 'AbortError') toast(e.name === 'QuotaExceededError' ? 'Not enough storage on this device.' : 'Download failed: ' + e.message);
} finally { dlState.delete(id); uiDl(); render(); }
}
async function removeDl(id) {
await OFF.remove(id).catch(() => {});
downloaded.delete(id);
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
uiDl(); render(); toast('Removed download');
}
function toggleDl(b) {
const id = bookId(b), st = dlState.get(id);
if (st) return st.ctl.abort();
if (downloaded.has(id)) { if (confirm(`Remove the downloaded copy of “${title(b)}”?`)) removeDl(id); return; }
if (!window.indexedDB) return toast("Downloads aren't available in this browser");
download(b);
}
$('dl').onclick = () => current && toggleDl(current);
// ---- library ----
async function load() {
if (!/^https?:$/.test(location.protocol)) {
$('msg').innerHTML = `<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;
}
await initOffline();
$('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));
$('offline').classList.add('hide');
render();
pull();
} catch (e) {
if (downloaded.size) { // can't reach the library: still let people listen to what they saved
books = [...downloaded.values()].map(m => m.book);
$('sort').value = 'dl'; $('offline').classList.remove('hide');
render(); return;
}
$('grid').replaceChildren();
$('msg').innerHTML = `<div>Couldn't load your library.<br><small>${e.message}</small></div><button onclick="load()">Try again</button>`;
$('msg').classList.remove('hide');
@@ -219,18 +319,23 @@ function card(b) {
img.onerror = () => { const p = document.createElement('div'); p.className = 'ph'; p.style.background = `linear-gradient(135deg,hsl(${hue(title(b))} 55% 40%),hsl(${hue(title(b)) + 40} 55% 28%))`; p.textContent = title(b); img.replaceWith(p); };
d.querySelector('b').textContent = title(b);
d.querySelector('span').textContent = author(b);
const badge = document.createElement('span');
badge.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' : ''); badge.dataset.id = id; badge.textContent = dlLabel(id);
badge.setAttribute('role', 'button'); badge.setAttribute('aria-label', 'Download for offline listening');
badge.onclick = e => { e.stopPropagation(); toggleDl(b); };
d.querySelector('.cov').append(badge);
d.onclick = () => play(b, true);
return d;
}
function render() {
const q = $('q').value.trim().toLowerCase(), sort = $('sort').value;
let list = books.filter(b => !q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q));
let list = books.filter(b => (sort !== 'dl' || downloaded.has(bookId(b))) && (!q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q)));
const last = b => (store.get('prog:' + bookId(b)) || {}).t || 0;
list.sort(sort === 'author' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b))
list.sort(sort === 'author' || sort === 'dl' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b))
: sort === 'recent' ? (a, b) => last(b) - last(a)
: (a, b) => title(a).localeCompare(title(b)));
$('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` : '';
if (sort === 'author') {
$('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` + (sort === 'dl' ? ' · ' + mb(list.reduce((a, b) => a + ((downloaded.get(bookId(b)) || {}).size || 0), 0)) : '') : '';
if (sort === 'author' || sort === 'dl') {
const groups = new Map();
const labels = new Map();
for (const b of list) {
@@ -254,7 +359,7 @@ function render() {
$('grid').className = 'grid';
$('grid').replaceChildren(...list.map(card));
}
if (!list.length && books.length) { $('msg').textContent = 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
if (!list.length && books.length) { $('msg').textContent = sort === 'dl' ? 'Nothing downloaded yet. Tap the ↓ on a book to save it.' : 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; })
.sort((a, b) => last(b) - last(a)).slice(0, 12);
$('contSec').classList.toggle('hide', !cont.length || !!q);
@@ -272,11 +377,23 @@ async function findFile(id) {
}
async function play(b, open) {
if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; }
const id = encodeURIComponent(bookId(b));
let file;
try { file = await findFile(id); } catch (e) { return toast('Could not list files: ' + e.message); }
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
const id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
let src;
if (off) {
try {
const parts = await OFF.chunks(bookId(b));
if (parts.length !== off.chunks) throw new Error('the saved copy is incomplete; remove it and download again');
src = URL.createObjectURL(new Blob(parts, { type: off.type }));
} catch (e) { return toast('Could not open the downloaded copy: ' + e.message); }
} else {
let file;
try { file = await findFile(id); } catch (e) { return toast('Could not list files: ' + e.message); }
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
}
saveProgress();
if (curObjUrl) URL.revokeObjectURL(curObjUrl);
curObjUrl = off ? src : null;
current = b;
$('mini').classList.remove('hide');
const fallback = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue(title(b))} 55% 40%)"/><stop offset="1" stop-color="hsl(${hue(title(b)) + 40} 55% 28%)"/></linearGradient></defs><rect width="100" height="100" fill="url(#g)"/><text x="50" y="62" font-size="40" font-family="sans-serif" font-weight="700" text-anchor="middle" fill="#fff">${(title(b)[0] || '?').replace(/[<&]/g, '')}</text></svg>`);
@@ -284,11 +401,13 @@ async function play(b, open) {
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
$('mtitle').textContent = $('ftitle').textContent = title(b);
$('msub').textContent = $('fsub').textContent = author(b);
audio.src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
await unlockP;
audio.src = src;
uiDl();
await pull();
const saved = (store.get('prog:' + bookId(b)) || {}).pos;
audio.addEventListener('loadedmetadata', () => { if (saved && saved < audio.duration - 30) audio.currentTime = saved; audio.playbackRate = speeds[spIdx]; audio.play().catch(() => {}); }, { once: true });
loadChapters(id);
loadChapters(id, off);
render();
if (open) openFull();
if ('mediaSession' in navigator) {
@@ -299,10 +418,10 @@ async function play(b, open) {
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
}
}
async function loadChapters(id) {
async function loadChapters(id, off) {
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
try {
const data = await get(`/api/books/${id}/chapters/flat`);
const data = (off && off.chapters) || await get(`/api/books/${id}/chapters/flat`);
const arr = Array.isArray(data) ? data : (data.chapters || []);
chapters = arr.map(c => {
let s = pick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec');
@@ -403,19 +522,21 @@ document.addEventListener('keydown', e => {
window.addEventListener('beforeunload', () => { saveProgress(); push(true); });
document.addEventListener('visibilitychange', () => { if (document.hidden) { saveProgress(); push(true); } else if (audio.paused) pull(); });
ico('play', 'play'); ico('mplay', 'play');
// iOS only lets audio start from a tap. Unlock the element on the first touch so later play() calls
// (which happen after async fetches) are still allowed.
let unlocked = false;
// iOS only lets audio start from a tap. Unlock the element on the first touch (before any click handler runs)
// so the later play() calls, which happen after async fetches, are still allowed. play() waits for this to finish.
const SILENT = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA=';
let unlocked = false, unlockP = Promise.resolve();
const unlock = () => {
if (unlocked) return; unlocked = true;
const keep = audio.src;
if (!keep) {
audio.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA=';
audio.play().then(() => audio.pause()).catch(() => {});
}
if (audio.src) return;
audio.src = SILENT;
unlockP = audio.play().then(() => audio.pause()).catch(() => {});
};
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true }));
fetch('/me').then(r => r.json()).then(m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {});
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true, capture: true }));
const showMe = m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').classList.remove('hide'); $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; };
$('who').classList.add('hide');
fetch('/me').then(r => r.ok ? r.json() : Promise.reject()).then(m => { store.set('me', m); showMe(m); }).catch(() => { const m = store.get('me'); if (m) showMe(m); });
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
load();
</script>
</body>
+2 -2
View File
@@ -41,7 +41,7 @@ SESSION_DAYS = 90
# Files the browser fetches without a session (home-screen icon and manifest).
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/icon-180.png": "image/png",
"/icon-192.png": "image/png", "/icon-512.png": "image/png"}
"/icon-192.png": "image/png", "/icon-512.png": "image/png", "/sw.js": "text/javascript"}
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
ALLOWED = [re.compile(p) for p in (
@@ -223,7 +223,7 @@ class Handler(BaseHTTPRequestHandler):
return self.logout()
if path in PUBLIC:
with open(os.path.join(HERE, path[1:]), "rb") as f:
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "public, max-age=86400")])
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "no-cache" if path in ("/sw.js", "/manifest.webmanifest") else "public, max-age=86400")])
user = self.user()
if not user:
return self.deny(path)
+24
View File
@@ -0,0 +1,24 @@
// Keeps the app shell so the player opens with no connection. Audio is stored separately by the page (IndexedDB).
// Service workers only run on HTTPS (or localhost).
const CACHE = 'shell-v1';
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting()));
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', e => {
const r = e.request, url = new URL(r.url);
if (r.method !== 'GET' || url.origin !== location.origin) return;
if (r.mode === 'navigate' && url.pathname === '/') {
// newest page when online, the saved one when not. Never save a sign-in redirect.
e.respondWith(fetch(r).then(res => {
if (res.ok && !res.redirected) { const copy = res.clone(); caches.open(CACHE).then(c => c.put('/', copy)); }
return res;
}).catch(() => caches.match('/')));
} else if (STATIC.includes(url.pathname)) {
e.respondWith(caches.match(r).then(hit => hit || fetch(r)));
}
});