mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-07-26 20:02:13 +00:00
feat: extend core integrations
This commit is contained in:
parent
cd2973d9bb
commit
fe817cb41d
17 changed files with 753 additions and 83 deletions
|
|
@ -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(
|
||||
|
|
|
|||
39
src/ffi.rs
39
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",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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("{}");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
323
src/profile_avatar_pack.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
7
tests/fixtures/external_sync/simkl_mark_watched_expected.json
vendored
Normal file
7
tests/fixtures/external_sync/simkl_mark_watched_expected.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"movies": [],
|
||||
"shows": [{
|
||||
"ids": {"imdb": "tt0944947"},
|
||||
"seasons": [{"number": 1, "episodes": [{"number": 2}]}]
|
||||
}]
|
||||
}
|
||||
4
tests/fixtures/external_sync/simkl_mark_watched_input.json
vendored
Normal file
4
tests/fixtures/external_sync/simkl_mark_watched_input.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"meta": {"type": "series"},
|
||||
"videoIds": ["tt0944947:1:2"]
|
||||
}
|
||||
4
tests/fixtures/external_sync/simkl_watched_expected.json
vendored
Normal file
4
tests/fixtures/external_sync/simkl_watched_expected.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"tt0944947": true,
|
||||
"tmdb:27205": true
|
||||
}
|
||||
4
tests/fixtures/external_sync/simkl_watched_response.json
vendored
Normal file
4
tests/fixtures/external_sync/simkl_watched_response.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"shows": [{"show": {"ids": {"imdb": "tt0944947"}}}],
|
||||
"movies": [{"movie": {"ids": {"tmdb": 27205}}}]
|
||||
}
|
||||
15
tests/fixtures/external_sync/trakt_playback_expected.json
vendored
Normal file
15
tests/fixtures/external_sync/trakt_playback_expected.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
8
tests/fixtures/external_sync/trakt_playback_response.json
vendored
Normal file
8
tests/fixtures/external_sync/trakt_playback_response.json
vendored
Normal 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}
|
||||
}
|
||||
]
|
||||
7
tests/fixtures/external_sync/trakt_scrobble_plan_expected.json
vendored
Normal file
7
tests/fixtures/external_sync/trakt_scrobble_plan_expected.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"action": "pause",
|
||||
"body": {
|
||||
"movie": {"ids": {"imdb": "tt1375666"}},
|
||||
"progress": 50.0
|
||||
}
|
||||
}
|
||||
6
tests/fixtures/external_sync/trakt_scrobble_plan_input.json
vendored
Normal file
6
tests/fixtures/external_sync/trakt_scrobble_plan_input.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"ids": {"imdb": "tt1375666"},
|
||||
"isEpisode": false,
|
||||
"timePosSec": 450,
|
||||
"durationSec": 900
|
||||
}
|
||||
|
|
@ -177,6 +177,10 @@ preferHttpsAssetUrl
|
|||
prefetchDetailStreamsPlan
|
||||
prioritizeHomeRows
|
||||
profileAvatarDefault
|
||||
profileAvatarPackCatalog
|
||||
profileAvatarPackDiscoveryPlan
|
||||
profileAvatarPackParse
|
||||
profileAvatarPackRepositoryPlan
|
||||
profileMutationPlan
|
||||
createProfilePlan
|
||||
profilePinHash
|
||||
|
|
|
|||
Loading…
Reference in a new issue