1use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
15
16const SPECIALS_SEASON: i32 = 0;
18
19const MIN_PROGRESS_FRACTION: f64 = 0.01;
22
23const MAX_PROGRESS_FRACTION: f64 = 0.95;
26
27fn 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
38pub 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
52fn 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 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 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 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
118pub fn pick_current_episode(
143 series_id: &str,
144 episodes: &[MediaItem],
145 next_up: &[MediaItem],
146 resume: &[MediaItem],
147) -> Option<MediaItem> {
148 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 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 let local = episodes.iter().find(|listed| listed.id == e.id);
173 !is_finished(local.unwrap_or(e))
174 }) {
175 let matched = episodes.iter().find(|e| e.id == found.id);
178 return Some(matched.unwrap_or(found).clone());
179 }
180
181 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 if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
194 return Some(found.clone());
195 }
196
197 episodes.first().cloned()
199}
200
201pub 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 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
232pub 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#[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
282pub 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
310async 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
340pub 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 #[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 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 #[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 #[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 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); eps[2] = in_progress(eps[2].clone(), 0.99); 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 #[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 #[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 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 #[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 #[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 #[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 eps[1] = in_progress(eps[1].clone(), 0.99);
673 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 #[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 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 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}