Fetch previews in batches with a rate-limit cooldown

This commit is contained in:
Fisto
2026-09-16 23:51:16 +02:00
parent 8f30ec14ad
commit 6c500f0cc5
7 changed files with 78 additions and 1 deletions
+4
View File
@@ -40,6 +40,10 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C31 Batched previews. The preview pass now runs in batches (default 25) with a cooldown
(default 60 s), configurable via `VESKSLOP_MEDIA_PREVIEW_BATCH` and
`VESKSLOP_MEDIA_PREVIEW_BATCH_PAUSE_MS`; batching counts rows, not successes. Live log:
`preview batch of 25 done, pausing 60000 ms`.
- [x] C30 Preview completeness. Audio attachments and voice messages get an ffmpeg waveform - [x] C30 Preview completeness. Audio attachments and voice messages get an ffmpeg waveform
strip; oversized animated GIFs fall back to an ffmpeg-read first frame instead of `too large`; strip; oversized animated GIFs fall back to an ffmpeg-read first frame instead of `too large`;
transient network failures stay pending for the next backfill instead of becoming permanent. transient network failures stay pending for the next backfill instead of becoming permanent.
+2
View File
@@ -177,6 +177,8 @@ Verified live on 2026-09-13: the web UI started the client under auto-Xvfb, the
| `VESKSLOP_MEDIA_MAX_FILE_BYTES` | `52428800` | Download size cap (50 MiB). Larger files are skipped. | | `VESKSLOP_MEDIA_MAX_FILE_BYTES` | `52428800` | Download size cap (50 MiB). Larger files are skipped. |
| `VESKSLOP_MEDIA_PREVIEW_MAX_BYTES` | `5242880` | Preview size cap (5 MiB). Larger previews are skipped. | | `VESKSLOP_MEDIA_PREVIEW_MAX_BYTES` | `5242880` | Preview size cap (5 MiB). Larger previews are skipped. |
| `VESKSLOP_MEDIA_PREVIEW_MIN_MS` / `_MAX_MS` | `200` / `600` | Gap between preview fetches. | | `VESKSLOP_MEDIA_PREVIEW_MIN_MS` / `_MAX_MS` | `200` / `600` | Gap between preview fetches. |
| `VESKSLOP_MEDIA_PREVIEW_BATCH` | `25` | Previews per batch before the cooldown. |
| `VESKSLOP_MEDIA_PREVIEW_BATCH_PAUSE_MS` | `60000` | Cooldown between preview batches. `0` disables batching. |
| `VESKSLOP_FFPROBE` | `ffprobe` | Probe binary used to fill missing dimensions after a download. | | `VESKSLOP_FFPROBE` | `ffprobe` | Probe binary used to fill missing dimensions after a download. |
| `VESKSLOP_MEDIA_DOWNLOAD_MIN_MS` / `_MAX_MS` | `1000` / `3000` | Gap between downloaded files. | | `VESKSLOP_MEDIA_DOWNLOAD_MIN_MS` / `_MAX_MS` | `1000` / `3000` | Gap between downloaded files. |
| `VESKSLOP_AUDIO_DUCK` | `30` | Sink volume percent while others speak. | | `VESKSLOP_AUDIO_DUCK` | `30` | Sink volume percent while others speak. |
+3 -1
View File
@@ -145,7 +145,9 @@ Previews run after every walk:
- At daemon start, `backfillPreviews` runs the same pass over scrapes that paused before their - At daemon start, `backfillPreviews` runs the same pass over scrapes that paused before their
previews were cached, so a stopped scrape does not leave blank tiles. previews were cached, so a stopped scrape does not leave blank tiles.
- Preview gaps default to 200-600 ms (`VESKSLOP_MEDIA_PREVIEW_MIN_MS`/`_MAX_MS`), much faster than - Preview gaps default to 200-600 ms (`VESKSLOP_MEDIA_PREVIEW_MIN_MS`/`_MAX_MS`), much faster than
file downloads. file downloads. Previews are fetched in **batches of 25 with a 60 s cooldown** between batches
(`VESKSLOP_MEDIA_PREVIEW_BATCH`, `VESKSLOP_MEDIA_PREVIEW_BATCH_PAUSE_MS`), because bursts of
thumbnail fetches trigger Discord and provider rate limits. A batch counts rows, not successes.
## Job states ## Job states
+4
View File
@@ -65,6 +65,10 @@ export interface DownloadOptions {
/** Preview gap. Default 200-600 ms, much faster than file downloads. */ /** Preview gap. Default 200-600 ms, much faster than file downloads. */
previewMinGapMs?: number; previewMinGapMs?: number;
previewMaxGapMs?: number; previewMaxGapMs?: number;
/** Previews per batch before a cooldown pause. Default 25. */
previewBatchSize?: number;
/** Cooldown between preview batches. Default 60 s. Set 0 to disable batching. */
previewBatchPauseMs?: number;
/** Cache third-party embed thumbnails. */ /** Cache third-party embed thumbnails. */
embedPreviews?: boolean; embedPreviews?: boolean;
/** Absolute destination for downloaded files. Null or unset keeps them under the scrape dir. */ /** Absolute destination for downloaded files. Null or unset keeps them under the scrape dir. */
+16
View File
@@ -381,6 +381,19 @@ export class ScrapeEngine {
destinationDir: meta.downloadDir ?? null destinationDir: meta.downloadDir ?? null
}; };
const pending = store.pendingPreviews(5000, true); const pending = store.pendingPreviews(5000, true);
const batchSize = Math.max(1, options.previewBatchSize ?? 25);
const batchPauseMs = Math.max(0, options.previewBatchPauseMs ?? 60_000);
let batch = 0;
// Previews are fetched in batches: Discord and the provider APIs throttle bursts.
const afterRow = async () => {
batch++;
if (batchPauseMs > 0 && batch >= batchSize) {
batch = 0;
this.log(`${meta.id}: preview batch of ${batchSize} done, pausing ${batchPauseMs} ms`);
await this.sleep(batchPauseMs);
}
};
for (const row of pending) { for (const row of pending) {
if (this.stopRequested.has(meta.id)) { if (this.stopRequested.has(meta.id)) {
@@ -403,6 +416,7 @@ export class ScrapeEngine {
} }
this.persist(dir, meta); this.persist(dir, meta);
await this.sleep(previewGapMs(options)); await this.sleep(previewGapMs(options));
await afterRow();
continue; continue;
} }
@@ -415,6 +429,7 @@ export class ScrapeEngine {
store.markPreviewError(row.id, "no preview source"); store.markPreviewError(row.id, "no preview source");
this.persist(dir, meta); this.persist(dir, meta);
await this.sleep(previewGapMs(options)); await this.sleep(previewGapMs(options));
await afterRow();
continue; continue;
} }
previewRow = { ...row, preview_url: resolved }; previewRow = { ...row, preview_url: resolved };
@@ -470,6 +485,7 @@ export class ScrapeEngine {
this.persist(dir, meta); this.persist(dir, meta);
await this.sleep(previewGapMs(options)); await this.sleep(previewGapMs(options));
await afterRow();
} }
} }
+47
View File
@@ -961,6 +961,53 @@ void (async () => {
); );
}); });
await test("previews pause between batches", async () => {
const backend = new FakeBackend([
{
messages: [
message("900", {
attachments: [
attachment({ filename: "a.png", contentType: "image/png" }),
attachment({ filename: "b.png", contentType: "image/png" }),
attachment({ filename: "c.png", contentType: "image/png" })
]
})
]
}
]);
const sleeps: number[] = [];
const engine = new ScrapeEngine({
rootDir: root,
pageSize: 10,
pageMinMs: 0,
pageMaxMs: 0,
sleep: async (ms: number) => {
sleeps.push(ms);
},
random: () => 0,
log: () => {},
previews: true,
download: {
fetchImpl: async () =>
new Response("webp", { status: 200, headers: { "content-type": "image/webp" } }),
minGapMs: 0,
maxGapMs: 0,
previewMinGapMs: 0,
previewMaxGapMs: 0,
previewBatchSize: 2,
previewBatchPauseMs: 5000,
sleep: async () => {}
}
});
const started = await engine.start({ channelId: "133", guildId: null, mode: "links", backend });
await engine.processQueue();
assert.equal(engine.get(started.id)?.previewed, 3);
assert.equal(sleeps.filter(ms => ms === 5000).length, 1);
});
await test("remoteFrame renders an audio waveform", async () => { await test("remoteFrame renders an audio waveform", async () => {
const dir = join(root, "waveform-test"); const dir = join(root, "waveform-test");
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
+2
View File
@@ -179,6 +179,8 @@ export class MediaService {
previewMaxBytes: number(process.env.VESKSLOP_MEDIA_PREVIEW_MAX_BYTES, 5 * 1024 * 1024), previewMaxBytes: number(process.env.VESKSLOP_MEDIA_PREVIEW_MAX_BYTES, 5 * 1024 * 1024),
previewMinGapMs: number(process.env.VESKSLOP_MEDIA_PREVIEW_MIN_MS, 200), previewMinGapMs: number(process.env.VESKSLOP_MEDIA_PREVIEW_MIN_MS, 200),
previewMaxGapMs: number(process.env.VESKSLOP_MEDIA_PREVIEW_MAX_MS, 600), previewMaxGapMs: number(process.env.VESKSLOP_MEDIA_PREVIEW_MAX_MS, 600),
previewBatchSize: number(process.env.VESKSLOP_MEDIA_PREVIEW_BATCH, 25),
previewBatchPauseMs: number(process.env.VESKSLOP_MEDIA_PREVIEW_BATCH_PAUSE_MS, 60_000),
minGapMs: number(process.env.VESKSLOP_MEDIA_DOWNLOAD_MIN_MS, 1000), minGapMs: number(process.env.VESKSLOP_MEDIA_DOWNLOAD_MIN_MS, 1000),
maxGapMs: number(process.env.VESKSLOP_MEDIA_DOWNLOAD_MAX_MS, 3000) maxGapMs: number(process.env.VESKSLOP_MEDIA_DOWNLOAD_MAX_MS, 3000)
} }