From 588df181635818f7d6a0f421159dac93d54a7336 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Thu, 30 Jul 2026 08:30:33 -0300
Subject: [PATCH 1/5] docs(download): document MediaDownloader, MediaRoute, and
the MediaConnection breaking change
whatsapp-rust#1194 replaced wacore::download::MediaConnection with MediaRoute
(auth is now optional) and added MediaDownloader, a session-less downloader
for callers with no connected Client. Document the new types and API on
api/download.mdx, and add a guide section on guides/media-handling.mdx
showing how to download persisted media references after disconnecting.
---
api/download.mdx | 156 ++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 154 insertions(+), 2 deletions(-)
diff --git a/api/download.mdx b/api/download.mdx
index 90eef7cd..131f39e7 100644
--- a/api/download.mdx
+++ b/api/download.mdx
@@ -11,6 +11,10 @@ Download and decrypt media from a message.
Only use `download` when you need the plaintext bytes (processing, transcoding, re-upload). To forward existing media unchanged, reuse the original message's CDN fields directly — no download required. See [media forwarding via CDN reuse](/guides/media-handling#forwarding-media-via-cdn-reuse).
+
+`download` needs a connected `Client` because it asks the server for CDN hosts and an auth token. If you've persisted a message's CDN fields and want to download after the session has disconnected, use [`MediaDownloader`](#mediadownloader) instead — it needs no `Client` at all.
+
+
```rust
pub async fn download(
&self,
@@ -74,6 +78,10 @@ For streaming downloads (`download_to_writer`), the writer is seeked back to pos
This classification reads `status_code` off a successfully completed HTTP exchange — it never sees a status that a custom `HttpClient` implementation turned into an `Err`. If you provide your own `HttpClient`, see the [non-2xx-as-`Ok` contract](/api/http-client#httpclient-trait) it must follow for this retry logic to work at all.
+
+`static_url` media (newsletter/channel content fetched from a fixed CDN URL) skips the media-conn round trip entirely — `prepare_requests` only asks the server for hosts when the downloadable has no `static_url`.
+
+
---
## download_to_writer
@@ -227,7 +235,151 @@ pub fn encrypted(
) -> Self
```
-`DownloadParams` implements [`Downloadable`](#downloadable-trait), so it works with `download`, `download_to_writer`, `download_from_params`, and `download_from_params_to_writer`.
+`DownloadParams` implements [`Downloadable`](#downloadable-trait), so it works with `download`, `download_to_writer`, `download_from_params`, `download_from_params_to_writer`, and [`MediaDownloader`](#mediadownloader).
+
+---
+
+## MediaDownloader
+
+Downloads and decrypts media from the CDN with **no connected `Client`**. Everything a download needs beyond the CDN hosts already lives in the [`Downloadable`](#downloadable-trait) itself — `MediaDownloader` takes the hosts (and, optionally, an auth token) up front instead of asking a live session for them, so a persisted reference (for example a stored [`DownloadParams`](#downloadparams)) stays downloadable after the client has disconnected.
+
+A live `Client` keeps asking the server for its hosts on every download; `MediaDownloader` is the path for callers that have no session to ask with — a background worker, a queue consumer, or a CLI tool operating on data a paired client saved earlier.
+
+```rust
+pub struct MediaDownloader { /* private fields */ }
+
+impl MediaDownloader {
+ pub fn new(
+ http_client: Arc,
+ runtime: Arc,
+ route: MediaRoute,
+ ) -> Self;
+
+ pub fn with_default_hosts(
+ http_client: Arc,
+ runtime: Arc,
+ ) -> Self;
+
+ pub fn route(&self) -> &MediaRoute;
+
+ pub async fn download(
+ &self,
+ downloadable: &dyn Downloadable,
+ ) -> Result, MediaDownloadError>;
+
+ pub async fn download_to_writer(
+ &self,
+ downloadable: &dyn Downloadable,
+ writer: W,
+ ) -> Result;
+}
+```
+
+
+ The same `HttpClient` implementation you pass to `ClientBuilder`. See [HTTP Client Trait](/api/http-client).
+
+
+
+ Runtime abstraction used to run blocking decrypt/streaming work.
+
+
+
+ The CDN hosts to try, in order, and an optional auth token. See [`MediaRoute`](#mediaroute-and-mediahost) below.
+
+
+`download` and `download_to_writer` mirror `Client::download` and `Client::download_to_writer` — same streaming/buffered branching, same retry-on-another-host behavior across the route's hosts, same writer contract for the streaming variant. The one difference is the auth-refresh budget: a `Client` gets one retry with freshly fetched credentials after an auth or not-found error; `MediaDownloader` has no session to refresh credentials from, so that class of error is terminal on the first attempt.
+
+### Example: download after the session is gone
+
+```rust
+use std::sync::Arc;
+use wacore::download::MediaType;
+use whatsapp_rust::download::{DownloadParams, MediaDownloader};
+
+// Needs only an HttpClient + Runtime — no paired Client required.
+let downloader = MediaDownloader::with_default_hosts(http_client.clone(), runtime.clone());
+
+// `params` came from something you persisted earlier, e.g. via `PersistenceManager`
+// or your own store, while the client was still connected.
+let params = DownloadParams::encrypted(
+ "/v/t62.7118-24/12345_67890",
+ &media_key,
+ &file_sha256,
+ &file_enc_sha256,
+ file_length,
+ MediaType::Image,
+);
+
+let bytes = downloader.download(¶ms).await?;
+```
+
+
+`with_default_hosts` routes through [`DEFAULT_MEDIA_HOSTS`](#mediaroute-and-mediahost) with no auth token, which matches how WhatsApp Web's own download URL builder works — auth is an upload concern, not a download one. Pass an explicit `MediaRoute` to `new` if you need specific hosts or want to carry a token you obtained another way.
+
+
+### MediaDownloadError
+
+```rust
+#[non_exhaustive]
+pub enum MediaDownloadError {
+ ReferenceRejected(anyhow::Error),
+ HostsUnreachable(anyhow::Error),
+ NoHosts,
+ Other(anyhow::Error),
+}
+```
+
+| Variant | Meaning |
+|---------|---------|
+| `ReferenceRejected` | The CDN rejected the reference itself (401/403/404/410). The `direct_path` or its token is expired or revoked, and — with no session to refresh from — another host cannot serve it either. Terminal: retrying without new credentials won't help. |
+| `HostsUnreachable` | Every host in the route failed for a reason other than the reference (transport failure, unexpected status, a body that failed to decrypt/verify). |
+| `NoHosts` | The `MediaRoute` named no hosts, so nothing was ever contacted. |
+| `Other` | No host was contacted and none could be — the `Downloadable` is missing fields needed to build a URL at all. Fix the metadata, not the network. |
+
+`Client::download` and `Client::download_to_writer` keep returning `anyhow::Error`: they already try a credential refresh before the error would escape, so the extra classification has nothing left to add there.
+
+### MediaRoute and MediaHost
+
+`MediaRoute` replaces the old `MediaConnection` type — it is the low-level input to [`DownloadUtils::prepare_download_requests`](#key-methods) and to `MediaDownloader`. Where `MediaConnection` required an `auth: String`, `MediaRoute` makes it `auth: Option`, because the CDN gates a download on the signed `direct_path` and its hash token, not on a session credential.
+
+```rust
+pub struct MediaHost {
+ pub hostname: String,
+}
+
+impl MediaHost {
+ pub fn new(hostname: impl Into) -> Self;
+}
+
+pub struct MediaRoute {
+ pub hosts: Vec,
+ pub auth: Option,
+}
+
+impl MediaRoute {
+ pub fn authenticated(hosts: Vec, auth: String) -> Self;
+ pub fn unauthenticated(hosts: Vec) -> Self;
+ pub fn without_auth(self) -> Self;
+ pub fn default_hosts() -> Self;
+}
+
+pub const DEFAULT_MEDIA_HOSTS: [&str; 2] = ["mmg.whatsapp.net", "mmg-fallback.whatsapp.net"];
+```
+
+| Constructor | Use when |
+|-------------|----------|
+| `MediaRoute::authenticated(hosts, auth)` | You have both server-provided hosts and a media auth token (what a connected `Client` builds from its `MediaConn`). |
+| `MediaRoute::unauthenticated(hosts)` | You have hosts but no token — the common case for `MediaDownloader`. |
+| `route.without_auth()` | You have an authenticated route but want to keep using it past the token's lifetime; drops the token rather than sending a stale one. |
+| `MediaRoute::default_hosts()` | You have neither — routes through [`DEFAULT_MEDIA_HOSTS`](#mediaroute-and-mediahost), the CDN hosts WhatsApp Web itself carries in its binary-protocol token dictionary. This is only a convenience: a live session should still take its hosts from the server, which is what lets a test harness point downloads at itself. |
+
+
+`MediaRoute`'s `Debug` implementation is hand-written to print `auth: Some("")` / `auth: None` instead of the token itself, so a stray `{:?}` or tracing field can't leak a live credential into a log.
+
+
+
+**Breaking change (as of PR #1194):** `wacore::download::MediaConnection` was replaced by `MediaRoute`. If you called `DownloadUtils::prepare_download_requests` directly with a `MediaConnection`, migrate with `MediaRoute::from(&media_conn)` (a `From<&MediaConn>` impl is provided) or by constructing a `MediaRoute` via `MediaRoute::authenticated(hosts, auth)`. `Client::download` and friends are unaffected — this only touches callers using the lower-level `DownloadUtils` / `MediaConnection` types directly.
+
---
@@ -484,5 +636,5 @@ use whatsapp_rust::download::DownloadUtils;
| `decrypt_stream_to_writer(reader, media_key, media_type, writer)` | Streaming decryption directly into a writer with constant memory usage |
| `validate_plaintext_sha256(data, expected_sha256)` | Validates SHA-256 hash of plaintext media (in-memory) |
| `copy_and_validate_plaintext_to_writer(reader, expected_sha256, writer)` | Streams plaintext media to a writer while validating SHA-256 hash |
-| `prepare_download_requests(downloadable, media_conn)` | Builds CDN request URLs with host failover |
+| `prepare_download_requests(downloadable, route)` | Builds CDN request URLs with host failover. Takes a [`MediaRoute`](#mediaroute-and-mediahost) (previously `MediaConnection`) — hosts plus an optional auth token |
| `get_media_keys(media_key, app_info)` | Derives IV, cipher key, and MAC key via HKDF |
From 97840b4ec39d74c46050da35c203477d25f8d220 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Thu, 30 Jul 2026 08:32:05 -0300
Subject: [PATCH 2/5] docs(media-handling): add downloading without an active
session
Cross-reference the new MediaDownloader from whatsapp-rust#1194 in the
media-handling guide, for readers who want to download persisted CDN
references after the client has disconnected.
---
guides/media-handling.mdx | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/guides/media-handling.mdx b/guides/media-handling.mdx
index 89be1c1a..7190115b 100644
--- a/guides/media-handling.mdx
+++ b/guides/media-handling.mdx
@@ -122,6 +122,34 @@ let file = client.download_from_params_to_writer(¶ms, file).await?;
See [Download API reference](/api/download#download_from_params_to_writer) for streaming from raw parameters.
+### Downloading without an active session
+
+`client.download()` and its variants need a connected `Client` — they ask the server for CDN hosts and an auth token. If you've persisted a message's CDN fields (for example as a [`DownloadParams`](/api/download#downloadparams)) and want to fetch that media later, after the session that received it has disconnected, use [`MediaDownloader`](/api/download#mediadownloader) instead. It needs only an `HttpClient` and a `Runtime` — no `Client`, no pairing:
+
+```rust
+use std::sync::Arc;
+use wacore::download::MediaType;
+use whatsapp_rust::download::{DownloadParams, MediaDownloader};
+
+// Same HttpClient/Runtime you'd pass to ClientBuilder — no session required.
+let downloader = MediaDownloader::with_default_hosts(http_client.clone(), runtime.clone());
+
+let params = DownloadParams::encrypted(
+ direct_path,
+ &media_key,
+ &file_sha256,
+ &file_enc_sha256,
+ file_length,
+ MediaType::Image,
+);
+
+let data = downloader.download(¶ms).await?;
+```
+
+
+`MediaDownloader` mirrors `client.download()`'s streaming/buffered behavior and host failover, but it can't refresh credentials — there's no session to get fresh ones from. A rejected reference (expired or revoked) fails immediately instead of retrying, and the error is a typed [`MediaDownloadError`](/api/download#mediadownloaderror) rather than `anyhow::Error`.
+
+
## Thumbnails
Thumbnails are **not generated by the library** — you provide them as raw JPEG bytes when constructing media messages. WhatsApp clients use these thumbnails as low-resolution previews while the full media is downloading.
@@ -639,7 +667,7 @@ Both `download` and `upload` methods automatically retry when WhatsApp's CDN ret
**Other errors (e.g., 500):** The client tries the next available CDN host without refreshing credentials. Media connections include multiple hosts sorted by priority (primary hosts first, then fallback hosts), and the client iterates through them sequentially.
-This retry behavior is fully transparent — no changes are needed in your code. Both `download` and `upload` handle auth refresh, URL re-derivation, and host failover automatically, with a maximum of one credential refresh per operation.
+This retry behavior is fully transparent — no changes are needed in your code. Both `download` and `upload` handle auth refresh, URL re-derivation, and host failover automatically, with a maximum of one credential refresh per operation. [`MediaDownloader`](/api/download#mediadownloader) follows the same host-failover behavior but skips the credential refresh, since it has no session to refresh from — see [Downloading without an active session](#downloading-without-an-active-session).
For streaming downloads (`download_to_writer`), the writer is automatically seeked back to position 0 before each retry so partial writes don't corrupt the output.
From 53cba612937d87fb84e59d58e6ea9b781148d4ce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Thu, 30 Jul 2026 08:40:38 -0300
Subject: [PATCH 3/5] docs(download): address review feedback on
MediaDownloader docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Qualify "download needs a connected Client" — static_url media is the
documented exception, and the top Tip contradicted the note below it.
- Stop implying the client re-fetches CDN hosts on every download; it's
cached and refreshed automatically per the existing retry docs.
- Don't attribute DownloadParams persistence to PersistenceManager, which
only covers device/session state.
- Distinguish a stale MediaRoute auth token (401/403, recoverable via
without_auth()) from a genuinely expired reference (404/410, terminal)
in the MediaDownloadError table, and note the #[non_exhaustive] wildcard
requirement.
- Fix the MediaConnection migration guidance: MediaRoute::from(&media_conn)
converts mediaconn::MediaConn (the server response type), not the
removed MediaConnection struct — point removed-type callers at
MediaRoute::authenticated(hosts, auth) instead.
Addresses review comments from coderabbitai and chatgpt-codex-connector
on PR #469.
---
api/download.mdx | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/api/download.mdx b/api/download.mdx
index 131f39e7..1d4cd542 100644
--- a/api/download.mdx
+++ b/api/download.mdx
@@ -12,7 +12,7 @@ Only use `download` when you need the plaintext bytes (processing, transcoding,
-`download` needs a connected `Client` because it asks the server for CDN hosts and an auth token. If you've persisted a message's CDN fields and want to download after the session has disconnected, use [`MediaDownloader`](#mediadownloader) instead — it needs no `Client` at all.
+`download` and its siblings need a connected `Client` for most media — they ask the server for CDN hosts and an auth token (cached and refreshed automatically, not fetched on every call). The exception is `static_url` media (newsletter/channel content), which skips that round trip entirely — see the note below. If you've persisted a message's CDN fields and want to download after the session has disconnected, use [`MediaDownloader`](#mediadownloader) instead — it needs no `Client` at all.
```rust
@@ -79,7 +79,7 @@ This classification reads `status_code` off a successfully completed HTTP exchan
-`static_url` media (newsletter/channel content fetched from a fixed CDN URL) skips the media-conn round trip entirely — `prepare_requests` only asks the server for hosts when the downloadable has no `static_url`.
+`static_url` media (newsletter/channel content fetched from a fixed CDN URL) skips the media-conn round trip entirely: `download` and its siblings only ask the server for hosts when the downloadable has no `static_url`.
---
@@ -243,7 +243,7 @@ pub fn encrypted(
Downloads and decrypts media from the CDN with **no connected `Client`**. Everything a download needs beyond the CDN hosts already lives in the [`Downloadable`](#downloadable-trait) itself — `MediaDownloader` takes the hosts (and, optionally, an auth token) up front instead of asking a live session for them, so a persisted reference (for example a stored [`DownloadParams`](#downloadparams)) stays downloadable after the client has disconnected.
-A live `Client` keeps asking the server for its hosts on every download; `MediaDownloader` is the path for callers that have no session to ask with — a background worker, a queue consumer, or a CLI tool operating on data a paired client saved earlier.
+A live `Client` still needs a session to fetch its hosts (cached and refreshed automatically, not re-fetched on every call — see [Automatic retry and URL re-derivation](#automatic-retry-and-url-re-derivation)); `MediaDownloader` is the path for callers that have no session to ask with at all — a background worker, a queue consumer, or a CLI tool operating on data a paired client saved earlier.
```rust
pub struct MediaDownloader { /* private fields */ }
@@ -299,8 +299,9 @@ use whatsapp_rust::download::{DownloadParams, MediaDownloader};
// Needs only an HttpClient + Runtime — no paired Client required.
let downloader = MediaDownloader::with_default_hosts(http_client.clone(), runtime.clone());
-// `params` came from something you persisted earlier, e.g. via `PersistenceManager`
-// or your own store, while the client was still connected.
+// `params` came from CDN fields you saved to your own store earlier, while
+// the client was still connected. (`PersistenceManager` covers device/session
+// state only — it doesn't store message metadata like this for you.)
let params = DownloadParams::encrypted(
"/v/t62.7118-24/12345_67890",
&media_key,
@@ -331,10 +332,12 @@ pub enum MediaDownloadError {
| Variant | Meaning |
|---------|---------|
-| `ReferenceRejected` | The CDN rejected the reference itself (401/403/404/410). The `direct_path` or its token is expired or revoked, and — with no session to refresh from — another host cannot serve it either. Terminal: retrying without new credentials won't help. |
+| `ReferenceRejected` | The CDN rejected the request with 401/403/404/410. On a route built with [`MediaRoute::authenticated`](#mediaroute-and-mediahost), a 401/403 can mean only the *auth token* is stale — downloads don't strictly need one, so retrying with [`route.without_auth()`](#mediaroute-and-mediahost) before giving up can still succeed. A 404/410, or a 401/403 on an already-unauthenticated route, means the `direct_path` itself is expired or revoked and no host can serve it — that case is terminal. |
| `HostsUnreachable` | Every host in the route failed for a reason other than the reference (transport failure, unexpected status, a body that failed to decrypt/verify). |
| `NoHosts` | The `MediaRoute` named no hosts, so nothing was ever contacted. |
-| `Other` | No host was contacted and none could be — the `Downloadable` is missing fields needed to build a URL at all. Fix the metadata, not the network. |
+| `Other` | The `Downloadable` didn't have the fields needed to build a request at all (e.g. no `direct_path` and no `static_url`), so — unlike `NoHosts` — no host was ever *going* to be contacted. Fix the metadata, not the network. |
+
+`MediaDownloadError` is `#[non_exhaustive]`; match it with a wildcard (`_ => `) arm so a future variant doesn't break your build.
`Client::download` and `Client::download_to_writer` keep returning `anyhow::Error`: they already try a credential refresh before the error would escape, so the extra classification has nothing left to add there.
@@ -378,7 +381,7 @@ pub const DEFAULT_MEDIA_HOSTS: [&str; 2] = ["mmg.whatsapp.net", "mmg-fallback.wh
-**Breaking change (as of PR #1194):** `wacore::download::MediaConnection` was replaced by `MediaRoute`. If you called `DownloadUtils::prepare_download_requests` directly with a `MediaConnection`, migrate with `MediaRoute::from(&media_conn)` (a `From<&MediaConn>` impl is provided) or by constructing a `MediaRoute` via `MediaRoute::authenticated(hosts, auth)`. `Client::download` and friends are unaffected — this only touches callers using the lower-level `DownloadUtils` / `MediaConnection` types directly.
+**Breaking change (as of PR #1194):** `wacore::download::MediaConnection` (`{ hosts: Vec, auth: String }`) no longer exists. If you built one directly and passed it to `DownloadUtils::prepare_download_requests`, migrate by constructing the equivalent `MediaRoute` in its place: `MediaRoute::authenticated(hosts, auth)`. (The `From<&MediaConn> for MediaRoute` impl is unrelated to this migration — it converts `whatsapp_rust`'s own `mediaconn::MediaConn`, the server response a connected `Client` refreshes internally, and is what `Client::prepare_requests` uses under the hood.) `Client::download` and friends are unaffected either way; this only touches callers using the lower-level `DownloadUtils` type directly.
---
From 4e70c7ca5c6302178b5bb7de150656c069d09a7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Thu, 30 Jul 2026 08:42:20 -0300
Subject: [PATCH 4/5] docs(media-handling): qualify the connected-Client
requirement for static_url media
The "Downloading without an active session" note categorically said
client.download() needs a connected Client, contradicting the static_url
exception documented on the download API page. Cross-reference it here too.
Addresses a chatgpt-codex-connector review comment on PR #469.
---
guides/media-handling.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/guides/media-handling.mdx b/guides/media-handling.mdx
index 7190115b..5c63e875 100644
--- a/guides/media-handling.mdx
+++ b/guides/media-handling.mdx
@@ -124,7 +124,7 @@ See [Download API reference](/api/download#download_from_params_to_writer) for s
### Downloading without an active session
-`client.download()` and its variants need a connected `Client` — they ask the server for CDN hosts and an auth token. If you've persisted a message's CDN fields (for example as a [`DownloadParams`](/api/download#downloadparams)) and want to fetch that media later, after the session that received it has disconnected, use [`MediaDownloader`](/api/download#mediadownloader) instead. It needs only an `HttpClient` and a `Runtime` — no `Client`, no pairing:
+`client.download()` and its variants need a connected `Client` for most media — they ask the server for CDN hosts and an auth token (cached and refreshed automatically, not fetched on every call). The exception is `static_url` media (newsletter/channel content), which skips that entirely — see the [Download API reference](/api/download#download) for details. If you've persisted a message's CDN fields (for example as a [`DownloadParams`](/api/download#downloadparams)) and want to fetch that media later, after the session that received it has disconnected, use [`MediaDownloader`](/api/download#mediadownloader) instead. It needs only an `HttpClient` and a `Runtime` — no `Client`, no pairing:
```rust
use std::sync::Arc;
From 98807debdb10725550749f2c37644e8c0969791d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Thu, 30 Jul 2026 08:48:34 -0300
Subject: [PATCH 5/5] docs(media-handling): carry the
stale-auth-vs-expired-reference nuance into the guide
The API reference already distinguishes a stale MediaRoute auth token
(401/403, recoverable via without_auth()) from a genuinely expired
reference (404/410, terminal), but this guide's Note still called every
rejection "expired or revoked." Bring it in line.
Addresses a chatgpt-codex-connector review comment on PR #469.
---
guides/media-handling.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/guides/media-handling.mdx b/guides/media-handling.mdx
index 5c63e875..63f671bb 100644
--- a/guides/media-handling.mdx
+++ b/guides/media-handling.mdx
@@ -147,7 +147,7 @@ let data = downloader.download(¶ms).await?;
```
-`MediaDownloader` mirrors `client.download()`'s streaming/buffered behavior and host failover, but it can't refresh credentials — there's no session to get fresh ones from. A rejected reference (expired or revoked) fails immediately instead of retrying, and the error is a typed [`MediaDownloadError`](/api/download#mediadownloaderror) rather than `anyhow::Error`.
+`MediaDownloader` mirrors `client.download()`'s streaming/buffered behavior and host failover, but it can't refresh credentials — there's no session to get fresh ones from. A rejection fails immediately instead of retrying, and the error is a typed [`MediaDownloadError`](/api/download#mediadownloaderror) rather than `anyhow::Error`. On a route built with `MediaRoute::authenticated`, a 401/403 can mean only the *auth token* went stale, not the reference itself — retry once with `route.without_auth()` before treating it as expired or revoked. See [`MediaDownloadError`](/api/download#mediadownloaderror) for the full breakdown.
## Thumbnails