diff --git a/fluxer_gifs/src/klipy.rs b/fluxer_gifs/src/klipy.rs index 6f98aa7cd..aa7eb30cb 100644 --- a/fluxer_gifs/src/klipy.rs +++ b/fluxer_gifs/src/klipy.rs @@ -65,11 +65,28 @@ struct KlipyGif { #[serde(default)] itemurl: Option, #[serde(default)] - file: Option>>, + file: Option>, + #[serde(default)] + file_meta: Option>, #[serde(default)] media_formats: Option>, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum KlipyFileGroup { + Sized(BTreeMap), + Flat(KlipyMediaEntry), +} + +#[derive(Debug, Deserialize)] +struct KlipyFileMeta { + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum KlipyMediaEntry { @@ -94,7 +111,13 @@ struct KlipyCategoryTag { #[derive(Debug, Deserialize)] struct DirectGifResponse { #[serde(default)] - data: Option, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct DirectGifItems { + #[serde(default)] + data: Vec, } enum KlipyJsonFetch { @@ -353,6 +376,7 @@ impl KlipyClient { KlipyJsonFetch::NotFound => Ok(None), KlipyJsonFetch::Found(response) => Ok(response .data + .and_then(|items| items.data.into_iter().next()) .and_then(|gif| self.transform_gif_with_path(gif, Some(path)))), } } @@ -426,7 +450,7 @@ impl KlipyClient { } fn create_direct_url(&self, api_key: &str, path: &KlipyPath) -> anyhow::Result { - let mut url = Url::parse(&format!("{KLIPY_DIRECT_BASE_URL}/"))?; + let mut url = Url::parse(KLIPY_DIRECT_BASE_URL)?; { let mut segments = url .path_segments_mut() @@ -434,8 +458,9 @@ impl KlipyClient { segments .push(api_key) .push(klipy_resource(path.path_type)) - .push(&path.slug); + .push("items"); } + url.query_pairs_mut().append_pair("slugs", &path.slug); Ok(url) } @@ -495,14 +520,16 @@ impl KlipyClient { let mut media = BTreeMap::new(); let mut preferred = None; for size in SIZE_PREFERENCE { - let Some(bucket) = input.file.as_ref().and_then(|files| files.get(size)) else { + 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 { continue; }; - let Some(media_format) = self.to_media_format(entry) else { + let Some(media_format) = self.to_media_format(entry, None) else { continue; }; let public_key = public_format_key(size, format); @@ -512,12 +539,32 @@ impl KlipyClient { } } } + 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 { continue; }; - let Some(media_format) = self.to_media_format(entry) else { + let Some(media_format) = self.to_media_format(entry, None) else { continue; }; media @@ -534,7 +581,7 @@ impl KlipyClient { { continue; } - let Some(media_format) = self.to_media_format(entry) else { + let Some(media_format) = self.to_media_format(entry, None) else { continue; }; media @@ -548,8 +595,12 @@ impl KlipyClient { (media, preferred) } - fn to_media_format(&self, entry: &KlipyMediaEntry) -> Option { - let (src, width, height) = entry.media_parts()?; + fn to_media_format( + &self, + entry: &KlipyMediaEntry, + meta: Option<&KlipyFileMeta>, + ) -> Option { + let (src, width, height) = entry.media_parts(meta)?; let proxy_src = self.media_proxy.external_proxy_url(src)?; Some(GifMediaFormat { src: src.to_owned(), @@ -561,11 +612,16 @@ impl KlipyClient { } impl KlipyMediaEntry { - fn media_parts(&self) -> Option<(&str, i32, i32)> { + fn media_parts(&self, meta: Option<&KlipyFileMeta>) -> Option<(&str, i32, i32)> { match self { KlipyMediaEntry::Url(url) => { - let _ = url; - None + let src = url.trim(); + if src.is_empty() { + return None; + } + let meta = meta?; + let (width, height) = valid_dimensions(meta.width?, meta.height?)?; + Some((src, width, height)) } KlipyMediaEntry::Object { url, @@ -744,6 +800,174 @@ mod tests { ); } + #[test] + fn direct_url_targets_the_items_endpoint() { + let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + "https://media.example.test", + "secret", + )) + .expect("client"); + let url = client + .create_direct_url( + "secret/key", + &KlipyPath { + path_type: KlipyPathType::Gif, + slug: "walter blame government-1".to_owned(), + }, + ) + .expect("direct URL"); + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/secret%2Fkey/gifs/items?slugs=walter+blame+government-1" + ); + let clip_url = client + .create_direct_url( + "key", + &KlipyPath { + path_type: KlipyPathType::Clip, + slug: "kittens".to_owned(), + }, + ) + .expect("direct URL"); + assert_eq!( + clip_url.as_str(), + "https://api.klipy.com/api/v1/key/clips/items?slugs=kittens" + ); + } + + #[test] + fn transforms_direct_gif_item_sizes() { + let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + "https://media.example.test", + "secret", + )) + .expect("client"); + let input = serde_json::from_value::(serde_json::json!({ + "id": 5441109273429299_i64, + "slug": "walter-blame-government-1", + "title": "Walter blame government", + "type": "gif", + "file": { + "hd": { + "gif": {"url": "https://static.klipy.com/hd.gif", "width": 498, "height": 420, "size": 1}, + "webp": {"url": "https://static.klipy.com/hd.webp", "width": 498, "height": 420, "size": 1}, + "webm": {"url": "https://static.klipy.com/hd.webm", "width": 498, "height": 420, "size": 1} + }, + "sm": { + "webp": {"url": "https://static.klipy.com/sm.webp", "width": 165, "height": 139, "size": 1} + } + } + })) + .expect("fixture"); + + let gif = client + .transform_gif_with_path( + input, + Some(&KlipyPath { + path_type: KlipyPathType::Gif, + slug: "walter-blame-government-1".to_owned(), + }), + ) + .expect("transformed gif"); + + assert_eq!(gif.id, "walter-blame-government-1"); + assert_eq!(gif.url, "https://klipy.com/gifs/walter-blame-government-1"); + assert_eq!(gif.src, "https://static.klipy.com/hd.webm"); + assert_eq!((gif.width, gif.height), (498, 420)); + assert_eq!( + gif.media.get("webp").map(|format| format.src.as_str()), + Some("https://static.klipy.com/hd.webp") + ); + assert_eq!( + gif.media + .get("tinywebp") + .map(|format| (format.width, format.height)), + Some((165, 139)) + ); + } + + #[test] + fn transforms_direct_clip_item_using_file_meta_dimensions() { + let client = KlipyClient::new(MediaProxyUrlBuilder::for_test( + "https://media.example.test", + "secret", + )) + .expect("client"); + let input = serde_json::from_value::(serde_json::json!({ + "id": 6641306069493365_i64, + "url": "https://klipy.com/clips/kittens", + "slug": "kittens", + "title": "Kittens", + "type": "clip", + "file": { + "mp4": "https://static.klipy.com/clip.mp4", + "gif": "https://static.klipy.com/clip.gif", + "webp": "https://static.klipy.com/clip.webp" + }, + "file_meta": { + "mp4": {"width": 854, "height": 480, "size": 924555}, + "gif": {"width": 320, "height": 180, "size": 4117532}, + "webp": {"width": 320, "height": 180, "size": 625686} + } + })) + .expect("fixture"); + + let clip = client + .transform_gif_with_path( + input, + Some(&KlipyPath { + path_type: KlipyPathType::Clip, + slug: "kittens".to_owned(), + }), + ) + .expect("transformed clip"); + + assert_eq!(clip.id, "kittens"); + assert_eq!(clip.url, "https://klipy.com/clips/kittens"); + assert_eq!(clip.src, "https://static.klipy.com/clip.mp4"); + assert_eq!((clip.width, clip.height), (854, 480)); + assert_eq!( + clip.media + .get("webp") + .map(|format| (format.src.as_str(), format.width, format.height)), + Some(("https://static.klipy.com/clip.webp", 320, 180)) + ); + assert!( + clip.media + .get("mp4") + .expect("mp4 format") + .proxy_src + .starts_with("https://media.example.test/external/") + ); + } + + #[test] + fn direct_response_unwraps_the_items_array() { + let response = serde_json::from_value::(serde_json::json!({ + "result": true, + "data": { + "data": [{ + "id": 1_i64, + "slug": "kittens", + "title": "Kittens", + "file": {"mp4": "https://static.klipy.com/clip.mp4"} + }], + "meta": {} + } + })) + .expect("fixture"); + let items = response.data.expect("data"); + assert_eq!(items.data.len(), 1); + assert_eq!(items.data[0].slug.as_deref(), Some("kittens")); + + let empty = serde_json::from_value::(serde_json::json!({ + "result": true, + "data": {"data": [], "meta": {}} + })) + .expect("fixture"); + assert!(empty.data.expect("data").data.is_empty()); + } + #[test] fn build_share_url_uses_gifs_path() { assert_eq!(build_share_url("hello"), "https://klipy.com/gifs/hello"); diff --git a/fluxer_unfurl/src/resolvers/klipy.rs b/fluxer_unfurl/src/resolvers/klipy.rs index d6055a17c..b7d7aa22e 100644 --- a/fluxer_unfurl/src/resolvers/klipy.rs +++ b/fluxer_unfurl/src/resolvers/klipy.rs @@ -172,21 +172,27 @@ async fn resolve_media_via_api( } let payload: serde_json::Value = serde_json::from_slice(&response.bytes)?; - Ok(payload.get("data").and_then(extract_klipy_api_media)) + Ok(payload + .pointer("/data/data/0") + .and_then(extract_klipy_api_media)) } fn klipy_direct_url(api_key: &str, resource: &str, slug: &str) -> anyhow::Result { - Ok(Url::parse(&format!( - "{KLIPY_API_V1_BASE_URL}/{api_key}/{resource}/{slug}" - ))?) + let mut url = Url::parse(KLIPY_API_V1_BASE_URL)?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("KLIPY API base URL cannot be a base"))? + .push(api_key) + .push(resource) + .push("items"); + url.query_pairs_mut().append_pair("slugs", slug); + Ok(url) } fn extract_klipy_api_media(item: &serde_json::Value) -> Option { - let file = item.get("file"); - let thumbnail = file.and_then(|file| pick_klipy_file_format(file, KLIPY_THUMBNAIL_FORMATS)); - let video = file - .and_then(|file| pick_klipy_file_format(file, KLIPY_VIDEO_FORMATS)) - .or_else(|| extract_klipy_fallback_webm(item.pointer("/media_formats/webm"))); + let file = item.get("file")?; + let file_meta = item.get("file_meta"); + let thumbnail = pick_klipy_file_format(file, file_meta, KLIPY_THUMBNAIL_FORMATS); + let video = pick_klipy_file_format(file, file_meta, KLIPY_VIDEO_FORMATS); if thumbnail.is_none() && video.is_none() { return None; @@ -194,7 +200,11 @@ fn extract_klipy_api_media(item: &serde_json::Value) -> Option Option { +fn pick_klipy_file_format( + file: &serde_json::Value, + file_meta: Option<&serde_json::Value>, + formats: &[&str], +) -> Option { for size in KLIPY_SIZE_PREFERENCE { for media_format in formats { if let Some(media) = @@ -205,35 +215,30 @@ fn pick_klipy_file_format(file: &serde_json::Value, formats: &[&str]) -> Option< } } for media_format in formats { - if let Some(media) = extract_media_format(file.get(*media_format)) { - return Some(media); + let Some(mut media) = extract_media_format(file.get(*media_format)) else { + continue; + }; + if media.width.is_none() { + media.width = klipy_meta_dimension(file_meta, media_format, "width"); } + if media.height.is_none() { + media.height = klipy_meta_dimension(file_meta, media_format, "height"); + } + return Some(media); } None } -fn extract_klipy_fallback_webm(value: Option<&serde_json::Value>) -> Option { - let value = value?; - let url = value - .get("url") - .and_then(|v| v.as_str()) - .filter(|url| !url.is_empty())?; - let dims = value.get("dims")?.as_array()?; - let width = dims - .first() - .and_then(|value| value.as_i64()) +fn klipy_meta_dimension( + file_meta: Option<&serde_json::Value>, + media_format: &str, + key: &str, +) -> Option { + file_meta? + .pointer(&format!("/{media_format}/{key}")) + .and_then(serde_json::Value::as_u64) .filter(|value| *value > 0) - .and_then(|value| u32::try_from(value).ok()); - let height = dims - .get(1) - .and_then(|value| value.as_i64()) - .filter(|value| *value > 0) - .and_then(|value| u32::try_from(value).ok()); - Some(KlipyMediaFormat { - url: Some(url.to_owned()), - width, - height, - }) + .and_then(|value| u32::try_from(value).ok()) } fn resolve_relative_url(base_url: &Url, media_url: &str) -> Option { @@ -404,7 +409,7 @@ mod tests { "webp": {"url": "https://img.klipy.com/sm.webp", "width": 165, "height": 294} } }); - let thumbnail = pick_klipy_file_format(&file, KLIPY_THUMBNAIL_FORMATS).unwrap(); + let thumbnail = pick_klipy_file_format(&file, None, KLIPY_THUMBNAIL_FORMATS).unwrap(); assert_eq!( thumbnail.url.as_deref(), Some("https://img.klipy.com/hd.webp") @@ -412,7 +417,7 @@ mod tests { assert_eq!(thumbnail.width, Some(254)); assert_eq!(thumbnail.height, Some(450)); assert_eq!( - pick_klipy_file_format(&file, KLIPY_VIDEO_FORMATS) + pick_klipy_file_format(&file, None, KLIPY_VIDEO_FORMATS) .unwrap() .url .as_deref(), @@ -427,14 +432,14 @@ mod tests { "gif": "https://img.klipy.com/c.gif", "webp": "https://img.klipy.com/c.webp" }); - let thumbnail = pick_klipy_file_format(&file, KLIPY_THUMBNAIL_FORMATS).unwrap(); + let thumbnail = pick_klipy_file_format(&file, None, KLIPY_THUMBNAIL_FORMATS).unwrap(); assert_eq!( thumbnail.url.as_deref(), Some("https://img.klipy.com/c.webp") ); assert_eq!(thumbnail.width, None); assert_eq!( - pick_klipy_file_format(&file, KLIPY_VIDEO_FORMATS) + pick_klipy_file_format(&file, None, KLIPY_VIDEO_FORMATS) .unwrap() .url .as_deref(), @@ -443,24 +448,66 @@ mod tests { } #[test] - fn extract_klipy_api_media_uses_fallback_webm_shape() { + fn klipy_direct_url_targets_the_items_endpoint() { + let url = klipy_direct_url("secret/key", "gifs", "walter blame government-1").unwrap(); + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/secret%2Fkey/gifs/items?slugs=walter+blame+government-1" + ); + } + + #[test] + fn extract_klipy_api_media_reads_the_gif_item_shape() { let item = serde_json::json!({ - "media_formats": { - "webm": { - "url": "https://img.klipy.com/fallback.webm", - "dims": [320, 180] + "slug": "walter-blame-government-1", + "file": { + "hd": { + "gif": {"url": "https://img.klipy.com/hd.gif", "width": 498, "height": 420}, + "webp": {"url": "https://img.klipy.com/hd.webp", "width": 498, "height": 420}, + "webm": {"url": "https://img.klipy.com/hd.webm", "width": 498, "height": 420} } } }); let media = extract_klipy_api_media(&item).unwrap(); - let video = media.video.unwrap(); + let thumbnail = media.thumbnail.unwrap(); assert_eq!( - video.url.as_deref(), - Some("https://img.klipy.com/fallback.webm") + thumbnail.url.as_deref(), + Some("https://img.klipy.com/hd.webp") ); - assert_eq!(video.width, Some(320)); - assert_eq!(video.height, Some(180)); - assert!(media.thumbnail.is_none()); + assert_eq!(thumbnail.width, Some(498)); + assert_eq!(thumbnail.height, Some(420)); + let video = media.video.unwrap(); + assert_eq!(video.url.as_deref(), Some("https://img.klipy.com/hd.webm")); + assert_eq!(extract_klipy_api_media(&serde_json::json!({})), None); + } + + #[test] + fn extract_klipy_api_media_reads_clip_dimensions_from_file_meta() { + let item = serde_json::json!({ + "slug": "kittens", + "file": { + "mp4": "https://img.klipy.com/clip.mp4", + "gif": "https://img.klipy.com/clip.gif", + "webp": "https://img.klipy.com/clip.webp" + }, + "file_meta": { + "mp4": {"width": 854, "height": 480, "size": 924555}, + "gif": {"width": 320, "height": 180, "size": 4117532}, + "webp": {"width": 320, "height": 180, "size": 625686} + } + }); + let media = extract_klipy_api_media(&item).unwrap(); + let thumbnail = media.thumbnail.unwrap(); + assert_eq!( + thumbnail.url.as_deref(), + Some("https://img.klipy.com/clip.webp") + ); + assert_eq!(thumbnail.width, Some(320)); + assert_eq!(thumbnail.height, Some(180)); + let video = media.video.unwrap(); + assert_eq!(video.url.as_deref(), Some("https://img.klipy.com/clip.mp4")); + assert_eq!(video.width, Some(854)); + assert_eq!(video.height, Some(480)); } #[test]