refactor: split library_state.rs into per-concern submodules

library_state.rs was 1399 lines mixing playback progress, library list
building, continue-watching/next-episode logic, and artwork/diff
helpers. Split into library_state/{helpers,playback_progress,
library_lists,continue_watching,artwork_diff}.rs:

- helpers.rs: shared text()/number() JSON field accessors
- playback_progress.rs: playback progress item plans
- library_lists.rs: continue-watching/watchlist list building, library
  document normalization
- continue_watching.rs: badge computation, next-episode resolution
  (kept together since they share several private helpers), plus the
  UP_NEXT_POSITION_SECONDS/UP_NEXT_DURATION_SECONDS constants
- artwork_diff.rs: continue-watching artwork selection, before/after
  diffing helpers

library_state.rs is now 228 lines: module wiring plus the existing test
suite. No behavior change — same 393 tests pass.
This commit is contained in:
KhooLy 2026-07-30 16:31:17 +03:00
parent 1f79a46543
commit 1010529852
6 changed files with 1228 additions and 1105 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,184 @@
use super::continue_watching::format_episode_line_json;
use serde_json::{json, Value};
/// Selects the best artwork URL for a continue-watching card.
/// `artwork_preference` is "poster", "background", or "episode" (default).
/// `is_horizontal` controls whether the card layout is wide/horizontal.
pub(crate) fn select_continue_watching_artwork_json(
item_json: &str,
artwork_preference: &str,
is_horizontal: bool,
) -> Option<String> {
let item: Value = serde_json::from_str(item_json).ok()?;
let str_field = |key: &str| -> Option<String> {
item.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
};
let poster = str_field("poster");
let background = str_field("background");
let logo = str_field("logo");
let thumbnail = str_field("lastEpisodeThumbnail");
let cw_poster = str_field("continueWatchingPoster");
let cw_background = str_field("continueWatchingBackground");
let is_real_backdrop = background.as_deref().is_some_and(|bg| {
(poster.as_deref() != Some(bg)) && !bg.to_lowercase().contains("/poster/")
});
let existing_backdrop = if is_real_backdrop {
background.clone()
} else {
None
};
let result = if !is_horizontal {
thumbnail
.or(cw_poster)
.or(poster)
.or(cw_background)
.or(background)
} else {
let content_type = item.get("type").and_then(Value::as_str).unwrap_or("");
let is_series = matches!(content_type, "series" | "tv" | "anime");
let _ = is_series;
match artwork_preference {
"poster" => poster.or(cw_background).or(existing_backdrop),
"background" => existing_backdrop.or(cw_background).or(poster),
_ => thumbnail
.or(cw_background)
.or(existing_backdrop)
.or(background)
.or(logo)
.or(poster),
}
};
result
}
/// Batched form of select_continue_watching_artwork_json + format_episode_line_json for
/// a whole Continue Watching row at once — each card used to call both over IPC
/// individually, which meant one IPC round trip per card on every Home load.
pub(crate) fn continue_watching_card_fields_json(
items_json: &str,
artwork_preference: &str,
is_horizontal: bool,
) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let fields: Vec<Value> = items
.iter()
.map(|item| {
let id = item
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let artwork = select_continue_watching_artwork_json(
&item.to_string(),
artwork_preference,
is_horizontal,
);
let episode_line = format_episode_line_json(
item.get("lastEpisodeName").and_then(Value::as_str),
item.get("lastEpisodeSeason").and_then(Value::as_i64),
item.get("lastEpisodeNumber").and_then(Value::as_i64),
item.get("lastVideoId").and_then(Value::as_str),
);
json!({ "id": id, "artwork": artwork, "episodeLine": episode_line })
})
.collect();
serde_json::to_string(&fields).ok()
}
/// Decides which entries of a bool map (e.g. watched) actually changed and need
/// persisting — before/after are id -> value maps.
pub(crate) fn watched_map_diff_json(before_json: &str, after_json: &str) -> Option<String> {
let before: Value = serde_json::from_str(before_json).ok()?;
let after: Value = serde_json::from_str(after_json).ok()?;
let before = before.as_object()?;
let after = after.as_object()?;
let changed: Vec<Value> = after
.iter()
.filter(|(id, value)| before.get(*id) != Some(*value))
.map(|(id, value)| json!({ "id": id, "value": value }))
.collect();
serde_json::to_string(&changed).ok()
}
/// Full upsert+delete diff for an id -> value map (e.g. playback progress).
pub(crate) fn value_map_diff_json(before_json: &str, after_json: &str) -> Option<String> {
let before: Value = serde_json::from_str(before_json).ok()?;
let after: Value = serde_json::from_str(after_json).ok()?;
let before = before.as_object()?;
let after = after.as_object()?;
let upserts: Vec<Value> = after
.iter()
.filter(|(id, value)| before.get(*id) != Some(*value))
.map(|(id, value)| json!({ "id": id, "value": value }))
.collect();
let deletes: Vec<&String> = before
.keys()
.filter(|id| !after.contains_key(*id))
.collect();
serde_json::to_string(&json!({ "upserts": upserts, "deletes": deletes })).ok()
}
/// Full upsert+delete diff for an id-keyed item list (e.g. continue watching rows).
pub(crate) fn item_list_diff_json(before_json: &str, after_json: &str) -> Option<String> {
let before: Vec<Value> = serde_json::from_str(before_json).ok()?;
let after: Vec<Value> = serde_json::from_str(after_json).ok()?;
let mut before_by_id: std::collections::HashMap<String, &Value> =
std::collections::HashMap::new();
for item in &before {
if let Some(id) = item.get("id").and_then(Value::as_str) {
before_by_id.insert(id.to_string(), item);
}
}
let mut after_ids = std::collections::HashSet::new();
let mut upserts: Vec<Value> = Vec::new();
for item in &after {
let Some(id) = item.get("id").and_then(Value::as_str) else {
continue;
};
after_ids.insert(id.to_string());
if before_by_id.get(id) != Some(&item) {
upserts.push(item.clone());
}
}
let deletes: Vec<&String> = before_by_id
.keys()
.filter(|id| !after_ids.contains(*id))
.collect();
serde_json::to_string(&json!({ "upserts": upserts, "deletes": deletes })).ok()
}
/// New entries of an id-keyed item list that weren't present before (e.g. status lists
/// like watchlist/completed/dropped, which are append-only from the merge's perspective).
pub(crate) fn item_list_new_entries_json(before_json: &str, after_json: &str) -> Option<String> {
let before: Vec<Value> = serde_json::from_str(before_json).ok()?;
let after: Vec<Value> = serde_json::from_str(after_json).ok()?;
let before_ids: std::collections::HashSet<String> = before
.iter()
.filter_map(|item| {
item.get("id")
.and_then(Value::as_str)
.map(|s| s.to_string())
})
.collect();
let new_entries: Vec<&Value> = after
.iter()
.filter(|item| {
item.get("id")
.and_then(Value::as_str)
.is_some_and(|id| !before_ids.contains(id))
})
.collect();
serde_json::to_string(&new_entries).ok()
}

