diff --git a/docs/requirements.md b/docs/requirements.md index 32059091..41c329b1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -72,6 +72,9 @@ For a narrative overview of the system design, see | UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done | | UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done | | UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done | +| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done | +| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done | +| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done | --- @@ -254,6 +257,13 @@ Internal architecture, components, and application logic. | DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done | | DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done | | DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to ``, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done | +| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done | +| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection` → `EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/?episode=` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/`, which the player route bounced back to `/library/` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done | +| DR-103 | A season is not a destination. `/library/` redirects to `/library/#season-`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done | +| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | Done | +| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done | +| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done | +| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done | | DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done | --- @@ -324,6 +334,9 @@ Internal architecture, components, and application logic. | UR-058 | - | DR-087 | | UR-060 | - | DR-090, DR-091 | | UR-061 | - | DR-092 | +| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 | +| UR-063 | - | DR-105 | +| UR-064 | - | DR-106 | --- diff --git a/docs/specs/series-current-episode-navigation.md b/docs/specs/series-current-episode-navigation.md new file mode 100644 index 00000000..9d918075 --- /dev/null +++ b/docs/specs/series-current-episode-navigation.md @@ -0,0 +1,239 @@ +# Spec: series navigation lands on the current episode + +**Status:** Accepted +**Requirements:** UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106 +**UX spec:** [ux-flows.md §5B.1](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md) + +## Summary + +Opening a TV series lands you where you actually are in it: the seasons render +as collapsible sections with **only the current season expanded**, the current +episode highlighted and scrolled into view, and the hero button opens that +episode's focus view (labelled `Resume S2E4` / `Play S1E1`) instead of the first +season. A season stops being a destination of its own — every route that used to +land on `/library/` now lands on the series with that season in view, +so the full cross-season episode list is always reachable in one place. Watch +history can be erased per series and per season. Separately, each video library +collapses from three routes (landing, all-titles, genres) to one route with +in-page tabs. + +## Motivation + +Two problems, reported together. + +**1. Series navigation dead-ends at season 1.** The series detail page's Play +button resolved its target as `$libraryItems[0]` — the first *season* child, +ordered by `SortName` — and navigated to `/player/`. The player route +classifies `season` as a container kind and bounces it back to +`/library/`. So Play on a series played nothing; it navigated you to +the season-1 page. Opening a series without pressing Play rendered every season +stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past +everything they had already watched. + +The backend has been able to answer "where is this viewer in this show" the +whole time: `repository_get_next_up_episodes(handle, series_id, limit)` is wired +end-to-end to `/Shows/NextUp?SeriesId=`. **Both frontend call sites pass +`undefined` for `series_id`** — the per-series capability existed and was never +used. + +**2. Seasons are an accidental page.** There is no season route. `/library/ +` falls through the detail page's `kind` chain into the generic +"Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season +must render as a row list). Worse, clicking an episode from that grid opens a +*bare* Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the +episode breadcrumb, `handleItemClick case "season"`, the TV landing page, and +the broken Play button above. + +**3. Too many video library routes.** Seven routes serve two media types, and the +naming does not even agree with itself: `/library/tv` + `/library/tv/shows` + +`/library/shows/genres` versus `/library/movies` + `/library/movies/all` + +`/library/movies/genres`. The genre routes do not share a prefix, which +`searchScope.ts:45` carries an apologetic comment about. The two "all" pages are +27-line config wrappers over the same `GenericMediaListPage`. + +## Layer assignment + +| Logic / responsibility | Layer | Why it belongs there | +|------------------------|-------|----------------------| +| Which episode is "current" for a series (resume → next-up → first unwatched → first) | **Rust** | Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what `UserData.is_played` means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned. | +| Gathering a series' episodes across all seasons in broadcast order | **Rust** | Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out *and* its flat-series fallback; that is domain knowledge that leaked. | +| Ordering rule for "series order" (season index, then episode index, specials last) | **Rust** | Season 0 = specials is a Jellyfin convention, not a layout choice. | +| Scrolling the current episode into view; the highlight ring and `Up next` badge | Frontend | Pure presentation. Changes only if the page is redesigned. | +| Which seasons start expanded | Frontend | Consumes the backend's answer (`currentEpisode`) to decide layout. The *decision* about where the viewer is stays in Rust; only "and therefore this section opens" is here. | +| What "erase watch history" means (played flag + resume position, recursive over a container) | **Rust** | Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign. | +| Refusing to clear history while offline | **Rust** | A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top. | +| Play button *label* (`Resume S2E4` vs `Play S1E1`) | Frontend | Rendering a decision the backend already made (the returned episode plus its resume position). | +| Which route Play navigates to | Frontend | Navigation is presentation. | +| Redirecting `/library/` to the series anchor | Frontend | Route topology. | +| Episode-strip window size (3 before / 6 after) | Frontend | A layout constant; §5B.2 owns it. | +| Library page tabs and the `?view=` param | Frontend | View preference and route topology. | + +Borderline row — **the strip's cross-season *ordering*** is Rust (it is series +order, above), but the *window* taken from that ordered list is frontend. The +tie-breaker: the list handed to the frontend is already correct and complete; +choosing how much of it fits on screen is layout. + +## Design + +### Rust: the current-episode policy + +Two new pieces, split so the policy is unit-testable without a repository. + +**Pure policy** — `src-tauri/src/repository/series_progress.rs`: + +```rust +/// Series order: season index asc, then episode index asc. Specials (season 0) +/// sort after every numbered season rather than before season 1. +pub fn sort_series_order(episodes: &mut [MediaItem]); + +/// The episode a viewer should land on, given everything already fetched. +/// Order: in-progress episode → Next Up → first unwatched → first episode. +pub fn pick_current_episode( + episodes: &[MediaItem], // series order + next_up: &[MediaItem], + resume: &[MediaItem], +) -> Option; +``` + +Why that order: + +- **In-progress wins** because a partially-watched episode is literally where + the viewer stopped; Next Up would skip past it. Ties break toward the earliest + in series order, so a viewer who dipped into a later episode still resumes the + one they are actually working through. +- **Next Up second** because it is the server's own answer, and it accounts for + history we do not cache. +- **First unwatched third** — the offline repository returns an empty vec for + Next Up (`offline.rs:1247`), so without this fallback the whole feature would + be online-only. This is the offline path, not dead code. +- **First episode last** so a never-watched series lands on S1E1 rather than + nothing. + +A `resume`/`next_up` entry that is not among `episodes` is still honoured — it +comes from the same server and may carry an id the season fan-out missed — but +it must belong to this series. + +**Fetch + command** — `src-tauri/src/commands/repository.rs`: + +```rust +#[tauri::command] +#[specta::specta] +pub async fn repository_get_series_episodes( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + series_id: String, +) -> Result, String> + +#[tauri::command] +#[specta::specta] +pub async fn repository_get_series_current_episode( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + series_id: String, +) -> Result, String> +``` + +Frontend params are camelCase (`{ handle, seriesId }`) per the Tauri v2 rule. + +`repository_get_series_episodes` performs the fan-out the frontend used to do: +`get_items(series_id)` → seasons → `get_items(season_id)` per season, plus the +flat-series fallback (a series whose children are episodes, not seasons), then +`sort_series_order`. `repository_get_series_current_episode` calls it, adds +`get_next_up_episodes(Some(series_id), Some(1))` and +`get_resume_items(Some(series_id), Some(10))`, and applies `pick_current_episode`. +Both tolerate a failing Next Up (offline) by treating it as empty rather than +failing the whole call. + +### Frontend: series page + +- `loadItem()` calls `repositoryGetSeriesEpisodes` once instead of fanning out + over seasons itself, and `repositoryGetSeriesCurrentEpisode` for the anchor. + Season *headers* still come from `get_items(seriesId)`; the page groups the + returned episodes under them by `parentIndexNumber`. +- No `?episode=` param → series view, `SeasonSection` receives + `currentEpisodeId`, `EpisodeRow` renders the highlight and scrolls itself into + view (`scrollIntoView({ block: "center" })`, the existing `focused` mechanism, + now distinguishing *focused* from *current*). +- Seasons are collapsible and **only the current season is expanded** + (`initialExpandedSeasons`). Without this a ten-season show renders every + episode of every season at once and buries the one the viewer came for. A + collapsed season still shows its episode count and watched count, so progress + is legible without expanding. Toggle state is local and not persisted — it is + a reading position, not a preference. +- Hero Play → `goto(/library/?episode=)`, i.e. the Episode + Focus View, where an explicit Play/Resume starts playback. This follows + ux-flows §5B.5's "tap opens, never commits" rule: Play on a *container* is + navigation; Play on a *leaf* (the focus view, a movie) commits. +- Clicking an episode in a season section → `?episode=` swap, not + `/player/`. §5B.1. + +### Frontend: seasons are not a destination + +`/library/` resolves the season's `seriesId` and redirects to +`/library/#season-`; `SeasonSection` renders that anchor +id. A season with no `seriesId` (deep link into a stale cache) keeps the old +generic rendering as a fallback so the user is never stranded. Inbound links +updated: episode breadcrumb, `handleItemClick case "season"`, the TV landing +page's `case "Season"`, and `DownloadedBrowse`. + +### Erasing watch history + +```rust +#[tauri::command] +#[specta::specta] +pub async fn repository_clear_watch_history( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + item_id: String, +) -> Result<(), String> +``` + +`OnlineRepository` maps it to `DELETE /Users/{userId}/PlayedItems/{itemId}` — +Jellyfin's mark-unplayed, which clears the played flag *and* zeroes the resume +position, and which the server applies recursively to a folder. One call +therefore handles a whole series or a single season; no per-episode fan-out. +`OfflineRepository` returns `RepoError::Offline` rather than clearing locally, +because divergent local history is undone by the next sync. + +`ClearHistoryButton` is shared by the series hero (`scope="series"`) and each +`SeasonSection` header (`scope="season"`). It confirms first — there is no undo — +disables itself while the server is unreachable, and reloads the page on success +so the recomputed current episode is what the viewer sees. Clearing a whole +series therefore returns it to S1E1, which is the same path a never-watched +series takes through `pick_current_episode`. + +### Frontend: one route per video library + +`/library/tv` and `/library/movies` each gain `?view=browse|all|genres` tabs, +rendering the existing `GenericMediaListPage` / `GenericGenreBrowser` components +inline. `?view=` is omitted for `browse` (the default) to keep URLs clean — +the same convention `searchRouteUrl` uses for the `all` scope. + +The four legacy routes become redirect-only `+page.ts` loads: + +| Legacy | Redirects to | +|--------|--------------| +| `/library/tv/shows` | `/library/tv?view=all` | +| `/library/shows/genres` | `/library/tv?view=genres` | +| `/library/movies/all` | `/library/movies?view=all` | +| `/library/movies/genres` | `/library/movies?view=genres` | + +They are kept (rather than deleted) because `GenreTags` builds links to them and +users may have them in history. `resolveSearchScope` keeps its `/library/shows` +branch for the same reason. + +The "Browse" tile grid at the bottom of both landing pages is removed — the tabs +replace it, and the tiles were a second navigation affordance to the same two +destinations the carousels' "Show all" links already reach. + +## Out of scope + +- **Cross-season autoplay.** `player/mod.rs:fetch_next_episode_for_item` is + still season-bounded, so autoplay stops at a season boundary. Fixing it should + reuse `repository_get_series_episodes`, but it touches the playback state + machine and the Android JNI advance path (see the `AutoplayDecision` deadlock + note in CLAUDE.md) and belongs in its own change. +- **Music library routes.** `/library/music/*` has five sub-routes with the same + shape; the same consolidation applies but is not done here. +- **Marking a series' progress** (mark-watched / mark-unwatched from the series + page). diff --git a/docs/traceability.md b/docs/traceability.md index b78d16ba..7e4f185d 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -1,22 +1,22 @@ # Code Traceability Matrix -**Generated:** 8/3/2026, 6:07:57 PM +**Generated:** 8/3/2026, 8:36:28 PM ## Summary -- **Total Files Scanned:** 307 -- **Total TRACES Found:** 340 +- **Total Files Scanned:** 314 +- **Total TRACES Found:** 368 - **Requirements Covered:** - - User Requirements (UR): 58 + - User Requirements (UR): 61 - Integration Requirements (IR): 15 - - Development Requirements (DR): 91 - - Jellyfin API Requirements (JA): 24 + - Development Requirements (DR): 98 + - Jellyfin API Requirements (JA): 25 ## Requirements by Type ### User Requirements (UR) ``` -UR-002, UR-003, UR-004, UR-005, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058, UR-059, UR-060, UR-061 +UR-002, UR-003, UR-004, UR-005, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058, UR-059, UR-060, UR-061, UR-062, UR-063, UR-064 ``` ### Integration Requirements (IR) @@ -26,12 +26,12 @@ IR-003, IR-004, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-020, ### Development Requirements (DR) ``` -DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087, DR-088, DR-089, DR-090, DR-091, DR-092, DR-093, DR-095, DR-096, DR-097, DR-098, DR-099, DR-100 +DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087, DR-088, DR-089, DR-090, DR-091, DR-092, DR-093, DR-095, DR-096, DR-097, DR-098, DR-099, DR-100, DR-101, DR-102, DR-103, DR-104, DR-105, DR-106, DR-107 ``` ### Jellyfin API Requirements (JA) ``` -JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, JA-017, JA-019, JA-020, JA-021, JA-022, JA-023, JA-024, JA-025, JA-026, JA-028, JA-029, JA-030, JA-031, JA-032 +JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, JA-017, JA-019, JA-020, JA-021, JA-022, JA-023, JA-024, JA-025, JA-026, JA-028, JA-029, JA-030, JA-031, JA-032, JA-033 ``` ## Detailed Mapping @@ -229,11 +229,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 17 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2258) - - **Line:** 2258 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2298) + - **Line:** 2298 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` - **File:** [`src/lib/stores/player.ts`](src/lib/stores/player.ts#L8) - **Line:** 8 @@ -285,8 +285,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2136) - - **Line:** 2136 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2176) + - **Line:** 2176 - **Context:** `export type PlaybackProgress = { itemId: string;` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L42) - **Line:** 42 @@ -296,8 +296,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) - - **Line:** 2240 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2280) + - **Line:** 2280 - **Context:** `Unknown` - **File:** [`src-tauri/src/player/media.rs`](src-tauri/src/player/media.rs#L124) - **Line:** 124 @@ -339,8 +339,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 13 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2512) - - **Line:** 2512 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2552) + - **Line:** 2552 - **Context:** `export type RemoteSessionStatus = { position: number; duration: number...` - **File:** [`src/lib/stores/queue.test.ts`](src/lib/stores/queue.test.ts#L4) - **Line:** 4 @@ -397,11 +397,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` - **File:** [`src/lib/components/library/AlphabetScrollBar.svelte`](src/lib/components/library/AlphabetScrollBar.svelte#L1) - **Line:** 1 @@ -738,8 +738,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 18 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` - **File:** [`src/lib/utils/duration.test.ts`](src/lib/utils/duration.test.ts#L4) - **Line:** 4 @@ -797,8 +797,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 10 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2572) - - **Line:** 2572 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2612) + - **Line:** 2612 - **Context:** `export type SessionInfo = { id?: string | null; userId?: string | null...` - **File:** [`src/lib/components/player/SleepTimerModal.svelte`](src/lib/components/player/SleepTimerModal.svelte#L1) - **Line:** 1 @@ -838,8 +838,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L181) - **Line:** 181 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1780) - - **Line:** 1780 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1820) + - **Line:** 1820 - **Context:** `export type DownloadsResponse = { downloads: DownloadInfo[]; stats: Do...` - **File:** [`src-tauri/src/player/android/mod.rs`](src-tauri/src/player/android/mod.rs#L545) - **Line:** 545 @@ -1032,11 +1032,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` - **File:** [`src/lib/components/home/HeroBanner.svelte`](src/lib/components/home/HeroBanner.svelte#L1) - **Line:** 1 @@ -1064,11 +1064,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` - **File:** [`src/lib/components/home/Carousel.svelte`](src/lib/components/home/Carousel.svelte#L1) - **Line:** 1 @@ -1142,8 +1142,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L241) - **Line:** 241 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` - **File:** [`src/lib/stores/nextEpisode.ts`](src/lib/stores/nextEpisode.ts#L9) - **Line:** 9 @@ -1370,12 +1370,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` +- **File:** [`src/lib/components/library/episodeStrip.ts`](src/lib/components/library/episodeStrip.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` - **File:** [`src/lib/components/library/EpisodeFocusView.svelte`](src/lib/components/library/EpisodeFocusView.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/library/episodeStrip.ts`](src/lib/components/library/episodeStrip.ts#L7) - - **Line:** 7 - - **Context:** `Unknown` ### DR-063 @@ -1390,11 +1390,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1300) - - **Line:** 1300 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1340) + - **Line:** 1340 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2547) - - **Line:** 2547 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2587) + - **Line:** 2587 - **Context:** `export type SearchResult = { items: MediaItem[]; totalRecordCount: num...` - **File:** [`src/lib/stores/library.ts`](src/lib/stores/library.ts#L236) - **Line:** 236 @@ -1405,14 +1405,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L36) - **Line:** 36 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L59) - - **Line:** 59 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L61) + - **Line:** 61 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L79) - - **Line:** 79 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L81) + - **Line:** 81 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L403) - - **Line:** 403 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L469) + - **Line:** 469 - **Context:** `pub struct SearchUpdateEvent {` - **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L305) - **Line:** 305 @@ -1465,8 +1465,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9) - **Line:** 9 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L188) - - **Line:** 188 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L190) + - **Line:** 190 - **Context:** `Unknown` ### DR-067 @@ -1479,8 +1479,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9) - **Line:** 9 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L238) - - **Line:** 238 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L240) + - **Line:** 240 - **Context:** `Unknown` ### DR-068 @@ -1517,11 +1517,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L961) - **Line:** 961 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1972) - - **Line:** 1972 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2012) + - **Line:** 2012 - **Context:** `export type MergedMediaItem = { id: string; title: string; artist: str...` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1981) - - **Line:** 1981 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2021) + - **Line:** 2021 - **Context:** `export type NetworkStateWrapperArg = { networkType: NetworkType; unmet...` - **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L81) - **Line:** 81 @@ -1612,8 +1612,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L43) - **Line:** 43 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2396) - - **Line:** 2396 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2402) + - **Line:** 2402 - **Context:** `Unknown` ### DR-079 @@ -1656,29 +1656,29 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L97) - - **Line:** 97 - - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) - - **Line:** 105 - - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1223) - **Line:** 1223 - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1231) - **Line:** 1231 - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L97) + - **Line:** 97 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) + - **Line:** 105 + - **Context:** `Unknown` - **File:** [`src/lib/components/downloads/DownloadedBrowse.svelte`](src/lib/components/downloads/DownloadedBrowse.svelte#L10) - **Line:** 10 - **Context:** `Unknown` - **File:** [`src/lib/services/downloadedCatalog.ts`](src/lib/services/downloadedCatalog.ts#L12) - **Line:** 12 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L204) - - **Line:** 204 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L205) + - **Line:** 205 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L219) - - **Line:** 219 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L220) + - **Line:** 220 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538) - **Line:** 538 @@ -1689,23 +1689,23 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L669) - **Line:** 669 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2697) - - **Line:** 2697 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2703) + - **Line:** 2703 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2719) - - **Line:** 2719 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2725) + - **Line:** 2725 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2753) - - **Line:** 2753 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2759) + - **Line:** 2759 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2799) - - **Line:** 2799 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2805) + - **Line:** 2805 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2820) - - **Line:** 2820 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2826) + - **Line:** 2826 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2839) - - **Line:** 2839 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2845) + - **Line:** 2845 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L143) - **Line:** 143 @@ -1721,15 +1721,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) - - **Line:** 105 - - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L807) - **Line:** 807 - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1231) - **Line:** 1231 - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) + - **Line:** 105 + - **Context:** `Unknown` - **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L24) - **Line:** 24 - **Context:** `Unknown` @@ -1748,8 +1748,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L1925) - **Line:** 1925 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L219) - - **Line:** 219 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L220) + - **Line:** 220 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538) - **Line:** 538 @@ -1757,20 +1757,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L565) - **Line:** 565 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2697) - - **Line:** 2697 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2703) + - **Line:** 2703 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2719) - - **Line:** 2719 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2725) + - **Line:** 2725 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2753) - - **Line:** 2753 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2759) + - **Line:** 2759 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2799) - - **Line:** 2799 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2805) + - **Line:** 2805 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2900) - - **Line:** 2900 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2906) + - **Line:** 2906 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L143) - **Line:** 143 @@ -1794,14 +1794,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L113) - - **Line:** 113 - - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1239) - **Line:** 1239 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1725) - - **Line:** 1725 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1765) + - **Line:** 1765 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L113) + - **Line:** 113 - **Context:** `Unknown` - **File:** [`src/lib/components/library/LibraryGrid.svelte`](src/lib/components/library/LibraryGrid.svelte#L17) - **Line:** 17 @@ -1821,21 +1821,21 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/services/downloadedCatalog.ts`](src/lib/services/downloadedCatalog.ts#L12) - **Line:** 12 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L236) - - **Line:** 236 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L237) + - **Line:** 237 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259) + - **Line:** 259 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L720) - **Line:** 720 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2861) - - **Line:** 2861 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2867) + - **Line:** 2867 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L161) - **Line:** 161 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259) - - **Line:** 259 - - **Context:** `Unknown` ### DR-086 @@ -1849,8 +1849,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 4 file(s) -- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L414) - - **Line:** 414 +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L448) + - **Line:** 448 - **Context:** `Unknown` - **File:** [`src/routes/+page.svelte`](src/routes/+page.svelte#L62) - **Line:** 62 @@ -1902,11 +1902,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L103) - - **Line:** 103 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L105) + - **Line:** 105 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L169) - - **Line:** 169 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L171) + - **Line:** 171 - **Context:** `Unknown` ### DR-092 @@ -2015,6 +2015,158 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 8 - **Context:** `Unknown` +### DR-101 + +**Locations:** 8 file(s) + +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L163) + - **Line:** 163 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1278) + - **Line:** 1278 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1291) + - **Line:** 1291 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L144) + - **Line:** 144 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L154) + - **Line:** 154 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L331) + - **Line:** 331 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L352) + - **Line:** 352 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/series_progress.rs`](src-tauri/src/repository/series_progress.rs#L12) + - **Line:** 12 + - **Context:** `Unknown` + +### DR-102 + +**Locations:** 6 file(s) + +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L163) + - **Line:** 163 + - **Context:** `Unknown` +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L278) + - **Line:** 278 + - **Context:** `async function handlePlayAll() {` +- **File:** [`src/lib/components/library/EpisodeRow.svelte`](src/lib/components/library/EpisodeRow.svelte#L16) + - **Line:** 16 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/seriesNavigation.ts`](src/lib/components/library/seriesNavigation.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` + +### DR-103 + +**Locations:** 5 file(s) + +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L104) + - **Line:** 104 + - **Context:** `Unknown` +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/seriesNavigation.ts`](src/lib/components/library/seriesNavigation.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` + +### DR-104 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/components/library/episodeStrip.ts`](src/lib/components/library/episodeStrip.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` + +### DR-105 + +**Locations:** 10 file(s) + +- **File:** [`src/routes/library/shows/genres/+page.ts`](src/routes/library/shows/genres/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/movies/genres/+page.ts`](src/routes/library/movies/genres/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/movies/all/+page.ts`](src/routes/library/movies/all/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/routes/library/tv/shows/+page.ts`](src/routes/library/tv/shows/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/LibraryViewTabs.svelte`](src/lib/components/library/LibraryViewTabs.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/GenericMediaListPage.svelte`](src/lib/components/library/GenericMediaListPage.svelte#L47) + - **Line:** 47 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/GenericGenreBrowser.svelte`](src/lib/components/library/GenericGenreBrowser.svelte#L41) + - **Line:** 41 + - **Context:** `Unknown` +- **File:** [`src/lib/utils/libraryView.ts`](src/lib/utils/libraryView.ts#L9) + - **Line:** 9 + - **Context:** `Unknown` + +### DR-106 + +**Locations:** 7 file(s) + +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1305) + - **Line:** 1305 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L165) + - **Line:** 165 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/ClearHistoryButton.svelte`](src/lib/components/library/ClearHistoryButton.svelte#L9) + - **Line:** 9 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L374) + - **Line:** 374 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L220) + - **Line:** 220 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1698) + - **Line:** 1698 + - **Context:** `Unknown` + +### DR-107 + +**Locations:** 2 file(s) + +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L19) + - **Line:** 19 + - **Context:** `Unknown` + ### JA-001 **Locations:** 1 file(s) @@ -2284,37 +2436,45 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L451) - **Line:** 451 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L203) - - **Line:** 203 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1366) + - **Line:** 1366 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1326) - - **Line:** 1326 +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L234) + - **Line:** 234 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L520) - - **Line:** 520 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L586) + - **Line:** 586 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L127) - - **Line:** 127 +- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L128) + - **Line:** 128 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) - **Line:** 435 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2109) - - **Line:** 2109 +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2151) + - **Line:** 2151 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2150) - - **Line:** 2150 +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2192) + - **Line:** 2192 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L82) - **Line:** 82 - **Context:** `Unknown` +### JA-033 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1698) + - **Line:** 1698 + - **Context:** `Unknown` + ### UR-002 **Locations:** 16 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) - - **Line:** 2240 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2280) + - **Line:** 2280 - **Context:** `Unknown` - **File:** [`src/lib/stores/connectivity.ts`](src/lib/stores/connectivity.ts#L10) - **Line:** 10 @@ -2366,11 +2526,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 20 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2136) - - **Line:** 2136 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2176) + - **Line:** 2176 - **Context:** `export type PlaybackProgress = { itemId: string;` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) - - **Line:** 2240 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2280) + - **Line:** 2280 - **Context:** `Unknown` - **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) - **Line:** 1 @@ -2431,11 +2591,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 15 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2136) - - **Line:** 2136 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2176) + - **Line:** 2176 - **Context:** `export type PlaybackProgress = { itemId: string;` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) - - **Line:** 2240 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2280) + - **Line:** 2280 - **Context:** `Unknown` - **File:** [`src/lib/components/player/AudioPlayer.svelte`](src/lib/components/player/AudioPlayer.svelte#L1) - **Line:** 1 @@ -2481,14 +2641,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 68 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2258) - - **Line:** 2258 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2298) + - **Line:** 2298 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2512) - - **Line:** 2512 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2552) + - **Line:** 2552 - **Context:** `export type RemoteSessionStatus = { position: number; duration: number...` - **File:** [`src/lib/components/player/Controls.svelte`](src/lib/components/player/Controls.svelte#L1) - **Line:** 1 @@ -2693,11 +2853,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` - **File:** [`src/lib/components/library/AlphabetScrollBar.svelte`](src/lib/components/library/AlphabetScrollBar.svelte#L1) - **Line:** 1 @@ -2736,6 +2896,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` +### UR-008 + +**Locations:** 2 file(s) + +- **File:** [`src/lib/components/library/GenericMediaListPage.searchEvent.test.ts`](src/lib/components/library/GenericMediaListPage.searchEvent.test.ts#L12) + - **Line:** 12 + - **Context:** `Unknown` +- **File:** [`src/lib/stores/library.ts`](src/lib/stores/library.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` + ### UR-009 **Locations:** 15 file(s) @@ -2790,8 +2961,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 11 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2240) - - **Line:** 2240 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2280) + - **Line:** 2280 - **Context:** `Unknown` - **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L2) - **Line:** 2 @@ -2824,6 +2995,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 441 - **Context:** `Unknown` +### UR-014 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/playlist.rs`](src-tauri/src/commands/playlist.rs#L4) + - **Line:** 4 + - **Context:** `Unknown` + ### UR-017 **Locations:** 9 file(s) @@ -2856,12 +3035,26 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 5 - **Context:** `Unknown` +### UR-018 + +**Locations:** 3 file(s) + +- **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` +- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) + - **Line:** 2027 + - **Context:** `Unknown` + ### UR-019 **Locations:** 12 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` - **File:** [`src/lib/services/playerEvents.test.ts`](src/lib/services/playerEvents.test.ts#L4) - **Line:** 4 @@ -2901,11 +3094,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 24 file(s) -- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` - **File:** [`src/routes/settings/+page.svelte`](src/routes/settings/+page.svelte#L1) - **Line:** 1 @@ -2913,8 +3106,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L241) - **Line:** 241 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` - **File:** [`src/lib/stores/nextEpisode.ts`](src/lib/stores/nextEpisode.ts#L9) - **Line:** 9 @@ -2974,59 +3167,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` -### UR-025 - -**Locations:** 16 file(s) - -- **File:** [`src/lib/utils/validation.test.ts`](src/lib/utils/validation.test.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.test.ts`](src/lib/services/playbackReporting.test.ts#L4) - - **Line:** 4 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L10) - - **Line:** 10 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L21) - - **Line:** 21 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L89) - - **Line:** 89 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L122) - - **Line:** 122 - - **Context:** `Unknown` -- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L7) - - **Line:** 7 - - **Context:** `Unknown` -- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L64) - - **Line:** 64 - - **Context:** `Unknown` -- **File:** [`src/lib/services/skipReporting.ts`](src/lib/services/skipReporting.ts#L14) - - **Line:** 14 - - **Context:** `Unknown` -- **File:** [`src/lib/services/skipReporting.test.ts`](src/lib/services/skipReporting.test.ts#L9) - - **Line:** 9 - - **Context:** `Unknown` -- **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L156) - - **Line:** 156 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/mod.rs`](src-tauri/src/commands/mod.rs#L2) - - **Line:** 2 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/playback_reporting.rs`](src-tauri/src/commands/playback_reporting.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/sync.rs`](src-tauri/src/commands/sync.rs#L5) - - **Line:** 5 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` - ### UR-026 **Locations:** 19 file(s) @@ -3034,11 +3174,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L241) - **Line:** 241 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2315) - - **Line:** 2315 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2355) + - **Line:** 2355 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2572) - - **Line:** 2572 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2612) + - **Line:** 2612 - **Context:** `export type SessionInfo = { id?: string | null; userId?: string | null...` - **File:** [`src/lib/components/player/SleepTimerModal.svelte`](src/lib/components/player/SleepTimerModal.svelte#L1) - **Line:** 1 @@ -3099,8 +3239,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L181) - **Line:** 181 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1780) - - **Line:** 1780 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1820) + - **Line:** 1820 - **Context:** `export type DownloadsResponse = { downloads: DownloadInfo[]; stats: Do...` - **File:** [`src-tauri/src/player/android/mod.rs`](src-tauri/src/player/android/mod.rs#L545) - **Line:** 545 @@ -3163,17 +3303,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 337 - **Context:** `Unknown` -### UR-028 - -**Locations:** 2 file(s) - -- **File:** [`src/lib/components/player/AudioPlayer.svelte`](src/lib/components/player/AudioPlayer.svelte#L1) - - **Line:** 1 - - **Context:** `Unknown` -- **File:** [`src/lib/components/player/MiniPlayer.svelte`](src/lib/components/player/MiniPlayer.svelte#L1) - - **Line:** 1 - - **Context:** `Unknown` - ### UR-029 **Locations:** 5 file(s) @@ -3181,10 +3310,10 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/settings/+page.svelte`](src/routes/settings/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/library/GenericMediaListPage.svelte`](src/lib/components/library/GenericMediaListPage.svelte#L1) +- **File:** [`src/lib/components/library/LibraryGrid.svelte`](src/lib/components/library/LibraryGrid.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/library/LibraryGrid.svelte`](src/lib/components/library/LibraryGrid.svelte#L1) +- **File:** [`src/lib/components/library/GenericMediaListPage.svelte`](src/lib/components/library/GenericMediaListPage.svelte#L1) - **Line:** 1 - **Context:** `Unknown` - **File:** [`src/lib/stores/viewMode.test.ts`](src/lib/stores/viewMode.test.ts#L7) @@ -3201,11 +3330,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/music/+page.svelte`](src/routes/library/music/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L1) - - **Line:** 1 +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 - **Context:** `Unknown` - **File:** [`src/lib/components/home/HeroBanner.svelte`](src/lib/components/home/HeroBanner.svelte#L1) - **Line:** 1 @@ -3267,9 +3396,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L451) - **Line:** 451 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L203) - - **Line:** 203 - - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L38) - **Line:** 38 - **Context:** `Unknown` @@ -3279,8 +3405,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L241) - **Line:** 241 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1326) - - **Line:** 1326 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1366) + - **Line:** 1366 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L234) + - **Line:** 234 - **Context:** `Unknown` - **File:** [`src/lib/components/player/backgroundAudioHandoff.test.ts`](src/lib/components/player/backgroundAudioHandoff.test.ts#L10) - **Line:** 10 @@ -3324,31 +3453,34 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/commands/player/timers.rs`](src-tauri/src/commands/player/timers.rs#L145) - **Line:** 145 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L520) - - **Line:** 520 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L586) + - **Line:** 586 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L127) - - **Line:** 127 +- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L128) + - **Line:** 128 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) - **Line:** 435 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2109) - - **Line:** 2109 +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2151) + - **Line:** 2151 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2150) - - **Line:** 2150 +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2192) + - **Line:** 2192 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L82) - **Line:** 82 - **Context:** `Unknown` -### UR-041 +### UR-043 -**Locations:** 1 file(s) +**Locations:** 2 file(s) -- **File:** [`src/lib/utils/pictureInPicture.ts`](src/lib/utils/pictureInPicture.ts#L4) - - **Line:** 4 +- **File:** [`src/lib/stores/connectivity.ts`](src/lib/stores/connectivity.ts#L10) + - **Line:** 10 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/connectivity.rs`](src-tauri/src/commands/connectivity.rs#L3) + - **Line:** 3 - **Context:** `Unknown` ### UR-048 @@ -3358,12 +3490,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L1) - **Line:** 1 - **Context:** `Unknown` +- **File:** [`src/lib/components/library/episodeStrip.ts`](src/lib/components/library/episodeStrip.ts#L8) + - **Line:** 8 + - **Context:** `Unknown` - **File:** [`src/lib/components/library/EpisodeFocusView.svelte`](src/lib/components/library/EpisodeFocusView.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/library/episodeStrip.ts`](src/lib/components/library/episodeStrip.ts#L7) - - **Line:** 7 - - **Context:** `Unknown` ### UR-049 @@ -3384,11 +3516,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L22) - **Line:** 22 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1300) - - **Line:** 1300 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1340) + - **Line:** 1340 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2547) - - **Line:** 2547 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2587) + - **Line:** 2587 - **Context:** `export type SearchResult = { items: MediaItem[]; totalRecordCount: num...` - **File:** [`src/lib/components/search/SearchScopeChips.svelte`](src/lib/components/search/SearchScopeChips.svelte#L7) - **Line:** 7 @@ -3405,14 +3537,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L36) - **Line:** 36 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L59) - - **Line:** 59 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L61) + - **Line:** 61 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L79) - - **Line:** 79 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L81) + - **Line:** 81 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L403) - - **Line:** 403 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L469) + - **Line:** 469 - **Context:** `pub struct SearchUpdateEvent {` - **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L305) - **Line:** 305 @@ -3428,8 +3560,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 9 file(s) -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1300) - - **Line:** 1300 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1340) + - **Line:** 1340 - **Context:** `Unknown` - **File:** [`src/lib/components/search/SearchResults.svelte`](src/lib/components/search/SearchResults.svelte#L9) - **Line:** 9 @@ -3446,14 +3578,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9) - **Line:** 9 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L188) - - **Line:** 188 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L190) + - **Line:** 190 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L238) - - **Line:** 238 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L240) + - **Line:** 240 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L403) - - **Line:** 403 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L469) + - **Line:** 469 - **Context:** `pub struct SearchUpdateEvent {` ### UR-053 @@ -3466,11 +3598,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L961) - **Line:** 961 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1972) - - **Line:** 1972 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2012) + - **Line:** 2012 - **Context:** `export type MergedMediaItem = { id: string; title: string; artist: str...` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1981) - - **Line:** 1981 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2021) + - **Line:** 2021 - **Context:** `export type NetworkStateWrapperArg = { networkType: NetworkType; unmet...` - **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L81) - **Line:** 81 @@ -3519,12 +3651,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L97) - - **Line:** 97 - - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) - - **Line:** 105 - - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L807) - **Line:** 807 - **Context:** `Unknown` @@ -3534,6 +3660,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1231) - **Line:** 1231 - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L97) + - **Line:** 97 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L105) + - **Line:** 105 + - **Context:** `Unknown` - **File:** [`src/lib/components/library/MediaCard.svelte`](src/lib/components/library/MediaCard.svelte#L24) - **Line:** 24 - **Context:** `Unknown` @@ -3555,11 +3687,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L1925) - **Line:** 1925 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L204) - - **Line:** 204 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L205) + - **Line:** 205 - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L219) - - **Line:** 219 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L220) + - **Line:** 220 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538) - **Line:** 538 @@ -3570,26 +3702,26 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L669) - **Line:** 669 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2697) - - **Line:** 2697 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2703) + - **Line:** 2703 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2719) - - **Line:** 2719 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2725) + - **Line:** 2725 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2753) - - **Line:** 2753 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2759) + - **Line:** 2759 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2799) - - **Line:** 2799 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2805) + - **Line:** 2805 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2820) - - **Line:** 2820 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2826) + - **Line:** 2826 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2839) - - **Line:** 2839 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2845) + - **Line:** 2845 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2900) - - **Line:** 2900 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2906) + - **Line:** 2906 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L143) - **Line:** 143 @@ -3605,14 +3737,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/routes/downloads/+page.svelte`](src/routes/downloads/+page.svelte#L14) - **Line:** 14 - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L113) - - **Line:** 113 - - **Context:** `Unknown` - **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1239) - **Line:** 1239 - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1725) - - **Line:** 1725 +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1765) + - **Line:** 1765 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L113) + - **Line:** 113 - **Context:** `Unknown` - **File:** [`src/lib/components/library/LibraryGrid.svelte`](src/lib/components/library/LibraryGrid.svelte#L17) - **Line:** 17 @@ -3638,21 +3770,21 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src/lib/services/downloadedCatalog.ts`](src/lib/services/downloadedCatalog.ts#L95) - **Line:** 95 - **Context:** `function sizeOf(itemId: string): number {` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L236) - - **Line:** 236 +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L237) + - **Line:** 237 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259) + - **Line:** 259 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L720) - **Line:** 720 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2861) - - **Line:** 2861 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2867) + - **Line:** 2867 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L161) - **Line:** 161 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259) - - **Line:** 259 - - **Context:** `Unknown` ### UR-057 @@ -3666,8 +3798,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 4 file(s) -- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L414) - - **Line:** 414 +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L448) + - **Line:** 448 - **Context:** `Unknown` - **File:** [`src/routes/+page.svelte`](src/routes/+page.svelte#L62) - **Line:** 62 @@ -3679,21 +3811,110 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 37 - **Context:** `Unknown` -### IT-003 +### UR-061 -**Locations:** 1 file(s) +**Locations:** 4 file(s) -- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) - - **Line:** 8 +- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L21) + - **Line:** 21 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/VideoPlayer.tapSurface.test.ts`](src/lib/components/player/VideoPlayer.tapSurface.test.ts#L5) + - **Line:** 5 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/components/player/VideoPlayer.touchScrub.test.ts`](src/lib/components/player/VideoPlayer.touchScrub.test.ts#L21) + - **Line:** 21 - **Context:** `Unknown` -### IT-004 +### UR-062 -**Locations:** 1 file(s) +**Locations:** 16 file(s) -- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L104) + - **Line:** 104 + - **Context:** `Unknown` +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L163) + - **Line:** 163 + - **Context:** `Unknown` +- **File:** [`src/routes/library/[id]/+page.svelte`](src/routes/library/[id]/+page.svelte#L278) + - **Line:** 278 + - **Context:** `async function handlePlayAll() {` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1278) + - **Line:** 1278 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1291) + - **Line:** 1291 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L144) + - **Line:** 144 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L154) + - **Line:** 154 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/episodeStrip.ts`](src/lib/components/library/episodeStrip.ts#L8) - **Line:** 8 - **Context:** `Unknown` +- **File:** [`src/lib/components/library/EpisodeRow.svelte`](src/lib/components/library/EpisodeRow.svelte#L16) + - **Line:** 16 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/seriesNavigation.ts`](src/lib/components/library/seriesNavigation.ts#L13) + - **Line:** 13 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L19) + - **Line:** 19 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L331) + - **Line:** 331 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L352) + - **Line:** 352 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/series_progress.rs`](src-tauri/src/repository/series_progress.rs#L12) + - **Line:** 12 + - **Context:** `Unknown` + +### UR-063 + +**Locations:** 10 file(s) + +- **File:** [`src/routes/library/shows/genres/+page.ts`](src/routes/library/shows/genres/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/movies/genres/+page.ts`](src/routes/library/movies/genres/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/movies/all/+page.ts`](src/routes/library/movies/all/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/movies/+page.svelte`](src/routes/library/movies/+page.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/routes/library/tv/shows/+page.ts`](src/routes/library/tv/shows/+page.ts#L6) + - **Line:** 6 + - **Context:** `Unknown` +- **File:** [`src/routes/library/tv/+page.svelte`](src/routes/library/tv/+page.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/LibraryViewTabs.svelte`](src/lib/components/library/LibraryViewTabs.svelte#L8) + - **Line:** 8 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/GenericMediaListPage.svelte`](src/lib/components/library/GenericMediaListPage.svelte#L47) + - **Line:** 47 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/GenericGenreBrowser.svelte`](src/lib/components/library/GenericGenreBrowser.svelte#L41) + - **Line:** 41 + - **Context:** `Unknown` +- **File:** [`src/lib/utils/libraryView.ts`](src/lib/utils/libraryView.ts#L9) + - **Line:** 9 + - **Context:** `Unknown` ### IT-013 @@ -3712,26 +3933,10 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 663 - **Context:** `Unknown` -### IT-016 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2396) - - **Line:** 2396 - - **Context:** `Unknown` - ### UT-003 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) - - **Line:** 535 - - **Context:** `pub enum AddPosition {` - -### UT-004 - -**Locations:** 1 file(s) - - **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) - **Line:** 535 - **Context:** `pub enum AddPosition {` @@ -3752,13 +3957,257 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 2 - **Context:** `Unknown` -### UT-012 +### UT-024 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) - - **Line:** 81 - - **Context:** `pub fn cancel(&mut self) {` +- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` + +### UT-027 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-028 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-029 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-031 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-033 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) + - **Line:** 245 + - **Context:** `Unknown` + +### UT-052 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L315) + - **Line:** 315 + - **Context:** `Unknown` + +### UT-061 + +**Locations:** 9 file(s) + +- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434) + - **Line:** 434 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L451) + - **Line:** 451 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L38) + - **Line:** 38 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L51) + - **Line:** 51 + - **Context:** `Unknown` +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1366) + - **Line:** 1366 + - **Context:** `Unknown` +- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7) + - **Line:** 7 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L571) + - **Line:** 571 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L663) + - **Line:** 663 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L586) + - **Line:** 586 + - **Context:** `Unknown` + +### UR-024 + +**Locations:** 2 file(s) + +- **File:** [`src/lib/stores/home.ts`](src/lib/stores/home.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-025 + +**Locations:** 16 file(s) + +- **File:** [`src/lib/utils/validation.test.ts`](src/lib/utils/validation.test.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.test.ts`](src/lib/services/playbackReporting.test.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L10) + - **Line:** 10 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L21) + - **Line:** 21 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L89) + - **Line:** 89 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playbackReporting.ts`](src/lib/services/playbackReporting.ts#L122) + - **Line:** 122 + - **Context:** `Unknown` +- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L7) + - **Line:** 7 + - **Context:** `Unknown` +- **File:** [`src/lib/services/syncService.ts`](src/lib/services/syncService.ts#L64) + - **Line:** 64 + - **Context:** `Unknown` +- **File:** [`src/lib/services/skipReporting.ts`](src/lib/services/skipReporting.ts#L14) + - **Line:** 14 + - **Context:** `Unknown` +- **File:** [`src/lib/services/skipReporting.test.ts`](src/lib/services/skipReporting.test.ts#L9) + - **Line:** 9 + - **Context:** `Unknown` +- **File:** [`src/lib/services/playerEvents.ts`](src/lib/services/playerEvents.ts#L156) + - **Line:** 156 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/storage/mod.rs`](src-tauri/src/commands/storage/mod.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/mod.rs`](src-tauri/src/commands/mod.rs#L2) + - **Line:** 2 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/playback_reporting.rs`](src-tauri/src/commands/playback_reporting.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/sync.rs`](src-tauri/src/commands/sync.rs#L5) + - **Line:** 5 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` + +### UR-041 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/utils/pictureInPicture.ts`](src/lib/utils/pictureInPicture.ts#L4) + - **Line:** 4 + - **Context:** `Unknown` + +### UR-044 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/download/pinning.rs`](src-tauri/src/commands/download/pinning.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-045 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/download/smart_cache.rs`](src-tauri/src/commands/download/smart_cache.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` + +### UR-059 + +**Locations:** 6 file(s) + +- **File:** [`src/lib/stores/continueWatchingFilter.ts`](src/lib/stores/continueWatchingFilter.ts#L12) + - **Line:** 12 + - **Context:** `Unknown` +- **File:** [`src/lib/stores/continueWatchingFilter.test.ts`](src/lib/stores/continueWatchingFilter.test.ts#L9) + - **Line:** 9 + - **Context:** `Unknown` +- **File:** [`src/lib/stores/home.ts`](src/lib/stores/home.ts#L2) + - **Line:** 2 + - **Context:** `Unknown` +- **File:** [`src/lib/stores/tv.ts`](src/lib/stores/tv.ts#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src/lib/services/skipReporting.ts`](src/lib/services/skipReporting.ts#L14) + - **Line:** 14 + - **Context:** `Unknown` +- **File:** [`src/lib/services/skipReporting.test.ts`](src/lib/services/skipReporting.test.ts#L9) + - **Line:** 9 + - **Context:** `Unknown` + +### UR-064 + +**Locations:** 7 file(s) + +- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1305) + - **Line:** 1305 + - **Context:** `Unknown` +- **File:** [`src/lib/api/repository-client.ts`](src/lib/api/repository-client.ts#L165) + - **Line:** 165 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/ClearHistoryButton.svelte`](src/lib/components/library/ClearHistoryButton.svelte#L9) + - **Line:** 9 + - **Context:** `Unknown` +- **File:** [`src/lib/components/library/SeasonSection.svelte`](src/lib/components/library/SeasonSection.svelte#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L374) + - **Line:** 374 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L220) + - **Line:** 220 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1698) + - **Line:** 1698 + - **Context:** `Unknown` + +### IT-016 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2402) + - **Line:** 2402 + - **Context:** `Unknown` + +### IT-017 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L441) + - **Line:** 441 + - **Context:** `Unknown` + +### UT-014 + +**Locations:** 2 file(s) + +- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) + - **Line:** 311 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` ### UT-015 @@ -3786,6 +4235,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 2 file(s) +- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) + - **Line:** 311 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` + +### UT-018 + +**Locations:** 2 file(s) + - **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - **Line:** 311 - **Context:** `Unknown` @@ -3826,7 +4286,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 157 - **Context:** `pub fn file_size(&self) -> Option {` -### UT-023 +### UT-022 **Locations:** 2 file(s) @@ -3837,13 +4297,16 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 157 - **Context:** `pub fn file_size(&self) -> Option {` -### UT-024 +### UT-023 -**Locations:** 1 file(s) +**Locations:** 2 file(s) -- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) - - **Line:** 2 +- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) + - **Line:** 311 - **Context:** `Unknown` +- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) + - **Line:** 157 + - **Context:** `pub fn file_size(&self) -> Option {` ### UT-025 @@ -3867,7 +4330,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 251 - **Context:** `Unknown` -### UT-027 +### UT-032 **Locations:** 1 file(s) @@ -3875,52 +4338,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 245 - **Context:** `Unknown` -### UT-029 +### UT-042 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 +- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) + - **Line:** 2027 - **Context:** `Unknown` -### UT-030 +### UT-043 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - -### UT-031 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - -### UT-033 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 - - **Context:** `Unknown` - -### UT-044 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/offline.rs`](src-tauri/src/commands/offline.rs#L136) - - **Line:** 136 - - **Context:** `Unknown` - -### UT-052 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L315) - - **Line:** 315 +- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) + - **Line:** 2027 - **Context:** `Unknown` ### UT-060 @@ -3934,53 +4365,15 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 4 - **Context:** `Unknown` -### UT-061 +### UR-028 -**Locations:** 9 file(s) +**Locations:** 2 file(s) -- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L434) - - **Line:** 434 - - **Context:** `Unknown` -- **File:** [`src/lib/api/repository-client.test.ts`](src/lib/api/repository-client.test.ts#L451) - - **Line:** 451 - - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L38) - - **Line:** 38 - - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L51) - - **Line:** 51 - - **Context:** `Unknown` -- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1326) - - **Line:** 1326 - - **Context:** `Unknown` -- **File:** [`src/lib/player/backgroundAudio.commands.test.ts`](src/lib/player/backgroundAudio.commands.test.ts#L7) - - **Line:** 7 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L571) - - **Line:** 571 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L663) - - **Line:** 663 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L520) - - **Line:** 520 - - **Context:** `Unknown` - -### UR-061 - -**Locations:** 4 file(s) - -- **File:** [`src/lib/components/player/tapGestures.ts`](src/lib/components/player/tapGestures.ts#L21) - - **Line:** 21 - - **Context:** `Unknown` -- **File:** [`src/lib/components/player/VideoPlayer.tapSurface.test.ts`](src/lib/components/player/VideoPlayer.tapSurface.test.ts#L5) - - **Line:** 5 - - **Context:** `Unknown` -- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1) +- **File:** [`src/lib/components/player/AudioPlayer.svelte`](src/lib/components/player/AudioPlayer.svelte#L1) - **Line:** 1 - **Context:** `Unknown` -- **File:** [`src/lib/components/player/VideoPlayer.touchScrub.test.ts`](src/lib/components/player/VideoPlayer.touchScrub.test.ts#L21) - - **Line:** 21 +- **File:** [`src/lib/components/player/MiniPlayer.svelte`](src/lib/components/player/MiniPlayer.svelte#L1) + - **Line:** 1 - **Context:** `Unknown` ### UT-062 @@ -4040,14 +4433,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 21 - **Context:** `Unknown` -### UT-089 - -**Locations:** 1 file(s) - -- **File:** [`src/lib/components/player/VideoPlayer.touchScrub.test.ts`](src/lib/components/player/VideoPlayer.touchScrub.test.ts#L21) - - **Line:** 21 - - **Context:** `Unknown` - ### UT-090 **Locations:** 1 file(s) @@ -4070,14 +4455,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 8 - **Context:** `Unknown` -### UR-014 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/playlist.rs`](src-tauri/src/commands/playlist.rs#L4) - - **Line:** 4 - - **Context:** `Unknown` - ### UR-020 **Locations:** 4 file(s) @@ -4115,6 +4492,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` +### UT-089 + +**Locations:** 1 file(s) + +- **File:** [`src/lib/components/player/VideoPlayer.touchScrub.test.ts`](src/lib/components/player/VideoPlayer.touchScrub.test.ts#L21) + - **Line:** 21 + - **Context:** `Unknown` + ### UT-093 **Locations:** 2 file(s) @@ -4126,17 +4511,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 8 - **Context:** `Unknown` -### UR-008 - -**Locations:** 2 file(s) - -- **File:** [`src/lib/components/library/GenericMediaListPage.searchEvent.test.ts`](src/lib/components/library/GenericMediaListPage.searchEvent.test.ts#L12) - - **Line:** 12 - - **Context:** `Unknown` -- **File:** [`src/lib/stores/library.ts`](src/lib/stores/library.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` - ### UR-010 **Locations:** 15 file(s) @@ -4274,31 +4648,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` -### UR-018 - -**Locations:** 3 file(s) - -- **File:** [`src/lib/stores/downloads.ts`](src/lib/stores/downloads.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` -- **File:** [`src/lib/stores/downloads.test.ts`](src/lib/stores/downloads.test.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) - - **Line:** 2027 - - **Context:** `Unknown` - -### UR-024 - -**Locations:** 2 file(s) - -- **File:** [`src/lib/stores/home.ts`](src/lib/stores/home.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-030 **Locations:** 2 file(s) @@ -4321,6 +4670,47 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 1 - **Context:** `Unknown` +### UR-033 + +**Locations:** 12 file(s) + +- **File:** [`src-tauri/src/player/android/mod.rs`](src-tauri/src/player/android/mod.rs#L545) + - **Line:** 545 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/android/mod.rs`](src-tauri/src/player/android/mod.rs#L583) + - **Line:** 583 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L584) + - **Line:** 584 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L638) + - **Line:** 638 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704) + - **Line:** 704 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714) + - **Line:** 714 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) + - **Line:** 759 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) + - **Line:** 3 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1) + - **Line:** 1 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L193) + - **Line:** 193 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L210) + - **Line:** 210 + - **Context:** `Unknown` +- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L234) + - **Line:** 234 + - **Context:** `Unknown` + ### UR-036 **Locations:** 3 file(s) @@ -4351,25 +4741,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 3 - **Context:** `Unknown` -### UR-043 - -**Locations:** 2 file(s) - -- **File:** [`src/lib/stores/connectivity.ts`](src/lib/stores/connectivity.ts#L10) - - **Line:** 10 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/connectivity.rs`](src-tauri/src/commands/connectivity.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - -### UR-045 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/smart_cache.rs`](src-tauri/src/commands/download/smart_cache.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-047 **Locations:** 1 file(s) @@ -4417,8 +4788,8 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L43) - **Line:** 43 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2396) - - **Line:** 2396 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2402) + - **Line:** 2402 - **Context:** `Unknown` - **File:** [`src-tauri/src/repository/hybrid.rs`](src-tauri/src/repository/hybrid.rs#L8) - **Line:** 8 @@ -4438,85 +4809,53 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 80 - **Context:** `Unknown` -### UR-059 - -**Locations:** 6 file(s) - -- **File:** [`src/lib/stores/continueWatchingFilter.ts`](src/lib/stores/continueWatchingFilter.ts#L12) - - **Line:** 12 - - **Context:** `Unknown` -- **File:** [`src/lib/stores/continueWatchingFilter.test.ts`](src/lib/stores/continueWatchingFilter.test.ts#L9) - - **Line:** 9 - - **Context:** `Unknown` -- **File:** [`src/lib/stores/home.ts`](src/lib/stores/home.ts#L2) - - **Line:** 2 - - **Context:** `Unknown` -- **File:** [`src/lib/stores/tv.ts`](src/lib/stores/tv.ts#L3) - - **Line:** 3 - - **Context:** `Unknown` -- **File:** [`src/lib/services/skipReporting.ts`](src/lib/services/skipReporting.ts#L14) - - **Line:** 14 - - **Context:** `Unknown` -- **File:** [`src/lib/services/skipReporting.test.ts`](src/lib/services/skipReporting.test.ts#L9) - - **Line:** 9 - - **Context:** `Unknown` - ### UR-060 **Locations:** 3 file(s) -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L103) - - **Line:** 103 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L105) + - **Line:** 105 - **Context:** `Unknown` -- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L169) - - **Line:** 169 +- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L171) + - **Line:** 171 - **Context:** `Unknown` - **File:** [`src-tauri/src/domain/search_rank.rs`](src-tauri/src/domain/search_rank.rs#L106) - **Line:** 106 - **Context:** `Unknown` -### IT-017 +### IT-003 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L441) - - **Line:** 441 +- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) + - **Line:** 8 - **Context:** `Unknown` -### UT-014 +### IT-004 -**Locations:** 2 file(s) +**Locations:** 1 file(s) -- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - - **Line:** 311 +- **File:** [`src-tauri/src/player/mpv_backend_test.rs`](src-tauri/src/player/mpv_backend_test.rs#L8) + - **Line:** 8 - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` -### UT-018 +### UT-004 -**Locations:** 2 file(s) +**Locations:** 1 file(s) -- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - - **Line:** 311 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` +- **File:** [`src-tauri/src/player/queue.rs`](src-tauri/src/player/queue.rs#L535) + - **Line:** 535 + - **Context:** `pub enum AddPosition {` -### UT-022 +### UT-012 -**Locations:** 2 file(s) +**Locations:** 1 file(s) -- **File:** [`src-tauri/src/storage/db_service.rs`](src-tauri/src/storage/db_service.rs#L311) - - **Line:** 311 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/storage/mod.rs`](src-tauri/src/storage/mod.rs#L157) - - **Line:** 157 - - **Context:** `pub fn file_size(&self) -> Option {` +- **File:** [`src-tauri/src/player/sleep_timer.rs`](src-tauri/src/player/sleep_timer.rs#L81) + - **Line:** 81 + - **Context:** `pub fn cancel(&mut self) {` -### UT-028 +### UT-030 **Locations:** 1 file(s) @@ -4524,20 +4863,20 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 245 - **Context:** `Unknown` -### UT-032 +### UT-044 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245) - - **Line:** 245 +- **File:** [`src-tauri/src/commands/offline.rs`](src-tauri/src/commands/offline.rs#L136) + - **Line:** 136 - **Context:** `Unknown` ### UT-051 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2900) - - **Line:** 2900 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2906) + - **Line:** 2906 - **Context:** `Unknown` ### UT-059 @@ -4547,11 +4886,11 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L435) - **Line:** 435 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2109) - - **Line:** 2109 +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2151) + - **Line:** 2151 - **Context:** `Unknown` -- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2150) - - **Line:** 2150 +- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L2192) + - **Line:** 2192 - **Context:** `Unknown` ### UT-066 @@ -4566,64 +4905,64 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2396) - - **Line:** 2396 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2402) + - **Line:** 2402 - **Context:** `Unknown` ### UT-072 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2697) - - **Line:** 2697 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2703) + - **Line:** 2703 - **Context:** `Unknown` ### UT-073 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2820) - - **Line:** 2820 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2826) + - **Line:** 2826 - **Context:** `Unknown` ### UT-074 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2839) - - **Line:** 2839 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2845) + - **Line:** 2845 - **Context:** `Unknown` ### UT-075 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2861) - - **Line:** 2861 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2867) + - **Line:** 2867 - **Context:** `Unknown` ### UT-076 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2719) - - **Line:** 2719 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2725) + - **Line:** 2725 - **Context:** `Unknown` ### UT-077 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2753) - - **Line:** 2753 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2759) + - **Line:** 2759 - **Context:** `Unknown` ### UT-078 **Locations:** 1 file(s) -- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2799) - - **Line:** 2799 +- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L2805) + - **Line:** 2805 - **Context:** `Unknown` ### UT-079 @@ -4658,6 +4997,14 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 337 - **Context:** `Unknown` +### UT-083 + +**Locations:** 1 file(s) + +- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L673) + - **Line:** 673 + - **Context:** `Unknown` + ### UT-084 **Locations:** 1 file(s) @@ -4722,79 +5069,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016, - **Line:** 234 - **Context:** `Unknown` -### UR-033 - -**Locations:** 12 file(s) - -- **File:** [`src-tauri/src/player/android/mod.rs`](src-tauri/src/player/android/mod.rs#L545) - - **Line:** 545 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/android/mod.rs`](src-tauri/src/player/android/mod.rs#L583) - - **Line:** 583 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L584) - - **Line:** 584 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L638) - - **Line:** 638 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L704) - - **Line:** 704 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L714) - - **Line:** 714 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L759) - - **Line:** 759 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1) - - **Line:** 1 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L193) - - **Line:** 193 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L210) - - **Line:** 210 - - **Context:** `Unknown` -- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L234) - - **Line:** 234 - - **Context:** `Unknown` - -### UT-042 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) - - **Line:** 2027 - - **Context:** `Unknown` - -### UT-043 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027) - - **Line:** 2027 - - **Context:** `Unknown` - -### UT-083 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/player/mpv_backend.rs`](src-tauri/src/player/mpv_backend.rs#L673) - - **Line:** 673 - - **Context:** `Unknown` - -### UR-044 - -**Locations:** 1 file(s) - -- **File:** [`src-tauri/src/commands/download/pinning.rs`](src-tauri/src/commands/download/pinning.rs#L3) - - **Line:** 3 - - **Context:** `Unknown` - ### UR-046 **Locations:** 1 file(s) diff --git a/docs/ux-flows.md b/docs/ux-flows.md index 9b93f038..5932a3cf 100644 --- a/docs/ux-flows.md +++ b/docs/ux-flows.md @@ -688,10 +688,10 @@ A movie has no continuation set, so cast follows the hero directly. ### 5B.4 Series detail — section order ``` -Hero (poster, title, metadata, Play / Download) +Hero (poster, title, metadata, Resume SxEy / Download / Clear history) → Crew links → Genre tags - → Seasons + episodes (per-season sections) + → Seasons (collapsible; only the current season expanded) → Cast → More Like This ``` @@ -700,6 +700,29 @@ The same principle as §5B.2: **episodes come before cast and similar shows.** The reason a user opens a series page is to pick an episode; discovery content is secondary and sits underneath. +**Rules for the seasons block** *(UR-062, UR-064)*: + +- **The page opens where the viewer is.** The backend resolves the current + episode — in progress, else Next Up, else first unwatched, else the premiere — + and the page scrolls it into view with an `Up next` badge and a highlight ring. + Never season 1 by default, unless season 1 *is* where the viewer is. +- **Seasons collapse; only the current one is expanded.** A ten-season show + otherwise renders hundreds of rows and buries the episode the viewer came for. + A collapsed season still names its episode count and watched count, so + progress is readable without expanding it. +- **The hero button opens, it does not play.** It reads `Resume S2E4` / + `Play S1E1` — naming its target — and navigates to that episode's Focus View, + where Play commits. Play on a *container* is navigation (§5B.5); Play on a + *leaf* is the commitment. +- **A season is never its own page.** `/library/` redirects to + `/library/#season-N`. Every affordance that names a season — the + episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on + the series with that season in view, so the episodes of all seasons stay one + browsable list. +- **Watch history is erasable** per series (hero) and per season (season + header). It confirms first, cannot be undone, and needs the server. Clearing a + whole series returns it to S1E1 by the same path a never-watched show takes. + ### 5B.5 Home-card interaction — tap opens, long-press plays Cards on the Home screen carousels (Next Movie, Next Episode, Continue diff --git a/package.json b/package.json index 1fcbfbfa..4b49bb6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jellytau", - "version": "0.2.9", + "version": "0.3.0", "description": "", "type": "module", "packageManager": "bun@1.3.5", diff --git a/scripts/extract-traces.test.ts b/scripts/extract-traces.test.ts index 68840ab5..07669e7c 100644 --- a/scripts/extract-traces.test.ts +++ b/scripts/extract-traces.test.ts @@ -173,10 +173,10 @@ describe("live requirements.md", () => { ); const defined = countDefinedRequirements(md); - expect(defined.UR).toBe(61); + expect(defined.UR).toBe(64); expect(defined.IR).toBe(29); - expect(defined.DR).toBe(97); + expect(defined.DR).toBe(104); expect(defined.JA).toBe(32); - expect(defined.total).toBe(219); + expect(defined.total).toBe(229); }); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 513ee164..0c592e73 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1994,7 +1994,7 @@ dependencies = [ [[package]] name = "jellytau" -version = "0.2.9" +version = "0.3.0" dependencies = [ "aes-gcm", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d330bd0c..5b1a194d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jellytau" -version = "0.2.9" +version = "0.3.0" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index cc6dbdab..c293c4a5 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -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, 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, 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] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f676199..6e443ecd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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, diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 431d286f..7037766b 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -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, diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index 2d15c1b6..93693a29 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -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 { 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 { 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 { unimplemented!() } diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index 109f3d95..a03dc5b2 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -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; diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 1d3a7645..765470c1 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -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 { let query = Query::with_params( "SELECT id, name, overview, primary_image_tag diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 03b82e65..4e189626 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -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 { let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id); let item: JellyfinItem = self.get_json(&endpoint).await?; diff --git a/src-tauri/src/repository/series_progress.rs b/src-tauri/src/repository/series_progress.rs new file mode 100644 index 00000000..566aba67 --- /dev/null +++ b/src-tauri/src/repository/series_progress.rs @@ -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) -> 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 { + // 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, RepoError> { + let children = repo.get_items(series_id, list_options()).await?; + + let mut episodes: Vec = 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, 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 { + 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 { + (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 = 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"); + } +} diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 1a8784fe..e7c88a2f 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -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 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b633e979..26087716 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index bf231036..807803b0 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1267,6 +1267,46 @@ async repositoryGetResumeItems(handle: string, parentId: string | null, limit: n async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise { return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit }); }, +/** + * 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 + */ +async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise { + return await TAURI_INVOKE("repository_get_series_episodes", { handle, seriesId }); +}, +/** + * 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 + */ +async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise { + return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId }); +}, +/** + * 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 + */ +async repositoryClearWatchHistory(handle: string, itemId: string) : Promise { + return await TAURI_INVOKE("repository_clear_watch_history", { handle, itemId }); +}, /** * Get recently played audio */ diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 64063e8e..9fedfd8e 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -137,6 +137,37 @@ export class RepositoryClient { return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null); } + /** + * Every episode of a series, across all seasons, already in series order. + * The backend owns the season fan-out and the flat-series fallback. + * + * TRACES: UR-062 | DR-101 + */ + async getSeriesEpisodes(seriesId: string): Promise { + return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId); + } + + /** + * The episode the viewer should land on when opening this series. `null` only + * when the series has no episodes. + * + * TRACES: UR-062 | DR-101 + */ + async getSeriesCurrentEpisode(seriesId: string): Promise { + return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId); + } + + /** + * Erase watch history for an item. On a series or season the server applies + * it to everything inside, so the container returns to "never watched". + * Requires the server — this fails offline rather than diverging local state. + * + * TRACES: UR-064 | DR-106 + */ + async clearWatchHistory(itemId: string): Promise { + await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId); + } + async getRecentlyPlayedAudio(limit?: number): Promise { return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null); } diff --git a/src/lib/components/downloads/DownloadedBrowse.svelte b/src/lib/components/downloads/DownloadedBrowse.svelte index d378add7..b58495be 100644 --- a/src/lib/components/downloads/DownloadedBrowse.svelte +++ b/src/lib/components/downloads/DownloadedBrowse.svelte @@ -14,6 +14,7 @@ import { goto } from "$app/navigation"; import type { Library, MediaItem } from "$lib/api/types"; import LibraryGrid from "$lib/components/library/LibraryGrid.svelte"; + import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation"; import { formatBytes } from "$lib/utils/formatBytes"; import { downloadedCatalog, @@ -62,6 +63,16 @@ void openLibrary(item as Library); return; } + // Seasons and episodes resolve inside their series (DR-103): a season has + // no page of its own and an episode is never browsed bare. + if (item.kind === "season") { + goto(seasonRedirectTarget(item) ?? `/library/${item.id}`); + return; + } + if (item.kind === "episode") { + goto(episodeFocusHref(item)); + return; + } goto(`/library/${item.id}`); } diff --git a/src/lib/components/library/ClearHistoryButton.svelte b/src/lib/components/library/ClearHistoryButton.svelte new file mode 100644 index 00000000..47931605 --- /dev/null +++ b/src/lib/components/library/ClearHistoryButton.svelte @@ -0,0 +1,94 @@ + + + + diff --git a/src/lib/components/library/EpisodeFocusView.svelte b/src/lib/components/library/EpisodeFocusView.svelte index 7dae39d1..2ecf54ef 100644 --- a/src/lib/components/library/EpisodeFocusView.svelte +++ b/src/lib/components/library/EpisodeFocusView.svelte @@ -4,7 +4,11 @@ import { truncateMiddle } from "$lib/utils/truncateMiddle"; import type { MediaItem } from "$lib/api/types"; import CachedImage from "$lib/components/common/CachedImage.svelte"; - import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip"; + import { + isCurrentEpisode as isSameEpisode, + adjacentEpisodes as computeAdjacent, + stripCardLabel, + } from "./episodeStrip"; interface Props { episode: MediaItem; @@ -245,8 +249,8 @@
- - {ep.indexNumber || 0}. + + {stripCardLabel(ep, episode)}

