From e68dcc405cf5a0aea035b84b01cf146a46cd5ebc Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:52:14 +0300 Subject: [PATCH] Add reusable calendar planning and profile-aware library commands --- src/calendar_plan.rs | 213 ++++++++++++++++++++++++++++- src/ffi.rs | 4 +- src/headless_engine/contracts.rs | 5 +- src/headless_engine/library.rs | 3 +- src/headless_engine/mod.rs | 8 +- tests/wire/core_invoke_methods.txt | 2 + 6 files changed, 223 insertions(+), 12 deletions(-) diff --git a/src/calendar_plan.rs b/src/calendar_plan.rs index afbc8cb..6d53141 100644 --- a/src/calendar_plan.rs +++ b/src/calendar_plan.rs @@ -21,6 +21,31 @@ struct CalendarItemInput { episode_title: Option, #[serde(default)] artwork_url: Option, + #[serde(default)] + meta: Value, + #[serde(default)] + poster: Option, + #[serde(default)] + episode_poster: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CandidatePlanRequest { + #[serde(default)] + groups: Vec>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReleaseRowsRequest { + meta: Value, + #[serde(default)] + detail: Value, + #[serde(default)] + videos: Vec, + month_prefix: String, + movie_label: String, } #[derive(Debug, Deserialize)] @@ -86,12 +111,17 @@ pub(crate) fn calendar_content_plan_json(request_json: &str) -> Option { .items .iter() .filter(|item| { + let meta_id = if item.meta_id.trim().is_empty() { + item.meta.get("id").and_then(Value::as_str).unwrap_or("") + } else { + &item.meta_id + }; item.date_iso.starts_with(prefix) - && !item.meta_id.trim().is_empty() + && !meta_id.trim().is_empty() && seen.insert(format!( "{}:{}:{}", item.date_iso, - item.meta_id, + meta_id, item.subtitle.as_deref().unwrap_or("") )) }) @@ -104,22 +134,159 @@ pub(crate) fn calendar_content_plan_json(request_json: &str) -> Option { let out: Vec = filtered .iter() .map(|item| { + let meta_id = if item.meta_id.trim().is_empty() { + item.meta.get("id").and_then(Value::as_str).unwrap_or("") + } else { + &item.meta_id + }; + let meta_type = if item.meta_type.trim().is_empty() { + item.meta.get("type").and_then(Value::as_str).unwrap_or("") + } else { + &item.meta_type + }; json!({ "dateIso": item.date_iso, - "metaId": item.meta_id, - "metaType": item.meta_type, + "metaId": meta_id, + "metaType": meta_type, "title": item.title, "subtitle": item.subtitle, "seasonNumber": item.season_number, "episodeNumber": item.episode_number, "episodeTitle": item.episode_title, - "artworkUrl": item.artwork_url + "artworkUrl": item.artwork_url, + "meta": item.meta, + "poster": item.poster, + "episodePoster": item.episode_poster }) }) .collect(); serde_json::to_string(&out).ok() } +pub(crate) fn calendar_candidate_plan_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let mut seen = std::collections::HashSet::new(); + let candidates: Vec = request + .groups + .into_iter() + .flatten() + .filter(|item| { + let id = item.get("id").and_then(Value::as_str).unwrap_or("").trim(); + let content_type = item + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + !id.is_empty() + && content_type != "catalog_folder" + && seen.insert(format!("{}:{}", content_type.to_ascii_lowercase(), id)) + }) + .collect(); + serde_json::to_string(&candidates).ok() +} + +pub(crate) fn calendar_release_rows_json(request_json: &str) -> Option { + let request = serde_json::from_str::(request_json).ok()?; + let meta = &request.meta; + let content_type = meta + .get("type") + .and_then(Value::as_str) + .unwrap_or("") + .to_ascii_lowercase(); + let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or(""); + let title = meta.get("name").and_then(Value::as_str).unwrap_or(meta_id); + if ["movie", "film"].contains(&content_type.as_str()) { + let date_iso = meta + .get("released") + .and_then(Value::as_str) + .and_then(|value| value.get(..10))?; + if !date_iso.starts_with(&request.month_prefix) { + return serde_json::to_string(&json!([])).ok(); + } + let poster = usable_artwork(meta.get("poster").and_then(Value::as_str)); + return serde_json::to_string(&json!([{ + "dateIso": date_iso, + "meta": meta, + "title": title, + "subtitle": request.movie_label, + "poster": poster + }])) + .ok(); + } + if !["series", "tv", "show", "anime"].contains(&content_type.as_str()) { + return serde_json::to_string(&json!([])).ok(); + } + let detail = &request.detail; + let fallback_artwork = [ + meta.get("poster").and_then(Value::as_str), + detail.get("poster").and_then(Value::as_str), + meta.get("continueWatchingPoster").and_then(Value::as_str), + meta.get("background").and_then(Value::as_str), + meta.get("continueWatchingBackground") + .and_then(Value::as_str), + detail.get("background").and_then(Value::as_str), + ] + .into_iter() + .flatten() + .find_map(|url| usable_artwork(Some(url))); + let rows: Vec = request + .videos + .iter() + .filter_map(|video| { + let date_iso = video + .get("released") + .and_then(Value::as_str) + .and_then(|value| value.get(..10))?; + if !date_iso.starts_with(&request.month_prefix) { + return None; + } + let season = video.get("season").and_then(Value::as_i64); + let episode = video + .get("number") + .or_else(|| video.get("episode")) + .and_then(Value::as_i64); + let episode_code = match (season, episode) { + (Some(s), Some(e)) => Some(format!("S{s}:E{e}")), + (Some(s), None) => Some(format!("S{s}")), + (None, Some(e)) => Some(format!("E{e}")), + _ => None, + }; + let episode_title = video + .get("name") + .or_else(|| video.get("title")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let subtitle = [episode_code.as_deref(), episode_title] + .into_iter() + .flatten() + .collect::>() + .join(" "); + Some(json!({ + "dateIso": date_iso, + "meta": meta, + "title": title, + "subtitle": subtitle, + "poster": fallback_artwork, + "episodePoster": fallback_artwork, + "seasonNumber": season, + "episodeNumber": episode, + "episodeTitle": episode_title + })) + }) + .collect(); + serde_json::to_string(&rows).ok() +} + +fn usable_artwork(url: Option<&str>) -> Option<&str> { + url.filter(|value| { + let normalized = value.trim().to_ascii_lowercase(); + !normalized.is_empty() + && normalized != "null" + && !normalized.contains("default-poster") + && !normalized.contains("placeholder") + }) +} + pub(crate) fn calendar_season_candidates_json(request_json: &str) -> Option { let request = serde_json::from_str::(request_json).ok()?; let seasons_count = request.seasons_count.unwrap_or(1).max(1); @@ -419,6 +586,42 @@ mod tests { use super::*; use serde_json::Value; + #[test] + fn candidate_plan_merges_groups_and_deduplicates_content() { + let result: Value = serde_json::from_str( + &calendar_candidate_plan_json( + r#"{"groups":[[{"id":"tt1","type":"series","name":"Library"}],[{"id":"tt1","type":"series","name":"Progress"},{"id":"tt2","type":"anime","name":"Provider"}]]}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(result.as_array().unwrap().len(), 2); + assert_eq!(result[0]["name"], json!("Library")); + assert_eq!(result[1]["id"], json!("tt2")); + } + + #[test] + fn release_rows_build_series_episodes_and_movie_releases() { + let series: Value = serde_json::from_str( + &calendar_release_rows_json( + r#"{"meta":{"id":"tt1","type":"tv","name":"Show","poster":"poster.jpg"},"videos":[{"released":"2026-07-20T00:00:00Z","season":2,"number":1,"name":"Premiere"}],"monthPrefix":"2026-07","movieLabel":"Movie"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(series[0]["subtitle"], json!("S2:E1 Premiere")); + assert_eq!(series[0]["meta"]["id"], json!("tt1")); + + let movie: Value = serde_json::from_str( + &calendar_release_rows_json( + r#"{"meta":{"id":"tt2","type":"film","name":"Film","released":"2026-07-21"},"videos":[],"monthPrefix":"2026-07","movieLabel":"Movie"}"#, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(movie[0]["subtitle"], json!("Movie")); + } + #[test] fn content_plan_filters_deduplicates_and_sorts_by_date_then_title() { let result: Value = serde_json::from_str( diff --git a/src/ffi.rs b/src/ffi.rs index d9e9674..a034ac2 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -848,6 +848,8 @@ fn route_content_identity(method: &str, args_json: &str) -> Outcome { fn route_calendar(method: &str, args_json: &str) -> Outcome { match method { + "calendarCandidatePlan" => opt_json(calendar_plan::calendar_candidate_plan_json(args_json)), + "calendarReleaseRows" => opt_json(calendar_plan::calendar_release_rows_json(args_json)), "calendarContentPlan" => opt_json(calendar_plan::calendar_content_plan_json(args_json)), "calendarSeasonCandidates" => opt_json(calendar_plan::calendar_season_candidates_json( args_json, @@ -1669,7 +1671,7 @@ fn route_addon_store(method: &str, args_json: &str) -> Outcome { ))) } // args_json IS the profile object - "profileLocalAddonsKey" => opt_json(addon_store::profile_local_addons_key_json( + "profileLocalAddonsKey" => opt_str(addon_store::profile_local_addons_key_json( args_json, )), "sanitizeProfile" => { diff --git a/src/headless_engine/contracts.rs b/src/headless_engine/contracts.rs index 945bd8d..0494381 100644 --- a/src/headless_engine/contracts.rs +++ b/src/headless_engine/contracts.rs @@ -159,7 +159,10 @@ pub(super) enum AppAction { #[serde(rename = "libraryHydrateRequested")] LibraryHydrateRequested { profile_id: Option }, #[serde(rename = "toggleWatchlistRequested")] - ToggleWatchlistRequested { item: Value }, + ToggleWatchlistRequested { + item: Value, + profile: Option, + }, #[serde(rename = "toggleLibraryStatusRequested")] ToggleLibraryStatusRequested { list: String, item: Value }, #[serde(rename = "setFeedbackRequested")] diff --git a/src/headless_engine/library.rs b/src/headless_engine/library.rs index c9a750a..8c4d676 100644 --- a/src/headless_engine/library.rs +++ b/src/headless_engine/library.rs @@ -157,9 +157,10 @@ pub(super) fn dispatch_hydrate( pub(super) fn dispatch_toggle_watchlist( engine: &mut HeadlessEngine, item: Value, + profile: Option, ) -> Vec { let generation = engine.bump_generation(GenerationKey::Library); - let profile_id = active_profile_id(&engine.state, &Value::Null); + let profile_id = active_profile_id(&engine.state, &profile.unwrap_or(Value::Null)); let command = ToggleWatchlistCommand { kind: "toggleWatchlist", item, diff --git a/src/headless_engine/mod.rs b/src/headless_engine/mod.rs index 3afd3bc..8b62117 100644 --- a/src/headless_engine/mod.rs +++ b/src/headless_engine/mod.rs @@ -350,8 +350,8 @@ impl HeadlessEngine { AppAction::LibraryHydrateRequested { profile_id } => { library::dispatch_hydrate(self, profile_id) } - AppAction::ToggleWatchlistRequested { item } => { - library::dispatch_toggle_watchlist(self, item) + AppAction::ToggleWatchlistRequested { item, profile } => { + library::dispatch_toggle_watchlist(self, item, profile) } AppAction::ToggleLibraryStatusRequested { list, item } => { library::dispatch_toggle_status(self, list, item) @@ -1243,14 +1243,14 @@ mod tests { let requested: Value = serde_json::from_str( &headless_engine_dispatch_json( handle, - r#"{"type":"toggleWatchlistRequested","item":{"id":"tt1","name":"Movie","type":"movie"}}"#, + r#"{"type":"toggleWatchlistRequested","profile":{"id":"p2"},"item":{"id":"tt1","name":"Movie","type":"movie"}}"#, ) .unwrap(), ) .unwrap(); assert_eq!(requested["effects"][0]["type"], "writeLibraryCommand"); - assert_eq!(requested["effects"][0]["payload"]["profileId"], "p1"); + assert_eq!(requested["effects"][0]["payload"]["profileId"], "p2"); assert_eq!( requested["effects"][0]["payload"]["command"]["type"], "toggleWatchlist" diff --git a/tests/wire/core_invoke_methods.txt b/tests/wire/core_invoke_methods.txt index f95cad3..a649a4a 100644 --- a/tests/wire/core_invoke_methods.txt +++ b/tests/wire/core_invoke_methods.txt @@ -19,11 +19,13 @@ buildResourceUrl buildTraktIds cacheEntryPolicy cacheTrimPolicy +calendarCandidatePlan calendarContentPlan calendarItemMatchesMonth calendarItemsFromMeta calendarNotificationContent calendarReleaseDetection +calendarReleaseRows calendarSeasonCandidates calendarWidgetRows canPrefetchNextEpisode