Master allocated DR-224 and UT-211 while this branch was in flight — the third collision on this work. Everything here moves up by one: DR-224..236 become DR-225..237, UT-211..213 become UT-212..214. UR-079, UR-080 and IR-033 were still free and are unchanged. Mechanical, and matched on each row's own text rather than on its number, so a row cannot be shifted twice or the wrong one caught. Master's DR-224 (the background-audio toggle) and UT-211 are untouched.
228 lines
12 KiB
Markdown
228 lines
12 KiB
Markdown
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
|
|
|
|
**Status:** Partially implemented. Landed: the cache/download unification
|
|
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
|
|
eviction only reclaims the temporary tier), local playback of downloaded media
|
|
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
|
|
DR-123 is in progress. Still open: the read-through capture itself — DR-122,
|
|
DR-124, DR-125.
|
|
|
|
**DR-121 has shipped and left this spec.** The player quality selector, the
|
|
per-playback bitrate ceiling, and the backend-owned stream decision it needed
|
|
were built as *backend-owned stream selection* (DR-225 … DR-228) and are
|
|
described in
|
|
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection) and
|
|
[03-data-flow.md](../architecture/03-data-flow.md#video-stream-selection-flow).
|
|
The settings-level ceiling (DR-162) is the same section. What remains here is the
|
|
*capture* half only — this spec no longer specifies anything about choosing a
|
|
bitrate.
|
|
|
|
**Requirements:** UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032
|
|
**Related:** the locally-indexed search and downloaded-browse work, both
|
|
shipped — see
|
|
[03-data-flow.md](../architecture/03-data-flow.md) and
|
|
[06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md)
|
|
|
|
## Summary
|
|
|
|
Two things that are today tangled become explicitly separate:
|
|
|
|
- **The playback path** streams at a bitrate the viewer can change from the
|
|
player. It is ephemeral and its rendition is volatile.
|
|
- **The download path** fetches the whole file at one canonical quality, in the
|
|
background, independently of whatever playback is doing.
|
|
|
|
Bytes fetched for playback are kept **only** when the playback rendition happens
|
|
to be the same artifact the download path would produce — i.e. direct play.
|
|
Otherwise playback bytes are discarded and the download path does its own fetch.
|
|
|
|
## Motivation
|
|
|
|
The appealing version of this — "stream and download at once, switch when enough
|
|
has arrived" — breaks the moment the viewer can change bitrate. A capture taken
|
|
while the rendition changes underneath it is a splice of two encodings: not a
|
|
playable file, and not something that can be honestly recorded as a download.
|
|
Once bitrate is selectable, one stream cannot serve both jobs.
|
|
|
|
Separating the paths also removes the thing that made the original idea
|
|
expensive: there is no mid-playback source swap to engineer, because the download
|
|
never has to take over the live session. It lands on disk and is used at the next
|
|
natural boundary — next episode, or next time the item is played.
|
|
|
|
What exists already and is *not* this: `SmartCache` predictively downloads *other*
|
|
items, `player_preload_upcoming` warms the next one, and
|
|
`refresh_queue_local_sources` swaps queue entries to local at boundaries. All of
|
|
it concerns items you are not currently playing.
|
|
|
|
## Layer assignment
|
|
|
|
| Logic / responsibility | Layer | Why it belongs there |
|
|
|------------------------|-------|----------------------|
|
|
| Available bitrate options for an item | **Rust** | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API. |
|
|
| Mapping a chosen bitrate to transcode parameters | **Rust** | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it. |
|
|
| Deciding whether playback bytes are keepable (direct play vs transcode) | **Rust** | Depends on the negotiated session. |
|
|
| Canonical download quality | **Rust** | Policy over domain data. |
|
|
| Cache eviction, storage budget, sparse-range bookkeeping | **Rust** | Storage policy. |
|
|
| Promotion to a `downloads` row, and what invalidates a cache entry | **Rust** | Domain state. |
|
|
| Rendering the quality selector; remembering the last choice | **Frontend** | Presentation and a view preference. The *list* comes from Rust. |
|
|
| WiFi-only / opt-in toggles | **Frontend collects, Rust enforces** | The control is UI; the gate must hold even if the UI never calls. |
|
|
|
|
Borderline, recorded: the **default** playback bitrate could look like a user
|
|
preference (frontend). It goes to Rust because it must be reconcilable with what
|
|
the server can actually produce for a given media source — a preference the
|
|
backend has to validate is not a preference the frontend can own alone. The
|
|
frontend stores the user's *choice*; Rust decides what that choice resolves to.
|
|
|
|
## Design
|
|
|
|
### DR-121 — moved out (shipped)
|
|
|
|
Bitrate selection in the player shipped as DR-225 … DR-228; see
|
|
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection).
|
|
|
|
The one constraint here that the capture work still has to respect: a quality
|
|
change re-negotiates **within HLS**. Returning a progressive `stream.mp4` for a
|
|
transcode means playback never starts, because the server encodes the whole file
|
|
before serving a byte (DR-140). That is why DR-122 below abandons a capture on a
|
|
quality change rather than trying to splice one.
|
|
|
|
### DR-122 — The playback path is ephemeral
|
|
|
|
Playback bytes are not persisted unless DR-124 says they are keepable. No partial
|
|
capture is ever retained across a quality change: on change, any in-flight capture
|
|
for that session is abandoned and its partial file deleted.
|
|
|
|
### DR-123 — The download path is independent
|
|
|
|
Downloading the whole file is a separate operation through the existing download
|
|
manager, at one canonical quality (default `original`, the direct static copy),
|
|
using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what
|
|
the resumable download worker relies on. It is unaffected by what playback is
|
|
doing, and playback is unaffected by it.
|
|
|
|
Once complete it becomes an ordinary download row, so everything already built on
|
|
top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads
|
|
page — picks it up with no further work.
|
|
|
|
**Prerequisite:** downloaded *video* is currently never played locally.
|
|
`repository_get_video_stream_url` goes straight to the online repo and
|
|
[player/[id]/+page.svelte:316](../../src/routes/player/[id]/+page.svelte#L316)
|
|
calls it with no local check — so a completed video download is still streamed.
|
|
This must be fixed or the whole feature is invisible for video.
|
|
|
|
### DR-124 — Keep playback bytes only when they *are* the download
|
|
|
|
Capture is enabled only where the played bytes and the canonical download artifact
|
|
are the same thing — a **direct-play** session. Then:
|
|
|
|
| Path | Mechanism |
|
|
|---|---|
|
|
| Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id **and** media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk. |
|
|
| Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing. |
|
|
| Linux video (HLS transcode) | **Not captured.** Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead. |
|
|
|
|
Two abandonment rules, both of which must delete the partial rather than promote
|
|
it:
|
|
|
|
- **Seek during an mpv capture.** `stream-record` is documented as intended for
|
|
linear streams; seeking breaks the recording. Straight-through listening
|
|
captures, scrubbing does not.
|
|
- **Any quality change** (DR-122).
|
|
|
|
### DR-125 — Promotion, rendition, and invalidation
|
|
|
|
A capture is promoted to a `downloads` row (`status = 'completed'`) only when it
|
|
covers the whole resource. Partial captures stay cache and remain evictable.
|
|
|
|
A new `downloads.source_rendition` column records the negotiated
|
|
quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by
|
|
the existing paths, which are always `original`. This is what makes an "upgrade to
|
|
original" action possible later, and what stops a 720p capture and a 4K download
|
|
being indistinguishable rows.
|
|
|
|
**Invalidation.** A quality change never touches a file that already exists —
|
|
neither a permanent download nor a completed temporary one. Both remain valid
|
|
copies of the rendition they hold, and deleting either would throw away bytes
|
|
already paid for.
|
|
|
|
What a quality change *does* invalidate is an **in-flight** capture or background
|
|
download of cached media: it is abandoned and restarted at the newly chosen
|
|
quality, because a capture spanning a rendition change is a splice of two
|
|
encodings rather than a playable file (DR-122).
|
|
|
|
So the rule is about *ongoing* work, not stored files. Nothing in this spec
|
|
deletes user data.
|
|
|
|
### Gating
|
|
|
|
Capture and background download obey the existing WiFi-only gate and storage
|
|
budget, and are off unless opted in. Enforcement is in Rust.
|
|
|
|
## Out of scope
|
|
|
|
- **Mid-playback switch onto a completing download.** Two independent paths make
|
|
it unnecessary; the download is used from the next boundary.
|
|
- **Backfilling the unplayed remainder of a capture.** Watch 40 minutes and you
|
|
have 40 minutes; completing it needs sparse-range bookkeeping and a resumable
|
|
tail fetch. The DR-123 download path already produces a complete file, which is
|
|
the reason this can wait.
|
|
- **Bundling ffmpeg** to make transcoded video capturable. Real option, large
|
|
packaging decision, its own proposal.
|
|
- **Routing Linux video playback through `stream.mp4`.** Regresses a documented,
|
|
hard-won fix.
|
|
|
|
## Acceptance criteria
|
|
|
|
- [ ] The player offers the qualities Rust reports, and changing one resumes at
|
|
the same position with audio/subtitle selection preserved.
|
|
- [ ] A quality change abandons any in-flight capture and leaves no partial file.
|
|
- [ ] A quality change never deletes a `downloads` row.
|
|
- [ ] A completed background download of a video is *played from disk* on the next
|
|
play (the DR-123 prerequisite).
|
|
- [ ] A direct-play session played start-to-finish leaves a complete local file
|
|
with no second fetch; replaying it fetches no media bytes.
|
|
- [ ] Seeking during an mpv capture abandons it; no truncated file is promoted.
|
|
- [ ] A transcoded Linux video session is never captured, and never partially
|
|
promoted.
|
|
- [ ] Promoted rows record their rendition; existing paths still record
|
|
`NULL`/`original`.
|
|
- [ ] Gates hold with the setting off *and* with the frontend never sending it.
|
|
- [ ] Eviction cannot delete bytes backing a promoted download row.
|
|
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`,
|
|
`bun run test:rust`, `bun run check:boundary` pass; `bindings.ts`
|
|
regenerated if Rust types changed.
|
|
|
|
## Testing
|
|
|
|
Rust, table-driven and pure where possible: quality→params resolution shared with
|
|
the download path; keepability (direct play vs transcode vs gate off); promotion
|
|
(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not);
|
|
invalidation (evicts cache, never a download row); rendition round-trip.
|
|
|
|
Android: instrumented — a played direct-play item yields cache entries, and a
|
|
replay issues no media network request.
|
|
|
|
Frontend: the quality list renders from backend data with no item-type or
|
|
codec taxonomy in `src/`; the selector's remembered choice is a view preference.
|
|
|
|
## TRACES
|
|
|
|
| Piece | Tag |
|
|
|---|---|
|
|
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
|
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
|
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
|
| Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 \| DR-125` |
|
|
|
|
## Notes for the implementer
|
|
|
|
- **A parallel Claude session is active in this repo.** `git diff` before
|
|
"repairing" anything you did not write.
|
|
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
|
|
- Reuse the smart-cache storage budget; two budgets over one disk is how devices
|
|
fill up.
|
|
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion
|
|
for an item that was never cached.
|
|
- Build DR-123's local-playback fix first. Without it nothing in this spec is
|
|
observable for video.
|