View file

@ -0,0 +1,606 @@
use serde_json::{json, Value};
pub(crate) const UP_NEXT_POSITION_SECONDS: i64 = 0;
pub(crate) const UP_NEXT_DURATION_SECONDS: i64 = 0;
const UNSTARTED_PLACEHOLDER_DURATION_SECONDS: f64 = 86_400.0;
pub(crate) fn is_up_next_item(item: &Value) -> bool {
let offset = item
.get("timeOffset")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let duration = item.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
if offset <= 1.0 && duration > UNSTARTED_PLACEHOLDER_DURATION_SECONDS {
return true;
}
if duration <= 0.0 {
return offset <= 1.0;
}
offset / duration >= 0.995
}
pub(crate) fn build_continue_watching_from_progress_json(progress_json: &str) -> Option<String> {
let progress: serde_json::Map<String, Value> = serde_json::from_str(progress_json).ok()?;
let mut items: Vec<Value> = progress.values()
.filter_map(|entry| {
let offset = entry.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0);
let duration = entry.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
let has_video_id = entry.get("lastVideoId").and_then(Value::as_str).filter(|s| !s.is_empty()).is_some();
// Include: items with real progress OR up-next entries (offset=0 but has lastVideoId)
let include = (offset > 0.0 && duration > 0.0 && offset / duration < 0.95)
|| (offset == 0.0 && has_video_id);
if !include { return None; }
let meta = entry.get("meta")?;
let id = meta.get("id").and_then(Value::as_str).unwrap_or("");
if id.is_empty() { return None; }
Some(json!({
"id": id,
"name": meta.get("name").and_then(Value::as_str).unwrap_or(""),
"type": meta.get("type").and_then(Value::as_str).unwrap_or(""),
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
"background": meta.get("background").cloned().unwrap_or(Value::Null),
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
"timeOffset": offset as i64,
"duration": duration as i64,
"lastVideoId": entry.get("lastVideoId").cloned().unwrap_or(Value::Null),
"lastEpisodeName": entry.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
"lastEpisodeSeason": entry.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
"lastEpisodeNumber": entry.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": entry.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
"lastStreamUrl": entry.get("lastStreamUrl").cloned().unwrap_or(Value::Null),
"lastStreamTitle": entry.get("lastStreamTitle").cloned().unwrap_or(Value::Null),
"lastStream": entry.get("lastStream").cloned().unwrap_or(Value::Null),
"continueWatchingBadge": entry.get("continueWatchingBadge").cloned().unwrap_or(Value::Null),
"continueWatchingEpisodeResolved": entry.get("continueWatchingEpisodeResolved").cloned().unwrap_or(Value::Null),
"savedAt": entry.get("savedAt").cloned().unwrap_or(Value::Null),
"source": entry.get("source").cloned().unwrap_or(Value::Null),
}))
})
.collect();
items.sort_by(|a, b| {
let a = a.get("savedAt").and_then(Value::as_str).unwrap_or("");
let b = b.get("savedAt").and_then(Value::as_str).unwrap_or("");
b.cmp(a)
});
serde_json::to_string(&items).ok()
}
pub(crate) fn compute_continue_watching_badges_json(
candidates_json: &str,
videos_by_series_json: &str,
last_watched_json: &str,
now_ms: i64,
) -> Option<String> {
let mut by_id: std::collections::HashMap<String, Value> = {
let items: Vec<Value> = serde_json::from_str(candidates_json).unwrap_or_default();
items
.into_iter()
.filter_map(|item| {
let id = item
.get("id")
.or_else(|| item.get("_id"))
.and_then(Value::as_str)
.map(str::to_string)?;
Some((id, item))
})
.collect()
};
let videos_by_series: serde_json::Map<String, Value> =
serde_json::from_str(videos_by_series_json).unwrap_or_default();
let last_watched: serde_json::Map<String, Value> =
serde_json::from_str(last_watched_json).unwrap_or_default();
// Track which IDs came from the real CW lists vs only from lastWatchedEpisodes.
// Candidates added only from lastWatchedEpisodes are removed when no video data
// is available to confirm a next episode exists, preventing phantom CW entries.
let cw_list_ids: std::collections::HashSet<String> = by_id.keys().cloned().collect();
seed_candidates_from_last_watched(&mut by_id, &last_watched);
let mut finished_series: Vec<String> = Vec::new();
for (series_id, candidate) in by_id.iter_mut() {
let next =
match next_episode_for_candidate(series_id, candidate, &videos_by_series, &cw_list_ids)
{
NextEpisodeOutcome::Skip => continue,
NextEpisodeOutcome::MarkFinished => {
finished_series.push(series_id.clone());
continue;
}
NextEpisodeOutcome::Found(next) => next,
};
let videos = videos_by_series
.get(series_id)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
apply_next_episode_badge(series_id, candidate, &next, videos, now_ms);
}
for id in &finished_series {
by_id.remove(id);
}
let mut result: Vec<Value> = by_id.into_values().collect();
result.sort_by(|a, b| {
let a_new = a.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
let b_new = b.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
if a_new != b_new {
return if a_new {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let a_time = a
.get("savedAt")
.or_else(|| a.get("newEpisodeReleasedAt"))
.and_then(Value::as_str)
.unwrap_or("");
let b_time = b
.get("savedAt")
.or_else(|| b.get("newEpisodeReleasedAt"))
.and_then(Value::as_str)
.unwrap_or("");
b_time.cmp(a_time)
});
serde_json::to_string(&result).ok()
}
// Adds a synthetic CW candidate for any series that only has a lastWatchedEpisodes
// record (no real continue-watching entry yet) so its next-episode badge still gets
// computed below; `by_id.entry(...).or_insert_with` leaves real entries untouched.
fn seed_candidates_from_last_watched(
by_id: &mut std::collections::HashMap<String, Value>,
last_watched: &serde_json::Map<String, Value>,
) {
for (series_id, raw) in last_watched {
let meta = match raw.get("meta") {
Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m,
_ => continue,
};
let record = raw;
by_id.entry(series_id.clone()).or_insert_with(|| json!({
"id": series_id,
"_id": series_id,
"type": "series",
"name": meta.get("name").cloned().unwrap_or(Value::Null),
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
"background": meta.get("background").cloned().unwrap_or(Value::Null),
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
"lastVideoId": record.get("lastVideoId").cloned().unwrap_or(Value::Null),
"lastEpisodeName": record.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
"lastEpisodeSeason": record.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
"lastEpisodeNumber": record.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": record.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
"timeOffset": UP_NEXT_POSITION_SECONDS,
"duration": UP_NEXT_DURATION_SECONDS,
"savedAt": record.get("watchedAt").cloned().unwrap_or(Value::Null),
}));
}
}
enum NextEpisodeOutcome {
Skip,
MarkFinished,
Found(Value),
}
// Decides what's next for one candidate: skip it untouched, mark its series as
// finished (to be dropped from the result), or hand back the episode to advance to.
fn next_episode_for_candidate(
series_id: &str,
candidate: &Value,
videos_by_series: &serde_json::Map<String, Value>,
cw_list_ids: &std::collections::HashSet<String>,
) -> NextEpisodeOutcome {
if candidate.get("type").and_then(Value::as_str) != Some("series") {
return NextEpisodeOutcome::Skip;
}
if !is_up_next_item(candidate) {
return NextEpisodeOutcome::Skip;
}
let Some(season) = candidate.get("lastEpisodeSeason").and_then(Value::as_i64) else {
return NextEpisodeOutcome::Skip;
};
let Some(episode) = candidate.get("lastEpisodeNumber").and_then(Value::as_i64) else {
return NextEpisodeOutcome::Skip;
};
let Some(videos) = videos_by_series.get(series_id).and_then(Value::as_array) else {
// No video data available. If this entry exists only because of
// lastWatchedEpisodes (not from any real CW list), conservatively
// remove it — we cannot confirm a next episode exists. It will
// reappear on the next home load once the addon responds.
return if !cw_list_ids.contains(series_id) {
NextEpisodeOutcome::MarkFinished
} else {
NextEpisodeOutcome::Skip
};
};
let stored_badge = candidate
.get("continueWatchingBadge")
.and_then(Value::as_str);
if stored_badge == Some("upNext")
&& candidate
.get("continueWatchingEpisodeResolved")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return NextEpisodeOutcome::Skip;
}
let stored_video_id = candidate
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
// When the stored badge is scheduledEpisode, lastEpisodeNumber already points to the
// scheduled episode itself. Re-check that same episode rather than advancing past it.
let next = if stored_badge == Some("scheduledEpisode") {
videos
.iter()
.find(|v| {
let vid = v
.get("id")
.or_else(|| v.get("_id"))
.and_then(Value::as_str)
.unwrap_or("");
vid == stored_video_id
})
.cloned()
.or_else(|| first_episode_after(videos, season, episode))
} else {
first_episode_after(videos, season, episode)
};
// No next episode and we have real video data — the series is fully watched.
// Remove it from Continue Watching instead of leaving a zombie entry.
match next {
Some(v) => NextEpisodeOutcome::Found(v),
None => NextEpisodeOutcome::MarkFinished,
}
}
// Computes the badge (upNext / newEpisode / scheduledEpisode) for advancing `candidate`
// to `next`, and rewrites `candidate` in place to point at that episode.
fn apply_next_episode_badge(
series_id: &str,
candidate: &mut Value,
next: &Value,
videos: &[Value],
now_ms: i64,
) {
let existing_video_id = candidate
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let next_id = next
.get("id")
.or_else(|| next.get("_id"))
.and_then(Value::as_str)
.unwrap_or(&existing_video_id)
.to_string();
if !is_up_next_item(candidate) && existing_video_id != next_id {
return;
}
let is_new_target = existing_video_id != next_id;
let is_released = is_episode_released(next, now_ms);
let existing_badge = if !is_new_target {
candidate
.get("continueWatchingBadge")
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
};
let badge = if !is_released {
"scheduledEpisode"
} else if existing_badge.as_deref() == Some("scheduledEpisode") {
"newEpisode"
} else if let Some(b) = existing_badge.as_deref() {
b
} else {
let watched_at = candidate
.get("savedAt")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis())
.unwrap_or(now_ms);
let next_released_at = next
.get("released")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis())
.unwrap_or(0);
let was_released_when_watched =
next.get("released").is_none() || next_released_at <= watched_at;
if was_released_when_watched {
"upNext"
} else {
"newEpisode"
}
}
.to_string();
let released_str = next
.get("released")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let saved_at_new = if is_new_target && badge == "newEpisode" {
Value::String(chrono::Utc::now().to_rfc3339())
} else {
candidate.get("savedAt").cloned().unwrap_or(Value::Null)
};
let unwatched_ahead = if badge == "scheduledEpisode" {
0
} else {
let next_season = next.get("season").and_then(Value::as_i64).unwrap_or(0);
let next_episode = next
.get("episode")
.or_else(|| next.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
count_released_after(videos, next_season, next_episode, now_ms)
};
*candidate = json!({
"id": series_id,
"_id": series_id,
"type": "series",
"name": candidate.get("name").cloned().unwrap_or(Value::Null),
"poster": candidate.get("poster").cloned().unwrap_or(Value::Null),
"background": candidate.get("background").cloned().unwrap_or(Value::Null),
"logo": candidate.get("logo").cloned().unwrap_or(Value::Null),
"timeOffset": UP_NEXT_POSITION_SECONDS,
"duration": UP_NEXT_DURATION_SECONDS,
"lastVideoId": next_id,
"lastEpisodeName": next.get("name").or_else(|| next.get("title")).cloned().unwrap_or(Value::Null),
"lastEpisodeSeason": next.get("season").cloned().unwrap_or(Value::Null),
"lastEpisodeNumber": next.get("episode").or_else(|| next.get("number")).cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": next.get("thumbnail")
.filter(|v| v.as_str().map_or(!v.is_null(), |s| !s.trim().is_empty()))
.cloned()
.or_else(|| if !is_new_target {
candidate.get("lastEpisodeThumbnail").cloned().filter(|v| v.as_str().map_or(!v.is_null(), |s| !s.trim().is_empty()))
} else {
None
})
.unwrap_or(Value::Null),
"continueWatchingBadge": badge,
"newEpisodeReleasedAt": released_str,
"savedAt": saved_at_new,
"source": candidate.get("source").cloned().unwrap_or(Value::Null),
"reason": candidate.get("reason").cloned().unwrap_or(Value::Null),
"unwatchedAhead": unwatched_ahead,
});
}
fn count_released_after(videos: &[Value], season: i64, episode: i64, now_ms: i64) -> i64 {
videos
.iter()
.filter(|v| {
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
let ve = v
.get("episode")
.or_else(|| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
(vs > season || (vs == season && ve > episode)) && is_episode_released(v, now_ms)
})
.count() as i64
}
fn first_episode_after(videos: &[Value], season: i64, episode: i64) -> Option<Value> {
let mut candidates: Vec<&Value> = videos
.iter()
.filter(|v| {
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
let ve = v
.get("episode")
.or_else(|| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
vs > season || (vs == season && ve > episode)
})
.collect();
candidates.sort_by(|a, b| {
let as_ = a.get("season").and_then(Value::as_i64).unwrap_or(0);
let bs = b.get("season").and_then(Value::as_i64).unwrap_or(0);
if as_ != bs {
return as_.cmp(&bs);
}
let ae = a
.get("episode")
.or_else(|| a.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let be = b
.get("episode")
.or_else(|| b.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
ae.cmp(&be)
});
candidates.first().map(|v| (*v).clone())
}
pub(crate) fn is_episode_released(video: &Value, now_ms: i64) -> bool {
let released = match video.get("released").and_then(Value::as_str) {
Some(s) => s,
None => return true,
};
match chrono::DateTime::parse_from_rfc3339(released) {
Ok(dt) => dt.timestamp_millis() <= now_ms,
Err(_) => true,
}
}
/// Given a library JSON and a set of just-watched video IDs, update `lastWatchedEpisodes`.
/// Returns the updated library as JSON.
pub(crate) fn remember_last_watched_episodes_json(
lib_json: &str,
watched_ids_json: &str,
) -> String {
let mut lib: Value = serde_json::from_str(lib_json).unwrap_or(json!({}));
let watched_ids: std::collections::HashSet<String> = serde_json::from_str(watched_ids_json)
.ok()
.and_then(|v: Value| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
})
})
.unwrap_or_default();
let progress = lib
.get("progress")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut last_watched = lib
.get("lastWatchedEpisodes")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (series_id, raw) in &progress {
let video_id = raw.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
if video_id.is_empty() || !watched_ids.contains(video_id) {
continue;
}
let meta = match raw.get("meta") {
Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m,
_ => continue,
};
last_watched.insert(series_id.clone(), json!({
"meta": meta,
"lastVideoId": video_id,
"lastEpisodeName": raw.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
"lastEpisodeSeason": raw.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
"lastEpisodeNumber": raw.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": raw.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
"watchedAt": chrono::Utc::now().to_rfc3339(),
}));
}
if let Some(obj) = lib.as_object_mut() {
obj.insert(
"lastWatchedEpisodes".to_string(),
Value::Object(last_watched),
);
}
serde_json::to_string(&lib).unwrap_or_else(|_| lib_json.to_string())
}
/// Returns the next episode after (current_season, current_episode).
/// If released_only is true, episodes whose `released` date is in the future
/// (relative to now_ms) are excluded.
pub(crate) fn resolve_next_episode_json(
videos_json: &str,
current_season: i64,
current_episode: i64,
now_ms: i64,
released_only: bool,
) -> Option<String> {
let videos: Vec<Value> = serde_json::from_str(videos_json).ok()?;
let filtered: Vec<&Value> = videos
.iter()
.filter(|v| !released_only || is_episode_released(v, now_ms))
.collect();
let next = first_episode_after(
&filtered.into_iter().cloned().collect::<Vec<_>>(),
current_season,
current_episode,
)?;
serde_json::to_string(&next).ok()
}
pub(crate) fn resolve_next_after_watched_json(request_json: &str) -> Option<String> {
let request: Value = serde_json::from_str(request_json).ok()?;
let watched = request.get("watchedEpisodes")?.as_array()?;
let last = watched.iter().max_by_key(|episode| {
episode.get("season").and_then(Value::as_i64).unwrap_or(1) * 10_000
+ episode
.get("episode")
.or_else(|| episode.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0)
})?;
resolve_next_episode_json(
&request.get("videos")?.to_string(),
last.get("season").and_then(Value::as_i64).unwrap_or(1),
last.get("episode")
.or_else(|| last.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0),
request.get("nowMs").and_then(Value::as_i64).unwrap_or(0),
false,
)
}
pub(crate) fn next_progress_info_plan_json(request_json: &str) -> Option<String> {
let request: Value = serde_json::from_str(request_json).ok()?;
let next: Value = serde_json::from_str(&resolve_next_after_watched_json(request_json)?).ok()?;
let content_id = request.get("contentId")?.as_str()?.trim();
if content_id.is_empty() {
return None;
}
serde_json::to_string(&json!({
"contentId": content_id,
"contentType": request.get("contentType").and_then(Value::as_str).unwrap_or("series"),
"videoId": next.get("id")?,
"positionSeconds": UP_NEXT_POSITION_SECONDS,
"durationSeconds": UP_NEXT_DURATION_SECONDS,
"lastWatched": request.get("nowMs").and_then(Value::as_i64).unwrap_or(0),
"season": next.get("season"),
"episode": next.get("episode").or_else(|| next.get("number")),
}))
.ok()
}
/// Formats a "S1:E2 Episode Name" line from the episode progress fields.
/// Falls back to parsing season/episode from lastVideoId when the explicit
/// season/episode numbers are absent.
pub(crate) fn format_episode_line_json(
last_episode_name: Option<&str>,
last_episode_season: Option<i64>,
last_episode_number: Option<i64>,
last_video_id: Option<&str>,
) -> String {
let mut season = last_episode_season;
let mut episode = last_episode_number;
if season.is_none() || episode.is_none() {
if let Some(id) = last_video_id.filter(|id| !id.is_empty()) {
let parts: Vec<&str> = id.split(':').collect();
if parts.len() >= 3 {
if let (Ok(s), Ok(e)) = (
parts[parts.len() - 2].parse::<i64>(),
parts[parts.len() - 1].parse::<i64>(),
) {
if s > 0 && e > 0 {
if season.is_none() {
season = Some(s);
}
if episode.is_none() {
episode = Some(e);
}
}
}
}
}
}
let code = match (season, episode) {
(Some(s), Some(e)) => format!("S{s}:E{e}"),
_ => String::new(),
};
let name = last_episode_name.map(str::trim).unwrap_or("").to_string();
[code, name]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}

View file

@ -0,0 +1,9 @@
use serde_json::Value;
pub(crate) fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value.get(key).and_then(Value::as_str)
}
pub(crate) fn number(value: &Value, key: &str) -> Option<i64> {
value.get(key).and_then(Value::as_i64)
}

