mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
@@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Added
|
||||
|
||||
### Artists browse — album vs track credit mode
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1232](https://github.com/Psychotoxical/psysonic/pull/1232)**
|
||||
|
||||
* Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes performers from the local artist index (featured/guest credits). Star filter works in both modes; the choice persists across app restarts like **Show artist images**.
|
||||
* Letter bucket filter (`A`–`Z`, `#`, `OTHER`) runs in local SQL instead of scanning catalog chunks client-side, so late-alphabet picks load promptly on large libraries.
|
||||
|
||||
### Square corners — sharp-edged cards and covers
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1215](https://github.com/Psychotoxical/psysonic/pull/1215)**
|
||||
|
||||
+4
-2
@@ -84,7 +84,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
|
||||
|
||||
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
|
||||
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
|
||||
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) runs in CI on frontend paths; run `npm run lint` locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
|
||||
5. **License:** new code must remain compatible with the project's GPLv3.
|
||||
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
|
||||
@@ -114,7 +114,7 @@ PRs must target `main`. `next` and `release` are maintainer-driven promotion bra
|
||||
|
||||
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
|
||||
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, etc.): `npm run lint` (ESLint strict), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
|
||||
|
||||
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
|
||||
@@ -132,7 +132,9 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
|
||||
```bash
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run dep:check
|
||||
npm test
|
||||
npm run prebuild:release-notes
|
||||
npx tsc --noEmit
|
||||
npm run test:coverage
|
||||
bash scripts/check-frontend-hot-path-coverage.sh
|
||||
|
||||
@@ -15,7 +15,8 @@ use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
|
||||
ArtistCreditMode, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto,
|
||||
LibraryArtistDto,
|
||||
LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir,
|
||||
};
|
||||
use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
|
||||
@@ -480,6 +481,45 @@ fn build_album_from_tracks(
|
||||
)
|
||||
}
|
||||
|
||||
fn album_artist_credit_mode(req: &LibraryAdvancedSearchRequest) -> bool {
|
||||
!matches!(req.artist_credit_mode, Some(ArtistCreditMode::Track))
|
||||
}
|
||||
|
||||
/// Letter bucket filter on `name_sort` (articles already stripped in column).
|
||||
fn push_artist_letter_bucket(w: &mut WhereBuilder, bucket: &str, applied: &mut BTreeSet<String>) {
|
||||
if bucket.is_empty() || bucket.eq_ignore_ascii_case("ALL") {
|
||||
return;
|
||||
}
|
||||
let col = "COALESCE(ar.name_sort, ar.name)";
|
||||
match bucket {
|
||||
"#" => {
|
||||
w.push_raw(&format!("SUBSTR({col}, 1, 1) GLOB '[0-9]'"));
|
||||
}
|
||||
"OTHER" => {
|
||||
w.push_raw(&format!(
|
||||
"LENGTH({col}) > 0 \
|
||||
AND SUBSTR({col}, 1, 1) NOT GLOB '[0-9]' \
|
||||
AND LOWER(SUBSTR({col}, 1, 1)) NOT GLOB '[a-z]'"
|
||||
));
|
||||
}
|
||||
letter if letter.len() == 1 => {
|
||||
let Some(ch) = letter.chars().next() else {
|
||||
return;
|
||||
};
|
||||
if !ch.is_ascii_alphabetic() {
|
||||
return;
|
||||
}
|
||||
let lower = ch.to_ascii_lowercase().to_string();
|
||||
w.push_param(
|
||||
&format!("LOWER(SUBSTR({col}, 1, 1)) = ?"),
|
||||
SqlValue::Text(lower),
|
||||
);
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
applied.insert("letter".to_string());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_artist(
|
||||
store: &LibraryStore,
|
||||
@@ -491,11 +531,9 @@ fn build_artist(
|
||||
skip_totals: bool,
|
||||
applied: &mut BTreeSet<String>,
|
||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||
// #1209: browse uses a single `artist` table path — no FTS / track fallthrough.
|
||||
if !scalar_requires_track_derived_entities(scalar) {
|
||||
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
|
||||
if !table.0.is_empty() || table.1 > 0 {
|
||||
return Ok(table);
|
||||
}
|
||||
return build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
|
||||
}
|
||||
if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) {
|
||||
return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
|
||||
@@ -516,6 +554,12 @@ fn build_artist_from_table(
|
||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||
let mut w = WhereBuilder::new();
|
||||
w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone()));
|
||||
if album_artist_credit_mode(req) {
|
||||
w.push_raw("ar.album_count IS NOT NULL");
|
||||
}
|
||||
if let Some(bucket) = req.artist_letter_bucket.as_deref() {
|
||||
push_artist_letter_bucket(&mut w, bucket, applied);
|
||||
}
|
||||
if let Some(t) = text {
|
||||
w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
|
||||
applied.insert("text".to_string());
|
||||
@@ -1461,15 +1505,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, 1, '{}')",
|
||||
rusqlite::params![server, id, name],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
insert_artist_with_album_count(store, server, id, name, Some(1));
|
||||
}
|
||||
|
||||
fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest {
|
||||
@@ -1486,9 +1522,29 @@ mod tests {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
skip_totals: false,
|
||||
artist_credit_mode: None,
|
||||
artist_letter_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_artist_with_album_count(
|
||||
store: &LibraryStore,
|
||||
server: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
album_count: Option<i64>,
|
||||
) {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, ?4, 1, '{}')",
|
||||
rusqlite::params![server, id, name, album_count],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn clause(field: &str, op: FilterOp, value: Option<Value>, value_to: Option<Value>) -> LibraryFilterClause {
|
||||
LibraryFilterClause {
|
||||
field: field.into(),
|
||||
@@ -1567,8 +1623,79 @@ mod tests {
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al_Aurora Nights");
|
||||
assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1"));
|
||||
// Artist rows come from the `artist` table only (#1209) — not track fallthrough.
|
||||
assert!(resp.artists.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_credit_album_mode_excludes_backfill_only_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
|
||||
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Album);
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
assert_eq!(resp.artists[0].id, "ar_Aurora Quartet");
|
||||
assert_eq!(resp.artists[0].id, "ar_va");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_credit_track_mode_includes_backfill_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
|
||||
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Track);
|
||||
r.sort = vec![LibrarySortClause {
|
||||
field: "name".into(),
|
||||
dir: SortDir::Asc,
|
||||
}];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.artists.len(), 2);
|
||||
assert_eq!(resp.artists[0].id, "ar_guest");
|
||||
assert_eq!(resp.artists[1].id, "ar_va");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_credit_album_mode_text_search_uses_artist_table_only() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
|
||||
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.query = Some("guest".into());
|
||||
r.artist_credit_mode = Some(ArtistCreditMode::Album);
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert!(resp.artists.is_empty());
|
||||
let mut r2 = req("s1", &[EntityKind::Artist]);
|
||||
r2.query = Some("guest".into());
|
||||
r2.artist_credit_mode = Some(ArtistCreditMode::Track);
|
||||
let resp2 = run_advanced_search(&store, &r2).unwrap();
|
||||
assert_eq!(resp2.artists.len(), 1);
|
||||
assert_eq!(resp2.artists[0].id, "ar_guest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_letter_bucket_filters_by_name_sort_prefix() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
insert_artist_with_album_count(&store, "s1", "ar_a", "Alpha", Some(1));
|
||||
insert_artist_with_album_count(&store, "s1", "ar_m", "Mike", Some(1));
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE artist SET name_sort = 'alpha' WHERE id = 'ar_a'",
|
||||
[],
|
||||
)?;
|
||||
c.execute(
|
||||
"UPDATE artist SET name_sort = 'mike' WHERE id = 'ar_m'",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Artist]);
|
||||
r.artist_letter_bucket = Some("M".into());
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
assert_eq!(resp.artists[0].id, "ar_m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -523,6 +523,15 @@ pub enum SortDir {
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Artist browse credit semantics when `entity_types` includes `artist` (#1209).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArtistCreditMode {
|
||||
#[default]
|
||||
Album,
|
||||
Track,
|
||||
}
|
||||
|
||||
/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an
|
||||
/// `fts` clause on the text fields; `entityTypes` controls which of the
|
||||
/// three queries run; `filters` are combined with AND.
|
||||
@@ -555,6 +564,13 @@ pub struct LibraryAdvancedSearchRequest {
|
||||
/// When true, skip per-entity COUNT queries (Live Search / small pages).
|
||||
#[serde(default)]
|
||||
pub skip_totals: bool,
|
||||
/// Album-artist vs track-performer browse when querying `artist` (#1209).
|
||||
/// `None` is treated as [`ArtistCreditMode::Album`].
|
||||
#[serde(default)]
|
||||
pub artist_credit_mode: Option<ArtistCreditMode>,
|
||||
/// A–Z / `#` / `OTHER` / omit or `ALL` — letter bucket on `name_sort` (#1209 browse).
|
||||
#[serde(default)]
|
||||
pub artist_letter_bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-entity result counts (full match count, not page size).
|
||||
|
||||
@@ -179,6 +179,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)',
|
||||
'Timeline play history — session buffer + play_session bootstrap across queue replace; pin current to top; history replay inserts in-place (PR #1204)',
|
||||
'Playback — ReplayGain index prefetch, gapless playbar sync, library replayGainPeak column, live RG refresh after sync (PR #1231)',
|
||||
'Artists browse — album vs track credit mode toggle, starred favorites in both modes, persisted credit mode, SQL letter-bucket filter (PR #1232)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getArtists } from '@/lib/api/subsonicArtists';
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import type { ArtistCreditMode } from '@/lib/api/library';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { dedupeById } from '@/lib/util/dedupeById';
|
||||
import {
|
||||
fetchLocalArtistCatalogChunk,
|
||||
fetchNetworkStarredArtists,
|
||||
} from '@/lib/library/browseTextSearch';
|
||||
import {
|
||||
fetchNetworkArtistCatalog,
|
||||
fetchStarredArtistsForBrowse,
|
||||
} from '@/features/artist/utils/artistBrowseCreditMode';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { useOfflineBrowseReloadToken } from '@/features/offline';
|
||||
import {
|
||||
@@ -23,6 +26,8 @@ export type UseArtistsBrowseCatalogArgs = {
|
||||
serverId: string | null | undefined;
|
||||
indexEnabled: boolean;
|
||||
starredOnly: boolean;
|
||||
creditMode: ArtistCreditMode;
|
||||
letterFilter: string;
|
||||
musicLibraryFilterVersion: number;
|
||||
};
|
||||
|
||||
@@ -30,6 +35,8 @@ export function useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
indexEnabled,
|
||||
starredOnly,
|
||||
creditMode,
|
||||
letterFilter,
|
||||
musicLibraryFilterVersion,
|
||||
}: UseArtistsBrowseCatalogArgs) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
@@ -56,8 +63,14 @@ export function useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
creditMode,
|
||||
letterFilter,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (generation !== loadGenerationRef.current) return;
|
||||
if (chunk == null) {
|
||||
if (append) setCatalogHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
@@ -75,8 +88,14 @@ export function useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
creditMode,
|
||||
letterFilter,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (generation !== loadGenerationRef.current) return;
|
||||
if (chunk == null) {
|
||||
if (append) setCatalogHasMore(false);
|
||||
return;
|
||||
}
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
@@ -95,7 +114,7 @@ export function useArtistsBrowseCatalog({
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [offlineBrowseActive, serverId]);
|
||||
}, [creditMode, letterFilter, offlineBrowseActive, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -115,12 +134,20 @@ export function useArtistsBrowseCatalog({
|
||||
if (offlineBrowseActive) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
try {
|
||||
setCatalogArtists(
|
||||
await fetchStarredArtistsForBrowse(creditMode, serverId, true),
|
||||
);
|
||||
} catch {
|
||||
setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
|
||||
}
|
||||
} else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
const first = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
creditMode,
|
||||
letterFilter,
|
||||
);
|
||||
setCatalogArtists(first?.artists ?? []);
|
||||
catalogOffsetRef.current = first?.artists.length ?? 0;
|
||||
@@ -135,7 +162,11 @@ export function useArtistsBrowseCatalog({
|
||||
}
|
||||
if (starredOnly) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await fetchNetworkStarredArtists());
|
||||
setCatalogArtists(
|
||||
await fetchStarredArtistsForBrowse(creditMode, serverId, indexEnabled),
|
||||
);
|
||||
setBrowseMode('network');
|
||||
setCatalogHasMore(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -144,6 +175,8 @@ export function useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
creditMode,
|
||||
letterFilter,
|
||||
);
|
||||
if (cancelled || generation !== loadGenerationRef.current) return;
|
||||
if (first != null) {
|
||||
@@ -155,12 +188,12 @@ export function useArtistsBrowseCatalog({
|
||||
}
|
||||
}
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await getArtists());
|
||||
setCatalogArtists(await fetchNetworkArtistCatalog(creditMode));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
if (generation === loadGenerationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -169,7 +202,7 @@ export function useArtistsBrowseCatalog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
|
||||
}, [creditMode, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
|
||||
|
||||
return {
|
||||
catalogArtists,
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
isArtistsBrowsePath,
|
||||
useArtistBrowseSessionStore,
|
||||
} from '@/features/artist/store/artistBrowseSessionStore';
|
||||
import type { ArtistCreditMode } from '@/lib/api/library';
|
||||
import { isArtistDetailPath } from '@/features/album';
|
||||
import { ALL_SENTINEL } from '@/features/artist/utils/artistsHelpers';
|
||||
import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
@@ -38,11 +40,13 @@ export function useArtistsBrowseFilters(
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
const creditMode = useAuthStore(s => s.artistBrowseCreditMode);
|
||||
const setArtistBrowseCreditMode = useAuthStore(s => s.setArtistBrowseCreditMode);
|
||||
|
||||
const [letterFilter, setLetterFilter] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
|
||||
);
|
||||
const [starredOnly, setStarredOnly] = useState(
|
||||
const [starredOnly, setStarredOnlyRaw] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
|
||||
@@ -59,10 +63,22 @@ export function useArtistsBrowseFilters(
|
||||
filter: useLiveSearchScopeStore.getState().query,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
creditMode,
|
||||
viewMode,
|
||||
showArtistImages,
|
||||
};
|
||||
|
||||
const setCreditMode = (next: ArtistCreditMode) => {
|
||||
if (next === creditMode) return;
|
||||
setArtistBrowseCreditMode(next);
|
||||
setLetterFilter(ALL_SENTINEL);
|
||||
};
|
||||
|
||||
const setStarredOnly = (next: boolean) => {
|
||||
if (next) useLiveSearchScopeStore.getState().setQuery('');
|
||||
setStarredOnlyRaw(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId]);
|
||||
@@ -78,7 +94,8 @@ export function useArtistsBrowseFilters(
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setStarredOnlyRaw(restored.starredOnly);
|
||||
setArtistBrowseCreditMode(restored.creditMode ?? 'album');
|
||||
setViewMode(restored.viewMode);
|
||||
setShowArtistImages(restored.showArtistImages);
|
||||
}
|
||||
@@ -90,9 +107,9 @@ export function useArtistsBrowseFilters(
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
|
||||
setStarredOnly(false);
|
||||
setStarredOnlyRaw(false);
|
||||
setViewMode('grid');
|
||||
}, [serverId, navigationType, location.state, setShowArtistImages]);
|
||||
}, [serverId, navigationType, location.state, setShowArtistImages, setArtistBrowseCreditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -119,6 +136,8 @@ export function useArtistsBrowseFilters(
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
creditMode,
|
||||
setCreditMode,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import type { ArtistCreditMode } from '@/lib/api/library';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
@@ -23,6 +24,8 @@ export function useBrowseArtistTextSearch(
|
||||
indexEnabled: boolean,
|
||||
serverId: string | null | undefined,
|
||||
surface: LibrarySearchSurface = 'artists_browse',
|
||||
creditMode: ArtistCreditMode = 'album',
|
||||
starredOnly = false,
|
||||
) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||
@@ -38,7 +41,7 @@ export function useBrowseArtistTextSearch(
|
||||
|
||||
useEffect(() => {
|
||||
const q = debouncedFilter;
|
||||
if (!q || !indexEnabled || !serverId) {
|
||||
if (starredOnly || !q || !indexEnabled || !serverId) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTextSearchArtists(null);
|
||||
@@ -53,7 +56,7 @@ export function useBrowseArtistTextSearch(
|
||||
void (async () => {
|
||||
if (offlineBrowseActive) {
|
||||
const artists = offlineLocalBrowseEnabled(serverId)
|
||||
? await searchOfflineLocalArtists(serverId, q)
|
||||
? await searchOfflineLocalArtists(serverId, q, creditMode)
|
||||
: [];
|
||||
if (isStale()) return;
|
||||
setTextSearchArtists(artists);
|
||||
@@ -62,8 +65,8 @@ export function useBrowseArtistTextSearch(
|
||||
}
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseArtists(serverId, q),
|
||||
() => runNetworkBrowseArtists(q),
|
||||
() => runLocalBrowseArtists(serverId, q, creditMode),
|
||||
() => runNetworkBrowseArtists(q, creditMode),
|
||||
{
|
||||
surface,
|
||||
query: q,
|
||||
@@ -75,7 +78,7 @@ export function useBrowseArtistTextSearch(
|
||||
setTextSearchArtists(outcome?.result ?? null);
|
||||
setTextSearchLoading(false);
|
||||
})();
|
||||
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
|
||||
}, [creditMode, debouncedFilter, indexEnabled, offlineBrowseActive, serverId, starredOnly, surface]);
|
||||
|
||||
const effectiveFilter = textSearchArtists != null ? '' : filter;
|
||||
return { textSearchArtists, textSearchLoading, effectiveFilter };
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useArtistsBrowseScrollRestore } from '@/features/artist/hooks/useArtist
|
||||
import { useArtistsBrowseScrollReset } from '@/features/artist/hooks/useArtistsBrowseScrollReset';
|
||||
import { useNavigateToArtist } from '@/features/artist/hooks/useNavigateToArtist';
|
||||
import { peekArtistBrowseScrollRestore } from '@/features/artist/store/artistBrowseSessionStore';
|
||||
import { nextArtistCreditMode } from '@/features/artist/utils/artistBrowseCreditMode';
|
||||
import { readArtistBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
|
||||
@@ -58,6 +59,8 @@ export default function Artists() {
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
creditMode,
|
||||
setCreditMode,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
|
||||
@@ -90,6 +93,8 @@ export default function Artists() {
|
||||
serverId,
|
||||
indexEnabled,
|
||||
starredOnly,
|
||||
creditMode,
|
||||
letterFilter,
|
||||
musicLibraryFilterVersion,
|
||||
});
|
||||
|
||||
@@ -97,10 +102,13 @@ export default function Artists() {
|
||||
artistsSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
'artists_browse',
|
||||
creditMode,
|
||||
starredOnly,
|
||||
);
|
||||
const artists = textSearchArtists ?? catalogArtists;
|
||||
const loading = catalogLoading || textSearchLoading;
|
||||
const textSearchActive = textSearchArtists != null;
|
||||
const artists = starredOnly ? catalogArtists : (textSearchArtists ?? catalogArtists);
|
||||
const loading = starredOnly ? catalogLoading : (catalogLoading || textSearchLoading);
|
||||
const textSearchActive = !starredOnly && textSearchArtists != null;
|
||||
/** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */
|
||||
const artistBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
@@ -113,7 +121,7 @@ export default function Artists() {
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: PAGE_SIZE,
|
||||
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
|
||||
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, creditMode, viewMode, musicLibraryFilterVersion, serverId],
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
scrollRootEl: artistsScrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
@@ -294,6 +302,7 @@ export default function Artists() {
|
||||
artistsSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
creditMode,
|
||||
viewMode,
|
||||
serverId,
|
||||
musicLibraryFilterVersion,
|
||||
@@ -331,7 +340,37 @@ export default function Artists() {
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
|
||||
<StarFilterButton
|
||||
size="compact"
|
||||
active={starredOnly}
|
||||
onChange={setStarredOnly}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-surface${creditMode === 'track' ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => setCreditMode(nextArtistCreditMode(creditMode))}
|
||||
data-tooltip={
|
||||
creditMode === 'album'
|
||||
? t('artists.browse.creditMode.tooltipTrack')
|
||||
: t('artists.browse.creditMode.tooltipAlbum')
|
||||
}
|
||||
data-tooltip-wrap
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={
|
||||
creditMode === 'album'
|
||||
? t('artists.browse.creditMode.tooltipTrack')
|
||||
: t('artists.browse.creditMode.tooltipAlbum')
|
||||
}
|
||||
style={
|
||||
creditMode === 'track'
|
||||
? { background: 'var(--accent)', color: 'var(--text-on-accent)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{creditMode === 'album'
|
||||
? t('artists.browse.creditMode.track')
|
||||
: t('artists.browse.creditMode.album')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('artistBrowseSessionStore', () => {
|
||||
filter: 'mozart',
|
||||
letterFilter: 'M',
|
||||
starredOnly: false,
|
||||
creditMode: 'album',
|
||||
viewMode: 'list',
|
||||
showArtistImages: true,
|
||||
scrollTop: 240,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { ALL_SENTINEL } from '@/features/artist/utils/artistsHelpers';
|
||||
import type { ArtistCreditMode } from '@/lib/api/library';
|
||||
|
||||
export type ArtistBrowseViewMode = 'grid' | 'list';
|
||||
|
||||
@@ -8,6 +9,7 @@ export interface ArtistBrowseReturnState {
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
creditMode: ArtistCreditMode;
|
||||
viewMode: ArtistBrowseViewMode;
|
||||
showArtistImages: boolean;
|
||||
scrollTop?: number;
|
||||
@@ -18,6 +20,7 @@ export const DEFAULT_ARTIST_BROWSE_RETURN_STATE: ArtistBrowseReturnState = {
|
||||
filter: '',
|
||||
letterFilter: ALL_SENTINEL,
|
||||
starredOnly: false,
|
||||
creditMode: 'album',
|
||||
viewMode: 'grid',
|
||||
showArtistImages: true,
|
||||
};
|
||||
@@ -41,6 +44,7 @@ export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set
|
||||
filter: state.filter,
|
||||
letterFilter: state.letterFilter,
|
||||
starredOnly: state.starredOnly,
|
||||
creditMode: state.creditMode,
|
||||
viewMode: state.viewMode,
|
||||
showArtistImages: state.showArtistImages,
|
||||
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
|
||||
@@ -65,6 +69,7 @@ export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set
|
||||
filter: stash.filter,
|
||||
letterFilter: stash.letterFilter,
|
||||
starredOnly: stash.starredOnly,
|
||||
creditMode: stash.creditMode ?? 'album',
|
||||
viewMode: stash.viewMode,
|
||||
showArtistImages: stash.showArtistImages,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { getArtists } from '@/lib/api/subsonicArtists';
|
||||
import { getStarred } from '@/lib/api/subsonicStarRating';
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import { libraryAdvancedSearch } from '@/lib/api/library';
|
||||
import type { ArtistCreditMode } from '@/lib/api/library';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { libraryIsReady } from '@/lib/library/libraryReady';
|
||||
import { ndListArtistsByRole } from '@/lib/api/navidromeBrowse';
|
||||
|
||||
/** Network artist catalog before local index is ready (#1209). */
|
||||
export async function fetchNetworkArtistCatalog(
|
||||
creditMode: ArtistCreditMode,
|
||||
): Promise<SubsonicArtist[]> {
|
||||
if (creditMode === 'track') {
|
||||
try {
|
||||
return await ndListArtistsByRole('performer', 0, 10_000);
|
||||
} catch {
|
||||
return getArtists();
|
||||
}
|
||||
}
|
||||
return getArtists();
|
||||
}
|
||||
|
||||
/** Artist ids in the current credit-mode catalog (local index). */
|
||||
async function fetchLocalArtistIdsForMode(
|
||||
serverId: string,
|
||||
creditMode: ArtistCreditMode,
|
||||
): Promise<Set<string> | null> {
|
||||
if (!(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['artist'],
|
||||
artistCreditMode: creditMode,
|
||||
limit: 100_000,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return new Set(resp.artists.map(a => a.id));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNetworkArtistIdsForMode(
|
||||
creditMode: ArtistCreditMode,
|
||||
): Promise<Set<string>> {
|
||||
return new Set((await fetchNetworkArtistCatalog(creditMode)).map(a => a.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* `getStarred2` artist slice intersected with the active credit-mode catalog
|
||||
* (album-artist index or full track performer set) — works in both modes (#1209).
|
||||
*/
|
||||
export async function fetchStarredArtistsForBrowse(
|
||||
creditMode: ArtistCreditMode,
|
||||
serverId: string | null | undefined,
|
||||
indexEnabled: boolean,
|
||||
): Promise<SubsonicArtist[]> {
|
||||
const { artists: starredRaw } = await getStarred();
|
||||
const starred = starredRaw.map(a => ({ ...a, starred: a.starred ?? 'true' }));
|
||||
if (starred.length === 0) return starred;
|
||||
|
||||
let scopeIds: Set<string> | null = null;
|
||||
if (indexEnabled && serverId) {
|
||||
scopeIds = await fetchLocalArtistIdsForMode(serverId, creditMode);
|
||||
}
|
||||
if (!scopeIds) {
|
||||
try {
|
||||
scopeIds = await fetchNetworkArtistIdsForMode(creditMode);
|
||||
} catch {
|
||||
return starred;
|
||||
}
|
||||
}
|
||||
return starred.filter(a => scopeIds!.has(a.id));
|
||||
}
|
||||
|
||||
export function nextArtistCreditMode(mode: ArtistCreditMode): ArtistCreditMode {
|
||||
return mode === 'album' ? 'track' : 'album';
|
||||
}
|
||||
|
||||
export type { ArtistCreditMode };
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LibraryTrackDto } from '@/lib/api/library';
|
||||
import type { ArtistCreditMode, LibraryTrackDto } from '@/lib/api/library';
|
||||
import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '@/lib/api/library';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
@@ -181,12 +181,49 @@ export async function fetchOfflineLocalStarredArtists(serverId: string): Promise
|
||||
return aggregateArtistsFromTracks(tracks, serverId);
|
||||
}
|
||||
|
||||
async function fetchOfflineLocalArtistCatalogFromIndex(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
creditMode: ArtistCreditMode,
|
||||
letterBucket?: string | null,
|
||||
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
|
||||
const bucket = letterBucket && letterBucket !== 'ALL' ? letterBucket : undefined;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['artist'],
|
||||
artistCreditMode: creditMode,
|
||||
...(bucket ? { artistLetterBucket: bucket } : {}),
|
||||
sort: [{ field: 'name', dir: 'asc' }],
|
||||
limit: chunkSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
const artists = resp.artists.map(artistToArtist).map(a => ({ ...a, serverId }));
|
||||
return { artists, hasMore: artists.length === chunkSize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
creditMode: ArtistCreditMode = 'album',
|
||||
letterBucket?: string | null,
|
||||
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const fromIndex = await fetchOfflineLocalArtistCatalogFromIndex(
|
||||
serverId,
|
||||
offset,
|
||||
chunkSize,
|
||||
creditMode,
|
||||
letterBucket,
|
||||
);
|
||||
if (fromIndex) return fromIndex;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const artists = aggregateArtistsFromTracks(tracks, serverId);
|
||||
const slice = artists.slice(offset, offset + chunkSize);
|
||||
@@ -199,10 +236,30 @@ export async function fetchOfflineLocalArtistCatalogChunk(
|
||||
export async function searchOfflineLocalArtists(
|
||||
serverId: string,
|
||||
query: string,
|
||||
creditMode: ArtistCreditMode = 'album',
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['artist'],
|
||||
artistCreditMode: creditMode,
|
||||
query: q,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source === 'local') {
|
||||
return resp.artists
|
||||
.map(artistToArtist)
|
||||
.map(a => ({ ...a, serverId }))
|
||||
.filter(a => a.name.toLowerCase().includes(q));
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
return aggregateArtistsFromTracks(tracks, serverId)
|
||||
.filter(a => a.name.toLowerCase().includes(q));
|
||||
|
||||
@@ -181,6 +181,9 @@ export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
/** Album-artist vs track-performer browse when querying artists (#1209). */
|
||||
export type ArtistCreditMode = 'album' | 'track';
|
||||
|
||||
export interface LibraryFilterClause {
|
||||
field: string; // registry id, e.g. 'genre' | 'year' | 'bpm'
|
||||
op: FilterOperator;
|
||||
@@ -209,6 +212,10 @@ export interface LibraryAdvancedSearchRequest {
|
||||
skipTotals?: boolean;
|
||||
/** Album text query matches title/name only (All Albums scoped browse). */
|
||||
queryAlbumTitleOnly?: boolean | null;
|
||||
/** Artist browse credit semantics (#1209). Omitted/null → album artists. */
|
||||
artistCreditMode?: ArtistCreditMode | null;
|
||||
/** A–Z, `#`, `OTHER`, or omit/`ALL` — letter bucket on local artist browse. */
|
||||
artistLetterBucket?: string | null;
|
||||
}
|
||||
|
||||
export interface LibraryAlbumDto {
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface LocalSearchOpts {
|
||||
resultType: AdvancedResultType;
|
||||
/** When searching albums, match album title only (not album artist). */
|
||||
albumTitleOnly?: boolean;
|
||||
/** Artist browse credit mode (#1209). */
|
||||
artistCreditMode?: 'album' | 'track';
|
||||
}
|
||||
|
||||
export interface LocalAdvancedSearchPage {
|
||||
@@ -148,6 +150,7 @@ function buildRequest(
|
||||
...(opts.resultType === 'albums' && opts.albumTitleOnly
|
||||
? { queryAlbumTitleOnly: true }
|
||||
: {}),
|
||||
...(opts.artistCreditMode ? { artistCreditMode: opts.artistCreditMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Browse-page text search — local index vs network race (LiveSearch / SearchBrowsePage pattern).
|
||||
*/
|
||||
import { getStarred } from '@/lib/api/subsonicStarRating';
|
||||
import { getArtists } from '@/lib/api/subsonicArtists';
|
||||
import type { ArtistCreditMode } from '@/lib/api/library';
|
||||
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '@/lib/api/library';
|
||||
@@ -177,7 +179,7 @@ export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugE
|
||||
const ARTIST_BROWSE_LIMIT = 500;
|
||||
const ALBUM_BROWSE_LIMIT = 500;
|
||||
|
||||
const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
const emptyBrowseOpts = (query: string, artistCreditMode?: ArtistCreditMode): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
@@ -186,6 +188,7 @@ const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
resultType: 'artists',
|
||||
...(artistCreditMode ? { artistCreditMode } : {}),
|
||||
});
|
||||
|
||||
const albumBrowseOpts = (query: string, losslessOnly = false): LocalSearchOpts => ({
|
||||
@@ -227,11 +230,12 @@ const fullSearchOpts = (query: string): LocalSearchOpts => ({
|
||||
export async function runLocalBrowseArtists(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
creditMode: ArtistCreditMode = 'album',
|
||||
limit = ARTIST_BROWSE_LIMIT,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
emptyBrowseOpts(query),
|
||||
emptyBrowseOpts(query, creditMode),
|
||||
limit,
|
||||
false,
|
||||
true,
|
||||
@@ -244,13 +248,16 @@ export async function runLocalBrowseArtists(
|
||||
/** Network search3 artist slice for browse pages. */
|
||||
export async function runNetworkBrowseArtists(
|
||||
query: string,
|
||||
creditMode: ArtistCreditMode = 'album',
|
||||
limit = ARTIST_BROWSE_LIMIT,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const r = await search(q, { artistCount: limit, albumCount: 0, songCount: 0 });
|
||||
return r.artists;
|
||||
if (creditMode === 'track') return r.artists;
|
||||
const indexIds = new Set((await getArtists()).map(a => a.id));
|
||||
return r.artists.filter(a => indexIds.has(a.id));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -561,13 +568,18 @@ export async function fetchLocalArtistCatalogChunk(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
creditMode: ArtistCreditMode = 'album',
|
||||
letterBucket?: string | null,
|
||||
): Promise<ArtistCatalogChunkResult | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
const bucket = letterBucket && letterBucket !== 'ALL' ? letterBucket : undefined;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['artist'],
|
||||
artistCreditMode: creditMode,
|
||||
...(bucket ? { artistLetterBucket: bucket } : {}),
|
||||
sort: [{ field: 'name', dir: 'asc' }],
|
||||
limit: chunkSize,
|
||||
offset,
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Активирай множествен избор',
|
||||
cancelSelect: 'Отказ',
|
||||
addToPlaylist: 'Добави в плейлист',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Артисти на албума',
|
||||
track: 'Артисти на записа',
|
||||
tooltipAlbum: 'Показва само album artist от индекса на библиотеката (по подразбиране)',
|
||||
tooltipTrack: 'Показва всички изпълнители на записи, включително гости в компилации',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Mehrfachauswahl aktivieren',
|
||||
cancelSelect: 'Abbrechen',
|
||||
addToPlaylist: 'Zur Playlist hinzufügen',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Album-Künstler',
|
||||
track: 'Track-Künstler',
|
||||
tooltipAlbum: 'Nur Album-Künstler anzeigen (Standard-Bibliotheksindex)',
|
||||
tooltipTrack: 'Alle Track-Interpreten, einschließlich Gäste auf Samplern',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Enable multi-select',
|
||||
cancelSelect: 'Cancel',
|
||||
addToPlaylist: 'Add to Playlist',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Album artists',
|
||||
track: 'Track artists',
|
||||
tooltipAlbum: 'Show album artists only (default library index)',
|
||||
tooltipTrack: 'Show all track performers, including guests on compilations',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Activar selección múltiple',
|
||||
cancelSelect: 'Cancelar',
|
||||
addToPlaylist: 'Agregar a Lista',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Artistas del álbum',
|
||||
track: 'Artistas del tema',
|
||||
tooltipAlbum: 'Mostrar solo artistas del álbum (índice de biblioteca predeterminado)',
|
||||
tooltipTrack: 'Mostrar todos los intérpretes, incluidos invitados en recopilaciones',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Activer la sélection multiple',
|
||||
cancelSelect: 'Annuler',
|
||||
addToPlaylist: 'Ajouter à la playlist',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Artistes d’album',
|
||||
track: 'Artistes de piste',
|
||||
tooltipAlbum: 'Afficher uniquement les artistes d’album (index bibliothèque par défaut)',
|
||||
tooltipTrack: 'Afficher tous les interprètes, y compris les invités sur les compilations',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Többszörös kijelölés bekapcsolása',
|
||||
cancelSelect: 'Mégse',
|
||||
addToPlaylist: 'Hozzáadás lejátszási listához',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Album előadók',
|
||||
track: 'Szám előadók',
|
||||
tooltipAlbum: 'Csak album előadók megjelenítése (alapértelmezett könyvtárindex)',
|
||||
tooltipTrack: 'Minden szám előadó megjelenítése, beleértve a vendégeket válogatásokon',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: '複数選択を有効化',
|
||||
cancelSelect: 'キャンセル',
|
||||
addToPlaylist: 'プレイリストに追加',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'アルバムアーティスト',
|
||||
track: 'トラックアーティスト',
|
||||
tooltipAlbum: 'アルバムアーティストのみ表示(デフォルトのライブラリ索引)',
|
||||
tooltipTrack: 'コンピレーションのゲストを含むすべてのトラック出演者を表示',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Aktiver multivalg',
|
||||
cancelSelect: 'Avbryt',
|
||||
addToPlaylist: 'Legg til i spilleliste',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Albumartister',
|
||||
track: 'Sporartister',
|
||||
tooltipAlbum: 'Vis kun albumartister (standard biblioteksindeks)',
|
||||
tooltipTrack: 'Vis alle sporutøvere, inkludert gjester på samlinger',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Meervoudige selectie inschakelen',
|
||||
cancelSelect: 'Annuleren',
|
||||
addToPlaylist: 'Toevoegen aan playlist',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Albumartiesten',
|
||||
track: 'Trackartiesten',
|
||||
tooltipAlbum: 'Alleen albumartiesten tonen (standaard bibliotheekindex)',
|
||||
tooltipTrack: 'Alle trackuitvoerders tonen, inclusief gasten op compilaties',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Włącz wybór wielokrotny',
|
||||
cancelSelect: 'Anuluj',
|
||||
addToPlaylist: 'Dodaj do playlisty',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Wykonawcy albumu',
|
||||
track: 'Wykonawcy utworu',
|
||||
tooltipAlbum: 'Pokaż tylko wykonawców albumu (domyślny indeks biblioteki)',
|
||||
tooltipTrack: 'Pokaż wszystkich wykonawców utworów, w tym gości na składankach',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: 'Activează multi-selecția',
|
||||
cancelSelect: 'Anulează',
|
||||
addToPlaylist: 'Adaugă la Playlist',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Artiști de album',
|
||||
track: 'Artiști de piesă',
|
||||
tooltipAlbum: 'Afișează doar artiștii de album (index bibliotecă implicit)',
|
||||
tooltipTrack: 'Afișează toți interpreții pieselor, inclusiv invitați pe compilații',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,4 +18,12 @@ export const artists = {
|
||||
startSelect: 'Включить множественный выбор',
|
||||
cancelSelect: 'Отмена',
|
||||
addToPlaylist: 'В плейлист',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: 'Альбомные артисты',
|
||||
track: 'Артисты треков',
|
||||
tooltipAlbum: 'Только album artist из индекса библиотеки (по умолчанию)',
|
||||
tooltipTrack: 'Все исполнители треков, включая гостей на сборниках',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,4 +16,12 @@ export const artists = {
|
||||
startSelect: '启用多选',
|
||||
cancelSelect: '取消',
|
||||
addToPlaylist: '添加到播放列表',
|
||||
browse: {
|
||||
creditMode: {
|
||||
album: '专辑艺术家',
|
||||
track: '曲目艺术家',
|
||||
tooltipAlbum: '仅显示专辑艺术家(默认库索引)',
|
||||
tooltipTrack: '显示所有曲目表演者,包括合辑中的客串艺术家',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('trivial pass-through setters', () => {
|
||||
['setInfiniteQueueEnabled', 'infiniteQueueEnabled', true],
|
||||
['setPreservePlayNextOrder', 'preservePlayNextOrder', true],
|
||||
['setShowArtistImages', 'showArtistImages', true],
|
||||
['setArtistBrowseCreditMode', 'artistBrowseCreditMode', 'track'],
|
||||
['setShowTrayIcon', 'showTrayIcon', false],
|
||||
['setMinimizeToTray', 'minimizeToTray', true],
|
||||
['setClockFormat', 'clockFormat', '24h'],
|
||||
|
||||
@@ -66,6 +66,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
infiniteQueueEnabled: false,
|
||||
preservePlayNextOrder: false,
|
||||
showArtistImages: false,
|
||||
artistBrowseCreditMode: 'album',
|
||||
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||
showTrayIcon: true,
|
||||
minimizeToTray: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './authStoreHelpers';
|
||||
import type {
|
||||
AuthState,
|
||||
ArtistBrowseCreditMode,
|
||||
DiscordCoverSource,
|
||||
DurationMode,
|
||||
LyricsSourceConfig,
|
||||
@@ -129,6 +130,13 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
? {}
|
||||
: { queueDisplayMode: 'queue' as QueueDisplayMode };
|
||||
|
||||
const VALID_ARTIST_BROWSE_CREDIT_MODES = new Set<string>(['album', 'track']);
|
||||
const artistBrowseCreditModeMigrated = VALID_ARTIST_BROWSE_CREDIT_MODES.has(
|
||||
(state as { artistBrowseCreditMode?: unknown }).artistBrowseCreditMode as string,
|
||||
)
|
||||
? {}
|
||||
: { artistBrowseCreditMode: 'album' as ArtistBrowseCreditMode };
|
||||
|
||||
const VALID_WAYLAND_TEXT_PROFILE = new Set<string>(['balanced', 'sharp', 'gpu', 'minimal']);
|
||||
const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile;
|
||||
const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string)
|
||||
@@ -265,6 +273,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
...windowButtonStyleMigrated,
|
||||
...queueDurationDisplayModeMigrated,
|
||||
...queueDisplayModeMigrated,
|
||||
...artistBrowseCreditModeMigrated,
|
||||
...linuxWaylandTextRenderProfileMigrated,
|
||||
...discordCoverSourceMigrated,
|
||||
...maxCacheMbMigrated,
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
} from '@/lib/server/subsonicServerIdentity';
|
||||
import type { PersistedAccount } from '../music-network';
|
||||
|
||||
/** Album-artist vs track-performer browse (#1209). Duplicated here — not `@/lib/api/library` — to avoid store ↔ library import cycles (dependency-cruiser). */
|
||||
export type ArtistBrowseCreditMode = 'album' | 'track';
|
||||
|
||||
export type CustomHeaderEntry = {
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -170,6 +173,8 @@ export interface AuthState {
|
||||
infiniteQueueEnabled: boolean;
|
||||
preservePlayNextOrder: boolean;
|
||||
showArtistImages: boolean;
|
||||
/** Artists browse: album artists vs track performers (#1209). Persisted across sessions. */
|
||||
artistBrowseCreditMode: ArtistBrowseCreditMode;
|
||||
/**
|
||||
* Max columns for album/artist/playlist-style card grids (Settings → Library).
|
||||
* Clamped 4…12; higher values mean more tiles per row and more layout/paint work.
|
||||
@@ -391,6 +396,7 @@ export interface AuthState {
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
setPreservePlayNextOrder: (v: boolean) => void;
|
||||
setShowArtistImages: (v: boolean) => void;
|
||||
setArtistBrowseCreditMode: (v: ArtistBrowseCreditMode) => void;
|
||||
setLibraryGridMaxColumns: (v: number) => void;
|
||||
setShowTrayIcon: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
|
||||
@@ -13,6 +13,7 @@ type SetState = (
|
||||
export function createUiAppearanceActions(set: SetState): Pick<
|
||||
AuthState,
|
||||
| 'setShowArtistImages'
|
||||
| 'setArtistBrowseCreditMode'
|
||||
| 'setLibraryGridMaxColumns'
|
||||
| 'setShowTrayIcon'
|
||||
| 'setMinimizeToTray'
|
||||
@@ -37,6 +38,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
> {
|
||||
return {
|
||||
setShowArtistImages: (v) => set({ showArtistImages: v }),
|
||||
setArtistBrowseCreditMode: (v) => set({ artistBrowseCreditMode: v === 'track' ? 'track' : 'album' }),
|
||||
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
|
||||
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
|
||||
@@ -5,28 +5,32 @@ import FilterQuickClear from '@/ui/FilterQuickClear';
|
||||
interface Props {
|
||||
active: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
/** 'default' = icon + label, regular padding (Albums toolbar).
|
||||
* 'compact' = icon-only, 0.5rem padding (Artists view-mode buttons).
|
||||
* 'small' = icon + label, 4px/14px padding + 12px text (SearchBrowsePage tabs). */
|
||||
size?: 'default' | 'compact' | 'small';
|
||||
}
|
||||
|
||||
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
|
||||
export default function StarFilterButton({ active, onChange, disabled = false, size = 'default' }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
|
||||
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
const activeStyle = active && !disabled ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
|
||||
const disabledStyle = disabled ? { opacity: 0.45, cursor: 'not-allowed' } : {};
|
||||
|
||||
if (size === 'compact') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => onChange(!active)}
|
||||
className={`btn btn-surface${active && !disabled ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => { if (!disabled) onChange(!active); }}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
aria-label={tooltip}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ padding: '0.5rem', ...activeStyle }}
|
||||
style={{ padding: '0.5rem', ...activeStyle, ...disabledStyle }}
|
||||
>
|
||||
<Star size={20} fill={active ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
@@ -38,10 +42,12 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${active ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => onChange(!active)}
|
||||
onClick={() => { if (!disabled) onChange(!active); }}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
data-tooltip={tooltip}
|
||||
style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
|
||||
style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem', ...disabledStyle }}
|
||||
>
|
||||
<Star size={12} fill={active ? 'currentColor' : 'none'} />
|
||||
{t('common.favorites')}
|
||||
@@ -53,12 +59,14 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
|
||||
onClick={() => onChange(!active)}
|
||||
onClick={() => { if (!disabled) onChange(!active); }}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle,
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle, ...disabledStyle,
|
||||
}}
|
||||
>
|
||||
<Star size={14} fill={active ? 'currentColor' : 'none'} />
|
||||
|
||||
Reference in New Issue
Block a user