Run the title catch-up in its own YouTube lane
This commit is contained in:
@@ -29,8 +29,6 @@ export interface BackfillMember {
|
||||
backend: BackendName;
|
||||
mode: ScrapeMode;
|
||||
pending: number;
|
||||
/** YouTube-like rows still missing a title. */
|
||||
pendingTitles: number;
|
||||
cached: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
@@ -154,7 +152,6 @@ export class BackfillQueue {
|
||||
backend: meta.backend,
|
||||
mode: meta.mode,
|
||||
pending: 0,
|
||||
pendingTitles: 0,
|
||||
cached: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
@@ -168,7 +165,6 @@ export class BackfillQueue {
|
||||
try {
|
||||
const counts = store.previewCounts();
|
||||
member.pending = counts.pending;
|
||||
member.pendingTitles = counts.titlePending;
|
||||
member.cached = counts.cached;
|
||||
member.failed = counts.failed;
|
||||
member.total = counts.total;
|
||||
@@ -209,10 +205,7 @@ export class BackfillQueue {
|
||||
for (const [chatId, group] of groups) {
|
||||
// Hide chats that are fully backfilled unless their job did work this session.
|
||||
const tracked = this.jobs.get(chatId);
|
||||
const pending = group.members.reduce(
|
||||
(sum, member) => sum + member.pending + member.pendingTitles,
|
||||
0
|
||||
);
|
||||
const pending = group.members.reduce((sum, member) => sum + member.pending, 0);
|
||||
if (!tracked && pending === 0) continue;
|
||||
|
||||
const perMinute = tracked?.perMinute ?? this.estimatedPerMinute(settings, gaps);
|
||||
@@ -418,9 +411,7 @@ export class BackfillQueue {
|
||||
|
||||
while (!job.stop) {
|
||||
const group = this.plan(false).get(job.chatId);
|
||||
const member = group?.members.find(
|
||||
candidate => candidate.pending > 0 || candidate.pendingTitles > 0
|
||||
);
|
||||
const member = group?.members.find(candidate => candidate.pending > 0);
|
||||
if (!member) break;
|
||||
|
||||
const limit = this.engine.rateLimitStatus();
|
||||
|
||||
+63
-42
@@ -46,14 +46,21 @@ export interface ScrapeBackend {
|
||||
fetchPage(channelId: string, before: string | null, limit: number): Promise<FetchPage>;
|
||||
}
|
||||
|
||||
/** One batch of title resolution, as returned by `titleBatch`. */
|
||||
export interface TitleBatchResult {
|
||||
titled: number;
|
||||
/** Rows closed out because the provider never returned a title. */
|
||||
failed: number;
|
||||
remaining: number;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
/** One batch of preview work, as returned by `previewBatch`. */
|
||||
export interface PreviewBatchResult {
|
||||
cached: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
ratelimited: number;
|
||||
/** Titles resolved in this batch. */
|
||||
titled: number;
|
||||
/** Rows still pending after the batch. */
|
||||
remaining: number;
|
||||
/** True when a stop request interrupted the batch. */
|
||||
@@ -550,7 +557,7 @@ export class ScrapeEngine {
|
||||
const dir = join(this.options.rootDir, scrapeId);
|
||||
const meta = this.active.get(scrapeId) ?? readScrapeMeta(dir);
|
||||
if (!meta) {
|
||||
return { cached: 0, skipped: 0, failed: 0, ratelimited: 0, titled: 0, remaining: 0, stopped: false };
|
||||
return { cached: 0, skipped: 0, failed: 0, ratelimited: 0, remaining: 0, stopped: false };
|
||||
}
|
||||
|
||||
const releaseTraffic = await this.acquireTraffic();
|
||||
@@ -563,6 +570,56 @@ export class ScrapeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve YouTube titles for up to `limit` rows. No media-traffic lock: these requests go to
|
||||
* YouTube's oEmbed, never Discord, so the lane can run beside a walk. The caller paces.
|
||||
*/
|
||||
async titleBatch(scrapeId: string, limit: number): Promise<TitleBatchResult> {
|
||||
const dir = join(this.options.rootDir, scrapeId);
|
||||
const meta = this.active.get(scrapeId) ?? readScrapeMeta(dir);
|
||||
const result: TitleBatchResult = { titled: 0, failed: 0, remaining: 0, stopped: false };
|
||||
if (!meta) return result;
|
||||
|
||||
const store = new ScrapeStore(dir);
|
||||
const options = this.previewOptions(meta);
|
||||
try {
|
||||
for (const row of store.pendingTitles(Math.max(1, limit))) {
|
||||
if (this.stopRequested.has(meta.id)) {
|
||||
meta.state = "paused";
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const title = await resolveEmbedTitle(row.url, options);
|
||||
if (title) {
|
||||
store.markTitle(row.id, title);
|
||||
result.titled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const attempts = store.bumpTitleAttempt(row.id);
|
||||
const maxAttempts = this.options.previewMaxAttempts ?? 3;
|
||||
if (attempts >= maxAttempts) {
|
||||
store.markTitleError(row.id, `no title after ${attempts} attempt(s)`);
|
||||
result.failed++;
|
||||
this.log(
|
||||
`${meta.id}: title #${row.id} gave up after ${attempts} attempts (${row.url.slice(0, 60)})`
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`${meta.id}: title #${row.id} unresolved (${attempts}/${maxAttempts}) (${row.url.slice(0, 60)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.remaining = store.pendingTitles(5000).length;
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** One batch of preview rows. Mutates the store and the meta counters. */
|
||||
private async previewRows(
|
||||
store: ScrapeStore,
|
||||
@@ -576,47 +633,11 @@ export class ScrapeEngine {
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
ratelimited: 0,
|
||||
titled: 0,
|
||||
remaining: 0,
|
||||
stopped: false
|
||||
};
|
||||
|
||||
// Title work first, then previews with what is left of the batch. YouTube rows that
|
||||
// never got a title share the queue with the preview pass.
|
||||
const titleBudget = store.pendingTitles(1).length > 0 && limit > 1 ? Math.ceil(limit / 2) : limit;
|
||||
for (const row of store.pendingTitles(titleBudget)) {
|
||||
if (this.stopRequested.has(meta.id)) {
|
||||
meta.state = "paused";
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const title = await resolveEmbedTitle(row.url, options);
|
||||
if (title) {
|
||||
store.markTitle(row.id, title);
|
||||
result.titled++;
|
||||
} else {
|
||||
const attempts = store.bumpTitleAttempt(row.id);
|
||||
const maxAttempts = this.options.previewMaxAttempts ?? 3;
|
||||
if (attempts >= maxAttempts) {
|
||||
store.markTitleError(row.id, `no title after ${attempts} attempt(s)`);
|
||||
this.log(`${meta.id}: title #${row.id} gave up after ${attempts} attempts (${row.url.slice(0, 60)})`);
|
||||
} else {
|
||||
this.log(`${meta.id}: title #${row.id} unresolved (${attempts}/${maxAttempts}) (${row.url.slice(0, 60)})`);
|
||||
}
|
||||
}
|
||||
|
||||
this.persist(dir, meta);
|
||||
await this.sleep(previewGapMs(options));
|
||||
}
|
||||
|
||||
if (result.stopped) {
|
||||
result.remaining = store.pendingPreviews(5000, true).length;
|
||||
return result;
|
||||
}
|
||||
|
||||
const previewLimit = Math.max(1, limit - result.titled);
|
||||
const pending = store.pendingPreviews(previewLimit, true);
|
||||
const pending = store.pendingPreviews(limit, true);
|
||||
|
||||
for (const row of pending) {
|
||||
if (this.stopRequested.has(meta.id)) {
|
||||
@@ -752,10 +773,10 @@ export class ScrapeEngine {
|
||||
}
|
||||
|
||||
result.remaining = store.pendingPreviews(5000, true).length;
|
||||
if (pending.length || result.titled) {
|
||||
if (pending.length) {
|
||||
this.log(
|
||||
`${meta.id}: preview pass done: ${result.cached} cached, ${result.skipped} skipped, ` +
|
||||
`${result.failed} failed, ${result.titled} titled, ${result.remaining} still pending`
|
||||
`${result.failed} failed, ${result.remaining} still pending`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
type BackfillStatus,
|
||||
type BackfillTotals
|
||||
} from "./backfill.js";
|
||||
export { TitleLane, type TitleLaneOptions, type TitleLaneStatus } from "./titles.js";
|
||||
export {
|
||||
ScrapeEngine,
|
||||
type FetchPage,
|
||||
|
||||
@@ -538,6 +538,10 @@ export class ScrapeStore {
|
||||
failed: number;
|
||||
/** YouTube-like rows still missing a title. */
|
||||
titlePending: number;
|
||||
/** Rows with a stored title. */
|
||||
titled: number;
|
||||
/** Rows closed out without a title. */
|
||||
titleFailed: number;
|
||||
byKind: Record<MediaKind, { total: number; pending: number; cached: number; failed: number }>;
|
||||
} {
|
||||
const empty = () => ({ total: 0, pending: 0, cached: 0, failed: 0 });
|
||||
@@ -583,15 +587,24 @@ export class ScrapeStore {
|
||||
|
||||
const titles = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM media
|
||||
WHERE title IS NULL AND title_error IS NULL
|
||||
AND (lower(COALESCE(provider, '')) LIKE '%youtube%'
|
||||
OR lower(url) LIKE '%youtube.com/%'
|
||||
OR lower(url) LIKE '%youtu.be/%')`
|
||||
`SELECT
|
||||
SUM(title IS NULL AND title_error IS NULL
|
||||
AND (lower(COALESCE(provider, '')) LIKE '%youtube%'
|
||||
OR lower(url) LIKE '%youtube.com/%'
|
||||
OR lower(url) LIKE '%youtu.be/%')) AS pending,
|
||||
SUM(title IS NOT NULL) AS titled,
|
||||
SUM(title IS NULL AND title_error IS NOT NULL) AS failed
|
||||
FROM media`
|
||||
)
|
||||
.get() as { count: number | null } | undefined;
|
||||
.get() as { pending: number | null; titled: number | null; failed: number | null } | undefined;
|
||||
|
||||
return { ...totals, titlePending: titles?.count ?? 0, byKind };
|
||||
return {
|
||||
...totals,
|
||||
titlePending: titles?.pending ?? 0,
|
||||
titled: titles?.titled ?? 0,
|
||||
titleFailed: titles?.failed ?? 0,
|
||||
byKind
|
||||
};
|
||||
}
|
||||
|
||||
/** Count one transient preview failure and return the new total. */
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* The YouTube title catch-up lane.
|
||||
*
|
||||
* Titles normally arrive with the scrape (Discord embeds carry them), so this lane only exists
|
||||
* for rows indexed before titles were stored. It talks to YouTube's oEmbed directly, never to
|
||||
* Discord, so it takes no media-traffic lock and can run beside a walk. Pacing is hard-coded
|
||||
* (100 rows per batch, 10 s between batches): the lane is a one-time catch-up, not a tuned knob.
|
||||
*/
|
||||
|
||||
import { ScrapeEngine } from "./engine.js";
|
||||
import { listScrapes, ScrapeStore, scrapeDir } from "./store.js";
|
||||
|
||||
export interface TitleLaneStatus {
|
||||
state: "idle" | "running" | "paused" | "done";
|
||||
pending: number;
|
||||
resolved: number;
|
||||
failed: number;
|
||||
/** Rows handled since the lane started. */
|
||||
processed: number;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
perMinute: number | null;
|
||||
etaMs: number | null;
|
||||
/** The scrape being worked on right now. */
|
||||
currentScrapeId: string | null;
|
||||
}
|
||||
|
||||
export interface TitleLaneOptions {
|
||||
rootDir: string;
|
||||
log?: (line: string) => void;
|
||||
now?: () => number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Hard-coded in production; tests override. */
|
||||
batchSize?: number;
|
||||
batchPauseMs?: number;
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const BATCH_PAUSE_MS = 10_000;
|
||||
|
||||
interface LaneState {
|
||||
state: TitleLaneStatus["state"];
|
||||
stop: boolean;
|
||||
startedAt: number | null;
|
||||
finishedAt: number | null;
|
||||
processed: number;
|
||||
perMinute: number | null;
|
||||
currentScrapeId: string | null;
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export class TitleLane {
|
||||
private readonly state: LaneState = {
|
||||
state: "idle",
|
||||
stop: false,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
processed: 0,
|
||||
perMinute: null,
|
||||
currentScrapeId: null
|
||||
};
|
||||
|
||||
private active: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly engine: ScrapeEngine,
|
||||
private readonly options: TitleLaneOptions
|
||||
) {}
|
||||
|
||||
private now(): number {
|
||||
return this.options.now?.() ?? Date.now();
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return (this.options.sleep ?? defaultSleep)(ms);
|
||||
}
|
||||
|
||||
/** Totals across every scrape, with the lane's own run state. */
|
||||
status(): TitleLaneStatus {
|
||||
let pending = 0;
|
||||
let resolved = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const meta of listScrapes(this.options.rootDir)) {
|
||||
if (meta.state === "deleted" || meta.state === "deleting" || meta.state === "pending-delete") {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const store = new ScrapeStore(scrapeDir(this.options.rootDir, meta.id));
|
||||
try {
|
||||
const counts = store.previewCounts();
|
||||
pending += counts.titlePending;
|
||||
resolved += counts.titled ?? 0;
|
||||
failed += counts.titleFailed ?? 0;
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
} catch {
|
||||
// an unreadable index counts as nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
const runMs = this.state.startedAt ? Math.max(1, this.now() - this.state.startedAt) : 0;
|
||||
const perMinute =
|
||||
this.state.perMinute ??
|
||||
(runMs && this.state.processed ? this.state.processed / (runMs / 60_000) : null);
|
||||
|
||||
return {
|
||||
state: this.state.state,
|
||||
pending,
|
||||
resolved,
|
||||
failed,
|
||||
processed: this.state.processed,
|
||||
startedAt: this.state.startedAt ? new Date(this.state.startedAt).toISOString() : null,
|
||||
finishedAt: this.state.finishedAt ? new Date(this.state.finishedAt).toISOString() : null,
|
||||
perMinute: perMinute === null ? null : Math.round(perMinute * 10) / 10,
|
||||
etaMs:
|
||||
pending > 0 && perMinute && perMinute > 0
|
||||
? Math.round((pending / perMinute) * 60_000)
|
||||
: null,
|
||||
currentScrapeId: this.state.currentScrapeId
|
||||
};
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.state.stop = false;
|
||||
this.state.state = "running";
|
||||
this.state.startedAt = this.state.startedAt ?? this.now();
|
||||
this.state.finishedAt = null;
|
||||
this.options.log?.("titles: lane started");
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.state.state !== "running") return;
|
||||
this.state.stop = true;
|
||||
this.state.state = "paused";
|
||||
this.options.log?.("titles: pausing");
|
||||
}
|
||||
|
||||
/** Resolves when the lane has stopped for now. */
|
||||
idle(): Promise<void> {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
this.active = this.active.then(async () => {
|
||||
if (this.state.state !== "running") return;
|
||||
await this.run();
|
||||
});
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
const batchSize = Math.max(1, this.options.batchSize ?? BATCH_SIZE);
|
||||
const batchPauseMs = Math.max(0, this.options.batchPauseMs ?? BATCH_PAUSE_MS);
|
||||
|
||||
while (!this.state.stop) {
|
||||
const meta = listScrapes(this.options.rootDir).find(candidate => {
|
||||
if (candidate.state === "deleted" || candidate.state === "deleting") return false;
|
||||
try {
|
||||
const store = new ScrapeStore(scrapeDir(this.options.rootDir, candidate.id));
|
||||
try {
|
||||
return store.previewCounts().titlePending > 0;
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!meta) break;
|
||||
|
||||
this.state.currentScrapeId = meta.id;
|
||||
const result = await this.engine.titleBatch(meta.id, batchSize);
|
||||
this.state.processed += result.titled + result.failed;
|
||||
|
||||
const elapsed = Math.max(1, this.now() - (this.state.startedAt ?? this.now()));
|
||||
this.state.perMinute = this.state.processed / (elapsed / 60_000);
|
||||
|
||||
this.options.log?.(
|
||||
`titles: ${meta.id} batch done: +${result.titled} titled, ${result.failed} closed, ` +
|
||||
`${result.remaining} left in scrape`
|
||||
);
|
||||
|
||||
if (this.state.stop) break;
|
||||
if (result.stopped) {
|
||||
this.state.state = "paused";
|
||||
break;
|
||||
}
|
||||
|
||||
if (batchPauseMs > 0) await this.sleep(batchPauseMs);
|
||||
}
|
||||
|
||||
this.state.currentScrapeId = null;
|
||||
if (this.state.state === "running") this.state.state = this.state.stop ? "paused" : "done";
|
||||
this.state.finishedAt = this.state.state === "done" ? this.now() : null;
|
||||
this.options.log?.(`titles: lane ${this.state.state} (${this.state.processed} rows this run)`);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js";
|
||||
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
|
||||
import { BackfillQueue } from "../src/backfill.js";
|
||||
import { TitleLane } from "../src/titles.js";
|
||||
import { extractMedia, normalizeYouTubeUrl } from "../src/extract.js";
|
||||
import { PuppetBackend } from "../src/puppet-backend.js";
|
||||
import { betterCandidate, sortCandidates } from "../src/dedupe.js";
|
||||
@@ -1502,7 +1503,7 @@ void (async () => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
await test("previewBatch resolves titles and gives up after the cap", async () => {
|
||||
await test("titleBatch resolves titles and gives up after the cap", async () => {
|
||||
const embed = {
|
||||
type: "video",
|
||||
url: "https://www.youtube.com/watch?v=titlepass",
|
||||
@@ -1534,7 +1535,8 @@ void (async () => {
|
||||
|
||||
const started = await engine.start({ channelId: "135", guildId: null, mode: "links", backend });
|
||||
await engine.processQueue();
|
||||
await engine.previewBatch(started.id, 5);
|
||||
const batch = await engine.titleBatch(started.id, 5);
|
||||
assert.equal(batch.titled, 1);
|
||||
|
||||
const store = new ScrapeStore(join(root, started.id));
|
||||
const row = store.queryMedia({ kind: "video" })[0];
|
||||
@@ -1561,7 +1563,8 @@ void (async () => {
|
||||
|
||||
const dead = await deadEngine.start({ channelId: "136", guildId: null, mode: "links", backend: deadBackend });
|
||||
await deadEngine.processQueue();
|
||||
await deadEngine.previewBatch(dead.id, 5);
|
||||
const deadBatch = await deadEngine.titleBatch(dead.id, 5);
|
||||
assert.equal(deadBatch.failed, 1);
|
||||
|
||||
const deadStore = new ScrapeStore(join(root, dead.id));
|
||||
const deadRow = deadStore.queryMedia({ kind: "video" })[0];
|
||||
@@ -1570,6 +1573,55 @@ void (async () => {
|
||||
deadStore.close();
|
||||
});
|
||||
|
||||
await test("title lane runs a catch-up across scrapes", async () => {
|
||||
const laneRoot = join(root, "lane-root");
|
||||
mkdirSync(laneRoot, { recursive: true });
|
||||
|
||||
const embed = (id: string) => ({
|
||||
type: "video",
|
||||
url: `https://www.youtube.com/watch?v=${id}`,
|
||||
title: null,
|
||||
provider: "YouTube",
|
||||
videoUrl: `https://www.youtube.com/watch?v=${id}`,
|
||||
imageUrl: null,
|
||||
thumbnailUrl: null
|
||||
});
|
||||
const backend = new FakeBackend([
|
||||
{ messages: [message("1500", { embeds: [embed("lane1"), embed("lane2")] })] }
|
||||
]);
|
||||
const engine = engineFor(laneRoot, backend, {
|
||||
download: {
|
||||
fetchImpl: async (input: any) =>
|
||||
new Response(JSON.stringify({ title: `T ${String(input).length}` }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
}),
|
||||
minGapMs: 0,
|
||||
maxGapMs: 0,
|
||||
sleep: async () => {}
|
||||
}
|
||||
});
|
||||
await engine.start({ channelId: "137", guildId: null, mode: "links", backend });
|
||||
await engine.processQueue();
|
||||
|
||||
const lane = new TitleLane(engine, {
|
||||
rootDir: laneRoot,
|
||||
batchSize: 1,
|
||||
batchPauseMs: 0,
|
||||
sleep: async () => {},
|
||||
log: () => {}
|
||||
});
|
||||
lane.start();
|
||||
await lane.idle();
|
||||
|
||||
const status = lane.status();
|
||||
assert.equal(status.state, "done");
|
||||
assert.equal(status.pending, 0);
|
||||
assert.equal(status.resolved, 2);
|
||||
assert.equal(status.failed, 0);
|
||||
assert.ok(status.processed >= 2);
|
||||
});
|
||||
|
||||
await test("title search matches word prefixes and exact phrases", () => {
|
||||
const dir = join(root, "title-search");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user