feat: add service worker with batching and status handling

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
IgorVolochay
2026-10-25 10:51:22 +03:00
co-authored by Claude Sonnet 5.5
parent 3d7c0aa4fd
commit 3a28991522
2 changed files with 146 additions and 0 deletions
+10
View File
@@ -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" }
}
+136
View File
@@ -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);