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:
141
frontend/src/lib/diashow/queue.test.ts
Normal file
141
frontend/src/lib/diashow/queue.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SlideQueue } from './queue';
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
|
||||
// Minimal FeedUpload factory — the queue only reads id, user_id, preview_url, thumbnail_url.
|
||||
const up = (
|
||||
id: string,
|
||||
opts: { user?: string; preview?: boolean; thumb?: boolean } = {}
|
||||
): FeedUpload =>
|
||||
({
|
||||
id,
|
||||
user_id: opts.user ?? 'u1',
|
||||
preview_url: opts.preview === false ? null : `/p/${id}`,
|
||||
thumbnail_url: opts.thumb ? `/t/${id}` : null
|
||||
}) as unknown as FeedUpload;
|
||||
|
||||
const drain = (q: SlideQueue, n: number): string[] => {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = q.next();
|
||||
if (!s) break;
|
||||
out.push(s.id);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('SlideQueue.merge', () => {
|
||||
it('dedups by id and reports the count actually added', () => {
|
||||
const q = new SlideQueue();
|
||||
expect(q.merge([up('a'), up('b')], { live: false })).toBe(2);
|
||||
expect(q.merge([up('b'), up('c')], { live: false })).toBe(1); // b already known
|
||||
expect(q.stats().known).toBe(3);
|
||||
});
|
||||
|
||||
it('skips uploads with no preview or thumbnail (still compressing)', () => {
|
||||
const q = new SlideQueue();
|
||||
const added = q.merge([up('a'), up('pending', { preview: false }), up('t', { thumb: true })], {
|
||||
live: false
|
||||
});
|
||||
expect(added).toBe(2); // a + t; pending has neither preview nor thumb
|
||||
expect(q.has('pending')).toBe(false);
|
||||
});
|
||||
|
||||
it('live items drain before the shuffle pool', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('s1'), up('s2')], { live: false });
|
||||
q.merge([up('L1'), up('L2')], { live: true });
|
||||
// Live first, in FIFO order, then the pool.
|
||||
expect(drain(q, 2)).toEqual(['L1', 'L2']);
|
||||
expect(drain(q, 2).sort()).toEqual(['s1', 's2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlideQueue completeness', () => {
|
||||
it('eventually shows every merged item across one full cycle (500 items, paged)', () => {
|
||||
const q = new SlideQueue();
|
||||
const all = new Set<string>();
|
||||
// Simulate a paginated load of 500 across 5 pages of 100.
|
||||
for (let page = 0; page < 5; page++) {
|
||||
const batch = Array.from({ length: 100 }, (_, i) => {
|
||||
const id = `img-${page * 100 + i}`;
|
||||
all.add(id);
|
||||
return up(id);
|
||||
});
|
||||
q.merge(batch, { live: false });
|
||||
}
|
||||
q.reshuffle();
|
||||
expect(q.stats().known).toBe(500);
|
||||
|
||||
// Drain generously more than the set size; every id must appear at least once.
|
||||
const shown = new Set(drain(q, 500 + 50));
|
||||
for (const id of all) expect(shown.has(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('a burst of live items are all shown before falling back to the pool', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('old1'), up('old2')], { live: false });
|
||||
const burst = Array.from({ length: 250 }, (_, i) => up(`burst-${i}`));
|
||||
q.merge(burst, { live: true });
|
||||
const firstBurst = drain(q, 250);
|
||||
expect(new Set(firstBurst).size).toBe(250);
|
||||
expect(firstBurst.every((id) => id.startsWith('burst-'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlideQueue removals', () => {
|
||||
it('remove() reports whether the removed id was current', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('a'), up('b')], { live: false });
|
||||
expect(q.remove('a', 'a').wasCurrent).toBe(true);
|
||||
expect(q.remove('b', 'a').wasCurrent).toBe(false);
|
||||
expect(q.stats().known).toBe(0);
|
||||
});
|
||||
|
||||
it('removeByUser() evicts a whole user and flags current', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('a', { user: 'x' }), up('b', { user: 'y' }), up('c', { user: 'x' })], {
|
||||
live: false
|
||||
});
|
||||
const res = q.removeByUser('x', 'c');
|
||||
expect(res.wasCurrent).toBe(true);
|
||||
expect(q.has('a')).toBe(false);
|
||||
expect(q.has('c')).toBe(false);
|
||||
expect(q.has('b')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlideQueue.knownIds + reconcile eviction', () => {
|
||||
it('knownIds snapshots exactly the current set', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('a'), up('b')], { live: false });
|
||||
expect(new Set(q.knownIds())).toEqual(new Set(['a', 'b']));
|
||||
});
|
||||
|
||||
// Mirrors the page's reconcile: evict only pre-scan ids the feed no longer lists, so an
|
||||
// upload that lands mid-scan survives.
|
||||
const reconcileEvict = (q: SlideQueue, before: Set<string>, seen: Set<string>) => {
|
||||
for (const id of before) if (!seen.has(id)) q.remove(id, null);
|
||||
};
|
||||
|
||||
it('drops a slide the server no longer lists (deleted/banned we missed)', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('a'), up('b'), up('c')], { live: false });
|
||||
const before = new Set(q.knownIds());
|
||||
reconcileEvict(q, before, new Set(['a', 'c'])); // feed no longer has b
|
||||
expect(q.has('b')).toBe(false);
|
||||
expect(q.has('a')).toBe(true);
|
||||
expect(q.has('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('never evicts an upload that arrived mid-scan (not in the pre-scan snapshot)', () => {
|
||||
const q = new SlideQueue();
|
||||
q.merge([up('a'), up('b')], { live: false });
|
||||
const before = new Set(q.knownIds()); // {a, b}
|
||||
// A live upload lands during the scan; the scan's `seen` predates it.
|
||||
q.merge([up('fresh')], { live: true });
|
||||
reconcileEvict(q, before, new Set(['a', 'b']));
|
||||
expect(q.has('fresh')).toBe(true); // survives — not an eviction candidate
|
||||
expect(q.has('a')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user