import browser from "webextension-polyfill"; import { onPlaylistPageReady, getPlaylistId } from "./navigation"; import { parseInitialData, buildPlaylistData } from "./extractor"; import type { PlaylistVideo } from "../types/playlist"; import type { Message } from "../shared/messages"; import { mountTable, appendToTable, setTableComplete } from "./ui/lifecycle"; const LOG = "[yt-playlist-features]"; let lastExtractedId: string | null = null; let pageScriptInjected = false; function ensurePageScript(): void { if (pageScriptInjected) return; const script = document.createElement("script"); script.src = browser.runtime.getURL("injected/page-script.js"); script.onload = () => script.remove(); (document.head || document.documentElement).appendChild(script); pageScriptInjected = true; } function handlePlaylistData(event: Event): void { const detail = (event as CustomEvent).detail; if (!detail) return; let payload: any; try { payload = JSON.parse(detail); } catch { console.error(LOG, "Failed to parse payload JSON"); return; } // Skip append messages — handled separately if (payload.type === "append") return; if (payload.error) { console.warn(LOG, "Page script error:", payload.error); return; } const { videos: domVideos, initialData } = payload; // Extract metadata from ytInitialData const parsed = initialData ? parseInitialData(initialData) : null; const metadata = parsed?.metadata; if (!metadata) { console.warn(LOG, "Could not extract playlist metadata"); return; } // Convert DOM-extracted videos to PlaylistVideo[] const videos: PlaylistVideo[] = domVideos.map((v: any) => { const bylineRun = v.shortBylineText?.runs?.[0]; const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint; return { videoId: v.videoId, title: v.title, index: v.index, durationSeconds: v.lengthSeconds, durationText: v.lengthText || null, thumbnails: v.thumbnails, channel: { name: bylineRun?.text ?? "", channelId: bylineEndpoint?.browseId ?? "", url: bylineRun?.navigationEndpoint?.commandMetadata ?.webCommandMetadata?.url ?? "", }, isPlayable: v.isPlayable, isLive: v.badges?.some( (b: any) => b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", ) ?? false, addedBy: v.addedBy ?? null, voteCount: v.voteCount ?? null, } satisfies PlaylistVideo; }); const playlistData = buildPlaylistData(metadata, videos, payload.isComplete !== false); console.log( LOG, `Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount}/${metadata.videoCount} videos)`, ); mountTable(playlistData); const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData }; browser.runtime.sendMessage(message).catch((err) => { console.error(LOG, "Failed to send playlist data to background:", err); }); } function handlePlaylistAppend(event: Event): void { const detail = (event as CustomEvent).detail; if (!detail) return; let payload: any; try { payload = JSON.parse(detail); } catch { return; } if (payload.type === "append") { const newVideos: PlaylistVideo[] = payload.videos.map((v: any) => { const bylineRun = v.shortBylineText?.runs?.[0]; const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint; return { videoId: v.videoId, title: v.title, index: v.index, durationSeconds: v.lengthSeconds, durationText: v.lengthText || null, thumbnails: v.thumbnails, channel: { name: bylineRun?.text ?? "", channelId: bylineEndpoint?.browseId ?? "", url: bylineRun?.navigationEndpoint?.commandMetadata ?.webCommandMetadata?.url ?? "", }, isPlayable: v.isPlayable, isLive: v.badges?.some( (b: any) => b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", ) ?? false, addedBy: v.addedBy ?? null, voteCount: v.voteCount ?? null, } satisfies PlaylistVideo; }); appendToTable(newVideos); if (payload.isComplete) { setTableComplete(payload.totalCount); } } } // Listen for data from the injected page script document.addEventListener("__yt_playlist_ext_data", handlePlaylistData); document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend); // Detect playlist page navigation and trigger extraction onPlaylistPageReady(() => { const playlistId = getPlaylistId(); if (!playlistId || playlistId === lastExtractedId) return; lastExtractedId = playlistId; ensurePageScript(); // Page script handles yt-navigate-finish itself, but send a trigger // as a fallback in case it missed the event (e.g. timing edge cases) setTimeout(() => { document.dispatchEvent(new CustomEvent("__yt_playlist_ext_trigger")); }, 50); });