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}