Skip to main content

jellytau_lib/repository/
series_progress.rs

1//! Where a viewer is in a TV series.
2//!
3//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
4//! means ("in progress", "played") and what Jellyfin's season numbering means
5//! (season 0 is specials). The frontend asks for *the* current episode and
6//! renders it; it does not get to decide what "current" means.
7//!
8//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
9//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
10//! can be unit-tested without standing up a repository.
11//!
12//! TRACES: UR-062 | DR-101
13
14use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
15
16/// Jellyfin files specials under season 0.
17const SPECIALS_SEASON: i32 = 0;
18
19/// Below this fraction watched, a position is a false start rather than
20/// progress — the same threshold the resume dialog uses.
21const MIN_PROGRESS_FRACTION: f64 = 0.01;
22
23/// Above this fraction watched, an episode is effectively finished; resuming it
24/// would drop the viewer into the closing credits.
25const MAX_PROGRESS_FRACTION: f64 = 0.95;
26
27/// Sort key for a season number. Specials sort *after* every numbered season:
28/// a viewer works through S1, S2, … and only then the extras, so season 0 must
29/// not lead just because `0 < 1`.
30fn season_rank(season: Option<i32>) -> i64 {
31    match season {
32        Some(SPECIALS_SEASON) => i64::MAX,
33        Some(n) => n as i64,
34        None => i64::MAX - 1,
35    }
36}
37
38/// Order episodes as the series is watched: season ascending, then episode,
39/// specials last.
40pub fn sort_series_order(episodes: &mut [MediaItem]) {
41    episodes.sort_by(|a, b| {
42        season_rank(a.parent_index_number)
43            .cmp(&season_rank(b.parent_index_number))
44            .then(
45                a.index_number
46                    .unwrap_or(0)
47                    .cmp(&b.index_number.unwrap_or(0)),
48            )
49    });
50}
51
52/// Is this episode genuinely part-watched (not a false start, not finished)?
53fn is_in_progress(item: &MediaItem) -> bool {
54    let Some(user_data) = item.user_data.as_ref() else {
55        return false;
56    };
57    if user_data.is_played.unwrap_or(false) {
58        return false;
59    }
60
61    let position_ms = user_data
62        .playback_position_ms
63        .or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
64        .unwrap_or(0);
65    if position_ms <= 0 {
66        return false;
67    }
68
69    // Without a duration we cannot tell "2 minutes in" from "2 minutes left",
70    // so any recorded position counts as progress.
71    let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
72        return true;
73    };
74
75    let fraction = position_ms as f64 / duration_ms as f64;
76    (MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
77}
78
79fn is_played(item: &MediaItem) -> bool {
80    item.user_data
81        .as_ref()
82        .and_then(|u| u.is_played)
83        .unwrap_or(false)
84}
85
86fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
87    item.series_id.as_deref() == Some(series_id)
88}
89
90/// The episode a viewer should land on when they open `series_id`.
91///
92/// Order of preference, and why:
93///
94/// 1. **An episode in progress.** That is literally where playback stopped;
95///    Next Up would skip past it. On a tie the earliest in series order wins, so
96///    a viewer who dipped into a later episode still returns to the one they are
97///    working through.
98/// 2. **The server's Next Up** for this series — it accounts for watch history
99///    we do not cache locally.
100/// 3. **The episode after the furthest-watched one**, falling back to the first
101///    unwatched episode when nothing has been watched or the series is finished.
102///    This is the offline path: `OfflineRepository::get_next_up_episodes`
103///    returns an empty vec, so without this rung the whole feature would be
104///    online-only. It deliberately does *not* return the first unwatched
105///    episode outright — an unwatched episode behind the viewer's furthest
106///    point was skipped on purpose, and sending them back to it is the bug
107///    DR-101 was reopened for.
108/// 4. **The first episode**, so a never-watched series opens on its premiere
109///    rather than on nothing.
110///
111/// `next_up` / `resume` entries are honoured even when absent from `episodes`
112/// (the season fan-out can miss an id the server returns), but only when they
113/// belong to this series.
114pub fn pick_current_episode(
115    series_id: &str,
116    episodes: &[MediaItem],
117    next_up: &[MediaItem],
118    resume: &[MediaItem],
119) -> Option<MediaItem> {
120    // 1. In progress — prefer a match inside the ordered episode list so the
121    //    "earliest in series order" tie-break is meaningful; fall back to the
122    //    resume feed for an episode the fan-out missed.
123    if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
124        return Some(found.clone());
125    }
126    if let Some(found) = resume
127        .iter()
128        .find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
129    {
130        return Some(found.clone());
131    }
132
133    // 2. Next Up for this series.
134    if let Some(found) = next_up
135        .iter()
136        .find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
137    {
138        // Prefer the copy from `episodes` when we have one: it carries the
139        // user-data and images the list already fetched.
140        let matched = episodes.iter().find(|e| e.id == found.id);
141        return Some(matched.unwrap_or(found).clone());
142    }
143
144    // 3. The episode after the furthest-watched one. Not simply the first
145    //    unwatched: a viewer who skipped the pilot but is deep into season 3
146    //    must not be dragged back to S1E1. An earlier gap is a deliberate skip;
147    //    where they stopped is the *last* thing they watched.
148    if let Some(furthest) = episodes.iter().rposition(is_played) {
149        if let Some(found) = episodes.get(furthest + 1) {
150            return Some(found.clone());
151        }
152    }
153
154    // Nothing watched yet (or the furthest-watched episode is the finale):
155    // the first unwatched episode in series order.
156    if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
157        return Some(found.clone());
158    }
159
160    // 4. First episode — a fully-watched series reopens at the start.
161    episodes.first().cloned()
162}
163
164/// Every episode of a series, in series order.
165///
166/// Jellyfin hangs episodes off season folders, except for "flat" series whose
167/// children are episodes directly. Both shapes are provider vocabulary, so the
168/// fan-out and the fallback live here rather than in the frontend.
169pub async fn fetch_series_episodes(
170    repo: &dyn MediaRepository,
171    series_id: &str,
172) -> Result<Vec<MediaItem>, RepoError> {
173    let children = repo.get_items(series_id, list_options()).await?;
174
175    let mut episodes: Vec<MediaItem> = Vec::new();
176    for season in children.items.iter().filter(|i| is_season(i)) {
177        // One failing season must not blank the whole show.
178        match repo.get_items(&season.id, list_options()).await {
179            Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
180            Err(e) => {
181                log::warn!(
182                    "[series] season {} of {} failed to load: {:?}",
183                    season.id,
184                    series_id,
185                    e
186                );
187            }
188        }
189    }
190
191    // Flat series: the children *are* the episodes.
192    if episodes.is_empty() {
193        episodes.extend(children.items.into_iter().filter(is_episode));
194    }
195
196    sort_series_order(&mut episodes);
197    Ok(episodes)
198}
199
200/// Resolve the current episode, fetching everything the policy needs.
201///
202/// Next Up and resume are best-effort: offline they fail or come back empty, and
203/// `pick_current_episode` has fallbacks for exactly that.
204pub async fn resolve_current_episode(
205    repo: &dyn MediaRepository,
206    series_id: &str,
207) -> Result<Option<MediaItem>, RepoError> {
208    let episodes = fetch_series_episodes(repo, series_id).await?;
209
210    let next_up = repo
211        .get_next_up_episodes(Some(series_id), Some(1))
212        .await
213        .unwrap_or_default();
214    let resume = repo
215        .get_resume_items(Some(series_id), Some(10))
216        .await
217        .unwrap_or_default();
218
219    Ok(pick_current_episode(
220        series_id, &episodes, &next_up, &resume,
221    ))
222}
223
224fn list_options() -> Option<GetItemsOptions> {
225    Some(GetItemsOptions {
226        limit: Some(500),
227        ..Default::default()
228    })
229}
230
231fn is_season(item: &MediaItem) -> bool {
232    item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
233}
234
235fn is_episode(item: &MediaItem) -> bool {
236    item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::repository::UserData;
243
244    const SERIES: &str = "series-1";
245
246    fn episode(id: &str, season: i32, number: i32) -> MediaItem {
247        MediaItem {
248            id: id.to_string(),
249            name: format!("S{season}E{number}"),
250            item_type: "Episode".to_string(),
251            series_id: Some(SERIES.to_string()),
252            parent_index_number: Some(season),
253            index_number: Some(number),
254            duration_ms: Some(1_000_000),
255            ..Default::default()
256        }
257    }
258
259    fn watched(mut item: MediaItem) -> MediaItem {
260        item.user_data = Some(UserData {
261            is_played: Some(true),
262            ..Default::default()
263        });
264        item
265    }
266
267    fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
268        let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
269        item.user_data = Some(UserData {
270            is_played: Some(false),
271            playback_position_ms: Some((duration * fraction) as i64),
272            ..Default::default()
273        });
274        item
275    }
276
277    fn season(n: i32, count: i32) -> Vec<MediaItem> {
278        (1..=count)
279            .map(|i| episode(&format!("s{n}e{i}"), n, i))
280            .collect()
281    }
282
283    #[test]
284    fn sorts_by_season_then_episode() {
285        let mut eps = vec![
286            episode("b", 2, 1),
287            episode("d", 1, 10),
288            episode("a", 1, 2),
289            episode("c", 2, 2),
290        ];
291        sort_series_order(&mut eps);
292        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
293        assert_eq!(ids, ["a", "d", "b", "c"]);
294    }
295
296    #[test]
297    fn sorts_specials_after_numbered_seasons() {
298        let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
299        sort_series_order(&mut eps);
300        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
301        assert_eq!(ids, ["premiere", "special"]);
302    }
303
304    #[test]
305    fn picks_the_in_progress_episode_over_next_up() {
306        let mut eps = season(1, 5);
307        eps[0] = watched(eps[0].clone());
308        eps[1] = in_progress(eps[1].clone(), 0.4);
309        // The server would send us past it; the half-watched episode wins.
310        let next_up = vec![episode("s1e3", 1, 3)];
311
312        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
313        assert_eq!(current.id, "s1e2");
314    }
315
316    #[test]
317    fn picks_the_earliest_in_progress_episode() {
318        let mut eps = season(1, 5);
319        eps[1] = in_progress(eps[1].clone(), 0.3);
320        eps[3] = in_progress(eps[3].clone(), 0.5);
321
322        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
323        assert_eq!(current.id, "s1e2");
324    }
325
326    #[test]
327    fn ignores_a_false_start_and_a_finished_episode() {
328        let mut eps = season(1, 5);
329        eps[0] = watched(eps[0].clone());
330        eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
331        eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
332
333        // Neither counts as progress, so Next Up decides.
334        let next_up = vec![episode("s1e4", 1, 4)];
335        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
336        assert_eq!(current.id, "s1e4");
337    }
338
339    #[test]
340    fn falls_back_to_next_up_when_nothing_is_in_progress() {
341        let eps = season(1, 5);
342        let next_up = vec![episode("s1e3", 1, 3)];
343
344        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
345        assert_eq!(current.id, "s1e3");
346    }
347
348    #[test]
349    fn next_up_from_another_series_is_ignored() {
350        let eps = season(1, 3);
351        let mut foreign = episode("other-show-ep", 1, 1);
352        foreign.series_id = Some("series-2".to_string());
353
354        let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
355        assert_eq!(current.id, "s1e1");
356    }
357
358    /// The offline path: `OfflineRepository::get_next_up_episodes` returns an
359    /// empty vec, so the first unwatched episode has to carry the feature.
360    #[test]
361    fn falls_back_to_first_unwatched_when_next_up_is_empty() {
362        let mut eps = [season(1, 3), season(2, 3)].concat();
363        for ep in eps.iter_mut().take(4) {
364            *ep = watched(ep.clone());
365        }
366
367        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
368        assert_eq!(current.id, "s2e2");
369    }
370
371    /// A viewer deep in season 3 who never watched the pilot must not be sent
372    /// back to it: the gap was a skip, not the place they stopped.
373    #[test]
374    fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
375        let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
376        for ep in eps.iter_mut() {
377            // Everything through S3E3 watched, except the never-watched pilot.
378            let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
379            if watched_through && ep.id != "s1e1" {
380                *ep = watched(ep.clone());
381            }
382        }
383
384        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
385        assert_eq!(current.id, "s3e4");
386    }
387
388    /// The furthest-watched episode being a finale must still roll into the
389    /// next season rather than stopping the series.
390    #[test]
391    fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
392        let mut eps = [season(1, 3), season(2, 3)].concat();
393        for ep in eps.iter_mut() {
394            if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
395                *ep = watched(ep.clone());
396            }
397        }
398
399        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
400        assert_eq!(current.id, "s2e1");
401    }
402
403    /// Specials sort last, so watching one must not mark the series finished
404    /// while numbered episodes remain.
405    #[test]
406    fn a_watched_special_does_not_end_the_series() {
407        let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
408        sort_series_order(&mut eps);
409        for ep in eps.iter_mut() {
410            if ep.id == "s1e1" || ep.id == "s0e1" {
411                *ep = watched(ep.clone());
412            }
413        }
414
415        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
416        assert_eq!(current.id, "s1e2");
417    }
418
419    #[test]
420    fn crosses_a_season_boundary_when_a_season_is_finished() {
421        let mut eps = [season(1, 3), season(2, 3)].concat();
422        for ep in eps.iter_mut().take(3) {
423            *ep = watched(ep.clone());
424        }
425
426        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
427        assert_eq!(current.id, "s2e1");
428    }
429
430    #[test]
431    fn a_never_watched_series_opens_on_its_premiere() {
432        let eps = [season(2, 3), season(1, 3)].concat();
433        let mut ordered = eps.clone();
434        sort_series_order(&mut ordered);
435
436        let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
437        assert_eq!(current.id, "s1e1");
438    }
439
440    #[test]
441    fn a_fully_watched_series_reopens_at_the_start() {
442        let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
443
444        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
445        assert_eq!(current.id, "s1e1");
446    }
447
448    #[test]
449    fn honours_a_resume_entry_missing_from_the_episode_list() {
450        // Season fan-out returned nothing usable, but the resume feed knows.
451        let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
452
453        let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
454        assert_eq!(current.id, "s3e7");
455    }
456
457    #[test]
458    fn resume_entries_from_other_series_are_ignored() {
459        let mut foreign = in_progress(episode("other", 1, 1), 0.5);
460        foreign.series_id = Some("series-2".to_string());
461
462        assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
463    }
464
465    #[test]
466    fn a_series_with_no_episodes_has_no_current_episode() {
467        assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
468    }
469
470    #[test]
471    fn an_episode_without_a_duration_still_counts_as_in_progress() {
472        let mut ep = episode("s1e2", 1, 2);
473        ep.duration_ms = None;
474        ep.user_data = Some(UserData {
475            is_played: Some(false),
476            playback_position_ms: Some(120_000),
477            ..Default::default()
478        });
479
480        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
481        assert_eq!(current.id, "s1e2");
482    }
483
484    #[test]
485    fn legacy_tick_positions_still_register_as_progress() {
486        let mut ep = episode("s1e2", 1, 2);
487        ep.user_data = Some(UserData {
488            is_played: Some(false),
489            // 400_000 ms expressed in Jellyfin ticks, no ms field.
490            playback_position_ticks: Some(400_000 * 10_000),
491            ..Default::default()
492        });
493
494        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
495        assert_eq!(current.id, "s1e2");
496    }
497}