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

@@ -27,6 +27,8 @@ pub struct FeedUpload {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Big-screen (~2048px) variant for the diashow. Absent until the derivative exists.
pub display_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub like_count: i64,
@@ -48,6 +50,7 @@ struct FeedRow {
uploader_name: String,
preview_path: Option<String>,
thumbnail_path: Option<String>,
display_path: Option<String>,
mime_type: String,
caption: Option<String>,
like_count: i64,
@@ -94,7 +97,8 @@ pub async fn feed(
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, FeedRow>(
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at
v.display_path, v.mime_type, v.caption, v.like_count, v.comment_count,
v.created_at
FROM v_feed v
JOIN upload_hashtag uh ON uh.upload_id = v.id
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
@@ -113,7 +117,7 @@ pub async fn feed(
} else {
sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
display_path, mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
@@ -154,6 +158,10 @@ pub async fn feed(
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id));
let display_url = r
.display_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/display", r.id));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
@@ -161,6 +169,7 @@ pub async fn feed(
uploader_name: r.uploader_name,
preview_url,
thumbnail_url,
display_url,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
@@ -247,7 +256,7 @@ pub async fn feed_delta(
// response's `server_time`, so this doesn't re-fetch on every subsequent delta.
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
display_path, mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1 AND created_at >= $2
ORDER BY created_at DESC, id DESC
@@ -304,6 +313,10 @@ pub async fn feed_delta(
.thumbnail_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/thumbnail", r.id)),
display_url: r
.display_path
.as_ref()
.map(|_| format!("/api/v1/upload/{}/display", r.id)),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,

View File

@@ -747,6 +747,30 @@ pub async fn get_preview(
.await
}
/// Streaming access to an upload's big-screen **display** derivative (~2048px), used by the
/// diashow. Gated identically to [`get_preview`]. 404s when the derivative doesn't exist yet
/// (still compressing, or an old upload the backfill hasn't reached) — the diashow then falls
/// back to the original.
pub async fn get_display(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let media = Upload::find_visible_media(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let rel = media
.display_path
.ok_or_else(|| AppError::NotFound("Anzeige nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel);
stream_media_file(
&absolute,
"image/jpeg".to_string(),
"inline",
"private, max-age=300",
)
.await
}
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
/// [`get_preview`].
pub async fn get_thumbnail(