diff --git a/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts b/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts index 84e3b6217..6f2db77a7 100644 --- a/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts +++ b/fluxer_api/src/api/favorite_gif/FavoriteGifResolver.ts @@ -2,6 +2,7 @@ import {Logger} from '@fluxer/logger/src/Logger'; import type {ResolvedGifEntrySchema} from '@fluxer/schema/src/domains/gif/FavoriteGifSchemas'; +import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys'; import type {GifMediaFormat, GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas'; import type {EmbedMediaResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas'; import {tryExtractGifProviderSlug} from '../gif/GifProviderUtils'; @@ -10,17 +11,6 @@ import type {IGifProvider} from '../gif/IGifProvider'; import type {IMediaService, MediaProxyMetadataResponse} from '../infrastructure/IMediaService'; import type {IUnfurlerService} from '../infrastructure/IUnfurlerService'; -const PREVIEW_FORMAT_PRIORITY = ['webm', 'mp4', 'tinywebm', 'tinymp4', 'webp', 'gif', 'tinygif', 'nanogif'] as const; -const FORMAT_CONTENT_TYPES: Record = { - webm: 'video/webm', - tinywebm: 'video/webm', - mp4: 'video/mp4', - tinymp4: 'video/mp4', - webp: 'image/webp', - gif: 'image/gif', - tinygif: 'image/gif', - nanogif: 'image/gif', -}; const logger = new Logger('FavoriteGifResolver'); function pickFavoriteGifPreviewFormat( @@ -221,10 +211,6 @@ function isUsableGifMediaFormat(format: GifMediaFormat | undefined): format is G return Boolean(format?.src && format.proxy_src && format.width > 0 && format.height > 0); } -function inferFormatContentType(formatKey: string): string { - return FORMAT_CONTENT_TYPES[formatKey] ?? ''; -} - function isRenderableMediaType(contentType: string | null | undefined): boolean { if (!contentType) return false; return contentType.startsWith('image/') || contentType.startsWith('video/'); diff --git a/fluxer_gifs/src/klipy.rs b/fluxer_gifs/src/klipy.rs index aa7eb30cb..a7826d5cb 100644 --- a/fluxer_gifs/src/klipy.rs +++ b/fluxer_gifs/src/klipy.rs @@ -7,6 +7,7 @@ use reqwest::Url; use serde::Deserialize; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; +use std::sync::LazyLock; use std::time::Duration; use tokio::time::sleep; @@ -20,10 +21,13 @@ const KLIPY_RESPONSE_LIMIT_BYTES: usize = 512 * 1024; const FLUXER_USER_AGENT: &str = "Fluxerbot/1.0 (+https://fluxer.app)"; const KLIPY_PROVIDER_NAME: &str = "klipy"; const KLIPY_FEATURED_CATEGORY_REFRESH_COUNTRY: &str = "US"; +const UNRESOLVABLE_CACHE_KEY: &str = "unresolvable"; -const SIZE_PREFERENCE: [&str; 4] = ["hd", "md", "sm", "xs"]; -const FILE_FORMAT_PREFERENCE: [&str; 4] = ["webm", "mp4", "webp", "gif"]; -const MEDIA_FORMAT_PREFERENCE: [&str; 11] = [ +const SIZE_KEY_PREFIXES: [(&str, &str); 4] = + [("hd", ""), ("md", "medium"), ("sm", "tiny"), ("xs", "nano")]; +const FILE_FORMATS: [&str; 4] = ["webm", "mp4", "webp", "gif"]; +const UNSIZED_MEDIA_FORMAT_KEYS: [&str; 1] = ["loopedmp4"]; +const MEDIA_FORMAT_PREFERENCE: [&str; 17] = [ "webm", "mp4", "webp", @@ -35,9 +39,14 @@ const MEDIA_FORMAT_PREFERENCE: [&str; 11] = [ "nanowebm", "nanomp4", "nanogif", + "loopedmp4", + "mediummp4", + "mediumwebm", + "mediumwebp", + "nanowebp", + "tinywebp", ]; -const MEDIA_FILTER: &str = - "webm,mp4,webp,gif,mediumgif,tinywebm,tinymp4,tinygif,nanowebm,nanomp4,nanogif"; +static MEDIA_FILTER: LazyLock = LazyLock::new(|| MEDIA_FORMAT_PREFERENCE.join(",")); #[derive(Clone)] pub struct KlipyClient { @@ -168,7 +177,7 @@ impl KlipyClient { ("country", country), ("locale", &locale), ("limit", &limit), - ("media_filter", MEDIA_FILTER), + ("media_filter", MEDIA_FILTER.as_str()), ], ) .await @@ -188,7 +197,7 @@ impl KlipyClient { ("country", country), ("locale", &locale), ("limit", "1"), - ("media_filter", MEDIA_FILTER), + ("media_filter", MEDIA_FILTER.as_str()), ], ) .await @@ -208,7 +217,7 @@ impl KlipyClient { ("country", country), ("locale", &locale), ("limit", "50"), - ("media_filter", MEDIA_FILTER), + ("media_filter", MEDIA_FILTER.as_str()), ], ) .await @@ -253,7 +262,13 @@ impl KlipyClient { ("q", q), ], )?; - let response = self.http_client.get(url).send().await?; + let response = self + .http_client + .get(url) + .send() + .await + .map_err(reqwest::Error::without_url) + .context("KLIPY registershare request failed")?; if !response.status().is_success() { anyhow::bail!( "KLIPY registershare failed with status {}", @@ -372,7 +387,10 @@ impl KlipyClient { url: Url, path: &KlipyPath, ) -> anyhow::Result> { - match self.fetch_json_response::(url).await? { + match self + .fetch_json_response::(url, klipy_resource(path.path_type)) + .await? + { KlipyJsonFetch::NotFound => Ok(None), KlipyJsonFetch::Found(response) => Ok(response .data @@ -388,7 +406,7 @@ impl KlipyClient { let url = self.create_url(endpoint, params)?; let mut last_error = None; for attempt in 0..MAX_RETRIES { - match self.fetch_json_once(url.clone()).await { + match self.fetch_json_once(url.clone(), endpoint).await { Ok(value) => return Ok(value), Err(error) if attempt + 1 < MAX_RETRIES => { last_error = Some(error); @@ -400,26 +418,41 @@ impl KlipyClient { Err(last_error.unwrap_or_else(|| anyhow::anyhow!("exceeded KLIPY retry limit"))) } - async fn fetch_json_once(&self, url: Url) -> anyhow::Result + async fn fetch_json_once(&self, url: Url, label: &str) -> anyhow::Result where T: serde::de::DeserializeOwned, { - match self.fetch_json_response(url).await? { + match self.fetch_json_response(url, label).await? { KlipyJsonFetch::Found(value) => Ok(value), - KlipyJsonFetch::NotFound => anyhow::bail!("KLIPY request returned not found"), + KlipyJsonFetch::NotFound => { + anyhow::bail!("KLIPY {label} request returned not found") + } } } - async fn fetch_json_response(&self, url: Url) -> anyhow::Result> + async fn fetch_json_response( + &self, + url: Url, + label: &str, + ) -> anyhow::Result> where T: serde::de::DeserializeOwned, { - let response = self.http_client.get(url.clone()).send().await?; + let response = self + .http_client + .get(url) + .send() + .await + .map_err(reqwest::Error::without_url) + .with_context(|| format!("KLIPY {label} request failed"))?; if response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(KlipyJsonFetch::NotFound); } if !response.status().is_success() { - anyhow::bail!("KLIPY request failed with status {}", response.status()); + anyhow::bail!( + "KLIPY {label} request failed with status {}", + response.status() + ); } if response .content_length() @@ -427,12 +460,16 @@ impl KlipyClient { { anyhow::bail!("KLIPY response declared more than {KLIPY_RESPONSE_LIMIT_BYTES} bytes"); } - let bytes = response.bytes().await?; + let bytes = response + .bytes() + .await + .map_err(reqwest::Error::without_url) + .with_context(|| format!("KLIPY {label} response body failed"))?; if bytes.len() > KLIPY_RESPONSE_LIMIT_BYTES { - anyhow::bail!("KLIPY response exceeded {KLIPY_RESPONSE_LIMIT_BYTES} bytes"); + anyhow::bail!("KLIPY {label} response exceeded {KLIPY_RESPONSE_LIMIT_BYTES} bytes"); } serde_json::from_slice(&bytes) - .with_context(|| format!("failed to parse KLIPY response from {url}")) + .with_context(|| format!("failed to parse KLIPY {label} response")) .map(KlipyJsonFetch::Found) } @@ -519,52 +556,39 @@ impl KlipyClient { ) -> (BTreeMap, Option) { let mut media = BTreeMap::new(); let mut preferred = None; - for size in SIZE_PREFERENCE { - let Some(KlipyFileGroup::Sized(bucket)) = - input.file.as_ref().and_then(|files| files.get(size)) - else { - continue; - }; - for format in FILE_FORMAT_PREFERENCE { - let Some(entry) = bucket.get(format) else { + if let Some(files) = input.file.as_ref() { + report_unmapped_file_keys(files); + for (size, _) in SIZE_KEY_PREFIXES { + let Some(KlipyFileGroup::Sized(bucket)) = files.get(size) else { continue; }; - let Some(media_format) = self.to_media_format(entry, None) else { - continue; - }; - let public_key = public_format_key(size, format); - media.insert(public_key, media_format.clone()); - if preferred.is_none() { - preferred = Some(media_format); + for format in FILE_FORMATS { + let Some(entry) = bucket.get(format) else { + continue; + }; + let Some(media_format) = self.to_media_format(entry, None) else { + continue; + }; + let Some(public_key) = public_format_key(size, format) else { + continue; + }; + media + .entry(public_key) + .or_insert_with(|| media_format.clone()); + if preferred.is_none() { + preferred = Some(media_format); + } } } - } - for format in FILE_FORMAT_PREFERENCE { - let Some(KlipyFileGroup::Flat(entry)) = - input.file.as_ref().and_then(|files| files.get(format)) - else { - continue; - }; - let meta = input - .file_meta - .as_ref() - .and_then(|file_meta| file_meta.get(format)); - let Some(media_format) = self.to_media_format(entry, meta) else { - continue; - }; - media - .entry(format.to_owned()) - .or_insert_with(|| media_format.clone()); - if preferred.is_none() { - preferred = Some(media_format); - } - } - if let Some(formats) = input.media_formats.as_ref() { - for format in MEDIA_FORMAT_PREFERENCE { - let Some(entry) = formats.get(format) else { + for format in FILE_FORMATS { + let Some(KlipyFileGroup::Flat(entry)) = files.get(format) else { continue; }; - let Some(media_format) = self.to_media_format(entry, None) else { + let meta = input + .file_meta + .as_ref() + .and_then(|file_meta| file_meta.get(format)); + let Some(media_format) = self.to_media_format(entry, meta) else { continue; }; media @@ -574,18 +598,18 @@ impl KlipyClient { preferred = Some(media_format); } } - - for (format, entry) in formats { - if MEDIA_FORMAT_PREFERENCE.contains(&format.as_str()) - || !is_supported_media_format_key(format) - { + } + if let Some(formats) = input.media_formats.as_ref() { + report_unmapped_media_format_keys(formats); + for format in MEDIA_FORMAT_PREFERENCE { + let Some(entry) = formats.get(format) else { continue; - } + }; let Some(media_format) = self.to_media_format(entry, None) else { continue; }; media - .entry(format.clone()) + .entry(format.to_owned()) .or_insert_with(|| media_format.clone()); if preferred.is_none() { preferred = Some(media_format); @@ -665,6 +689,13 @@ pub fn extract_slug_from_url(url: &str) -> Option { parse_klipy_path(url).map(|path| path.slug) } +pub fn resolve_cache_key(url: &str) -> String { + match parse_klipy_path(url) { + Some(path) => format!("{}:{}", klipy_resource(path.path_type), path.slug), + None => UNRESOLVABLE_CACHE_KEY.to_owned(), + } +} + fn klipy_id_as_string(value: &Value) -> Option { match value { Value::String(value) => { @@ -712,27 +743,49 @@ fn klipy_resource(path_type: KlipyPathType) -> &'static str { } } -fn public_format_key(size: &str, format: &str) -> String { - match (size, format) { - ("hd", "webm") => "webm", - ("hd", "mp4") => "mp4", - ("hd", "webp") => "webp", - ("hd", "gif") => "gif", - ("md", "webm") => "mediumwebm", - ("md", "mp4") => "mediummp4", - ("md", "webp") => "mediumwebp", - ("md", "gif") => "mediumgif", - ("sm", "webm") => "tinywebm", - ("sm", "mp4") => "tinymp4", - ("sm", "webp") => "tinywebp", - ("sm", "gif") => "tinygif", - ("xs", "webm") => "nanowebm", - ("xs", "mp4") => "nanomp4", - ("xs", "webp") => "nanowebp", - ("xs", "gif") => "nanogif", - _ => format, +fn size_key_prefix(size: &str) -> Option<&'static str> { + SIZE_KEY_PREFIXES + .iter() + .find(|(known, _)| *known == size) + .map(|(_, prefix)| *prefix) +} + +fn public_format_key(size: &str, format: &str) -> Option { + if !FILE_FORMATS.contains(&format) { + return None; + } + Some(format!("{}{format}", size_key_prefix(size)?)) +} + +fn report_unmapped_file_keys(files: &BTreeMap) { + for (key, group) in files { + match group { + KlipyFileGroup::Sized(bucket) => { + if size_key_prefix(key).is_none() { + tracing::warn!(size = %key, "skipping gif file bucket with an unmapped size"); + continue; + } + for format in bucket.keys() { + if !FILE_FORMATS.contains(&format.as_str()) { + tracing::warn!(size = %key, format = %format, "skipping gif file entry with an unmapped format"); + } + } + } + KlipyFileGroup::Flat(_) => { + if !FILE_FORMATS.contains(&key.as_str()) { + tracing::warn!(format = %key, "skipping flat gif file entry with an unmapped format"); + } + } + } + } +} + +fn report_unmapped_media_format_keys(formats: &BTreeMap) { + for key in formats.keys() { + if !is_supported_media_format_key(key) { + tracing::debug!(format = %key, "skipping unsupported gif media format"); + } } - .to_owned() } fn valid_dimensions(width: i32, height: i32) -> Option<(i32, i32)> { @@ -740,21 +793,12 @@ fn valid_dimensions(width: i32, height: i32) -> Option<(i32, i32)> { } fn is_supported_media_format_key(format: &str) -> bool { - matches!( - format, - "webm" - | "tinywebm" - | "nanowebm" - | "mp4" - | "loopedmp4" - | "tinymp4" - | "nanomp4" - | "webp" - | "gif" - | "mediumgif" - | "tinygif" - | "nanogif" - ) + UNSIZED_MEDIA_FORMAT_KEYS.contains(&format) + || SIZE_KEY_PREFIXES.iter().any(|(_, prefix)| { + format + .strip_prefix(prefix) + .is_some_and(|codec| FILE_FORMATS.contains(&codec)) + }) } fn category_response(name: String, gif: Option) -> GifCategoryTag { @@ -992,10 +1036,212 @@ mod tests { } #[test] - fn maps_provider_format_keys() { - assert_eq!(public_format_key("hd", "webm"), "webm"); - assert_eq!(public_format_key("sm", "gif"), "tinygif"); - assert_eq!(public_format_key("xs", "webp"), "nanowebp"); + fn resolve_cache_key_ignores_everything_but_the_klipy_path() { + assert_eq!( + resolve_cache_key("https://klipy.com/gifs/funny-123"), + "gifs:funny-123" + ); + assert_eq!( + resolve_cache_key("https://www.klipy.com/gif/funny-123?utm_source=x"), + "gifs:funny-123" + ); + assert_eq!( + resolve_cache_key("https://klipy.com/clips/kittens"), + "clips:kittens" + ); + assert_ne!( + resolve_cache_key("https://klipy.com/gifs/kittens"), + resolve_cache_key("https://klipy.com/clips/kittens") + ); + assert_eq!( + resolve_cache_key("https://notklipy.com/gifs/funny"), + "unresolvable" + ); + } + + const FROZEN_PUBLIC_FORMAT_KEYS: [(&str, &str, &str); 16] = [ + ("hd", "webm", "webm"), + ("hd", "mp4", "mp4"), + ("hd", "webp", "webp"), + ("hd", "gif", "gif"), + ("md", "webm", "mediumwebm"), + ("md", "mp4", "mediummp4"), + ("md", "webp", "mediumwebp"), + ("md", "gif", "mediumgif"), + ("sm", "webm", "tinywebm"), + ("sm", "mp4", "tinymp4"), + ("sm", "webp", "tinywebp"), + ("sm", "gif", "tinygif"), + ("xs", "webm", "nanowebm"), + ("xs", "mp4", "nanomp4"), + ("xs", "webp", "nanowebp"), + ("xs", "gif", "nanogif"), + ]; + + #[test] + fn every_size_and_format_pair_maps_to_its_frozen_key() { + assert_eq!( + FROZEN_PUBLIC_FORMAT_KEYS.len(), + SIZE_KEY_PREFIXES.len() * FILE_FORMATS.len() + ); + for (size, format, expected) in FROZEN_PUBLIC_FORMAT_KEYS { + assert_eq!(public_format_key(size, format).as_deref(), Some(expected)); + } + } + + #[test] + fn unmapped_sizes_and_formats_produce_no_key() { + assert_eq!(public_format_key("xxl", "webm"), None); + assert_eq!(public_format_key("orig", "gif"), None); + assert_eq!(public_format_key("hd", "avif"), None); + assert_eq!(public_format_key("", "webm"), None); + } + + #[test] + fn the_accepted_vocabulary_is_frozen() { + let mut expected: Vec<&str> = FROZEN_PUBLIC_FORMAT_KEYS + .iter() + .map(|(_, _, key)| *key) + .collect(); + expected.extend(UNSIZED_MEDIA_FORMAT_KEYS); + expected.sort_unstable(); + let mut ordered = MEDIA_FORMAT_PREFERENCE.to_vec(); + ordered.sort_unstable(); + assert_eq!( + ordered, expected, + "preference order must cover exactly the accepted keys" + ); + for key in expected { + assert!(is_supported_media_format_key(key), "dropped {key}"); + } + for key in [ + "preview", "avif", "medium", "hdwebm", "webmm", "tinyavif", "", + ] { + assert!(!is_supported_media_format_key(key), "admitted {key}"); + } + } + + #[test] + fn media_filter_requests_every_accepted_key() { + assert_eq!( + MEDIA_FILTER.as_str(), + "webm,mp4,webp,gif,mediumgif,tinywebm,tinymp4,tinygif,nanowebm,nanomp4,nanogif,loopedmp4,mediummp4,mediumwebm,mediumwebp,nanowebp,tinywebp" + ); + } + + #[test] + fn sized_payloads_emit_exactly_the_frozen_key_set() { + let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + "https://media.example.test", + "secret", + )) + .expect("client"); + let input = serde_json::from_value::(serde_json::json!({ + "id": "frozen-keys", + "title": "Frozen Keys", + "itemurl": "https://klipy.com/gifs/frozen-keys", + "file": { + "hd": { + "webm": {"url": "https://static.klipy.com/hd.webm", "width": 640, "height": 420}, + "mp4": {"url": "https://static.klipy.com/hd.mp4", "width": 640, "height": 420}, + "webp": {"url": "https://static.klipy.com/hd.webp", "width": 640, "height": 420}, + "gif": {"url": "https://static.klipy.com/hd.gif", "width": 640, "height": 420}, + "avif": {"url": "https://static.klipy.com/hd.avif", "width": 640, "height": 420} + }, + "md": { + "webm": {"url": "https://static.klipy.com/md.webm", "width": 498, "height": 327}, + "mp4": {"url": "https://static.klipy.com/md.mp4", "width": 498, "height": 327}, + "webp": {"url": "https://static.klipy.com/md.webp", "width": 498, "height": 327}, + "gif": {"url": "https://static.klipy.com/md.gif", "width": 498, "height": 327} + }, + "sm": { + "webm": {"url": "https://static.klipy.com/sm.webm", "width": 220, "height": 144}, + "mp4": {"url": "https://static.klipy.com/sm.mp4", "width": 220, "height": 144}, + "webp": {"url": "https://static.klipy.com/sm.webp", "width": 220, "height": 144}, + "gif": {"url": "https://static.klipy.com/sm.gif", "width": 220, "height": 144} + }, + "xs": { + "webm": {"url": "https://static.klipy.com/xs.webm", "width": 137, "height": 90}, + "mp4": {"url": "https://static.klipy.com/xs.mp4", "width": 137, "height": 90}, + "webp": {"url": "https://static.klipy.com/xs.webp", "width": 137, "height": 90}, + "gif": {"url": "https://static.klipy.com/xs.gif", "width": 137, "height": 90} + }, + "xxl": { + "webm": {"url": "https://static.klipy.com/xxl.webm", "width": 1280, "height": 840} + } + } + })) + .expect("fixture"); + + let gif = client.transform_gif(input).expect("transformed gif"); + + let keys: Vec<&str> = gif.media.keys().map(String::as_str).collect(); + assert_eq!( + keys, + [ + "gif", + "mediumgif", + "mediummp4", + "mediumwebm", + "mediumwebp", + "mp4", + "nanogif", + "nanomp4", + "nanowebm", + "nanowebp", + "tinygif", + "tinymp4", + "tinywebm", + "tinywebp", + "webm", + "webp" + ] + ); + assert_eq!(gif.media["webm"].src, "https://static.klipy.com/hd.webm"); + assert_eq!(gif.src, gif.media["webm"].src); + } + + #[test] + fn keeps_medium_variants_from_the_v2_media_formats_payload() { + let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + "https://media.example.test", + "secret", + )) + .expect("client"); + let input = serde_json::from_value::(serde_json::json!({ + "id": "medium-variants", + "title": "Medium Variants", + "itemurl": "https://klipy.com/gifs/medium-variants", + "media_formats": { + "webp": {"url": "https://static.klipy.com/m.webp", "dims": [498, 327]}, + "mediumwebm": {"url": "https://static.klipy.com/m-medium.webm", "dims": [640, 420]}, + "mediummp4": {"url": "https://static.klipy.com/m-medium.mp4", "dims": [640, 420]}, + "mediumwebp": {"url": "https://static.klipy.com/m-medium.webp", "dims": [640, 420]}, + "tinywebp": {"url": "https://static.klipy.com/m-tiny.webp", "dims": [220, 144]}, + "nanowebp": {"url": "https://static.klipy.com/m-nano.webp", "dims": [137, 90]} + } + })) + .expect("fixture"); + + let gif = client.transform_gif(input).expect("transformed gif"); + + for format in [ + "mediumwebm", + "mediummp4", + "mediumwebp", + "tinywebp", + "nanowebp", + ] { + assert!(gif.media.contains_key(format), "missing {format}"); + } + assert_eq!( + gif.media.get("mediumwebm").map(|format| ( + format.src.as_str(), + format.width, + format.height + )), + Some(("https://static.klipy.com/m-medium.webm", 640, 420)) + ); } #[test] diff --git a/fluxer_gifs/src/router_impl.rs b/fluxer_gifs/src/router_impl.rs index 1ac07a140..822c571c3 100644 --- a/fluxer_gifs/src/router_impl.rs +++ b/fluxer_gifs/src/router_impl.rs @@ -80,12 +80,9 @@ fn request_key(req: &GifRequest) -> Option { } => Some(format!("trending:{locale}:{country}")), GifRequest::Suggest { q, locale, .. } => Some(format!("suggest:{locale}:{q}")), GifRequest::RegisterShare { .. } => None, - GifRequest::ResolveByUrl { - url, - locale, - country, - .. - } => Some(format!("resolve:{locale}:{country}:{url}")), + GifRequest::ResolveByUrl { url, .. } => { + Some(format!("resolve:{}", crate::klipy::resolve_cache_key(url))) + } GifRequest::BuildShareUrl { slug } => Some(format!("share-url:{slug}")), GifRequest::ExtractSlugFromUrl { url } => Some(format!("extract-slug:{url}")), } @@ -167,6 +164,42 @@ mod tests { assert_eq!(request_l1_key(&base), Some("trending:en_US:US".to_owned())); } + #[test] + fn resolve_key_collapses_locale_country_and_url_spelling() { + let make = |url: &str, locale: &str, country: &str| GifRequest::ResolveByUrl { + api_key: "key".to_owned(), + url: url.to_owned(), + locale: locale.to_owned(), + country: country.to_owned(), + }; + let base = make("https://klipy.com/gifs/kittens", "en_US", "US"); + + assert_eq!( + GifsRouter::coalesce_key(&base), + Some("resolve:gifs:kittens".to_owned()) + ); + assert_eq!( + GifsRouter::coalesce_key(&base), + GifsRouter::coalesce_key(&make("https://klipy.com/gifs/kittens", "sv_SE", "SE")) + ); + assert_eq!( + GifsRouter::coalesce_key(&base), + GifsRouter::coalesce_key(&make( + "https://www.klipy.com/gif/kittens?x=1", + "en_US", + "US" + )) + ); + assert_ne!( + GifsRouter::coalesce_key(&base), + GifsRouter::coalesce_key(&make("https://klipy.com/clips/kittens", "en_US", "US")) + ); + assert_eq!( + request_l1_key(&base), + Some("resolve:gifs:kittens".to_owned()) + ); + } + #[test] fn register_share_is_not_cached_or_coalesced() { let request = GifRequest::RegisterShare { diff --git a/fluxer_gifs/src/shard_impl.rs b/fluxer_gifs/src/shard_impl.rs index 7a04bbdd8..a7c1bd354 100644 --- a/fluxer_gifs/src/shard_impl.rs +++ b/fluxer_gifs/src/shard_impl.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::klipy::{KlipyClient, build_share_url, extract_slug_from_url}; +use crate::klipy::{KlipyClient, build_share_url, extract_slug_from_url, resolve_cache_key}; use crate::media_proxy::MediaProxyUrlBuilder; use crate::types::{GifCategoryTag, GifItem, GifRequest, GifServiceResponse}; use fluxer_svc::config::ServiceConfig; @@ -310,7 +310,7 @@ impl GifsShard { locale: String, country: String, ) -> anyhow::Result { - let key = format!("resolve:{locale}:{country}:{url}"); + let key = format!("resolve:{}", resolve_cache_key(&url)); let this = self.clone(); let gif = self .get_cached( diff --git a/packages/schema/src/domains/gif/GifMediaFormatKeys.ts b/packages/schema/src/domains/gif/GifMediaFormatKeys.ts new file mode 100644 index 000000000..e419b9d65 --- /dev/null +++ b/packages/schema/src/domains/gif/GifMediaFormatKeys.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const FORMAT_CONTENT_TYPES: Record = { + gif: 'image/gif', + loopedmp4: 'video/mp4', + mediumgif: 'image/gif', + mediummp4: 'video/mp4', + mediumwebm: 'video/webm', + mediumwebp: 'image/webp', + mp4: 'video/mp4', + nanogif: 'image/gif', + nanomp4: 'video/mp4', + nanowebm: 'video/webm', + nanowebp: 'image/webp', + tinygif: 'image/gif', + tinymp4: 'video/mp4', + tinywebm: 'video/webm', + tinywebp: 'image/webp', + webm: 'video/webm', + webp: 'image/webp', +}; + +export const PREVIEW_FORMAT_PRIORITY = [ + 'tinywebm', + 'tinymp4', + 'mediumwebm', + 'mediummp4', + 'webm', + 'mp4', + 'loopedmp4', + 'nanowebm', + 'nanomp4', + 'tinywebp', + 'tinygif', + 'mediumwebp', + 'webp', + 'mediumgif', + 'gif', + 'nanowebp', + 'nanogif', +] as const; + +export function inferFormatContentType(formatKey: string): string { + return FORMAT_CONTENT_TYPES[formatKey] ?? ''; +} diff --git a/packages/schema/src/domains/gif/GifSchemas.ts b/packages/schema/src/domains/gif/GifSchemas.ts index 4fd25d2a3..977f00b30 100644 --- a/packages/schema/src/domains/gif/GifSchemas.ts +++ b/packages/schema/src/domains/gif/GifSchemas.ts @@ -55,7 +55,7 @@ export const GifResponse = z.object({ media: z .record(z.string(), GifMediaFormat) .describe( - 'Map of format-name → media descriptor. Keys are provider-issued format names (e.g. "webm", "mp4", "webp", "gif", "tinygif", "nanogif"). Clients without webm support should pick "webp" / "gif" / "tinygif" / "nanogif" in that order.', + 'Map of format-name → media descriptor. Keys are a size prefix (none for full size, "medium", "tiny", "nano") joined to a codec name ("webm", "mp4", "webp", "gif"), plus "loopedmp4". Video keys are "webm" / "mp4" / "loopedmp4" / "mediumwebm" / "mediummp4" / "tinywebm" / "tinymp4" / "nanowebm" / "nanomp4"; image keys are "webp" / "gif" / "mediumwebp" / "mediumgif" / "tinywebp" / "tinygif" / "nanowebp" / "nanogif". Every key is optional, so clients must walk a priority list rather than index a single key. Clients that cannot decode the video keys should prefer "tinywebp" / "tinygif" / "mediumwebp" / "mediumgif" / "webp" / "gif" / "nanowebp" / "nanogif" in that order.', ), placeholder: z .string()