`merge` decided by whether a usable still existed, so the same clip was included or dropped depending on whether ffmpeg happened to extract a poster — a video WITH a thumbnail was queued and shown as a frozen frame, one without was skipped. Keyed on the mime type instead: the projector shows stills only. The test factory casts through `as unknown as FeedUpload`, so adding a field the queue reads does not fail typechecking — it fails at runtime, which is how this surfaced as ten broken tests rather than a compile error. The factory now carries `mime_type` and the comment says why keeping it in step matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
6.4 KiB
TypeScript
166 lines
6.4 KiB
TypeScript
// Two-queue slide state machine. Pure logic — no DOM, no Svelte, no network. Lets us
|
||
// unit-test the policy without spinning up a browser.
|
||
//
|
||
// Policy: live posts always drain first; the shuffle queue refills from `allKnown`
|
||
// (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 `merge(_, { live: true })`. */
|
||
private liveQueue: FeedUpload[] = [];
|
||
/** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */
|
||
private shuffleQueue: FeedUpload[] = [];
|
||
/** Every slide we've ever seen, keyed by id. Source for shuffle refills. */
|
||
private allKnown: Map<string, FeedUpload> = new Map();
|
||
/** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */
|
||
private recentlyShown: string[] = [];
|
||
|
||
/**
|
||
* Add uploads, de-duplicated by id. `live` items jump onto the live queue (shown next);
|
||
* otherwise they join the shuffle pool. Returns the count newly added.
|
||
*
|
||
* Two things are skipped:
|
||
*
|
||
* 1. **Videos, deliberately and by mime type.** The projector shows stills only — a clip
|
||
* would either hold the slide for its full duration or be cut off mid-way, and it has
|
||
* no audio path on a room's screen. This used to happen only ACCIDENTALLY, as a side
|
||
* effect of the derivative check below: a video whose poster frame extracted fine has
|
||
* a `thumbnail_url` and so was shown as a still frame, while one whose extraction
|
||
* failed was dropped — the same upload included or excluded depending on whether
|
||
* ffmpeg happened to find a frame. Now the rule is the mime type, so it is consistent.
|
||
* 2. **Images with no derivative yet.** A still-compressing photo has no image to show;
|
||
* it is picked up by a later sync once its preview exists.
|
||
*/
|
||
merge(uploads: FeedUpload[], opts: { live: boolean }): number {
|
||
let added = 0;
|
||
for (const u of uploads) {
|
||
if (u.mime_type.startsWith('video/')) continue;
|
||
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++;
|
||
}
|
||
return added;
|
||
}
|
||
|
||
/** 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). */
|
||
next(): FeedUpload | null {
|
||
// 1. Drain live first.
|
||
const live = this.liveQueue.shift();
|
||
if (live) {
|
||
this.markShown(live.id);
|
||
return live;
|
||
}
|
||
// 2. Refill shuffle queue from `allKnown` minus recently shown.
|
||
if (this.shuffleQueue.length === 0) {
|
||
this.shuffleQueue = shuffle(
|
||
Array.from(this.allKnown.values()).filter((s) => !this.recentlyShown.includes(s.id))
|
||
);
|
||
// If everything is recently shown (small event), fall back to the full pool.
|
||
if (this.shuffleQueue.length === 0) {
|
||
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
|
||
}
|
||
}
|
||
const next = this.shuffleQueue.shift() ?? null;
|
||
if (next) this.markShown(next.id);
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Remove a slide that was deleted or hidden. Returns true if it was the current
|
||
* "head" and the caller should advance immediately (UX: don't keep showing a
|
||
* post that the host just deleted).
|
||
*/
|
||
remove(id: string, currentId: string | null): { wasCurrent: boolean } {
|
||
this.allKnown.delete(id);
|
||
this.liveQueue = this.liveQueue.filter((s) => s.id !== id);
|
||
this.shuffleQueue = this.shuffleQueue.filter((s) => s.id !== id);
|
||
this.recentlyShown = this.recentlyShown.filter((sid) => sid !== id);
|
||
return { wasCurrent: currentId === id };
|
||
}
|
||
|
||
/**
|
||
* Remove every slide belonging to a user (banned with hide_uploads). Returns
|
||
* true if the current head was one of them so the caller advances immediately.
|
||
*/
|
||
removeByUser(userId: string, currentId: string | null): { wasCurrent: boolean } {
|
||
let wasCurrent = false;
|
||
for (const [id, slide] of this.allKnown) {
|
||
if (slide.user_id === userId) {
|
||
if (id === currentId) wasCurrent = true;
|
||
this.allKnown.delete(id);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
|
||
/** Snapshot — useful for stats / debugging. */
|
||
stats() {
|
||
return {
|
||
known: this.allKnown.size,
|
||
live: this.liveQueue.length,
|
||
shuffle: this.shuffleQueue.length
|
||
};
|
||
}
|
||
|
||
private markShown(id: string): void {
|
||
this.recentlyShown.push(id);
|
||
while (this.recentlyShown.length > RECENT_RING_SIZE) {
|
||
this.recentlyShown.shift();
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Fisher–Yates. Mutates a copy of the input so callers can pass `allKnown.values()`. */
|
||
function shuffle<T>(arr: T[]): T[] {
|
||
const out = arr.slice();
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[out[i], out[j]] = [out[j], out[i]];
|
||
}
|
||
return out;
|
||
}
|