Add message links, suspect retries, and Discord preview recovery
This commit is contained in:
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user