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, DR-264 | UT-239
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
86/// Has the viewer reached the end of this episode?
87///
88/// The played *flag* is not enough. Nothing records completion locally — the
89/// stop report writes a position, and the cache mirror carries the server's
90/// flag only on the next refresh — so within seconds of an episode ending the
91/// only local evidence that it is over is its position, parked at the very end
92/// of its runtime. Leaving the player with Back reloads the series page inside
93/// that window (DR-264).
94fn is_finished(item: &MediaItem) -> bool {
95    if is_played(item) {
96        return true;
97    }
98
99    let Some(user_data) = item.user_data.as_ref() else {
100        return false;
101    };
102    let position_ms = user_data
103        .playback_position_ms
104        .or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
105        .unwrap_or(0);
106    // Without a duration a position says nothing about how much is left.
107    let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
108        return false;
109    };
110
111    position_ms as f64 / duration_ms as f64 >= MAX_PROGRESS_FRACTION
112}
113
114fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
115    item.series_id.as_deref() == Some(series_id)
116}
117
118/// The episode a viewer should land on when they open `series_id`.
119///
120/// Order of preference, and why:
121///
122/// 1. **An episode in progress.** That is literally where playback stopped;
123///    Next Up would skip past it. On a tie the earliest in series order wins, so
124///    a viewer who dipped into a later episode still returns to the one they are
125///    working through.
126/// 2. **The server's Next Up** for this series — it accounts for watch history
127///    we do not cache locally.
128/// 3. **The episode after the furthest-watched one**, falling back to the first
129///    unwatched episode when nothing has been watched or the series is finished.
130///    This is the offline path: `OfflineRepository::get_next_up_episodes`
131///    returns an empty vec, so without this rung the whole feature would be
132///    online-only. It deliberately does *not* return the first unwatched
133///    episode outright — an unwatched episode behind the viewer's furthest
134///    point was skipped on purpose, and sending them back to it is the bug
135///    DR-101 was reopened for.
136/// 4. **The first episode**, so a never-watched series opens on its premiere
137///    rather than on nothing.
138///
139/// `next_up` / `resume` entries are honoured even when absent from `episodes`
140/// (the season fan-out can miss an id the server returns), but only when they
141/// belong to this series.
142pub fn pick_current_episode(
143    series_id: &str,
144    episodes: &[MediaItem],
145    next_up: &[MediaItem],
146    resume: &[MediaItem],
147) -> Option<MediaItem> {
148    // 1. In progress — prefer a match inside the ordered episode list so the
149    //    "earliest in series order" tie-break is meaningful; fall back to the
150    //    resume feed for an episode the fan-out missed.
151    if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
152        return Some(found.clone());
153    }
154    if let Some(found) = resume
155        .iter()
156        .find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
157    {
158        return Some(found.clone());
159    }
160
161    // 2. Next Up for this series — unless it names an episode we can already
162    //    see is over. Next Up is the server's answer, and the server is one
163    //    stop-report behind for a moment after an episode ends; the local
164    //    position is not, so a finished candidate is dropped rather than handed
165    //    back as "up next" (DR-264).
166    if let Some(found) = next_up.iter().find(|e| {
167        if !(e.series_id.is_none() || belongs_to_series(e, series_id)) {
168            return false;
169        }
170        // Judge it by the copy from `episodes` when there is one: that is the
171        // copy carrying the local user-data.
172        let local = episodes.iter().find(|listed| listed.id == e.id);
173        !is_finished(local.unwrap_or(e))
174    }) {
175        // Prefer the copy from `episodes` when we have one: it carries the
176        // user-data and images the list already fetched.
177        let matched = episodes.iter().find(|e| e.id == found.id);
178        return Some(matched.unwrap_or(found).clone());
179    }
180
181    // 3. The episode after the furthest-watched one. Not simply the first
182    //    unwatched: a viewer who skipped the pilot but is deep into season 3
183    //    must not be dragged back to S1E1. An earlier gap is a deliberate skip;
184    //    where they stopped is the *last* thing they watched.
185    if let Some(furthest) = episodes.iter().rposition(is_finished) {
186        if let Some(found) = episodes.get(furthest + 1) {
187            return Some(found.clone());
188        }
189    }
190
191    // Nothing watched yet (or the furthest-watched episode is the finale):
192    // the first unwatched episode in series order.
193    if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
194        return Some(found.clone());
195    }
196
197    // 4. First episode — a fully-watched series reopens at the start.
198    episodes.first().cloned()
199}
200
201/// Every episode of a series, in series order.
202///
203/// Jellyfin hangs episodes off season folders, except for "flat" series whose
204/// children are episodes directly. Both shapes are provider vocabulary, so the
205/// fan-out and the fallback live here rather than in the frontend.
206pub async fn fetch_series_episodes(
207    repo: &dyn MediaRepository,
208    series_id: &str,
209) -> Result<Vec<MediaItem>, RepoError> {
210    let children = repo.get_items(series_id, list_options()).await?;
211
212    let mut episodes: Vec<MediaItem> = Vec::new();
213    for season in children.items.iter().filter(|i| is_season(i)) {
214        // One failing season must not blank the whole show.
215        match repo.get_items(&season.id, list_options()).await {
216            Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
217            Err(e) => {
218                log::warn!(
219                    "[series] season {} of {} failed to load: {:?}",
220                    season.id,
221                    series_id,
222                    e
223                );
224            }
225        }
226    }
227
228    // Flat series: the children *are* the episodes.
229    if episodes.is_empty() {
230        episodes.extend(children.items.into_iter().filter(is_episode));
231    }
232
233    sort_series_order(&mut episodes);
234    Ok(episodes)
235}
236
237/// Resolve the current episode, fetching everything the policy needs.
238///
239/// Next Up and resume are best-effort: offline they fail or come back empty, and
240/// `pick_current_episode` has fallbacks for exactly that.
241pub async fn resolve_current_episode(
242    repo: &dyn MediaRepository,
243    series_id: &str,
244) -> Result<Option<MediaItem>, RepoError> {
245    let episodes = fetch_series_episodes(repo, series_id).await?;
246
247    let next_up = repo
248        .get_next_up_episodes(Some(series_id), Some(1))
249        .await
250        .unwrap_or_default();
251    let resume = repo
252        .get_resume_items(Some(series_id), Some(10))
253        .await
254        .unwrap_or_default();
255
256    Ok(pick_current_episode(
257        series_id, &episodes, &next_up, &resume,
258    ))
259}
260
261fn list_options() -> Option<GetItemsOptions> {
262    Some(GetItemsOptions {
263        limit: Some(500),
264        ..Default::default()
265    })
266}
267
268fn is_season(item: &MediaItem) -> bool {
269    item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
270}
271
272fn is_episode(item: &MediaItem) -> bool {
273    item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::repository::UserData;
280
281    const SERIES: &str = "series-1";
282
283    fn episode(id: &str, season: i32, number: i32) -> MediaItem {
284        MediaItem {
285            id: id.to_string(),
286            name: format!("S{season}E{number}"),
287            item_type: "Episode".to_string(),
288            series_id: Some(SERIES.to_string()),
289            parent_index_number: Some(season),
290            index_number: Some(number),
291            duration_ms: Some(1_000_000),
292            ..Default::default()
293        }
294    }
295
296    fn watched(mut item: MediaItem) -> MediaItem {
297        item.user_data = Some(UserData {
298            is_played: Some(true),
299            ..Default::default()
300        });
301        item
302    }
303
304    fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
305        let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
306        item.user_data = Some(UserData {
307            is_played: Some(false),
308            playback_position_ms: Some((duration * fraction) as i64),
309            ..Default::default()
310        });
311        item
312    }
313
314    fn season(n: i32, count: i32) -> Vec<MediaItem> {
315        (1..=count)
316            .map(|i| episode(&format!("s{n}e{i}"), n, i))
317            .collect()
318    }
319
320    #[test]
321    fn sorts_by_season_then_episode() {
322        let mut eps = vec![
323            episode("b", 2, 1),
324            episode("d", 1, 10),
325            episode("a", 1, 2),
326            episode("c", 2, 2),
327        ];
328        sort_series_order(&mut eps);
329        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
330        assert_eq!(ids, ["a", "d", "b", "c"]);
331    }
332
333    #[test]
334    fn sorts_specials_after_numbered_seasons() {
335        let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
336        sort_series_order(&mut eps);
337        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
338        assert_eq!(ids, ["premiere", "special"]);
339    }
340
341    #[test]
342    fn picks_the_in_progress_episode_over_next_up() {
343        let mut eps = season(1, 5);
344        eps[0] = watched(eps[0].clone());
345        eps[1] = in_progress(eps[1].clone(), 0.4);
346        // The server would send us past it; the half-watched episode wins.
347        let next_up = vec![episode("s1e3", 1, 3)];
348
349        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
350        assert_eq!(current.id, "s1e2");
351    }
352
353    #[test]
354    fn picks_the_earliest_in_progress_episode() {
355        let mut eps = season(1, 5);
356        eps[1] = in_progress(eps[1].clone(), 0.3);
357        eps[3] = in_progress(eps[3].clone(), 0.5);
358
359        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
360        assert_eq!(current.id, "s1e2");
361    }
362
363    #[test]
364    fn ignores_a_false_start_and_a_finished_episode() {
365        let mut eps = season(1, 5);
366        eps[0] = watched(eps[0].clone());
367        eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
368        eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
369
370        // Neither counts as progress, so Next Up decides.
371        let next_up = vec![episode("s1e4", 1, 4)];
372        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
373        assert_eq!(current.id, "s1e4");
374    }
375
376    #[test]
377    fn falls_back_to_next_up_when_nothing_is_in_progress() {
378        let eps = season(1, 5);
379        let next_up = vec![episode("s1e3", 1, 3)];
380
381        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
382        assert_eq!(current.id, "s1e3");
383    }
384
385    #[test]
386    fn next_up_from_another_series_is_ignored() {
387        let eps = season(1, 3);
388        let mut foreign = episode("other-show-ep", 1, 1);
389        foreign.series_id = Some("series-2".to_string());
390
391        let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
392        assert_eq!(current.id, "s1e1");
393    }
394
395    /// The offline path: `OfflineRepository::get_next_up_episodes` returns an
396    /// empty vec, so the first unwatched episode has to carry the feature.
397    #[test]
398    fn falls_back_to_first_unwatched_when_next_up_is_empty() {
399        let mut eps = [season(1, 3), season(2, 3)].concat();
400        for ep in eps.iter_mut().take(4) {
401            *ep = watched(ep.clone());
402        }
403
404        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
405        assert_eq!(current.id, "s2e2");
406    }
407
408    /// A viewer deep in season 3 who never watched the pilot must not be sent
409    /// back to it: the gap was a skip, not the place they stopped.
410    #[test]
411    fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
412        let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
413        for ep in eps.iter_mut() {
414            // Everything through S3E3 watched, except the never-watched pilot.
415            let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
416            if watched_through && ep.id != "s1e1" {
417                *ep = watched(ep.clone());
418            }
419        }
420
421        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
422        assert_eq!(current.id, "s3e4");
423    }
424
425    /// The furthest-watched episode being a finale must still roll into the
426    /// next season rather than stopping the series.
427    #[test]
428    fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
429        let mut eps = [season(1, 3), season(2, 3)].concat();
430        for ep in eps.iter_mut() {
431            if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
432                *ep = watched(ep.clone());
433            }
434        }
435
436        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
437        assert_eq!(current.id, "s2e1");
438    }
439
440    /// Specials sort last, so watching one must not mark the series finished
441    /// while numbered episodes remain.
442    #[test]
443    fn a_watched_special_does_not_end_the_series() {
444        let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
445        sort_series_order(&mut eps);
446        for ep in eps.iter_mut() {
447            if ep.id == "s1e1" || ep.id == "s0e1" {
448                *ep = watched(ep.clone());
449            }
450        }
451
452        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
453        assert_eq!(current.id, "s1e2");
454    }
455
456    #[test]
457    fn crosses_a_season_boundary_when_a_season_is_finished() {
458        let mut eps = [season(1, 3), season(2, 3)].concat();
459        for ep in eps.iter_mut().take(3) {
460            *ep = watched(ep.clone());
461        }
462
463        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
464        assert_eq!(current.id, "s2e1");
465    }
466
467    /// The episode the viewer just finished must not still be "up next".
468    ///
469    /// Leaving the player with Back reloads the series page within a second of
470    /// the stop report, and Jellyfin's Next Up can still name the episode that
471    /// just ended. Locally we know better: the position sits at the very end of
472    /// its runtime.
473    ///
474    /// TRACES: UR-062 | DR-264 | UT-239
475    #[test]
476    fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
477        let mut eps = season(1, 5);
478        eps[0] = watched(eps[0].clone());
479        // Just finished: the position is at the end, the flag has not landed.
480        eps[1] = in_progress(eps[1].clone(), 0.99);
481        // The server has not caught up with the stop report.
482        let next_up = vec![episode("s1e2", 1, 2)];
483
484        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
485        assert_eq!(current.id, "s1e3");
486    }
487
488    /// The same, offline: with no Next Up to lean on, an episode watched to the
489    /// end counts as watched when scanning for the furthest-watched one.
490    ///
491    /// TRACES: UR-062 | DR-264 | UT-239
492    #[test]
493    fn an_episode_watched_to_the_end_counts_as_watched_offline() {
494        let mut eps = season(1, 5);
495        eps[0] = watched(eps[0].clone());
496        eps[1] = in_progress(eps[1].clone(), 0.99);
497
498        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
499        assert_eq!(current.id, "s1e3");
500    }
501
502    #[test]
503    fn a_never_watched_series_opens_on_its_premiere() {
504        let eps = [season(2, 3), season(1, 3)].concat();
505        let mut ordered = eps.clone();
506        sort_series_order(&mut ordered);
507
508        let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
509        assert_eq!(current.id, "s1e1");
510    }
511
512    #[test]
513    fn a_fully_watched_series_reopens_at_the_start() {
514        let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
515
516        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
517        assert_eq!(current.id, "s1e1");
518    }
519
520    #[test]
521    fn honours_a_resume_entry_missing_from_the_episode_list() {
522        // Season fan-out returned nothing usable, but the resume feed knows.
523        let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
524
525        let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
526        assert_eq!(current.id, "s3e7");
527    }
528
529    #[test]
530    fn resume_entries_from_other_series_are_ignored() {
531        let mut foreign = in_progress(episode("other", 1, 1), 0.5);
532        foreign.series_id = Some("series-2".to_string());
533
534        assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
535    }
536
537    #[test]
538    fn a_series_with_no_episodes_has_no_current_episode() {
539        assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
540    }
541
542    #[test]
543    fn an_episode_without_a_duration_still_counts_as_in_progress() {
544        let mut ep = episode("s1e2", 1, 2);
545        ep.duration_ms = None;
546        ep.user_data = Some(UserData {
547            is_played: Some(false),
548            playback_position_ms: Some(120_000),
549            ..Default::default()
550        });
551
552        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
553        assert_eq!(current.id, "s1e2");
554    }
555
556    #[test]
557    fn legacy_tick_positions_still_register_as_progress() {
558        let mut ep = episode("s1e2", 1, 2);
559        ep.user_data = Some(UserData {
560            is_played: Some(false),
561            // 400_000 ms expressed in Jellyfin ticks, no ms field.
562            playback_position_ticks: Some(400_000 * 10_000),
563            ..Default::default()
564        });
565
566        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
567        assert_eq!(current.id, "s1e2");
568    }
569}