feat(sync): add configurable source-of-truth and ranking for continue-watching conflicts

merge_continue_watching_lists_json and replace_external_continue_watching_json
accept optional sourceOfTruth/rankingMode params so a caller can pin a
specific provider or rank by episode number instead of always taking the
most recent savedAt. Defaults preserve prior behavior exactly.

Also tags Stremio's continue-watching items with reason:"stremio", matching
Trakt/Simkl/AniList, so it can participate in provider pinning and so stale
Stremio entries are actually evicted on re-sync instead of lingering forever.
This commit is contained in:
KhooLy 2026-07-07 19:20:49 +03:00
parent 4acb603bc8
commit 83ff0408e9
3 changed files with 71 additions and 27 deletions

View file

@ -462,32 +462,51 @@ pub(crate) fn merge_external_watched_json(local_json: &str, external_json: &str)
serde_json::to_string(&Value::Object(local)).unwrap_or_else(|_| "{}".to_string())
}
fn item_id(item: &Value) -> String {
item.get("id")
.or_else(|| item.get("_id"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
fn saved_at_ms(item: &Value) -> i64 {
item.get("savedAt")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
.unwrap_or(0)
}
fn episode_rank(item: &Value) -> Option<(i64, i64)> {
let season = item.get("lastEpisodeSeason").and_then(Value::as_i64)?;
let number = item.get("lastEpisodeNumber").and_then(Value::as_i64)?;
Some((season, number))
}
fn ranked_winner(a: &Value, a_time: i64, b: &Value, b_time: i64, ranking_mode: Option<&str>) -> bool {
if ranking_mode == Some("most_recent_episode") {
if let (Some(ra), Some(rb)) = (episode_rank(a), episode_rank(b)) {
if ra != rb {
return ra > rb;
}
}
}
a_time >= b_time
}
pub(crate) fn merge_continue_watching_lists_json(
local_json: &str,
external_json: &str,
progress_json: &str,
source_of_truth: Option<&str>,
ranking_mode: Option<&str>,
) -> Option<String> {
let local: Vec<Value> = serde_json::from_str(local_json).unwrap_or_default();
let external: Vec<Value> = serde_json::from_str(external_json).unwrap_or_default();
let progress: serde_json::Map<String, Value> =
serde_json::from_str(progress_json).unwrap_or_default();
fn item_id(item: &Value) -> String {
item.get("id")
.or_else(|| item.get("_id"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
fn saved_at_ms(item: &Value) -> i64 {
item.get("savedAt")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
.unwrap_or(0)
}
let local_by_id: std::collections::HashMap<String, &Value> =
local.iter().map(|item| (item_id(item), item)).collect();
let external_by_id: std::collections::HashMap<String, &Value> =
@ -506,11 +525,26 @@ pub(crate) fn merge_continue_watching_lists_json(
let mut merged: Vec<Value> = Vec::new();
for ext_item in &external {
let id = item_id(ext_item);
let local_item = local_by_id.get(&id).copied();
let local_time = local_saved_at_from_progress(&progress, &id);
let ext_time = saved_at_ms(ext_item);
if local_time > ext_time {
let local_item = local_by_id.get(&id).copied();
merged.push(local_item.cloned().unwrap_or_else(|| ext_item.clone()));
let local_wins = if let Some(local_item) = local_item {
if source_of_truth == Some("local") {
true
} else if source_of_truth.is_some()
&& source_of_truth == ext_item.get("reason").and_then(Value::as_str)
{
false
} else {
ranked_winner(local_item, local_time, ext_item, ext_time, ranking_mode)
}
} else {
false
};
if local_wins {
merged.push(local_item.unwrap().clone());
} else {
merged.push(ext_item.clone());
}
@ -646,6 +680,8 @@ pub(crate) fn replace_external_continue_watching_json(
existing_json: &str,
provider: Option<&str>,
items_json: &str,
source_of_truth: Option<&str>,
ranking_mode: Option<&str>,
) -> String {
let existing: Vec<Value> = serde_json::from_str(existing_json).unwrap_or_default();
let incoming: Vec<Value> = serde_json::from_str(items_json).unwrap_or_default();
@ -683,15 +719,18 @@ pub(crate) fn replace_external_continue_watching_json(
if id.is_empty() {
continue;
}
let item_time = item
.get("savedAt")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
match by_id.get(&id) {
Some(prev) => {
let prev_time = prev.get("savedAt").and_then(Value::as_str).unwrap_or("");
if item_time.as_str() > prev_time {
let item_reason = item.get("reason").and_then(Value::as_str);
let prev_reason = prev.get("reason").and_then(Value::as_str);
let item_wins = if source_of_truth.is_some() && source_of_truth == item_reason {
true
} else if source_of_truth.is_some() && source_of_truth == prev_reason {
false
} else {
ranked_winner(&item, saved_at_ms(&item), prev, saved_at_ms(prev), ranking_mode)
};
if item_wins {
by_id.insert(id, item);
}
}

View file

@ -597,6 +597,8 @@ fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
field_str(&args, "localJson")?,
field_str(&args, "externalJson")?,
field_str(&args, "progressJson")?,
args.get("sourceOfTruth").and_then(Value::as_str),
args.get("rankingMode").and_then(Value::as_str),
))
}
"traktScrobblePlan" => {
@ -629,6 +631,8 @@ fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
field_str(&args, "existingJson")?,
provider,
field_str(&args, "itemsJson")?,
args.get("sourceOfTruth").and_then(Value::as_str),
args.get("rankingMode").and_then(Value::as_str),
))
}
"traktPlaybackItemsDedup" => {

View file

@ -145,7 +145,8 @@ pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option<S
"description": Value::Null,
"timeOffset": number(state, "timeOffset"),
"duration": number(state, "duration"),
"lastVideoId": text(state, "videoId")
"lastVideoId": text(state, "videoId"),
"reason": "stremio"
})
})
.collect::<Vec<_>>();