yt-playlist-features/src/content/index.ts
2026-04-09 03:00:56 +09:00

157 lines
4.9 KiB
TypeScript

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);
});