"use client"; // Client helpers for the opt-in "download this channel for offline" feature and // the pinned-channel registry. Talks to the service worker (public/sw.js) over a // MessageChannel; degrades gracefully where no SW is controlling the page. // // Origin-aware: every entry point takes an `origin` ("" = same-origin, the // single-site case; a full origin like "https://x.com" for a federated hub // channel). idBaseUrl("") === "" so same-origin URLs stay root-relative and the // pinned-registry key stays the bare slug — the single-site path is unchanged. // The hub SW (sw-hub.js) reads the `origin` in each message to scope caching // per (origin, slug); the site SW ignores it (same-origin only). import { channelArchiveUrls, siteArchiveUrls, type PagedManifest, } from "yt-dlp-transcript-common/lib/archive/offlineUrls"; import { idBaseUrl, makeId, splitId } from "yt-dlp-transcript-common/components/originId"; const PINNED_KEY = "ytdlp-tb:offline-channels"; export function swSupported(): boolean { return ( typeof navigator !== "undefined" && "serviceWorker" in navigator ); } async function controller(): Promise { if (!swSupported()) return null; // Wait for an active, controlling worker (first load registers but may not // control this page until reload; ready resolves once active). const reg = await navigator.serviceWorker.ready.catch(() => null); return navigator.serviceWorker.controller ?? reg?.active ?? null; } // Round-trip a message to the SW over a fresh MessageChannel. `onMessage` sees // every reply; the promise resolves when a reply of type `done`/`ok`/`status` // arrives (or rejects on timeout). function sendToSw( sw: ServiceWorker, message: unknown, onMessage?: (data: any) => void, ): Promise { return new Promise((resolve, reject) => { const channel = new MessageChannel(); const timer = setTimeout(() => reject(new Error("SW timeout")), 10 * 60_000); channel.port1.onmessage = (e) => { const data = e.data || {}; onMessage?.(data); if (data.type === "done" || data.ok || data.type === "status") { clearTimeout(timer); resolve(data); } }; sw.postMessage(message, [channel.port2]); }); } // Deliberately NOT the shared ArchiveReader: the reader is a normal `fetch`, so // a page walking it would be answered from the very service-worker cache this // function exists to REFILL, and would compute its download list from the stale // copy. `cache: "no-store"` is the whole point of this read. What is shared is // the thing that actually drifted — the URL shape, and the two lists in // common/lib/archive/offlineUrls.ts. async function readManifest(url: string): Promise { try { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) return null; return (await res.json()) as PagedManifest; } catch { return null; } } export type DownloadProgress = { done: number; total: number }; // Is any channel of this origin already pinned? That is the test for whether // the origin's SITE DATA (summaries, stats, the root files) is already in the // cache, and it is deliberately answered from the pinned registry rather than // by asking the worker: the two lists are written and evicted together, so one // pinned channel means one downloaded copy of the site data. // // The failure mode this accepts is the one the registry already has — a viewer // who clears site data while keeping localStorage gets a pin with no bytes // behind it, which is exactly why channelCachedPages() exists. The honest fix // is a status round-trip per origin; it is not worth a second SW message for a // case a re-pin already repairs. function originHasPin(origin: string): boolean { return pinnedChannels().some((id) => splitId(id).origin === origin); } // Download a channel for offline use, reporting progress. // // EVERY PER-CHANNEL LAYER THAT HAS A MANIFEST, not just /transcripts — that is // why an offline channel now has its live chat, its social posts and its AI // digests — plus, ONCE PER ORIGIN, the site-wide documents the viewer needs to // render any of it: the summaries index, stats, and the root files. The // duplicates report is one of those root files, which is why the duplicates page // used to be dead with no connection. // // The site data is downloaded with the FIRST channel pinned on an origin and // skipped for every channel after it. Pinning it per channel is ~41.5 MB of // identical bytes each time on a corpus the size of jeralyzer, re-fetched for // real because the worker bulk-caches with `cache: "reload"`. export async function downloadChannelOffline( origin: string, slug: string, onProgress?: (p: DownloadProgress) => void, ): Promise { const sw = await controller(); if (!sw) return false; const base = idBaseUrl(origin); const urls = await channelArchiveUrls(readManifest, base, slug); // No transcripts manifest: the channel is not readable, so there is nothing // to take offline. Refused before anything is written, exactly as before. if (urls.length === 0) return false; // Checked BEFORE this channel is pinned, so the first channel of an origin // brings the site data with it and later ones do not. if (!originHasPin(origin)) { urls.push(...(await siteArchiveUrls(readManifest, base))); } await sendToSw(sw, { type: "CACHE_URLS", origin, slug, urls }, (data) => { if (data.type === "progress") { onProgress?.({ done: data.done, total: data.total }); } }); pin(origin, slug); return true; } // Remove a channel's offline copy — and, when it was the LAST pinned channel of // its origin, that origin's site data with it. Without the second half the // summaries/stats/root bytes survive every removal and there is no way to get // them back out of the cache at all; they are downloaded once, so they have to // be evicted once. export async function evictChannelOffline( origin: string, slug: string, ): Promise { const sw = await controller(); if (sw) { await sendToSw(sw, { type: "EVICT_CHANNEL", origin, slug }).catch(() => {}); } unpin(origin, slug); if (sw && !originHasPin(origin)) { await sendToSw(sw, { type: "EVICT_SITE", origin }).catch(() => {}); } } // How many page shards of a channel are currently cached (0 = not offline). export async function channelCachedPages( origin: string, slug: string, ): Promise { const sw = await controller(); if (!sw) return 0; try { const data = await sendToSw(sw, { type: "CHANNEL_STATUS", origin, slug }); return typeof data.cachedPages === "number" ? data.cachedPages : 0; } catch { return 0; } } // ---- Pinned registry (which channels the user chose to keep offline) ---- // Entries are origin-qualified ids (makeId): a bare slug for same-origin, // "origin\tslug" for a federated channel — so a hub can pin the same channel // name from different sites without collision, and single-site pins are // unchanged (bare slugs). export function pinnedChannels(): string[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(PINNED_KEY); if (!raw) return []; const arr = JSON.parse(raw); return Array.isArray(arr) ? arr.filter((x) => typeof x === "string") : []; } catch { return []; } } export function isPinned(origin: string, slug: string): boolean { return pinnedChannels().includes(makeId(origin, slug)); } function writePinned(ids: string[]): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(PINNED_KEY, JSON.stringify([...new Set(ids)])); } catch { /* quota — non-fatal */ } } function pin(origin: string, slug: string): void { writePinned([...pinnedChannels(), makeId(origin, slug)]); } function unpin(origin: string, slug: string): void { const id = makeId(origin, slug); writePinned(pinnedChannels().filter((s) => s !== id)); }