feat(diashow): guarantee all eligible photos shown + 2048px display derivative

Diashow completeness rewrite so every eligible upload is shown regardless of
bursts, disconnects, or library size:

- queue.ts: SlideQueue with live/shuffle queues, allKnown map, recentlyShown
  ring; merge(dedup, live-first), remove/removeByUser (prunes recentlyShown),
  knownIds for reconcile-eviction. Adds queue.test.ts (burst/completeness/race).
- diashow/+page.svelte: reconcile (full paginate + evict, pre-scan snapshot to
  spare concurrent uploads) on mount/reconnect/periodic; catchUpNew paginate-
  until-known for bursts with debounced maxWait; hard-cut removals; decode
  timeout + candidate fallback + bounded skip so a broken image never stalls.

New ~2048px "display" derivative for big-screen sharpness, decoupled from the
data-saver preview (800px) used on phones:

- migration 016: upload.display_path + v_feed rebuilt (DROP+CREATE, not REPLACE,
  to slot the column beside preview/thumbnail).
- compression: generate_image_derivatives emits preview+display (downscale-only
  guard, no upscaling); backfill_missing_display regenerates on startup (safe:
  logs on error, never soft-deletes).
- upload.rs/main.rs: GET /upload/{id}/display (mirrors preview auth/cache),
  /media/displays direct-serve blocked.
- feed.rs + types.ts: display_url in feed/delta DTOs.
- diashow candidate chain: display -> original -> preview.

Verified on the running stack: migration applied, 10/10 existing images
backfilled (2048px cap honoured, small images not upscaled), /display serves
200, /feed returns display_url, diashow cycles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 17:53:53 +02:00
parent d9738a4cb9
commit 5009590882
11 changed files with 643 additions and 151 deletions

View File

@@ -5,13 +5,18 @@
// (minus the most recent N items) once it empties. A new live post pushes onto the
// live queue and waits for the next slide transition — it never interrupts the
// current slide.
//
// Completeness is the whole point: `allKnown` is meant to converge to the server's full
// set of eligible uploads. The page feeds it via `merge` (incremental / paginated) and
// `retainOnly` (evict anything the server no longer returns — deletions, bans, hides),
// so every eligible image is eventually shown and no ineligible one lingers.
import type { FeedUpload } from '$lib/types';
const RECENT_RING_SIZE = 5;
export class SlideQueue {
/** Live queue — FIFO. New uploads land here via `pushLive`. */
/** Live queue — FIFO. New uploads land here via `merge(_, { live: true })`. */
private liveQueue: FeedUpload[] = [];
/** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */
private shuffleQueue: FeedUpload[] = [];
@@ -20,19 +25,35 @@ export class SlideQueue {
/** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */
private recentlyShown: string[] = [];
/** Seed the shuffle pool from an initial fetch of the feed. */
seed(initial: FeedUpload[]): void {
for (const slide of initial) {
this.allKnown.set(slide.id, slide);
/**
* Add uploads, de-duplicated by id. Only *displayable* items (preview- or
* thumbnail-ready) are kept — a still-compressing upload has no image yet, so it's
* skipped until a later sync brings it with a preview. `live` items jump onto the live
* queue (shown next); otherwise they join the shuffle pool. Returns the count newly added.
*/
merge(uploads: FeedUpload[], opts: { live: boolean }): number {
let added = 0;
for (const u of uploads) {
if (!u.preview_url && !u.thumbnail_url) continue;
if (this.allKnown.has(u.id)) continue;
this.allKnown.set(u.id, u);
if (opts.live) this.liveQueue.push(u);
else this.shuffleQueue.push(u);
added++;
}
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
return added;
}
/** Add a slide pushed by a new SSE upload-processed event. */
pushLive(slide: FeedUpload): void {
if (this.allKnown.has(slide.id)) return;
this.allKnown.set(slide.id, slide);
this.liveQueue.push(slide);
/** True if this id is already in the known set — used to stop a catch-up scan once it
* reaches territory we've already ingested. */
has(id: string): boolean {
return this.allKnown.has(id);
}
/** Re-randomise the not-yet-shown shuffle pool. Called once after the initial paginated
* load so the first cycle isn't just newest-first (merge appends in feed order). */
reshuffle(): void {
this.shuffleQueue = shuffle(this.shuffleQueue);
}
/** Pop the next slide. Returns null while both queues are empty (event has no posts). */
@@ -85,9 +106,21 @@ export class SlideQueue {
}
this.liveQueue = this.liveQueue.filter((s) => s.user_id !== userId);
this.shuffleQueue = this.shuffleQueue.filter((s) => s.user_id !== userId);
this.recentlyShown = this.recentlyShown.filter((sid) => this.allKnown.has(sid));
return { wasCurrent };
}
/**
* Snapshot of all currently-known ids. The page captures this BEFORE a reconcile scan,
* then evicts only ids that were known before AND the server no longer lists — the
* reconciliation half of `merge`. Snapshotting first means an upload that lands *during*
* the scan (not in the snapshot) is never wrongly evicted, so a busy event doesn't
* flicker freshly-added slides out and back in.
*/
knownIds(): string[] {
return Array.from(this.allKnown.keys());
}
/** Look up a slide by id — for the diashow page to render the current slide. */
get(id: string): FeedUpload | undefined {
return this.allKnown.get(id);