Skip to main content

jellytau_lib/
media_server.rs

1//! A loopback HTTP server for locally downloaded media.
2//!
3//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
4//! webview. Its response to a request *without* a `Range` header reads the whole
5//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
6//! inside the range branch — so the first request never learns ranges are
7//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
8//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
9//! "downloaded video does not play offline".
10//!
11//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
12//! deliberate: it makes range support a property of the transport instead of
13//! depending on whether a platform's webview forwards `Range` to a custom
14//! scheme, which differs between Android and the desktop webviews.
15//!
16//! Two things confine it, because **loopback is shared between apps on
17//! Android** — any other installed app can connect to this port:
18//!
19//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
20//! - every URL carries a random per-session token, so another app cannot guess a
21//!   working URL, and paths are confined to the app data directory even if one
22//!   did.
23//!
24//! Phase 1 serves local files only. The same origin is the intended home for
25//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
26//!
27//! TRACES: UR-071 | DR-137 | UT-127
28
29use std::fs::File;
30use std::io::{Read, Seek, SeekFrom};
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33
34use log::{debug, error, info, warn};
35use rand::Rng;
36use tiny_http::{Header, Response, Server, StatusCode};
37
38/// Bytes per response. Large enough that a film needs relatively few round
39/// trips, small enough that one response is never a memory problem on a phone.
40/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
41/// multi-gigabyte files this exists to serve.
42const CHUNK_LEN: u64 = 4 * 1024 * 1024;
43
44/// Managed state. `None` when the server could not bind — local playback then
45/// fails with a clear error instead of the app refusing to start.
46pub struct MediaServerWrapper(pub Option<MediaServer>);
47
48/// A running server. Dropping this does not stop the thread; the server lives
49/// for the life of the process by design, since playback can start at any time.
50pub struct MediaServer {
51    port: u16,
52    token: String,
53}
54
55impl MediaServer {
56    /// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
57    pub fn base_url(&self) -> String {
58        format!("http://127.0.0.1:{}/{}", self.port, self.token)
59    }
60
61    /// A playable URL for an absolute on-disk path.
62    pub fn url_for(&self, path: &str) -> String {
63        format!("{}/{}", self.base_url(), urlencoding::encode(path))
64    }
65}
66
67/// Bind to an ephemeral loopback port and start serving `root` in a background
68/// thread.
69///
70/// TRACES: UR-071 | DR-137
71pub fn start(root: PathBuf) -> Result<MediaServer, String> {
72    // Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
73    // this off the network.
74    let server =
75        Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
76
77    let port = server
78        .server_addr()
79        .to_ip()
80        .ok_or_else(|| "Media server bound to a non-IP address".to_string())?
81        .port();
82
83    let token: String = {
84        let mut rng = rand::thread_rng();
85        (0..32)
86            .map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
87            .collect()
88    };
89
90    info!(
91        "[MediaServer] Serving {} on 127.0.0.1:{}",
92        root.display(),
93        port
94    );
95
96    let server = Arc::new(server);
97    let shared = Arc::new((root, token.clone()));
98
99    std::thread::Builder::new()
100        .name("media-server".into())
101        .spawn(move || loop {
102            let request = match server.recv() {
103                Ok(r) => r,
104                Err(e) => {
105                    error!("[MediaServer] accept failed: {e}");
106                    continue;
107                }
108            };
109            let shared = Arc::clone(&shared);
110            // A thread per request: media clients open several connections at
111            // once, and a blocking read of one must not stall the others.
112            if let Err(e) = std::thread::Builder::new()
113                .name("media-server-req".into())
114                .spawn(move || {
115                    let (root, token) = &*shared;
116                    handle(request, root, token);
117                })
118            {
119                error!("[MediaServer] could not spawn handler: {e}");
120            }
121        })
122        .map_err(|e| format!("Failed to start media server thread: {e}"))?;
123
124    Ok(MediaServer { port, token })
125}
126
127fn header(name: &str, value: &str) -> Header {
128    Header::from_bytes(name.as_bytes(), value.as_bytes())
129        .expect("static header name/value are valid")
130}
131
132fn empty(status: u16) -> Response<std::io::Empty> {
133    Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
134}
135
136fn handle(request: tiny_http::Request, root: &Path, token: &str) {
137    let url = request.url().to_string();
138    let method = request.method().as_str().to_string();
139
140    let outcome = match route(&url, root, token) {
141        Ok(path) => path,
142        Err(status) => {
143            let _ = request.respond(empty(status));
144            return;
145        }
146    };
147
148    if method != "GET" && method != "HEAD" {
149        let _ = request.respond(empty(405));
150        return;
151    }
152
153    let mut file = match File::open(&outcome) {
154        Ok(f) => f,
155        Err(e) => {
156            warn!("[MediaServer] {}: {}", outcome.display(), e);
157            let _ = request.respond(empty(404));
158            return;
159        }
160    };
161    let len = match file.metadata() {
162        Ok(m) => m.len(),
163        Err(e) => {
164            warn!(
165                "[MediaServer] metadata failed for {}: {}",
166                outcome.display(),
167                e
168            );
169            let _ = request.respond(empty(404));
170            return;
171        }
172    };
173
174    let range = request
175        .headers()
176        .iter()
177        .find(|h| h.field.equiv("Range"))
178        .map(|h| h.value.as_str().to_string());
179
180    debug!(
181        "[MediaServer] {} {} ({} bytes) range={:?}",
182        method,
183        outcome.display(),
184        len,
185        range
186    );
187
188    let Some(span) = span_for(range.as_deref(), len) else {
189        let _ = request
190            .respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
191        return;
192    };
193
194    // Sniff before seeking to the span, for extension-less files.
195    let mut head = [0u8; 16];
196    let head_len = file.read(&mut head).unwrap_or(0);
197    let mime = content_type(&outcome, &head[..head_len]);
198
199    if method == "HEAD" {
200        let _ = request.respond(
201            empty(200)
202                .with_header(header("Content-Type", mime))
203                .with_header(header("Content-Length", &len.to_string())),
204        );
205        return;
206    }
207
208    if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
209        warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
210        let _ = request.respond(empty(500));
211        return;
212    }
213
214    // Streamed straight from the file handle: at no point is more than the
215    // span in memory, and the span is capped at CHUNK_LEN.
216    let nbytes = span.len();
217    let body = file.take(nbytes);
218    // tiny_http switches to chunked transfer above a 32 KiB default, which drops
219    // Content-Length — and a 206 without one is unusable to Chromium's media
220    // loader, which needs the range's size. Raising the threshold past our own
221    // cap keeps every response length-delimited.
222    let response = Response::new(
223        StatusCode(206),
224        vec![
225            header("Accept-Ranges", "bytes"),
226            header("Content-Type", mime),
227            header(
228                "Content-Range",
229                &format!("bytes {}-{}/{}", span.start, span.end, len),
230            ),
231        ],
232        body,
233        Some(nbytes as usize),
234        None,
235    )
236    .with_chunked_threshold(usize::MAX);
237
238    if let Err(e) = request.respond(response) {
239        // A client that seeks away closes the connection mid-body; that is
240        // normal and must not be logged as a failure.
241        debug!("[MediaServer] response ended early: {e}");
242    }
243}
244
245/// Check the token and resolve the path, or return the status to answer with.
246fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
247    let trimmed = url.trim_start_matches('/');
248    let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
249
250    // Constant-time-ish: length check first, then a byte compare. The token is
251    // the only thing standing between another app on the device and this server.
252    if got_token.len() != token.len() || got_token != token {
253        warn!("[MediaServer] Rejected a request with a bad token");
254        return Err(403);
255    }
256
257    // Strip any query string before decoding.
258    let raw = rest.split('?').next().unwrap_or("");
259    match resolve_path(raw, root) {
260        Resolved::Allow(p) => Ok(p),
261        Resolved::Forbidden => {
262            warn!("[MediaServer] Refused a path outside the app data directory");
263            Err(403)
264        }
265    }
266}
267
268/// What a request path resolved to, before any file is touched.
269#[derive(Debug, PartialEq, Eq)]
270pub enum Resolved {
271    Allow(PathBuf),
272    /// Escaped the allowed root.
273    Forbidden,
274}
275
276/// Resolve a percent-encoded request path to a file inside `root`.
277///
278/// `..` segments are folded away lexically rather than through `canonicalize`,
279/// so a missing file still resolves (and then 404s) instead of being reported as
280/// a scope violation.
281///
282/// TRACES: UR-071 | DR-137 | UT-127
283pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
284    let decoded = match urlencoding::decode(raw) {
285        Ok(d) => d.into_owned(),
286        Err(_) => raw.to_string(),
287    };
288
289    let mut normalised = PathBuf::new();
290    for part in Path::new(&decoded).components() {
291        match part {
292            std::path::Component::ParentDir => {
293                normalised.pop();
294            }
295            std::path::Component::CurDir => {}
296            other => normalised.push(other),
297        }
298    }
299
300    if normalised.starts_with(root) {
301        Resolved::Allow(normalised)
302    } else {
303        Resolved::Forbidden
304    }
305}
306
307/// The byte range a response should carry. `end` is inclusive.
308#[derive(Debug, PartialEq, Eq)]
309pub struct Span {
310    pub start: u64,
311    pub end: u64,
312}
313
314impl Span {
315    pub fn len(&self) -> u64 {
316        self.end + 1 - self.start
317    }
318}
319
320/// Decide which span to send for a `Range` header (or its absence).
321///
322/// `None` means unsatisfiable — answer 416. A missing or unparseable header
323/// yields the first chunk, so a client that did not ask for a range still gets a
324/// bounded response it can continue from, which is exactly the case the asset
325/// protocol answered with the whole file.
326///
327/// TRACES: UR-071 | DR-137 | UT-127
328pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
329    // A zero-length file has no byte to serve. `end` is inclusive, so the
330    // shortest span this type can express is one byte — returning one for an
331    // empty file declared `Content-Length: 1` and then streamed nothing, which
332    // Chromium's media loader waits on forever. 416 says so honestly instead.
333    if len == 0 {
334        return None;
335    }
336    let last = len - 1;
337    let first_chunk = Span {
338        start: 0,
339        end: (CHUNK_LEN - 1).min(last),
340    };
341
342    let Some(raw) = range else {
343        return Some(first_chunk);
344    };
345    let Some(spec) = raw.trim().strip_prefix("bytes=") else {
346        return Some(first_chunk);
347    };
348    // Only the first range of a multi-range request is honoured; media clients
349    // ask for one, and a single 206 is a valid answer either way.
350    let spec = spec.split(',').next().unwrap_or("").trim();
351    let Some((from, to)) = spec.split_once('-') else {
352        return Some(first_chunk);
353    };
354
355    let (start, end) = if from.is_empty() {
356        // Suffix form: `-500` is the final 500 bytes.
357        let suffix: u64 = match to.parse() {
358            Ok(n) => n,
359            Err(_) => return Some(first_chunk),
360        };
361        if suffix == 0 {
362            return None;
363        }
364        (len.saturating_sub(suffix), last)
365    } else {
366        let start: u64 = match from.parse() {
367            Ok(n) => n,
368            Err(_) => return Some(first_chunk),
369        };
370        let end = if to.is_empty() {
371            last
372        } else {
373            match to.parse::<u64>() {
374                Ok(n) => n.min(last),
375                Err(_) => return Some(first_chunk),
376            }
377        };
378        (start, end)
379    };
380
381    if start > last || end < start {
382        return None;
383    }
384
385    Some(Span {
386        start,
387        end: end.min(start + CHUNK_LEN - 1),
388    })
389}
390
391/// Guess a content type.
392///
393/// **Magic bytes win over the extension.** Downloading at `original` quality
394/// asks Jellyfin for a direct static copy, which returns the *source file's*
395/// bytes under a `.mp4` name whatever the real container is — a downloaded film
396/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
397/// there labels it `video/mp4` and the player is handed a container that is not
398/// what the header claims. The extension is only a fallback for a file whose
399/// bytes are unrecognised, and for the extension-less files the offline queue
400/// writes under an item id.
401///
402/// TRACES: UR-071 | DR-137 | UT-127
403fn content_type(path: &Path, head: &[u8]) -> &'static str {
404    // `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
405    if head.len() > 11 && &head[4..8] == b"ftyp" {
406        return "video/mp4";
407    }
408    if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
409        return "video/x-msvideo";
410    }
411    if head.starts_with(b"\x1aE\xdf\xa3") {
412        return "video/x-matroska";
413    }
414    if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
415        return "audio/mpeg";
416    }
417    if head.starts_with(b"OggS") {
418        return "audio/ogg";
419    }
420    if head.starts_with(b"fLaC") {
421        return "audio/flac";
422    }
423
424    match path
425        .extension()
426        .and_then(|e| e.to_str())
427        .map(|e| e.to_ascii_lowercase())
428        .as_deref()
429    {
430        Some("mp4" | "m4v" | "mov") => return "video/mp4",
431        Some("mkv") => return "video/x-matroska",
432        Some("webm") => return "video/webm",
433        Some("mp3") => return "audio/mpeg",
434        Some("m4a" | "aac") => return "audio/mp4",
435        Some("flac") => return "audio/flac",
436        Some("ogg" | "opus") => return "audio/ogg",
437        Some("wav") => return "audio/wav",
438        Some("avi") => return "video/x-msvideo",
439        _ => {}
440    }
441
442    "application/octet-stream"
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// An empty file must not be answered with a span that promises a byte.
450    ///
451    /// `Span::len()` is `end + 1 - start`, so the `Span { start: 0, end: 0 }`
452    /// that a zero-length file used to produce reported a length of **one**.
453    /// The response then declared `Content-Length: 1` and streamed nothing,
454    /// which Chromium's media loader waits on forever — reaching the user as a
455    /// downloaded item that never starts. A zero-byte file has no satisfiable
456    /// range, so 416 is the honest answer.
457    ///
458    /// TRACES: UR-071 | DR-137 | UT-127
459    #[test]
460    fn test_span_for_an_empty_file_is_unsatisfiable() {
461        assert!(
462            span_for(None, 0).is_none(),
463            "a zero-length file has no byte to serve"
464        );
465        assert!(span_for(Some("bytes=0-"), 0).is_none());
466        assert!(span_for(Some("bytes=0-100"), 0).is_none());
467    }
468
469    /// Whatever a span says, its length must match the bytes that follow it.
470    ///
471    /// TRACES: UR-071 | DR-137 | UT-127
472    #[test]
473    fn test_span_len_never_exceeds_the_file() {
474        for len in [0u64, 1, 2, 4095, CHUNK_LEN, CHUNK_LEN + 1] {
475            if let Some(span) = span_for(None, len) {
476                assert!(
477                    span.len() <= len,
478                    "span for a {len}-byte file claims {} bytes",
479                    span.len()
480                );
481            }
482        }
483    }
484
485    /// The whole point: a request with no `Range` must still come back bounded.
486    /// That is the case Tauri's asset protocol answers with the entire file —
487    /// the read Chromium abandoned after 31s.
488    ///
489    /// TRACES: UR-071 | DR-137 | UT-127
490    #[test]
491    fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
492        let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
493        let span = span_for(None, huge).unwrap();
494        assert_eq!(span.start, 0);
495        assert_eq!(span.len(), CHUNK_LEN);
496    }
497
498    /// TRACES: UR-071 | DR-137 | UT-127
499    #[test]
500    fn no_response_ever_exceeds_one_chunk() {
501        let len = 8 * 1024 * 1024 * 1024;
502        for h in [
503            "bytes=0-",
504            "bytes=0-99999999999",
505            "bytes=1024-",
506            "bytes=-99999999",
507        ] {
508            let span = span_for(Some(h), len).unwrap();
509            assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
510        }
511    }
512
513    /// TRACES: UR-071 | DR-137 | UT-127
514    #[test]
515    fn ranges_are_honoured() {
516        let len = 1000u64;
517        assert_eq!(
518            span_for(Some("bytes=100-199"), len).unwrap(),
519            Span {
520                start: 100,
521                end: 199
522            }
523        );
524        // Open-ended runs to the end of a small file.
525        assert_eq!(
526            span_for(Some("bytes=900-"), len).unwrap(),
527            Span {
528                start: 900,
529                end: 999
530            }
531        );
532        // Suffix form.
533        assert_eq!(
534            span_for(Some("bytes=-100"), len).unwrap(),
535            Span {
536                start: 900,
537                end: 999
538            }
539        );
540        // Past the end is unsatisfiable, not a clamp — a clamp would make a
541        // seek past the end silently replay earlier bytes.
542        assert!(span_for(Some("bytes=1000-"), len).is_none());
543    }
544
545    /// A malformed header must not fail the request: playing from the start is
546    /// strictly better than refusing to open the file.
547    ///
548    /// TRACES: UR-071 | DR-137 | UT-127
549    #[test]
550    fn a_malformed_range_falls_back_to_the_first_chunk() {
551        assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
552        assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
553    }
554
555    /// TRACES: UR-071 | DR-137 | UT-127
556    #[test]
557    fn reads_are_confined_to_the_app_data_directory() {
558        let root = Path::new("/data/user/0/app");
559
560        assert_eq!(
561            resolve_path("/data/user/0/app/videos/f.mp4", root),
562            Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
563        );
564        // Percent-encoded, as the URL builder produces.
565        assert_eq!(
566            resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
567            Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
568        );
569        // Traversal out of the root, and an unrelated absolute path, are refused.
570        assert_eq!(
571            resolve_path("/data/user/0/app/../../../etc/passwd", root),
572            Resolved::Forbidden
573        );
574        assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
575    }
576
577    /// Loopback is shared between apps on Android, so the token is the only
578    /// thing stopping another installed app from reading downloaded media.
579    ///
580    /// TRACES: UR-071 | DR-137 | UT-127
581    #[test]
582    fn a_request_without_the_right_token_is_refused() {
583        let root = Path::new("/data/user/0/app");
584        let good = "0123456789abcdef0123456789abcdef";
585
586        assert_eq!(
587            route(
588                &format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
589                root,
590                good
591            ),
592            Ok(PathBuf::from("/data/user/0/app/f.mp4"))
593        );
594        assert_eq!(
595            route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
596            Err(403)
597        );
598        // No token segment at all.
599        assert_eq!(route("/f.mp4", root, good), Err(404));
600        // Right token, but a path outside the root is still refused.
601        assert_eq!(
602            route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
603            Err(403)
604        );
605    }
606
607    /// TRACES: UR-071 | DR-137 | UT-127
608    #[test]
609    fn content_type_uses_the_extension_then_the_magic_bytes() {
610        assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
611        assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
612        // A `.mp4` that is really an AVI: downloading at `original` quality
613        // copies the source bytes under an mp4 name, so the extension lies and
614        // the magic bytes must win.
615        let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
616        assert_eq!(
617            content_type(Path::new("/a/film.mp4"), avi_head),
618            "video/x-msvideo"
619        );
620        // Extension-less, as the offline queue writes them: sniff instead.
621        let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
622        assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
623        assert_eq!(
624            content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
625            "audio/mpeg"
626        );
627    }
628}