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 mut episodes: Vec<MediaItem> = Vec::new();
213 for season in children.items.iter().filter(|i| is_season(i)) {
214 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 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
237pub 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 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); eps[2] = in_progress(eps[2].clone(), 0.99); 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 #[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 #[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 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 #[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 #[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 #[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 eps[1] = in_progress(eps[1].clone(), 0.99);
481 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 #[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 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 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}