mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-19 05:25:50 +00:00
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:
parent
4acb603bc8
commit
83ff0408e9
3 changed files with 71 additions and 27 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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<_>>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue