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,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