diff --git a/src/ffi.rs b/src/ffi.rs index fea4282..878fb0b 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -2,12 +2,14 @@ use serde_json::{json, Value}; use crate::{ addon_protocol, addon_resource, addon_store, anime_detection, app_state, calendar_plan, - content_identity, core_contract, data_policy, discovery_plan, dolby_vision_rpu, - external_sync, headless_adapter_plan, headless_engine, home_ranking, intro_segments, + 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, watchlist_plan, }; +#[cfg(feature = "native")] +use crate::dolby_vision_rpu; #[derive(Clone, Copy, PartialEq, Eq)] pub enum ErrorKind { @@ -96,6 +98,7 @@ const ROUTERS: &[fn(&str, &str) -> Outcome] = &[ route_headless_adapter_plan, route_discovery_plan, route_data_policy, + #[cfg(feature = "native")] route_dolby_vision_rpu, route_player_flow, route_player_scrobble, @@ -1353,6 +1356,9 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome { "libraryContinueWatchingItems" => opt_json( library_state::library_continue_watching_items_json(args_json), ), + "libraryWatchlistItems" => opt_json( + library_state::library_watchlist_items_json(args_json), + ), "normalizeLibraryDocument" => { into_json(library_state::normalize_library_document_json(args_json)) } @@ -1850,6 +1856,7 @@ fn route_data_policy(method: &str, args_json: &str) -> Outcome { } } +#[cfg(feature = "native")] fn route_dolby_vision_rpu(method: &str, args_json: &str) -> Outcome { match method { // args_json IS the request object for both of these diff --git a/src/library_state.rs b/src/library_state.rs index 551551b..201689d 100644 --- a/src/library_state.rs +++ b/src/library_state.rs @@ -116,7 +116,9 @@ pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option = serde_json::from_str(items_json).ok()?; items.retain(|item| { let state = item.get("state").unwrap_or(&Value::Null); - !state.is_null() + let removed = item.get("removed").and_then(Value::as_bool).unwrap_or(false); + !removed + && !state.is_null() && number(state, "timeOffset").unwrap_or(0) > 0 && number(state, "flaggedWatched").unwrap_or(0) == 0 }); @@ -153,6 +155,29 @@ pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option Option { + let items: Vec = serde_json::from_str(items_json).ok()?; + let entries: Vec = items + .iter() + .filter(|item| !item.get("removed").and_then(Value::as_bool).unwrap_or(false)) + .filter_map(|item| { + let id = text(item, "_id").filter(|s| !s.is_empty())?.to_string(); + let updated_at_ms = text(item, "_mtime") + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt: chrono::DateTime| dt.timestamp_millis())?; + Some(json!({ + "id": id, + "name": text(item, "name").unwrap_or(""), + "type": text(item, "type").unwrap_or(""), + "poster": item.get("poster").cloned().unwrap_or(Value::Null), + "background": item.get("background").cloned().unwrap_or(Value::Null), + "updatedAtMs": updated_at_ms + })) + }) + .collect(); + serde_json::to_string(&entries).ok() +} + pub(crate) fn filter_home_continue_watching_json( items_json: &str, trakt_watched_json: &str, @@ -1027,6 +1052,31 @@ mod tests { use super::*; use serde_json::Value; + #[test] + fn library_watchlist_items_excludes_removed_and_undated_entries() { + let items = r#"[ + {"_id":"tt1","name":"Active","type":"movie","_mtime":"2026-01-01T00:00:00.000Z"}, + {"_id":"tt2","name":"Removed","type":"movie","removed":true,"_mtime":"2026-01-01T00:00:00.000Z"}, + {"_id":"tt3","name":"NoTimestamp","type":"movie"} + ]"#; + let result: Value = serde_json::from_str(&library_watchlist_items_json(items).unwrap()).unwrap(); + let ids: Vec<&str> = result.as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["tt1"]); + assert_eq!(result[0]["updatedAtMs"], json!(1767225600000i64)); + } + + #[test] + fn continue_watching_items_exclude_removed_entries() { + let items = r#"[ + {"_id":"tt1","name":"Active","type":"movie","state":{"timeOffset":100,"duration":1000,"flaggedWatched":0}}, + {"_id":"tt2","name":"Removed","type":"movie","removed":true,"state":{"timeOffset":100,"duration":1000,"flaggedWatched":0}} + ]"#; + let result: Value = + serde_json::from_str(&library_continue_watching_items_json(items).unwrap()).unwrap(); + assert_eq!(result.as_array().unwrap().len(), 1); + assert_eq!(result[0]["id"], "tt1"); + } + #[test] fn watched_state_items_build_series_episode_payloads() { let items = watched_state_items_json( diff --git a/tests/wire/core_invoke_methods.txt b/tests/wire/core_invoke_methods.txt index 6c724da..41bb0b3 100644 --- a/tests/wire/core_invoke_methods.txt +++ b/tests/wire/core_invoke_methods.txt @@ -83,6 +83,7 @@ libraryExternalMergePlan libraryLocalStatePlan libraryOfflineGrouping librarySortPlan +libraryWatchlistItems manifestCandidates manifestFetchDecision manifestFetchPlan