feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md
This commit is contained in:
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.2.9"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.2.9"
|
||||
version = "0.3.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -15,7 +15,8 @@ use uuid::Uuid;
|
||||
use crate::domain::rank_search_results;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||
OnlineRepository,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
@@ -320,6 +321,71 @@ pub async fn repository_get_next_up_episodes(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Every episode of a series, across all seasons, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and its fallback live in Rust rather than being reimplemented in the
|
||||
/// frontend (which is what it used to do).
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_episodes(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open a series.
|
||||
///
|
||||
/// "Current" is domain policy, not layout: an episode in progress, else the
|
||||
/// server's Next Up for the series, else the first unwatched episode, else the
|
||||
/// first. The third rung is what makes this work offline, where Next Up is
|
||||
/// always empty. Returns `None` only when the series has no episodes at all.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_current_episode(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<Option<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Erase the viewer's watch history for an item.
|
||||
///
|
||||
/// Clears the played flag and the resume position; on a series or season the
|
||||
/// server applies it to everything inside. A series cleared this way is "never
|
||||
/// watched" again, so `repository_get_series_current_episode` returns its
|
||||
/// premiere. Requires the server — offline this fails rather than diverging
|
||||
/// local state the next sync would overwrite.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_clear_watch_history(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.clear_watch_history(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get recently played audio
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -178,6 +178,7 @@ use commands::{
|
||||
remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// Repository commands
|
||||
repository_clear_watch_history,
|
||||
repository_create,
|
||||
repository_destroy,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
@@ -201,6 +202,8 @@ use commands::{
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_resume_items,
|
||||
repository_get_resume_movies,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_similar_items,
|
||||
repository_get_subtitle_url,
|
||||
repository_get_video_download_url,
|
||||
@@ -869,6 +872,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_current_episode,
|
||||
repository_clear_watch_history,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
|
||||
@@ -2862,6 +2862,9 @@ mod tests {
|
||||
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_person(
|
||||
&self,
|
||||
_: &str,
|
||||
|
||||
@@ -750,6 +750,11 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.unmark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.clear_watch_history(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
@@ -1128,6 +1133,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1386,6 +1395,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
pub mod series_progress;
|
||||
pub mod types;
|
||||
|
||||
pub use hybrid::HybridRepository;
|
||||
@@ -211,6 +212,14 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// Unmark item as favorite
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Erase the viewer's watch history for an item: clear its played flag and
|
||||
/// its resume position. On a container (series, season) this applies to
|
||||
/// everything inside it, so a series is returned to "never watched" and
|
||||
/// reopens on its premiere.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get person details
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
|
||||
@@ -1627,6 +1627,12 @@ impl MediaRepository for OfflineRepository {
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
// Erasing history has to reach the server to be meaningful — clearing
|
||||
// it only locally would be silently undone by the next sync.
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let query = Query::with_params(
|
||||
"SELECT id, name, overview, primary_image_tag
|
||||
|
||||
@@ -1691,6 +1691,48 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
|
||||
/// unplayed", which also zeroes the resume position. On a folder (series,
|
||||
/// season) the server applies it recursively to the children.
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106, JA-033
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
})?;
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RepoError::Server {
|
||||
message: format!("HTTP {}", response.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
self.report_outcome(&result).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Where a viewer is in a TV series.
|
||||
//!
|
||||
//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
|
||||
//! means ("in progress", "played") and what Jellyfin's season numbering means
|
||||
//! (season 0 is specials). The frontend asks for *the* current episode and
|
||||
//! renders it; it does not get to decide what "current" means.
|
||||
//!
|
||||
//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
|
||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||
//! can be unit-tested without standing up a repository.
|
||||
//!
|
||||
//! TRACES: UR-062 | DR-101
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
/// Jellyfin files specials under season 0.
|
||||
const SPECIALS_SEASON: i32 = 0;
|
||||
|
||||
/// Below this fraction watched, a position is a false start rather than
|
||||
/// progress — the same threshold the resume dialog uses.
|
||||
const MIN_PROGRESS_FRACTION: f64 = 0.01;
|
||||
|
||||
/// Above this fraction watched, an episode is effectively finished; resuming it
|
||||
/// would drop the viewer into the closing credits.
|
||||
const MAX_PROGRESS_FRACTION: f64 = 0.95;
|
||||
|
||||
/// Sort key for a season number. Specials sort *after* every numbered season:
|
||||
/// a viewer works through S1, S2, … and only then the extras, so season 0 must
|
||||
/// not lead just because `0 < 1`.
|
||||
fn season_rank(season: Option<i32>) -> i64 {
|
||||
match season {
|
||||
Some(SPECIALS_SEASON) => i64::MAX,
|
||||
Some(n) => n as i64,
|
||||
None => i64::MAX - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Order episodes as the series is watched: season ascending, then episode,
|
||||
/// specials last.
|
||||
pub fn sort_series_order(episodes: &mut [MediaItem]) {
|
||||
episodes.sort_by(|a, b| {
|
||||
season_rank(a.parent_index_number)
|
||||
.cmp(&season_rank(b.parent_index_number))
|
||||
.then(
|
||||
a.index_number
|
||||
.unwrap_or(0)
|
||||
.cmp(&b.index_number.unwrap_or(0)),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Is this episode genuinely part-watched (not a false start, not finished)?
|
||||
fn is_in_progress(item: &MediaItem) -> bool {
|
||||
let Some(user_data) = item.user_data.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if user_data.is_played.unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let position_ms = user_data
|
||||
.playback_position_ms
|
||||
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||
.unwrap_or(0);
|
||||
if position_ms <= 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Without a duration we cannot tell "2 minutes in" from "2 minutes left",
|
||||
// so any recorded position counts as progress.
|
||||
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let fraction = position_ms as f64 / duration_ms as f64;
|
||||
(MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
|
||||
}
|
||||
|
||||
fn is_played(item: &MediaItem) -> bool {
|
||||
item.user_data
|
||||
.as_ref()
|
||||
.and_then(|u| u.is_played)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
item.series_id.as_deref() == Some(series_id)
|
||||
}
|
||||
|
||||
/// The episode a viewer should land on when they open `series_id`.
|
||||
///
|
||||
/// Order of preference, and why:
|
||||
///
|
||||
/// 1. **An episode in progress.** That is literally where playback stopped;
|
||||
/// Next Up would skip past it. On a tie the earliest in series order wins, so
|
||||
/// a viewer who dipped into a later episode still returns to the one they are
|
||||
/// working through.
|
||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||
/// we do not cache locally.
|
||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
||||
/// this rung the whole feature would be online-only.
|
||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||
/// rather than on nothing.
|
||||
///
|
||||
/// `next_up` / `resume` entries are honoured even when absent from `episodes`
|
||||
/// (the season fan-out can miss an id the server returns), but only when they
|
||||
/// belong to this series.
|
||||
pub fn pick_current_episode(
|
||||
series_id: &str,
|
||||
episodes: &[MediaItem],
|
||||
next_up: &[MediaItem],
|
||||
resume: &[MediaItem],
|
||||
) -> Option<MediaItem> {
|
||||
// 1. In progress — prefer a match inside the ordered episode list so the
|
||||
// "earliest in series order" tie-break is meaningful; fall back to the
|
||||
// resume feed for an episode the fan-out missed.
|
||||
if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
if let Some(found) = resume
|
||||
.iter()
|
||||
.find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
|
||||
{
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 2. Next Up for this series.
|
||||
if let Some(found) = next_up
|
||||
.iter()
|
||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
||||
{
|
||||
// Prefer the copy from `episodes` when we have one: it carries the
|
||||
// user-data and images the list already fetched.
|
||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||
return Some(matched.unwrap_or(found).clone());
|
||||
}
|
||||
|
||||
// 3. First unwatched in series order.
|
||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 4. First episode — a fully-watched series reopens at the start.
|
||||
episodes.first().cloned()
|
||||
}
|
||||
|
||||
/// Every episode of a series, in series order.
|
||||
///
|
||||
/// Jellyfin hangs episodes off season folders, except for "flat" series whose
|
||||
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
||||
/// fan-out and the fallback live here rather than in the frontend.
|
||||
pub async fn fetch_series_episodes(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let children = repo.get_items(series_id, list_options()).await?;
|
||||
|
||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
||||
// One failing season must not blank the whole show.
|
||||
match repo.get_items(&season.id, list_options()).await {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[series] season {} of {} failed to load: {:?}",
|
||||
season.id,
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flat series: the children *are* the episodes.
|
||||
if episodes.is_empty() {
|
||||
episodes.extend(children.items.into_iter().filter(is_episode));
|
||||
}
|
||||
|
||||
sort_series_order(&mut episodes);
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
/// `pick_current_episode` has fallbacks for exactly that.
|
||||
pub async fn resolve_current_episode(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<Option<MediaItem>, RepoError> {
|
||||
let episodes = fetch_series_episodes(repo, series_id).await?;
|
||||
|
||||
let next_up = repo
|
||||
.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let resume = repo
|
||||
.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(pick_current_episode(
|
||||
series_id, &episodes, &next_up, &resume,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_options() -> Option<GetItemsOptions> {
|
||||
Some(GetItemsOptions {
|
||||
limit: Some(500),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn is_season(item: &MediaItem) -> bool {
|
||||
item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
|
||||
}
|
||||
|
||||
fn is_episode(item: &MediaItem) -> bool {
|
||||
item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::UserData;
|
||||
|
||||
const SERIES: &str = "series-1";
|
||||
|
||||
fn episode(id: &str, season: i32, number: i32) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("S{season}E{number}"),
|
||||
item_type: "Episode".to_string(),
|
||||
series_id: Some(SERIES.to_string()),
|
||||
parent_index_number: Some(season),
|
||||
index_number: Some(number),
|
||||
duration_ms: Some(1_000_000),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
|
||||
let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some((duration * fraction) as i64),
|
||||
..Default::default()
|
||||
});
|
||||
item
|
||||
}
|
||||
|
||||
fn season(n: i32, count: i32) -> Vec<MediaItem> {
|
||||
(1..=count)
|
||||
.map(|i| episode(&format!("s{n}e{i}"), n, i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_season_then_episode() {
|
||||
let mut eps = vec![
|
||||
episode("b", 2, 1),
|
||||
episode("d", 1, 10),
|
||||
episode("a", 1, 2),
|
||||
episode("c", 2, 2),
|
||||
];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "d", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_specials_after_numbered_seasons() {
|
||||
let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
|
||||
sort_series_order(&mut eps);
|
||||
let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["premiere", "special"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_in_progress_episode_over_next_up() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.4);
|
||||
// The server would send us past it; the half-watched episode wins.
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_earliest_in_progress_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[1] = in_progress(eps[1].clone(), 0.3);
|
||||
eps[3] = in_progress(eps[3].clone(), 0.5);
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_a_false_start_and_a_finished_episode() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
|
||||
eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
|
||||
|
||||
// Neither counts as progress, so Next Up decides.
|
||||
let next_up = vec![episode("s1e4", 1, 4)];
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_next_up_when_nothing_is_in_progress() {
|
||||
let eps = season(1, 5);
|
||||
let next_up = vec![episode("s1e3", 1, 3)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_up_from_another_series_is_ignored() {
|
||||
let eps = season(1, 3);
|
||||
let mut foreign = episode("other-show-ep", 1, 1);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
/// The offline path: `OfflineRepository::get_next_up_episodes` returns an
|
||||
/// empty vec, so the first unwatched episode has to carry the feature.
|
||||
#[test]
|
||||
fn falls_back_to_first_unwatched_when_next_up_is_empty() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(4) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||
for ep in eps.iter_mut().take(3) {
|
||||
*ep = watched(ep.clone());
|
||||
}
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_watched_series_opens_on_its_premiere() {
|
||||
let eps = [season(2, 3), season(1, 3)].concat();
|
||||
let mut ordered = eps.clone();
|
||||
sort_series_order(&mut ordered);
|
||||
|
||||
let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_watched_series_reopens_at_the_start() {
|
||||
let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_a_resume_entry_missing_from_the_episode_list() {
|
||||
// Season fan-out returned nothing usable, but the resume feed knows.
|
||||
let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
|
||||
assert_eq!(current.id, "s3e7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_entries_from_other_series_are_ignored() {
|
||||
let mut foreign = in_progress(episode("other", 1, 1), 0.5);
|
||||
foreign.series_id = Some("series-2".to_string());
|
||||
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_with_no_episodes_has_no_current_episode() {
|
||||
assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_episode_without_a_duration_still_counts_as_in_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.duration_ms = None;
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
playback_position_ms: Some(120_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tick_positions_still_register_as_progress() {
|
||||
let mut ep = episode("s1e2", 1, 2);
|
||||
ep.user_data = Some(UserData {
|
||||
is_played: Some(false),
|
||||
// 400_000 ms expressed in Jellyfin ticks, no ms field.
|
||||
playback_position_ticks: Some(400_000 * 10_000),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e2");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub struct Library {
|
||||
}
|
||||
|
||||
/// User-specific data for an item (playback state, favorites, etc.)
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.2.9",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
Reference in New Issue
Block a user