Skip to content
← 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, '&quot;') + '">' + 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, '&quot;') + '"/>';
    }
    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, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  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();
})();`
}