tonhowtf/omniget · error

Server returned HTML instead of media — the link may have ex

Error message

Server returned HTML instead of media — the link may have expired or needs a login

What it means

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.

Solutions

  1. Refresh the media URL — expired signed links are the most common cause; fetch a fresh link before calling download.
  2. 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.
  3. 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).
  4. Check whether a captive portal/proxy is intercepting traffic (test the URL with curl from the same network).
  5. 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.

Example fix

// before
let url = "https://cdn.example.com/media/abc?token=OLD"; // token expired
let file = downloader.download(&url, &out).await?;
// after
let url = fetch_fresh_media_url(); // renew signed token / re-auth session first
let mut headers = HeaderMap::new();
headers.insert("authorization", format!("Bearer {}", token).parse()?);
let file = downloader.download_direct_with_headers(&url, &out, &headers).await?;
Defensive patterns

Strategy: validation

Validate before calling

// Rust: peek the first bytes of the URL yourself before downloading
async fn looks_like_media(client: &reqwest::Client, url: &str) -> bool {
    use tokio::io::AsyncReadExt;
    let mut r = match client.get(url).send().await { Ok(r) => r, Err(_) => return false };
    let mut head = [0u8; 16];
    // read from the body stream via bytes_stream or a small range request
    let _ = r.content_length();
    true // then sniff head[] with the same magic-number logic as sniff_media_format
}

Try / catch

match downloader.download(&url, &out).await {
    Err(e) if e.to_string().contains("Server returned HTML") => {
        // link expired or auth needed: refresh URL / add auth headers, then retry once
        Err(anyhow!("link no longer serves media, refresh URL or login: {e}"))
    }
    other => other,
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of tonhowtf/omniget@8600b91f42 (2026-09-12). Data as JSON: /api/errors/94e977b4b341b971. Report an issue: GitHub.

Appendix: source

Thrown at src-tauri/omniget-core/src/core/direct_downloader.rs:440

        .host_str()
        .map(|h| h.to_ascii_lowercase())
}

/// Last gate before the `.part` becomes the real file.
///
/// Only an HTML page is rejected. Sniffing the *positive* case would mean
/// failing every container we cannot recognise — subtitles, images, archives,
/// PDFs all go through this same path — so the rule is the conservative one:
/// no known media signature **and** it opens like a document. A CDN error page
/// served as `200 OK` is exactly that; a `.srt` is not.
///
/// The content-type check in `download_single_stream` only sees the header,
/// which a misconfigured CDN may set to `application/octet-stream` while the
/// body is still an error page. This reads the bytes that actually landed.
fn reject_html_masquerading_as_media(part_path: &Path) -> anyhow::Result<()> {
    let head = read_head(part_path, SNIFF_BYTES)?;
    if sniff_media_format(&head).is_none() && looks_like_html(&head) {
        return Err(anyhow!(
            "Server returned HTML instead of media — the link may have expired or needs a login"
        ));
    }
    Ok(())
}

/// First `max` bytes of a file, or fewer if the file is shorter.
fn read_head(path: &Path, max: usize) -> anyhow::Result<Vec<u8>> {
    use std::io::Read;
    let mut file = std::fs::File::open(path)?;
    let mut buf = vec![0u8; max];
    let mut filled = 0usize;
    while filled < max {
        match file.read(&mut buf[filled..])? {
            0 => break,
            n => filled += n,
        }
    }

View on GitHub (pinned to 8600b91f42)