Every video in the app was unplayable. Two independent defects, either one
sufficient on its own, and nothing in the suite covered either — no test
anywhere played media or asserted a `<video>` src.
1. The lightbox handed `<video>` a JPEG.
`pickMediaUrl` is mime-agnostic, and compression only ever produces a THUMBNAIL
for a video (one `ffmpeg -vframes 1` frame) — no preview, no display. So in the
DEFAULT saver mode the element's src resolved to `/api/v1/upload/{id}/thumbnail`,
served as `image/jpeg` with `nosniff` so the browser can't even sniff its way
out. Chromium reports DEMUXER_ERROR_COULD_NOT_OPEN.
Fixed in the lightbox rather than in `pickMediaUrl`: FeedListCard shares that
helper and legitimately wants the thumbnail for its `<img>` poster, so a central
mime branch would break the feed. This mirrors the rule the diashow already
applies ("videos play the original file directly"). Added `preload="none"` so
saver-mode guests on cellular still fetch nothing until they press play — there
is no smaller video derivative to offer them — plus `playsinline`, without which
iOS hijacks playback into fullscreen.
2. `stream_media_file` ignored Range entirely.
It took no request headers, so it could not see `Range`; it always returned 200
with the whole body and never sent Accept-Ranges or Content-Range. iOS Safari
opens every `<video>` with a `Range: bytes=0-1` probe and abandons the load
without a 206 — so video failed on the app's primary platform even in `original`
mode, where the src was already correct.
Adds single-range support (`bytes=N-`, `bytes=N-M`, `bytes=-S`) with 206 +
Content-Range, 416 + `bytes */len` past EOF, and Accept-Ranges advertised on
every response. Anything it won't handle — multi-range, non-bytes units, garbage
— falls back to a full 200, which RFC 9110 explicitly permits and which is safer
than guessing. All four media routes share the helper, so seeking works
uniformly.
`get_original` now serves `inline` instead of `attachment`. An attachment
disposition is hostile to a `<video>` element, and this route is the only source
of playable video bytes; it also matches what the UI promises, since the action
is labelled "Original anzeigen" — view, not download. `no-store` is deliberately
kept so a takedown still revokes access promptly; ranges work fine under it, the
client just re-fetches.
Tests: 11 unit tests pin the parser (the iOS `bytes=0-1` probe, inclusive ends,
suffix ranges, clamping past EOF, 416 vs 200, malformed fallbacks). A new
03-feed/video-playback spec asserts the src is the original and not the
thumbnail, that the browser accepts the bytes as media (readyState > 0, no
MediaError), that no video bytes are delivered before play, and that Range
returns the correct 206 slices and a 416 past EOF — verified on both Chromium
and WebKit.
The "not downloaded before play" test asserts no *delivered body* rather than no
request: WebKit opens a connection for a preload="none" video and immediately
aborts it (GET, no Range, status 0, nothing transferred) while Chromium issues
nothing at all. The portable guarantee is that no response carrying bytes
completes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lib/ conventions
Short rules. The patterns we already follow as of v0.16 — write new code that fits.
One store per cross-cutting concern. A single *-store.ts file owns each one:
auth.ts— JWT / PIN inlocalStorage,isAuthenticatedwritableui-store.ts— bottom-nav visibility, upload-sheet open state, FAB badge countdata-mode-store.ts— Saver vs Original media-loading preferenceprivacy-note-store.ts— admin-configured Datenschutzhinweis textquota-store.ts— live per-user storage snapshotupload-queue.ts— IndexedDB-persisted upload queue + processing state
Don't import these into other stores unless strictly necessary; let pages compose them.
DTOs mirror Rust types. All TS interfaces live in types.ts. Each one carries a
// mirrors backend/src/path::TypeName comment so the two stay searchable. If you add
a Rust DTO, add the TS twin in the same PR.
Gestures via Svelte actions in actions/. Long-press, double-tap, future swipe —
each is a use: action that fires a CustomEvent. Components stay free of gesture
plumbing.
Reusable bottom sheets via ContextSheet.svelte. Pass an actions: ContextAction[]
array. Any page that needs a long-press / kebab context menu uses the same primitive.
SSE relays are listed in sse.ts::KNOWN_EVENTS. New server event → add one entry
to that array, that's it.
Diashow transitions live in diashow/transitions/. Each is a Svelte component
plus one entry in transitions/index.ts. Adding a new animation is two-line work; no
diashow code needs to change.
No new global stores beyond the list above unless the new concept is genuinely app-wide. Page state belongs in the page.