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 seasons: Vec<MediaItem> = children
213        .items
214        .iter()
215        .filter(|i| is_season(i))
216        .cloned()
217        .collect();
218    let mut episodes = gather_season_episodes(&seasons, |season_id| async move {
219        repo.get_items(&season_id, list_options()).await
220    })
221    .await;
222
223    // Flat series: the children *are* the episodes.
224    if episodes.is_empty() {
225        episodes.extend(children.items.into_iter().filter(is_episode));
226    }
227
228    sort_series_order(&mut episodes);
229    Ok(episodes)
230}
231
232/// Every episode in `seasons`, fetched with `fetch_season` (a season id → its
233/// children), **all at once**.
234///
235/// They used to be fetched one after another, so the wait was the sum of every
236/// season's listing: ~4 s for Frasier's eleven, on a phone whose cache reads
237/// were slowed by a catalog sync writing in the background. Concurrently it is
238/// the slowest single season. Order is restored afterwards by
239/// `sort_series_order`, so completion order does not matter.
240///
241/// One failing season must not blank the whole show: its episodes are left out
242/// and the rest returned.
243///
244/// TRACES: UR-062 | DR-295 | UT-264
245pub async fn gather_season_episodes<F, Fut>(
246    seasons: &[MediaItem],
247    fetch_season: F,
248) -> Vec<MediaItem>
249where
250    F: Fn(String) -> Fut,
251    Fut: std::future::Future<Output = Result<super::SearchResult, RepoError>>,
252{
253    let results = futures_util::future::join_all(
254        seasons.iter().map(|season| fetch_season(season.id.clone())),
255    )
256    .await;
257
258    let mut episodes = Vec::new();
259    for (season, result) in seasons.iter().zip(results) {
260        match result {
261            Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
262            Err(e) => log::warn!("[series] season {} failed to load: {:?}", season.id, e),
263        }
264    }
265    episodes
266}
267
268/// A series' episodes and the one the viewer is up to, from **one** season
269/// fan-out.
270///
271/// The series page needs both, and asked for them as two commands; each walked
272/// every season, so every visit listed the show twice. One call, one walk.
273///
274/// TRACES: UR-062 | DR-101, DR-295
275#[derive(Debug, Clone, serde::Serialize, specta::Type)]
276#[serde(rename_all = "camelCase")]
277pub struct SeriesView {
278    pub episodes: Vec<MediaItem>,
279    pub current: Option<MediaItem>,
280}
281
282/// Build a [`SeriesView`]: one fan-out, with Next Up and resume fetched
283/// alongside it rather than after.
284///
285/// TRACES: UR-062 | DR-101, DR-295
286pub async fn resolve_series_view(
287    repo: &dyn MediaRepository,
288    series_id: &str,
289) -> Result<SeriesView, RepoError> {
290    let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
291        futures_util::join!(
292            async {
293                repo.get_next_up_episodes(Some(series_id), Some(1))
294                    .await
295                    .unwrap_or_default()
296            },
297            async {
298                repo.get_resume_items(Some(series_id), Some(10))
299                    .await
300                    .unwrap_or_default()
301            },
302        )
303    })
304    .await;
305    let episodes = episodes?;
306    let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
307    Ok(SeriesView { episodes, current })
308}
309
310/// Run `primary` and `hints` together, but never hold `primary` back for
311/// `hints`: once `primary` is ready, the hints are taken if they have already
312/// answered and dropped (`H::default()`) if not.
313///
314/// For the series view the primary is the episode list and the hints are Next
315/// Up and resume, which only refine which episode is "current" — and the
316/// picker falls back to the episodes' own watch state without them. Waiting
317/// for them made the episode list wait for the server (2-3 s on a phone)
318/// although every episode was in the cache in 50 ms. The cache legs of the
319/// hints usually answer before the episodes do, so they are normally kept.
320///
321/// TRACES: UR-062 | DR-101, DR-295
322async fn with_hints<P, H>(
323    primary: impl std::future::Future<Output = P>,
324    hints: impl std::future::Future<Output = H>,
325) -> (P, H)
326where
327    H: Default,
328{
329    use futures_util::future::{select, Either};
330    use futures_util::FutureExt;
331
332    let primary = std::pin::pin!(primary);
333    let hints = std::pin::pin!(hints);
334    match select(primary, hints).await {
335        Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
336        Either::Right((hints, primary)) => (primary.await, hints),
337    }
338}
339
340/// Resolve the current episode, fetching everything the policy needs.
341///
342/// Next Up and resume are best-effort: offline they fail or come back empty, and
343/// `pick_current_episode` has fallbacks for exactly that.
344pub async fn resolve_current_episode(
345    repo: &dyn MediaRepository,
346    series_id: &str,
347) -> Result<Option<MediaItem>, RepoError> {
348    let episodes = fetch_series_episodes(repo, series_id).await?;
349
350    let next_up = repo
351        .get_next_up_episodes(Some(series_id), Some(1))
352        .await
353        .unwrap_or_default();
354    let resume = repo
355        .get_resume_items(Some(series_id), Some(10))
356        .await
357        .unwrap_or_default();
358
359    Ok(pick_current_episode(
360        series_id, &episodes, &next_up, &resume,
361    ))
362}
363
364fn list_options() -> Option<GetItemsOptions> {
365    Some(GetItemsOptions {
366        limit: Some(500),
367        ..Default::default()
368    })
369}
370
371fn is_season(item: &MediaItem) -> bool {
372    item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
373}
374
375fn is_episode(item: &MediaItem) -> bool {
376    item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::repository::UserData;
383
384    const SERIES: &str = "series-1";
385
386    fn episode(id: &str, season: i32, number: i32) -> MediaItem {
387        MediaItem {
388            id: id.to_string(),
389            name: format!("S{season}E{number}"),
390            item_type: "Episode".to_string(),
391            series_id: Some(SERIES.to_string()),
392            parent_index_number: Some(season),
393            index_number: Some(number),
394            duration_ms: Some(1_000_000),
395            ..Default::default()
396        }
397    }
398
399    /// "More info" on Frasier took ~4 s to list its episodes, twice over: the
400    /// eleven seasons were fetched one after another, so the wait was the *sum*
401    /// of eleven listings. Fetched together it is the slowest one.
402    ///
403    /// TRACES: UR-062 | DR-295 | UT-264
404    #[tokio::test]
405    async fn seasons_are_fetched_concurrently_not_one_after_another() {
406        let seasons: Vec<MediaItem> = (1..=10)
407            .map(|n| MediaItem {
408                id: format!("season-{n}"),
409                item_type: "Season".to_string(),
410                ..Default::default()
411            })
412            .collect();
413
414        let started = std::time::Instant::now();
415        let episodes = gather_season_episodes(&seasons, |season_id| async move {
416            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
417            if season_id == "season-3" {
418                // One failing season must not blank the show.
419                return Err(RepoError::Network {
420                    message: "gone".to_string(),
421                });
422            }
423            let n: i32 = season_id.trim_start_matches("season-").parse().unwrap();
424            Ok(crate::repository::SearchResult {
425                items: vec![episode(&format!("e{n}"), n, 1)],
426                total_record_count: 1,
427            })
428        })
429        .await;
430        let elapsed = started.elapsed();
431
432        assert!(
433            elapsed < std::time::Duration::from_millis(400),
434            "ten 100 ms seasons took {elapsed:?} — fetched in sequence, not together"
435        );
436        assert_eq!(episodes.len(), 9, "every season but the failing one");
437    }
438
439    /// The episode list must not wait for Next Up or resume.
440    ///
441    /// The series page rendered its episodes only once Next Up had come back
442    /// from the server — 2-3 s on a phone while the page's other requests were
443    /// in flight — although every episode was in the cache after 50 ms. Those
444    /// two only refine which episode is "current", and the picker falls back
445    /// to the episodes' own watch state without them.
446    ///
447    /// TRACES: UR-062 | DR-101, DR-295
448    #[tokio::test]
449    async fn the_episode_list_does_not_wait_for_slow_hints() {
450        let started = std::time::Instant::now();
451        let (episodes, hints) = with_hints(
452            async {
453                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
454                vec![episode("e1", 1, 1)]
455            },
456            async {
457                tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
458                vec![episode("from-server", 1, 2)]
459            },
460        )
461        .await;
462        let elapsed = started.elapsed();
463
464        assert_eq!(episodes.len(), 1);
465        assert!(hints.is_empty(), "late hints are dropped, not waited for");
466        assert!(
467            elapsed < std::time::Duration::from_millis(500),
468            "the episode list waited {elapsed:?} for Next Up / resume"
469        );
470    }
471
472    /// Hints that are already in (a cache answer) are used.
473    ///
474    /// TRACES: UR-062 | DR-101, DR-295
475    #[tokio::test]
476    async fn hints_that_answer_first_are_kept() {
477        let (_, hints) = with_hints(
478            async {
479                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
480                vec![episode("e1", 1, 1)]
481            },
482            async { vec![episode("cached", 1, 2)] },
483        )
484        .await;
485        assert_eq!(hints.len(), 1);
486    }
487
488    fn watched(mut item: MediaItem) -> MediaItem {
489        item.user_data = Some(UserData {
490            is_played: Some(true),
491            ..Default::default()
492        });
493        item
494    }
495
496    fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
497        let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
498        item.user_data = Some(UserData {
499            is_played: Some(false),
500            playback_position_ms: Some((duration * fraction) as i64),
501            ..Default::default()
502        });
503        item
504    }
505
506    fn season(n: i32, count: i32) -> Vec<MediaItem> {
507        (1..=count)
508            .map(|i| episode(&format!("s{n}e{i}"), n, i))
509            .collect()
510    }
511
512    #[test]
513    fn sorts_by_season_then_episode() {
514        let mut eps = vec![
515            episode("b", 2, 1),
516            episode("d", 1, 10),
517            episode("a", 1, 2),
518            episode("c", 2, 2),
519        ];
520        sort_series_order(&mut eps);
521        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
522        assert_eq!(ids, ["a", "d", "b", "c"]);
523    }
524
525    #[test]
526    fn sorts_specials_after_numbered_seasons() {
527        let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
528        sort_series_order(&mut eps);
529        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
530        assert_eq!(ids, ["premiere", "special"]);
531    }
532
533    #[test]
534    fn picks_the_in_progress_episode_over_next_up() {
535        let mut eps = season(1, 5);
536        eps[0] = watched(eps[0].clone());
537        eps[1] = in_progress(eps[1].clone(), 0.4);
538        // The server would send us past it; the half-watched episode wins.
539        let next_up = vec![episode("s1e3", 1, 3)];
540
541        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
542        assert_eq!(current.id, "s1e2");
543    }
544
545    #[test]
546    fn picks_the_earliest_in_progress_episode() {
547        let mut eps = season(1, 5);
548        eps[1] = in_progress(eps[1].clone(), 0.3);
549        eps[3] = in_progress(eps[3].clone(), 0.5);
550
551        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
552        assert_eq!(current.id, "s1e2");
553    }
554
555    #[test]
556    fn ignores_a_false_start_and_a_finished_episode() {
557        let mut eps = season(1, 5);
558        eps[0] = watched(eps[0].clone());
559        eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
560        eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
561
562        // Neither counts as progress, so Next Up decides.
563        let next_up = vec![episode("s1e4", 1, 4)];
564        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
565        assert_eq!(current.id, "s1e4");
566    }
567
568    #[test]
569    fn falls_back_to_next_up_when_nothing_is_in_progress() {
570        let eps = season(1, 5);
571        let next_up = vec![episode("s1e3", 1, 3)];
572
573        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
574        assert_eq!(current.id, "s1e3");
575    }
576
577    #[test]
578    fn next_up_from_another_series_is_ignored() {
579        let eps = season(1, 3);
580        let mut foreign = episode("other-show-ep", 1, 1);
581        foreign.series_id = Some("series-2".to_string());
582
583        let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
584        assert_eq!(current.id, "s1e1");
585    }
586
587    /// The offline path: `OfflineRepository::get_next_up_episodes` returns an
588    /// empty vec, so the first unwatched episode has to carry the feature.
589    #[test]
590    fn falls_back_to_first_unwatched_when_next_up_is_empty() {
591        let mut eps = [season(1, 3), season(2, 3)].concat();
592        for ep in eps.iter_mut().take(4) {
593            *ep = watched(ep.clone());
594        }
595
596        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
597        assert_eq!(current.id, "s2e2");
598    }
599
600    /// A viewer deep in season 3 who never watched the pilot must not be sent
601    /// back to it: the gap was a skip, not the place they stopped.
602    #[test]
603    fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
604        let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
605        for ep in eps.iter_mut() {
606            // Everything through S3E3 watched, except the never-watched pilot.
607            let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
608            if watched_through && ep.id != "s1e1" {
609                *ep = watched(ep.clone());
610            }
611        }
612
613        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
614        assert_eq!(current.id, "s3e4");
615    }
616
617    /// The furthest-watched episode being a finale must still roll into the
618    /// next season rather than stopping the series.
619    #[test]
620    fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
621        let mut eps = [season(1, 3), season(2, 3)].concat();
622        for ep in eps.iter_mut() {
623            if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
624                *ep = watched(ep.clone());
625            }
626        }
627
628        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
629        assert_eq!(current.id, "s2e1");
630    }
631
632    /// Specials sort last, so watching one must not mark the series finished
633    /// while numbered episodes remain.
634    #[test]
635    fn a_watched_special_does_not_end_the_series() {
636        let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
637        sort_series_order(&mut eps);
638        for ep in eps.iter_mut() {
639            if ep.id == "s1e1" || ep.id == "s0e1" {
640                *ep = watched(ep.clone());
641            }
642        }
643
644        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
645        assert_eq!(current.id, "s1e2");
646    }
647
648    #[test]
649    fn crosses_a_season_boundary_when_a_season_is_finished() {
650        let mut eps = [season(1, 3), season(2, 3)].concat();
651        for ep in eps.iter_mut().take(3) {
652            *ep = watched(ep.clone());
653        }
654
655        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
656        assert_eq!(current.id, "s2e1");
657    }
658
659    /// The episode the viewer just finished must not still be "up next".
660    ///
661    /// Leaving the player with Back reloads the series page within a second of
662    /// the stop report, and Jellyfin's Next Up can still name the episode that
663    /// just ended. Locally we know better: the position sits at the very end of
664    /// its runtime.
665    ///
666    /// TRACES: UR-062 | DR-264 | UT-239
667    #[test]
668    fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
669        let mut eps = season(1, 5);
670        eps[0] = watched(eps[0].clone());
671        // Just finished: the position is at the end, the flag has not landed.
672        eps[1] = in_progress(eps[1].clone(), 0.99);
673        // The server has not caught up with the stop report.
674        let next_up = vec![episode("s1e2", 1, 2)];
675
676        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
677        assert_eq!(current.id, "s1e3");
678    }
679
680    /// The same, offline: with no Next Up to lean on, an episode watched to the
681    /// end counts as watched when scanning for the furthest-watched one.
682    ///
683    /// TRACES: UR-062 | DR-264 | UT-239
684    #[test]
685    fn an_episode_watched_to_the_end_counts_as_watched_offline() {
686        let mut eps = season(1, 5);
687        eps[0] = watched(eps[0].clone());
688        eps[1] = in_progress(eps[1].clone(), 0.99);
689
690        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
691        assert_eq!(current.id, "s1e3");
692    }
693
694    #[test]
695    fn a_never_watched_series_opens_on_its_premiere() {
696        let eps = [season(2, 3), season(1, 3)].concat();
697        let mut ordered = eps.clone();
698        sort_series_order(&mut ordered);
699
700        let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
701        assert_eq!(current.id, "s1e1");
702    }
703
704    #[test]
705    fn a_fully_watched_series_reopens_at_the_start() {
706        let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
707
708        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
709        assert_eq!(current.id, "s1e1");
710    }
711
712    #[test]
713    fn honours_a_resume_entry_missing_from_the_episode_list() {
714        // Season fan-out returned nothing usable, but the resume feed knows.
715        let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
716
717        let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
718        assert_eq!(current.id, "s3e7");
719    }
720
721    #[test]
722    fn resume_entries_from_other_series_are_ignored() {
723        let mut foreign = in_progress(episode("other", 1, 1), 0.5);
724        foreign.series_id = Some("series-2".to_string());
725
726        assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
727    }
728
729    #[test]
730    fn a_series_with_no_episodes_has_no_current_episode() {
731        assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
732    }
733
734    #[test]
735    fn an_episode_without_a_duration_still_counts_as_in_progress() {
736        let mut ep = episode("s1e2", 1, 2);
737        ep.duration_ms = None;
738        ep.user_data = Some(UserData {
739            is_played: Some(false),
740            playback_position_ms: Some(120_000),
741            ..Default::default()
742        });
743
744        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
745        assert_eq!(current.id, "s1e2");
746    }
747
748    #[test]
749    fn legacy_tick_positions_still_register_as_progress() {
750        let mut ep = episode("s1e2", 1, 2);
751        ep.user_data = Some(UserData {
752            is_played: Some(false),
753            // 400_000 ms expressed in Jellyfin ticks, no ms field.
754            playback_position_ticks: Some(400_000 * 10_000),
755            ..Default::default()
756        });
757
758        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
759        assert_eq!(current.id, "s1e2");
760    }
761}