{"record":{"id":"94e977b4b341b971","repo":"tonhowtf/omniget","slug":"server-returned-html-instead-of-media-the-link-may-have","errorCode":null,"errorMessage":"Server returned HTML instead of media — the link may have expired or needs a login","messagePattern":"Server returned HTML instead of media — the link may have expired or needs a login","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src-tauri/omniget-core/src/core/direct_downloader.rs","lineNumber":440,"sourceCode":"        .host_str()\n        .map(|h| h.to_ascii_lowercase())\n}\n\n/// Last gate before the `.part` becomes the real file.\n///\n/// Only an HTML page is rejected. Sniffing the *positive* case would mean\n/// failing every container we cannot recognise — subtitles, images, archives,\n/// PDFs all go through this same path — so the rule is the conservative one:\n/// no known media signature **and** it opens like a document. A CDN error page\n/// served as `200 OK` is exactly that; a `.srt` is not.\n///\n/// The content-type check in `download_single_stream` only sees the header,\n/// which a misconfigured CDN may set to `application/octet-stream` while the\n/// body is still an error page. This reads the bytes that actually landed.\nfn reject_html_masquerading_as_media(part_path: &Path) -> anyhow::Result<()> {\n    let head = read_head(part_path, SNIFF_BYTES)?;\n    if sniff_media_format(&head).is_none() && looks_like_html(&head) {\n        return Err(anyhow!(\n            \"Server returned HTML instead of media — the link may have expired or needs a login\"\n        ));\n    }\n    Ok(())\n}\n\n/// First `max` bytes of a file, or fewer if the file is shorter.\nfn read_head(path: &Path, max: usize) -> anyhow::Result<Vec<u8>> {\n    use std::io::Read;\n    let mut file = std::fs::File::open(path)?;\n    let mut buf = vec![0u8; max];\n    let mut filled = 0usize;\n    while filled < max {\n        match file.read(&mut buf[filled..])? {\n            0 => break,\n            n => filled += n,\n        }\n    }","sourceCodeStart":422,"sourceCodeEnd":458,"githubUrl":"https://github.com/tonhowtf/omniget/blob/8600b91f4246848bac346874daa9e61c1fc5677a/src-tauri/omniget-core/src/core/direct_downloader.rs#L422-L458","documentation":"reject_html_masquerading_as_media sniffs the first SNIFF_BYTES of the downloaded .part file: if the bytes don't match any known media signature but do look like HTML, the 'download' is actually an error page (login page, expired-link notice, CDN block page) that a misconfigured server delivered with an octet-stream content-type — defeating the header-only content-type check. The function errors so the HTML file is never renamed into place as fake media.","triggerScenarios":"download_attempt completed a transfer whose body starts with '<!DOCTYPE html'/'<html' instead of a media magic number — typically when download/download_direct is pointed at a link whose session/token expired, requires authentication, or is served a captive-portal/anti-bot page by the CDN.","commonSituations":"Signed/media URLs past their expiry; links behind a login that the app isn't authenticated to; CDN WAF or rate-limit pages served as application/octet-stream; hotspot/captive portals intercepting the request; cookies/session tokens not forwarded in the custom headers.","solutions":["Refresh the media URL — expired signed links are the most common cause; fetch a fresh link before calling download.","Pass authentication (cookies, Authorization/Bearer token, referer) via the headers variant (download_direct_with_headers) so the server doesn't answer with a login page.","Open the .part/failed output bytes in a browser or inspect the head bytes to read the HTML error message the server returned — it usually states why (expired, login, blocked).","Check whether a captive portal/proxy is intercepting traffic (test the URL with curl from the same network).","If the content is legitimately non-media but not HTML-sniffable and being falsely flagged, verify the actual file format against sniff_media_format's supported signatures."],"exampleFix":"// before\nlet url = \"https://cdn.example.com/media/abc?token=OLD\"; // token expired\nlet file = downloader.download(&url, &out).await?;\n// after\nlet url = fetch_fresh_media_url(); // renew signed token / re-auth session first\nlet mut headers = HeaderMap::new();\nheaders.insert(\"authorization\", format!(\"Bearer {}\", token).parse()?);\nlet file = downloader.download_direct_with_headers(&url, &out, &headers).await?;","handlingStrategy":"validation","validationCode":"// Rust: peek the first bytes of the URL yourself before downloading\nasync fn looks_like_media(client: &reqwest::Client, url: &str) -> bool {\n    use tokio::io::AsyncReadExt;\n    let mut r = match client.get(url).send().await { Ok(r) => r, Err(_) => return false };\n    let mut head = [0u8; 16];\n    // read from the body stream via bytes_stream or a small range request\n    let _ = r.content_length();\n    true // then sniff head[] with the same magic-number logic as sniff_media_format\n}","typeGuard":null,"tryCatchPattern":"match downloader.download(&url, &out).await {\n    Err(e) if e.to_string().contains(\"Server returned HTML\") => {\n        // link expired or auth needed: refresh URL / add auth headers, then retry once\n        Err(anyhow!(\"link no longer serves media, refresh URL or login: {e}\"))\n    }\n    other => other,\n}","preventionTips":["Refresh signed/expiring URLs immediately before each download","Always pass auth cookies/tokens via download_direct_with_headers for gated content","Inspect the failed output's head bytes — the HTML body names the real reason","Verify the network isn't a captive portal when many links suddenly 'return HTML'"],"tags":["download","http","content-sniffing","authentication","rust"],"backgroundTag":"unexpected-response-shape","analyzedSha":"8600b91f4246848bac346874daa9e61c1fc5677a","analyzedAt":"2026-09-12T14:29:19.317Z","contentChangedAt":"2026-09-12T14:29:19.317Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}