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    if len == 0 {
330        return Some(Span { start: 0, end: 0 });
331    }
332    let last = len - 1;
333    let first_chunk = Span {
334        start: 0,
335        end: (CHUNK_LEN - 1).min(last),
336    };
337
338    let Some(raw) = range else {
339        return Some(first_chunk);
340    };
341    let Some(spec) = raw.trim().strip_prefix("bytes=") else {
342        return Some(first_chunk);
343    };
344    // Only the first range of a multi-range request is honoured; media clients
345    // ask for one, and a single 206 is a valid answer either way.
346    let spec = spec.split(',').next().unwrap_or("").trim();
347    let Some((from, to)) = spec.split_once('-') else {
348        return Some(first_chunk);
349    };
350
351    let (start, end) = if from.is_empty() {
352        // Suffix form: `-500` is the final 500 bytes.
353        let suffix: u64 = match to.parse() {
354            Ok(n) => n,
355            Err(_) => return Some(first_chunk),
356        };
357        if suffix == 0 {
358            return None;
359        }
360        (len.saturating_sub(suffix), last)
361    } else {
362        let start: u64 = match from.parse() {
363            Ok(n) => n,
364            Err(_) => return Some(first_chunk),
365        };
366        let end = if to.is_empty() {
367            last
368        } else {
369            match to.parse::<u64>() {
370                Ok(n) => n.min(last),
371                Err(_) => return Some(first_chunk),
372            }
373        };
374        (start, end)
375    };
376
377    if start > last || end < start {
378        return None;
379    }
380
381    Some(Span {
382        start,
383        end: end.min(start + CHUNK_LEN - 1),
384    })
385}
386
387/// Guess a content type.
388///
389/// **Magic bytes win over the extension.** Downloading at `original` quality
390/// asks Jellyfin for a direct static copy, which returns the *source file's*
391/// bytes under a `.mp4` name whatever the real container is — a downloaded film
392/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
393/// there labels it `video/mp4` and the player is handed a container that is not
394/// what the header claims. The extension is only a fallback for a file whose
395/// bytes are unrecognised, and for the extension-less files the offline queue
396/// writes under an item id.
397///
398/// TRACES: UR-071 | DR-137 | UT-127
399fn content_type(path: &Path, head: &[u8]) -> &'static str {
400    // `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
401    if head.len() > 11 && &head[4..8] == b"ftyp" {
402        return "video/mp4";
403    }
404    if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
405        return "video/x-msvideo";
406    }
407    if head.starts_with(b"\x1aE\xdf\xa3") {
408        return "video/x-matroska";
409    }
410    if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
411        return "audio/mpeg";
412    }
413    if head.starts_with(b"OggS") {
414        return "audio/ogg";
415    }
416    if head.starts_with(b"fLaC") {
417        return "audio/flac";
418    }
419
420    match path
421        .extension()
422        .and_then(|e| e.to_str())
423        .map(|e| e.to_ascii_lowercase())
424        .as_deref()
425    {
426        Some("mp4" | "m4v" | "mov") => return "video/mp4",
427        Some("mkv") => return "video/x-matroska",
428        Some("webm") => return "video/webm",
429        Some("mp3") => return "audio/mpeg",
430        Some("m4a" | "aac") => return "audio/mp4",
431        Some("flac") => return "audio/flac",
432        Some("ogg" | "opus") => return "audio/ogg",
433        Some("wav") => return "audio/wav",
434        Some("avi") => return "video/x-msvideo",
435        _ => {}
436    }
437
438    "application/octet-stream"
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    /// The whole point: a request with no `Range` must still come back bounded.
446    /// That is the case Tauri's asset protocol answers with the entire file —
447    /// the read Chromium abandoned after 31s.
448    ///
449    /// TRACES: UR-071 | DR-137 | UT-127
450    #[test]
451    fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
452        let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
453        let span = span_for(None, huge).unwrap();
454        assert_eq!(span.start, 0);
455        assert_eq!(span.len(), CHUNK_LEN);
456    }
457
458    /// TRACES: UR-071 | DR-137 | UT-127
459    #[test]
460    fn no_response_ever_exceeds_one_chunk() {
461        let len = 8 * 1024 * 1024 * 1024;
462        for h in [
463            "bytes=0-",
464            "bytes=0-99999999999",
465            "bytes=1024-",
466            "bytes=-99999999",
467        ] {
468            let span = span_for(Some(h), len).unwrap();
469            assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
470        }
471    }
472
473    /// TRACES: UR-071 | DR-137 | UT-127
474    #[test]
475    fn ranges_are_honoured() {
476        let len = 1000u64;
477        assert_eq!(
478            span_for(Some("bytes=100-199"), len).unwrap(),
479            Span {
480                start: 100,
481                end: 199
482            }
483        );
484        // Open-ended runs to the end of a small file.
485        assert_eq!(
486            span_for(Some("bytes=900-"), len).unwrap(),
487            Span {
488                start: 900,
489                end: 999
490            }
491        );
492        // Suffix form.
493        assert_eq!(
494            span_for(Some("bytes=-100"), len).unwrap(),
495            Span {
496                start: 900,
497                end: 999
498            }
499        );
500        // Past the end is unsatisfiable, not a clamp — a clamp would make a
501        // seek past the end silently replay earlier bytes.
502        assert!(span_for(Some("bytes=1000-"), len).is_none());
503    }
504
505    /// A malformed header must not fail the request: playing from the start is
506    /// strictly better than refusing to open the file.
507    ///
508    /// TRACES: UR-071 | DR-137 | UT-127
509    #[test]
510    fn a_malformed_range_falls_back_to_the_first_chunk() {
511        assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
512        assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
513    }
514
515    /// TRACES: UR-071 | DR-137 | UT-127
516    #[test]
517    fn reads_are_confined_to_the_app_data_directory() {
518        let root = Path::new("/data/user/0/app");
519
520        assert_eq!(
521            resolve_path("/data/user/0/app/videos/f.mp4", root),
522            Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
523        );
524        // Percent-encoded, as the URL builder produces.
525        assert_eq!(
526            resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
527            Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
528        );
529        // Traversal out of the root, and an unrelated absolute path, are refused.
530        assert_eq!(
531            resolve_path("/data/user/0/app/../../../etc/passwd", root),
532            Resolved::Forbidden
533        );
534        assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
535    }
536
537    /// Loopback is shared between apps on Android, so the token is the only
538    /// thing stopping another installed app from reading downloaded media.
539    ///
540    /// TRACES: UR-071 | DR-137 | UT-127
541    #[test]
542    fn a_request_without_the_right_token_is_refused() {
543        let root = Path::new("/data/user/0/app");
544        let good = "0123456789abcdef0123456789abcdef";
545
546        assert_eq!(
547            route(
548                &format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
549                root,
550                good
551            ),
552            Ok(PathBuf::from("/data/user/0/app/f.mp4"))
553        );
554        assert_eq!(
555            route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
556            Err(403)
557        );
558        // No token segment at all.
559        assert_eq!(route("/f.mp4", root, good), Err(404));
560        // Right token, but a path outside the root is still refused.
561        assert_eq!(
562            route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
563            Err(403)
564        );
565    }
566
567    /// TRACES: UR-071 | DR-137 | UT-127
568    #[test]
569    fn content_type_uses_the_extension_then_the_magic_bytes() {
570        assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
571        assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
572        // A `.mp4` that is really an AVI: downloading at `original` quality
573        // copies the source bytes under an mp4 name, so the extension lies and
574        // the magic bytes must win.
575        let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
576        assert_eq!(
577            content_type(Path::new("/a/film.mp4"), avi_head),
578            "video/x-msvideo"
579        );
580        // Extension-less, as the offline queue writes them: sniff instead.
581        let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
582        assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
583        assert_eq!(
584            content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
585            "audio/mpeg"
586        );
587    }
588}