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