Archilyzer · Source

archilyzer

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

commit 41005d01cd4962be9fe25495a755dc69a1c0636a
parent 935569938878d3ecc5ae8d3c90286f239ec367cf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 22 Jun 2026 21:31:51 -0400

Fix Odysee videos opening in the YouTube player (stale IndexedDB cache)

The export transcript cache (IndexedDB) was keyed by slug with no
versioning or invalidation, and load() returns the cached entry before
fetching the authoritative page JSON. Entries cached before multi-platform
support lacked a `platform` field, so PlayerProvider's player ternary fell
through to the YouTube <ReactPlayer> forever — even though the rebuilt data
correctly says platform: "odysee".

- transcriptStore: bump DB_VERSION 1->2 (wipes the store on upgrade) and
  reject entries whose platform isn't a valid Platform on read, so stale
  records self-heal by refetching the authoritative page.
- PlayerProvider: render the YouTube player only when platform === "youtube";
  unknown platforms degrade to an "Open on source site" link instead of
  silently embedding the wrong player.
- Add export-player-platform-cache e2e regression spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mcommon/components/PlayerProvider.tsx | 15++++++++++++++-
Mcommon/components/transcriptStore.ts | 26++++++++++++++++++++++----
Aeditor/e2e/export-player-platform-cache.spec.ts | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
4 files changed, 235 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,5 +1,8 @@ # Changelog +## [0.4.2] - 2026-06-22 +- **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. + ## [0.4.1] - 2026-06-22 - **New "Content added" charts: track when videos joined the library, not just when they were uploaded.** The time axis of any chart can now bin on **when we downloaded** or **transcribed** a video, in addition to its upload date — pick the **Date field** in the chart options. The dashboard ships a cumulative **Library growth (added)** chart by default (grouped by channel), and the chart gallery gains a **Content added** group with cumulative download/transcription curves and a per-month stacked-by-channel breakdown. Like every chart, these filter by channel and export to CSV/PNG. (Populating acquisition dates for existing videos requires a one-time data rebuild — `build:index` + `build:stats` re-extract on the bumped stats schema; videos added before this release fall back to file timestamps.)