jellytau_lib/repository/stream_selection.rs
1//! What stream to play, decided in Rust and handed to a player whole.
2//!
3//! Every player backend — mpv, ExoPlayer, the webview `<video>`/hls.js path —
4//! used to receive a bare URL and re-derive the rest: the frontend decided
5//! "is this HLS?" by looking for `.m3u8` in the string, and nothing anywhere
6//! carried *why* a stream was transcoded or what else the source could have
7//! offered. This module is the replacement contract: one self-describing
8//! [`StreamSelection`] that says what the stream is, how to fetch it, and what
9//! the alternatives were.
10//!
11//! The division of labour it encodes — **Rust decides *what stream*, the player
12//! decides *how to deliver it*** — is the point. A multi-variant playlist handed
13//! to ExoPlayer is still ExoPlayer's to adapt over; Rust never paces bytes.
14//!
15//! TRACES: UR-079 | DR-225
16
17use serde::{Deserialize, Serialize};
18
19use crate::settings::StreamingQuality;
20
21/// How the bytes of a chosen stream are fetched.
22///
23/// This field exists to delete a substring search. The frontend previously
24/// decided which loader to attach by testing `url.contains(".m3u8")`, which is a
25/// domain fact reconstructed in the presentation layer — the same class of leak
26/// as the item-type taxonomy that `check:boundary` guards, and one that breaks
27/// silently the moment a server serves a playlist from a path that does not end
28/// in `.m3u8`, or serves a progressive file from one that does.
29///
30/// Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
31/// discriminant instead of comparing text.
32///
33/// TRACES: UR-079 | DR-225
34#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "camelCase")]
36pub enum Transport {
37 /// An HLS playlist. The webview attaches hls.js (or Safari's native loader);
38 /// ExoPlayer uses its HLS media source.
39 Hls,
40 /// A single progressive HTTP resource, seekable by byte range.
41 Progressive,
42 /// A file already on disk — a completed download, or the loopback media
43 /// server standing in front of one.
44 LocalFile,
45}
46
47/// What the server is doing to the source to produce this stream.
48///
49/// Distinct from [`Transport`] because the two are genuinely independent: a
50/// direct-streamed remux and a transcode can both arrive over HLS, and a direct
51/// play can arrive progressively or as a local file. Keeping them apart is what
52/// lets the UI say "this is not costing the server anything" without inferring
53/// it from a URL shape.
54///
55/// TRACES: UR-079 | DR-228
56#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "camelCase")]
58pub enum PlaybackKind {
59 /// The source file is served untouched. No server CPU, no quality loss.
60 DirectPlay,
61 /// The container is repackaged but the codecs are copied — cheap, and
62 /// visually identical to the source.
63 DirectStream,
64 /// The server is re-encoding. The only case where a bitrate ceiling can
65 /// actually be honoured, and the only one that costs the server real work.
66 Transcode,
67}
68
69impl PlaybackKind {
70 /// Whether the server is spending encoder time on this stream.
71 ///
72 /// The queue carries a `needs_transcoding` flag that predates this enum and
73 /// that several seek/reload paths still branch on; this keeps the two from
74 /// drifting by making one derive from the other.
75 ///
76 /// TRACES: UR-079 | DR-228
77 pub fn needs_transcoding(&self) -> bool {
78 matches!(self, PlaybackKind::Transcode)
79 }
80}
81
82/// The rendition actually negotiated — what the viewer is receiving right now.
83///
84/// `None` on a [`StreamSelection`] when the source is being direct-played as-is:
85/// there is no *chosen* rendition in that case, only the file itself, and
86/// reporting the ceiling that happened to be set would misdescribe it.
87///
88/// TRACES: UR-079 | DR-225, DR-226
89#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct Rendition {
92 /// The rung of the ladder this stream was built against.
93 pub quality: StreamingQuality,
94 /// Total bits per second the stream may use, when a ceiling applies.
95 pub max_bitrate: Option<u64>,
96 /// Resolution ceiling, when one applies. `None` preserves the source's.
97 pub max_height: Option<u32>,
98 /// Video codec the server was asked to produce.
99 pub video_codec: Option<String>,
100 /// Audio codec the server was asked to produce.
101 pub audio_codec: Option<String>,
102}
103
104/// One rung of the quality picker, as it applies to *this* media source.
105///
106/// The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
107/// which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
108/// them indistinguishable from Original. `exceeds_source` is what lets the
109/// frontend render that honestly without knowing anything about bitrates.
110///
111/// TRACES: UR-070, UR-079 | DR-227, DR-121
112#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct QualityOption {
115 pub quality: StreamingQuality,
116 /// Human label ("8 Mbps"). Lives in Rust beside the number it describes.
117 pub label: String,
118 /// Secondary line ("1080p").
119 pub detail: String,
120 /// True when this rung's ceiling is at or above what the source itself
121 /// carries, so selecting it yields the same stream as `Original`.
122 ///
123 /// The frontend renders these differently (or hides them); it does not
124 /// decide which they are.
125 pub exceeds_source: bool,
126 /// The source's own bitrate, when the server reported one. Presentation
127 /// only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
128 pub source_bitrate: Option<u64>,
129}
130
131/// Everything a player backend needs to open a stream, and everything the UI
132/// needs to describe it.
133///
134/// Replaces the bare `String` URL that `get_video_stream_url` used to return.
135///
136/// TRACES: UR-079 | DR-225, DR-227, DR-228
137#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct StreamSelection {
140 /// The URL (or loopback URL) to open.
141 pub url: String,
142 /// How to fetch it. Replaces the `.m3u8` substring check.
143 pub transport: Transport,
144 /// What the server is doing to the source to produce it.
145 pub playback_kind: PlaybackKind,
146 /// The negotiated rendition; `None` when direct-playing the source as-is.
147 pub rendition: Option<Rendition>,
148 /// What this media source can offer, for the quality picker (DR-227).
149 pub available: Vec<QualityOption>,
150 /// The media source this selection is for, so a later re-open (quality
151 /// change, audio-track switch, transcoded seek) targets the same one.
152 pub media_source_id: Option<String>,
153 /// The transcode identity the server keyed this job by, when there is one.
154 pub play_session_id: Option<String>,
155 /// Whether the server is spending encoder time on this stream.
156 ///
157 /// Derived from [`playback_kind`](Self::playback_kind) rather than left for
158 /// the frontend to compute: "which kinds count as transcoding" is a domain
159 /// rule, and a direct *stream* is a remux that must not be counted. The
160 /// queue's long-standing `needs_transcoding` flag and the seek strategy both
161 /// read this, so there is one answer rather than three.
162 ///
163 /// TRACES: UR-079 | DR-225, DR-228
164 pub needs_transcoding: bool,
165}
166
167impl StreamSelection {
168 /// A selection for a file already on disk.
169 ///
170 /// A downloaded file is a direct play by definition — the bytes are the
171 /// source's — and offering a quality ladder over it would be a lie, since
172 /// nothing about a local file can be re-negotiated.
173 ///
174 /// TRACES: UR-071, UR-079 | DR-225
175 pub fn local_file(url: impl Into<String>) -> Self {
176 Self {
177 url: url.into(),
178 transport: Transport::LocalFile,
179 playback_kind: PlaybackKind::DirectPlay,
180 rendition: None,
181 available: Vec::new(),
182 media_source_id: None,
183 play_session_id: None,
184 needs_transcoding: false,
185 }
186 }
187
188 /// A selection for an item already sitting in the queue.
189 ///
190 /// The queue predates `StreamSelection`: its items carry a URL, an optional
191 /// transport and the older `needs_transcoding` flag. This rebuilds a
192 /// selection from those without re-negotiating with the server, so the
193 /// controller can hand an engine an `OpenRequest` for an item it already
194 /// holds.
195 ///
196 /// The transport falls back rather than being sniffed from the URL — the
197 /// substring check is exactly what DR-230 removed. `needs_transcoding` is an
198 /// exact stand-in because every transcode this app requests is HLS (DR-140).
199 ///
200 /// TRACES: UR-079, UR-081 | DR-225, DR-245
201 pub fn for_queued_item(
202 url: impl Into<String>,
203 transport: Option<Transport>,
204 needs_transcoding: bool,
205 ) -> Self {
206 let transport = transport.unwrap_or(if needs_transcoding {
207 Transport::Hls
208 } else {
209 Transport::Progressive
210 });
211 Self {
212 url: url.into(),
213 transport,
214 playback_kind: if needs_transcoding {
215 PlaybackKind::Transcode
216 } else {
217 PlaybackKind::DirectPlay
218 },
219 rendition: None,
220 available: Vec::new(),
221 media_source_id: None,
222 play_session_id: None,
223 needs_transcoding,
224 }
225 }
226}
227
228/// Build the quality ladder as it applies to a source of a known bitrate.
229///
230/// Every rung is returned — the picker stays a fixed, predictable list rather
231/// than one that changes length per item — but each is marked with whether it
232/// would actually constrain *this* source. A rung whose ceiling is at or above
233/// the source bitrate produces the same bytes as `Original`, so presenting it as
234/// a distinct choice is noise.
235///
236/// `source_bitrate` is `None` when the server did not report one (it is absent
237/// for some containers — the sampled library has `avi` files with no bitrate at
238/// all). In that case nothing can be judged redundant and every rung is offered,
239/// which is the safe direction: the viewer keeps every choice they had before.
240///
241/// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
242pub fn quality_options_for_source(source_bitrate: Option<u64>) -> Vec<QualityOption> {
243 StreamingQuality::ALL
244 .iter()
245 .map(|quality| QualityOption {
246 quality: *quality,
247 label: quality.label().to_string(),
248 detail: quality.detail().to_string(),
249 exceeds_source: match (quality.max_bitrate(), source_bitrate) {
250 // `Original` is the source; it never "exceeds" it.
251 (None, _) => false,
252 // Nothing known about the source — judge nothing redundant.
253 (Some(_), None) => false,
254 (Some(cap), Some(source)) => cap >= source,
255 },
256 source_bitrate,
257 })
258 .collect()
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 /// The tag the frontend matches on has to be exactly what it expects, and
266 /// it is a *string in TypeScript* — nothing but a test keeps the two in step.
267 ///
268 /// TRACES: UR-079 | DR-225 | UT-212
269 #[test]
270 fn test_transport_serialises_with_the_tag_the_frontend_matches() {
271 let cases = [
272 (Transport::Hls, r#"{"type":"hls"}"#),
273 (Transport::Progressive, r#"{"type":"progressive"}"#),
274 (Transport::LocalFile, r#"{"type":"localFile"}"#),
275 ];
276 for (transport, expected) in cases {
277 let json = serde_json::to_string(&transport).expect("serialises");
278 assert_eq!(json, expected, "wire shape of {transport:?}");
279 let back: Transport = serde_json::from_str(&json).expect("round-trips");
280 assert_eq!(back, transport);
281 }
282 }
283
284 /// TRACES: UR-079 | DR-228 | UT-212
285 #[test]
286 fn test_playback_kind_serialises_with_the_tag_the_frontend_matches() {
287 let cases = [
288 (PlaybackKind::DirectPlay, r#"{"type":"directPlay"}"#),
289 (PlaybackKind::DirectStream, r#"{"type":"directStream"}"#),
290 (PlaybackKind::Transcode, r#"{"type":"transcode"}"#),
291 ];
292 for (kind, expected) in cases {
293 let json = serde_json::to_string(&kind).expect("serialises");
294 assert_eq!(json, expected, "wire shape of {kind:?}");
295 let back: PlaybackKind = serde_json::from_str(&json).expect("round-trips");
296 assert_eq!(back, kind);
297 }
298 }
299
300 /// Only a transcode costs the server encoder time. A direct *stream* is a
301 /// remux — cheap, and not what `needs_transcoding` has ever meant.
302 ///
303 /// TRACES: UR-079 | DR-228 | UT-212
304 #[test]
305 fn test_only_transcode_counts_as_transcoding() {
306 assert!(PlaybackKind::Transcode.needs_transcoding());
307 assert!(!PlaybackKind::DirectStream.needs_transcoding());
308 assert!(!PlaybackKind::DirectPlay.needs_transcoding());
309 }
310
311 /// A local file is a direct play over a local transport, with no ladder:
312 /// nothing about a file on disk can be re-negotiated.
313 ///
314 /// TRACES: UR-071, UR-079 | DR-225 | UT-212
315 #[test]
316 fn test_local_file_selection_offers_no_ladder() {
317 let selection = StreamSelection::local_file("http://127.0.0.1:9000/media/x.mkv");
318 assert_eq!(selection.transport, Transport::LocalFile);
319 assert_eq!(selection.playback_kind, PlaybackKind::DirectPlay);
320 assert!(selection.rendition.is_none());
321 assert!(selection.available.is_empty());
322 assert!(!selection.needs_transcoding);
323 }
324
325 /// The camelCase rule applies to nested struct fields too, and
326 /// `playbackKind` is the one the frontend branches on.
327 ///
328 /// TRACES: UR-079 | DR-225 | UT-212
329 #[test]
330 fn test_stream_selection_fields_are_camel_case_on_the_wire() {
331 let selection = StreamSelection {
332 url: "https://example/master.m3u8".to_string(),
333 transport: Transport::Hls,
334 playback_kind: PlaybackKind::Transcode,
335 rendition: Some(Rendition {
336 quality: StreamingQuality::Mbps8,
337 max_bitrate: Some(8_000_000),
338 max_height: Some(1080),
339 video_codec: Some("h264".to_string()),
340 audio_codec: Some("aac".to_string()),
341 }),
342 available: Vec::new(),
343 media_source_id: Some("src-1".to_string()),
344 play_session_id: Some("sess-1".to_string()),
345 needs_transcoding: true,
346 };
347 let json = serde_json::to_string(&selection).expect("serialises");
348 assert!(
349 json.contains(r#""playbackKind":{"type":"transcode"}"#),
350 "{json}"
351 );
352 assert!(json.contains(r#""transport":{"type":"hls"}"#), "{json}");
353 assert!(json.contains(r#""mediaSourceId":"src-1""#), "{json}");
354 assert!(json.contains(r#""playSessionId":"sess-1""#), "{json}");
355 assert!(json.contains(r#""maxBitrate":8000000"#), "{json}");
356 assert!(json.contains(r#""maxHeight":1080"#), "{json}");
357 assert!(json.contains(r#""needsTranscoding":true"#), "{json}");
358 }
359
360 /// The measured library has 1.1 Mbps sources in it. Offering those a choice
361 /// of 20, 10, 8, 4 and 2 Mbps is offering five ways to spell "Original".
362 ///
363 /// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
364 #[test]
365 fn test_rungs_above_the_source_bitrate_are_marked_redundant() {
366 let options = quality_options_for_source(Some(1_122_137));
367 let redundant: Vec<_> = options
368 .iter()
369 .filter(|o| o.exceeds_source)
370 .map(|o| o.quality)
371 .collect();
372 assert_eq!(
373 redundant,
374 vec![
375 StreamingQuality::Mbps20,
376 StreamingQuality::Mbps10,
377 StreamingQuality::Mbps8,
378 StreamingQuality::Mbps4,
379 StreamingQuality::Mbps2,
380 ],
381 "every rung at or above a 1.12 Mbps source is the source"
382 );
383
384 // The rungs that genuinely constrain it are not marked.
385 let constraining: Vec<_> = options
386 .iter()
387 .filter(|o| !o.exceeds_source)
388 .map(|o| o.quality)
389 .collect();
390 assert_eq!(
391 constraining,
392 vec![
393 StreamingQuality::Original,
394 StreamingQuality::Mbps1,
395 StreamingQuality::Kbps720,
396 ]
397 );
398 }
399
400 /// `Original` is the source, so it is never "above" it — not even for a
401 /// source whose bitrate is unknown or zero.
402 ///
403 /// TRACES: UR-070, UR-079 | DR-227 | UT-212
404 #[test]
405 fn test_original_is_never_marked_as_exceeding_the_source() {
406 for bitrate in [None, Some(0), Some(1), Some(50_000_000)] {
407 let options = quality_options_for_source(bitrate);
408 let original = options
409 .iter()
410 .find(|o| o.quality == StreamingQuality::Original)
411 .expect("Original is always offered");
412 assert!(!original.exceeds_source, "bitrate {bitrate:?}");
413 }
414 }
415
416 /// An `avi` with no reported bitrate must not lose the picker. Judging
417 /// nothing redundant is the safe direction — the viewer keeps every choice.
418 ///
419 /// TRACES: UR-070, UR-079 | DR-227 | UT-212
420 #[test]
421 fn test_an_unknown_source_bitrate_keeps_every_rung_offered() {
422 let options = quality_options_for_source(None);
423 assert_eq!(options.len(), StreamingQuality::ALL.len());
424 assert!(
425 options.iter().all(|o| !o.exceeds_source),
426 "nothing can be judged redundant without a source bitrate"
427 );
428 assert!(options.iter().all(|o| o.source_bitrate.is_none()));
429 }
430
431 /// A 4K remux constrains at every rung — the ladder is fully meaningful.
432 ///
433 /// TRACES: UR-070, UR-079 | DR-227 | UT-212
434 #[test]
435 fn test_a_source_above_the_ladder_marks_nothing_redundant() {
436 let options = quality_options_for_source(Some(40_000_000));
437 assert!(options.iter().all(|o| !o.exceeds_source));
438 }
439
440 /// The picker's text comes from Rust, beside the numbers it describes, so a
441 /// relabelled rung cannot drift out of step with what it does.
442 ///
443 /// TRACES: UR-070, UR-079 | DR-227 | UT-212
444 #[test]
445 fn test_options_carry_the_ladder_labels() {
446 let options = quality_options_for_source(Some(6_652_961));
447 assert_eq!(options.len(), StreamingQuality::ALL.len());
448 for (option, quality) in options.iter().zip(StreamingQuality::ALL) {
449 assert_eq!(option.quality, quality);
450 assert_eq!(option.label, quality.label());
451 assert_eq!(option.detail, quality.detail());
452 assert_eq!(option.source_bitrate, Some(6_652_961));
453 }
454 }
455}