Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5759a97289 | ||
|
|
b9f026e215 | ||
|
|
b7a7037194 | ||
|
|
124da29fc7 | ||
|
|
5927299c0f | ||
|
|
7650efcb7f |
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
|
|||||||
/docs/README.md
|
/docs/README.md
|
||||||
/docs/api-redirect.md
|
/docs/api-redirect.md
|
||||||
/docs-site/book/
|
/docs-site/book/
|
||||||
|
|
||||||
|
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
|
||||||
|
/.cargo-arch/
|
||||||
|
/packaging/arch/pkg/
|
||||||
|
/packaging/arch/src/
|
||||||
|
/packaging/arch/*.pkg.tar.zst
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to JellyTau are documented here.
|
||||||
|
|
||||||
|
Entries are grouped by the capability they change, not by commit. Requirement
|
||||||
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
|
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||||
|
|
||||||
|
## v0.1.2
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
- **Search results are ordered by how well they match.** A name that *starts*
|
||||||
|
with the query now 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, so a series lands above its own episodes.
|
||||||
|
Ranking is applied to the instant cached results and to the merged
|
||||||
|
cache+server list alike, so the list no longer reshuffles when server results
|
||||||
|
arrive. (UR-060, DR-090)
|
||||||
|
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
|
||||||
|
group splits into Shows and Episodes so a show never competes with its own
|
||||||
|
episodes for a slot, and a new People group means searching an actor's name
|
||||||
|
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
|
||||||
|
Albums → Artists → People; a group order saved before the split keeps the
|
||||||
|
position it was dragged to. (UR-060, DR-091)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- **The library header search bar works on every library page.** It previously
|
||||||
|
searched in place and depended on `/library` rendering results inline, so on
|
||||||
|
any other `/library/**` route the results were fetched and never shown.
|
||||||
|
`/search` is now the single surface that renders results, and the header bar
|
||||||
|
hands its query and scope over via the URL. (UR-049, DR-063)
|
||||||
|
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
|
||||||
|
oversized media, so a 480p source on a 1080p display played as a small picture
|
||||||
|
in the middle of a black frame. The picture now fits whichever axis constrains
|
||||||
|
it, in both directions, preserving aspect ratio. (UR-005)
|
||||||
|
|
||||||
|
### 📋 Requirements
|
||||||
|
|
||||||
|
**Linux:** 64-bit, GLIBC 2.29+
|
||||||
|
**Android:** 8.0+
|
||||||
|
|
||||||
|
## v0.1.1 and earlier
|
||||||
|
|
||||||
|
Released before this file existed — see the git history and the release notes on
|
||||||
|
each tag.
|
||||||
@@ -70,6 +70,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||||
| 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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
|
||||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||||
@@ -242,6 +243,8 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||||
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||||
|
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||||||
|
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -309,6 +312,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-056 | - | DR-085 |
|
| UR-056 | - | DR-085 |
|
||||||
| UR-057 | - | DR-086 |
|
| UR-057 | - | DR-086 |
|
||||||
| UR-058 | - | DR-087 |
|
| UR-058 | - | DR-087 |
|
||||||
|
| UR-060 | - | DR-090, DR-091 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+168
-102
@@ -1,22 +1,22 @@
|
|||||||
# Code Traceability Matrix
|
# Code Traceability Matrix
|
||||||
|
|
||||||
**Generated:** 7/25/2026, 9:21:22 AM
|
**Generated:** 7/25/2026, 3:13:47 PM
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **Total Files Scanned:** 293
|
- **Total Files Scanned:** 296
|
||||||
- **Total TRACES Found:** 301
|
- **Total TRACES Found:** 310
|
||||||
- **Requirements Covered:**
|
- **Requirements Covered:**
|
||||||
- User Requirements (UR): 56
|
- User Requirements (UR): 57
|
||||||
- Integration Requirements (IR): 15
|
- Integration Requirements (IR): 15
|
||||||
- Development Requirements (DR): 81
|
- Development Requirements (DR): 83
|
||||||
- Jellyfin API Requirements (JA): 24
|
- Jellyfin API Requirements (JA): 24
|
||||||
|
|
||||||
## Requirements by Type
|
## Requirements by Type
|
||||||
|
|
||||||
### User Requirements (UR)
|
### 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-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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration Requirements (IR)
|
### Integration Requirements (IR)
|
||||||
@@ -26,7 +26,7 @@ IR-003, IR-004, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-020,
|
|||||||
|
|
||||||
### Development Requirements (DR)
|
### 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-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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Jellyfin API Requirements (JA)
|
### Jellyfin API Requirements (JA)
|
||||||
@@ -1304,8 +1304,17 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### DR-063
|
### DR-063
|
||||||
|
|
||||||
**Locations:** 3 file(s)
|
**Locations:** 8 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/routes/library/+layout.svelte`](src/routes/library/+layout.svelte#L60)
|
||||||
|
- **Line:** 60
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/routes/library/+page.svelte`](src/routes/library/+page.svelte#L17)
|
||||||
|
- **Line:** 17
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L14)
|
||||||
|
- **Line:** 14
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9)
|
||||||
- **Line:** 9
|
- **Line:** 9
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -1315,16 +1324,22 @@ 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#L50)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L50)
|
||||||
- **Line:** 50
|
- **Line:** 50
|
||||||
- **Context:** `export function scopeItemTypes(scope: SearchScope): string[] | undefin...`
|
- **Context:** `export function scopeItemTypes(scope: SearchScope): string[] | undefin...`
|
||||||
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L73)
|
||||||
|
- **Line:** 73
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L93)
|
||||||
|
- **Line:** 93
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### DR-064
|
### DR-064
|
||||||
|
|
||||||
**Locations:** 3 file(s)
|
**Locations:** 3 file(s)
|
||||||
|
|
||||||
- **File:** [`src/routes/library/+layout.svelte`](src/routes/library/+layout.svelte#L41)
|
- **File:** [`src/routes/library/+layout.svelte`](src/routes/library/+layout.svelte#L45)
|
||||||
- **Line:** 41
|
- **Line:** 45
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L15)
|
- **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L22)
|
||||||
- **Line:** 15
|
- **Line:** 22
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/components/search/SearchScopeChips.svelte`](src/lib/components/search/SearchScopeChips.svelte#L7)
|
- **File:** [`src/lib/components/search/SearchScopeChips.svelte`](src/lib/components/search/SearchScopeChips.svelte#L7)
|
||||||
- **Line:** 7
|
- **Line:** 7
|
||||||
@@ -1357,8 +1372,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)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9)
|
||||||
- **Line:** 9
|
- **Line:** 9
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L117)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L202)
|
||||||
- **Line:** 117
|
- **Line:** 202
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### DR-067
|
### DR-067
|
||||||
@@ -1371,8 +1386,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)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9)
|
||||||
- **Line:** 9
|
- **Line:** 9
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L161)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L252)
|
||||||
- **Line:** 161
|
- **Line:** 252
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### DR-068
|
### DR-068
|
||||||
@@ -1566,11 +1581,11 @@ 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)
|
- **File:** [`src/lib/services/downloadedCatalog.ts`](src/lib/services/downloadedCatalog.ts#L12)
|
||||||
- **Line:** 12
|
- **Line:** 12
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L203)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L204)
|
||||||
- **Line:** 203
|
- **Line:** 204
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L218)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L219)
|
||||||
- **Line:** 218
|
- **Line:** 219
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538)
|
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538)
|
||||||
- **Line:** 538
|
- **Line:** 538
|
||||||
@@ -1640,8 +1655,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)
|
- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L1925)
|
||||||
- **Line:** 1925
|
- **Line:** 1925
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L218)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L219)
|
||||||
- **Line:** 218
|
- **Line:** 219
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538)
|
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538)
|
||||||
- **Line:** 538
|
- **Line:** 538
|
||||||
@@ -1713,8 +1728,8 @@ 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)
|
- **File:** [`src/lib/services/downloadedCatalog.ts`](src/lib/services/downloadedCatalog.ts#L12)
|
||||||
- **Line:** 12
|
- **Line:** 12
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L235)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L236)
|
||||||
- **Line:** 235
|
- **Line:** 236
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259)
|
- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259)
|
||||||
- **Line:** 259
|
- **Line:** 259
|
||||||
@@ -1782,6 +1797,25 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### DR-090
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/domain/search_rank.rs`](src-tauri/src/domain/search_rank.rs#L106)
|
||||||
|
- **Line:** 106
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### DR-091
|
||||||
|
|
||||||
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L117)
|
||||||
|
- **Line:** 117
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L183)
|
||||||
|
- **Line:** 183
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### JA-001
|
### JA-001
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -1813,12 +1847,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
**Locations:** 3 file(s)
|
**Locations:** 3 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L4)
|
|
||||||
- **Line:** 4
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L3)
|
- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L4)
|
||||||
|
- **Line:** 4
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/jellyfin/client.rs`](src-tauri/src/jellyfin/client.rs#L1)
|
- **File:** [`src-tauri/src/jellyfin/client.rs`](src-tauri/src/jellyfin/client.rs#L1)
|
||||||
- **Line:** 1
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -2057,8 +2091,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#L1319)
|
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L1319)
|
||||||
- **Line:** 1319
|
- **Line:** 1319
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L494)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L505)
|
||||||
- **Line:** 494
|
- **Line:** 505
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L127)
|
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L127)
|
||||||
- **Line:** 127
|
- **Line:** 127
|
||||||
@@ -2246,7 +2280,7 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### UR-005
|
### UR-005
|
||||||
|
|
||||||
**Locations:** 58 file(s)
|
**Locations:** 59 file(s)
|
||||||
|
|
||||||
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2251)
|
- **File:** [`src/lib/api/bindings.ts`](src/lib/api/bindings.ts#L2251)
|
||||||
- **Line:** 2251
|
- **Line:** 2251
|
||||||
@@ -2266,6 +2300,9 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **File:** [`src/lib/components/player/MiniPlayer.svelte`](src/lib/components/player/MiniPlayer.svelte#L1)
|
- **File:** [`src/lib/components/player/MiniPlayer.svelte`](src/lib/components/player/MiniPlayer.svelte#L1)
|
||||||
- **Line:** 1
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/components/player/videoFit.ts`](src/lib/components/player/videoFit.ts#L7)
|
||||||
|
- **Line:** 7
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
|
- **File:** [`src/lib/components/player/VideoPlayer.svelte`](src/lib/components/player/VideoPlayer.svelte#L1)
|
||||||
- **Line:** 1
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -2463,12 +2500,12 @@ 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/storage/thumbnails.rs`](src-tauri/src/commands/storage/thumbnails.rs#L3)
|
- **File:** [`src-tauri/src/commands/storage/thumbnails.rs`](src-tauri/src/commands/storage/thumbnails.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L4)
|
|
||||||
- **Line:** 4
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L3)
|
- **File:** [`src-tauri/src/commands/catalog.rs`](src-tauri/src/commands/catalog.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L4)
|
||||||
|
- **Line:** 4
|
||||||
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1)
|
- **File:** [`src-tauri/src/repository/online.rs`](src-tauri/src/repository/online.rs#L1)
|
||||||
- **Line:** 1
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -3250,8 +3287,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/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L675)
|
- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L675)
|
||||||
- **Line:** 675
|
- **Line:** 675
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L494)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L505)
|
||||||
- **Line:** 494
|
- **Line:** 505
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L127)
|
- **File:** [`src-tauri/src/repository/mod.rs`](src-tauri/src/repository/mod.rs#L127)
|
||||||
- **Line:** 127
|
- **Line:** 127
|
||||||
@@ -3304,13 +3341,22 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
### UR-049
|
### UR-049
|
||||||
|
|
||||||
**Locations:** 8 file(s)
|
**Locations:** 13 file(s)
|
||||||
|
|
||||||
- **File:** [`src/routes/library/+layout.svelte`](src/routes/library/+layout.svelte#L41)
|
- **File:** [`src/routes/library/+layout.svelte`](src/routes/library/+layout.svelte#L45)
|
||||||
- **Line:** 41
|
- **Line:** 45
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L15)
|
- **File:** [`src/routes/library/+layout.svelte`](src/routes/library/+layout.svelte#L60)
|
||||||
- **Line:** 15
|
- **Line:** 60
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/routes/library/+page.svelte`](src/routes/library/+page.svelte#L17)
|
||||||
|
- **Line:** 17
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L14)
|
||||||
|
- **Line:** 14
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/routes/search/+page.svelte`](src/routes/search/+page.svelte#L22)
|
||||||
|
- **Line:** 22
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/components/search/SearchScopeChips.svelte`](src/lib/components/search/SearchScopeChips.svelte#L7)
|
- **File:** [`src/lib/components/search/SearchScopeChips.svelte`](src/lib/components/search/SearchScopeChips.svelte#L7)
|
||||||
- **Line:** 7
|
- **Line:** 7
|
||||||
@@ -3330,6 +3376,12 @@ 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#L50)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L50)
|
||||||
- **Line:** 50
|
- **Line:** 50
|
||||||
- **Context:** `export function scopeItemTypes(scope: SearchScope): string[] | undefin...`
|
- **Context:** `export function scopeItemTypes(scope: SearchScope): string[] | undefin...`
|
||||||
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L73)
|
||||||
|
- **Line:** 73
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L93)
|
||||||
|
- **Line:** 93
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-050
|
### UR-050
|
||||||
|
|
||||||
@@ -3350,11 +3402,11 @@ 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)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L9)
|
||||||
- **Line:** 9
|
- **Line:** 9
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L117)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L202)
|
||||||
- **Line:** 117
|
- **Line:** 202
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L161)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L252)
|
||||||
- **Line:** 161
|
- **Line:** 252
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-051
|
### UR-051
|
||||||
@@ -3516,11 +3568,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)
|
- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L1925)
|
||||||
- **Line:** 1925
|
- **Line:** 1925
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L203)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L204)
|
||||||
- **Line:** 203
|
- **Line:** 204
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L218)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L219)
|
||||||
- **Line:** 218
|
- **Line:** 219
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538)
|
- **File:** [`src-tauri/src/repository/offline.rs`](src-tauri/src/repository/offline.rs#L538)
|
||||||
- **Line:** 538
|
- **Line:** 538
|
||||||
@@ -3599,8 +3651,8 @@ 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)
|
- **File:** [`src/lib/services/downloadedCatalog.ts`](src/lib/services/downloadedCatalog.ts#L95)
|
||||||
- **Line:** 95
|
- **Line:** 95
|
||||||
- **Context:** `function sizeOf(itemId: string): number {`
|
- **Context:** `function sizeOf(itemId: string): number {`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L235)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L236)
|
||||||
- **Line:** 235
|
- **Line:** 236
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259)
|
- **File:** [`src-tauri/src/repository/types.rs`](src-tauri/src/repository/types.rs#L259)
|
||||||
- **Line:** 259
|
- **Line:** 259
|
||||||
@@ -3768,14 +3820,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
|
||||||
- **Line:** 245
|
|
||||||
- **Context:** `Unknown`
|
|
||||||
|
|
||||||
### UT-032
|
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
||||||
- **Line:** 245
|
- **Line:** 245
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -3835,8 +3879,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/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L675)
|
- **File:** [`src-tauri/src/commands/player/mod.rs`](src-tauri/src/commands/player/mod.rs#L675)
|
||||||
- **Line:** 675
|
- **Line:** 675
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L494)
|
- **File:** [`src-tauri/src/commands/repository.rs`](src-tauri/src/commands/repository.rs#L505)
|
||||||
- **Line:** 494
|
- **Line:** 505
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-059
|
### UR-059
|
||||||
@@ -3897,41 +3941,18 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 251
|
- **Line:** 251
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-032
|
### UR-060
|
||||||
|
|
||||||
**Locations:** 2 file(s)
|
**Locations:** 3 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L117)
|
||||||
- **Line:** 3
|
- **Line:** 117
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1)
|
- **File:** [`src/lib/utils/searchScope.ts`](src/lib/utils/searchScope.ts#L183)
|
||||||
- **Line:** 1
|
- **Line:** 183
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/domain/search_rank.rs`](src-tauri/src/domain/search_rank.rs#L106)
|
||||||
### UR-033
|
- **Line:** 106
|
||||||
|
|
||||||
**Locations:** 7 file(s)
|
|
||||||
|
|
||||||
- **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`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### IT-016
|
### IT-016
|
||||||
@@ -4071,6 +4092,48 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 157
|
- **Line:** 157
|
||||||
- **Context:** `pub fn file_size(&self) -> Option<u64> {`
|
- **Context:** `pub fn file_size(&self) -> Option<u64> {`
|
||||||
|
|
||||||
|
### UT-032
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/player/backend.rs`](src-tauri/src/player/backend.rs#L245)
|
||||||
|
- **Line:** 245
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UR-033
|
||||||
|
|
||||||
|
**Locations:** 7 file(s)
|
||||||
|
|
||||||
|
- **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`
|
||||||
|
|
||||||
|
### 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-051
|
### UT-051
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 1 file(s)
|
||||||
@@ -4248,13 +4311,16 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 1
|
- **Line:** 1
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-042
|
### UR-032
|
||||||
|
|
||||||
**Locations:** 1 file(s)
|
**Locations:** 2 file(s)
|
||||||
|
|
||||||
- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3)
|
- **File:** [`src-tauri/src/commands/player/settings.rs`](src-tauri/src/commands/player/settings.rs#L3)
|
||||||
- **Line:** 3
|
- **Line:** 3
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
- **File:** [`src-tauri/src/settings.rs`](src-tauri/src/settings.rs#L1)
|
||||||
|
- **Line:** 1
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
### UR-044
|
### UR-044
|
||||||
|
|
||||||
@@ -4284,14 +4350,6 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
|
|
||||||
**Locations:** 1 file(s)
|
**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)
|
- **File:** [`src-tauri/src/commands/download/mod.rs`](src-tauri/src/commands/download/mod.rs#L2027)
|
||||||
- **Line:** 2027
|
- **Line:** 2027
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
@@ -4312,4 +4370,12 @@ JA-001, JA-002, JA-003, JA-004, JA-005, JA-007, JA-010, JA-011, JA-012, JA-016,
|
|||||||
- **Line:** 136
|
- **Line:** 136
|
||||||
- **Context:** `Unknown`
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
### UR-042
|
||||||
|
|
||||||
|
**Locations:** 1 file(s)
|
||||||
|
|
||||||
|
- **File:** [`src-tauri/src/commands/auth.rs`](src-tauri/src/commands/auth.rs#L3)
|
||||||
|
- **Line:** 3
|
||||||
|
- **Context:** `Unknown`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::domain::rank_search_results;
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||||
@@ -409,7 +410,7 @@ pub async fn repository_search(
|
|||||||
|
|
||||||
// Phase 1: instant local results from the cache (downloaded content) so the
|
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||||
// UI can render immediately while the server is still being queried.
|
// UI can render immediately while the server is still being queried.
|
||||||
let cache_result = repo
|
let mut cache_result = repo
|
||||||
.search_cache_only(&query, options.clone())
|
.search_cache_only(&query, options.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
@@ -420,6 +421,12 @@ pub async fn repository_search(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Neither backend orders by *where* the query matched, so a mid-word hit
|
||||||
|
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
|
||||||
|
// Both phases are ranked with the same rules so the list does not reshuffle
|
||||||
|
// when the server results land.
|
||||||
|
rank_search_results(&mut cache_result.items, &query);
|
||||||
|
|
||||||
// Phase 2: query the live server in the background, merge with the cache,
|
// Phase 2: query the live server in the background, merge with the cache,
|
||||||
// and push the union to the frontend via a `search-event`. Tagged with
|
// and push the union to the frontend via a `search-event`. Tagged with
|
||||||
// `request_id` so the frontend can discard results from superseded queries.
|
// `request_id` so the frontend can discard results from superseded queries.
|
||||||
@@ -428,7 +435,11 @@ pub async fn repository_search(
|
|||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match repo_bg.search_server_only(&query, options).await {
|
match repo_bg.search_server_only(&query, options).await {
|
||||||
Ok(server_result) => {
|
Ok(server_result) => {
|
||||||
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
|
let mut merged =
|
||||||
|
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||||
|
// Rank the union, not each half: a server-only prefix match must
|
||||||
|
// be able to outrank a cached mid-word one.
|
||||||
|
rank_search_results(&mut merged.items, &query);
|
||||||
let event = SearchUpdateEvent {
|
let event = SearchUpdateEvent {
|
||||||
request_id,
|
request_id,
|
||||||
result: merged,
|
result: merged,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
pub mod from_jellyfin;
|
pub mod from_jellyfin;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod search_rank;
|
||||||
|
|
||||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||||
pub use media::{MediaKind, StreamKind};
|
pub use media::{MediaKind, StreamKind};
|
||||||
|
pub use search_rank::rank_search_results;
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! Relevance ranking for search results.
|
||||||
|
//!
|
||||||
|
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
|
||||||
|
//! in an order that ignores *where* in the name the query matched: a server
|
||||||
|
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
|
||||||
|
//! the query "parks". Neither backend is going to change, so the app imposes its
|
||||||
|
//! own ordering on the union.
|
||||||
|
//!
|
||||||
|
//! Ranking is domain logic, not presentation: it encodes what a "better match"
|
||||||
|
//! means and which media kinds outrank which. The frontend only renders the
|
||||||
|
//! order it is given.
|
||||||
|
//!
|
||||||
|
//! Two rules, in priority order:
|
||||||
|
//!
|
||||||
|
//! 1. **Match position** — a prefix match beats a word-start match, which beats
|
||||||
|
//! a mid-word substring match. This is what makes "parks" find
|
||||||
|
//! "Parks and Recreation" before "Sparks of Love".
|
||||||
|
//! 2. **Kind** — containers before their contents at equal match quality, so a
|
||||||
|
//! series outranks its own episodes.
|
||||||
|
//!
|
||||||
|
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
|
||||||
|
//! `rank`) still breaks ties it was never overruled on.
|
||||||
|
|
||||||
|
use crate::domain::MediaKind;
|
||||||
|
use crate::repository::types::MediaItem;
|
||||||
|
|
||||||
|
/// How well a query matched an item's name — better matches sort first.
|
||||||
|
///
|
||||||
|
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
|
||||||
|
/// comparison for free, so adding a tier in the right position is all it takes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum MatchQuality {
|
||||||
|
/// The name starts with the query — "parks" in "Parks and Recreation".
|
||||||
|
Prefix,
|
||||||
|
/// Some later *word* starts with the query — "recreation" in "Parks and
|
||||||
|
/// Recreation". Still a deliberate hit: users type whole words.
|
||||||
|
WordStart,
|
||||||
|
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
|
||||||
|
/// that still counts as a match.
|
||||||
|
Substring,
|
||||||
|
/// No match on the name at all. The backend returned it for some other
|
||||||
|
/// reason (overview, artist, album), so it is kept but sorted last.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank of a media kind when match quality ties — lower sorts first.
|
||||||
|
///
|
||||||
|
/// Containers outrank the items they contain: searching a show's name should
|
||||||
|
/// surface the show, not an arbitrary episode of it. Within a tier the order is
|
||||||
|
/// arbitrary but stable, and equal ranks fall through to input order.
|
||||||
|
fn kind_rank(kind: MediaKind) -> u8 {
|
||||||
|
match kind {
|
||||||
|
// Top-level containers a user is most likely to be looking for.
|
||||||
|
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
|
||||||
|
// Sub-containers and standalone collections.
|
||||||
|
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
|
||||||
|
// Leaves — an episode/track is a match *inside* something bigger.
|
||||||
|
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
// Peripheral matches.
|
||||||
|
MediaKind::Person | MediaKind::Other => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify how `query` matches `name`, case-insensitively.
|
||||||
|
///
|
||||||
|
/// Both sides are trimmed and lowercased; an empty query matches everything
|
||||||
|
/// equally (`Prefix`), which leaves the input order untouched.
|
||||||
|
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
|
||||||
|
let query = query.trim().to_lowercase();
|
||||||
|
if query.is_empty() {
|
||||||
|
return MatchQuality::Prefix;
|
||||||
|
}
|
||||||
|
let name = name.trim().to_lowercase();
|
||||||
|
|
||||||
|
let Some(index) = name.find(&query) else {
|
||||||
|
return MatchQuality::None;
|
||||||
|
};
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
return MatchQuality::Prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word start is any match preceded by a non-alphanumeric character, so
|
||||||
|
// "the-office" and "The Office" behave the same. Indexing back one char is
|
||||||
|
// safe on the byte index `find` returned only via `char_indices`, since a
|
||||||
|
// multi-byte char would panic on a raw slice.
|
||||||
|
let preceded_by_boundary = name[..index]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_some_and(|c| !c.is_alphanumeric());
|
||||||
|
|
||||||
|
if preceded_by_boundary {
|
||||||
|
MatchQuality::WordStart
|
||||||
|
} else {
|
||||||
|
MatchQuality::Substring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort search results by relevance to `query`, in place.
|
||||||
|
///
|
||||||
|
/// Stable, so items the rules rank equally keep the order the backend supplied
|
||||||
|
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-060 | DR-090
|
||||||
|
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
|
||||||
|
// An empty query carries no relevance signal, so there is nothing to rank
|
||||||
|
// by — reordering on kind alone would shuffle the backend's own ordering
|
||||||
|
// for no reason.
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn item(name: &str, kind: MediaKind) -> MediaItem {
|
||||||
|
let mut item = MediaItem::default();
|
||||||
|
item.id = format!("id-{}-{:?}", name, kind);
|
||||||
|
item.name = name.to_string();
|
||||||
|
item.kind = kind;
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(items: &[MediaItem]) -> Vec<&str> {
|
||||||
|
items.iter().map(|i| i.name.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UT-085: a prefix match outranks a mid-word substring match.
|
||||||
|
#[test]
|
||||||
|
fn prefix_match_beats_midword_substring() {
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("Parks and Recreation", "parks"),
|
||||||
|
MatchQuality::Prefix
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("Sparks of Love", "parks"),
|
||||||
|
MatchQuality::Substring
|
||||||
|
);
|
||||||
|
assert!(MatchQuality::Prefix < MatchQuality::Substring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
|
||||||
|
#[test]
|
||||||
|
fn ranks_prefix_match_before_substring_match() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Sparks of Love", MediaKind::Series),
|
||||||
|
item("Parks and Recreation", MediaKind::Series),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "parks");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
names(&items),
|
||||||
|
vec!["Parks and Recreation", "Sparks of Love"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A match at a later word start beats a mid-word one but loses to a prefix.
|
||||||
|
#[test]
|
||||||
|
fn word_start_ranks_between_prefix_and_substring() {
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("The Office", "office"),
|
||||||
|
MatchQuality::WordStart
|
||||||
|
);
|
||||||
|
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
|
||||||
|
|
||||||
|
let mut items = vec![
|
||||||
|
item("Bofficer", MediaKind::Series),
|
||||||
|
item("The Office", MediaKind::Series),
|
||||||
|
item("Office Space", MediaKind::Movie),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "office");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
names(&items),
|
||||||
|
vec!["Office Space", "The Office", "Bofficer"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UT-086: at equal match quality a series outranks an episode.
|
||||||
|
#[test]
|
||||||
|
fn series_ranks_before_episode_at_equal_match_quality() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Parks and Recreation S01E01", MediaKind::Episode),
|
||||||
|
item("Parks and Recreation", MediaKind::Series),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "parks");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
names(&items),
|
||||||
|
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Albums outrank their tracks for the same reason series outrank episodes.
|
||||||
|
#[test]
|
||||||
|
fn album_ranks_before_track_at_equal_match_quality() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Rumours", MediaKind::Track),
|
||||||
|
item("Rumours", MediaKind::Album),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "rumours");
|
||||||
|
|
||||||
|
assert_eq!(items[0].kind, MediaKind::Album);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match quality dominates kind: a better-matching episode beats a
|
||||||
|
/// worse-matching series, so kind never drags an irrelevant show to the top.
|
||||||
|
#[test]
|
||||||
|
fn match_quality_outranks_kind() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Sparks of Love", MediaKind::Series),
|
||||||
|
item("Parks Cleanup", MediaKind::Episode),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "parks");
|
||||||
|
|
||||||
|
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items the backend returned for a non-name reason (overview, artist) are
|
||||||
|
/// kept, but sort below everything that actually matched the name.
|
||||||
|
#[test]
|
||||||
|
fn non_matching_names_sort_last_without_being_dropped() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Unrelated Documentary", MediaKind::Movie),
|
||||||
|
item("Parks and Recreation", MediaKind::Series),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "parks");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
names(&items),
|
||||||
|
vec!["Parks and Recreation", "Unrelated Documentary"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ranking is stable: equally-ranked items keep the backend's order, so the
|
||||||
|
/// FTS/server relevance signal still breaks ties.
|
||||||
|
#[test]
|
||||||
|
fn equal_rank_preserves_input_order() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Parks A", MediaKind::Series),
|
||||||
|
item("Parks B", MediaKind::Series),
|
||||||
|
item("Parks C", MediaKind::Series),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "parks");
|
||||||
|
|
||||||
|
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case and surrounding whitespace never change the tier.
|
||||||
|
#[test]
|
||||||
|
fn matching_is_case_and_whitespace_insensitive() {
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("PARKS AND RECREATION", " parks "),
|
||||||
|
MatchQuality::Prefix
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("Parks and Recreation", "PARKS"),
|
||||||
|
MatchQuality::Prefix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty query leaves the order alone rather than reshuffling on kind.
|
||||||
|
#[test]
|
||||||
|
fn empty_query_preserves_input_order() {
|
||||||
|
let mut items = vec![
|
||||||
|
item("Zebra", MediaKind::Episode),
|
||||||
|
item("Apple", MediaKind::Series),
|
||||||
|
];
|
||||||
|
|
||||||
|
rank_search_results(&mut items, "");
|
||||||
|
|
||||||
|
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A multi-byte name must not panic when the match is mid-string — the
|
||||||
|
/// boundary check walks chars rather than slicing raw bytes.
|
||||||
|
#[test]
|
||||||
|
fn handles_multibyte_names_without_panicking() {
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("Pokémon Journeys", "journeys"),
|
||||||
|
MatchQuality::WordStart
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("Café Parks", "parks"),
|
||||||
|
MatchQuality::WordStart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
|
||||||
|
#[test]
|
||||||
|
fn punctuation_counts_as_a_word_boundary() {
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("The-Office", "office"),
|
||||||
|
MatchQuality::WordStart
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
match_quality("Show: Parks", "parks"),
|
||||||
|
MatchQuality::WordStart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import SleepTimerModal from "./SleepTimerModal.svelte";
|
import SleepTimerModal from "./SleepTimerModal.svelte";
|
||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
|
import { videoFitClass } from "./videoFit";
|
||||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
import { playbackPosition } from "$lib/stores/player";
|
import { playbackPosition } from "$lib/stores/player";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
@@ -1595,7 +1596,7 @@
|
|||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||||
class="max-w-full max-h-full"
|
class={videoFitClass()}
|
||||||
class:invisible={!isMediaReady}
|
class:invisible={!isMediaReady}
|
||||||
style="filter: brightness({brightness})"
|
style="filter: brightness({brightness})"
|
||||||
playsinline
|
playsinline
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { videoFitClass, fittedVideoSize } from "./videoFit";
|
||||||
|
|
||||||
|
describe("videoFitClass", () => {
|
||||||
|
it("fills the container instead of capping at the source's intrinsic size", () => {
|
||||||
|
const cls = videoFitClass();
|
||||||
|
// max-w/max-h only shrink oversized media; a 480p source would stay a small
|
||||||
|
// box in the middle of a large window.
|
||||||
|
expect(cls).not.toContain("max-w-full");
|
||||||
|
expect(cls).not.toContain("max-h-full");
|
||||||
|
expect(cls).toContain("w-full");
|
||||||
|
expect(cls).toContain("h-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
|
||||||
|
const cls = videoFitClass();
|
||||||
|
expect(cls).toContain("object-contain");
|
||||||
|
expect(cls).not.toContain("object-cover");
|
||||||
|
expect(cls).not.toContain("object-fill");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fittedVideoSize", () => {
|
||||||
|
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
|
||||||
|
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
|
||||||
|
// staying a 854x480 box in the middle.
|
||||||
|
const size = fittedVideoSize(853.33, 480, 1920, 1080);
|
||||||
|
expect(size.width).toBeCloseTo(1920, 0);
|
||||||
|
expect(size.height).toBeCloseTo(1080, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits to the constraining dimension when aspect ratios differ", () => {
|
||||||
|
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
|
||||||
|
const size = fittedVideoSize(640, 480, 1920, 1080);
|
||||||
|
expect(size.height).toBeCloseTo(1080, 0);
|
||||||
|
expect(size.width).toBeCloseTo(1440, 0);
|
||||||
|
expect(size.width).toBeLessThan(1920);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits to width when the source is wider than the window", () => {
|
||||||
|
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
|
||||||
|
const size = fittedVideoSize(2560, 1080, 1920, 1080);
|
||||||
|
expect(size.width).toBeCloseTo(1920, 0);
|
||||||
|
expect(size.height).toBeCloseTo(810, 0);
|
||||||
|
expect(size.height).toBeLessThan(1080);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks oversized media to fit rather than overflowing", () => {
|
||||||
|
const size = fittedVideoSize(3840, 2160, 1280, 720);
|
||||||
|
expect(size.width).toBeCloseTo(1280, 0);
|
||||||
|
expect(size.height).toBeCloseTo(720, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a zero size for unknown intrinsic dimensions", () => {
|
||||||
|
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Sizing rules for the HTML5 <video> element in the full-screen player.
|
||||||
|
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes applied to the <video> element so it fits the player viewport.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005
|
||||||
|
*
|
||||||
|
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
|
||||||
|
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
|
||||||
|
* intrinsic size - a small box in the middle of a black screen. Filling the
|
||||||
|
* container and letting `object-contain` do the scaling fits the picture to
|
||||||
|
* whichever axis constrains it, in both directions, preserving aspect ratio.
|
||||||
|
*/
|
||||||
|
export function videoFitClass(): string {
|
||||||
|
return "w-full h-full object-contain";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FittedSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendered size of a video of the given intrinsic dimensions once it has
|
||||||
|
* been fitted into the container - i.e. scaled (up or down) so that it touches
|
||||||
|
* the container on its constraining axis, with the other axis letter/pillar
|
||||||
|
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
|
||||||
|
*/
|
||||||
|
export function fittedVideoSize(
|
||||||
|
intrinsicWidth: number,
|
||||||
|
intrinsicHeight: number,
|
||||||
|
containerWidth: number,
|
||||||
|
containerHeight: number,
|
||||||
|
): FittedSize {
|
||||||
|
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(
|
||||||
|
containerWidth / intrinsicWidth,
|
||||||
|
containerHeight / intrinsicHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: intrinsicWidth * scale,
|
||||||
|
height: intrinsicHeight * scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
{item}
|
{item}
|
||||||
size="medium"
|
size="medium"
|
||||||
showProgress={group.id !== "artists"}
|
showProgress={group.id !== "artists" && group.id !== "people"}
|
||||||
onclick={() => onItemClick?.(item)}
|
onclick={() => onItemClick?.(item)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
|
|||||||
it("loads a stored order", async () => {
|
it("loads a stored order", async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
|
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
|
||||||
);
|
);
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"tvShows",
|
"episodes",
|
||||||
|
"shows",
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
"people",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates a stored `tvShows` from before the group split", async () => {
|
||||||
|
// Upgrading must keep the user's placement of TV, not append the two new
|
||||||
|
// groups at the bottom.
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
|
||||||
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"movies",
|
||||||
|
"songs",
|
||||||
|
"albums",
|
||||||
|
"artists",
|
||||||
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,10 +77,12 @@ describe("searchGroupOrder", () => {
|
|||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
|
|||||||
|
|
||||||
it("persists a move so the order survives a restart", async () => {
|
it("persists a move so the order survives a restart", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
// Default is shows, episodes, movies, songs, … — move movies up one.
|
||||||
searchGroupOrder.move("movies", -1);
|
searchGroupOrder.move("movies", -1);
|
||||||
|
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
const expected = [
|
||||||
|
"shows",
|
||||||
|
"movies",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"movies",
|
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
];
|
||||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
|
expect(get(searchGroupOrder)).toEqual(expected);
|
||||||
"songs",
|
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
|
||||||
"albums",
|
|
||||||
"movies",
|
|
||||||
"artists",
|
|
||||||
"tvShows",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Simulate a fresh app start reading the same storage.
|
// Simulate a fresh app start reading the same storage.
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const reloaded = await import("./searchGroupOrder");
|
const reloaded = await import("./searchGroupOrder");
|
||||||
expect(get(reloaded.searchGroupOrder)).toEqual([
|
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
|
||||||
"songs",
|
|
||||||
"albums",
|
|
||||||
"movies",
|
|
||||||
"artists",
|
|
||||||
"tvShows",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists a drag reorder", async () => {
|
it("persists a drag reorder", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
|
// Drag "albums" (index 4) to the front.
|
||||||
searchGroupOrder.reorder(4, 0);
|
searchGroupOrder.reorder(4, 0);
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"tvShows",
|
|
||||||
"songs",
|
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"movies",
|
"movies",
|
||||||
|
"songs",
|
||||||
|
"artists",
|
||||||
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets to the shipped default", async () => {
|
it("resets to the shipped default", async () => {
|
||||||
const { searchGroupOrder } = await import("./searchGroupOrder");
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
||||||
searchGroupOrder.move("tvShows", -1);
|
searchGroupOrder.move("movies", -1);
|
||||||
searchGroupOrder.reset();
|
searchGroupOrder.reset();
|
||||||
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
|
||||||
});
|
});
|
||||||
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
|
|||||||
searchGroupOrder.set(["movies", "podcasts"] as never);
|
searchGroupOrder.set(["movies", "podcasts"] as never);
|
||||||
expect(get(searchGroupOrder)).toEqual([
|
expect(get(searchGroupOrder)).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
normalizeGroupOrder,
|
normalizeGroupOrder,
|
||||||
reorderGroups,
|
reorderGroups,
|
||||||
resolveSearchScope,
|
resolveSearchScope,
|
||||||
|
searchRouteUrl,
|
||||||
|
shouldNavigateToSearch,
|
||||||
scopeItemTypes,
|
scopeItemTypes,
|
||||||
type SearchGroupId,
|
type SearchGroupId,
|
||||||
} from "./searchScope";
|
} from "./searchScope";
|
||||||
@@ -92,9 +94,11 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,9 +107,11 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,34 +119,78 @@ describe("normalizeGroupOrder", () => {
|
|||||||
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
||||||
"songs",
|
"songs",
|
||||||
"movies",
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"tvShows",
|
"people",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves a complete valid order unchanged", () => {
|
it("preserves a complete valid order unchanged", () => {
|
||||||
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
|
const order: SearchGroupId[] = [
|
||||||
|
"episodes",
|
||||||
|
"shows",
|
||||||
|
"movies",
|
||||||
|
"artists",
|
||||||
|
"albums",
|
||||||
|
"songs",
|
||||||
|
"people",
|
||||||
|
];
|
||||||
expect(normalizeGroupOrder(order)).toEqual(order);
|
expect(normalizeGroupOrder(order)).toEqual(order);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("expands a stored `tvShows` into shows + episodes in place", () => {
|
||||||
|
// Migration: the old combined group split, and a user who put TV first
|
||||||
|
// must still get TV first rather than appended at the bottom.
|
||||||
|
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"movies",
|
||||||
|
"songs",
|
||||||
|
"albums",
|
||||||
|
"artists",
|
||||||
|
"people",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("groupsForScope", () => {
|
describe("groupsForScope", () => {
|
||||||
it("returns every group in saved order for the all scope", () => {
|
it("returns every group in saved order for the all scope", () => {
|
||||||
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
|
expect(
|
||||||
|
groupsForScope("all", [
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"tvShows",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
]);
|
"people",
|
||||||
|
])
|
||||||
|
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps only in-scope groups, in saved order", () => {
|
it("keeps only in-scope groups, in saved order", () => {
|
||||||
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
|
const order: SearchGroupId[] = [
|
||||||
|
"artists",
|
||||||
|
"movies",
|
||||||
|
"albums",
|
||||||
|
"episodes",
|
||||||
|
"shows",
|
||||||
|
"songs",
|
||||||
|
"people",
|
||||||
|
];
|
||||||
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
|
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
|
||||||
expect(groupsForScope("movies", order)).toEqual(["movies"]);
|
expect(groupsForScope("movies", order)).toEqual(["movies"]);
|
||||||
expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
|
expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces people only under the all scope", () => {
|
||||||
|
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
|
||||||
|
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
|
||||||
|
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
||||||
|
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
||||||
|
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,13 +206,29 @@ describe("composeSearchGroups", () => {
|
|||||||
|
|
||||||
it("renders groups in the configured order", () => {
|
it("renders groups in the configured order", () => {
|
||||||
const groups = composeSearchGroups(results, "all", [
|
const groups = composeSearchGroups(results, "all", [
|
||||||
"tvShows",
|
"shows",
|
||||||
|
"episodes",
|
||||||
"movies",
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
"people",
|
||||||
]);
|
]);
|
||||||
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
|
expect(groups.map((g) => g.id)).toEqual([
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"movies",
|
||||||
|
"songs",
|
||||||
|
"albums",
|
||||||
|
"people",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts shows ahead of episodes by default", () => {
|
||||||
|
// Searching a show's name should surface the show itself first, not an
|
||||||
|
// arbitrary episode of it.
|
||||||
|
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
|
||||||
|
expect(ids).toEqual(["shows", "episodes"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits empty groups", () => {
|
it("omits empty groups", () => {
|
||||||
@@ -177,27 +243,44 @@ describe("composeSearchGroups", () => {
|
|||||||
"albums",
|
"albums",
|
||||||
]);
|
]);
|
||||||
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
||||||
"tvShows",
|
"shows",
|
||||||
|
"episodes",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups series and episodes together under tvShows", () => {
|
it("separates series and episodes into their own groups", () => {
|
||||||
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
|
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
|
||||||
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
|
expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
|
||||||
|
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces people so an actor search reaches their bio", () => {
|
||||||
|
// Person items were previously returned by the backend and silently dropped.
|
||||||
|
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
||||||
|
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores item types that belong to no group", () => {
|
it("ignores item types that belong to no group", () => {
|
||||||
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
|
||||||
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
|
const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
|
||||||
|
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("narrowing then widening restores the full arrangement", () => {
|
it("narrowing then widening restores the full arrangement", () => {
|
||||||
// Scope is a filter over the saved order, never a rewrite of it.
|
// Scope is a filter over the saved order, never a rewrite of it.
|
||||||
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
|
const order: SearchGroupId[] = [
|
||||||
|
"shows",
|
||||||
|
"songs",
|
||||||
|
"movies",
|
||||||
|
"albums",
|
||||||
|
"artists",
|
||||||
|
"episodes",
|
||||||
|
"people",
|
||||||
|
];
|
||||||
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
|
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
|
||||||
composeSearchGroups(results, "music", order);
|
composeSearchGroups(results, "music", order);
|
||||||
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
|
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
|
||||||
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
|
expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("survives a stored order containing an unknown id", () => {
|
it("survives a stored order containing an unknown id", () => {
|
||||||
@@ -205,7 +288,14 @@ describe("composeSearchGroups", () => {
|
|||||||
"podcasts",
|
"podcasts",
|
||||||
"movies",
|
"movies",
|
||||||
] as unknown as SearchGroupId[]);
|
] as unknown as SearchGroupId[]);
|
||||||
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
|
expect(groups.map((g) => g.id)).toEqual([
|
||||||
|
"movies",
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"songs",
|
||||||
|
"albums",
|
||||||
|
"people",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles items with a missing type", () => {
|
it("handles items with a missing type", () => {
|
||||||
@@ -219,7 +309,7 @@ describe("composeSearchGroups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("moveGroup", () => {
|
describe("moveGroup", () => {
|
||||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
|
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||||
|
|
||||||
it("moves a group up", () => {
|
it("moves a group up", () => {
|
||||||
expect(moveGroup(order, "artists", -1)).toEqual([
|
expect(moveGroup(order, "artists", -1)).toEqual([
|
||||||
@@ -227,7 +317,7 @@ describe("moveGroup", () => {
|
|||||||
"artists",
|
"artists",
|
||||||
"albums",
|
"albums",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,13 +327,13 @@ describe("moveGroup", () => {
|
|||||||
"songs",
|
"songs",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op at the boundaries", () => {
|
it("is a no-op at the boundaries", () => {
|
||||||
expect(moveGroup(order, "songs", -1)).toEqual(order);
|
expect(moveGroup(order, "songs", -1)).toEqual(order);
|
||||||
expect(moveGroup(order, "tvShows", 1)).toEqual(order);
|
expect(moveGroup(order, "shows", 1)).toEqual(order);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op for an unknown id", () => {
|
it("is a no-op for an unknown id", () => {
|
||||||
@@ -258,18 +348,18 @@ describe("moveGroup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("reorderGroups", () => {
|
describe("reorderGroups", () => {
|
||||||
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
|
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
||||||
|
|
||||||
it("moves an item from one index to another", () => {
|
it("moves an item from one index to another", () => {
|
||||||
expect(reorderGroups(order, 0, 4)).toEqual([
|
expect(reorderGroups(order, 0, 4)).toEqual([
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"movies",
|
||||||
"tvShows",
|
"shows",
|
||||||
"songs",
|
"songs",
|
||||||
]);
|
]);
|
||||||
expect(reorderGroups(order, 4, 0)).toEqual([
|
expect(reorderGroups(order, 4, 0)).toEqual([
|
||||||
"tvShows",
|
"shows",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
@@ -283,3 +373,41 @@ describe("reorderGroups", () => {
|
|||||||
expect(reorderGroups(order, 0, 9)).toEqual(order);
|
expect(reorderGroups(order, 0, 9)).toEqual(order);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("searchRouteUrl", () => {
|
||||||
|
it("encodes the query and the scope", () => {
|
||||||
|
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the scope key for the default `all` scope", () => {
|
||||||
|
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets bare /search for an empty query so the page shows its empty state", () => {
|
||||||
|
expect(searchRouteUrl("", "all")).toBe("/search");
|
||||||
|
expect(searchRouteUrl(" ", "music")).toBe("/search");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldNavigateToSearch", () => {
|
||||||
|
it("navigates from any library page, which cannot render results itself", () => {
|
||||||
|
// The bug: the header search bar shows on every /library/** route but only
|
||||||
|
// /library rendered $library.searchResults, so typing did nothing on
|
||||||
|
// /library/music, /library/tv, /library/movies and detail pages.
|
||||||
|
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
|
||||||
|
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
|
||||||
|
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
|
||||||
|
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
|
||||||
|
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays put when already on /search, so typing does not re-push history", () => {
|
||||||
|
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
|
||||||
|
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not navigate on an empty query", () => {
|
||||||
|
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
|
||||||
|
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+105
-14
@@ -62,51 +62,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
|||||||
return "all";
|
return "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the single search surface for a query + scope.
|
||||||
|
*
|
||||||
|
* `/search` is the *only* route that renders results, so every other search
|
||||||
|
* affordance (the desktop header bar) is a navigator to this URL rather than a
|
||||||
|
* second result renderer. The `all` scope is the page's own default, so it is
|
||||||
|
* omitted to keep shared/back-navigated URLs clean.
|
||||||
|
*
|
||||||
|
* TRACES: UR-049 | DR-063
|
||||||
|
*/
|
||||||
|
export function searchRouteUrl(query: string, scope: SearchScope): string {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return "/search";
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ q: trimmed });
|
||||||
|
if (scope !== "all") params.set("scope", scope);
|
||||||
|
// URLSearchParams renders spaces as "+", valid in a query but noisier to
|
||||||
|
// read; %20 is equally valid and matches how the app builds other links.
|
||||||
|
return `/search?${params.toString().replace(/\+/g, "%20")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
|
||||||
|
*
|
||||||
|
* True for every route except `/search` itself: no other page renders
|
||||||
|
* `searchResults`, so a search performed there is invisible. Guarding on
|
||||||
|
* `/search` keeps typing from pushing a history entry per keystroke.
|
||||||
|
*
|
||||||
|
* TRACES: UR-049 | DR-063
|
||||||
|
*/
|
||||||
|
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
|
||||||
|
if (!query.trim()) return false;
|
||||||
|
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
|
||||||
|
return path !== "/search";
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Result groups
|
// Result groups
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
|
export type SearchGroupId =
|
||||||
|
| "shows"
|
||||||
|
| "episodes"
|
||||||
|
| "movies"
|
||||||
|
| "songs"
|
||||||
|
| "albums"
|
||||||
|
| "artists"
|
||||||
|
| "people";
|
||||||
|
|
||||||
/** Shipped default order, per the spec. */
|
/**
|
||||||
|
* Shipped default order.
|
||||||
|
*
|
||||||
|
* TRACES: UR-060 | DR-091
|
||||||
|
*
|
||||||
|
* Containers lead the kinds they contain — a show above its episodes, an album
|
||||||
|
* above nothing (songs are ranked separately) — which matches how people search:
|
||||||
|
* you look for the show, not an arbitrary episode of it. `people` sits last as
|
||||||
|
* a peripheral match; it exists so searching an actor's name reaches their bio
|
||||||
|
* page rather than silently dropping the result.
|
||||||
|
*/
|
||||||
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
|
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
|
||||||
|
"shows",
|
||||||
|
"episodes",
|
||||||
|
"movies",
|
||||||
"songs",
|
"songs",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
"movies",
|
"people",
|
||||||
"tvShows",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
export const GROUP_LABELS: Record<SearchGroupId, string> = {
|
||||||
|
shows: "TV Shows",
|
||||||
|
episodes: "Episodes",
|
||||||
|
movies: "Movies",
|
||||||
songs: "Songs",
|
songs: "Songs",
|
||||||
albums: "Albums",
|
albums: "Albums",
|
||||||
artists: "Artists",
|
artists: "Artists",
|
||||||
movies: "Movies",
|
people: "People",
|
||||||
tvShows: "TV Shows",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Which scopes each group belongs to (`all` always includes everything). */
|
/**
|
||||||
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
|
* Which scopes each group belongs to (`all` always includes everything).
|
||||||
|
*
|
||||||
|
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
|
||||||
|
* it surfaces only under All rather than being forced into one of them.
|
||||||
|
*/
|
||||||
|
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
|
||||||
|
shows: "tv",
|
||||||
|
episodes: "tv",
|
||||||
|
movies: "movies",
|
||||||
songs: "music",
|
songs: "music",
|
||||||
albums: "music",
|
albums: "music",
|
||||||
artists: "music",
|
artists: "music",
|
||||||
movies: "movies",
|
people: null,
|
||||||
tvShows: "tv",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Item types that fall into each group. */
|
/** Item types that fall into each group. */
|
||||||
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
|
||||||
|
shows: ["Series"],
|
||||||
|
episodes: ["Episode"],
|
||||||
|
movies: ["Movie"],
|
||||||
songs: ["Audio"],
|
songs: ["Audio"],
|
||||||
albums: ["MusicAlbum"],
|
albums: ["MusicAlbum"],
|
||||||
artists: ["MusicArtist"],
|
artists: ["MusicArtist"],
|
||||||
movies: ["Movie"],
|
people: ["Person"],
|
||||||
tvShows: ["Series", "Episode"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function groupItemTypes(group: SearchGroupId): string[] {
|
export function groupItemTypes(group: SearchGroupId): string[] {
|
||||||
return [...GROUP_ITEM_TYPES[group]];
|
return [...GROUP_ITEM_TYPES[group]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored group ids that no longer exist, mapped to the ids that replaced them.
|
||||||
|
*
|
||||||
|
* `tvShows` was one group holding both Series and Episode; it split so a show
|
||||||
|
* can outrank its own episodes. Expanding in place preserves the position the
|
||||||
|
* user chose for it.
|
||||||
|
*
|
||||||
|
* TRACES: UR-060 | DR-091
|
||||||
|
*/
|
||||||
|
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
|
||||||
|
tvShows: ["shows", "episodes"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
|
||||||
|
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
|
||||||
|
if (known.has(id)) return [id as SearchGroupId];
|
||||||
|
return RETIRED_GROUP_IDS[id] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalise a stored order into a usable one.
|
* Normalise a stored order into a usable one.
|
||||||
*
|
*
|
||||||
@@ -123,13 +208,17 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
|
|||||||
|
|
||||||
if (Array.isArray(stored)) {
|
if (Array.isArray(stored)) {
|
||||||
for (const id of stored) {
|
for (const id of stored) {
|
||||||
if (typeof id !== "string" || !known.has(id)) continue;
|
if (typeof id !== "string") continue;
|
||||||
const groupId = id as SearchGroupId;
|
// Retired ids expand in place rather than being dropped, so a user who
|
||||||
|
// dragged the old combined "TV Shows" group to the top keeps TV at the
|
||||||
|
// top instead of having shows/episodes appended to the bottom.
|
||||||
|
for (const groupId of migrateGroupId(id, known)) {
|
||||||
if (seen.has(groupId)) continue;
|
if (seen.has(groupId)) continue;
|
||||||
seen.add(groupId);
|
seen.add(groupId);
|
||||||
order.push(groupId);
|
order.push(groupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const id of DEFAULT_GROUP_ORDER) {
|
for (const id of DEFAULT_GROUP_ORDER) {
|
||||||
if (!seen.has(id)) order.push(id);
|
if (!seen.has(id)) order.push(id);
|
||||||
@@ -143,6 +232,8 @@ export function groupsForScope(
|
|||||||
scope: SearchScope,
|
scope: SearchScope,
|
||||||
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
|
||||||
): SearchGroupId[] {
|
): SearchGroupId[] {
|
||||||
|
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
|
||||||
|
// only under `all` — the `=== scope` test already excludes it elsewhere.
|
||||||
return normalizeGroupOrder(order as SearchGroupId[]).filter(
|
return normalizeGroupOrder(order as SearchGroupId[]).filter(
|
||||||
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
(id) => scope === "all" || GROUP_SCOPE[id] === scope
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
import {
|
||||||
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
|
resolveSearchScope,
|
||||||
|
searchRouteUrl,
|
||||||
|
shouldNavigateToSearch,
|
||||||
|
type SearchScope,
|
||||||
|
} from "$lib/utils/searchScope";
|
||||||
import AppHeader from "$lib/components/AppHeader.svelte";
|
import AppHeader from "$lib/components/AppHeader.svelte";
|
||||||
import BottomUi from "$lib/components/BottomUi.svelte";
|
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
@@ -48,18 +52,22 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The header bar is a *navigator*, not a second results surface: /search is
|
||||||
|
// the only route that renders searchResults, so searching here routes there
|
||||||
|
// with the query + route-derived scope in the URL. Previously this ran
|
||||||
|
// library.search() in place, which was invisible on every /library/** page
|
||||||
|
// except /library itself.
|
||||||
|
// TRACES: UR-049 | DR-063
|
||||||
async function handleSearch(query: string) {
|
async function handleSearch(query: string) {
|
||||||
if (query.trim()) {
|
if (!query.trim()) {
|
||||||
await library.search(query, searchScope);
|
|
||||||
} else {
|
|
||||||
library.clearSearch();
|
library.clearSearch();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (shouldNavigateToSearch($page.url.pathname, query)) {
|
||||||
|
await goto(searchRouteUrl(query, searchScope));
|
||||||
async function handleScopeChange(next: SearchScope) {
|
// The query now lives in the URL; clear the header input so returning to
|
||||||
searchScope = next;
|
// a library page does not leave a stale term sitting in the box.
|
||||||
if (searchQuery.trim()) {
|
searchQuery = "";
|
||||||
await library.search(searchQuery, next);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -74,14 +82,12 @@
|
|||||||
<AppHeader search={librarySearch} />
|
<AppHeader search={librarySearch} />
|
||||||
|
|
||||||
{#snippet librarySearch()}
|
{#snippet librarySearch()}
|
||||||
|
<!-- Scope chips live on /search, which owns the results. -->
|
||||||
<Search
|
<Search
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Search your library..."
|
placeholder="Search your library..."
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
/>
|
/>
|
||||||
{#if searchQuery.trim()}
|
|
||||||
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
|
||||||
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
|
||||||
|
|
||||||
let searchResults = $derived($library.searchResults);
|
// Search results are rendered exclusively by /search — this page used to
|
||||||
let searchQuery = $derived($library.searchQuery);
|
// render them inline, which made the header search bar appear broken on every
|
||||||
|
// other /library/** route. TRACES: UR-049 | DR-063
|
||||||
|
|
||||||
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
|
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
|
||||||
|
|
||||||
@@ -169,28 +170,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
{#if searchQuery}
|
{#if showInlineLibraryContent}
|
||||||
<!-- Search results -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h1 class="text-2xl font-bold text-white">
|
|
||||||
Search results for "{searchQuery}"
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
onclick={() => library.clearSearch()}
|
|
||||||
class="text-sm text-gray-400 hover:text-white"
|
|
||||||
>
|
|
||||||
Clear search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LibraryGrid
|
|
||||||
items={searchResults}
|
|
||||||
loading={$isLibraryLoading}
|
|
||||||
onItemClick={handleItemClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if showInlineLibraryContent}
|
|
||||||
<!-- Library content (live TV / channels / other inline-rendered types) -->
|
<!-- Library content (live TV / channels / other inline-rendered types) -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -5,15 +5,41 @@
|
|||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
||||||
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
||||||
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
|
import { resolveSearchScope, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
// `?q=` / `?scope=` seed the page so the desktop header search bar can hand
|
||||||
|
// a query over by navigating here — /search is the only surface that renders
|
||||||
|
// results, so every other search affordance routes into it.
|
||||||
|
// TRACES: UR-049 | DR-063
|
||||||
|
const initialQuery = $page.url.searchParams.get("q") ?? "";
|
||||||
|
const initialScope = $page.url.searchParams.get("scope");
|
||||||
|
|
||||||
|
let searchQuery = $state(initialQuery);
|
||||||
|
|
||||||
// Route resolves the *initial* scope only. Deriving it reactively would snap
|
// Route resolves the *initial* scope only. Deriving it reactively would snap
|
||||||
// a user who widened to All back to the route's scope on any navigation.
|
// a user who widened to All back to the route's scope on any navigation.
|
||||||
// TRACES: UR-049 | DR-064
|
// TRACES: UR-049 | DR-064
|
||||||
let scope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
|
let scope = $state<SearchScope>(
|
||||||
|
SEARCH_SCOPES.includes(initialScope as SearchScope)
|
||||||
|
? (initialScope as SearchScope)
|
||||||
|
: resolveSearchScope($page.url.pathname)
|
||||||
|
);
|
||||||
|
|
||||||
|
// A query arriving in the URL must actually run — mounting with a seeded
|
||||||
|
// input alone would render the empty state with a filled box.
|
||||||
|
$effect(() => {
|
||||||
|
const q = $page.url.searchParams.get("q") ?? "";
|
||||||
|
if (!q.trim()) return;
|
||||||
|
const urlScope = $page.url.searchParams.get("scope");
|
||||||
|
const nextScope = SEARCH_SCOPES.includes(urlScope as SearchScope)
|
||||||
|
? (urlScope as SearchScope)
|
||||||
|
: "all";
|
||||||
|
if (q === $library.searchQuery && nextScope === scope) return;
|
||||||
|
searchQuery = q;
|
||||||
|
scope = nextScope;
|
||||||
|
library.search(q, nextScope);
|
||||||
|
});
|
||||||
|
|
||||||
async function handleSearch(query: string) {
|
async function handleSearch(query: string) {
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user