import { test, expect, type Page } from "@playwright/test"; import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // 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 . 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:${portFor("EXPORT_PORT")}`; 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, ): Promise { await page.evaluate(async (record) => { await new Promise((resolve, reject) => { // Match transcriptStore.ts DB_NAME / DB_VERSION / STORE. v3 uses // out-of-line keys: the record is stored under its OriginId (for // same-origin content that's just the bare slug). const open = indexedDB.open("yt-dlp-transcript-browser", 3); open.onupgradeneeded = () => { const db = open.result; if (!db.objectStoreNames.contains("transcripts")) { db.createObjectStore("transcripts"); } }; open.onsuccess = () => { const db = open.result; const tx = db.transaction("transcripts", "readwrite"); tx.objectStore("transcripts").put(record, record.slug as string); 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); }); });