From fc0d5d56ba600293d415cfb1b6887b7f626e890c Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:15:18 +0300 Subject: [PATCH] Support provider sync and calendar data plans --- .gitignore | 1 + src/calendar_plan.rs | 108 +++++++++++++++++++++++++- src/external_sync.rs | 130 ++++++++++++++++++++++++++++++-- src/ffi.rs | 1 + src/ffi/external_sync_routes.rs | 1 + src/oauth_plan.rs | 7 +- src/player_scrobble.rs | 36 ++++++++- 7 files changed, 266 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 9caf289..c2b8bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target/ CLAUDE.md AGENTS.md +docs/external/ *.pdb stremio-addon-api/ fluxa-streaming-engine/resources/ffmpeg/ diff --git a/src/calendar_plan.rs b/src/calendar_plan.rs index 48b6e9c..cda9135 100644 --- a/src/calendar_plan.rs +++ b/src/calendar_plan.rs @@ -4,17 +4,18 @@ use serde_json::{json, Value}; pub(crate) fn calendar_visibility_plan_json(request_json: &str) -> Option { let request: Value = serde_json::from_str(request_json).ok()?; let items = request.get("items")?.as_array()?; - if request.get("showCompleted").and_then(Value::as_bool) == Some(true) { - return serde_json::to_string(items).ok(); - } let today_iso = request .get("todayIso") .and_then(Value::as_str) .unwrap_or(""); let completed = request.get("completedItems")?.as_array()?; + let show_completed = request.get("showCompleted").and_then(Value::as_bool) == Some(true); let visible: Vec<&Value> = items .iter() .filter(|item| { + if show_completed { + return true; + } if !today_iso.is_empty() && item .get("dateIso") @@ -46,7 +47,68 @@ pub(crate) fn calendar_visibility_plan_json(request_json: &str) -> Option *current_score { + unique[*index] = item; + *current_score = score; + } + } else { + seen.insert(key, (unique.len(), score)); + unique.push(item); + } + } + serde_json::to_string(&unique).ok() +} + +fn calendar_item_identity(item: &Value) -> String { + let date = item + .get("dateIso") + .and_then(Value::as_str) + .map(|value| value.get(..10).unwrap_or(value)) + .unwrap_or(""); + let content = item + .get("title") + .and_then(Value::as_str) + .or_else(|| item.get("name").and_then(Value::as_str)) + .or_else(|| { + ["contentId", "seriesId"] + .iter() + .find_map(|key| item.get(*key).and_then(Value::as_str)) + }) + .or_else(|| item.get("id").and_then(Value::as_str)) + .unwrap_or(""); + let season = ["seasonNumber", "season"] + .iter() + .find_map(|key| item.get(*key).and_then(Value::as_i64)) + .unwrap_or_default(); + let episode = ["episodeNumber", "episode", "number"] + .iter() + .find_map(|key| item.get(*key).and_then(Value::as_i64)) + .unwrap_or_default(); + format!("{date}:{content}:{season}:{episode}") +} + +fn calendar_item_detail_score(item: &Value) -> usize { + [ + "poster", + "seriesPoster", + "episodePoster", + "episodeTitle", + "airTime", + "releaseTime", + ] + .iter() + .filter(|key| { + item.get(**key) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + }) + .count() } #[derive(Debug, Deserialize)] @@ -590,6 +652,9 @@ pub(crate) fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str) "subtitle": subtitle, "dateIso": date_iso, "poster": poster, + "contentId": meta_id, + "seriesId": meta_id, + "metaType": meta.get("type"), })); } serde_json::to_string(&items).ok() @@ -724,6 +789,41 @@ mod tests { assert_eq!(items[0]["dateIso"], "2026-07-27T03:00:00Z"); } + #[test] + fn visibility_deduplicates_provider_copies_of_the_same_episode() { + let request = json!({ + "items": [ + {"id":"tt2861424:episode-10:2026-07-27","title":"Rick and Morty","dateIso":"2026-07-27T03:00:00Z","seasonNumber":9,"episodeNumber":10,"poster":"poster.jpg"}, + {"contentId":"tt2861424","title":"Rick and Morty","dateIso":"2026-07-27T00:00:00Z","seasonNumber":9,"episodeNumber":10,"episodeTitle":"Field of Dreams"}, + {"contentId":"tt2861424","title":"Rick and Morty","dateIso":"2026-07-27T00:00:00Z","seasonNumber":9,"episodeNumber":10,"poster":"poster.jpg","episodeTitle":"Field of Dreams"} + ], + "completedItems": [], + "showCompleted": true, + "todayIso": "2026-07-26" + }); + let result: Value = + serde_json::from_str(&calendar_visibility_plan_json(&request.to_string()).unwrap()) + .unwrap(); + let items = result.as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["episodeTitle"], "Field of Dreams"); + } + + #[test] + fn items_from_meta_keep_calendar_identity_for_deduplication() { + let result: Value = serde_json::from_str( + &calendar_items_from_meta_json( + r#"{"id":"tt2861424","type":"series","name":"Rick and Morty","videos":[{"released":"2026-08-02T00:00:00Z","season":9,"episode":11,"name":"Episode"}]}"#, + "2026-08", + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result[0]["contentId"], "tt2861424"); + assert_eq!(result[0]["seriesId"], "tt2861424"); + assert_eq!(result[0]["metaType"], "series"); + } + #[test] fn candidate_plan_merges_groups_and_deduplicates_content() { let result: Value = serde_json::from_str( diff --git a/src/external_sync.rs b/src/external_sync.rs index 36365f4..3adde9b 100644 --- a/src/external_sync.rs +++ b/src/external_sync.rs @@ -165,6 +165,72 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option { } return serde_json::to_string(&items).ok(); } + if provider == "simkl" && args.get("shows").and_then(|value| value.get("calendar")).is_some() { + let allowed_content_ids: std::collections::HashSet<&str> = args + .get("allowedContentIds") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + for (calendar, metadata, is_movie) in [ + ( + args.get("shows").and_then(|value| value.get("calendar")).and_then(Value::as_array), + args.get("shows").and_then(|value| value.get("metadata")).and_then(Value::as_object), + false, + ), + ( + args.get("movies").and_then(|value| value.get("calendar")).and_then(Value::as_array), + args.get("movies").and_then(|value| value.get("metadata")).and_then(Value::as_object), + true, + ), + ] { + for entry in calendar.into_iter().flatten() { + let Some(simkl_id) = entry.get("simkl_id") else { continue }; + let simkl_key = simkl_id.as_str().map(str::to_string).or_else(|| simkl_id.as_i64().map(|value| value.to_string())); + let Some(media) = simkl_key.as_deref().and_then(|key| metadata.and_then(|value| value.get(key))) else { continue }; + let ids = media.get("ids").unwrap_or(&Value::Null); + let content_id = ids.get("imdb").and_then(Value::as_str).map(str::to_string).or_else(|| { + ids.get("tmdb").and_then(Value::as_i64).map(|id| format!("tmdb:{id}")).or_else(|| { + ids.get("tmdb").and_then(Value::as_str).filter(|id| !id.is_empty()).map(|id| format!("tmdb:{id}")) + }) + }); + let Some(content_id) = content_id else { continue }; + if !allowed_content_ids.contains(content_id.as_str()) { + continue; + } + let Some(date) = entry.get("date").and_then(Value::as_str) else { continue }; + if is_movie { + items.push(json!({ + "id": content_id, + "title": media.get("title"), + "dateIso": date, + "contentId": content_id, + "metaType": "movie", + "poster": media.get("poster"), + })); + continue; + } + let episode = entry.get("episode").unwrap_or(&Value::Null); + let season = episode.get("season").and_then(Value::as_i64); + let number = episode.get("episode").and_then(Value::as_i64); + items.push(json!({ + "id": format!("{content_id}:{}:{}", season.unwrap_or_default(), number.unwrap_or_default()), + "title": media.get("title"), + "episodeTitle": episode.get("title"), + "seasonNumber": season, + "episodeNumber": number, + "dateIso": date, + "contentId": content_id, + "seriesId": content_id, + "metaType": "series", + "poster": media.get("poster"), + "seriesPoster": media.get("poster"), + })); + } + } + return serde_json::to_string(&items).ok(); + } for entry in shows.into_iter().flatten() { let Some(show) = entry.get("show") else { continue; @@ -204,6 +270,8 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option { .or_else(|| episode.get("episode")) .or_else(|| episode.get("episode_number")) .and_then(Value::as_i64); + let episode_poster = provider_image_url(episode, "screenshot"); + let series_poster = provider_image_url(show, "poster"); items.push(json!({ "id": format!("{series_id}:{}:{}", season.unwrap_or_default(), number.unwrap_or_default()), "title": show.get("title"), @@ -214,6 +282,9 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option { "contentId": series_id, "seriesId": series_id, "metaType": "series", + "poster": episode_poster.as_ref().or(series_poster.as_ref()), + "episodePoster": episode_poster, + "seriesPoster": series_poster, })); } for entry in movies.into_iter().flatten() { @@ -245,11 +316,32 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option { else { continue; }; - items.push(json!({"id": content_id, "title": movie.get("title"), "dateIso": date, "contentId": content_id})); + let poster = provider_image_url(movie, "poster"); + items.push(json!({ + "id": content_id, + "title": movie.get("title"), + "dateIso": date, + "contentId": content_id, + "metaType": "movie", + "poster": poster, + })); } serde_json::to_string(&items).ok() } +fn provider_image_url(media: &Value, image_type: &str) -> Option { + let image = media + .get("images")? + .get(image_type) + .and_then(|value| value.as_array().and_then(|images| images.first()).or(Some(value))) + .and_then(Value::as_str)?; + if image.starts_with("https://") || image.starts_with("http://") { + Some(image.to_string()) + } else { + Some(format!("https://{image}")) + } +} + pub(crate) fn provider_pagination_plan_json(args_json: &str) -> Option { let args: Value = serde_json::from_str(args_json).ok()?; let base_url = args.get("baseUrl")?.as_str()?; @@ -559,8 +651,12 @@ pub(crate) fn trakt_bearer(token: &str) -> String { } pub(crate) fn trakt_scrobble_url(action: &str) -> Option { - matches!(action.trim(), "start" | "pause" | "stop") - .then(|| format!("{TRAKT_API_BASE_URL}/scrobble/{}", action.trim())) + match action.trim() { + "start" => Some(format!("{TRAKT_API_BASE_URL}/scrobble/start")), + "pause" => Some(format!("{TRAKT_API_BASE_URL}/scrobble/pause")), + "stop" => Some(format!("{TRAKT_API_BASE_URL}/scrobble/stop")), + _ => None, + } } pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> Option { @@ -1227,12 +1323,14 @@ mod tests { "first_aired": "2026-07-27T03:00:00Z", "show": { "title": "Rick and Morty", - "ids": {"imdb": "tt2861424"} + "ids": {"imdb": "tt2861424"}, + "images": {"poster": ["walter-r2.trakt.tv/images/shows/poster.webp"]} }, "episode": { "season": 9, "number": 10, - "title": "Episode Title" + "title": "Episode Title", + "images": {"screenshot": ["walter-r2.trakt.tv/images/episodes/screenshot.webp"]} } }], "movies": [] @@ -1243,6 +1341,7 @@ mod tests { assert_eq!(result[0]["seasonNumber"], 9); assert_eq!(result[0]["episodeNumber"], 10); assert_eq!(result[0]["metaType"], "series"); + assert_eq!(result[0]["episodePoster"], "https://walter-r2.trakt.tv/images/episodes/screenshot.webp"); } #[test] @@ -1270,6 +1369,26 @@ mod tests { assert_eq!(result[0]["episodeNumber"], 10); } + #[test] + fn simkl_calendar_items_accept_v2_cdn_payloads() { + let request = json!({ + "provider": "simkl", + "shows": { + "calendar": [{"simkl_id": 3437, "date": "2026-07-27T04:00:00Z", "episode": {"season": 15, "episode": 10, "title": "Propane Recall"}}], + "metadata": {"3437": {"title": "King of the Hill", "poster": "https://example.test/poster.jpg", "ids": {"imdb": "tt0118375"}}} + }, + "movies": {"calendar": [], "metadata": {}}, + "allowedContentIds": ["tt0118375"] + }); + let result: Value = + serde_json::from_str(&provider_calendar_items_json(&request.to_string()).unwrap()) + .unwrap(); + assert_eq!(result[0]["contentId"], "tt0118375"); + assert_eq!(result[0]["seasonNumber"], 15); + assert_eq!(result[0]["episodeNumber"], 10); + assert_eq!(result[0]["poster"], "https://example.test/poster.jpg"); + } + #[test] fn replace_external_continue_watching_sorts_by_saved_at_descending() { let items = json!([ @@ -1497,6 +1616,7 @@ mod tests { None, trakt_input["timePosSec"].as_f64().unwrap(), trakt_input["durationSec"].as_f64().unwrap(), + None, ) .unwrap(), ) diff --git a/src/ffi.rs b/src/ffi.rs index e579a69..7744f6f 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -1403,6 +1403,7 @@ fn route_player_flow(method: &str, args_json: &str) -> Outcome { fn route_player_scrobble(method: &str, args_json: &str) -> Outcome { match method { + "playerScrobbleLifecycleAction" => opt_json(player_scrobble::lifecycle_action_json(args_json)), "scrobbleMediaContext" => opt_json(player_scrobble::scrobble_media_context_json(args_json)), "playerProgressPercent" => { let args = object(args_json)?; diff --git a/src/ffi/external_sync_routes.rs b/src/ffi/external_sync_routes.rs index 08971ea..78a8994 100644 --- a/src/ffi/external_sync_routes.rs +++ b/src/ffi/external_sync_routes.rs @@ -179,6 +179,7 @@ pub(super) fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcom ep_number, time_pos, duration, + args.get("action").and_then(Value::as_str), )) } "replaceExternalContinueWatching" => { diff --git a/src/oauth_plan.rs b/src/oauth_plan.rs index a85cd9f..70dbe16 100644 --- a/src/oauth_plan.rs +++ b/src/oauth_plan.rs @@ -13,6 +13,7 @@ pub(crate) fn oauth_request_plan_json(request_json: &str) -> Option { .and_then(Value::as_str) .unwrap_or(""); let code = request.get("code").and_then(Value::as_str).unwrap_or(""); + let code_verifier = request.get("codeVerifier").and_then(Value::as_str).unwrap_or(""); let refresh_token = request .get("refreshToken") .and_then(Value::as_str) @@ -38,13 +39,9 @@ pub(crate) fn oauth_request_plan_json(request_json: &str) -> Option { "https://anilist.co/api/v2/oauth/token", json!({"grant_type": "authorization_code", "client_id": client_id, "client_secret": client_secret, "redirect_uri": "fluxa://oauth/anilist", "code": code}), ), - ("anilist", "refresh") => ( - "https://anilist.co/api/v2/oauth/token", - json!({"grant_type": "refresh_token", "client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token}), - ), ("simkl", "exchange") => ( "https://api.simkl.com/oauth/token", - json!({"code": code, "client_id": client_id, "client_secret": client_secret, "redirect_uri": "fluxa://oauth/simkl", "grant_type": "authorization_code"}), + json!({"code": code, "client_id": client_id, "code_verifier": code_verifier, "redirect_uri": "fluxa://oauth/simkl", "grant_type": "authorization_code"}), ), _ => return None, }; diff --git a/src/player_scrobble.rs b/src/player_scrobble.rs index b586136..e352027 100644 --- a/src/player_scrobble.rs +++ b/src/player_scrobble.rs @@ -94,6 +94,23 @@ fn has_token(token: Option<&str>) -> bool { token.is_some_and(|value| !value.trim().is_empty()) } +pub(crate) fn lifecycle_action_json(args_json: &str) -> Option { + let args: serde_json::Value = serde_json::from_str(args_json).ok()?; + let event = args.get("event")?.as_str()?; + let has_started = args.get("hasStarted").and_then(serde_json::Value::as_bool).unwrap_or(false); + let has_paused = args.get("hasPaused").and_then(serde_json::Value::as_bool).unwrap_or(false); + let has_stopped = args.get("hasStopped").and_then(serde_json::Value::as_bool).unwrap_or(false); + let progress = args.get("progress").and_then(serde_json::Value::as_f64).unwrap_or(0.0) as f32; + let token = args.get("token").and_then(serde_json::Value::as_str); + let action = match event { + "start" if should_send_start(token, true, !has_paused && has_started, progress) => "start", + "pause" if should_queue_pause(token, true, has_started, has_stopped) => "pause", + "stop" if has_started && !has_stopped => "stop", + _ => return None, + }; + serde_json::to_string(&serde_json::json!({ "action": action })).ok() +} + pub(crate) fn scrobble_media_context_json(args_json: &str) -> Option { let args: serde_json::Value = serde_json::from_str(args_json).ok()?; let meta = args.get("meta")?; @@ -141,16 +158,19 @@ pub(crate) fn trakt_scrobble_plan_json( ep_number: Option, time_pos_sec: f64, duration_sec: f64, + requested_action: Option<&str>, ) -> Option { let ids: serde_json::Value = serde_json::from_str(ids_json).ok()?; if duration_sec <= 0.0 { return None; } let progress = ((time_pos_sec / duration_sec) * 100.0).clamp(0.0, 100.0); - let action = if progress as f32 >= SCROBBLE_STOP_PROGRESS_PERCENT { - "stop" - } else { - "pause" + let action = match requested_action { + Some("start") => "start", + Some("pause") => "pause", + Some("stop") => "stop", + _ if progress as f32 >= SCROBBLE_STOP_PROGRESS_PERCENT => "stop", + _ => "pause", }; let body = if is_episode { serde_json::json!({ @@ -209,6 +229,14 @@ mod tests { assert!(should_send_start(Some("token"), true, false, 0.3)); } + #[test] + fn lifecycle_actions_follow_start_pause_stop_order() { + assert_eq!(lifecycle_action_json(r#"{"event":"start","token":"token","hasStarted":false,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"start"}"#)); + assert_eq!(lifecycle_action_json(r#"{"event":"start","token":"token","hasStarted":true,"hasPaused":true,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"start"}"#)); + assert_eq!(lifecycle_action_json(r#"{"event":"pause","token":"token","hasStarted":true,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"pause"}"#)); + assert_eq!(lifecycle_action_json(r#"{"event":"stop","token":"token","hasStarted":true,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"stop"}"#)); + } + #[test] fn pause_stop_and_save_thresholds_match_platform_contract() { assert!(!should_mark_stopped(false, 79.9));