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}