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 belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
87 item.series_id.as_deref() == Some(series_id)
88}
89
90pub fn pick_current_episode(
115 series_id: &str,
116 episodes: &[MediaItem],
117 next_up: &[MediaItem],
118 resume: &[MediaItem],
119) -> Option<MediaItem> {
120 if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
124 return Some(found.clone());
125 }
126 if let Some(found) = resume
127 .iter()
128 .find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
129 {
130 return Some(found.clone());
131 }
132
133 if let Some(found) = next_up
135 .iter()
136 .find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
137 {
138 let matched = episodes.iter().find(|e| e.id == found.id);
141 return Some(matched.unwrap_or(found).clone());
142 }
143
144 if let Some(furthest) = episodes.iter().rposition(is_played) {
149 if let Some(found) = episodes.get(furthest + 1) {
150 return Some(found.clone());
151 }
152 }
153
154 if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
157 return Some(found.clone());
158 }
159
160 episodes.first().cloned()
162}
163
164pub async fn fetch_series_episodes(
170 repo: &dyn MediaRepository,
171 series_id: &str,
172) -> Result<Vec<MediaItem>, RepoError> {
173 let children = repo.get_items(series_id, list_options()).await?;
174
175 let mut episodes: Vec<MediaItem> = Vec::new();
176 for season in children.items.iter().filter(|i| is_season(i)) {
177 match repo.get_items(&season.id, list_options()).await {
179 Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
180 Err(e) => {
181 log::warn!(
182 "[series] season {} of {} failed to load: {:?}",
183 season.id,
184 series_id,
185 e
186 );
187 }
188 }
189 }
190
191 if episodes.is_empty() {
193 episodes.extend(children.items.into_iter().filter(is_episode));
194 }
195
196 sort_series_order(&mut episodes);
197 Ok(episodes)
198}
199
200pub async fn resolve_current_episode(
205 repo: &dyn MediaRepository,
206 series_id: &str,
207) -> Result<Option<MediaItem>, RepoError> {
208 let episodes = fetch_series_episodes(repo, series_id).await?;
209
210 let next_up = repo
211 .get_next_up_episodes(Some(series_id), Some(1))
212 .await
213 .unwrap_or_default();
214 let resume = repo
215 .get_resume_items(Some(series_id), Some(10))
216 .await
217 .unwrap_or_default();
218
219 Ok(pick_current_episode(
220 series_id, &episodes, &next_up, &resume,
221 ))
222}
223
224fn list_options() -> Option<GetItemsOptions> {
225 Some(GetItemsOptions {
226 limit: Some(500),
227 ..Default::default()
228 })
229}
230
231fn is_season(item: &MediaItem) -> bool {
232 item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
233}
234
235fn is_episode(item: &MediaItem) -> bool {
236 item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::repository::UserData;
243
244 const SERIES: &str = "series-1";
245
246 fn episode(id: &str, season: i32, number: i32) -> MediaItem {
247 MediaItem {
248 id: id.to_string(),
249 name: format!("S{season}E{number}"),
250 item_type: "Episode".to_string(),
251 series_id: Some(SERIES.to_string()),
252 parent_index_number: Some(season),
253 index_number: Some(number),
254 duration_ms: Some(1_000_000),
255 ..Default::default()
256 }
257 }
258
259 fn watched(mut item: MediaItem) -> MediaItem {
260 item.user_data = Some(UserData {
261 is_played: Some(true),
262 ..Default::default()
263 });
264 item
265 }
266
267 fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
268 let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
269 item.user_data = Some(UserData {
270 is_played: Some(false),
271 playback_position_ms: Some((duration * fraction) as i64),
272 ..Default::default()
273 });
274 item
275 }
276
277 fn season(n: i32, count: i32) -> Vec<MediaItem> {
278 (1..=count)
279 .map(|i| episode(&format!("s{n}e{i}"), n, i))
280 .collect()
281 }
282
283 #[test]
284 fn sorts_by_season_then_episode() {
285 let mut eps = vec![
286 episode("b", 2, 1),
287 episode("d", 1, 10),
288 episode("a", 1, 2),
289 episode("c", 2, 2),
290 ];
291 sort_series_order(&mut eps);
292 let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
293 assert_eq!(ids, ["a", "d", "b", "c"]);
294 }
295
296 #[test]
297 fn sorts_specials_after_numbered_seasons() {
298 let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
299 sort_series_order(&mut eps);
300 let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
301 assert_eq!(ids, ["premiere", "special"]);
302 }
303
304 #[test]
305 fn picks_the_in_progress_episode_over_next_up() {
306 let mut eps = season(1, 5);
307 eps[0] = watched(eps[0].clone());
308 eps[1] = in_progress(eps[1].clone(), 0.4);
309 let next_up = vec![episode("s1e3", 1, 3)];
311
312 let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
313 assert_eq!(current.id, "s1e2");
314 }
315
316 #[test]
317 fn picks_the_earliest_in_progress_episode() {
318 let mut eps = season(1, 5);
319 eps[1] = in_progress(eps[1].clone(), 0.3);
320 eps[3] = in_progress(eps[3].clone(), 0.5);
321
322 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
323 assert_eq!(current.id, "s1e2");
324 }
325
326 #[test]
327 fn ignores_a_false_start_and_a_finished_episode() {
328 let mut eps = season(1, 5);
329 eps[0] = watched(eps[0].clone());
330 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)];
335 let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
336 assert_eq!(current.id, "s1e4");
337 }
338
339 #[test]
340 fn falls_back_to_next_up_when_nothing_is_in_progress() {
341 let eps = season(1, 5);
342 let next_up = vec![episode("s1e3", 1, 3)];
343
344 let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
345 assert_eq!(current.id, "s1e3");
346 }
347
348 #[test]
349 fn next_up_from_another_series_is_ignored() {
350 let eps = season(1, 3);
351 let mut foreign = episode("other-show-ep", 1, 1);
352 foreign.series_id = Some("series-2".to_string());
353
354 let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
355 assert_eq!(current.id, "s1e1");
356 }
357
358 #[test]
361 fn falls_back_to_first_unwatched_when_next_up_is_empty() {
362 let mut eps = [season(1, 3), season(2, 3)].concat();
363 for ep in eps.iter_mut().take(4) {
364 *ep = watched(ep.clone());
365 }
366
367 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
368 assert_eq!(current.id, "s2e2");
369 }
370
371 #[test]
374 fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
375 let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
376 for ep in eps.iter_mut() {
377 let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
379 if watched_through && ep.id != "s1e1" {
380 *ep = watched(ep.clone());
381 }
382 }
383
384 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
385 assert_eq!(current.id, "s3e4");
386 }
387
388 #[test]
391 fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
392 let mut eps = [season(1, 3), season(2, 3)].concat();
393 for ep in eps.iter_mut() {
394 if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
395 *ep = watched(ep.clone());
396 }
397 }
398
399 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
400 assert_eq!(current.id, "s2e1");
401 }
402
403 #[test]
406 fn a_watched_special_does_not_end_the_series() {
407 let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
408 sort_series_order(&mut eps);
409 for ep in eps.iter_mut() {
410 if ep.id == "s1e1" || ep.id == "s0e1" {
411 *ep = watched(ep.clone());
412 }
413 }
414
415 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
416 assert_eq!(current.id, "s1e2");
417 }
418
419 #[test]
420 fn crosses_a_season_boundary_when_a_season_is_finished() {
421 let mut eps = [season(1, 3), season(2, 3)].concat();
422 for ep in eps.iter_mut().take(3) {
423 *ep = watched(ep.clone());
424 }
425
426 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
427 assert_eq!(current.id, "s2e1");
428 }
429
430 #[test]
431 fn a_never_watched_series_opens_on_its_premiere() {
432 let eps = [season(2, 3), season(1, 3)].concat();
433 let mut ordered = eps.clone();
434 sort_series_order(&mut ordered);
435
436 let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
437 assert_eq!(current.id, "s1e1");
438 }
439
440 #[test]
441 fn a_fully_watched_series_reopens_at_the_start() {
442 let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
443
444 let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
445 assert_eq!(current.id, "s1e1");
446 }
447
448 #[test]
449 fn honours_a_resume_entry_missing_from_the_episode_list() {
450 let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
452
453 let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
454 assert_eq!(current.id, "s3e7");
455 }
456
457 #[test]
458 fn resume_entries_from_other_series_are_ignored() {
459 let mut foreign = in_progress(episode("other", 1, 1), 0.5);
460 foreign.series_id = Some("series-2".to_string());
461
462 assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
463 }
464
465 #[test]
466 fn a_series_with_no_episodes_has_no_current_episode() {
467 assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
468 }
469
470 #[test]
471 fn an_episode_without_a_duration_still_counts_as_in_progress() {
472 let mut ep = episode("s1e2", 1, 2);
473 ep.duration_ms = None;
474 ep.user_data = Some(UserData {
475 is_played: Some(false),
476 playback_position_ms: Some(120_000),
477 ..Default::default()
478 });
479
480 let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
481 assert_eq!(current.id, "s1e2");
482 }
483
484 #[test]
485 fn legacy_tick_positions_still_register_as_progress() {
486 let mut ep = episode("s1e2", 1, 2);
487 ep.user_data = Some(UserData {
488 is_played: Some(false),
489 playback_position_ticks: Some(400_000 * 10_000),
491 ..Default::default()
492 });
493
494 let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
495 assert_eq!(current.id, "s1e2");
496 }
497}