← Public packages
@kentcdodds/audible
Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.
src/app/client-js.ts
947 lines · 36.8 KB · TypeScript/** Browser client for the Audible package app (mount-aware fetch). */
export function renderClientJs(): string {
return String.raw`(() => {
const root = document.querySelector('.app');
if (!root) return;
const appBase = (root.getAttribute('data-app-base') || '').replace(/\/$/, '');
const api = (path) => appBase + path;
const panels = [...document.querySelectorAll('[data-panel]')];
const navBtns = [...document.querySelectorAll('[data-nav]')];
const chip = document.getElementById('auth-chip');
const loginBox = document.getElementById('login-url-box');
const sessionInput = document.getElementById('authSessionId');
let libraryItemsCache = [];
let libraryPage = 0;
let libraryHasMore = true;
let libraryLoading = false;
let libraryQuery = '';
let libraryNasFilter = 'all';
try {
const saved = sessionStorage.getItem('audible-library-nas-filter');
if (saved === 'all' || saved === 'not-on-nas' || saved === 'on-nas') libraryNasFilter = saved;
} catch {}
/** Bumps when filter/search resets so in-flight NAS auto-scan loops abort. */
let libraryNasScanGen = 0;
let libraryNasScanning = false;
const NAS_FILTER_AUTO_MAX_PAGES = 30;
const NAS_FILTER_VISIBLE_THRESHOLD = 0;
let wishlistItemsCache = [];
let wishlistPage = -1;
let wishlistHasMore = true;
let wishlistLoading = false;
let remainingTotal = 0;
let remainingItems = [];
let bulkRunning = false;
let bulkStop = false;
const BULK_CONCURRENCY = 1;
/** ASINs with an in-flight Save — debounce double-clicks / overlapping archive races. */
const archiveInFlight = new Set();
function showPanel(name) {
panels.forEach((p) => p.classList.toggle('active', p.getAttribute('data-panel') === name));
navBtns.forEach((b) => {
const on = b.getAttribute('data-nav') === name;
if (on) b.setAttribute('aria-current', 'page');
else b.removeAttribute('aria-current');
});
if (name === 'library') loadLibrary();
if (name === 'wishlist') loadWishlist();
if (name === 'archive') refreshRemainingCount();
history.replaceState(null, '', '#' + name);
}
navBtns.forEach((b) => b.addEventListener('click', () => showPanel(b.getAttribute('data-nav'))));
const hash = (location.hash || '#connect').slice(1);
if (['connect','library','wishlist','archive'].includes(hash)) showPanel(hash);
async function refreshStatus() {
try {
const res = await fetch(api('/api/status'), { credentials: 'same-origin' });
const data = await res.json();
if (!chip) return;
chip.dataset.state = data.state || 'pending';
chip.textContent = data.label || 'Unknown';
return data;
} catch {
if (chip) {
chip.dataset.state = 'pending';
chip.textContent = 'Status unavailable';
}
}
}
document.getElementById('reload-status')?.addEventListener('click', () => refreshStatus());
document.getElementById('start-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fd = new FormData(form);
const btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
try {
const res = await fetch(api('/auth/start'), {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ locale: fd.get('locale') || 'us' }),
credentials: 'same-origin',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not start auth');
if (sessionInput) sessionInput.value = data.authSessionId || '';
if (loginBox) {
loginBox.classList.add('show');
loginBox.innerHTML = '<div><strong>Open Amazon login</strong></div><a target="_blank" rel="noopener" href="' +
String(data.loginUrl).replace(/"/g, '"') + '">' + String(data.loginUrl) + '</a>' +
'<p style="margin:8px 0 0">Session ' + String(data.authSessionId || '') + ' — paste the final maplanding URL below when Amazon shows the 404/error page.</p>';
}
if (data.loginUrl) window.open(data.loginUrl, '_blank', 'noopener');
} catch (error) {
alert(error instanceof Error ? error.message : String(error));
} finally {
if (btn) btn.disabled = false;
}
});
document.getElementById('complete-form')?.addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fd = new FormData(form);
const btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
try {
const res = await fetch(api('/auth/complete'), {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
redirectUrl: fd.get('redirectUrl'),
authSessionId: fd.get('authSessionId') || undefined,
}),
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.message || ('HTTP ' + res.status));
}
await refreshStatus();
alert(data.message || 'Connected. Library is ready.');
showPanel('library');
} catch (error) {
alert(error instanceof Error ? error.message : String(error));
} finally {
if (btn) btn.disabled = false;
}
});
function coverHtml(item) {
if (item.coverUrl) {
return '<img class="cover" alt="" loading="lazy" src="' + String(item.coverUrl).replace(/"/g, '"') + '"/>';
}
const letter = (item.title || item.asin || '?').slice(0, 1).toUpperCase();
return '<div class="cover fallback" aria-hidden="true">' + letter + '</div>';
}
function progressHtml(item) {
const pct = typeof item.percentComplete === 'number' ? Math.max(0, Math.min(100, item.percentComplete)) : null;
if (pct == null && !item.isFinished) return '';
const value = item.isFinished ? 100 : pct;
return '<div class="progress" title="' + value + '%"><span style="width:' + value + '%"></span></div>';
}
function authors(item) {
return Array.isArray(item.authors) && item.authors.length ? item.authors.join(', ') : 'Unknown author';
}
let librarySoftReloadTimer = 0;
function eligibilityBadge(item) {
const elig = item.archiveEligibility || 'downloadable';
if (elig === 'plus_stream') {
const hint = item.archiveHint || 'Plus · stream only';
return '<span class="quiet-badge" title="Audible Plus — stream in the app; not downloadable to NAS">' +
escapeHtml(hint) + '</span>';
}
if (elig === 'unsupported_format') {
const hint = item.archiveHint || 'Not an audiobook download';
return '<span class="quiet-badge" title="Not an audiobook file Audible will license for download">' +
escapeHtml(hint) + '</span>';
}
return '';
}
function filterLibraryItems(items) {
if (libraryNasFilter === 'on-nas') {
return items.filter((item) => Boolean(item.onNas));
}
if (libraryNasFilter === 'not-on-nas') {
return items.filter((item) => {
const elig = item.archiveEligibility || 'downloadable';
return elig === 'downloadable' && !item.onNas;
});
}
return items;
}
function syncLibraryNasFilterChips() {
document.querySelectorAll('[data-nas-filter]').forEach((btn) => {
const on = btn.getAttribute('data-nas-filter') === libraryNasFilter;
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
function setMoreStatus(id, text) {
const el = document.getElementById(id);
if (!el) return;
if (!text) {
el.hidden = true;
el.textContent = '';
return;
}
el.hidden = false;
el.textContent = text;
}
function visibleLibraryCount(items) {
return filterLibraryItems(items || libraryItemsCache).length;
}
function nasFilterLookingMessage() {
if (libraryNasFilter === 'not-on-nas') return 'Looking for titles not on NAS…';
if (libraryNasFilter === 'on-nas') return 'Looking for titles on NAS…';
return 'Loading more…';
}
function nasFilterEmptyFinalMessage() {
if (libraryNasFilter === 'not-on-nas') {
if (!libraryItemsCache.length) return 'No titles left to save.';
return libraryHasMore
? 'Still scanning… no downloadable titles not on NAS in the loaded pages yet.'
: 'All loaded titles are already on NAS (or not downloadable).';
}
if (libraryNasFilter === 'on-nas') {
return libraryHasMore
? 'Still scanning… no On NAS titles in the loaded pages yet.'
: 'No titles marked On NAS in the loaded pages.';
}
return 'No titles matched. Connect first or clear the search.';
}
function paintLibraryItems(list, items) {
const visible = filterLibraryItems(items);
if (!visible.length) {
const emptyMsg = libraryNasScanning
? nasFilterLookingMessage()
: (libraryNasFilter === 'not-on-nas' || libraryNasFilter === 'on-nas')
? nasFilterEmptyFinalMessage()
: 'No titles matched. Connect first or clear the search.';
list.innerHTML = '<div class="empty">' + emptyMsg + '</div>';
return;
}
list.innerHTML = visible.map((item) => {
const onNas = Boolean(item.onNas);
const elig = item.archiveEligibility || 'downloadable';
const downloadable = elig === 'downloadable';
let actions = '';
if (onNas) {
actions += '<span class="nas-badge" title="' + escapeHtml(item.nasFilename || 'On NAS') + '">On NAS</span>';
}
actions += eligibilityBadge(item);
if (downloadable) {
actions += onNas
? '<button type="button" class="btn btn-secondary" data-archive-asin="' +
escapeHtml(item.asin) + '" data-archive-title="' +
escapeHtml(item.title || '') + '" data-archive-overwrite="1">Re-save</button>'
: '<button type="button" class="btn btn-secondary" data-archive-asin="' +
escapeHtml(item.asin) + '" data-archive-title="' +
escapeHtml(item.title || '') + '">Save to NAS</button>';
}
const cls = ['item'];
if (onNas) cls.push('on-nas');
if (!downloadable) cls.push('not-downloadable');
return '<article class="' + cls.join(' ') + '">' +
coverHtml(item) +
'<div class="meta"><h3>' + escapeHtml(item.title || item.asin) + '</h3><p>' +
escapeHtml(authors(item)) + '</p>' + progressHtml(item) + '</div>' +
'<div class="item-actions">' + actions + '</div></article>';
}).join('');
list.querySelectorAll('[data-archive-asin]').forEach((btn) => {
btn.addEventListener('click', () => archiveAsin(
btn.getAttribute('data-archive-asin'),
btn.getAttribute('data-archive-title'),
btn.getAttribute('data-archive-overwrite') === '1'
));
});
}
function mergeLibraryItems(existing, incoming) {
const byAsin = new Map(existing.map((item) => [item.asin, item]));
for (const item of incoming) {
if (!item || !item.asin) continue;
const prev = byAsin.get(item.asin);
byAsin.set(item.asin, prev ? { ...prev, ...item } : item);
}
// Preserve scroll order: existing order first, then newly seen asins in incoming order.
const out = [];
const seen = new Set();
for (const item of existing) {
if (!item?.asin || seen.has(item.asin)) continue;
const merged = byAsin.get(item.asin);
if (merged) {
out.push(merged);
seen.add(item.asin);
}
}
for (const item of incoming) {
if (!item?.asin || seen.has(item.asin)) continue;
out.push(item);
seen.add(item.asin);
}
return out;
}
async function loadLibrary(opts) {
const forceRefresh = Boolean(opts && opts.forceRefresh);
const soft = Boolean(opts && opts.soft);
const append = Boolean(opts && opts.append);
const list = document.getElementById('library-list');
if (!list) return;
if (libraryLoading) return;
const q = (document.getElementById('library-q')?.value || '').trim();
if (!append && q !== libraryQuery) {
libraryQuery = q;
}
if (append) {
if (!libraryHasMore) return;
} else if (!soft) {
libraryPage = 0;
libraryHasMore = true;
libraryItemsCache = [];
libraryQuery = q;
libraryNasScanGen += 1;
libraryNasScanning = false;
}
const nextPage = append ? libraryPage + 1 : (soft && libraryPage > 0 ? 1 : 1);
if (!soft) list.innerHTML = append
? list.innerHTML
: '<div class="empty">Loading library…</div>';
if (append) setMoreStatus('library-more-status', 'Loading more…');
libraryLoading = true;
try {
const url = new URL(api('/api/library'), location.origin);
if (libraryQuery) url.searchParams.set('q', libraryQuery);
url.searchParams.set('page', String(nextPage));
if (forceRefresh) url.searchParams.set('refresh', '1');
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Library failed');
const items = data.items || [];
if (soft && libraryItemsCache.length && !append) {
libraryItemsCache = mergeLibraryItems(libraryItemsCache, items);
} else if (append) {
libraryItemsCache = mergeLibraryItems(libraryItemsCache, items);
libraryPage = nextPage;
libraryHasMore = Boolean(data.hasMore);
} else {
libraryItemsCache = items;
libraryPage = nextPage;
libraryHasMore = Boolean(data.hasMore);
}
syncLibraryNasFilterChips();
if (!libraryItemsCache.length) {
list.innerHTML = '<div class="empty">No titles matched. Connect first or clear the search.</div>';
setMoreStatus('library-more-status', '');
return;
}
paintLibraryItems(list, libraryItemsCache);
if (libraryNasScanning) {
setMoreStatus(
'library-more-status',
nasFilterLookingMessage() + ' (loaded ' + libraryItemsCache.length + ' titles…)'
);
} else {
setMoreStatus(
'library-more-status',
libraryHasMore ? 'Scroll for more titles…' : (libraryPage > 1 ? 'End of library' : '')
);
}
if (!forceRefresh && !(opts && opts.skipNasAutoFetch) && !libraryNasScanning &&
data.archiveStatus && data.archiveStatus.stale) {
clearTimeout(librarySoftReloadTimer);
librarySoftReloadTimer = setTimeout(() => loadLibrary({ forceRefresh: true, soft: true }), 50);
}
} catch (error) {
if (!soft && !append) {
list.innerHTML = '<div class="empty">' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
}
if (append) setMoreStatus('library-more-status', 'Could not load more — try Reload.');
} finally {
libraryLoading = false;
if (!opts || !opts.skipNasAutoFetch) {
queueMicrotask(() => ensureNasFilterResults());
}
}
}
async function ensureNasFilterResults() {
if (libraryNasFilter !== 'not-on-nas' && libraryNasFilter !== 'on-nas') {
libraryNasScanning = false;
return;
}
if (libraryLoading) return;
if (visibleLibraryCount() > NAS_FILTER_VISIBLE_THRESHOLD) {
libraryNasScanning = false;
return;
}
if (!libraryHasMore) {
libraryNasScanning = false;
const list = document.getElementById('library-list');
if (list) paintLibraryItems(list, libraryItemsCache);
setMoreStatus(
'library-more-status',
libraryPage > 1 ? 'End of library' : ''
);
return;
}
const gen = ++libraryNasScanGen;
libraryNasScanning = true;
let pagesFetched = 0;
const list = document.getElementById('library-list');
if (list) {
list.innerHTML = '<div class="empty">' + nasFilterLookingMessage() + '</div>';
}
setMoreStatus('library-more-status', nasFilterLookingMessage());
while (
gen === libraryNasScanGen &&
libraryNasFilter !== 'all' &&
visibleLibraryCount() <= NAS_FILTER_VISIBLE_THRESHOLD &&
libraryHasMore &&
pagesFetched < NAS_FILTER_AUTO_MAX_PAGES
) {
const beforePage = libraryPage;
const beforeLen = libraryItemsCache.length;
await loadLibrary({ append: true, skipNasAutoFetch: true });
pagesFetched += 1;
if (libraryPage === beforePage && libraryItemsCache.length === beforeLen) {
// Fetch did not advance (error or race) — stop to avoid a tight loop.
break;
}
if (list && visibleLibraryCount() <= NAS_FILTER_VISIBLE_THRESHOLD) {
list.innerHTML = '<div class="empty">' + nasFilterLookingMessage() + '</div>';
setMoreStatus(
'library-more-status',
nasFilterLookingMessage() + ' (loaded ' + libraryItemsCache.length + ' titles…)'
);
}
}
if (gen !== libraryNasScanGen) return;
libraryNasScanning = false;
const listEl = document.getElementById('library-list');
if (!listEl) return;
if (visibleLibraryCount() > NAS_FILTER_VISIBLE_THRESHOLD) {
paintLibraryItems(listEl, libraryItemsCache);
setMoreStatus(
'library-more-status',
libraryHasMore ? 'Scroll for more titles…' : (libraryPage > 1 ? 'End of library' : '')
);
return;
}
if (libraryHasMore && pagesFetched >= NAS_FILTER_AUTO_MAX_PAGES) {
listEl.innerHTML = '<div class="empty">Still scanning… loaded ' +
libraryItemsCache.length +
' titles with no filter matches yet. Scroll or tap Reload to continue, or clear the filter.</div>';
setMoreStatus('library-more-status', 'Safety cap reached — scroll for more or Reload.');
return;
}
paintLibraryItems(listEl, libraryItemsCache);
setMoreStatus(
'library-more-status',
libraryHasMore ? 'Scroll for more titles…' : (libraryPage > 1 ? 'End of library' : '')
);
}
document.getElementById('library-reload')?.addEventListener('click', () => loadLibrary({ forceRefresh: true }));
document.getElementById('library-q')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); loadLibrary(); }
});
document.getElementById('library-nas-filter')?.addEventListener('click', (e) => {
const btn = e.target && e.target.closest ? e.target.closest('[data-nas-filter]') : null;
if (!btn) return;
const next = btn.getAttribute('data-nas-filter');
if (next !== 'all' && next !== 'not-on-nas' && next !== 'on-nas') return;
libraryNasFilter = next;
libraryNasScanGen += 1;
libraryNasScanning = false;
try { sessionStorage.setItem('audible-library-nas-filter', libraryNasFilter); } catch {}
syncLibraryNasFilterChips();
const list = document.getElementById('library-list');
if (list && libraryItemsCache.length) paintLibraryItems(list, libraryItemsCache);
queueMicrotask(() => ensureNasFilterResults());
});
if ('IntersectionObserver' in window) {
const libSentinel = document.getElementById('library-sentinel');
if (libSentinel) {
new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
const active = document.querySelector('.panel.active')?.getAttribute('data-panel');
if (active !== 'library') return;
if (!libraryHasMore || libraryLoading) return;
loadLibrary({ append: true });
}, { root: null, rootMargin: '240px 0px', threshold: 0 }).observe(libSentinel);
}
}
function paintWishlist(list, products) {
const q = (document.getElementById('wishlist-q')?.value || '').trim().toLowerCase();
let visible = products;
if (q) {
visible = products.filter((p) => {
const hay = [p.title, ...(p.authors || []), p.asin].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
}
if (!visible.length) {
list.innerHTML = '<div class="empty">' + (q ? 'No wishlist matches for that filter.' : 'Wishlist is empty.') + '</div>';
return;
}
list.innerHTML = visible.map((item) => {
return '<article class="item">' +
coverHtml(item) +
'<div class="meta"><h3>' + escapeHtml(item.title || item.asin) + '</h3><p>' +
escapeHtml(authors(item)) + '</p></div>' +
'<div class="item-actions"><button type="button" class="btn btn-secondary" data-remove-asin="' +
escapeHtml(item.asin) + '">Remove</button></div></article>';
}).join('');
list.querySelectorAll('[data-remove-asin]').forEach((btn) => {
btn.addEventListener('click', async () => {
const asin = btn.getAttribute('data-remove-asin');
const res = await fetch(api('/api/wishlist'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'remove', asin }),
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) alert(data.error || 'Remove failed');
else loadWishlist();
});
});
}
async function loadWishlist(opts) {
const append = Boolean(opts && opts.append);
const list = document.getElementById('wishlist-list');
if (!list) return;
if (wishlistLoading) return;
if (append) {
if (!wishlistHasMore) return;
} else {
wishlistPage = -1;
wishlistHasMore = true;
wishlistItemsCache = [];
list.innerHTML = '<div class="empty">Loading wishlist…</div>';
}
const nextPage = append ? wishlistPage + 1 : 0;
if (append) setMoreStatus('wishlist-more-status', 'Loading more…');
wishlistLoading = true;
try {
const url = new URL(api('/api/wishlist'), location.origin);
url.searchParams.set('page', String(nextPage));
const res = await fetch(url, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Wishlist failed');
const products = data.products || [];
if (append) {
const seen = new Set(wishlistItemsCache.map((p) => p.asin));
for (const p of products) {
if (p?.asin && !seen.has(p.asin)) wishlistItemsCache.push(p);
}
} else {
wishlistItemsCache = products;
}
wishlistPage = nextPage;
wishlistHasMore = Boolean(data.hasMore);
if (!wishlistItemsCache.length) {
list.innerHTML = '<div class="empty">Wishlist is empty.</div>';
setMoreStatus('wishlist-more-status', '');
return;
}
paintWishlist(list, wishlistItemsCache);
setMoreStatus(
'wishlist-more-status',
wishlistHasMore ? 'Scroll for more…' : (wishlistPage > 0 ? 'End of wishlist' : '')
);
} catch (error) {
if (!append) {
list.innerHTML = '<div class="empty">' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
}
if (append) setMoreStatus('wishlist-more-status', 'Could not load more — try Reload.');
} finally {
wishlistLoading = false;
}
}
if ('IntersectionObserver' in window) {
const wishSentinel = document.getElementById('wishlist-sentinel');
if (wishSentinel) {
new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
const active = document.querySelector('.panel.active')?.getAttribute('data-panel');
if (active !== 'wishlist') return;
if (!wishlistHasMore || wishlistLoading) return;
loadWishlist({ append: true });
}, { root: null, rootMargin: '240px 0px', threshold: 0 }).observe(wishSentinel);
}
}
document.getElementById('wishlist-reload')?.addEventListener('click', () => loadWishlist());
document.getElementById('wishlist-q')?.addEventListener('input', () => {
const list = document.getElementById('wishlist-list');
if (list && wishlistItemsCache.length) paintWishlist(list, wishlistItemsCache);
});
document.getElementById('wishlist-add')?.addEventListener('submit', async (event) => {
event.preventDefault();
const asin = document.getElementById('wishlist-asin')?.value?.trim();
if (!asin) return;
const res = await fetch(api('/api/wishlist'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'add', asin }),
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) alert(data.error || 'Add failed');
else {
document.getElementById('wishlist-asin').value = '';
loadWishlist();
}
});
function paintArchiveResult(out, data, ok) {
if (!out) return;
out.classList.remove('flash', 'flash-success', 'flash-error', 'flash-info');
const skipped = Boolean(data.skipped);
const success = Boolean(data.ok) && ok;
const notEligible = data.retryable === false || data.code === 'ARCHIVE_NOT_ELIGIBLE' ||
data.archiveEligibility === 'plus_stream' || data.archiveEligibility === 'unsupported_format';
const kind = success ? (skipped ? 'flash-info' : 'flash-success') : (notEligible ? 'flash-info' : 'flash-error');
out.classList.add('flash', kind);
const title = data.title || data.asin || 'Title';
const filename = data.filename ? ' as ' + data.filename : '';
if (skipped) {
out.textContent = title + ' is already on the NAS' + filename + '.';
return;
}
if (success) {
out.textContent = 'Saved ' + title + filename + '.';
return;
}
if (notEligible) {
out.textContent = data.archiveHint
? (title + ' — ' + data.archiveHint + '. Not archived.')
: (data.message || data.error || 'Not downloadable to NAS.');
return;
}
out.textContent = data.message || data.error || 'Save to NAS failed.';
}
async function archiveAsin(asin, title, forceOverwrite) {
if (!asin) return;
const out = document.getElementById('archive-result');
if (archiveInFlight.has(asin)) {
showPanel('archive');
if (out) {
out.classList.remove('flash-success', 'flash-error');
out.classList.add('flash', 'flash-info');
out.textContent = 'Save already in progress for this title…';
}
return;
}
archiveInFlight.add(asin);
showPanel('archive');
const input = document.getElementById('archive-asin');
if (input) input.value = asin;
const titleInput = document.getElementById('archive-title');
if (titleInput && title) titleInput.value = title;
const overwriteBox = document.getElementById('archive-overwrite');
if (forceOverwrite && overwriteBox) overwriteBox.checked = true;
const overwrite = Boolean(forceOverwrite) || Boolean(overwriteBox?.checked);
const submitBtn = document.querySelector('#archive-form button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
document.querySelectorAll('[data-archive-asin="' + asin + '"]').forEach((btn) => {
btn.disabled = true;
});
if (out) {
out.classList.remove('flash-success', 'flash-error');
out.classList.add('flash', 'flash-info');
out.textContent = 'Working… home converts on the NAS. A new title can take a few minutes; an already-saved file returns immediately.';
}
try {
const res = await fetch(api('/api/archive'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
asin,
title: title || titleInput?.value?.trim() || undefined,
overwrite,
}),
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
paintArchiveResult(out, data, res.ok);
if (res.ok && data.ok) {
// Write-through mirror in local library cache when present.
libraryItemsCache = libraryItemsCache.map((item) => {
if (item.asin !== asin) return item;
return {
...item,
onNas: true,
nasFilename: data.filename || item.nasFilename,
};
});
// Refresh badges so Save becomes On NAS / Re-save without a full reload.
const list = document.getElementById('library-list');
if (list && libraryItemsCache.length) paintLibraryItems(list, libraryItemsCache);
}
} catch (error) {
paintArchiveResult(out, { message: error instanceof Error ? error.message : String(error) }, false);
} finally {
archiveInFlight.delete(asin);
if (submitBtn) submitBtn.disabled = false;
document.querySelectorAll('[data-archive-asin="' + asin + '"]').forEach((btn) => {
btn.disabled = false;
});
}
}
document.getElementById('archive-form')?.addEventListener('submit', (event) => {
event.preventDefault();
archiveAsin(
document.getElementById('archive-asin')?.value?.trim(),
document.getElementById('archive-title')?.value?.trim()
);
});
function paintRemainingList() {
const el = document.getElementById('remaining-list');
if (!el) return;
if (!remainingItems.length) {
el.innerHTML = remainingTotal <= 0
? '<div class="empty" style="padding:12px 0">Nothing left in the remaining queue.</div>'
: '<div class="empty" style="padding:12px 0">Count is ready, but the remaining sample is empty — tap Refresh count.</div>';
return;
}
const shown = remainingItems.slice(0, 40);
const more = remainingTotal > shown.length
? '<li class="remaining-more">…and ' + (remainingTotal - shown.length) + ' more</li>'
: '';
el.innerHTML = '<ul class="remaining-asins">' +
shown.map((item) => {
const title = escapeHtml(item.title || item.asin);
const asin = escapeHtml(item.asin);
return '<li><button type="button" class="remaining-link" data-archive-asin="' + asin +
'" data-archive-title="' + title + '">' + title +
'</button> <span class="quiet-badge">' + asin + '</span></li>';
}).join('') + more + '</ul>';
el.querySelectorAll('[data-archive-asin]').forEach((btn) => {
btn.addEventListener('click', () => archiveAsin(
btn.getAttribute('data-archive-asin'),
btn.getAttribute('data-archive-title')
));
});
}
function updateRemainingSummary() {
const summary = document.getElementById('remaining-summary');
const startBtn = document.getElementById('bulk-archive-start');
if (summary) {
if (remainingTotal <= 0) {
summary.innerHTML = 'Nice — <span class="remaining-count">0</span> purchased titles left to save. Your NAS archive is caught up.';
} else {
summary.innerHTML = '<span class="remaining-count">' + remainingTotal + '</span> purchased title' +
(remainingTotal === 1 ? '' : 's') + ' ready to save to the NAS.';
}
}
if (startBtn) {
startBtn.disabled = bulkRunning || remainingTotal <= 0;
startBtn.textContent = remainingTotal > 0
? ('Save all remaining (' + remainingTotal + ')')
: 'Save all remaining';
}
paintRemainingList();
}
async function refreshRemainingCount() {
const summary = document.getElementById('remaining-summary');
if (summary && !bulkRunning) summary.textContent = 'Counting titles left to save…';
const startBtn = document.getElementById('bulk-archive-start');
if (startBtn && !bulkRunning) startBtn.disabled = true;
try {
const res = await fetch(api('/api/archive-remaining'), { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not count remaining titles');
remainingTotal = Number(data.total) || 0;
remainingItems = Array.isArray(data.remaining) ? data.remaining : [];
updateRemainingSummary();
} catch (error) {
if (summary) {
summary.textContent = error instanceof Error ? error.message : String(error);
}
if (startBtn) startBtn.disabled = true;
}
}
function setBulkConfirm(open) {
const el = document.getElementById('bulk-confirm');
if (!el) return;
if (open) {
el.hidden = false;
el.classList.add('show');
} else {
el.classList.remove('show');
el.hidden = true;
}
}
function appendBulkLog(line) {
const log = document.getElementById('bulk-archive-log');
if (!log) return;
log.textContent += (log.textContent ? '\n' : '') + line;
log.scrollTop = log.scrollHeight;
}
async function runBulkArchive() {
if (bulkRunning) return;
if (!remainingItems.length && remainingTotal > 0) {
await refreshRemainingCount();
}
const queue = remainingItems.slice();
if (!queue.length) {
updateRemainingSummary();
return;
}
bulkRunning = true;
bulkStop = false;
const progress = document.getElementById('bulk-archive-progress');
const status = document.getElementById('bulk-archive-status');
const stopBtn = document.getElementById('bulk-archive-stop');
const startBtn = document.getElementById('bulk-archive-start');
const log = document.getElementById('bulk-archive-log');
if (progress) progress.hidden = false;
if (log) log.textContent = '';
if (stopBtn) stopBtn.hidden = false;
if (startBtn) startBtn.disabled = true;
let archived = 0;
let skipped = 0;
let errors = 0;
const total = queue.length;
let index = 0;
async function worker() {
while (!bulkStop) {
const i = index++;
if (i >= queue.length) return;
const item = queue[i];
const n = i + 1;
if (status) {
status.textContent = 'Saving ' + n + ' of ' + total + ' — ' + (item.title || item.asin);
}
try {
const res = await fetch(api('/api/archive'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ asin: item.asin, title: item.title }),
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
const notEligible = data.retryable === false || data.code === 'ARCHIVE_NOT_ELIGIBLE';
if (res.ok && data.ok) {
if (data.skipped) {
skipped += 1;
appendBulkLog('Skipped (already on NAS): ' + (item.title || item.asin));
} else {
archived += 1;
appendBulkLog('Saved: ' + (item.title || item.asin));
}
libraryItemsCache = libraryItemsCache.map((row) => {
if (row.asin !== item.asin) return row;
return { ...row, onNas: true, nasFilename: data.filename || row.nasFilename };
});
} else if (notEligible) {
skipped += 1;
appendBulkLog('Skipped (not downloadable): ' + (item.title || item.asin));
} else {
errors += 1;
appendBulkLog('Error: ' + (item.title || item.asin) + ' — ' + (data.message || data.error || 'failed'));
}
} catch (error) {
errors += 1;
appendBulkLog('Error: ' + (item.title || item.asin) + ' — ' + (error instanceof Error ? error.message : String(error)));
}
}
}
const workers = [];
for (let w = 0; w < BULK_CONCURRENCY; w++) workers.push(worker());
await Promise.all(workers);
bulkRunning = false;
if (stopBtn) stopBtn.hidden = true;
if (status) {
status.textContent = (bulkStop ? 'Stopped. ' : 'Finished. ') +
'Saved ' + archived + ', skipped ' + skipped + ', errors ' + errors + '.';
}
await refreshRemainingCount();
}
document.getElementById('bulk-archive-start')?.addEventListener('click', () => {
if (bulkRunning || remainingTotal <= 0) return;
const body = document.getElementById('bulk-confirm-body');
if (body) {
body.textContent = 'This will archive ' + remainingTotal +
' purchased title' + (remainingTotal === 1 ? '' : 's') +
' that are not on the NAS. Plus and unsupported titles stay skipped. You can stop anytime.';
}
setBulkConfirm(true);
});
document.getElementById('bulk-confirm-no')?.addEventListener('click', () => setBulkConfirm(false));
document.getElementById('bulk-confirm-yes')?.addEventListener('click', () => {
setBulkConfirm(false);
runBulkArchive();
});
document.getElementById('bulk-archive-stop')?.addEventListener('click', () => {
bulkStop = true;
});
document.getElementById('bulk-archive-refresh')?.addEventListener('click', () => refreshRemainingCount());
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
let touchStartY = 0;
document.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0]?.clientY || 0;
}, { passive: true });
document.addEventListener('touchend', (e) => {
const y = e.changedTouches[0]?.clientY || 0;
if (touchStartY < 40 && y - touchStartY > 70) {
const active = document.querySelector('.panel.active')?.getAttribute('data-panel');
if (active === 'library') loadLibrary({ forceRefresh: true });
if (active === 'wishlist') loadWishlist();
if (active === 'archive') refreshRemainingCount();
refreshStatus();
}
}, { passive: true });
refreshStatus();
})();`
}