Skip to main content

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}