Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 84540f9f47f0b1fa4d6178dbfcb28806c971d647
parent af8d892dd17b6da8d0f34e6733e04ad6fe6f3226
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 30 Jun 2026 23:56:11 -0400

Merge fix/odysee-player-stale-cache: Odysee videos no longer open in the YouTube player (self-healing IndexedDB cache via DB_VERSION bump + platform-guarded PlayerProvider)

# Conflicts:
#	export/CHANGELOG.md

Diffstat:
Mcommon/components/PlayerProvider.tsx | 15++++++++++++++-
Mcommon/components/transcriptStore.ts | 26++++++++++++++++++++++----
Aeditor/e2e/export-player-platform-cache.spec.ts | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 1+
4 files changed, 233 insertions(+), 5 deletions(-)

diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -673,7 +673,7 @@ export function PlayerProvider({ videoId={data.id} startSeconds={urlTime ?? 0} /> - ) : ( + ) : data.platform === "youtube" ? ( <ReactPlayer ref={(p: ReactPlayerType | null) => { playerRef.current = p; @@ -690,6 +690,19 @@ export function PlayerProvider({ setCurrentTime(s.playedSeconds) } /> + ) : ( + // Unknown/unsupported platform: degrade to a link rather than + // silently embedding the wrong player. + <div className="flex h-full w-full items-center justify-center p-4 text-center"> + <a + href={data.webpageUrl} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-white underline hover:text-zinc-200" + > + Open on source site + </a> + </div> )} {displayMode === "mini" && ( <button diff --git a/common/components/transcriptStore.ts b/common/components/transcriptStore.ts @@ -1,9 +1,13 @@ "use client"; import type { TranscriptDetail } from "../lib/transcripts"; +import { PLATFORM_VALUES } from "../lib/platform"; const DB_NAME = "yt-dlp-transcript-browser"; -const DB_VERSION = 1; +// Bump to invalidate stale cached entries on existing clients. v2 wipes +// pre-multi-platform records that were cached without a `platform` field +// (they would otherwise fall through to the YouTube player forever). +const DB_VERSION = 2; const STORE = "transcripts"; type Mode = "pending" | "ok" | "unavailable"; @@ -28,9 +32,13 @@ function openDb(): Promise<IDBDatabase | null> { } req.onupgradeneeded = () => { const db = req.result; - if (!db.objectStoreNames.contains(STORE)) { - db.createObjectStore(STORE, { keyPath: "slug" }); + // Drop any existing store so a version bump discards stale entries + // (the cache is a pure perf layer over authoritative page JSON, so + // wiping is safe — `load()` re-fetches and re-warms on a miss). + if (db.objectStoreNames.contains(STORE)) { + db.deleteObjectStore(STORE); } + db.createObjectStore(STORE, { keyPath: "slug" }); }; req.onsuccess = () => { mode = "ok"; @@ -73,7 +81,17 @@ export async function idbGet( resolve(null); return; } - req.onsuccess = () => resolve(req.result ?? null); + req.onsuccess = () => { + const entry = req.result ?? null; + // Reject entries whose shape predates multi-platform support (or is + // otherwise corrupt): treat as a miss so `load()` re-fetches the + // authoritative page. Self-heals without per-entry version bookkeeping. + if (entry && !PLATFORM_VALUES.includes(entry.platform)) { + resolve(null); + return; + } + resolve(entry); + }; req.onerror = () => resolve(null); }); } diff --git a/editor/e2e/export-player-platform-cache.spec.ts b/editor/e2e/export-player-platform-cache.spec.ts @@ -0,0 +1,196 @@ +import { test, expect, type Page } from "@playwright/test"; + +// Regression: an Odysee video must not open in the YouTube player because of a +// stale local transcript cache. Before multi-platform support, the export +// cached TranscriptDetail entries in IndexedDB with NO `platform` field and +// never invalidated them, so a video opened back then would forever fall +// through PlayerProvider's ternary to the YouTube <ReactPlayer>. The cache now +// rejects platform-less / unknown-platform entries on read (transcriptStore +// idbGet), so the authoritative page data (platform: "odysee") wins. + +const EXPORT_BASE = `http://localhost:${process.env.EXPORT_PORT ?? 3010}`; + +const CHANNEL = "Test Channel"; +const CHANNEL_SLUG = "test-channel"; +const VIDEO_ID = "odysee-vid-1"; +const SLUG = `${CHANNEL_SLUG}/${VIDEO_ID}`; +const WEBPAGE_URL = `https://odysee.com/TestClip:${VIDEO_ID}`; + +// Full TranscriptDetail the fresh page fetch returns — correctly tagged odysee. +const detail = { + slug: SLUG, + id: VIDEO_ID, + channelSlug: CHANNEL_SLUG, + title: "An Odysee clip", + uploadDate: "20260101", + duration: 300, + channel: CHANNEL, + description: "", + tags: [] as string[], + isLivestream: false, + ageRestricted: false, + platform: "odysee" as string, + webpageUrl: WEBPAGE_URL, + cues: [{ start: 0, end: 5000, text: "hello from odysee" }], +}; + +// Install the network fixtures. `transcriptPlatform` overrides the platform on +// the transcript-page detail (used to exercise the unknown-platform fallback). +async function installRoutes(page: Page, transcriptPlatform = "odysee") { + const summary = { + slug: SLUG, + id: VIDEO_ID, + channelSlug: CHANNEL_SLUG, + title: detail.title, + uploadDate: "20260101", + date: "2026-01-01", + duration: "5:00", + channel: CHANNEL, + isLivestream: false, + ageRestricted: false, + isDeleted: false, + isUnlisted: false, + platform: "odysee", + webpageUrl: WEBPAGE_URL, + }; + + await page.route("**/summaries/manifest.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + version: 3, + totalCount: 1, + pageSize: 1000, + pageCount: 1, + generatedAt: new Date().toISOString(), + channels: [{ name: CHANNEL, count: 1, groupId: "default" }], + groups: [{ id: "default", name: "All channels", selectedByDefault: true }], + defaultGroupId: "default", + }), + }); + }); + await page.route("**/summaries/page-0000.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([summary]), + }); + }); + await page.route("**/transcripts/**/manifest.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + version: 1, + pageSize: 1000, + slugToPage: { [VIDEO_ID]: 0 }, + }), + }); + }); + await page.route("**/transcripts/**/page-*.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([{ ...detail, platform: transcriptPlatform }]), + }); + }); + await page.route("**/subs/manifest.json", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + version: 4, + channels: [], + totalCount: 0, + liveChatTotalCount: 0, + generatedAt: new Date().toISOString(), + }), + }); + }); +} + +// Write a TranscriptDetail into the app's IndexedDB store. `platform` omitted -> +// simulates a pre-multi-platform stale entry. +async function seedCache( + page: Page, + entry: Record<string, unknown>, +): Promise<void> { + await page.evaluate(async (record) => { + await new Promise<void>((resolve, reject) => { + // Match transcriptStore.ts DB_NAME / DB_VERSION / STORE. + const open = indexedDB.open("yt-dlp-transcript-browser", 2); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("transcripts")) { + db.createObjectStore("transcripts", { keyPath: "slug" }); + } + }; + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction("transcripts", "readwrite"); + tx.objectStore("transcripts").put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }; + open.onerror = () => reject(open.error); + }); + }, entry); +} + +async function waitForHydration(page: Page) { + await page.getByTestId("query-builder").waitFor(); +} + +const odyseeIframe = (page: Page) => + page.locator('iframe[src*="odysee.com/%24/embed/"]'); +const youtubeIframe = (page: Page) => + page.locator('iframe[src*="youtube.com/watch"]'); + +test.describe("export player — stale platform cache", () => { + test("a stale cache entry without a platform self-heals to the Odysee player", async ({ + page, + }) => { + await installRoutes(page); + // Load once to create the v2 store, then plant the stale (platform-less) + // entry an older build would have left behind. + await page.goto(EXPORT_BASE); + await waitForHydration(page); + await seedCache(page, { + slug: SLUG, + id: VIDEO_ID, + channelSlug: CHANNEL_SLUG, + title: detail.title, + uploadDate: "20260101", + duration: 300, + channel: CHANNEL, + description: "", + tags: [], + isLivestream: false, + ageRestricted: false, + // platform intentionally omitted + webpageUrl: WEBPAGE_URL, + cues: detail.cues, + }); + + // Deep-link straight to the video; idbGet should reject the stale entry, + // refetch the authoritative odysee page, and render the Odysee iframe. + await page.goto(`${EXPORT_BASE}/?v=${encodeURIComponent(SLUG)}`); + await expect(odyseeIframe(page)).toBeVisible(); + await expect(youtubeIframe(page)).toHaveCount(0); + }); + + test("an unknown platform shows a source-site link instead of a wrong embed", async ({ + page, + }) => { + // Fresh data itself carries an unrecognized platform (defense-in-depth: + // PlayerProvider must not silently embed YouTube). + await installRoutes(page, "vimeo"); + await page.goto(`${EXPORT_BASE}/?v=${encodeURIComponent(SLUG)}`); + await expect( + page.getByRole("link", { name: /Open on source site/i }), + ).toBeVisible(); + await expect(youtubeIframe(page)).toHaveCount(0); + await expect(odyseeIframe(page)).toHaveCount(0); + }); +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Fixed: some Odysee/Rumble/Twitch videos opened in a YouTube player.** A video you'd opened before multi-platform support was added could get stuck playing in the YouTube embed even after its data was corrected, because the browser's local transcript cache (IndexedDB) held an old record with no platform and was never invalidated. The cache now self-heals — stale records are discarded on load — so every video plays in its correct platform's player. As a safeguard, a video whose platform somehow can't be determined now shows an "Open on source site" link instead of silently loading the wrong player. - **Instant offline word-search, powered by a background FlexSearch index.** On the Offline page, each channel now has a **Build index** action: it constructs a FlexSearch full-text index for that channel in a **Web Worker** — entirely off the main thread, so indexing a large channel never freezes the page (a live progress % shows the work). Once built, a search box does **instant word/prefix search** across your indexed channels and returns the matching transcript lines (with channel + timestamp) — and because the index is built from the same shards the offline download caches, it works with no connection. The worker bundles into the static site (no server needed). This is a first, self-contained surface; the main search box will gain the same acceleration next. - **Install the archive as an app, and search it offline.** Each public site is now a PWA: it ships a web app manifest (named + themed with the site's accent color) and an icon set, so your browser can install it to your home screen or desktop and open it in its own window. A service worker caches the app so it loads instantly on repeat visits and still opens without a connection. A new **Offline** page (linked in the footer) lists the site's channels with a **Download** button each — pick the channels you want and their transcripts are saved to your device so you can **search them with no connection**; a live Online/Offline indicator and per-channel "Available offline" state show what's ready, and you can remove a channel to free space. Downloads are opt-in (a channel's transcripts can be large) and refresh automatically when the site is rebuilt. Everything still works online-only in browsers without service-worker support. - **Two more themes + a theme picker.** A palette menu next to the light/dark toggle lets you switch the whole site's look between **Archive** (the warm reading room), **Terminal** (an ice-cyan signal terminal), **Swiss** (red/black/white editorial), and **Base** (neutral) — each in light or dark, and your choice is remembered. Switching is instant and purely cosmetic (just colors; the layout never changes).