diff --git a/crates/x-media/src/site/bilibili/interface.rs b/crates/x-media/src/site/bilibili/interface.rs index 4d4b959..be15176 100644 --- a/crates/x-media/src/site/bilibili/interface.rs +++ b/crates/x-media/src/site/bilibili/interface.rs @@ -111,10 +111,6 @@ pub static PATTERN: LazyLock = LazyLock::new(|| { .unwrap() }); -pub fn enabled() -> bool { - true -} - pub async fn fetch_from_url(url: &str) -> Result { let dynamic_id = PATTERN .captures(url) @@ -133,18 +129,9 @@ pub fn cache_key(url: &str) -> Option { .map(|caps| format!("bilibili:{}", &caps[1])) } -/// Bilibili's fetch-retry policy: transient classes only. Not-found, blocked -/// and parse failures are permanent. -pub fn is_retryable(err: &FetchError) -> bool { - matches!(err, FetchError::Http(_) | FetchError::Transient(_)) -} - -/// hdslb media serves without a `Referer` (verified live 2026-09-17 on -/// `i0.hdslb.com` image URLs, requested both with and without one), so no -/// extra headers. -pub fn media_headers(_url: &str) -> Option> { - None -} +// hdslb media serves without a `Referer` (verified live 2026-09-17 on +// `i0.hdslb.com` image URLs, requested both with and without one), so this +// adapter does not override `Site::media_headers`. /// `Cookie` header for bilibili requests: the operator's `BILIBILI_COOKIE` /// when set, otherwise the anonymous device cookies. @@ -908,7 +895,7 @@ mod tests { // dropping the post. for code in [-352, -412] { let err = code_error(code, "-352").unwrap(); - assert!(is_retryable(&err), "{err}"); + assert!(BilibiliSite.is_retryable(&err), "{err}"); } // A removed dynamic is permanent. assert!(matches!(code_error(500, ""), Some(FetchError::NotFound))); @@ -917,7 +904,7 @@ mod tests { Some(FetchError::NotFound) )); let err = code_error(-400, "param parsing failed").unwrap(); - assert!(!is_retryable(&err), "{err}"); + assert!(!BilibiliSite.is_retryable(&err), "{err}"); assert!(err.to_string().contains("-400"), "{err}"); } diff --git a/crates/x-media/src/site/bilibili/mod.rs b/crates/x-media/src/site/bilibili/mod.rs index d6858ad..eb9c3db 100644 --- a/crates/x-media/src/site/bilibili/mod.rs +++ b/crates/x-media/src/site/bilibili/mod.rs @@ -1,6 +1,4 @@ mod interface; mod model; -pub use interface::{ - BilibiliSite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers, -}; +pub use interface::{BilibiliSite, PATTERN, cache_key, fetch_from_url}; diff --git a/crates/x-media/src/site/bsky/interface.rs b/crates/x-media/src/site/bsky/interface.rs index 84afab2..93d1560 100644 --- a/crates/x-media/src/site/bsky/interface.rs +++ b/crates/x-media/src/site/bsky/interface.rs @@ -30,10 +30,6 @@ pub static PATTERN: LazyLock = LazyLock::new(|| { Regex::new(r"^(?:https?://)?bsky\.app/profile/([\w.\-:]+)/post/([\w.\-~]+)").unwrap() }); -pub fn enabled() -> bool { - true -} - pub async fn fetch_from_url(url: &str) -> Result { let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let handle = caps @@ -112,17 +108,6 @@ pub fn cache_key(url: &str) -> Option { .map(|caps| format!("bsky:{}/{}", &caps[1], &caps[2])) } -/// Bluesky's fetch-retry policy: transient classes only. Not-found, blocked -/// and parse failures are permanent. -pub fn is_retryable(err: &FetchError) -> bool { - matches!(err, FetchError::Http(_) | FetchError::Transient(_)) -} - -/// bsky media (cdn.bsky.app) needs no extra headers. -pub fn media_headers(_url: &str) -> Option> { - None -} - /// Segments fetched (and written) at once while remuxing an HLS video. Small /// on purpose: a segment can be up to 20 MiB and the whole playlist is capped /// at 256 MiB, so this is also what bounds the remux's peak memory. @@ -499,10 +484,11 @@ mod tests { /// ([`fetch_hls`]). The classes below are the ones still retried there. #[test] fn media_prep_failure_is_not_retried() { - assert!(!is_retryable(&FetchError::MediaPrep( + use crate::site::Site as _; + assert!(!BskySite.is_retryable(&FetchError::MediaPrep( "bsky video remux failed: segment 400: 503".into() ))); - assert!(is_retryable(&FetchError::Transient("429".into()))); + assert!(BskySite.is_retryable(&FetchError::Transient("429".into()))); } #[test] diff --git a/crates/x-media/src/site/bsky/mod.rs b/crates/x-media/src/site/bsky/mod.rs index acbaf80..5725dd7 100644 --- a/crates/x-media/src/site/bsky/mod.rs +++ b/crates/x-media/src/site/bsky/mod.rs @@ -1,6 +1,4 @@ mod interface; mod model; -pub use interface::{ - BskySite, PATTERN, Post, cache_key, enabled, fetch_from_url, is_retryable, media_headers, -}; +pub use interface::{BskySite, PATTERN, Post, cache_key, fetch_from_url}; diff --git a/crates/x-media/src/site/misskey/interface.rs b/crates/x-media/src/site/misskey/interface.rs index ecf9567..fd8c7ff 100644 --- a/crates/x-media/src/site/misskey/interface.rs +++ b/crates/x-media/src/site/misskey/interface.rs @@ -34,10 +34,6 @@ impl Site for MisskeySite { pub static PATTERN: LazyLock = LazyLock::new(|| Regex::new(r"^(?:https?://)?misskey\.io/notes/([\w.\-~]+)").unwrap()); -pub fn enabled() -> bool { - true -} - pub async fn fetch_from_url(url: &str) -> Result { let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let note_id = caps.get(1).ok_or(FetchError::NotFound)?.as_str(); @@ -53,17 +49,6 @@ pub fn cache_key(url: &str) -> Option { .map(|caps| format!("misskey:{}", &caps[1])) } -/// Misskey's fetch-retry policy: transient classes only. Not-found, blocked -/// and parse failures are permanent. -pub fn is_retryable(err: &FetchError) -> bool { - matches!(err, FetchError::Http(_) | FetchError::Transient(_)) -} - -/// misskey.io media hosts need no extra headers (verified: direct GET works). -pub fn media_headers(_url: &str) -> Option> { - None -} - /// Fetches a note from misskey.io by id. The API answers client failures /// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound); /// everything else non-success is transient and retried by [`crate::site::fetch`]. diff --git a/crates/x-media/src/site/misskey/mod.rs b/crates/x-media/src/site/misskey/mod.rs index 325cdb3..f12fb58 100644 --- a/crates/x-media/src/site/misskey/mod.rs +++ b/crates/x-media/src/site/misskey/mod.rs @@ -1,6 +1,4 @@ mod interface; mod model; -pub use interface::{ - MisskeySite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers, -}; +pub use interface::{MisskeySite, PATTERN, cache_key, fetch_from_url}; diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index 27e2fa5..ed96eac 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -891,8 +891,10 @@ mod tests { }; assert_eq!(err.to_string(), "example error: boom"); assert!(err.source().is_some()); - // Permanent by default: no site's is_retryable matches it. - assert!(!twitter::is_retryable(&err)); + // Permanent by default: no site's is_retryable matches it (the trait + // default is the policy for every site that does not override it). + assert!(!twitter::TwitterSite.is_retryable(&err)); + assert!(twitter::TwitterSite.is_retryable(&FetchError::Transient("429".into()))); } #[test] diff --git a/crates/x-media/src/site/twitter/interface.rs b/crates/x-media/src/site/twitter/interface.rs index 5505e27..ed0c3e4 100644 --- a/crates/x-media/src/site/twitter/interface.rs +++ b/crates/x-media/src/site/twitter/interface.rs @@ -30,8 +30,12 @@ pub static PATTERN: LazyLock = LazyLock::new(|| { Regex::new(r"^(?:https?://)?(?:www\.|mobile\.)?(?:x|twitter|fixvx|vxtwitter|fixupx|fxtwitter)\.com/[^.]+/status/(\d+)").unwrap() }); -pub fn enabled() -> bool { - true +/// Cache key for a twitter URL: `"twitter:"`. The prefix is the site id +/// used for caption-format lookup and link-cache keys. +pub fn cache_key(url: &str) -> Option { + PATTERN + .captures(url) + .map(|caps| format!("twitter:{}", &caps[1])) } pub async fn fetch_from_url(url: &str) -> Result { @@ -68,26 +72,6 @@ pub async fn fetch_from_url(url: &str) -> Result { } } -/// Cache key for a twitter URL: `"twitter:"`. The prefix is the site id -/// used for caption-format lookup and link-cache keys. -pub fn cache_key(url: &str) -> Option { - PATTERN - .captures(url) - .map(|caps| format!("twitter:{}", &caps[1])) -} - -/// Twitter's fetch-retry policy: transient classes only. Not-found, blocked, -/// sensitive (NSFW withholding) and parse failures are permanent — retrying -/// them only wastes attempts against the syndication endpoint. -pub fn is_retryable(err: &FetchError) -> bool { - matches!(err, FetchError::Http(_) | FetchError::Transient(_)) -} - -/// twimg URLs need no extra headers (no hotlink protection). -pub fn media_headers(_url: &str) -> Option> { - None -} - /// Fetches a tweet from the syndication endpoint. Deleted/blocked tweets /// surface as `FetchError::NotFound`; withheld content (empty tombstone, /// age-restricted) as `FetchError::Sensitive`. @@ -462,20 +446,6 @@ mod tests { assert_eq!(cache_key("https://example.com/1"), None); } - #[test] - fn is_retryable_classifies_transient_and_permanent() { - // Transient: network errors and explicit transient statuses (the - // `Http` arm shares this match arm with `Transient`). - assert!(is_retryable(&FetchError::Transient("429".into()))); - // Permanent: gone, blocked, withheld, oversized, unparseable. - assert!(!is_retryable(&FetchError::NotFound)); - assert!(!is_retryable(&FetchError::Blocked)); - assert!(!is_retryable(&FetchError::Sensitive)); - assert!(!is_retryable(&FetchError::TooLarge)); - let json_err = serde_json::from_str::("x").unwrap_err(); - assert!(!is_retryable(&FetchError::Json(json_err))); - } - #[test] fn syndication_json_converts_to_fetched() { let raw = fixture(serde_json::json!([ diff --git a/crates/x-media/src/site/twitter/mod.rs b/crates/x-media/src/site/twitter/mod.rs index c49d67d..442e19f 100644 --- a/crates/x-media/src/site/twitter/mod.rs +++ b/crates/x-media/src/site/twitter/mod.rs @@ -2,6 +2,4 @@ mod auth; mod interface; mod model; -pub use interface::{ - PATTERN, Tweet, TwitterSite, cache_key, enabled, fetch_from_url, is_retryable, media_headers, -}; +pub use interface::{PATTERN, Tweet, TwitterSite, cache_key, fetch_from_url};