Add message links, suspect retries, and Discord preview recovery

This commit is contained in:
Fisto
2026-09-18 11:47:08 +02:00
parent 9481286169
commit 8752b36209
16 changed files with 831 additions and 35 deletions
+35 -2
View File
@@ -31,6 +31,10 @@ export interface BackfillMember {
pending: number;
cached: number;
failed: number;
/** Preview failures kept for the delayed suspect retry. */
suspect: number;
/** Suspect rows whose retry failed too. */
unverified: number;
total: number;
downloaded: number;
bytes: number;
@@ -154,6 +158,8 @@ export class BackfillQueue {
pending: 0,
cached: 0,
failed: 0,
suspect: 0,
unverified: 0,
total: 0,
downloaded: meta.downloaded ?? 0,
bytes: 0,
@@ -167,6 +173,8 @@ export class BackfillQueue {
member.pending = counts.pending;
member.cached = counts.cached;
member.failed = counts.failed;
member.suspect = counts.suspect;
member.unverified = counts.unverified;
member.total = counts.total;
for (const kind of Object.keys(member.pendingByKind) as MediaKind[]) {
member.pendingByKind[kind] = counts.byKind[kind].pending;
@@ -408,6 +416,9 @@ export class BackfillQueue {
private async runJob(job: Job): Promise<void> {
let stalled = 0;
// The cooldown doubles when a batch is mostly suspect failures; it stays raised for the
// rest of this job so throttling is not immediately forgotten.
let pauseMs = this.options.getSettings().batchPauseMs;
while (!job.stop) {
const group = this.plan(false).get(job.chatId);
@@ -457,8 +468,30 @@ export class BackfillQueue {
stalled = 0;
}
if (settings.batchPauseMs > 0 && progressed > 0) {
await this.sleep(settings.batchPauseMs);
// Suspect failures get one delayed retry. When they reach two thirds of a batch the
// cooldown doubles (floor 60 s) and the retry runs before the next batch.
const suspectThreshold = Math.ceil((settings.batchSize * 2) / 3);
if (result.suspect > 0 && result.suspect >= suspectThreshold) {
pauseMs = Math.min(300_000, Math.max(60_000, pauseMs * 2));
this.options.log?.(
`backfill: ${job.chatId} ${result.suspect} suspect failures, cooldown now ${pauseMs} ms`
);
}
if (pauseMs > 0 && (progressed > 0 || result.suspect > 0)) {
await this.sleep(pauseMs);
if (job.stop) break;
}
if (result.suspect > 0 || result.unverified > 0) {
const retried = await this.engine.previewSuspects(
member.scrapeId,
Math.max(2, Math.ceil(settings.batchSize / 2))
);
this.options.log?.(
`backfill: ${job.chatId} suspect retry: ${retried.cached} recovered, ` +
`${retried.unverified} left unverified`
);
}
}
+14
View File
@@ -0,0 +1,14 @@
/*
* Permanent Discord links. CDN URLs expire; the message link never does.
*/
/** `discord.com/channels/<guildId|@me>/<channelId>/<messageId>`, or null without a message. */
export function messageUrlFor(
guildId: string | null,
channelId: string,
messageId: string | null
): string | null {
if (!messageId) return null;
const scope = guildId ?? "@me";
return `https://discord.com/channels/${scope}/${channelId}/${messageId}`;
}
+7 -2
View File
@@ -66,7 +66,7 @@ export function ogImageFromHtml(html: string): string | null {
export async function resolveEmbedTitle(
url: string,
options: DownloadOptions = {}
): Promise<string | null> {
): Promise<{ title: string; author: string | null } | null> {
let parsed: URL;
try {
parsed = new URL(url);
@@ -80,7 +80,12 @@ export async function resolveEmbedTitle(
}
const data = await getJson(`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`, options);
return typeof data?.title === "string" && data.title.trim() ? data.title.trim() : null;
if (typeof data?.title !== "string" || !data.title.trim()) return null;
return {
title: data.title.trim(),
author: typeof data.author_name === "string" && data.author_name.trim() ? data.author_name.trim() : null
};
}
/**
+98 -12
View File
@@ -61,6 +61,10 @@ export interface PreviewBatchResult {
skipped: number;
failed: number;
ratelimited: number;
/** Rows parked for one delayed retry (possible throttling, not confirmed gone). */
suspect: number;
/** Suspect rows whose delayed retry failed too. */
unverified: number;
/** Rows still pending after the batch. */
remaining: number;
/** True when a stop request interrupted the batch. */
@@ -89,6 +93,13 @@ export interface ScrapeEngineOptions {
rateLimitPauseMs?: number;
/** Transient preview failures allowed before a row is marked permanently unreachable. Default 3. */
previewMaxAttempts?: number;
/** Fresh URL for a row whose stored URL expired (message re-fetch). Best effort. */
refreshUrl?: (
scrapeId: string,
media: { id: number; url: string; playUrl: string | null }
) => Promise<string | null>;
/** Delay before the single suspect retry. Default 50 s. */
suspectRetryMs?: number;
/** Read dimensions and duration from a downloaded file. Defaults to ffprobe. */
probe?: (
path: string
@@ -113,6 +124,10 @@ function oldestId(messages: CoreMessage[]): string | null {
return oldest;
}
function isDiscordHost(url: string): boolean {
return /^https?:\/\/[^/]*discordapp\.(com|net)\//i.test(url);
}
function isRateLimited(error: unknown): boolean {
const anyError = error as { status?: number; message?: string } | undefined;
return anyError?.status === 429 || /\b429\b|rate.?limit/i.test(String(anyError?.message ?? error));
@@ -557,7 +572,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, remaining: 0, stopped: false };
return { cached: 0, skipped: 0, failed: 0, ratelimited: 0, suspect: 0, unverified: 0, remaining: 0, stopped: false };
}
const releaseTraffic = await this.acquireTraffic();
@@ -590,9 +605,9 @@ export class ScrapeEngine {
break;
}
const title = await resolveEmbedTitle(row.url, options);
if (title) {
store.markTitle(row.id, title);
const resolved = await resolveEmbedTitle(row.url, options);
if (resolved) {
store.markTitle(row.id, resolved.title, resolved.author);
result.titled++;
continue;
}
@@ -620,24 +635,47 @@ export class ScrapeEngine {
return result;
}
/** Retry the due suspect rows (one delayed attempt each) for one scrape. */
async previewSuspects(scrapeId: string, limit: number): Promise<PreviewBatchResult> {
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, suspect: 0, unverified: 0, remaining: 0, stopped: false };
}
const releaseTraffic = await this.acquireTraffic();
const store = new ScrapeStore(dir);
try {
return await this.previewRows(store, dir, meta, Math.max(1, limit), this.previewOptions(meta), true);
} finally {
store.close();
releaseTraffic();
}
}
/** One batch of preview rows. Mutates the store and the meta counters. */
private async previewRows(
store: ScrapeStore,
dir: string,
meta: ScrapeMeta,
limit: number,
options: DownloadOptions & { embedPreviews: boolean; destinationDir: string | null }
options: DownloadOptions & { embedPreviews: boolean; destinationDir: string | null },
retryingSuspects = false
): Promise<PreviewBatchResult> {
const result: PreviewBatchResult = {
cached: 0,
skipped: 0,
failed: 0,
ratelimited: 0,
suspect: 0,
unverified: 0,
remaining: 0,
stopped: false
};
const pending = store.pendingPreviews(limit, true);
const pending = retryingSuspects
? store.dueSuspects(limit, this.now())
: store.pendingPreviews(limit, true);
for (const row of pending) {
if (this.stopRequested.has(meta.id)) {
@@ -704,6 +742,7 @@ export class ScrapeEngine {
}
store.markPreview(row.id, { hash, bytes, ext, animHash });
store.clearSuspect(row.id);
meta.previewed = (meta.previewed ?? 0) + 1;
result.cached++;
@@ -723,6 +762,7 @@ export class ScrapeEngine {
const frame = await remoteFrame(previewRow.preview_url ?? previewRow.url, join(dir, "thumbs"));
if (frame) {
store.markPreview(row.id, { hash: frame.hash, bytes: frame.bytes, ext: "webp" });
store.clearSuspect(row.id);
meta.previewed = (meta.previewed ?? 0) + 1;
result.cached++;
} else {
@@ -760,12 +800,58 @@ export class ScrapeEngine {
);
}
} else {
store.markPreviewError(row.id, outcome.error ?? outcome.state);
result.skipped++;
this.log(
`${meta.id}: preview #${row.id} ${row.kind}/${row.provider ?? "?"} skipped: ` +
`${outcome.error ?? outcome.state} (${row.url.slice(0, 60)})`
);
const error = outcome.error ?? outcome.state;
const httpError = /^http \d/.test(error);
if (httpError && !retryingSuspects && this.options.refreshUrl && isDiscordHost(row.url)) {
// The signed CDN URL may have expired. Refresh it from the source message,
// persist the fresh one, and try once more before calling it gone.
const refreshed = await this.options.refreshUrl(meta.id, {
id: row.id,
url: row.url,
playUrl: row.play_url
});
if (refreshed && refreshed !== previewRow.url) {
const retry = await downloadPreview({ ...previewRow, url: refreshed }, join(dir, "thumbs"), options);
if (retry.state === "downloaded" && retry.hash) {
store.markPreview(row.id, {
hash: retry.hash,
bytes: retry.bytes ?? 0,
ext: retry.ext ?? "webp"
});
store.clearSuspect(row.id);
meta.previewed = (meta.previewed ?? 0) + 1;
result.cached++;
this.persist(dir, meta);
await this.sleep(previewGapMs(options));
continue;
}
}
}
if (httpError) {
if (retryingSuspects || row.preview_suspect) {
store.confirmSuspect(row.id, `still ${error} after retry`);
result.unverified++;
this.log(
`${meta.id}: preview #${row.id} still ${error} after retry, left unverified (${row.url.slice(0, 60)})`
);
} else {
const retryAt = this.now() + (this.options.suspectRetryMs ?? 50_000);
store.markSuspect(row.id, error, retryAt);
result.suspect++;
this.log(
`${meta.id}: preview #${row.id} suspect (${error}), retrying after a pause (${row.url.slice(0, 60)})`
);
}
} else {
store.markPreviewError(row.id, error);
result.skipped++;
this.log(
`${meta.id}: preview #${row.id} ${row.kind}/${row.provider ?? "?"} skipped: ` +
`${error} (${row.url.slice(0, 60)})`
);
}
}
this.persist(dir, meta);
+1
View File
@@ -5,6 +5,7 @@
export { extractMedia, type ExtractedMedia } from "./extract.js";
export { betterCandidate, candidateScore, sortCandidates, type DedupeCandidate } from "./dedupe.js";
export { messageUrlFor } from "./discord-links.js";
export { ogImageFromHtml, resolveEmbedPreview, resolveEmbedTitle } from "./embed-preview.js";
export { isNearLimit, planRetention, type RetentionPlan, type RetentionScrape } from "./retention.js";
export { isoFromSnowflake, snowflakeFromIso } from "./snowflake.js";
+119 -6
View File
@@ -169,10 +169,18 @@ export interface MediaRow {
download_error: string | null;
preview_error: string | null;
preview_attempts: number;
/** First message that carried this media; null on rows written before linking. */
message_id: string | null;
/** Embed title from Discord (YouTube and friends). */
title: string | null;
title_attempts: number;
title_error: string | null;
/** YouTube channel name from oEmbed, kept even if the video later disappears. */
title_author: string | null;
/** Last suspect (possibly throttled) preview failure; retried once, then left alone. */
preview_suspect: string | null;
/** Epoch ms when the suspect row may be retried. Null once confirmed unavailable. */
preview_retry_at: number | null;
/** Lowercased, punctuation-collapsed title for token-prefix search. */
title_key: string | null;
created_at: string;
@@ -242,6 +250,9 @@ CREATE TABLE IF NOT EXISTS media (
title_key TEXT,
title_attempts INTEGER NOT NULL DEFAULT 0,
title_error TEXT,
title_author TEXT,
preview_suspect TEXT,
preview_retry_at INTEGER,
created_at TEXT NOT NULL
);
@@ -295,6 +306,11 @@ export class ScrapeStore {
this.db.exec("ALTER TABLE media ADD COLUMN title_attempts INTEGER NOT NULL DEFAULT 0");
this.db.exec("ALTER TABLE media ADD COLUMN title_error TEXT");
}
if (!names.has("title_author")) this.db.exec("ALTER TABLE media ADD COLUMN title_author TEXT");
if (!names.has("preview_suspect")) {
this.db.exec("ALTER TABLE media ADD COLUMN preview_suspect TEXT");
this.db.exec("ALTER TABLE media ADD COLUMN preview_retry_at INTEGER");
}
this.migrateYouTubeShorts();
}
@@ -499,6 +515,88 @@ export class ScrapeStore {
* Rows that still need a cached preview. Attachment/sticker images and GIFs always;
* embed thumbnails only when `includeEmbeds` is set (download mode).
*/
/**
* Rows whose preview failed in a way that may be throttling rather than a dead link. Each is
* retried once after a delay; confirmed rows stay here and are skipped by normal passes, so a
* finished backfill never churns over genuinely unavailable media.
*/
dueSuspects(limit = 500, nowMs = Date.now()): MediaRow[] {
return this.db
.prepare(
`SELECT * FROM media
WHERE preview_hash IS NULL AND preview_error IS NULL
AND preview_suspect IS NOT NULL
AND preview_retry_at IS NOT NULL
AND preview_retry_at <= ?
ORDER BY preview_retry_at LIMIT ?`
)
.all(nowMs, limit) as unknown as MediaRow[];
}
/** Suspect rows that still have a retry coming. */
suspectCount(): number {
const row = this.db
.prepare(
"SELECT COUNT(*) AS count FROM media WHERE preview_hash IS NULL AND preview_suspect IS NOT NULL AND preview_retry_at IS NOT NULL"
)
.get() as { count: number | null } | undefined;
return row?.count ?? 0;
}
/** Suspect rows whose single retry failed too; skipped by normal passes. */
unverifiedCount(): number {
const row = this.db
.prepare(
"SELECT COUNT(*) AS count FROM media WHERE preview_hash IS NULL AND preview_suspect IS NOT NULL AND preview_retry_at IS NULL"
)
.get() as { count: number | null } | undefined;
return row?.count ?? 0;
}
/** Schedule the single delayed retry for a suspect row. */
markSuspect(id: number, error: string, retryAtMs: number): void {
this.db
.prepare("UPDATE media SET preview_suspect = ?, preview_retry_at = ? WHERE id = ?")
.run(error.slice(0, 200), retryAtMs, id);
}
/** The delayed retry failed too: keep the note but stop retrying. */
confirmSuspect(id: number, error: string): void {
this.db
.prepare("UPDATE media SET preview_suspect = ?, preview_retry_at = NULL WHERE id = ?")
.run(error.slice(0, 200), id);
}
/** The retry succeeded or the preview arrived another way. */
clearSuspect(id: number): void {
this.db.prepare("UPDATE media SET preview_suspect = NULL, preview_retry_at = NULL WHERE id = ?").run(id);
}
/** Discord-CDN rows closed out with an HTTP error; the recovery task retries these. */
discordPreviewFailures(limit = 5000): MediaRow[] {
return this.db
.prepare(
`SELECT * FROM media
WHERE preview_hash IS NULL AND preview_error IS NOT NULL
AND lower(url) LIKE '%discordapp%'
ORDER BY id LIMIT ?`
)
.all(limit) as unknown as MediaRow[];
}
clearPreviewError(id: number): void {
this.db.prepare("UPDATE media SET preview_error = NULL WHERE id = ?").run(id);
}
discordPreviewFailureCount(): number {
const row = this.db
.prepare(
"SELECT COUNT(*) AS count FROM media WHERE preview_hash IS NULL AND preview_error IS NOT NULL AND lower(url) LIKE '%discordapp%'"
)
.get() as { count: number | null } | undefined;
return row?.count ?? 0;
}
pendingPreviews(limit = 5000, includeEmbeds = false): MediaRow[] {
const local =
"((source IS NULL OR source != 'embed') AND kind IN ('image', 'gif', 'video', 'audio'))";
@@ -508,7 +606,10 @@ export class ScrapeStore {
return this.db
.prepare(
`SELECT * FROM media WHERE ${condition} AND preview_hash IS NULL AND preview_error IS NULL ORDER BY id LIMIT ?`
`SELECT * FROM media
WHERE ${condition} AND preview_hash IS NULL AND preview_error IS NULL
AND preview_suspect IS NULL
ORDER BY id LIMIT ?`
)
.all(limit) as unknown as MediaRow[];
}
@@ -542,6 +643,10 @@ export class ScrapeStore {
titled: number;
/** Rows closed out without a title. */
titleFailed: number;
/** Preview failures kept for one delayed retry. */
suspect: number;
/** Suspect rows whose retry failed too. */
unverified: number;
byKind: Record<MediaKind, { total: number; pending: number; cached: number; failed: number }>;
} {
const empty = () => ({ total: 0, pending: 0, cached: 0, failed: 0 });
@@ -603,6 +708,8 @@ export class ScrapeStore {
titlePending: titles?.pending ?? 0,
titled: titles?.titled ?? 0,
titleFailed: titles?.failed ?? 0,
suspect: this.suspectCount(),
unverified: this.unverifiedCount(),
byKind
};
}
@@ -625,11 +732,13 @@ export class ScrapeStore {
.all(limit) as unknown as MediaRow[];
}
/** Store a resolved title and its search key. */
markTitle(id: number, title: string): void {
/** Store a resolved title, its search key, and the provider's channel name. */
markTitle(id: number, title: string, author?: string | null): void {
this.db
.prepare("UPDATE media SET title = ?, title_key = ?, title_error = NULL WHERE id = ?")
.run(title.slice(0, 300), normalizeTitleKey(title), id);
.prepare(
"UPDATE media SET title = ?, title_key = ?, title_error = NULL, title_author = COALESCE(?, title_author) WHERE id = ?"
)
.run(title.slice(0, 300), normalizeTitleKey(title), author ?? null, id);
}
/** Count one transient title failure and return the new total. */
@@ -873,7 +982,11 @@ export class ScrapeStore {
const { where, values } = this.mediaWhere(options);
const rows = this.db
.prepare(`SELECT * FROM media ${where} ORDER BY id DESC LIMIT ? OFFSET ?`)
.prepare(
`SELECT media.*,
(SELECT message_id FROM media_sources WHERE media_id = media.id LIMIT 1) AS message_id
FROM media ${where} ORDER BY id DESC LIMIT ? OFFSET ?`
)
.all(...values, limit, offset);
return rows as unknown as MediaRow[];
+111
View File
@@ -24,6 +24,7 @@ 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 { messageUrlFor } from "../src/discord-links.js";
import { betterCandidate, sortCandidates } from "../src/dedupe.js";
import { aspectCondition, PROVIDER_FILTERS, resolutionCondition } from "../src/filter-presets.js";
import { isNearLimit, planRetention } from "../src/retention.js";
@@ -1436,6 +1437,116 @@ void (async () => {
reopened.close();
});
await test("message links build for guilds and DMs", () => {
assert.equal(messageUrlFor("123", "456", "789"), "https://discord.com/channels/123/456/789");
assert.equal(messageUrlFor(null, "456", "789"), "https://discord.com/channels/@me/456/789");
assert.equal(messageUrlFor("123", "456", null), null);
});
await test("suspect rows park, due, and confirm", () => {
const dir = join(root, "suspects");
mkdirSync(dir, { recursive: true });
const store = new ScrapeStore(dir);
store.insertPage([
message("1700", {
attachments: [attachment({ filename: "gone.mp4", contentType: "video/mp4" })]
})
]);
const row = store.queryMedia({ kind: "video" })[0];
const now = Date.now();
store.markSuspect(row.id, "http 404", now + 10_000);
assert.equal(store.previewCounts().suspect, 1);
assert.equal(store.previewCounts().unverified, 0);
assert.equal(store.pendingPreviews(10, true).length, 0);
assert.equal(store.dueSuspects(10, now).length, 0);
assert.equal(store.dueSuspects(10, now + 20_000).length, 1);
store.confirmSuspect(row.id, "still http 404 after retry");
assert.equal(store.previewCounts().suspect, 0);
assert.equal(store.previewCounts().unverified, 1);
assert.equal(store.dueSuspects(10, now + 20_000).length, 0);
store.markSuspect(row.id, "http 503", now);
assert.equal(store.previewCounts().suspect, 1);
assert.equal(store.previewCounts().unverified, 0);
assert.equal(store.dueSuspects(10, now).length, 1);
store.close();
});
await test("discord 404s become suspects and the delayed retry resolves them", async () => {
let failing = true;
const backend = new FakeBackend([
{
messages: [
message("1701", {
attachments: [attachment({ filename: "clip.mp4", contentType: "video/mp4" })]
})
]
}
]);
const engine = engineFor(root, backend, {
suspectRetryMs: 0,
download: {
fetchImpl: async () =>
failing
? new Response("gone", { status: 404 })
: new Response("png", { status: 200, headers: { "content-type": "image/png" } }),
minGapMs: 0,
maxGapMs: 0,
sleep: async () => {}
}
});
const started = await engine.start({ channelId: "138", guildId: null, mode: "links", backend });
await engine.processQueue();
const first = await engine.previewBatch(started.id, 5);
assert.equal(first.suspect, 1);
assert.equal(first.skipped, 0);
// Normal passes leave suspects alone.
assert.equal((await engine.previewBatch(started.id, 5)).suspect, 0);
failing = false;
const retry = await engine.previewSuspects(started.id, 5);
assert.equal(retry.cached, 1);
const store = new ScrapeStore(join(root, started.id));
assert.equal(store.previewCounts().cached, 1);
assert.equal(store.previewCounts().suspect, 0);
store.close();
// A retry that fails again is confirmed unverified.
const dead = new FakeBackend([
{
messages: [
message("1702", {
attachments: [attachment({ filename: "dead.mp4", contentType: "video/mp4" })]
})
]
}
]);
const deadEngine = engineFor(root, dead, {
suspectRetryMs: 0,
download: {
fetchImpl: async () => new Response("gone", { status: 404 }),
minGapMs: 0,
maxGapMs: 0,
sleep: async () => {}
}
});
const deadScrape = await deadEngine.start({ channelId: "139", guildId: null, mode: "links", backend: dead });
await deadEngine.processQueue();
assert.equal((await deadEngine.previewBatch(deadScrape.id, 5)).suspect, 1);
assert.equal((await deadEngine.previewSuspects(deadScrape.id, 5)).unverified, 1);
const deadStore = new ScrapeStore(join(root, deadScrape.id));
assert.equal(deadStore.previewCounts().unverified, 1);
assert.equal(deadStore.pendingPreviews(10, true).length, 0);
deadStore.close();
});
await test("pendingTitles selects YouTube rows without a title", () => {
const dir = join(root, "pending-titles");
mkdirSync(dir, { recursive: true });