docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had claimed for picture-in-picture in the meantime. Renumbered to DR-162 across the Rust and frontend TRACES comments (the PiP tags in VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160) and regenerated bindings.ts. Adds the requirement rows the tags point at: UR-074 for the user need, and DR-162 covering why the cap has to reach the PlaybackInfo negotiation and not only the transcode URL, why the ceiling is process-wide, and why the Settings default persists while the in-player override does not. Notes that this gives UR-070 its resume-at-the-same-point mechanism while the server-offered rendition list that requirement also asks for stays proposed. UT-156/157 record what the tests pin. docs/specs/streaming-bitrate-cap.md carries the layer assignment — the step definitions, the video/audio split, the resolution pairing and the reload decision are all Rust; the frontend holds a serde token and the labels it was handed. TRACES: UR-074 | DR-162 | UT-156, UT-157
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# Spec: streaming bitrate cap
|
||||
|
||||
**Status:** Implemented
|
||||
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
|
||||
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
|
||||
|
||||
## Summary
|
||||
|
||||
The viewer picks a bandwidth ceiling for video — from `Original` (no client
|
||||
limit) down to 720 kbps — and every video the app opens is fetched within it,
|
||||
live TV included. The choice is made once in Settings and persists across
|
||||
restarts; a single video can be moved to another ceiling from the player, which
|
||||
re-opens the stream and resumes where it was without changing the saved default.
|
||||
|
||||
## Motivation
|
||||
|
||||
Every video URL the app built carried a fixed allowance —
|
||||
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
|
||||
negotiation asked for 20 Mbps, and the device profile advertised
|
||||
`999999999`, which invites the server to direct-play a source of any size. On a
|
||||
metered or slow connection there was no lever at all short of not watching.
|
||||
|
||||
The related UR-070 asks for something adjacent but different: a list of the
|
||||
renditions *the server can produce for this item*. That needs per-item
|
||||
`MediaSources` negotiation and is still proposed. What was missing first is
|
||||
cruder and more valuable: a device-wide budget that holds regardless of what is
|
||||
playing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
|
||||
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
|
||||
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
|
||||
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
|
||||
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
|
||||
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
|
||||
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
|
||||
|
||||
The frontend holds one string — the serde token for the chosen variant — and
|
||||
labels/details it received from Rust. It never encodes a bitrate, a resolution
|
||||
or a parameter name.
|
||||
|
||||
## Design
|
||||
|
||||
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
|
||||
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
|
||||
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
|
||||
`video_bitrate()` (= total − audio), `max_height()`, `label()`, `detail()`.
|
||||
|
||||
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
|
||||
`repository/online.rs`, read by every builder. Process-wide rather than a field
|
||||
on `OnlineRepository` because it is a preference about *this device's
|
||||
connection*: it must survive a repository rebuilt on re-login, and the URL
|
||||
builders and the negotiation have to agree on it or the cap leaks. This mirrors
|
||||
`offline::INCLUDE_CATALOG_BROWSE`.
|
||||
|
||||
Enforcement points — all four are required:
|
||||
|
||||
| Point | What the cap sets |
|
||||
|-------|-------------------|
|
||||
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
|
||||
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
|
||||
| `open_live_stream` | `MaxStreamingBitrate` |
|
||||
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
|
||||
|
||||
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
|
||||
the server refuse to *direct play* a source fatter than the ceiling; without it
|
||||
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
|
||||
it.
|
||||
|
||||
IPC:
|
||||
|
||||
```rust
|
||||
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
|
||||
player_set_stream_quality(repository_handle, quality, use_html5,
|
||||
current_position, media_source_id, audio_stream_index)
|
||||
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
|
||||
```
|
||||
|
||||
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
|
||||
persisted before the field existed load as uncapped).
|
||||
`player_set_video_settings` applies it and writes it to `app_settings`;
|
||||
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
|
||||
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
|
||||
|
||||
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
|
||||
tauri-specta only camelCases the `strategy` tag. The facade
|
||||
(`playerController.setStreamQuality`) dispatches `reloadSource` for
|
||||
`reloadStream` and does nothing for `native`, because the backend has already
|
||||
reloaded itself.
|
||||
|
||||
Mid-playback the change applies to the current video **and** becomes the process
|
||||
ceiling for what follows, but it is not persisted: the in-player menu is a "this
|
||||
film, this connection" control and Settings owns the durable default.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
|
||||
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
|
||||
- Adaptive/automatic selection from measured throughput.
|
||||
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `bun run check` passes.
|
||||
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
|
||||
- [x] `bun run test` passes.
|
||||
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
|
||||
- [x] New code carries `// TRACES:` comments.
|
||||
- [x] `bindings.ts` regenerated from Rust.
|
||||
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
|
||||
|
||||
## Testing
|
||||
|
||||
Rust (`cargo test`):
|
||||
|
||||
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
|
||||
- `test_video_stream_url_uncapped_keeps_legacy_allowance` — `Original` is unchanged and adds no `MaxHeight`.
|
||||
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
|
||||
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
|
||||
|
||||
The ceiling is process-wide, so tests that depend on it serialise on a guard
|
||||
(`QualityFixture`) that restores `Original` on drop — including the two
|
||||
pre-existing stream-URL tests, which would otherwise see another test's cap.
|
||||
|
||||
`get_playback_info` and `open_live_stream` need a live server and are not unit
|
||||
tested; their behaviour is the enum's `max_bitrate()`, which is.
|
||||
|
||||
## TRACES
|
||||
|
||||
- `StreamingQuality`, `VideoSettings.streaming_quality` — `UR-074 | DR-162`
|
||||
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
|
||||
- Audio-only handoff — `UR-040, UR-074 | DR-162`
|
||||
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
|
||||
- Tests — `UT-156`, `UT-157`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
|
||||
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
|
||||
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
|
||||
- A parallel Claude session may be active in this repo; `git diff` before
|
||||
repairing unexpected changes. DR-160/161 were claimed by such a session while
|
||||
this feature was in flight, which is why it is DR-162.
|
||||
Reference in New Issue
Block a user