View file

@ -0,0 +1,207 @@
use super::continue_watching::is_up_next_item;
use super::helpers::{number, text};
use serde_json::{json, Value};
pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option<String> {
let mut items: Vec<Value> = serde_json::from_str(items_json).ok()?;
items.retain(|item| {
let state = item.get("state").unwrap_or(&Value::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
});
items.sort_by(|a, b| {
let a = a
.get("state")
.and_then(|state| text(state, "lastWatched"))
.unwrap_or("");
let b = b
.get("state")
.and_then(|state| text(state, "lastWatched"))
.unwrap_or("");
b.cmp(a)
});
let metas = items
.into_iter()
.map(|item| {
let state = item.get("state").unwrap_or(&Value::Null);
json!({
"id": text(&item, "_id").unwrap_or(""),
"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),
"logo": item.get("logo").cloned().unwrap_or(Value::Null),
"description": Value::Null,
"timeOffset": number(state, "timeOffset"),
"duration": number(state, "duration"),
"lastVideoId": text(state, "videoId"),
"reason": "stremio"
})
})
.collect::<Vec<_>>();
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,
) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let trakt: Value = serde_json::from_str(trakt_watched_json).unwrap_or(Value::Null);
let movie_keys: std::collections::HashSet<&str> = trakt
.get("movieKeys")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let episode_keys: std::collections::HashSet<&str> = trakt
.get("episodeKeys")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let filtered: Vec<&Value> = items
.iter()
.filter(|item| {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
let last_video_id = item
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("");
let time_offset = item.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
let duration = item.get("duration").and_then(Value::as_i64).unwrap_or(0);
let is_series = matches!(item_type, "series" | "tv" | "anime");
let is_up_next =
is_series && !last_video_id.is_empty() && time_offset <= 0 && duration <= 0;
let has_progress = time_offset > 0 && duration > 0;
if !is_up_next && !has_progress {
return false;
}
let watched_keys = crate::content_identity::content_watched_keys_value(item);
if item_type == "movie"
&& !movie_keys.is_empty()
&& watched_keys.iter().any(|k| movie_keys.contains(k.as_str()))
{
return false;
}
if is_series && !episode_keys.is_empty() && !last_video_id.is_empty() {
if let Some((_, season, episode)) =
crate::content_identity::parse_episode_locator(last_video_id)
{
if watched_keys.iter().any(|k| {
let candidate = format!("{k}:{season}:{episode}");
episode_keys.contains(candidate.as_str())
}) {
return false;
}
}
}
true
})
.collect::<Vec<_>>();
let mut ranked = filtered;
ranked.sort_by_key(|item| {
std::cmp::Reverse(
item.get("lastWatchedAt")
.and_then(Value::as_i64)
.unwrap_or(0),
)
});
serde_json::to_string(&ranked).ok()
}
pub(crate) fn watched_video_ids_json(items_json: &str, imdb_id: &str) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let ids = items
.iter()
.filter(|item| {
text(item, "_id").is_some_and(|id| id.starts_with(imdb_id))
&& item
.get("state")
.and_then(|state| number(state, "flaggedWatched"))
== Some(1)
})
.filter_map(|item| text(item, "_id").map(str::to_string))
.collect::<Vec<_>>();
serde_json::to_string(&ids).ok()
}
pub(crate) fn normalize_library_document_json(json: &str) -> String {
let mut lib: serde_json::Map<String, Value> = serde_json::from_str(json).unwrap_or_default();
lib.insert("schemaVersion".to_string(), json!(2));
if !lib.get("watchlist").map(Value::is_array).unwrap_or(false) {
lib.insert("watchlist".to_string(), json!([]));
}
if !lib.get("history").map(Value::is_array).unwrap_or(false) {
lib.insert("history".to_string(), json!([]));
}
if !lib
.get("continueWatching")
.map(Value::is_array)
.unwrap_or(false)
{
lib.insert("continueWatching".to_string(), json!([]));
}
if !lib
.get("progress")
.map(|v| v.is_object() && !v.is_array())
.unwrap_or(false)
{
lib.insert("progress".to_string(), json!({}));
}
if !lib
.get("watched")
.map(|v| v.is_object() && !v.is_array())
.unwrap_or(false)
{
lib.insert("watched".to_string(), json!({}));
}
if !lib.get("dropped").map(Value::is_array).unwrap_or(false) {
lib.insert("dropped".to_string(), json!([]));
}
if !lib.get("completed").map(Value::is_array).unwrap_or(false) {
lib.insert("completed".to_string(), json!([]));
}
serde_json::to_string(&Value::Object(lib)).unwrap_or_else(|_| "{}".to_string())
}
pub(crate) fn is_up_next_continue_watching_item_json(item_json: &str) -> bool {
let item: Value = serde_json::from_str(item_json).unwrap_or(Value::Null);
is_up_next_item(&item)
}

