157 lines
4.9 KiB
TypeScript
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);
|
|
}); |