yt-playlist-features/src/background/service-worker.ts

119 lines
3.6 KiB
TypeScript

import browser from "webextension-polyfill";
import type { Message } from "../shared/messages";
import type { DetailUpdate } from "../types/playlist";
import { CATEGORY_MAP } from "../shared/category-map";
const LOG = "[yt-playlist-features:bg]";
const DEFAULT_API_KEY = "AIzaSyDPyWG3ABnVV3en_KBhIxUH6O2_A0oP4Wk";
const BATCH_SIZE = 50;
async function getApiKey(): Promise<string> {
const result = await browser.storage.sync.get("apiKey");
return (result.apiKey as string) || DEFAULT_API_KEY;
}
const BATCH_DELAY = 500; // ms between batches
const RETRY_DELAY = 5000; // ms before retrying after rate limit
const MAX_RETRIES = 3;
async function fetchVideoDetails(
videoIds: string[],
tabId: number,
): Promise<void> {
const apiKey = await getApiKey();
if (!apiKey) {
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: "no-api-key",
} satisfies Message);
return;
}
for (let i = 0; i < videoIds.length; i += BATCH_SIZE) {
if (i > 0) await new Promise((r) => setTimeout(r, BATCH_DELAY));
const batch = videoIds.slice(i, i + BATCH_SIZE);
const ids = batch.join(",");
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics&id=${ids}&key=${apiKey}`;
let success = false;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
console.log(LOG, `Retry ${attempt} after rate limit, waiting ${RETRY_DELAY}ms...`);
await new Promise((r) => setTimeout(r, RETRY_DELAY));
}
const res = await fetch(url);
if (res.status === 403) {
console.warn(LOG, `Rate limited (attempt ${attempt + 1}/${MAX_RETRIES})`);
continue;
}
if (!res.ok) {
const body = await res.text();
console.error(LOG, `API error ${res.status}:`, body);
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: `api-error-${res.status}`,
} satisfies Message);
return;
}
const data = await res.json();
const updates: DetailUpdate[] = (data.items ?? []).map((item: any) => ({
videoId: item.id,
viewCountText: item.statistics?.viewCount
? parseInt(item.statistics.viewCount, 10).toLocaleString()
: null,
publishedAt: item.snippet?.publishedAt?.slice(0, 10) ?? null,
category: CATEGORY_MAP[item.snippet?.categoryId] ?? null,
}));
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_UPDATE",
updates,
} satisfies Message);
success = true;
break;
} catch (e) {
console.error(LOG, "Fetch failed:", e);
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: "network-error",
} satisfies Message);
return;
}
}
if (!success) {
console.error(LOG, "Max retries exceeded for batch");
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: "rate-limit-exceeded",
} satisfies Message);
return;
}
}
}
browser.runtime.onMessage.addListener(
async (message: unknown, sender: browser.Runtime.MessageSender) => {
const msg = message as Message;
if (msg.type === "PLAYLIST_EXTRACTED") {
console.log(
LOG,
`Playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
);
}
if (msg.type === "FETCH_VIDEO_DETAILS") {
const tabId = sender.tab?.id;
if (!tabId) return;
fetchVideoDetails(msg.videoIds, tabId);
}
},
);
console.log(LOG, "Service worker started.");