fix(library): preserve folder browse identities and durations

This commit is contained in:
cucadmuh
2026-07-17 23:17:34 +03:00
parent 54fd143b46
commit 9d4227052f
17 changed files with 634 additions and 170 deletions
@@ -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 effects 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);
+2 -3
View File
@@ -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' });
});
});
-20
View File
@@ -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 [];