feat: extend core integrations

This commit is contained in:
KhooLy 2026-07-22 01:07:50 +03:00
parent cd2973d9bb
commit fe817cb41d
17 changed files with 753 additions and 83 deletions

View file

@ -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<String> {
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<String> {
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<i32> {
@ -695,14 +702,9 @@ fn trakt_playback_item_to_library(item: &Value) -> Option<Value> {
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<Value> = serde_json::from_str(shows_json).unwrap_or_default();
let mut items: Vec<Value> = 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<Value> = serde_json::from_str(shows_json).unwrap_or_default();
let mut ids: serde_json::Map<String, Value> = 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<Value> = serde_json::from_str(movies_json).unwrap_or_default();
let mut items: Vec<Value> = 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<Value> = serde_json::from_str(movies_json).unwrap_or_default();
let mut items: Vec<Value> = 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<Value> = serde_json::from_str(movies_json).unwrap_or_default();
let mut ids: serde_json::Map<String, Value> = 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<Value> = 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<Value> = 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<Value> = 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(

View file

@ -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",

View file

@ -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)]

View file

@ -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::<Vec<_>>()
})
.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<Value> {
.ok()
}
fn normalize_meta_link_trailer(link: &Value) -> Option<Value> {
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<String> {
value[key]
.as_str()

View file

@ -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("{}");

View file

@ -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);

View file

@ -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;

323
src/profile_avatar_pack.rs Normal file
View file

@ -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<String>,
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<String> {
let request = serde_json::from_str::<RepositoryPlanRequest>(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<String> {
let request = serde_json::from_str::<DiscoveryPlanRequest>(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<String> {
let request = serde_json::from_str::<CatalogRequest>(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<String> {
let request = serde_json::from_str::<PackRequest>(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<GitHubRepository> {
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::<Vec<_>>()
.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");
}
}

View file

@ -0,0 +1,7 @@
{
"movies": [],
"shows": [{
"ids": {"imdb": "tt0944947"},
"seasons": [{"number": 1, "episodes": [{"number": 2}]}]
}]
}

View file

@ -0,0 +1,4 @@
{
"meta": {"type": "series"},
"videoIds": ["tt0944947:1:2"]
}

View file

@ -0,0 +1,4 @@
{
"tt0944947": true,
"tmdb:27205": true
}

View file

@ -0,0 +1,4 @@
{
"shows": [{"show": {"ids": {"imdb": "tt0944947"}}}],
"movies": [{"movie": {"ids": {"tmdb": 27205}}}]
}

View file

@ -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"
}
]

View file

@ -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}
}
]

View file

@ -0,0 +1,7 @@
{
"action": "pause",
"body": {
"movie": {"ids": {"imdb": "tt1375666"}},
"progress": 50.0
}
}

View file

@ -0,0 +1,6 @@
{
"ids": {"imdb": "tt1375666"},
"isEpisode": false,
"timePosSec": 450,
"durationSec": 900
}

View file

@ -177,6 +177,10 @@ preferHttpsAssetUrl
prefetchDetailStreamsPlan
prioritizeHomeRows
profileAvatarDefault
profileAvatarPackCatalog
profileAvatarPackDiscoveryPlan
profileAvatarPackParse
profileAvatarPackRepositoryPlan
profileMutationPlan
createProfilePlan
profilePinHash