feat: add service worker with batching and status handling
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3d7c0aa4fd
commit
3a28991522
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user