From a651e4fa386be5bc1fd81aa4bd509dc73545be70 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:15:52 +0300 Subject: [PATCH] feat: add unwatchedAhead count to continue watching badges --- src/library_state.rs | 82 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/library_state.rs b/src/library_state.rs index 4c70eee..9dba6e8 100644 --- a/src/library_state.rs +++ b/src/library_state.rs @@ -375,7 +375,12 @@ pub(crate) fn compute_continue_watching_badges_json( } NextEpisodeOutcome::Found(next) => next, }; - apply_next_episode_badge(series_id, candidate, &next, now_ms); + 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 { @@ -523,7 +528,13 @@ fn next_episode_for_candidate( // 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, now_ms: i64) { +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) @@ -589,6 +600,18 @@ fn apply_next_episode_badge(series_id: &str, candidate: &mut Value, next: &Value 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, @@ -611,9 +634,25 @@ fn apply_next_episode_badge(series_id: &str, candidate: &mut Value, next: &Value "continueWatchingBadge": badge, "newEpisodeReleasedAt": released_str, "savedAt": saved_at_new, + "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 { let mut candidates: Vec<&Value> = videos .iter() @@ -1000,4 +1039,43 @@ mod tests { assert_eq!(result[0]["lastEpisodeNumber"], 3); assert_eq!(result[0]["continueWatchingBadge"], "upNext"); } + + #[test] + fn continue_watching_badges_count_only_released_episodes_ahead() { + let candidates = json!([{ + "id": "s1", + "_id": "s1", + "type": "series", + "lastVideoId": "s1:1:2", + "lastEpisodeSeason": 1, + "lastEpisodeNumber": 2, + "timeOffset": 1, + "duration": 99999, + "savedAt": "2020-02-01T00:00:00Z", + }]); + let videos_by_series = json!({ + "s1": [ + { "id": "s1:1:2", "season": 1, "episode": 2, "released": "2020-01-01T00:00:00Z" }, + { "id": "s1:1:3", "season": 1, "episode": 3, "released": "2020-01-08T00:00:00Z" }, + { "id": "s1:1:4", "season": 1, "episode": 4, "released": "2020-01-15T00:00:00Z" }, + { "id": "s1:1:5", "season": 1, "episode": 5, "released": "2099-01-01T00:00:00Z" }, + ], + }); + let now_ms = chrono::DateTime::parse_from_rfc3339("2021-01-01T00:00:00Z") + .unwrap() + .timestamp_millis(); + + let result = compute_continue_watching_badges_json( + &candidates.to_string(), + &videos_by_series.to_string(), + "{}", + now_ms, + ) + .and_then(|json| serde_json::from_str::(&json).ok()) + .expect("badges"); + let result = result.as_array().unwrap(); + + assert_eq!(result[0]["lastVideoId"], "s1:1:3"); + assert_eq!(result[0]["unwatchedAhead"], 1); + } }