{ep.name} diff --git a/src/lib/components/library/EpisodeRow.svelte b/src/lib/components/library/EpisodeRow.svelte index 95b27744..dfa5a2b8 100644 --- a/src/lib/components/library/EpisodeRow.svelte +++ b/src/lib/components/library/EpisodeRow.svelte @@ -10,15 +10,21 @@ interface Props { episode: MediaItem; focused?: boolean; + /** + * This is the episode the viewer is up to. Marked and scrolled to when the + * series page opens, so a viewer four seasons deep lands on their place + * instead of the top of season 1. TRACES: UR-062 | DR-102 + */ + current?: boolean; onclick?: () => void; } - let { episode, focused = false, onclick }: Props = $props(); + let { episode, focused = false, current = false, onclick }: Props = $props(); let buttonRef: HTMLButtonElement | null = null; onMount(() => { - if (focused && buttonRef) { + if ((focused || current) && buttonRef) { // Scroll into view with some offset from top setTimeout(() => { buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -51,7 +57,11 @@ + {/each} + diff --git a/src/lib/components/library/SeasonSection.svelte b/src/lib/components/library/SeasonSection.svelte index 455c81a6..e333cbd3 100644 --- a/src/lib/components/library/SeasonSection.svelte +++ b/src/lib/components/library/SeasonSection.svelte @@ -1,26 +1,56 @@ + -

+
@@ -38,28 +68,60 @@
-
-

- {seasonName} + + - -
+ +
+

-
- {#each episodes as episode (episode.id)} - onEpisodeClick?.(episode)} - /> - {/each} -
+ {#if expanded} +
+ {#each episodes as episode (episode.id)} + onEpisodeClick?.(episode)} + /> + {/each} +
+ {/if}
diff --git a/src/lib/components/library/episodeStrip.test.ts b/src/lib/components/library/episodeStrip.test.ts index f1f5c43f..823900dc 100644 --- a/src/lib/components/library/episodeStrip.test.ts +++ b/src/lib/components/library/episodeStrip.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { MediaItem } from "$lib/api/types"; -import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip"; +import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip"; // Minimal episode factory — only the fields the strip logic reads. function ep( @@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => { expect(strip).toContain(current); }); - it("restricts to the current season when multiple seasons are present", () => { + // ux-flows §5B.2, "Cross-season continuity": the window spans the whole + // series in episode order, so it runs past a season boundary rather than + // dead-ending at the end of a season. + it("runs past the end of a season into the next one", () => { const eps = [...season(1, 5), ...season(2, 5)]; - const current = eps[6]; // S2E2 + const current = eps[4]; // S1E5 — the season finale const strip = adjacentEpisodes(current, eps); - expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true); + expect(strip.map((e) => e.id)).toEqual([ + "s1e2", "s1e3", "s1e4", "s1e5", + "s2e1", "s2e2", "s2e3", "s2e4", "s2e5", + ]); + }); + + it("reaches back into the previous season from a season opener", () => { + const eps = [...season(1, 5), ...season(2, 5)]; + const current = eps[5]; // S2E1 + const strip = adjacentEpisodes(current, eps); + expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]); + expect(strip[3].id).toBe("s2e1"); + }); + + it("orders by season then episode, never interleaving seasons", () => { + const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order + const current = eps[3]; // S1E1 + const strip = adjacentEpisodes(current, eps); + expect(strip.map((e) => e.id)).toEqual([ + "s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3", + ]); + }); + + it("sorts specials (season 0) after the numbered seasons", () => { + const eps = [...season(0, 2), ...season(1, 2)]; + const current = eps[2]; // S1E1 + const strip = adjacentEpisodes(current, eps); + expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]); }); it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => { @@ -93,5 +123,42 @@ describe("adjacentEpisodes", () => { const current = ep("mystery", null, 3); // no season number const strip = adjacentEpisodes(current, eps); expect(strip.length).toBeGreaterThan(1); + // Anchored at its episode number, not dumped at one end of the list. + expect(strip.indexOf(current)).toBeGreaterThan(0); + expect(strip.indexOf(current)).toBeLessThan(strip.length - 1); + }); +}); + +describe("compareSeriesOrder", () => { + it("orders by season, then episode", () => { + expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0); + expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0); + expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0); + }); + + it("puts specials last", () => { + expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0); + expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0); + }); + + it("falls back to episode number when a season is unknown", () => { + expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0); + }); +}); + +describe("stripCardLabel", () => { + const current = ep("cur", 2, 4); + + it("shows a bare episode number within the current season", () => { + expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6."); + }); + + it("shows SxEy once the card crosses a season boundary", () => { + expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1"); + expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8"); + }); + + it("degrades to the episode number when the season is unknown", () => { + expect(stripCardLabel(ep("a", null, 7), current)).toBe("7."); }); }); diff --git a/src/lib/components/library/episodeStrip.ts b/src/lib/components/library/episodeStrip.ts index 6b2604d6..6e119b37 100644 --- a/src/lib/components/library/episodeStrip.ts +++ b/src/lib/components/library/episodeStrip.ts @@ -1,12 +1,20 @@ // Pure logic for the "More Episodes" strip in EpisodeFocusView. // // Extracted from the component so it can be unit-tested: the strip must never -// collapse to just the current episode while real siblings exist, and it must -// not mistake number-less episodes for the current one. +// collapse to just the current episode while real siblings exist, must not +// mistake number-less episodes for the current one, and must run past a season +// boundary rather than dead-ending at the end of a season (ux-flows §5B.2). // -// TRACES: UR-048 | DR-062 +// TRACES: UR-048, UR-062 | DR-062, DR-104 import type { MediaItem } from "$lib/api/types"; +/** Episodes shown before / after the current one in the strip window. */ +const BEFORE = 3; +const AFTER = 6; + +/** Jellyfin puts specials in season 0; they air outside the numbered run. */ +const SPECIALS_SEASON = 0; + /** * Does `ep` refer to the same episode as `current`? * @@ -29,33 +37,74 @@ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean { ); } +/** + * Sort key for a season: specials (season 0) come *after* every numbered + * season, matching how a viewer works through a show — S1, S2, …, then the + * extras — rather than opening on a special because 0 < 1. + */ +function seasonRank(seasonNumber: number | null | undefined): number { + return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!; +} + +/** + * Broadcast order across a whole series: season ascending, then episode. + * + * When *either* side's season is unknown there is no season axis to compare on, + * so it falls through to episode number. That makes the comparator technically + * non-transitive across such a mix, which is safe here because only the + * directly-fetched `current` episode can lack a season and it is never part of + * the array being sorted — it is only positioned against it (see + * `adjacentEpisodes`). + */ +export function compareSeriesOrder(a: MediaItem, b: MediaItem): number { + if (a.parentIndexNumber != null && b.parentIndexNumber != null) { + const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber); + if (bySeason !== 0) return bySeason; + } + return (a.indexNumber ?? 0) - (b.indexNumber ?? 0); +} + /** * The window of episodes shown under the hero: up to 3 before and 6 after the - * current episode. Degrades gracefully: - * - prefers the current season, falling back to the full list when the season - * is unknown (e.g. the episode was fetched directly on an API-ID mismatch); - * - splices the current episode into the pool at its numeric position when it - * isn't present, so it still anchors the window; + * current episode, in series order across *all* seasons. + * + * Crossing a season boundary is the point (ux-flows §5B.2): finishing a season + * finale should offer the next season's premiere, not an empty strip. Degrades + * gracefully: + * - splices the current episode into the pool at its ordered position when it + * isn't present (an API id mismatch on a directly-fetched episode), so it + * still anchors the window; * - returns just `[current]` only when there genuinely are no other episodes. */ export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] { - const seasonMatches = allEpisodes.filter( - (e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber - ); - const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes) - .slice() - .sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0)); + const pool = allEpisodes.slice().sort(compareSeriesOrder); let idx = pool.findIndex((e) => isCurrentEpisode(e, current)); if (idx === -1) { - const epNum = current.indexNumber ?? 0; - const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum); + const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0); idx = insertAt === -1 ? pool.length : insertAt; pool.splice(idx, 0, current); } - const start = Math.max(0, idx - 3); - const end = Math.min(pool.length, idx + 7); + const start = Math.max(0, idx - BEFORE); + const end = Math.min(pool.length, idx + AFTER + 1); return pool.slice(start, end); } + +/** + * Label for a strip card, relative to the episode in focus. + * + * Within the current season a bare number reads cleanly ("6."). Once the window + * crosses into another season that number is ambiguous, so the card names the + * season too ("S3E1") — otherwise the premiere after a finale just reads "1." + */ +export function stripCardLabel(ep: MediaItem, current: MediaItem): string { + const crossesSeason = + ep.parentIndexNumber != null && + current.parentIndexNumber != null && + ep.parentIndexNumber !== current.parentIndexNumber; + + if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`; + return `${ep.indexNumber ?? 0}.`; +} diff --git a/src/lib/components/library/seriesNavigation.test.ts b/src/lib/components/library/seriesNavigation.test.ts new file mode 100644 index 00000000..eb44fcd3 --- /dev/null +++ b/src/lib/components/library/seriesNavigation.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import type { MediaItem } from "$lib/api/types"; +import { + seasonAnchorId, + seasonRedirectTarget, + episodeFocusHref, + seriesPlayHref, + seriesPlayLabel, + groupEpisodesBySeason, + initialExpandedSeasons, +} from "./seriesNavigation"; + +const SERIES = "series-1"; + +function ep(id: string, season: number | null, number: number | null): MediaItem { + return { + id, + name: `S${season}E${number}`, + kind: "episode", + seriesId: SERIES, + parentIndexNumber: season, + indexNumber: number, + durationMs: 1_000_000, + } as unknown as MediaItem; +} + +function seasonHeader(number: number, id = `season-${number}`): MediaItem { + return { + id, + name: `Season ${number}`, + kind: "season", + seriesId: SERIES, + indexNumber: number, + } as unknown as MediaItem; +} + +function withProgress(episode: MediaItem, fraction: number): MediaItem { + return { + ...episode, + userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction }, + } as MediaItem; +} + +describe("seriesPlayHref", () => { + // The reported bug: Play resolved the first *season* child and navigated to + // /player/, which bounced back to the season-1 page. + it("opens the current episode's focus view, never a season or the player", () => { + const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4)); + expect(href).toBe("/library/series-1?episode=s2e4"); + expect(href).not.toContain("/player/"); + }); + + it("returns null for a series with no episodes so the button can hide", () => { + expect(seriesPlayHref(SERIES, null)).toBeNull(); + }); +}); + +describe("seriesPlayLabel", () => { + it("names the episode it will open", () => { + expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4"); + }); + + it("says Resume for a part-watched episode", () => { + expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4"); + }); + + it("says Play for a barely-started or nearly-finished episode", () => { + expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1"); + expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1"); + }); + + it("degrades to a bare verb when the numbering is unknown", () => { + expect(seriesPlayLabel(ep("x", null, null))).toBe("Play"); + expect(seriesPlayLabel(null)).toBe("Play"); + }); +}); + +describe("seasonRedirectTarget", () => { + it("sends a season to its series, anchored at that season", () => { + expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3"); + }); + + it("returns null when the series is unknown, so the caller can fall back", () => { + const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem; + expect(seasonRedirectTarget(orphan)).toBeNull(); + }); + + it("matches the anchor the season section renders", () => { + expect(seasonRedirectTarget(seasonHeader(2))).toBe( + `/library/${SERIES}#${seasonAnchorId(2)}` + ); + }); +}); + +describe("episodeFocusHref", () => { + it("opens an episode inside its series (never a bare episode page)", () => { + expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2"); + }); + + it("falls back to the bare item page when the series is unknown", () => { + const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem; + expect(episodeFocusHref(orphan)).toBe("/library/lone"); + }); +}); + +describe("groupEpisodesBySeason", () => { + it("groups episodes under their season headers, in season order", () => { + const seasons = [seasonHeader(2), seasonHeader(1)]; + const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)]; + + const grouped = groupEpisodesBySeason(seasons, episodes); + expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]); + expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]); + expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]); + }); + + it("puts specials after the numbered seasons", () => { + const grouped = groupEpisodesBySeason( + [seasonHeader(0), seasonHeader(1)], + [ep("s0e1", 0, 1), ep("s1e1", 1, 1)] + ); + expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]); + }); + + // A flat series: episodes hang off the series, no season folders exist. + it("synthesizes headers when the server returned no seasons", () => { + const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]); + expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]); + expect(grouped.every((g) => g.season.kind === "season")).toBe(true); + }); + + it("names a synthesized season 0 'Specials'", () => { + const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]); + expect(grouped[0].season.name).toBe("Specials"); + }); + + it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => { + const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]); + const ids = grouped.map((g) => g.season.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("drops seasons that have no episodes", () => { + const grouped = groupEpisodesBySeason( + [seasonHeader(1), seasonHeader(2), seasonHeader(3)], + [ep("s2e1", 2, 1)] + ); + expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]); + }); + + it("buckets season-less episodes into season 1 rather than losing them", () => { + const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]); + expect(grouped).toHaveLength(1); + expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]); + }); +}); + +describe("initialExpandedSeasons", () => { + const seasons = groupEpisodesBySeason( + [seasonHeader(1), seasonHeader(2), seasonHeader(3)], + [ + ep("s1e1", 1, 1), + ep("s2e1", 2, 1), + ep("s2e2", 2, 2), + ep("s3e1", 3, 1), + ] + ); + + it("expands only the season holding the current episode", () => { + const expanded = initialExpandedSeasons(seasons, "s2e2"); + expect([...expanded]).toEqual(["season-2"]); + }); + + it("also expands the season of a ?episode= deep link", () => { + const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1"); + expect(expanded.has("season-1")).toBe(true); + expect(expanded.has("season-3")).toBe(true); + expect(expanded.has("season-2")).toBe(false); + }); + + it("collapses nothing extra when current and focused share a season", () => { + const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2"); + expect([...expanded]).toEqual(["season-2"]); + }); + + it("falls back to the first season when there is no current episode", () => { + expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]); + }); + + it("falls back to the first season when the current episode is unknown here", () => { + expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]); + }); + + it("returns nothing for a series with no seasons", () => { + expect(initialExpandedSeasons([], "s1e1").size).toBe(0); + }); +}); diff --git a/src/lib/components/library/seriesNavigation.ts b/src/lib/components/library/seriesNavigation.ts new file mode 100644 index 00000000..415594d6 --- /dev/null +++ b/src/lib/components/library/seriesNavigation.ts @@ -0,0 +1,167 @@ +// Pure navigation/grouping logic for the series detail page. +// +// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the +// series Play button used to resolve `$libraryItems[0]` — the first *season* by +// SortName — and navigate to `/player/`, which the player route +// bounced back to `/library/`. Play on a series therefore played +// nothing and landed on the season-1 page. +// +// Note what is NOT here: *which* episode is current. That is domain policy and +// lives in Rust (`repository_get_series_current_episode`); this module only +// renders and routes around the answer. +// +// TRACES: UR-062 | DR-102, DR-103 +import type { MediaItem } from "$lib/api/types"; + +export interface SeasonData { + season: MediaItem; + episodes: MediaItem[]; +} + +/** Jellyfin files specials under season 0. */ +const SPECIALS_SEASON = 0; + +/** Sort key for a season number: specials come after every numbered season. */ +function seasonRank(seasonNumber: number | null | undefined): number { + if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1; + return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber; +} + +/** + * The in-page anchor for a season, so a season link scrolls the series' single + * continuous episode list instead of opening a page of its own. + */ +export function seasonAnchorId(seasonNumber: number | null | undefined): string { + return `season-${seasonNumber ?? 0}`; +} + +/** + * Where a link naming a season should actually go: the series, anchored at that + * season. Returns `null` when the season carries no `seriesId` (a deep link into + * a stale cache), in which case the caller must keep rendering something rather + * than strand the user. + */ +export function seasonRedirectTarget(season: MediaItem): string | null { + if (!season.seriesId) return null; + const seasonNumber = season.indexNumber ?? season.parentIndexNumber; + return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`; +} + +/** + * Where an episode link should go: the episode in the context of its series + * (ux-flows §5B.1 — an episode is never browsed as a bare Episode page). + * Falls back to the bare item page only when the series is unknown. + */ +export function episodeFocusHref(episode: MediaItem): string { + if (!episode.seriesId) return `/library/${episode.id}`; + return `/library/${episode.seriesId}?episode=${episode.id}`; +} + +/** + * Where the series hero button goes. + * + * The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a + * *container* navigation and Play on a *leaf* the commitment. Returns `null` + * when there is no current episode (an empty series), so the caller can hide + * the button rather than link nowhere. + */ +export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null { + if (!current) return null; + return `/library/${seriesId}?episode=${current.id}`; +} + +/** Fraction of an episode already watched, 0 when unknown. */ +function progressFraction(episode: MediaItem): number { + const position = episode.userData?.playbackPositionMs ?? 0; + if (!episode.durationMs || position <= 0) return 0; + return position / episode.durationMs; +} + +/** + * Label for the series hero button — it names the episode it will open, so the + * viewer knows where the button leads before pressing it. + */ +export function seriesPlayLabel(current: MediaItem | null): string { + if (!current) return "Play"; + + const fraction = progressFraction(current); + const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play"; + + if (current.parentIndexNumber == null || current.indexNumber == null) return verb; + return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`; +} + +/** + * Group a series' episodes under its season headers. + * + * The episodes arrive from Rust already in series order; this only decides which + * header each one renders beneath, and synthesizes a header for any season the + * server did not return one for (a flat series, or a season fetch that failed). + * Seasons with no episodes are dropped — an empty accordion row is noise. + */ +export function groupEpisodesBySeason( + seasons: MediaItem[], + episodes: MediaItem[] +): SeasonData[] { + const headerFor = new Map(); + for (const season of seasons) { + const number = season.indexNumber ?? season.parentIndexNumber; + if (number != null && !headerFor.has(number)) headerFor.set(number, season); + } + + const grouped = new Map(); + for (const episode of episodes) { + const number = episode.parentIndexNumber ?? 1; + const bucket = grouped.get(number); + if (bucket) bucket.push(episode); + else grouped.set(number, [episode]); + } + + return [...grouped.entries()] + .sort(([a], [b]) => seasonRank(a) - seasonRank(b)) + .map(([number, seasonEpisodes]) => ({ + season: + headerFor.get(number) ?? + ({ + ...seasonEpisodes[0], + id: `synthetic-season-${number}`, + kind: "season", + indexNumber: number, + name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`, + overview: null, + } as MediaItem), + episodes: seasonEpisodes, + })); +} + +/** + * Which seasons start expanded. + * + * Only the one the viewer is in. A ten-season show otherwise renders every + * episode of every season at once, burying the one episode they came for. A + * `?episode=` deep link expands that episode's season as well, and a show with + * no resolved current episode falls back to its first season so the page is + * never entirely collapsed. + * + * Returns season ids (not numbers) so the caller can key state per section, + * including the synthesized headers. + */ +export function initialExpandedSeasons( + seasons: SeasonData[], + currentEpisodeId: string | null | undefined, + focusedEpisodeId?: string | null +): Set { + if (seasons.length === 0) return new Set(); + + const expanded = new Set(); + for (const id of [currentEpisodeId, focusedEpisodeId]) { + if (!id) continue; + const owner = seasons.find((s) => s.episodes.some((e) => e.id === id)); + if (owner) expanded.add(owner.season.id); + } + + // Nothing matched — open the first season rather than nothing at all. + if (expanded.size === 0) expanded.add(seasons[0].season.id); + + return expanded; +} diff --git a/src/lib/utils/libraryView.test.ts b/src/lib/utils/libraryView.test.ts new file mode 100644 index 00000000..25f21f2a --- /dev/null +++ b/src/lib/utils/libraryView.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { + resolveLibraryView, + libraryViewUrl, + LIBRARY_VIEWS, + DEFAULT_LIBRARY_VIEW, +} from "./libraryView"; + +describe("resolveLibraryView", () => { + it("resolves each known view", () => { + for (const view of LIBRARY_VIEWS) { + expect(resolveLibraryView(view)).toBe(view); + } + }); + + it("defaults to browse when the param is absent", () => { + expect(resolveLibraryView(null)).toBe("browse"); + expect(resolveLibraryView(undefined)).toBe("browse"); + }); + + it("falls back to the default rather than rendering nothing for junk", () => { + expect(resolveLibraryView("shows")).toBe(DEFAULT_LIBRARY_VIEW); + expect(resolveLibraryView("")).toBe(DEFAULT_LIBRARY_VIEW); + }); + + it("tolerates case and surrounding whitespace", () => { + expect(resolveLibraryView("Genres")).toBe("genres"); + expect(resolveLibraryView(" all ")).toBe("all"); + }); +}); + +describe("libraryViewUrl", () => { + it("omits the param for the default view so the landing URL stays clean", () => { + expect(libraryViewUrl("/library/tv", "browse")).toBe("/library/tv"); + }); + + it("names the non-default views", () => { + expect(libraryViewUrl("/library/tv", "all")).toBe("/library/tv?view=all"); + expect(libraryViewUrl("/library/movies", "genres")).toBe("/library/movies?view=genres"); + }); + + it("round-trips through resolveLibraryView", () => { + for (const view of LIBRARY_VIEWS) { + const url = libraryViewUrl("/library/tv", view); + const param = new URL(url, "http://x").searchParams.get("view"); + expect(resolveLibraryView(param)).toBe(view); + } + }); +}); diff --git a/src/lib/utils/libraryView.ts b/src/lib/utils/libraryView.ts new file mode 100644 index 00000000..df1c24af --- /dev/null +++ b/src/lib/utils/libraryView.ts @@ -0,0 +1,36 @@ +// Which section of a video library page is showing. +// +// Browse / All / Genres used to be three routes per library, named +// inconsistently across the two libraries (`/library/tv/shows` vs +// `/library/movies/all`; `/library/shows/genres` vs `/library/movies/genres`). +// They are now one route with tabs, and this is the pure `?view=` ↔ tab +// mapping. +// +// TRACES: UR-063 | DR-105 +export type LibraryView = "browse" | "all" | "genres"; + +/** Tab order, left to right. `browse` leads because it is the landing view. */ +export const LIBRARY_VIEWS: readonly LibraryView[] = ["browse", "all", "genres"]; + +/** The view a page shows when `?view=` is absent or unrecognised. */ +export const DEFAULT_LIBRARY_VIEW: LibraryView = "browse"; + +/** + * Read a `?view=` value. Anything unknown — a typo, a stale bookmark, a + * removed tab — lands on the default rather than rendering nothing. + */ +export function resolveLibraryView(value: string | null | undefined): LibraryView { + if (value == null) return DEFAULT_LIBRARY_VIEW; + const normalized = value.trim().toLowerCase(); + return (LIBRARY_VIEWS as readonly string[]).includes(normalized) + ? (normalized as LibraryView) + : DEFAULT_LIBRARY_VIEW; +} + +/** + * URL for a tab. The default view omits the param, so the landing URL stays + * `/library/tv` — the same convention `searchRouteUrl` uses for the `all` scope. + */ +export function libraryViewUrl(basePath: string, view: LibraryView): string { + return view === DEFAULT_LIBRARY_VIEW ? basePath : `${basePath}?view=${view}`; +} diff --git a/src/lib/utils/searchScope.ts b/src/lib/utils/searchScope.ts index b1c08270..58fc07b3 100644 --- a/src/lib/utils/searchScope.ts +++ b/src/lib/utils/searchScope.ts @@ -42,7 +42,9 @@ export function resolveSearchScope(pathname: string): SearchScope { if (path === "/library/music" || path.startsWith("/library/music/")) return "music"; if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies"; if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv"; - // `/library/shows/genres` is the TV genre route despite the differing segment. + // `/library/shows/*` is a legacy TV route that now redirects into + // `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL + // before the redirect lands still scopes to TV. if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv"; return "all"; diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 874ef2ce..898bf4ba 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -1,4 +1,4 @@ - + -{#if isLoading} -
-
+
+ +
+

{$currentLibrary?.name ?? "Movies"}

+
-{:else} -
- -
-

{$currentLibrary?.name ?? "Movies"}

- + + + + {#if view === "all"} +
+
- - - {#if heroItems.length > 0} - - {/if} - - - {#if continueWatching.length > 0} - - {/if} - - - {#if recentlyAdded.length > 0} - goto("/library/movies/all")} - /> - {/if} - - - {#each genreRows as row (row.id)} - goto(`/library/movies/genres`)} - /> - {/each} - - {#if !hasContent} -

Nothing here yet. Add some movies to your library to fill this page.

- {/if} - - -
-

Browse

-
- {#each categories as category (category.id)} - - {/each} -
+ {:else if view === "genres"} +
+
-
-{/if} + {:else if isLoading} +
+
+
+ {:else} +
+ + {#if heroItems.length > 0} + + {/if} + + + {#if continueWatching.length > 0} + + {/if} + + + {#if recentlyAdded.length > 0} + goto(libraryViewUrl(BASE_PATH, "all"))} + /> + {/if} + + + {#each genreRows as row (row.id)} + goto(libraryViewUrl(BASE_PATH, "genres"))} + /> + {/each} + + {#if !hasContent} +

Nothing here yet. Add some movies to your library to fill this page.

+ {/if} +
+ {/if} +
diff --git a/src/routes/library/movies/all/+page.svelte b/src/routes/library/movies/all/+page.svelte deleted file mode 100644 index 7bcf1995..00000000 --- a/src/routes/library/movies/all/+page.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/routes/library/movies/all/+page.ts b/src/routes/library/movies/all/+page.ts new file mode 100644 index 00000000..3a00f2a0 --- /dev/null +++ b/src/routes/library/movies/all/+page.ts @@ -0,0 +1,11 @@ +// Legacy route — now the Movies library's All Movies tab. +// +// Kept as a redirect rather than deleted: GenreTags builds links to these +// paths and users have them in history. +// +// TRACES: UR-063 | DR-105 +import { redirect } from "@sveltejs/kit"; + +export const load = () => { + redirect(307, "/library/movies?view=all"); +}; diff --git a/src/routes/library/movies/genres/+page.svelte b/src/routes/library/movies/genres/+page.svelte deleted file mode 100644 index c43a40a7..00000000 --- a/src/routes/library/movies/genres/+page.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/routes/library/movies/genres/+page.ts b/src/routes/library/movies/genres/+page.ts new file mode 100644 index 00000000..0b863593 --- /dev/null +++ b/src/routes/library/movies/genres/+page.ts @@ -0,0 +1,11 @@ +// Legacy route — now the Movies library's Genres tab. +// +// Kept as a redirect rather than deleted: GenreTags builds links to these +// paths and users have them in history. +// +// TRACES: UR-063 | DR-105 +import { redirect } from "@sveltejs/kit"; + +export const load = () => { + redirect(307, "/library/movies?view=genres"); +}; diff --git a/src/routes/library/shows/genres/+page.svelte b/src/routes/library/shows/genres/+page.svelte deleted file mode 100644 index 92d29104..00000000 --- a/src/routes/library/shows/genres/+page.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/src/routes/library/shows/genres/+page.ts b/src/routes/library/shows/genres/+page.ts new file mode 100644 index 00000000..51206d6d --- /dev/null +++ b/src/routes/library/shows/genres/+page.ts @@ -0,0 +1,11 @@ +// Legacy route — now the TV library's Genres tab. +// +// Kept as a redirect rather than deleted: GenreTags builds links to these +// paths and users have them in history. +// +// TRACES: UR-063 | DR-105 +import { redirect } from "@sveltejs/kit"; + +export const load = () => { + redirect(307, "/library/tv?view=genres"); +}; diff --git a/src/routes/library/tv/+page.svelte b/src/routes/library/tv/+page.svelte index 3d645a1c..5d85686d 100644 --- a/src/routes/library/tv/+page.svelte +++ b/src/routes/library/tv/+page.svelte @@ -1,6 +1,15 @@ - + -{#if isLoading} -
-
+
+ +
+

{$currentLibrary?.name ?? "TV Shows"}

+
-{:else} -
- -
-

{$currentLibrary?.name ?? "TV Shows"}

- + + + + {#if view === "all"} +
+
- - - {#if heroItems.length > 0} - - {/if} - - - {#if continueWatching.length > 0} - - {/if} - - - {#if nextUp.length > 0} - - {/if} - - - {#if recentlyAdded.length > 0} - goto("/library/tv/shows")} - /> - {/if} - - - {#each genreRows as row (row.id)} - goto(`/library/shows/genres`)} - /> - {/each} - - {#if !hasContent} -

Nothing here yet. Start watching something to fill this page.

- {/if} - - -
-

Browse

-
- {#each categories as category (category.id)} - - {/each} -
+ {:else if view === "genres"} +
+
-
-{/if} + {:else if isLoading} +
+
+
+ {:else} +
+ + {#if heroItems.length > 0} + + {/if} + + + {#if continueWatching.length > 0} + + {/if} + + + {#if nextUp.length > 0} + + {/if} + + + {#if recentlyAdded.length > 0} + goto(libraryViewUrl(BASE_PATH, "all"))} + /> + {/if} + + + {#each genreRows as row (row.id)} + goto(libraryViewUrl(BASE_PATH, "genres"))} + /> + {/each} + + {#if !hasContent} +

Nothing here yet. Start watching something to fill this page.

+ {/if} +
+ {/if} +
diff --git a/src/routes/library/tv/shows/+page.svelte b/src/routes/library/tv/shows/+page.svelte deleted file mode 100644 index e81a7b06..00000000 --- a/src/routes/library/tv/shows/+page.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/routes/library/tv/shows/+page.ts b/src/routes/library/tv/shows/+page.ts new file mode 100644 index 00000000..998c445b --- /dev/null +++ b/src/routes/library/tv/shows/+page.ts @@ -0,0 +1,11 @@ +// Legacy route — now the TV library's All Shows tab. +// +// Kept as a redirect rather than deleted: GenreTags builds links to these +// paths and users have them in history. +// +// TRACES: UR-063 | DR-105 +import { redirect } from "@sveltejs/kit"; + +export const load = () => { + redirect(307, "/library/tv?view=all"); +};