diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..e74fdee --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "manifest_version": 3, + "name": "Outlier Saver", + "version": "0.1.0", + "description": "Находит на YouTube видео с аномальными просмотрами и сохраняет их локально.", + "permissions": ["storage"], + "host_permissions": ["https://www.googleapis.com/youtube/v3/*"], + "background": { "service_worker": "src/background.js", "type": "module" }, + "action": { "default_title": "Outlier Saver" } +} diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..671b872 --- /dev/null +++ b/src/background.js @@ -0,0 +1,136 @@ +import { ApiError, fetchChannels, fetchVideos, nextQuotaReset } from './lib/api.js'; +import * as db from './lib/db.js'; +import { processBatch } from './lib/process.js'; +import { getSettings, getStatus, setStatus } from './lib/storage.js'; +import { isVideoId } from './lib/video-id.js'; + +const BATCH_SIZE = 50; +const FLUSH_DELAY_MS = 1000; +const MAX_QUEUE = 5000; +const PAGES = new Set(['home', 'watch', 'search', 'channel']); + +// videoId -> queue item. In memory only: if the worker is killed, the content script +// re-sends visible ids after the next navigation, and `seen` is written only after success. +const queue = new Map(); +let flushTimer = null; +let flushing = false; + +function sanitizeItem(raw) { + if (!raw || !isVideoId(raw.videoId)) return null; + return { + videoId: raw.videoId, + vidiqScore: typeof raw.vidiqScore === 'number' && Number.isFinite(raw.vidiqScore) ? raw.vidiqScore : null, + viaShortsUrl: raw.viaShortsUrl === true, + page: PAGES.has(raw.page) ? raw.page : 'home', + }; +} + +function enqueue(rawItems) { + for (const raw of rawItems) { + const item = sanitizeItem(raw); + if (!item) continue; + const queued = queue.get(item.videoId); + if (queued) { + queued.vidiqScore = item.vidiqScore ?? queued.vidiqScore; + queued.viaShortsUrl = queued.viaShortsUrl || item.viaShortsUrl; + } else if (queue.size < MAX_QUEUE) { + queue.set(item.videoId, item); + } + } + scheduleFlush(); +} + +function requeue(items) { + for (const item of items) if (!queue.has(item.videoId)) queue.set(item.videoId, item); +} + +function takeBatch() { + const batch = []; + for (const [videoId, item] of queue) { + if (batch.length >= BATCH_SIZE) break; + batch.push(item); + queue.delete(videoId); + } + return batch; +} + +function scheduleFlush() { + if (flushTimer !== null || flushing || queue.size === 0) return; + flushTimer = setTimeout(flush, FLUSH_DELAY_MS); +} + +async function updateBadge(status) { + if (status.state === 'ok') { + await chrome.action.setBadgeText({ text: '' }); + return; + } + await chrome.action.setBadgeBackgroundColor({ color: '#d93025' }); + await chrome.action.setBadgeText({ text: '!' }); +} + +async function reportStatus(status) { + await setStatus(status); + await updateBadge(status); +} + +// Returns true when the flush loop may continue, false when collection must stop. +async function handleError(err, batch) { + if (err instanceof ApiError && err.kind === 'quota') { + requeue(batch); + await reportStatus({ state: 'quota', until: nextQuotaReset(), message: err.message }); + return false; + } + if (err instanceof ApiError && err.kind === 'invalid-key') { + requeue(batch); + await reportStatus({ state: 'invalid-key', message: err.message }); + return false; + } + console.warn(`Outlier Saver: dropped a batch of ${batch.length} ids`, err); + return true; +} + +async function flush() { + flushTimer = null; + if (flushing) return; + flushing = true; + try { + while (queue.size > 0) { + const settings = await getSettings(); + if (!settings.apiKey) { + await reportStatus({ state: 'no-key' }); + return; + } + const status = await getStatus(); + if (status.state === 'invalid-key') return; + if (status.state === 'quota' && Date.now() < status.until) return; + if (status.state !== 'ok') await reportStatus({ state: 'ok' }); + + const batch = takeBatch(); + const apiKey = settings.apiKey; + const api = { + fetchVideos: (ids) => fetchVideos(ids, { apiKey }), + fetchChannels: (ids) => fetchChannels(ids, { apiKey }), + }; + try { + await processBatch(batch, { settings, db, api }); + } catch (err) { + if (!(await handleError(err, batch))) return; + } + } + } finally { + flushing = false; + } +} + +chrome.runtime.onMessage.addListener((message) => { + if (message?.type === 'videos-seen' && Array.isArray(message.items)) enqueue(message.items); +}); + +// A new API key clears a previous "no key / invalid key / quota" pause. +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes.settings) return; + if (changes.settings.oldValue?.apiKey === changes.settings.newValue?.apiKey) return; + reportStatus({ state: 'ok' }).then(scheduleFlush); +}); + +getStatus().then(updateBadge);