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
+171
View File
@@ -10,6 +10,7 @@ import { isAbsolute, join } from "node:path";
import {
BackfillQueue,
betterCandidate,
messageUrlFor,
sortCandidates,
dirSizeBytes,
isNearLimit,
@@ -115,6 +116,10 @@ export interface MediaItemResult {
scrapeId: string;
itemId: number;
channelId: string;
guildId: string | null;
/** The message that carried the media; the permanent link target. */
messageId: string | null;
messageUrl: string | null;
kind: MediaKind;
source: string | null;
authorId: string | null;
@@ -139,6 +144,8 @@ export interface MediaItemResult {
downloaded: boolean;
/** Embed title (YouTube and friends), when Discord provided one. */
title: string | null;
/** YouTube channel name kept from oEmbed, even if the video later disappears. */
titleAuthor: string | null;
createdAt: string;
}
@@ -182,6 +189,19 @@ export class MediaService {
/** The newest bot and scrape errors, capped. Surfaced in the Bot tab. */
private readonly errors: MediaErrorEntry[] = [];
/** One-shot maintenance: re-fetch previews for Discord rows closed out with HTTP errors. */
private readonly recover = {
state: "idle" as "idle" | "running" | "paused" | "done",
processed: 0,
recovered: 0,
gone: 0,
startedAt: null as number | null,
finishedAt: null as number | null,
currentScrapeId: null as string | null
};
/** Finished maintenance tasks, newest first. */
private readonly taskHistory: Array<{ name: string; at: string; detail: string }> = [];
constructor(private readonly deps: MediaServiceDeps) {
const tunables = this.deps.getMediaPrefs().tunables;
@@ -195,6 +215,14 @@ export class MediaService {
pageMaxMs: tunables.timers.pageMaxMs,
maxRetries: tunables.scrape.maxRetries,
previewMaxAttempts: tunables.backfill.maxAttempts,
suspectRetryMs: 50_000,
refreshUrl: async (scrapeId, media) => {
const meta = this.engine.get(scrapeId);
if (!meta) return null;
const item = this.getMediaItem(scrapeId, media.id);
if (!item) return null;
return this.refreshUrls(meta, item);
},
download: {
maxFileBytes: tunables.scrape.maxFileBytes,
previewMaxBytes: tunables.backfill.maxBytes,
@@ -293,6 +321,145 @@ export class MediaService {
return this.titles.status();
}
/** The recovery task with its live remaining count and the task history. */
recoverStatus(): {
state: string;
total: number;
processed: number;
recovered: number;
gone: number;
startedAt: string | null;
finishedAt: string | null;
currentScrapeId: string | null;
history: Array<{ name: string; at: string; detail: string }>;
} {
let total = 0;
for (const meta of this.engine.list()) {
if (meta.state === "deleted" || meta.state === "deleting") continue;
try {
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
try {
total += store.discordPreviewFailureCount();
} finally {
store.close();
}
} catch {
// an unreadable index has nothing to recover
}
}
return {
state: this.recover.state,
total,
processed: this.recover.processed,
recovered: this.recover.recovered,
gone: this.recover.gone,
startedAt: this.recover.startedAt ? new Date(this.recover.startedAt).toISOString() : null,
finishedAt: this.recover.finishedAt ? new Date(this.recover.finishedAt).toISOString() : null,
currentScrapeId: this.recover.currentScrapeId,
history: [...this.taskHistory]
};
}
startRecover(): void {
if (this.recover.state === "running") return;
this.recover.state = "running";
this.recover.startedAt = this.recover.startedAt ?? Date.now();
this.recover.processed = 0;
this.recover.recovered = 0;
this.recover.gone = 0;
this.deps.log?.("recover: retrying Discord-CDN previews marked gone");
void this.runRecover();
}
pauseRecover(): void {
if (this.recover.state !== "running") return;
this.recover.state = "paused";
this.deps.log?.("recover: pausing");
}
/**
* Walk every scrape's Discord-CDN preview failures, refresh the signed URL from the source
* message (slopbot REST for guild scrapes, the puppet for DMs and group chats), then let the
* engine fetch the preview. Rows whose URL cannot be refreshed are counted as gone.
*/
private async runRecover(): Promise<void> {
const scrapeIds = this.engine
.list()
.filter(meta => meta.state !== "deleted" && meta.state !== "deleting")
.map(meta => meta.id);
while (this.recover.state === "running") {
let acted = false;
for (const scrapeId of scrapeIds) {
if (this.recover.state !== "running") break;
const meta = this.engine.get(scrapeId);
if (!meta) continue;
let store: ScrapeStore | null = null;
try {
store = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
} catch {
continue;
}
try {
const rows = store.discordPreviewFailures(10);
if (!rows.length) continue;
acted = true;
this.recover.currentScrapeId = scrapeId;
const cleared: MediaRow[] = [];
for (const row of rows) {
if (this.recover.state !== "running") break;
const fresh = await this.refreshUrls(meta, {
...row,
scrapeId,
channelId: meta.channelId
} as MediaRow & { scrapeId: string; channelId: string });
if (fresh) {
store.clearPreviewError(row.id);
cleared.push(row);
} else {
this.recover.gone++;
}
this.recover.processed++;
}
if (cleared.length && this.recover.state === "running") {
await this.engine.previewBatch(scrapeId, cleared.length);
for (const row of cleared) {
if (store.getMedia(row.id)?.preview_hash) this.recover.recovered++;
else this.recover.gone++;
}
}
} finally {
store?.close();
}
if (acted) break;
}
if (!acted) break;
}
if (this.recover.state === "running") this.recover.state = "done";
this.recover.finishedAt = Date.now();
this.recover.currentScrapeId = null;
this.taskHistory.unshift({
name: "Recover Discord previews",
at: new Date().toISOString(),
detail: `${this.recover.recovered} recovered, ${this.recover.gone} unavailable`
});
if (this.taskHistory.length > 10) this.taskHistory.length = 10;
this.deps.log?.(
`recover: done, ${this.recover.recovered} recovered, ${this.recover.gone} unavailable`
);
}
startBackfill(chatId?: string): BackfillStatus {
this.backfill.start(chatId);
return this.backfillStatus();
@@ -1006,6 +1173,9 @@ export class MediaService {
scrapeId: meta.id,
itemId: row.id,
channelId: meta.channelId,
guildId: meta.guildId,
messageId: row.message_id,
messageUrl: messageUrlFor(meta.guildId, meta.channelId, row.message_id),
kind: row.kind,
source: row.source,
authorId: row.author_id,
@@ -1029,6 +1199,7 @@ export class MediaService {
provider: row.provider,
downloaded: row.downloaded === 1,
title: row.title,
titleAuthor: row.title_author,
createdAt: row.created_at
}));
}