🏗️ 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
147 lines
7.6 KiB
Markdown
147 lines
7.6 KiB
Markdown
# 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.
|