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 { 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 { 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.");