mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(library): preserve folder browse identities and durations
This commit is contained in:
@@ -19,9 +19,10 @@
|
||||
/// Separator for composite keys — U+001F cannot appear in normalized output.
|
||||
pub(crate) const KEY_SEP: char = '\u{001f}';
|
||||
|
||||
/// Bump when normalization rules change; stored in `cluster.cluster_meta.norm_version`.
|
||||
/// Bump when cluster-key derivation changes; stored in `cluster.cluster_meta.norm_version`.
|
||||
/// v2: locale-aware folding (ß→ss, æ→ae, œ→oe, Romanian ș/ț, Cyrillic ё/й).
|
||||
pub const NORM_VERSION: &str = "2";
|
||||
/// v3: artist keys use the canonical artist entity name when the track has an artist id.
|
||||
pub const NORM_VERSION: &str = "3";
|
||||
|
||||
/// Normalize one identity field. Returns `None` when input is empty/whitespace-only
|
||||
/// or when normalization strips everything (punctuation-only, etc.).
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::store::LibraryStore;
|
||||
|
||||
use super::attach::CLUSTER_SCHEMA;
|
||||
use super::keys::build_track_cluster_keys;
|
||||
use super::norm::NORM_VERSION;
|
||||
use super::norm::{norm_part, NORM_VERSION};
|
||||
|
||||
const UPSERT_CLUSTER_KEY_SQL: &str = "
|
||||
INSERT INTO cluster.track_cluster_key (
|
||||
@@ -67,6 +67,7 @@ type SourceTrackRow = (
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
@@ -81,11 +82,14 @@ pub fn rebuild_cluster_keys(
|
||||
store.with_conn_mut("identity.rebuild_cluster_keys", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut select = String::from(
|
||||
"SELECT server_id, COALESCE(library_id, ''), id, artist, title, album_artist, album, duration_sec \
|
||||
FROM track WHERE deleted = 0",
|
||||
"SELECT t.server_id, COALESCE(t.library_id, ''), t.id, t.artist, ar.name, t.title, \
|
||||
t.album_artist, t.album, t.duration_sec \
|
||||
FROM track t \
|
||||
LEFT JOIN artist ar ON ar.server_id = t.server_id AND ar.id = t.artist_id \
|
||||
WHERE t.deleted = 0",
|
||||
);
|
||||
if server_id.is_some() {
|
||||
select.push_str(" AND server_id = ?1");
|
||||
select.push_str(" AND t.server_id = ?1");
|
||||
}
|
||||
// Stream rows straight from the `track` SELECT into the sidecar UPSERT
|
||||
// (both statements borrow the same tx; the SELECT reads `track`, the
|
||||
@@ -98,14 +102,28 @@ pub fn rebuild_cluster_keys(
|
||||
let mut upserted = 0u64;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(filter_params.iter()))?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let (server_id, library_id, track_id, artist, title, album_artist, album, duration_sec) =
|
||||
map_source_track_row(row)?;
|
||||
let keys = build_track_cluster_keys(
|
||||
let (
|
||||
server_id,
|
||||
library_id,
|
||||
track_id,
|
||||
artist,
|
||||
canonical_artist,
|
||||
title,
|
||||
album_artist,
|
||||
album,
|
||||
duration_sec,
|
||||
) = map_source_track_row(row)?;
|
||||
let mut keys = build_track_cluster_keys(
|
||||
artist.as_deref(),
|
||||
&title,
|
||||
&album,
|
||||
album_artist.as_deref(),
|
||||
);
|
||||
keys.artist_key = canonical_artist
|
||||
.as_deref()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.or(artist.as_deref())
|
||||
.and_then(norm_part);
|
||||
upsert.execute(params![
|
||||
server_id,
|
||||
library_id,
|
||||
@@ -199,6 +217,7 @@ fn map_source_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SourceTrack
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
row.get(7)?,
|
||||
row.get(8)?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -317,6 +336,55 @@ mod tests {
|
||||
assert!(empty_artist.2.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_uses_canonical_artist_name_for_every_track_with_the_same_artist_id() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_row(
|
||||
"s1",
|
||||
"t1",
|
||||
"Song 1",
|
||||
Some("Andromida • Daedric"),
|
||||
"Album 1",
|
||||
None,
|
||||
200,
|
||||
"lib-a",
|
||||
),
|
||||
track_row(
|
||||
"s1",
|
||||
"t2",
|
||||
"Song 2",
|
||||
Some("Andromida • Nevertel"),
|
||||
"Album 2",
|
||||
None,
|
||||
220,
|
||||
"lib-a",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.canonical_artist_key", |conn| {
|
||||
conn.execute("UPDATE track SET artist_id = 'artist-1' WHERE server_id = 's1'", [])?;
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at) VALUES ('s1', 'artist-1', 'Andromida', 1)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
rebuild_cluster_keys(&store, Some("s1")).unwrap();
|
||||
|
||||
for track_id in ["t1", "t2"] {
|
||||
let row = store
|
||||
.with_read_conn(|conn| read_cluster_row(conn, "s1", track_id))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(row.2.as_deref(), Some("andromida"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_is_idempotent() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -256,6 +256,22 @@ pub(crate) fn ensure_cluster_keys_for_scopes(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Artist reads use `artist_key` even for a single library, so they must apply
|
||||
/// identity-key version upgrades without relying on multi-library dedup being enabled.
|
||||
fn ensure_artist_cluster_keys_for_scopes(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
) -> Result<(), String> {
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for pair in scopes {
|
||||
if !seen.contains(&pair.server_id.as_str()) {
|
||||
seen.push(pair.server_id.as_str());
|
||||
crate::identity::ensure_cluster_keys_built(store, &pair.server_id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_albums(
|
||||
store: &LibraryStore,
|
||||
request: &LibraryScopeListRequest,
|
||||
@@ -346,7 +362,7 @@ pub fn list_artists(
|
||||
request: &LibraryScopeListRequest,
|
||||
) -> Result<Vec<LibraryArtistDto>, String> {
|
||||
let scopes = non_empty_scopes(&request.scopes)?;
|
||||
ensure_cluster_keys_for_scopes(store, scopes)?;
|
||||
ensure_artist_cluster_keys_for_scopes(store, scopes)?;
|
||||
let limit = clamp_limit(request.limit);
|
||||
let offset = clamp_offset(request.offset);
|
||||
let order = artist_order_sql(request.sort.as_deref());
|
||||
@@ -598,6 +614,7 @@ pub(crate) fn list_artists_layer1_filtered(
|
||||
skip_totals: bool,
|
||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||
let scopes = non_empty_scopes(scopes)?;
|
||||
ensure_artist_cluster_keys_for_scopes(store, scopes)?;
|
||||
let (cte, scope_binds) = scope_cte_sql(scopes);
|
||||
let scoped = if scopes.len() == 1 {
|
||||
scoped_track_join_layer1()
|
||||
@@ -697,6 +714,7 @@ pub(crate) fn list_index_artists_layer1_filtered(
|
||||
skip_totals: bool,
|
||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||
let scopes = non_empty_scopes(scopes)?;
|
||||
ensure_artist_cluster_keys_for_scopes(store, scopes)?;
|
||||
let (cte, scope_binds) = scope_cte_sql(scopes);
|
||||
let scoped_from = "FROM scope s \
|
||||
CROSS JOIN track t ON t.server_id = s.server_id AND t.library_id = s.library_id";
|
||||
@@ -996,6 +1014,7 @@ pub(crate) fn list_artists_filtered(
|
||||
skip_totals: bool,
|
||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||
let scopes = non_empty_scopes(scopes)?;
|
||||
ensure_artist_cluster_keys_for_scopes(store, scopes)?;
|
||||
let (cte, scope_binds) = scope_cte_sql(scopes);
|
||||
let base_where = append_extra_where(
|
||||
&format!(
|
||||
@@ -1377,6 +1396,7 @@ pub(crate) fn live_search_artists(
|
||||
limit: u32,
|
||||
) -> Result<Vec<LibraryArtistDto>, String> {
|
||||
let scopes = non_empty_scopes(scopes)?;
|
||||
ensure_artist_cluster_keys_for_scopes(store, scopes)?;
|
||||
let (cte, mut binds) = scope_cte_sql(scopes);
|
||||
let sql = format!(
|
||||
"{cte}, \
|
||||
@@ -1871,6 +1891,7 @@ pub fn artist_detail(
|
||||
request: &LibraryScopeArtistDetailRequest,
|
||||
) -> Result<LibraryScopeArtistDetailResponse, String> {
|
||||
let scopes = non_empty_scopes(&request.scopes)?;
|
||||
ensure_artist_cluster_keys_for_scopes(store, scopes)?;
|
||||
let server_id = request.server_id.trim();
|
||||
let artist_id = request.artist_id.trim();
|
||||
if server_id.is_empty() || artist_id.is_empty() {
|
||||
@@ -2029,6 +2050,66 @@ mod tests {
|
||||
assert!(response.tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_artists_collapses_collaboration_track_names_for_one_artist_id() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track(
|
||||
"s1",
|
||||
"t1",
|
||||
"Song 1",
|
||||
Some("Andromida • Daedric"),
|
||||
"Album 1",
|
||||
"album-1",
|
||||
Some("artist-1"),
|
||||
200,
|
||||
"lib-a",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
track(
|
||||
"s1",
|
||||
"t2",
|
||||
"Song 2",
|
||||
Some("Andromida • Nevertel"),
|
||||
"Album 2",
|
||||
"album-2",
|
||||
Some("artist-1"),
|
||||
220,
|
||||
"lib-a",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn_mut("test.canonical_artist_scope", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at) VALUES ('s1', 'artist-1', 'Andromida', 1)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, Some("s1")).unwrap();
|
||||
|
||||
let artists = list_artists(
|
||||
&store,
|
||||
&LibraryScopeListRequest {
|
||||
scopes: vec![scope_pair("s1", "lib-a"), scope_pair("s1", "lib-b")],
|
||||
sort: Some("name".into()),
|
||||
limit: Some(50),
|
||||
offset: Some(0),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(artists.iter().filter(|artist| artist.id == "artist-1").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_collapses_same_album_and_priority_winner_flips() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -29,6 +29,11 @@ pub(crate) const LIBRARY_ID_BACKFILL_RECONCILE_ID: &str = "library_id_backfill_r
|
||||
/// prune these inline; this clears already-accumulated rows at first open.
|
||||
pub(crate) const ORPHAN_BROWSE_RECONCILE_ID: &str = "orphan_browse_rows_reconcile_v1";
|
||||
|
||||
/// One-time repair of Navidrome decimal durations stored as zero before the
|
||||
/// native mapper began rounding them to whole seconds.
|
||||
pub(crate) const DURATION_SEC_BACKFILL_RECONCILE_ID: &str = "duration_sec_decimal_backfill_v1";
|
||||
const DURATION_SEC_BACKFILL_BATCH_SIZE: i64 = 1_000;
|
||||
|
||||
/// Lowest applied schema version the current code can advance from purely
|
||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
||||
/// fires (spec §5.7 / P22): the library is treated as incompatible, must be
|
||||
@@ -920,6 +925,7 @@ fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()>
|
||||
maybe_reconcile_artist_name_fold(conn)?;
|
||||
maybe_reconcile_replay_gain_peak(conn)?;
|
||||
maybe_reconcile_library_id_backfill(conn)?;
|
||||
maybe_reconcile_duration_sec_backfill(conn)?;
|
||||
maybe_reconcile_orphan_browse_rows(conn)?;
|
||||
ensure_genre_tags_schema(conn)?;
|
||||
ensure_mainstage_feed_indexes(conn)?;
|
||||
@@ -1226,6 +1232,78 @@ fn maybe_reconcile_library_id_backfill(conn: &Connection) -> rusqlite::Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn duration_sec_backfill_completed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![DURATION_SEC_BACKFILL_RECONCILE_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
/// Restore zeroed decimal durations from `raw_json` in bounded transactions.
|
||||
/// `cursor_rowid` lets an interrupted startup continue from the last batch.
|
||||
fn maybe_reconcile_duration_sec_backfill(conn: &Connection) -> rusqlite::Result<()> {
|
||||
if duration_sec_backfill_completed(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
|
||||
VALUES (?1, 0, strftime('%s','now')) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
started_at = COALESCE(library_data_migration.started_at, excluded.started_at)",
|
||||
params![DURATION_SEC_BACKFILL_RECONCILE_ID],
|
||||
)?;
|
||||
|
||||
loop {
|
||||
let cursor: i64 = conn.query_row(
|
||||
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
|
||||
params![DURATION_SEC_BACKFILL_RECONCILE_ID],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let last_rowid: Option<i64> = conn.query_row(
|
||||
"SELECT MAX(rowid) FROM ( \
|
||||
SELECT rowid FROM track \
|
||||
WHERE rowid > ?1 \
|
||||
AND duration_sec = 0 \
|
||||
AND json_valid(raw_json) \
|
||||
AND json_type(raw_json, '$.duration') IN ('integer', 'real') \
|
||||
AND CAST(json_extract(raw_json, '$.duration') AS REAL) > 0 \
|
||||
ORDER BY rowid LIMIT ?2 \
|
||||
)",
|
||||
params![cursor, DURATION_SEC_BACKFILL_BATCH_SIZE],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let Some(last_rowid) = last_rowid else {
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration \
|
||||
SET completed_at = strftime('%s','now') WHERE id = ?1",
|
||||
params![DURATION_SEC_BACKFILL_RECONCILE_ID],
|
||||
)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE track \
|
||||
SET duration_sec = CAST(ROUND(CAST(json_extract(raw_json, '$.duration') AS REAL)) AS INTEGER) \
|
||||
WHERE rowid > ?1 AND rowid <= ?2 \
|
||||
AND duration_sec = 0 \
|
||||
AND json_valid(raw_json) \
|
||||
AND json_type(raw_json, '$.duration') IN ('integer', 'real') \
|
||||
AND CAST(json_extract(raw_json, '$.duration') AS REAL) > 0",
|
||||
params![cursor, last_rowid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
|
||||
params![DURATION_SEC_BACKFILL_RECONCILE_ID, last_rowid],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
}
|
||||
|
||||
fn orphan_browse_reconcile_completed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
@@ -2103,6 +2181,63 @@ mod tests {
|
||||
assert_eq!(library_id_after, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_sec_backfill_rounds_decimal_raw_duration_once() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn_mut("test.seed_duration_backfill", |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM library_data_migration WHERE id = ?1",
|
||||
params![DURATION_SEC_BACKFILL_RECONCILE_ID],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, duration_sec, deleted, synced_at, raw_json) \
|
||||
VALUES ('s1', 'decimal', 'Decimal', 'Al', 0, 0, 1, '{\"duration\":229.85}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, duration_sec, deleted, synced_at, raw_json) \
|
||||
VALUES ('s1', 'zero', 'Zero', 'Al', 0, 0, 1, '{\"duration\":0}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track (server_id, id, title, album, duration_sec, deleted, synced_at, raw_json) \
|
||||
VALUES ('s1', 'set', 'Set', 'Al', 100, 0, 1, '{\"duration\":200}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed tracks");
|
||||
|
||||
store
|
||||
.with_conn("test.duration_backfill", maybe_reconcile_duration_sec_backfill)
|
||||
.expect("duration backfill");
|
||||
|
||||
let durations: Vec<(String, i64)> = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.prepare("SELECT id, duration_sec FROM track WHERE server_id = 's1' ORDER BY id")?
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect()
|
||||
})
|
||||
.expect("backfilled durations");
|
||||
assert_eq!(durations, vec![("decimal".into(), 230), ("set".into(), 100), ("zero".into(), 0)]);
|
||||
|
||||
store
|
||||
.with_conn_mut("test.clear_decimal_duration", |conn| {
|
||||
conn.execute("UPDATE track SET duration_sec = 0 WHERE id = 'decimal'", [])
|
||||
})
|
||||
.expect("clear duration");
|
||||
store
|
||||
.with_conn("test.duration_backfill_again", maybe_reconcile_duration_sec_backfill)
|
||||
.expect("guarded duration backfill");
|
||||
let duration_after: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row("SELECT duration_sec FROM track WHERE id = 'decimal'", [], |row| row.get(0))
|
||||
})
|
||||
.expect("duration after guarded re-run");
|
||||
assert_eq!(duration_after, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conn_recovers_after_closure_panic() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -122,7 +122,7 @@ pub fn navidrome_song_to_track_row(
|
||||
album: string_field(raw, "album").unwrap_or_default(),
|
||||
album_id: string_field(raw, "albumId"),
|
||||
album_artist: string_field(raw, "albumArtist"),
|
||||
duration_sec: raw.get("duration").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
duration_sec: duration_seconds(raw),
|
||||
track_number: raw.get("trackNumber").and_then(|v| v.as_i64()),
|
||||
disc_number: raw.get("discNumber").and_then(|v| v.as_i64()),
|
||||
year: raw.get("year").and_then(|v| v.as_i64()),
|
||||
@@ -167,6 +167,19 @@ fn string_field(raw: &Value, key: &str) -> Option<String> {
|
||||
json_string_field(raw, key)
|
||||
}
|
||||
|
||||
/// Navidrome's native API reports seconds as either an integer or a decimal.
|
||||
/// The local index stores whole seconds, so round rather than silently dropping
|
||||
/// a valid fractional value to zero.
|
||||
fn duration_seconds(raw: &Value) -> i64 {
|
||||
let seconds = raw.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let rounded = seconds.round();
|
||||
if rounded.is_finite() && (0.0..=i64::MAX as f64).contains(&rounded) {
|
||||
rounded as i64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_iso_ms(s: Option<&str>) -> Option<i64> {
|
||||
s.and_then(parse_iso_ms_str)
|
||||
}
|
||||
@@ -376,6 +389,19 @@ mod tests {
|
||||
assert_eq!(row.library_id.as_deref(), Some("3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navidrome_song_rounds_decimal_duration_seconds() {
|
||||
let raw = json!({
|
||||
"id": "tr_1",
|
||||
"title": "Hello",
|
||||
"duration": 229.85,
|
||||
});
|
||||
|
||||
let row = navidrome_song_to_track_row("s1", &raw, 1, None).unwrap();
|
||||
|
||||
assert_eq!(row.duration_sec, 230);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navidrome_song_skips_rows_without_id() {
|
||||
let row = navidrome_song_to_track_row("s1", &json!({"title": "no id"}), 1, None);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronRight, Folder, FolderOpen, Music } from 'lucide-react';
|
||||
import type { SubsonicDirectoryEntry } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
folderBrowserHasKeyModifiers, isFolderBrowserArrowKey,
|
||||
folderBrowserEntryKey, folderBrowserHasKeyModifiers, isFolderBrowserArrowKey,
|
||||
type Column,
|
||||
} from '@/features/folderBrowser/utils/folderBrowserHelpers';
|
||||
|
||||
@@ -89,19 +89,22 @@ export default function FolderBrowserColumn({
|
||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||
) : (
|
||||
filteredItems.map((item, rowIndex) => {
|
||||
const isSelected = col.selectedId === item.id;
|
||||
const itemKey = folderBrowserEntryKey(item);
|
||||
const isSelected = col.selectedKey === itemKey;
|
||||
const isContextRow = contextRowIndex === rowIndex;
|
||||
const isKeyboardRow = keyboardRowIndex === rowIndex;
|
||||
const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id;
|
||||
const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id));
|
||||
const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id && (
|
||||
!currentTrack.serverId || !item.serverId || currentTrack.serverId === item.serverId
|
||||
);
|
||||
const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(itemKey));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
key={itemKey}
|
||||
type="button"
|
||||
title={item.title}
|
||||
data-col-index={colIndex}
|
||||
data-row-index={rowIndex}
|
||||
data-item-id={item.id}
|
||||
data-item-key={itemKey}
|
||||
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
|
||||
onClick={() => onRowClick(item, rowIndex)}
|
||||
onKeyDown={e => {
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Args {
|
||||
fallbackNavPos: (cols: Column[]) => NavPos | null;
|
||||
handleActivate: (colIndex: number, item: SubsonicDirectoryEntry) => void;
|
||||
handleDirClick: (colIndex: number, item: SubsonicDirectoryEntry) => void;
|
||||
setSelectedInColumn: (colIndex: number, itemId: string) => void;
|
||||
setSelectedInColumn: (colIndex: number, item: SubsonicDirectoryEntry) => void;
|
||||
clearSelectedInColumn: (colIndex: number) => void;
|
||||
openContextMenuForEntry: (col: Column, item: SubsonicDirectoryEntry, x: number, y: number) => void;
|
||||
clearFiltersRightOf: (colIndex: number) => void;
|
||||
@@ -91,7 +91,7 @@ export function useFolderBrowserKeyboardNav({
|
||||
const nextItem = visibleItems[nextRowIndex];
|
||||
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
||||
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
||||
else setSelectedInColumn(current.colIndex, nextItem.id);
|
||||
else setSelectedInColumn(current.colIndex, nextItem);
|
||||
} else if (
|
||||
current.rowIndex === 0 &&
|
||||
(filterFocusCol === current.colIndex || !!columnFilters[current.colIndex])
|
||||
@@ -112,7 +112,7 @@ export function useFolderBrowserKeyboardNav({
|
||||
const nextItem = visibleItems[nextRowIndex];
|
||||
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
||||
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
||||
else setSelectedInColumn(current.colIndex, nextItem.id);
|
||||
else setSelectedInColumn(current.colIndex, nextItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export function useFolderBrowserKeyboardNav({
|
||||
const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1);
|
||||
if (rowIndex >= 0) {
|
||||
const nextItem = nextVisibleItems[rowIndex];
|
||||
setSelectedInColumn(nextColIndex, nextItem.id);
|
||||
setSelectedInColumn(nextColIndex, nextItem);
|
||||
setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getMusicDirectoryForServer, getMusicIndexesForServer } from '@/lib/api/subsonicLibrary';
|
||||
import {
|
||||
libraryScopeAlbumDetail,
|
||||
libraryScopeArtistDetail,
|
||||
libraryScopeListArtists,
|
||||
} from '@/lib/api/library/scopeReads';
|
||||
import type { SubsonicDirectoryEntry } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import type { Column, NavPos } from '@/features/folderBrowser/utils/folderBrowserHelpers';
|
||||
import {
|
||||
albumDtoToFolderEntry, artistDtoToFolderEntry, folderBrowserEntryKey, selectedFolderBrowserEntry,
|
||||
trackDtoToFolderEntry,
|
||||
type Column, type NavPos,
|
||||
} from '@/features/folderBrowser/utils/folderBrowserHelpers';
|
||||
|
||||
let persistedPlayingPathIds: string[] = [];
|
||||
|
||||
@@ -27,11 +35,11 @@ export function useFolderBrowserNowPlayingPath({
|
||||
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
|
||||
const [playingPathServerId, setPlayingPathServerId] = useState<string | null>(null);
|
||||
const autoResolvedTrackRef = useRef<string | null>(null);
|
||||
const prevTrackIdRef = useRef<string | null>(null);
|
||||
const prevTrackKeyRef = useRef<string | null>(null);
|
||||
const lastHotkeyRevealTsRef = useRef<number | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
const trackIdentity = currentTrack ? `${currentTrack.serverId ?? ''}\u0000${currentTrack.id}` : null;
|
||||
const trackIdentity = currentTrack ? folderBrowserEntryKey(currentTrack) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.id) {
|
||||
@@ -41,21 +49,21 @@ export function useFolderBrowserNowPlayingPath({
|
||||
setPlayingPathServerId(null);
|
||||
return;
|
||||
}
|
||||
setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : []));
|
||||
setPlayingPathIds(prev => (prev[prev.length - 1] === trackIdentity ? prev : []));
|
||||
setPlayingPathServerId(prev => prev === currentTrack.serverId ? prev : null);
|
||||
}, [currentTrack?.id, currentTrack?.serverId]);
|
||||
}, [currentTrack?.id, currentTrack?.serverId, trackIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !currentTrack?.id) return;
|
||||
const selectedChain = columns
|
||||
.map(c => c.selectedId)
|
||||
.filter((id): id is string => !!id);
|
||||
.map(selectedFolderBrowserEntry)
|
||||
.filter((entry): entry is SubsonicDirectoryEntry => !!entry)
|
||||
.map(folderBrowserEntryKey);
|
||||
if (selectedChain.length === 0) return;
|
||||
|
||||
const lastSelectedId = selectedChain[selectedChain.length - 1];
|
||||
const leafColumn = [...columns].reverse().find(c => c.selectedId);
|
||||
const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId);
|
||||
if (!leafColumn || !leafItem || leafItem.isDir || leafItem.id !== currentTrack.id || leafColumn.serverId !== currentTrack.serverId) return;
|
||||
const leafColumn = [...columns].reverse().find(c => c.selectedKey);
|
||||
const leafItem = leafColumn && selectedFolderBrowserEntry(leafColumn);
|
||||
if (!leafColumn || !leafItem || leafItem.isDir || folderBrowserEntryKey(leafItem) !== trackIdentity) return;
|
||||
|
||||
// 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
|
||||
@@ -68,8 +76,8 @@ export function useFolderBrowserNowPlayingPath({
|
||||
}
|
||||
return selectedChain;
|
||||
});
|
||||
setPlayingPathServerId(leafColumn.serverId ?? null);
|
||||
}, [columns, currentTrack?.id, currentTrack?.serverId, isPlaying]);
|
||||
setPlayingPathServerId(leafItem.serverId ?? null);
|
||||
}, [columns, currentTrack?.id, currentTrack?.serverId, isPlaying, trackIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
persistedPlayingPathIds = playingPathIds;
|
||||
@@ -81,23 +89,34 @@ export function useFolderBrowserNowPlayingPath({
|
||||
): Promise<Column[] | null> => {
|
||||
for (const root of roots) {
|
||||
if (!root.serverId || (track.serverId && root.serverId !== track.serverId)) continue;
|
||||
const scopes = [{ serverId: root.serverId, libraryId: root.sourceId ?? root.id }];
|
||||
let indexes: SubsonicDirectoryEntry[];
|
||||
try {
|
||||
indexes = (await getMusicIndexesForServer(root.serverId, root.sourceId ?? root.id))
|
||||
.map(entry => ({ ...entry, serverId: root.serverId }));
|
||||
indexes = (await libraryScopeListArtists(root.serverId, { scopes, sort: 'name', limit: 10_000 }))
|
||||
.map(artistDtoToFolderEntry);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const artistEntry =
|
||||
indexes.find(it => it.isDir && !!track.artistId && it.id === track.artistId) ??
|
||||
indexes.find(it => it.isDir && it.title === track.artist);
|
||||
indexes.find(it =>
|
||||
it.isDir && !!track.artistId && it.id === track.artistId &&
|
||||
(!track.serverId || !it.serverId || it.serverId === track.serverId),
|
||||
) ??
|
||||
indexes.find(it =>
|
||||
it.isDir && it.title === track.artist &&
|
||||
(!track.serverId || !it.serverId || it.serverId === track.serverId),
|
||||
);
|
||||
if (!artistEntry) continue;
|
||||
|
||||
let artistChildren: SubsonicDirectoryEntry[];
|
||||
try {
|
||||
artistChildren = (await getMusicDirectoryForServer(root.serverId, artistEntry.id)).child
|
||||
.map(entry => ({ ...entry, serverId: root.serverId }));
|
||||
artistChildren = (await libraryScopeArtistDetail(root.serverId, {
|
||||
scopes,
|
||||
artistId: artistEntry.id,
|
||||
serverId: root.serverId,
|
||||
includeTracks: false,
|
||||
})).albums.map(albumDtoToFolderEntry);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -107,25 +126,32 @@ export function useFolderBrowserNowPlayingPath({
|
||||
(
|
||||
(!!track.albumId && (it.albumId === track.albumId || it.id === track.albumId)) ||
|
||||
(!!track.album && (it.album === track.album || it.title === track.album))
|
||||
),
|
||||
) &&
|
||||
(!track.serverId || !it.serverId || it.serverId === track.serverId),
|
||||
);
|
||||
if (!albumEntry) continue;
|
||||
|
||||
let albumChildren: SubsonicDirectoryEntry[];
|
||||
try {
|
||||
albumChildren = (await getMusicDirectoryForServer(root.serverId, albumEntry.id)).child
|
||||
.map(entry => ({ ...entry, serverId: root.serverId }));
|
||||
albumChildren = (await libraryScopeAlbumDetail(root.serverId, {
|
||||
scopes,
|
||||
albumId: albumEntry.id,
|
||||
serverId: root.serverId,
|
||||
})).tracks.map(trackDtoToFolderEntry);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const songEntry = albumChildren.find(it => !it.isDir && it.id === track.id);
|
||||
const songEntry = albumChildren.find(it =>
|
||||
!it.isDir && it.id === track.id &&
|
||||
(!track.serverId || !it.serverId || it.serverId === track.serverId),
|
||||
);
|
||||
if (!songEntry) continue;
|
||||
|
||||
return [
|
||||
{ id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' },
|
||||
{ id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes', serverId: root.serverId },
|
||||
{ id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory', serverId: root.serverId },
|
||||
{ id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory', serverId: root.serverId },
|
||||
{ id: 'root', name: '', items: roots, selectedKey: folderBrowserEntryKey(root), loading: false, error: false, kind: 'roots' },
|
||||
{ id: root.id, name: root.title, items: indexes, selectedKey: folderBrowserEntryKey(artistEntry), loading: false, error: false, kind: 'artists', serverId: root.serverId, scopes },
|
||||
{ id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedKey: folderBrowserEntryKey(albumEntry), loading: false, error: false, kind: 'albums', serverId: root.serverId, scopes },
|
||||
{ id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedKey: folderBrowserEntryKey(songEntry), loading: false, error: false, kind: 'tracks', serverId: root.serverId, scopes },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
@@ -145,11 +171,11 @@ export function useFolderBrowserNowPlayingPath({
|
||||
const rootCol = columns[0];
|
||||
if (!rootCol || rootCol.loading || rootCol.error || rootCol.items.length === 0) return;
|
||||
|
||||
const selectedLeafId =
|
||||
[...columns].reverse().find(c => c.selectedId)?.selectedId ?? null;
|
||||
const wasOnPreviousTrackPath = !!prevTrackIdRef.current && selectedLeafId === prevTrackIdRef.current;
|
||||
const selectedLeafColumn = [...columns].reverse().find(c => c.selectedId);
|
||||
if (selectedLeafId === currentTrack.id && selectedLeafColumn?.serverId === currentTrack.serverId) {
|
||||
const selectedLeafColumn = [...columns].reverse().find(c => c.selectedKey);
|
||||
const selectedLeafEntry = selectedLeafColumn && selectedFolderBrowserEntry(selectedLeafColumn);
|
||||
const selectedLeafKey = selectedLeafEntry ? folderBrowserEntryKey(selectedLeafEntry) : null;
|
||||
const wasOnPreviousTrackPath = !!prevTrackKeyRef.current && selectedLeafKey === prevTrackKeyRef.current;
|
||||
if (selectedLeafKey === trackIdentity) {
|
||||
autoResolvedTrackRef.current = trackIdentity;
|
||||
if (hotkeyRevealRequested) {
|
||||
lastHotkeyRevealTsRef.current = hotkeyRevealTs;
|
||||
@@ -162,11 +188,11 @@ export function useFolderBrowserNowPlayingPath({
|
||||
resolveColumnsForTrack(currentTrack, rootCol.items).then((resolved) => {
|
||||
if (cancelled || !resolved) return;
|
||||
setColumns(resolved);
|
||||
const path = resolved.map(c => c.selectedId).filter((id): id is string => !!id);
|
||||
const path = resolved.map(c => c.selectedKey).filter((key): key is string => !!key);
|
||||
setPlayingPathIds(path);
|
||||
setPlayingPathServerId(currentTrack.serverId ?? null);
|
||||
const leafColIndex = resolved.length - 1;
|
||||
const leafRowIndex = resolved[leafColIndex].items.findIndex(it => it.id === currentTrack.id);
|
||||
const leafRowIndex = resolved[leafColIndex].items.findIndex(it => folderBrowserEntryKey(it) === trackIdentity);
|
||||
if (leafRowIndex >= 0) setKeyboardPos({ colIndex: leafColIndex, rowIndex: leafRowIndex });
|
||||
autoResolvedTrackRef.current = trackIdentity;
|
||||
if (hotkeyRevealRequested) {
|
||||
@@ -178,11 +204,11 @@ export function useFolderBrowserNowPlayingPath({
|
||||
}, [columns, currentTrack, trackIdentity, resolveColumnsForTrack, location.state, setColumns, setKeyboardPos]);
|
||||
|
||||
useEffect(() => {
|
||||
prevTrackIdRef.current = currentTrack?.id ?? null;
|
||||
}, [currentTrack?.id]);
|
||||
prevTrackKeyRef.current = trackIdentity;
|
||||
}, [trackIdentity]);
|
||||
|
||||
const isSelectedPathForCurrentTrack =
|
||||
isPlaying && !!currentTrack && playingPathServerId === currentTrack.serverId && playingPathIds[playingPathIds.length - 1] === currentTrack.id;
|
||||
isPlaying && !!currentTrack && playingPathServerId === currentTrack.serverId && playingPathIds[playingPathIds.length - 1] === trackIdentity;
|
||||
|
||||
return {
|
||||
playingPathIds,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Column, NavPos } from '@/features/folderBrowser/utils/folderBrowserHelpers';
|
||||
import {
|
||||
folderBrowserEntryKey,
|
||||
type Column, type NavPos,
|
||||
} from '@/features/folderBrowser/utils/folderBrowserHelpers';
|
||||
|
||||
interface Args {
|
||||
columns: Column[];
|
||||
@@ -42,10 +45,12 @@ export function useFolderBrowserScrolling({
|
||||
if (!wrapperRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
columns.forEach((col, colIndex) => {
|
||||
const selectedId = col.selectedId;
|
||||
if (!selectedId) return;
|
||||
const selectedKey = col.selectedKey;
|
||||
if (!selectedKey) return;
|
||||
const selectedRowIndex = col.items.findIndex(item => folderBrowserEntryKey(item) === selectedKey);
|
||||
if (selectedRowIndex < 0) return;
|
||||
const row = wrapperRef.current?.querySelector<HTMLElement>(
|
||||
`.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-item-id="${selectedId}"]`,
|
||||
`.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-row-index="${selectedRowIndex}"]`,
|
||||
);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
@@ -58,7 +63,7 @@ export function useFolderBrowserScrolling({
|
||||
}
|
||||
|
||||
const fallbackColIndex = [...columns]
|
||||
.map((c, i) => (c.selectedId ? i : -1))
|
||||
.map((c, i) => (c.selectedKey ? i : -1))
|
||||
.filter(i => i >= 0)
|
||||
.pop();
|
||||
const baseColIndex = keyboardPos?.colIndex ?? fallbackColIndex ?? Math.max(0, columns.length - 1);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Folder Browser feature — the Miller-column directory browser over the
|
||||
* saved servers' raw music folders (`getMusicFoldersForServer`/
|
||||
* `getMusicDirectoryForServer`/`getMusicIndexesForServer`): the FolderBrowser
|
||||
* page, its column component, the
|
||||
* selected servers' indexed music folders: the FolderBrowser page, its column
|
||||
* component, the
|
||||
* now-playing-path / scrolling / keyboard-nav hooks, and the pure path/entry
|
||||
* helpers. The page is lazy-loaded by the router via its deep path, so it is
|
||||
* not re-exported here; nothing else outside the feature consumes its modules.
|
||||
|
||||
@@ -4,20 +4,22 @@ import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const getMusicFoldersForServerMock = vi.fn();
|
||||
const getMusicIndexesForServerMock = vi.fn();
|
||||
const libraryScopeListArtistsMock = vi.fn();
|
||||
const libraryScopeArtistDetailMock = vi.fn();
|
||||
const libraryScopeAlbumDetailMock = vi.fn();
|
||||
const playTrackMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
||||
getMusicFoldersForServer: (...args: unknown[]) => getMusicFoldersForServerMock(...args),
|
||||
getMusicIndexesForServer: (...args: unknown[]) => getMusicIndexesForServerMock(...args),
|
||||
getMusicDirectoryForServer: vi.fn(),
|
||||
vi.mock('@/lib/api/library/scopeReads', () => ({
|
||||
libraryScopeListArtists: (...args: unknown[]) => libraryScopeListArtistsMock(...args),
|
||||
libraryScopeArtistDetail: (...args: unknown[]) => libraryScopeArtistDetailMock(...args),
|
||||
libraryScopeAlbumDetail: (...args: unknown[]) => libraryScopeAlbumDetailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
usePlayerStore: (selector: (state: object) => unknown) => selector({
|
||||
currentTrack: null,
|
||||
isPlaying: false,
|
||||
playTrack: vi.fn(),
|
||||
playTrack: playTrackMock,
|
||||
openContextMenu: vi.fn(),
|
||||
contextMenu: { isOpen: false },
|
||||
}),
|
||||
@@ -39,8 +41,10 @@ import FolderBrowser from './FolderBrowser';
|
||||
|
||||
describe('FolderBrowser', () => {
|
||||
beforeEach(() => {
|
||||
getMusicFoldersForServerMock.mockReset();
|
||||
getMusicIndexesForServerMock.mockReset();
|
||||
libraryScopeListArtistsMock.mockReset();
|
||||
libraryScopeArtistDetailMock.mockReset();
|
||||
libraryScopeAlbumDetailMock.mockReset();
|
||||
playTrackMock.mockReset();
|
||||
useAuthStore.setState({
|
||||
servers: [
|
||||
{ id: 'server-a', name: 'Alpha', url: 'https://alpha.example', username: 'u', password: 'p' },
|
||||
@@ -49,25 +53,82 @@ describe('FolderBrowser', () => {
|
||||
],
|
||||
activeServerId: 'server-a',
|
||||
libraryBrowseServerIds: ['server-a', 'server-b'],
|
||||
musicFoldersByServer: {
|
||||
'server-a': [{ id: 'music', name: 'Library A' }],
|
||||
'server-b': [{ id: 'music', name: 'Library B' }],
|
||||
'server-c': [{ id: 'music', name: 'Library C' }],
|
||||
},
|
||||
});
|
||||
getMusicFoldersForServerMock.mockImplementation(async (serverId: string) => [
|
||||
{ id: 'music', name: serverId === 'server-a' ? 'Library A' : 'Library B' },
|
||||
libraryScopeListArtistsMock.mockResolvedValue([
|
||||
{ serverId: 'server-a', id: 'artist-a', name: 'Artist A' },
|
||||
]);
|
||||
getMusicIndexesForServerMock.mockResolvedValue([]);
|
||||
libraryScopeArtistDetailMock.mockResolvedValue({
|
||||
artist: { serverId: 'server-a', id: 'artist-a', name: 'Artist A' },
|
||||
albums: [{ serverId: 'server-a', id: 'album-a', name: 'Album A', artist: 'Artist A', artistId: 'artist-a', syncedAt: 1, rawJson: {} }],
|
||||
tracks: [],
|
||||
});
|
||||
libraryScopeAlbumDetailMock.mockResolvedValue({
|
||||
album: { serverId: 'server-a', id: 'album-a', name: 'Album A', syncedAt: 1, rawJson: {} },
|
||||
tracks: [{ serverId: 'server-a', id: 'track-a', title: 'Track A', album: 'Album A', durationSec: 60, syncedAt: 1, rawJson: {} }],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows folders from every connected server in the root column', async () => {
|
||||
it('shows folders from every selected server in the root column', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<FolderBrowser />, { route: '/folders' });
|
||||
|
||||
const alphaLibrary = await screen.findByRole('button', { name: 'Alpha - Library A' });
|
||||
expect(alphaLibrary).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Beta - Library B' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Gamma - Library B' })).not.toBeInTheDocument();
|
||||
expect(getMusicFoldersForServerMock).toHaveBeenCalledWith('server-a');
|
||||
expect(getMusicFoldersForServerMock).toHaveBeenCalledWith('server-b');
|
||||
expect(screen.queryByRole('button', { name: 'Gamma - Library C' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(alphaLibrary);
|
||||
expect(getMusicIndexesForServerMock).toHaveBeenCalledWith('server-a', 'music');
|
||||
expect(libraryScopeListArtistsMock).toHaveBeenCalledWith('server-a', {
|
||||
scopes: [{ serverId: 'server-a', libraryId: 'music' }],
|
||||
sort: 'name',
|
||||
limit: 10_000,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Artist A' }));
|
||||
expect(libraryScopeArtistDetailMock).toHaveBeenCalledWith('server-a', {
|
||||
scopes: [{ serverId: 'server-a', libraryId: 'music' }],
|
||||
artistId: 'artist-a',
|
||||
serverId: 'server-a',
|
||||
includeTracks: false,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Album A' }));
|
||||
expect(libraryScopeAlbumDetailMock).toHaveBeenCalledWith('server-a', {
|
||||
scopes: [{ serverId: 'server-a', libraryId: 'music' }],
|
||||
albumId: 'album-a',
|
||||
serverId: 'server-a',
|
||||
});
|
||||
const track = await screen.findByRole('button', { name: 'Track A' });
|
||||
expect(track).toBeInTheDocument();
|
||||
await user.click(track);
|
||||
expect(playTrackMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'track-a', serverId: 'server-a', duration: 60 }),
|
||||
[expect.objectContaining({ id: 'track-a', serverId: 'server-a', duration: 60 })],
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps equal server-local artist ids distinct in one column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
libraryScopeListArtistsMock.mockResolvedValue([
|
||||
{ serverId: 'server-a', id: 'shared-artist-id', name: 'Artist from Alpha' },
|
||||
{ serverId: 'server-b', id: 'shared-artist-id', name: 'Artist from Beta' },
|
||||
]);
|
||||
|
||||
try {
|
||||
renderWithProviders(<FolderBrowser />, { route: '/folders' });
|
||||
await user.click(await screen.findByRole('button', { name: 'Alpha - Library A' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Artist from Alpha' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Artist from Beta' })).toBeInTheDocument();
|
||||
expect(consoleError.mock.calls.flat().join(' ')).not.toContain('Encountered two children with the same key');
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
getMusicDirectoryForServer,
|
||||
getMusicFoldersForServer,
|
||||
getMusicIndexesForServer,
|
||||
} from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicDirectoryEntry, SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
entryToAlbumIfPresent, entryToTrack,
|
||||
albumDtoToFolderEntry, artistDtoToFolderEntry, entryToAlbumIfPresent, entryToTrack,
|
||||
folderBrowserEntryKey,
|
||||
trackDtoToFolderEntry,
|
||||
type Column, type ColumnKind, type NavPos,
|
||||
} from '@/features/folderBrowser/utils/folderBrowserHelpers';
|
||||
import FolderBrowserColumn from '@/features/folderBrowser/components/FolderBrowserColumn';
|
||||
@@ -16,6 +13,11 @@ import { useFolderBrowserNowPlayingPath } from '@/features/folderBrowser/hooks/u
|
||||
import { useFolderBrowserScrolling } from '@/features/folderBrowser/hooks/useFolderBrowserScrolling';
|
||||
import { useFolderBrowserKeyboardNav } from '@/features/folderBrowser/hooks/useFolderBrowserKeyboardNav';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
libraryScopeAlbumDetail,
|
||||
libraryScopeArtistDetail,
|
||||
libraryScopeListArtists,
|
||||
} from '@/lib/api/library/scopeReads';
|
||||
|
||||
export default function FolderBrowser() {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,6 +37,8 @@ export default function FolderBrowser() {
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const libraryBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
|
||||
const musicFoldersByServer = useAuthStore(s => s.musicFoldersByServer);
|
||||
const libraryBrowseSelectionByServer = useAuthStore(s => s.libraryBrowseSelectionByServer);
|
||||
const visibleServers = useMemo(() => {
|
||||
const serverIds = libraryBrowseServerIds.length > 0
|
||||
? new Set(libraryBrowseServerIds)
|
||||
@@ -54,7 +58,7 @@ export default function FolderBrowser() {
|
||||
id: 'root',
|
||||
name: '',
|
||||
items: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
kind: 'roots',
|
||||
@@ -62,28 +66,21 @@ export default function FolderBrowser() {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColumns([placeholder]);
|
||||
Promise.all(visibleServers.map(async server => {
|
||||
try {
|
||||
const folders = await getMusicFoldersForServer(server.id);
|
||||
return folders.map(folder => ({
|
||||
id: `${server.id}\u0000${folder.id}`,
|
||||
const items: SubsonicDirectoryEntry[] = visibleServers.flatMap(server => {
|
||||
const folders = musicFoldersByServer[server.id] ?? [];
|
||||
const selectedIds = libraryBrowseSelectionByServer[server.id] ?? [];
|
||||
return folders
|
||||
.filter(folder => selectedIds.length === 0 || selectedIds.includes(folder.id))
|
||||
.map(folder => ({
|
||||
id: folder.id,
|
||||
sourceId: folder.id,
|
||||
serverId: server.id,
|
||||
title: `${server.name} - ${folder.name}`,
|
||||
isDir: true,
|
||||
}));
|
||||
} catch {
|
||||
return [] as SubsonicDirectoryEntry[];
|
||||
}
|
||||
}))
|
||||
.then(groups => {
|
||||
const items = groups.flat();
|
||||
setColumns([{ ...placeholder, items, loading: false }]);
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns([{ ...placeholder, items: [], loading: false, error: true }]);
|
||||
});
|
||||
}, [visibleServers]);
|
||||
});
|
||||
setColumns([{ ...placeholder, items, loading: false }]);
|
||||
}, [libraryBrowseSelectionByServer, musicFoldersByServer, visibleServers]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
@@ -121,9 +118,9 @@ export default function FolderBrowser() {
|
||||
const preferredRowIndex = useCallback((colIndex: number): number => {
|
||||
const items = filteredItemsByCol[colIndex] ?? [];
|
||||
if (items.length === 0) return -1;
|
||||
const selectedId = columns[colIndex]?.selectedId;
|
||||
if (selectedId) {
|
||||
const selectedIdx = items.findIndex(it => it.id === selectedId);
|
||||
const selectedKey = columns[colIndex]?.selectedKey;
|
||||
if (selectedKey) {
|
||||
const selectedIdx = items.findIndex(it => folderBrowserEntryKey(it) === selectedKey);
|
||||
if (selectedIdx >= 0) return selectedIdx;
|
||||
}
|
||||
return 0;
|
||||
@@ -147,7 +144,7 @@ export default function FolderBrowser() {
|
||||
const safeRowIndex = Math.min(Math.max(0, rowIndex), targetItems.length - 1);
|
||||
const targetItem = targetItems[safeRowIndex];
|
||||
setColumns(prev =>
|
||||
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)),
|
||||
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedKey: folderBrowserEntryKey(targetItem) } : c)),
|
||||
);
|
||||
setKeyboardPos({
|
||||
colIndex: targetColIndex,
|
||||
@@ -189,31 +186,43 @@ export default function FolderBrowser() {
|
||||
const serverId = item.serverId ?? columns[colIndex]?.serverId;
|
||||
if (!serverId) return;
|
||||
clearFiltersRightOf(colIndex);
|
||||
const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory';
|
||||
const scopes = colIndex === 0
|
||||
? [{ serverId, libraryId: item.sourceId ?? item.id }]
|
||||
: columns[colIndex]?.scopes ?? [];
|
||||
const nextKind: ColumnKind = colIndex === 0
|
||||
? 'artists'
|
||||
: columns[colIndex]?.kind === 'artists'
|
||||
? 'albums'
|
||||
: 'tracks';
|
||||
setColumns(prev => [
|
||||
...prev.slice(0, colIndex + 1).map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
i === colIndex ? { ...c, selectedKey: folderBrowserEntryKey(item) } : c,
|
||||
),
|
||||
{
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
items: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
kind: nextKind,
|
||||
serverId,
|
||||
scopes,
|
||||
},
|
||||
]);
|
||||
|
||||
const fetchItems =
|
||||
colIndex === 0
|
||||
? getMusicIndexesForServer(serverId, item.sourceId ?? item.id)
|
||||
: getMusicDirectoryForServer(serverId, item.id).then(d => d.child);
|
||||
const fetchItems = colIndex === 0
|
||||
? libraryScopeListArtists(serverId, { scopes, sort: 'name', limit: 10_000 })
|
||||
.then(artists => artists.map(artistDtoToFolderEntry))
|
||||
: columns[colIndex]?.kind === 'artists'
|
||||
? libraryScopeArtistDetail(serverId, { scopes, artistId: item.id, serverId, includeTracks: false })
|
||||
.then(response => response.albums.map(albumDtoToFolderEntry))
|
||||
: libraryScopeAlbumDetail(serverId, { scopes, albumId: item.id, serverId })
|
||||
.then(response => response.tracks.map(trackDtoToFolderEntry));
|
||||
|
||||
fetchItems
|
||||
.then(items => {
|
||||
const serverItems = items.map(entry => ({ ...entry, serverId }));
|
||||
const serverItems = items.map(entry => ({ ...entry, serverId: entry.serverId ?? serverId }));
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
@@ -236,11 +245,11 @@ export default function FolderBrowser() {
|
||||
const handleFileClick = useCallback(
|
||||
(colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
setColumns(prev =>
|
||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: item.id } : c)),
|
||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedKey: folderBrowserEntryKey(item) } : c)),
|
||||
);
|
||||
const path = [
|
||||
...columns.slice(0, colIndex).map(c => c.selectedId).filter((id): id is string => !!id),
|
||||
item.id,
|
||||
...columns.slice(0, colIndex).map(c => c.selectedKey).filter((key): key is string => !!key),
|
||||
folderBrowserEntryKey(item),
|
||||
];
|
||||
setPlayingPathIds(path);
|
||||
const visibleItems = filteredItemsByCol[colIndex] ?? columns[colIndex]?.items ?? [];
|
||||
@@ -250,19 +259,20 @@ export default function FolderBrowser() {
|
||||
[columns, filteredItemsByCol, playTrack, setPlayingPathIds],
|
||||
);
|
||||
|
||||
const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => {
|
||||
const setSelectedInColumn = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
const itemKey = folderBrowserEntryKey(item);
|
||||
setColumns(prev => {
|
||||
const prevSelectedId = prev[colIndex]?.selectedId ?? null;
|
||||
if (prevSelectedId !== itemId) {
|
||||
const prevSelectedKey = prev[colIndex]?.selectedKey ?? null;
|
||||
if (prevSelectedKey !== itemKey) {
|
||||
clearFiltersRightOf(colIndex);
|
||||
}
|
||||
return prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c));
|
||||
return prev.map((c, i) => (i === colIndex ? { ...c, selectedKey: itemKey } : c));
|
||||
});
|
||||
}, [clearFiltersRightOf]);
|
||||
|
||||
const clearSelectedInColumn = useCallback((colIndex: number) => {
|
||||
setColumns(prev =>
|
||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: null } : c)),
|
||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedKey: null } : c)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -279,7 +289,7 @@ export default function FolderBrowser() {
|
||||
const openContextMenuForEntry = useCallback(
|
||||
(col: Column, item: SubsonicDirectoryEntry, x: number, y: number) => {
|
||||
if (item.isDir) {
|
||||
if (col.kind === 'indexes') {
|
||||
if (col.kind === 'artists') {
|
||||
const artist: SubsonicArtist = {
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
@@ -333,7 +343,7 @@ export default function FolderBrowser() {
|
||||
const activeColIndex = useMemo(() => {
|
||||
if (keyboardPos) return keyboardPos.colIndex;
|
||||
const fromSelection = [...columns]
|
||||
.map((c, i) => (c.selectedId ? i : -1))
|
||||
.map((c, i) => (c.selectedKey ? i : -1))
|
||||
.filter(i => i >= 0);
|
||||
if (fromSelection.length > 0) return fromSelection[fromSelection.length - 1];
|
||||
return Math.max(0, columns.length - 1);
|
||||
@@ -398,7 +408,7 @@ export default function FolderBrowser() {
|
||||
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
|
||||
if (nextItem) {
|
||||
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
|
||||
else setSelectedInColumn(colIndex, nextItem.id);
|
||||
else setSelectedInColumn(colIndex, nextItem);
|
||||
}
|
||||
setKeyboardPos({ colIndex, rowIndex });
|
||||
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||
|
||||
@@ -1,21 +1,82 @@
|
||||
import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicDirectoryEntry } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import type { LibraryAlbumDto, LibraryArtistDto, LibraryTrackDto } from '@/lib/api/library/dto';
|
||||
|
||||
export type ColumnKind = 'roots' | 'indexes' | 'directory';
|
||||
export type ColumnKind = 'roots' | 'artists' | 'albums' | 'tracks';
|
||||
export type NavPos = { colIndex: number; rowIndex: number };
|
||||
|
||||
export type Column = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: SubsonicDirectoryEntry[];
|
||||
selectedId: string | null;
|
||||
selectedKey: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
kind: ColumnKind;
|
||||
serverId?: string;
|
||||
scopes?: { serverId: string; libraryId: string }[];
|
||||
};
|
||||
|
||||
/** Server APIs only guarantee entity IDs are unique within one server. */
|
||||
export function folderBrowserEntryKey(entry: Pick<SubsonicDirectoryEntry, 'id' | 'serverId'>): string {
|
||||
return `${entry.serverId ?? ''}\u0000${entry.id}`;
|
||||
}
|
||||
|
||||
export function selectedFolderBrowserEntry(column: Column): SubsonicDirectoryEntry | undefined {
|
||||
return column.selectedKey
|
||||
? column.items.find(item => folderBrowserEntryKey(item) === column.selectedKey)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function artistDtoToFolderEntry(artist: LibraryArtistDto): SubsonicDirectoryEntry {
|
||||
return {
|
||||
id: artist.id,
|
||||
serverId: artist.serverId,
|
||||
title: artist.name,
|
||||
artistId: artist.id,
|
||||
isDir: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function albumDtoToFolderEntry(album: LibraryAlbumDto): SubsonicDirectoryEntry {
|
||||
return {
|
||||
id: album.id,
|
||||
serverId: album.serverId,
|
||||
title: album.name,
|
||||
artist: album.artist ?? undefined,
|
||||
artistId: album.artistId ?? undefined,
|
||||
album: album.name,
|
||||
albumId: album.id,
|
||||
coverArt: album.coverArtId ?? undefined,
|
||||
year: album.year ?? undefined,
|
||||
genre: album.genre ?? undefined,
|
||||
isDir: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function trackDtoToFolderEntry(track: LibraryTrackDto): SubsonicDirectoryEntry {
|
||||
return {
|
||||
id: track.id,
|
||||
serverId: track.serverId,
|
||||
title: track.title,
|
||||
artist: track.artist ?? undefined,
|
||||
artistId: track.artistId ?? undefined,
|
||||
album: track.album,
|
||||
albumId: track.albumId ?? undefined,
|
||||
coverArt: track.coverArtId ?? undefined,
|
||||
duration: track.durationSec,
|
||||
track: track.trackNumber ?? undefined,
|
||||
year: track.year ?? undefined,
|
||||
bitRate: track.bitRate ?? undefined,
|
||||
suffix: track.suffix ?? undefined,
|
||||
genre: track.genre ?? undefined,
|
||||
starred: track.starredAt != null ? new Date(track.starredAt).toISOString() : undefined,
|
||||
userRating: track.userRating ?? undefined,
|
||||
isDir: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */
|
||||
export function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null {
|
||||
if (!item.isDir) return null;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
import { getCachedTrack } from '@/features/playback/store/queueTrackResolver';
|
||||
|
||||
beforeEach(() => {
|
||||
resetPlayerStore();
|
||||
@@ -58,6 +59,14 @@ describe('enqueue', () => {
|
||||
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('keeps the supplied duration available to queue rows', () => {
|
||||
const folderTrack = makeTrack({ id: 'folder-track', duration: 60, serverId: 'folder.example' });
|
||||
usePlayerStore.getState().enqueue([folderTrack], true);
|
||||
|
||||
const ref = usePlayerStore.getState().queueItems[0];
|
||||
expect(getCachedTrack(ref!)).toEqual(expect.objectContaining({ duration: 60 }));
|
||||
});
|
||||
|
||||
it('appends multiple tracks in order', () => {
|
||||
const tracks = makeTracks(3);
|
||||
usePlayerStore.getState().enqueue(tracks, true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
import { getTimelineSessionHistorySnapshot } from '@/features/playback/store/timelineSessionHistory';
|
||||
import { getCachedTrack } from '@/features/playback/store/queueTrackResolver';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
@@ -33,4 +34,19 @@ describe('timeline history on queue replace', () => {
|
||||
const history = getTimelineSessionHistorySnapshot();
|
||||
expect(history.some(h => h.trackId === first[0]!.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('seeds an explicit replacement queue with its supplied duration', async () => {
|
||||
const folderTrack = {
|
||||
id: 'folder-track', title: 'Folder track', artist: 'Artist', album: 'Album', albumId: 'album',
|
||||
duration: 60, serverId: 'x.test',
|
||||
};
|
||||
|
||||
usePlayerStore.getState().playTrack(folderTrack, [folderTrack], true, true);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const ref = usePlayerStore.getState().queueItems[0];
|
||||
expect(ref).toEqual({ serverId: 'x.test', trackId: 'folder-track' });
|
||||
expect(getCachedTrack(ref!)).toEqual(expect.objectContaining({ duration: 60 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,8 +39,6 @@ vi.mock('@/lib/library/patchOnUse', () => ({
|
||||
import {
|
||||
getAlbumForServer,
|
||||
getAlbumListForServer,
|
||||
getMusicDirectoryForServer,
|
||||
getMusicIndexesForServer,
|
||||
getRandomSongsForServer,
|
||||
getSongForServer,
|
||||
} from '@/lib/api/subsonicLibrary';
|
||||
@@ -109,19 +107,4 @@ describe('explicit-server library wrappers', () => {
|
||||
await expect(getSongForServer('srv-detail', 'song-1')).resolves.toEqual({ ...song, serverId: 'srv-detail' });
|
||||
});
|
||||
|
||||
it('loads directory trees against the requested server', async () => {
|
||||
apiForServerMock
|
||||
.mockResolvedValueOnce({ indexes: { index: [{ name: 'A', artist: [{ id: 'artist-1', name: 'Artist' }] }] } })
|
||||
.mockResolvedValueOnce({ directory: { id: 'artist-1', name: 'Artist', child: { id: 'song-1', title: 'Song', isDir: false } } });
|
||||
|
||||
await expect(getMusicIndexesForServer('srv-folder', 'folder-1')).resolves.toEqual([
|
||||
{ id: 'artist-1', title: 'Artist', isDir: true },
|
||||
]);
|
||||
await expect(getMusicDirectoryForServer('srv-folder', 'artist-1')).resolves.toMatchObject({
|
||||
id: 'artist-1',
|
||||
child: [{ id: 'song-1', title: 'Song', isDir: false }],
|
||||
});
|
||||
expect(apiForServerMock).toHaveBeenNthCalledWith(1, 'srv-folder', 'getIndexes.view', { musicFolderId: 'folder-1' });
|
||||
expect(apiForServerMock).toHaveBeenNthCalledWith(2, 'srv-folder', 'getMusicDirectory.view', { id: 'artist-1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,26 +51,6 @@ export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDi
|
||||
return mapMusicIndexes(data);
|
||||
}
|
||||
|
||||
export async function getMusicDirectoryForServer(serverId: string, id: string): Promise<SubsonicDirectory> {
|
||||
const data = await apiForServer<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
|
||||
serverId,
|
||||
'getMusicDirectory.view',
|
||||
{ id },
|
||||
);
|
||||
const dir = data.directory;
|
||||
const child = !dir.child ? [] : Array.isArray(dir.child) ? dir.child : [dir.child];
|
||||
return { id: dir.id, parent: dir.parent, name: dir.name, child };
|
||||
}
|
||||
|
||||
export async function getMusicIndexesForServer(serverId: string, musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
|
||||
const data = await apiForServer<{ indexes: { index?: { name: string; artist?: { id: string; name: string; coverArt?: string } | { id: string; name: string; coverArt?: string }[] } | { name: string; artist?: { id: string; name: string; coverArt?: string } | { id: string; name: string; coverArt?: string }[] }[] } }>(
|
||||
serverId,
|
||||
'getIndexes.view',
|
||||
{ musicFolderId },
|
||||
);
|
||||
return mapMusicIndexes(data);
|
||||
}
|
||||
|
||||
function mapMusicFolders(data: { musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }): SubsonicMusicFolder[] {
|
||||
const raw = data.musicFolders?.musicFolder;
|
||||
if (!raw) return [];
|
||||
|
||||
Reference in New Issue
Block a user