diff --git a/src/external_sync.rs b/src/external_sync.rs index c463939..f1eb290 100644 --- a/src/external_sync.rs +++ b/src/external_sync.rs @@ -434,21 +434,28 @@ pub(crate) fn trakt_bearer(token: &str) -> String { format!("Bearer {token}") } -pub(crate) fn trakt_scrobble_url(action: &str) -> String { - format!("{TRAKT_API_BASE_URL}/scrobble/{action}") +pub(crate) fn trakt_scrobble_url(action: &str) -> Option { + matches!(action.trim(), "start" | "pause" | "stop") + .then(|| format!("{TRAKT_API_BASE_URL}/scrobble/{}", action.trim())) } -pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> String { - match content_type.filter(|value| !value.trim().is_empty()) { - Some(content_type) => format!("{TRAKT_API_BASE_URL}/sync/playback/{content_type}"), +pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> Option { + let suffix = match content_type.map(str::trim).filter(|value| !value.is_empty()) { + None => None, + Some("movie" | "movies") => Some("movies"), + Some("series" | "show" | "shows" | "episode" | "episodes") => Some("episodes"), + Some(_) => return None, + }; + Some(match suffix { + Some(suffix) => format!("{TRAKT_API_BASE_URL}/sync/playback/{suffix}"), None => format!("{TRAKT_API_BASE_URL}/sync/playback"), - } + }) } pub(crate) fn trakt_token_expires_at(created_at_seconds: i64, expires_in_seconds: i64) -> i64 { let refresh_buffer_seconds = 5 * 60; let effective_expires_in = (expires_in_seconds - refresh_buffer_seconds).max(0); - (created_at_seconds * 1000) + (effective_expires_in * 1000) + created_at_seconds + effective_expires_in } fn number_to_i32(value: &Value) -> Option { @@ -695,14 +702,9 @@ fn trakt_playback_item_to_library(item: &Value) -> Option { let time_offset_sec = ((progress / 100.0) * duration_sec as f64).round() as i64; let content_type = if movie.is_some() { "movie" } else { "series" }; let last_video_id = if let Some(ep) = episode { - let show_imdb = show - .and_then(|s| s.get("ids")) - .and_then(|ids| ids.get("imdb")) - .and_then(Value::as_str) - .unwrap_or("trakt"); let season = ep.get("season").and_then(Value::as_i64).unwrap_or(0); let number = ep.get("number").and_then(Value::as_i64).unwrap_or(0); - format!("{show_imdb}:{season}:{number}") + format!("{id}:{season}:{number}") } else { id.clone() }; @@ -733,14 +735,22 @@ pub(crate) fn trakt_watchlist_to_items_json(movies_json: &str, shows_json: &str) let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); let mut items: Vec = Vec::new(); for entry in &movies { - let movie = entry.get("movie")?; - let id = trakt_id_from_source(movie)?; + let Some(movie) = entry.get("movie") else { + continue; + }; + let Some(id) = trakt_id_from_source(movie) else { + continue; + }; let name = movie.get("title").and_then(Value::as_str).unwrap_or(""); items.push(json!({ "id": id, "name": name, "type": "movie", "source": "trakt" })); } for entry in &shows { - let show = entry.get("show")?; - let id = trakt_id_from_source(show)?; + let Some(show) = entry.get("show") else { + continue; + }; + let Some(id) = trakt_id_from_source(show) else { + continue; + }; let name = show.get("title").and_then(Value::as_str).unwrap_or(""); items.push(json!({ "id": id, "name": name, "type": "series", "source": "trakt" })); } @@ -752,25 +762,13 @@ pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) -> let shows: Vec = serde_json::from_str(shows_json).unwrap_or_default(); let mut ids: serde_json::Map = serde_json::Map::new(); for entry in &movies { - if let Some(imdb) = entry - .get("movie") - .and_then(|m| m.get("ids")) - .and_then(|ids| ids.get("imdb")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - ids.insert(imdb.to_string(), Value::Bool(true)); + if let Some(id) = entry.get("movie").and_then(trakt_id_from_source) { + ids.insert(id, Value::Bool(true)); } } for entry in &shows { - let imdb = match entry - .get("show") - .and_then(|s| s.get("ids")) - .and_then(|ids| ids.get("imdb")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - Some(s) => s, + let show_id = match entry.get("show").and_then(trakt_id_from_source) { + Some(id) => id, None => continue, }; let seasons = entry @@ -788,7 +786,7 @@ pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) -> for ep in &episodes { let e_num = ep.get("number").and_then(Value::as_i64).unwrap_or(0); if s_num > 0 && e_num > 0 { - ids.insert(format!("{imdb}:{s_num}:{e_num}"), Value::Bool(true)); + ids.insert(format!("{show_id}:{s_num}:{e_num}"), Value::Bool(true)); } } } @@ -1050,6 +1048,8 @@ pub(crate) fn merge_continue_watching_lists_json( } } + merged.sort_by(|a, b| saved_at_ms(b).cmp(&saved_at_ms(a))); + serde_json::to_string(&merged).ok() } @@ -1058,12 +1058,17 @@ pub(crate) fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str) let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); let mut items: Vec = Vec::new(); for entry in &shows { - let show = entry.get("show")?; - let ids = show.get("ids")?; - let imdb = ids + let Some(show) = entry.get("show") else { + continue; + }; + let Some(ids) = show.get("ids") else { continue }; + let Some(imdb) = ids .get("imdb") .and_then(Value::as_str) - .filter(|s| !s.is_empty())?; + .filter(|s| !s.is_empty()) + else { + continue; + }; let title = show.get("title").and_then(Value::as_str).unwrap_or(""); let poster = show .get("poster") @@ -1080,12 +1085,19 @@ pub(crate) fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str) })); } for entry in &movies { - let movie = entry.get("movie")?; - let ids = movie.get("ids")?; - let imdb = ids + let Some(movie) = entry.get("movie") else { + continue; + }; + let Some(ids) = movie.get("ids") else { + continue; + }; + let Some(imdb) = ids .get("imdb") .and_then(Value::as_str) - .filter(|s| !s.is_empty())?; + .filter(|s| !s.is_empty()) + else { + continue; + }; let title = movie.get("title").and_then(Value::as_str).unwrap_or(""); let poster = movie .get("poster") @@ -1108,12 +1120,17 @@ pub(crate) fn simkl_watchlist_to_items_json(shows_json: &str, movies_json: &str) let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); let mut items: Vec = Vec::new(); for entry in &shows { - let show = entry.get("show")?; - let ids = show.get("ids")?; - let imdb = ids + let Some(show) = entry.get("show") else { + continue; + }; + let Some(ids) = show.get("ids") else { continue }; + let Some(imdb) = ids .get("imdb") .and_then(Value::as_str) - .filter(|s| !s.is_empty())?; + .filter(|s| !s.is_empty()) + else { + continue; + }; let title = show.get("title").and_then(Value::as_str).unwrap_or(""); let poster = show .get("poster") @@ -1122,12 +1139,19 @@ pub(crate) fn simkl_watchlist_to_items_json(shows_json: &str, movies_json: &str) items.push(json!({ "id": imdb, "name": title, "type": "series", "source": "simkl", "poster": poster })); } for entry in &movies { - let movie = entry.get("movie")?; - let ids = movie.get("ids")?; - let imdb = ids + let Some(movie) = entry.get("movie") else { + continue; + }; + let Some(ids) = movie.get("ids") else { + continue; + }; + let Some(imdb) = ids .get("imdb") .and_then(Value::as_str) - .filter(|s| !s.is_empty())?; + .filter(|s| !s.is_empty()) + else { + continue; + }; let title = movie.get("title").and_then(Value::as_str).unwrap_or(""); let poster = movie .get("poster") @@ -1143,25 +1167,13 @@ pub(crate) fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) -> let movies: Vec = serde_json::from_str(movies_json).unwrap_or_default(); let mut ids: serde_json::Map = serde_json::Map::new(); for entry in &shows { - if let Some(imdb) = entry - .get("show") - .and_then(|s| s.get("ids")) - .and_then(|i| i.get("imdb")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - ids.insert(imdb.to_string(), Value::Bool(true)); + if let Some(id) = entry.get("show").and_then(trakt_id_from_source) { + ids.insert(id, Value::Bool(true)); } } for entry in &movies { - if let Some(imdb) = entry - .get("movie") - .and_then(|m| m.get("ids")) - .and_then(|i| i.get("imdb")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - ids.insert(imdb.to_string(), Value::Bool(true)); + if let Some(id) = entry.get("movie").and_then(trakt_id_from_source) { + ids.insert(id, Value::Bool(true)); } } serde_json::to_string(&Value::Object(ids)).ok() @@ -1945,6 +1957,7 @@ pub(crate) fn merge_library_items_by_id(local: &[Value], incoming: &[Value]) -> #[cfg(test)] mod tests { use super::*; + use crate::player_scrobble; use serde_json::Value; #[test] @@ -1960,6 +1973,28 @@ mod tests { assert_eq!(ids, vec!["tt2", "tt3", "tt1"]); } + #[test] + fn merge_continue_watching_sorts_the_combined_result_by_saved_at_descending() { + let local = json!([ + {"id": "tt1", "savedAt": "2026-07-16T16:18:15Z"}, + {"id": "tt3", "savedAt": "2026-07-17T19:11:51Z"}, + ]); + let external = json!([ + {"id": "tt2", "savedAt": "2026-07-18T22:15:23Z"}, + ]); + let result = merge_continue_watching_lists_json( + &local.to_string(), + &external.to_string(), + "{}", + None, + None, + ) + .unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + let ids: Vec<&str> = parsed.iter().map(|v| v["id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["tt2", "tt3", "tt1"]); + } + #[test] fn extracts_anilist_id_from_link_url() { let meta = json!({"links": [{"name": "AniList", "category": "other", "url": "https://anilist.co/anime/1535"}]}); @@ -2108,6 +2143,147 @@ mod tests { ); } + #[test] + fn trakt_token_expiry_stays_in_epoch_seconds() { + assert_eq!(trakt_token_expires_at(1_700_000_000, 3_600), 1_700_003_300); + } + + #[test] + fn trakt_urls_accept_only_supported_routes() { + assert_eq!( + trakt_scrobble_url("pause").as_deref(), + Some("https://api.trakt.tv/scrobble/pause") + ); + assert_eq!(trakt_scrobble_url("delete"), None); + assert_eq!( + trakt_playback_url(Some("series")).as_deref(), + Some("https://api.trakt.tv/sync/playback/episodes") + ); + assert_eq!(trakt_playback_url(Some("unknown")), None); + } + + #[test] + fn external_sync_wire_fixtures_preserve_provider_contracts() { + let trakt_input: Value = serde_json::from_str(include_str!( + "../tests/fixtures/external_sync/trakt_scrobble_plan_input.json" + )) + .unwrap(); + let trakt_expected: Value = serde_json::from_str(include_str!( + "../tests/fixtures/external_sync/trakt_scrobble_plan_expected.json" + )) + .unwrap(); + let trakt_actual: Value = serde_json::from_str( + &player_scrobble::trakt_scrobble_plan_json( + &trakt_input["ids"].to_string(), + trakt_input["isEpisode"].as_bool().unwrap(), + None, + None, + trakt_input["timePosSec"].as_f64().unwrap(), + trakt_input["durationSec"].as_f64().unwrap(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(trakt_actual, trakt_expected); + + let simkl_input = include_str!("../tests/fixtures/external_sync/simkl_mark_watched_input.json"); + let simkl_expected: Value = serde_json::from_str(include_str!( + "../tests/fixtures/external_sync/simkl_mark_watched_expected.json" + )) + .unwrap(); + let simkl_actual: Value = serde_json::from_str( + &simkl_mark_watched_body_json(simkl_input).unwrap(), + ) + .unwrap(); + assert_eq!(simkl_actual, simkl_expected); + + let trakt_playback_expected: Value = serde_json::from_str(include_str!( + "../tests/fixtures/external_sync/trakt_playback_expected.json" + )) + .unwrap(); + let trakt_playback_actual: Value = serde_json::from_str( + &trakt_playback_items_to_library_json(include_str!( + "../tests/fixtures/external_sync/trakt_playback_response.json" + )) + .unwrap(), + ) + .unwrap(); + assert_eq!(trakt_playback_actual, trakt_playback_expected); + + let simkl_response: Value = serde_json::from_str(include_str!( + "../tests/fixtures/external_sync/simkl_watched_response.json" + )) + .unwrap(); + let simkl_watched_expected: Value = serde_json::from_str(include_str!( + "../tests/fixtures/external_sync/simkl_watched_expected.json" + )) + .unwrap(); + let simkl_watched_actual: Value = serde_json::from_str( + &simkl_watched_to_ids_json( + &simkl_response["shows"].to_string(), + &simkl_response["movies"].to_string(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(simkl_watched_actual, simkl_watched_expected); + } + + #[test] + fn trakt_playback_tmdb_show_keeps_a_resolvable_episode_id() { + let item = json!({ + "progress": 50.0, + "paused_at": "2026-07-21T00:00:00.000Z", + "show": {"title": "Show", "runtime": 45, "ids": {"tmdb": 42}}, + "episode": {"season": 1, "number": 2, "runtime": 45} + }); + let result = trakt_playback_item_to_library(&item).expect("playback item"); + assert_eq!(result["id"], "tmdb:42"); + assert_eq!(result["lastVideoId"], "tmdb:42:1:2"); + } + + #[test] + fn external_list_mappers_skip_invalid_records_and_keep_valid_ones() { + let trakt: Vec = serde_json::from_str( + &trakt_watchlist_to_items_json( + r#"[{"movie":{"title":"Valid","ids":{"tmdb":7}}},{"movie":{"title":"Invalid","ids":{}}}]"#, + "[]", + ) + .expect("trakt items"), + ) + .unwrap(); + assert_eq!(trakt.len(), 1); + assert_eq!(trakt[0]["id"], "tmdb:7"); + + let simkl: Vec = serde_json::from_str( + &simkl_watchlist_to_items_json( + r#"[{"show":{"title":"Valid","ids":{"imdb":"tt7"}}},{"show":{"title":"Invalid","ids":{}}}]"#, + "[]", + ) + .expect("simkl items"), + ) + .unwrap(); + assert_eq!(simkl.len(), 1); + assert_eq!(simkl[0]["id"], "tt7"); + } + + #[test] + fn watched_mappers_retain_tmdb_only_records() { + let trakt: Value = serde_json::from_str( + &trakt_watched_to_ids_json(r#"[{"movie":{"ids":{"tmdb":7}}}]"#, "[]") + .expect("trakt watched"), + ) + .unwrap(); + assert_eq!(trakt["tmdb:7"], Value::Bool(true)); + + let simkl: Value = serde_json::from_str( + &simkl_watched_to_ids_json("[]", r#"[{"movie":{"ids":{"tmdb":8}}}]"#) + .expect("simkl watched"), + ) + .unwrap(); + assert_eq!(simkl["tmdb:8"], Value::Bool(true)); + } + #[test] fn history_request_builds_show_seasons_from_episode_ids() { let request = trakt_history_request_json( diff --git a/src/ffi.rs b/src/ffi.rs index 482479e..911626b 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -7,8 +7,8 @@ use crate::{ content_identity, core_contract, data_policy, discovery_plan, external_sync, headless_adapter_plan, headless_engine, home_ranking, intro_segments, library_state, nuvio_sync, offline_download, platform_plan, player_flow, player_policy, player_scrobble, - plugins, profile_contract, profile_prefs, repository_flow, search_plan, stream_policy, - tmdb_plan, trailer_subtitles, watchlist_plan, + plugins, profile_avatar_pack, profile_contract, profile_prefs, repository_flow, search_plan, + stream_policy, tmdb_plan, trailer_subtitles, watchlist_plan, }; #[derive(Clone, Copy, PartialEq, Eq)] @@ -93,6 +93,7 @@ const ROUTERS: &[fn(&str, &str) -> Outcome] = &[ route_core_contract, route_plugins, route_addon_store, + route_profile_avatar_pack, route_profile_contract, route_profile_prefs, route_headless_adapter_plan, @@ -1000,15 +1001,13 @@ fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome { "traktBearer" => Ok(Value::String(external_sync::trakt_bearer(&arg_str( args_json, "token", )?))), - "traktScrobbleUrl" => Ok(Value::String(external_sync::trakt_scrobble_url(&arg_str( + "traktScrobbleUrl" => opt_str(external_sync::trakt_scrobble_url(&arg_str( args_json, "action", - )?))), + )?)), "traktPlaybackUrl" => { let args = object(args_json)?; let content_type = args.get("contentType").and_then(Value::as_str); - Ok(Value::String(external_sync::trakt_playback_url( - content_type, - ))) + opt_str(external_sync::trakt_playback_url(content_type)) } "traktTokenExpiresAt" => { let args = object(args_json)?; @@ -1867,6 +1866,30 @@ fn route_addon_store(method: &str, args_json: &str) -> Outcome { } } +fn route_profile_avatar_pack(method: &str, args_json: &str) -> Outcome { + match method { + // args_json IS the request object for all of these. The platform owns + // the HTTP calls between plans; this crate only validates and maps the + // GitHub responses into the stable UI contract. + "profileAvatarPackRepositoryPlan" => { + opt_json(profile_avatar_pack::profile_avatar_pack_repository_plan_json(args_json)) + } + "profileAvatarPackDiscoveryPlan" => { + opt_json(profile_avatar_pack::profile_avatar_pack_discovery_plan_json(args_json)) + } + "profileAvatarPackCatalog" => { + opt_json(profile_avatar_pack::profile_avatar_pack_catalog_json(args_json)) + } + "profileAvatarPackParse" => { + opt_json(profile_avatar_pack::profile_avatar_pack_json(args_json)) + } + _ => Err(fail( + ErrorKind::UnknownMethod, + format!("no such method `{method}`"), + )), + } +} + fn route_profile_contract(method: &str, args_json: &str) -> Outcome { match method { // args_json IS the request object for all of these @@ -2430,7 +2453,7 @@ mod tests { "traktTokenExpiresAt", r#"{"createdAtSeconds":1000,"expiresInSeconds":7200}"#, )); - assert_eq!(expires_at["value"], json!((1000 * 1000) + (6900 * 1000))); + assert_eq!(expires_at["value"], json!(1000 + 6900)); let show_id = parse(&core_invoke( "traktShowIdFromEpisodeId", diff --git a/src/headless_engine/contracts.rs b/src/headless_engine/contracts.rs index 1682cbd..a4fdd08 100644 --- a/src/headless_engine/contracts.rs +++ b/src/headless_engine/contracts.rs @@ -321,10 +321,7 @@ pub(super) enum AppAction { #[serde(rename = "pluginScraperToggled")] PluginScraperToggled { scraper_id: String, enabled: bool }, #[serde(rename = "pluginScraperSettingsUpdated")] - PluginScraperSettingsUpdated { - scraper_id: String, - settings: Value, - }, + PluginScraperSettingsUpdated { scraper_id: String, settings: Value }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] diff --git a/src/headless_engine/helpers.rs b/src/headless_engine/helpers.rs index 4a94f38..1fc41fa 100644 --- a/src/headless_engine/helpers.rs +++ b/src/headless_engine/helpers.rs @@ -80,7 +80,7 @@ pub(super) fn with_normalized_meta_trailers(mut meta: Value) -> Value { } pub(super) fn normalize_meta_trailers(meta: &Value) -> Value { - let trailers = meta["trailers"] + let mut trailers = meta["trailers"] .as_array() .map(|items| { items @@ -89,6 +89,23 @@ pub(super) fn normalize_meta_trailers(meta: &Value) -> Value { .collect::>() }) .unwrap_or_default(); + // Trailerio and a few Stremio-compatible addons expose direct trailer + // streams as `links: [{ trailers, provider }]` rather than the usual + // `trailers` array. Preserve native trailer entries first, then append + // these URLs as additional sources without duplicating a URL. + if let Some(links) = meta["links"].as_array() { + for link in links { + let Some(trailer) = normalize_meta_link_trailer(link) else { + continue; + }; + let duplicate = trailers + .iter() + .any(|existing| existing["url"] == trailer["url"]); + if !duplicate { + trailers.push(trailer); + } + } + } Value::Array(trailers) } @@ -126,6 +143,24 @@ fn normalize_meta_trailer(trailer: &Value) -> Option { .ok() } +fn normalize_meta_link_trailer(link: &Value) -> Option { + let url = non_blank_str(link, "trailers")?; + let item_type = non_blank_str(link, "type").unwrap_or_else(|| "Trailer".to_string()); + let title = non_blank_str(link, "provider") + .or_else(|| non_blank_str(link, "name")) + .or_else(|| non_blank_str(link, "title")) + .unwrap_or_else(|| item_type.clone()); + serde_json::to_value(NormalizedTrailer { + id: url.clone(), + title, + item_type, + url, + thumbnail: non_blank_str(link, "thumbnail"), + source: "addon", + }) + .ok() +} + pub(super) fn non_blank_str(value: &Value, key: &str) -> Option { value[key] .as_str() diff --git a/src/headless_engine/mod.rs b/src/headless_engine/mod.rs index 495ce68..018891a 100644 --- a/src/headless_engine/mod.rs +++ b/src/headless_engine/mod.rs @@ -847,6 +847,64 @@ mod tests { assert!(destroy_headless_engine(handle)); } + #[test] + fn detail_meta_link_trailers_become_direct_playback_sources() { + let handle = create_headless_engine("{}"); + let requested: Value = serde_json::from_str( + &headless_engine_dispatch_json( + handle, + r#"{"type":"detailLoadRequested","contentType":"series","id":"tt0944947","language":"en"}"#, + ) + .expect("dispatch"), + ) + .expect("json"); + let effect_id = requested["effects"][0]["id"].as_str().unwrap(); + let completed: Value = serde_json::from_str( + &headless_engine_complete_effect_json( + handle, + &json!({ + "effectId": effect_id, + "status": "ok", + "value": { + "id": "tt0944947", + "name": "Game of Thrones", + "links": [ + { + "trailers": "https://video.fandango.com/trailer.mp4", + "provider": "Rotten Tomatoes 1080p" + }, + { + "trailers": "https://imdb-video.media-imdb.com/trailer.m3u8", + "provider": "IMDb SD" + } + ] + } + }) + .to_string(), + ) + .expect("complete"), + ) + .expect("json"); + + assert_eq!( + completed["state"]["detail"]["trailers"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + completed["state"]["detail"]["trailers"][0]["title"], + "Rotten Tomatoes 1080p" + ); + assert_eq!( + completed["state"]["detail"]["trailers"][1]["url"], + "https://imdb-video.media-imdb.com/trailer.m3u8" + ); + + assert!(destroy_headless_engine(handle)); + } + #[test] fn detail_selected_addon_changes_visible_streams_without_mutating_raw_streams() { let handle = create_headless_engine("{}"); diff --git a/src/headless_engine/plugins.rs b/src/headless_engine/plugins.rs index 45b5440..5d02635 100644 --- a/src/headless_engine/plugins.rs +++ b/src/headless_engine/plugins.rs @@ -151,10 +151,7 @@ pub(super) fn complete( for mut scraper in scrapers { scraper["repositoryUrl"] = Value::String(manifest_url.to_string()); let manifest_enabled = scraper["enabled"].as_bool().unwrap_or(true); - if let Some(previous) = scraper["id"] - .as_str() - .and_then(|id| previous_by_id.get(id)) - { + if let Some(previous) = scraper["id"].as_str().and_then(|id| previous_by_id.get(id)) { if manifest_enabled { if let Some(previous_enabled) = previous["enabled"].as_bool() { scraper["enabled"] = Value::Bool(previous_enabled); diff --git a/src/lib.rs b/src/lib.rs index 985e428..1f11baa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,6 +87,8 @@ pub mod plugin_runtime; #[cfg(any(feature = "full-api", not(feature = "streaming-shared")))] mod profile_contract; #[cfg(any(feature = "full-api", not(feature = "streaming-shared")))] +mod profile_avatar_pack; +#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))] mod profile_prefs; #[cfg(any(feature = "full-api", not(feature = "streaming-shared")))] mod repository_flow; diff --git a/src/profile_avatar_pack.rs b/src/profile_avatar_pack.rs new file mode 100644 index 0000000..fb428b6 --- /dev/null +++ b/src/profile_avatar_pack.rs @@ -0,0 +1,323 @@ +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashSet; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RepositoryPlanRequest { + repository_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DiscoveryPlanRequest { + repository_url: String, + repository: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CatalogRequest { + repository_url: String, + #[serde(default)] + reference: Option, + tree: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PackRequest { + manifest_url: String, + pack: Value, +} + +struct GitHubRepository { + owner: String, + name: String, +} + +/// Normalizes a GitHub repository pasted by a user and returns the first +/// platform-owned HTTP request needed to discover its default branch. +pub(crate) fn profile_avatar_pack_repository_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let repository = parse_repository_url(&request.repository_url)?; + serde_json::to_string(&json!({ + "owner": repository.owner, + "repository": repository.name, + "repositoryApiUrl": format!("https://api.github.com/repos/{}/{}", repository.owner, repository.name), + })) + .ok() +} + +/// Creates the recursive GitHub tree request after the platform has fetched +/// the repository metadata returned by `profileAvatarPackRepositoryPlan`. +pub(crate) fn profile_avatar_pack_discovery_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let repository = parse_repository_url(&request.repository_url)?; + let reference = request + .repository + .get("default_branch") + .or_else(|| request.repository.get("defaultBranch")) + .and_then(Value::as_str) + .filter(|value| valid_ref(value))? + .to_string(); + serde_json::to_string(&json!({ + "reference": reference, + "treeApiUrl": format!( + "https://api.github.com/repos/{}/{}/git/trees/{}?recursive=1", + repository.owner, + repository.name, + percent_encode(&reference), + ), + })) + .ok() +} + +/// Extracts every supported avatar-pack manifest from a GitHub recursive tree. +/// A pack is a `pack.json` or `json.pack` blob in any directory, allowing +/// repositories to group packs under arbitrary category paths. +pub(crate) fn profile_avatar_pack_catalog_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let repository = parse_repository_url(&request.repository_url)?; + let reference = request.reference.filter(|value| valid_ref(value))?; + if request.tree.get("truncated").and_then(Value::as_bool) == Some(true) { + return None; + } + let entries = request.tree.get("tree")?.as_array()?; + let mut categories = Vec::new(); + let mut seen_paths = HashSet::new(); + + for entry in entries { + if entry.get("type").and_then(Value::as_str) != Some("blob") { + continue; + } + let Some(path) = entry.get("path").and_then(Value::as_str) else { + continue; + }; + let Some((directory, filename)) = path.rsplit_once('/') else { + continue; + }; + if !matches!( + filename.to_ascii_lowercase().as_str(), + "pack.json" | "json.pack" + ) || directory.is_empty() + || !valid_path(path) + || !seen_paths.insert(path.to_string()) + { + continue; + } + let name = directory.rsplit('/').next().unwrap_or(directory); + categories.push(json!({ + "name": name, + "path": directory, + "manifestUrl": raw_file_url(&repository, &reference, path), + })); + } + + categories.sort_by(|left, right| { + left["path"] + .as_str() + .unwrap_or("") + .to_lowercase() + .cmp(&right["path"].as_str().unwrap_or("").to_lowercase()) + }); + serde_json::to_string(&json!({ + "owner": repository.owner, + "repository": repository.name, + "reference": reference, + "categories": categories, + })) + .ok() +} + +/// Validates a fetched pack document before image URLs reach a platform image +/// loader. The source repository is intentionally not restricted here: packs +/// may legitimately host images on a CDN, but only HTTPS image URLs are safe. +pub(crate) fn profile_avatar_pack_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + if !is_https_url(&request.manifest_url) { + return None; + } + let title = request + .pack + .get("title") + .or_else(|| request.pack.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let images = request.pack.get("images")?.as_array()?; + let mut avatars = Vec::new(); + let mut seen_urls = HashSet::new(); + for image in images { + let Some(url) = image.get("url").and_then(Value::as_str).map(str::trim) else { + continue; + }; + if !is_https_url(url) || !seen_urls.insert(url.to_string()) { + continue; + } + let name = image + .get("name") + .or_else(|| image.get("title")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Avatar"); + avatars.push(json!({"name": name, "url": url})); + } + serde_json::to_string(&json!({ + "title": title, + "manifestUrl": request.manifest_url, + "avatars": avatars, + })) + .ok() +} + +fn parse_repository_url(input: &str) -> Option { + let input = input.trim().trim_end_matches('/'); + let path = input + .strip_prefix("https://github.com/") + .or_else(|| input.strip_prefix("http://github.com/")) + .or_else(|| input.strip_prefix("github.com/")) + .unwrap_or(input); + let path = path.strip_suffix(".git").unwrap_or(path); + let mut parts = path.split('/'); + let owner = parts.next()?; + let name = parts.next()?; + if parts.next().is_some() || !valid_repository_part(owner) || !valid_repository_part(name) { + return None; + } + Some(GitHubRepository { + owner: owner.to_string(), + name: name.to_string(), + }) +} + +fn valid_repository_part(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn valid_ref(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= 255 + && !value.contains([' ', '\\', '?', '#']) + && !value + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") +} + +fn valid_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") +} + +fn raw_file_url(repository: &GitHubRepository, reference: &str, path: &str) -> String { + let path = path + .split('/') + .map(percent_encode) + .collect::>() + .join("/"); + format!( + "https://raw.githubusercontent.com/{}/{}/{}/{}", + repository.owner, + repository.name, + percent_encode(reference), + path, + ) +} + +fn percent_encode(value: &str) -> String { + value + .bytes() + .flat_map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + vec![byte as char] + } else { + format!("%{byte:02X}").chars().collect() + } + }) + .collect() +} + +fn is_https_url(value: &str) -> bool { + let value = value.trim(); + let Some(rest) = value.strip_prefix("https://") else { + return false; + }; + !rest.is_empty() && !rest.starts_with('/') && !rest.contains([' ', '\\', '\n', '\r']) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repository_plan_accepts_github_urls_only() { + let output: Value = serde_json::from_str( + &profile_avatar_pack_repository_plan_json( + r#"{"repositoryUrl":"https://github.com/eueueue292/Fusion-Profile-Avatars.git"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(output["owner"], "eueueue292"); + assert_eq!(output["repository"], "Fusion-Profile-Avatars"); + assert!(profile_avatar_pack_repository_plan_json( + r#"{"repositoryUrl":"https://github.com.evil/a/b"}"# + ) + .is_none()); + } + + #[test] + fn catalog_discovers_nested_packs_and_builds_raw_urls() { + let output: Value = serde_json::from_str( + &profile_avatar_pack_catalog_json( + r#"{ + "repositoryUrl":"eueueue292/Fusion-Profile-Avatars", + "reference":"main", + "tree":{"tree":[ + {"path":"Attack On Titan/pack.json","type":"blob"}, + {"path":"Disney+/Marvel/json.pack","type":"blob"}, + {"path":"README.md","type":"blob"} + ]} + }"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(output["categories"].as_array().unwrap().len(), 2); + assert_eq!(output["categories"][0]["path"], "Attack On Titan"); + assert_eq!(output["categories"][1]["name"], "Marvel"); + assert_eq!( + output["categories"][1]["manifestUrl"], + "https://raw.githubusercontent.com/eueueue292/Fusion-Profile-Avatars/main/Disney%2B/Marvel/json.pack" + ); + } + + #[test] + fn pack_parser_keeps_only_unique_https_avatars() { + let output: Value = serde_json::from_str( + &profile_avatar_pack_json( + r#"{ + "manifestUrl":"https://example.com/pack.json", + "pack":{"title":"Test pack","images":[ + {"name":"A","url":"https://images.example/a.png"}, + {"name":"Duplicate","url":"https://images.example/a.png"}, + {"name":"Unsafe","url":"file:///tmp/avatar.png"} + ]} + }"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(output["avatars"].as_array().unwrap().len(), 1); + assert_eq!(output["avatars"][0]["name"], "A"); + } +} diff --git a/tests/fixtures/external_sync/simkl_mark_watched_expected.json b/tests/fixtures/external_sync/simkl_mark_watched_expected.json new file mode 100644 index 0000000..b046d6e --- /dev/null +++ b/tests/fixtures/external_sync/simkl_mark_watched_expected.json @@ -0,0 +1,7 @@ +{ + "movies": [], + "shows": [{ + "ids": {"imdb": "tt0944947"}, + "seasons": [{"number": 1, "episodes": [{"number": 2}]}] + }] +} diff --git a/tests/fixtures/external_sync/simkl_mark_watched_input.json b/tests/fixtures/external_sync/simkl_mark_watched_input.json new file mode 100644 index 0000000..69e7aef --- /dev/null +++ b/tests/fixtures/external_sync/simkl_mark_watched_input.json @@ -0,0 +1,4 @@ +{ + "meta": {"type": "series"}, + "videoIds": ["tt0944947:1:2"] +} diff --git a/tests/fixtures/external_sync/simkl_watched_expected.json b/tests/fixtures/external_sync/simkl_watched_expected.json new file mode 100644 index 0000000..d35e302 --- /dev/null +++ b/tests/fixtures/external_sync/simkl_watched_expected.json @@ -0,0 +1,4 @@ +{ + "tt0944947": true, + "tmdb:27205": true +} diff --git a/tests/fixtures/external_sync/simkl_watched_response.json b/tests/fixtures/external_sync/simkl_watched_response.json new file mode 100644 index 0000000..42706c3 --- /dev/null +++ b/tests/fixtures/external_sync/simkl_watched_response.json @@ -0,0 +1,4 @@ +{ + "shows": [{"show": {"ids": {"imdb": "tt0944947"}}}], + "movies": [{"movie": {"ids": {"tmdb": 27205}}}] +} diff --git a/tests/fixtures/external_sync/trakt_playback_expected.json b/tests/fixtures/external_sync/trakt_playback_expected.json new file mode 100644 index 0000000..d28944a --- /dev/null +++ b/tests/fixtures/external_sync/trakt_playback_expected.json @@ -0,0 +1,15 @@ +[ + { + "id": "tt0944947", + "name": "Example Show", + "type": "series", + "timeOffset": 1350, + "duration": 2700, + "lastVideoId": "tt0944947:1:2", + "lastEpisodeName": "The Episode", + "lastEpisodeSeason": 1, + "lastEpisodeNumber": 2, + "savedAt": "2026-07-21T00:00:00.000Z", + "reason": "trakt" + } +] diff --git a/tests/fixtures/external_sync/trakt_playback_response.json b/tests/fixtures/external_sync/trakt_playback_response.json new file mode 100644 index 0000000..8ea0d35 --- /dev/null +++ b/tests/fixtures/external_sync/trakt_playback_response.json @@ -0,0 +1,8 @@ +[ + { + "progress": 50.0, + "paused_at": "2026-07-21T00:00:00.000Z", + "show": {"title": "Example Show", "runtime": 45, "ids": {"imdb": "tt0944947"}}, + "episode": {"season": 1, "number": 2, "title": "The Episode", "runtime": 45} + } +] diff --git a/tests/fixtures/external_sync/trakt_scrobble_plan_expected.json b/tests/fixtures/external_sync/trakt_scrobble_plan_expected.json new file mode 100644 index 0000000..78f64ad --- /dev/null +++ b/tests/fixtures/external_sync/trakt_scrobble_plan_expected.json @@ -0,0 +1,7 @@ +{ + "action": "pause", + "body": { + "movie": {"ids": {"imdb": "tt1375666"}}, + "progress": 50.0 + } +} diff --git a/tests/fixtures/external_sync/trakt_scrobble_plan_input.json b/tests/fixtures/external_sync/trakt_scrobble_plan_input.json new file mode 100644 index 0000000..7f95a50 --- /dev/null +++ b/tests/fixtures/external_sync/trakt_scrobble_plan_input.json @@ -0,0 +1,6 @@ +{ + "ids": {"imdb": "tt1375666"}, + "isEpisode": false, + "timePosSec": 450, + "durationSec": 900 +} diff --git a/tests/wire/core_invoke_methods.txt b/tests/wire/core_invoke_methods.txt index a2f7f42..157defc 100644 --- a/tests/wire/core_invoke_methods.txt +++ b/tests/wire/core_invoke_methods.txt @@ -177,6 +177,10 @@ preferHttpsAssetUrl prefetchDetailStreamsPlan prioritizeHomeRows profileAvatarDefault +profileAvatarPackCatalog +profileAvatarPackDiscoveryPlan +profileAvatarPackParse +profileAvatarPackRepositoryPlan profileMutationPlan createProfilePlan profilePinHash