View file

@ -0,0 +1,197 @@
use super::continue_watching::build_continue_watching_from_progress_json;
use super::helpers::text;
use serde_json::{json, Value};
fn library_item_from_meta(meta: &Value, state: Value, last_watched: Option<&str>) -> Value {
let mut item = json!({
"_id": text(meta, "id").unwrap_or(""),
"name": text(meta, "name").unwrap_or(""),
"type": text(meta, "type").unwrap_or(""),
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
"background": meta.get("background").cloned().unwrap_or(Value::Null),
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
"state": state
});
if let Some(last_watched) = last_watched {
item["lastWatched"] = Value::String(last_watched.to_string());
}
item
}
pub(crate) fn playback_progress_item_json(
meta_json: &str,
time_offset: i64,
duration: i64,
now_utc: &str,
) -> Option<String> {
let meta: Value = serde_json::from_str(meta_json).ok()?;
let item = library_item_from_meta(
&meta,
json!({
"lastWatched": now_utc,
"timeOffset": time_offset,
"duration": duration
}),
None,
);
serde_json::to_string(&item).ok()
}
pub(crate) fn clear_playback_progress_item_json(meta_json: &str) -> Option<String> {
let meta: Value = serde_json::from_str(meta_json).ok()?;
let item = library_item_from_meta(
&meta,
json!({
"lastWatched": Value::Null,
"timeOffset": 0,
"duration": 0,
"videoId": Value::Null,
"timesWatched": 0,
"flaggedWatched": 0
}),
None,
);
serde_json::to_string(&item).ok()
}
pub(crate) fn clear_playback_progress_plan_json(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let mut library = args.get("library")?.clone();
let meta = args.get("meta")?;
let id = text(meta, "id")?.to_string();
let preserve_last_watched = args
.get("preserveLastWatched")
.and_then(Value::as_bool)
.unwrap_or(false);
let drop_continue_watching = args
.get("dropContinueWatching")
.and_then(Value::as_bool)
.unwrap_or(false);
let now_iso = args.get("nowIso").and_then(Value::as_str).unwrap_or("");
let document = library.as_object_mut()?;
let progress = document
.entry("progress")
.or_insert_with(|| json!({}))
.as_object_mut()?;
progress.remove(&id);
let progress_json = serde_json::to_string(progress).ok()?;
document.insert(
"continueWatching".to_string(),
serde_json::from_str(&build_continue_watching_from_progress_json(&progress_json)?).ok()?,
);
let mut removed_external = false;
let mut dropped_external = Value::Null;
if let Some(external) = document
.entry("externalContinueWatching")
.or_insert_with(|| json!([]))
.as_array_mut()
{
let before = external.len();
dropped_external = external
.iter()
.find(|item| text(item, "id") == Some(&id))
.cloned()
.unwrap_or(Value::Null);
external.retain(|item| text(item, "id") != Some(&id));
removed_external = external.len() != before;
}
if drop_continue_watching {
document
.entry("dismissedContinueWatching")
.or_insert_with(|| json!({}))
.as_object_mut()?
.insert(id.clone(), Value::String(now_iso.to_string()));
}
let mut last_watched_entry = Value::Null;
if preserve_last_watched
&& meta
.get("lastVideoId")
.is_some_and(|value| !value.is_null())
{
last_watched_entry = json!({
"meta": {
"id": id,
"type": meta.get("type").cloned().unwrap_or_else(|| json!("series")),
"name": meta.get("name").cloned().unwrap_or(Value::Null),
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
"background": meta.get("background").cloned().unwrap_or(Value::Null),
},
"lastVideoId": meta.get("lastVideoId").cloned().unwrap_or(Value::Null),
"lastEpisodeSeason": meta.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
"lastEpisodeNumber": meta.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
"lastEpisodeName": meta.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": meta.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
"savedAt": now_iso,
});
document
.entry("lastWatchedEpisodes")
.or_insert_with(|| json!({}))
.as_object_mut()?
.insert(id.clone(), last_watched_entry.clone());
} else if !preserve_last_watched {
document
.entry("lastWatchedEpisodes")
.or_insert_with(|| json!({}))
.as_object_mut()?
.remove(&id);
}
serde_json::to_string(&json!({
"library": library,
"contentId": id,
"lastWatchedEntry": last_watched_entry,
"removedExternalContinueWatching": removed_external,
"droppedExternalContinueWatching": dropped_external,
}))
.ok()
}
pub(crate) fn watched_state_items_json(
meta_json: &str,
episodes_json: &str,
watched: bool,
watched_at: Option<&str>,
) -> Option<String> {
let meta: Value = serde_json::from_str(meta_json).ok()?;
let episodes: Vec<Value> = serde_json::from_str(episodes_json).unwrap_or_default();
let watched_value = if watched { 1 } else { 0 };
let watched_at_value = watched_at
.map(|value| Value::String(value.to_string()))
.unwrap_or(Value::Null);
let items = if text(&meta, "type") == Some("series") && !episodes.is_empty() {
episodes
.iter()
.map(|episode| {
json!({
"_id": text(episode, "id").unwrap_or(""),
"name": text(episode, "name").or_else(|| text(&meta, "name")).unwrap_or(""),
"type": "series",
"poster": episode.get("thumbnail").cloned().unwrap_or(Value::Null),
"background": meta.get("background").cloned().unwrap_or(Value::Null),
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
"state": {
"lastWatched": watched_at_value,
"timeOffset": 0,
"duration": 0,
"videoId": text(episode, "id").unwrap_or(""),
"timesWatched": watched_value,
"flaggedWatched": watched_value
},
"lastWatched": watched_at_value
})
})
.collect::<Vec<_>>()
} else {
vec![library_item_from_meta(
&meta,
json!({
"lastWatched": watched_at_value,
"timeOffset": 0,
"duration": 0,
"videoId": Value::Null,
"timesWatched": watched_value,
"flaggedWatched": watched_value
}),
watched_at,
)]
};
serde_json::to_string(&items).ok()
}