Read removed/_mtime from Stremio library items; add watchlist extraction

Kotlin's LibraryItem DTO never declared removed/_mtime, so even though
this file already worked on raw JSON Values, those fields never
survived the trip from the Stremio datastore response. Adds
libraryWatchlistItems (id/name/type/poster/background/updatedAtMs,
excluding removed items) so the pull side can finally see Stremio
library membership as a timestamped list, matching the shape the
mergeWatchlistTimestamped bridge expects. Also fixes
libraryContinueWatchingItems, which never checked "removed" at all --
a removed item with leftover progress state could still surface as
continue-watching.
This commit is contained in:
KhooLy 2026-07-18 20:11:15 +03:00
parent 5f43a8c11a
commit 25b3ca73a0
3 changed files with 61 additions and 3 deletions

View file

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

View file

@ -116,7 +116,9 @@ pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option<S
let mut items: Vec<Value> = 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<S
serde_json::to_string(&metas).ok()
}
pub(crate) fn library_watchlist_items_json(items_json: &str) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let entries: Vec<Value> = 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<chrono::FixedOffset>| 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(

View file

@ -83,6 +83,7 @@ libraryExternalMergePlan
libraryLocalStatePlan
libraryOfflineGrouping
librarySortPlan
libraryWatchlistItems
manifestCandidates
manifestFetchDecision
manifestFetchPlan