feat(offline): unify local playback, offline library, and favorites sync (#1008)

* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
This commit is contained in:
cucadmuh
2026-06-06 22:43:03 +03:00
committed by GitHub
parent 40db6b08d2
commit 2d3c723a6e
228 changed files with 11551 additions and 1534 deletions
+14
View File
@@ -40,6 +40,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Offline — unified local playback, library index join, and favorites sync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
## Changed
### Dependencies — npm and Rust refresh
+1
View File
@@ -4266,6 +4266,7 @@ dependencies = [
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"psysonic-library",
"reqwest",
"serde",
"serde_json",
+2 -1
View File
@@ -6,6 +6,7 @@ resolver = "2"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -18,7 +19,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license = ""
license.workspace = true
repository = ""
default-run = "psysonic"
edition.workspace = true
+2 -1
View File
@@ -21,6 +21,8 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -38,5 +40,4 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file(
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
}
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
pub async fn enqueue_offline_library_analysis_from_file(
app: &tauri::AppHandle,
server_index_key: &str,
library_server_id: &str,
track_id: &str,
file_path: &std::path::Path,
explicit_priority: Option<AnalysisBackfillPriority>,
) -> Result<(), String> {
use tokio::io::AsyncReadExt;
use crate::track_analysis_plan::plan_track_analysis_offline_library;
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| e.to_string())?;
let mut prefix = vec![0u8; 16384];
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
prefix.truncate(n);
if prefix.is_empty() {
return Ok(());
}
let content_hash = analysis_cache::md5_first_16kb(&prefix);
let plan = plan_track_analysis_offline_library(
app,
&[server_index_key, library_server_id],
library_server_id,
track_id,
&content_hash,
);
if !plan.any() {
crate::app_deprintln!(
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
track_id,
server_index_key,
library_server_id,
);
return Ok(());
}
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
let format_hint = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| !e.is_empty());
let priority = explicit_priority.unwrap_or_else(|| {
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
});
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
app,
cache_server_id: server_index_key,
enrichment_server_id: library_server_id,
track_id,
bytes: &bytes,
format_hint: format_hint.as_deref(),
priority,
plan,
fetch_ms: 0,
})
.await?;
Ok(())
}
struct OfflineLibraryAnalysisEnqueue<'a> {
app: &'a tauri::AppHandle,
cache_server_id: &'a str,
enrichment_server_id: &'a str,
track_id: &'a str,
bytes: &'a [u8],
format_hint: Option<&'a str>,
priority: AnalysisBackfillPriority,
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
fetch_ms: u64,
}
async fn enqueue_track_analysis_offline_library_with_plan(
args: OfflineLibraryAnalysisEnqueue<'_>,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if args.bytes.is_empty() || !args.plan.any() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
if args.plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
args.track_id,
content_hash,
args.plan.need_waveform,
args.plan.need_loudness,
args.plan.enrichment.any()
);
submit_analysis_cpu_seed(
args.app.clone(),
args.cache_server_id.to_string(),
args.track_id.to_string(),
args.bytes.to_vec(),
args.format_hint.map(str::to_string),
args.priority,
args.fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if args.plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
args.track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
args.app,
args.enrichment_server_id,
args.track_id,
args.bytes,
analysis_emits_ui_events(args.priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: args.cache_server_id.to_string(),
track_id: args.track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
@@ -103,6 +116,32 @@ fn cache_gaps(
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
@@ -117,6 +156,45 @@ fn enrichment_plan(
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
@@ -194,4 +272,14 @@ mod tests {
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -3,6 +3,7 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -3,6 +3,7 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim();
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
trimmed
let cleaned: String = trimmed
.chars()
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
.collect()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
@@ -256,6 +277,13 @@ mod tests {
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
@@ -5,6 +5,7 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
@@ -0,0 +1,364 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -3,6 +3,7 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -3,6 +3,7 @@ name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album(
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
}
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
server_id: String,
songs: Vec<serde_json::Value>,
) -> Result<u32, String> {
use crate::sync::subsonic_song_to_track_row;
use psysonic_integration::subsonic::Song;
if songs.is_empty() {
return Ok(0);
}
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let repo = TrackRepository::new(&runtime.store);
let mut rows = Vec::with_capacity(songs.len());
for raw in songs {
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
rows.push(subsonic_song_to_track_row(
&server_id,
&song,
&raw,
synced_at,
None,
));
}
repo.upsert_batch(&rows)?;
Ok(rows.len() as u32)
}
#[tauri::command]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
@@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> {
})
}
/// All live rows for a Subsonic track id (any server). Used when legacy offline
/// folders name the server by URL index key rather than profile UUID.
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
let rows = stmt
.query_map(params![track_id], row_to_track_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
})
}
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
/// preserve their order in the result; unknown / deleted refs
/// are silently dropped (frontend reads `tracks.length` against
@@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> {
})
}
/// Legacy offline rows keyed by library `server_id` (index key scope).
pub fn list_offline_local_paths(
&self,
server_id: &str,
) -> Result<Vec<(String, String, Option<String>)>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
)?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
@@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-library = { path = "../psysonic-library" }
psysonic-analysis = { path = "../psysonic-analysis" }
psysonic-audio = { path = "../psysonic-audio" }
+100 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
total
}
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
files
}
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
let mut current = path.parent()?;
loop {
match current.file_name().and_then(|s| s.to_str()) {
Some("cache") | Some("library") | Some("favorites") => {
return Some(current.to_path_buf());
}
_ => current = current.parent()?,
}
}
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let boundary_norm = normalize_path_for_prefix(boundary);
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(boundary) {
let dir_norm = normalize_path_for_prefix(&dir);
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
break;
}
match std::fs::read_dir(&dir) {
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
}
}
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
pub fn prune_empty_subdirs_under(root: &Path) {
if !root.is_dir() {
return;
}
let children: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
for child in children {
prune_empty_subdirs_under(&child);
let is_empty = std::fs::read_dir(&child)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = std::fs::remove_dir(&child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,6 +153,17 @@ mod tests {
assert!(path.exists(), "boundary dir must never be removed");
}
#[test]
fn collect_regular_files_under_lists_nested_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
let sub = dir.path().join("Artist/Album");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
let files = collect_regular_files_under(dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
let root = tempfile::tempdir().unwrap();
@@ -98,4 +175,25 @@ mod tests {
assert!(!child.exists(), "empty leaf should be pruned");
assert!(parent.exists(), "non-empty parent must stay");
}
#[test]
fn local_tier_boundary_from_path_finds_cache_root() {
let root = tempfile::tempdir().unwrap();
let track = root
.path()
.join("vol/media/cache/my.server/Artist/Album/track.flac");
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
let boundary = local_tier_boundary_from_path(&track).unwrap();
assert_eq!(boundary, root.path().join("vol/media/cache"));
}
#[test]
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
std::fs::create_dir_all(&cache).unwrap();
prune_empty_subdirs_under(&root.path().join("cache"));
assert!(!cache.exists());
assert!(root.path().join("cache").exists(), "tier root preserved");
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
pub mod local;
+14
View File
@@ -715,6 +715,7 @@ pub fn run() {
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
@@ -775,6 +776,19 @@ pub fn run() {
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::download_track_local,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
+24
View File
@@ -473,6 +473,18 @@ export function libraryGetTrack(
.then(track => (track ? { ...track, serverId } : track));
}
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
export function libraryUpsertSongsFromApi(
serverId: string,
songs: unknown[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
}
/** `library_get_tracks_batch` cap (spec §7.1). */
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
@@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
})));
}
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
if (refs.length === 0) return [];
const out: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
+5
View File
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
default: { get: vi.fn() },
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: () => true,
shouldAttemptSubsonicForServer: () => true,
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
+15
View File
@@ -1,4 +1,8 @@
import { useAuthStore } from '../store/authStore';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
RandomSongsFilters,
@@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
@@ -71,6 +76,7 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
@@ -212,6 +218,7 @@ export async function getAlbumListForServer(
offset = 0,
extra: Record<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForServer(serverId)) return [];
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
type,
size,
@@ -224,6 +231,7 @@ export async function getAlbumListForServer(
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForActiveServer()) return null;
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
@@ -233,6 +241,7 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
try {
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
return data.song ?? null;
@@ -242,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForActiveServer()) {
throw new Error('Subsonic unavailable');
}
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -251,6 +263,9 @@ export async function getAlbumForServer(
serverId: string,
id: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
+21 -1
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { api } from './subsonicClient';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer } from './subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
@@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
@@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('../utils/offline/pinnedOfflineSync')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
+3
View File
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: apiForServerMock,
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
describe('subsonicScrobble', () => {
beforeEach(() => {
+2
View File
@@ -1,6 +1,7 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
async function scrobbleOnServer(
serverId: string,
@@ -8,6 +9,7 @@ async function scrobbleOnServer(
submission: boolean,
time?: number,
): Promise<void> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
const params: Record<string, unknown> = { id, submission };
if (time !== undefined) params.time = time;
await apiForServer(serverId, 'scrobble.view', params);
+55 -9
View File
@@ -1,4 +1,4 @@
import { api, libraryFilterParams } from './subsonicClient';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
@@ -15,6 +15,17 @@ import type {
SubsonicSong,
} from './subsonicTypes';
function parseStarred2Response(data: {
starred2?: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}): StarredResults {
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function getStarred(): Promise<StarredResults> {
const data = await api<{
starred2: {
@@ -23,21 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
return parseStarred2Response(data);
}
/** Starred entities for an explicit saved server (not necessarily the active one). */
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
const data = await apiForServer<{
starred2: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
return parseStarred2Response(data);
}
function resolveStarServerId(meta?: StarPatchMeta): string | null {
return meta?.serverId ?? useAuthStore.getState().activeServerId;
}
async function starApi(
serverId: string | null | undefined,
endpoint: string,
params: Record<string, string>,
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) throw new Error('No server for star API');
if (sid === useAuthStore.getState().activeServerId) {
await api(endpoint, params);
} else {
await apiForServer(sid, endpoint, params);
}
}
export async function star(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
const serverId = useAuthStore.getState().activeServerId;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'star.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -45,19 +85,22 @@ export async function star(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
.catch(() => {});
}
export async function unstar(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
const serverId = useAuthStore.getState().activeServerId;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'unstar.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -65,6 +108,9 @@ export async function unstar(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
.catch(() => {});
}
export async function setRating(id: string, rating: number): Promise<void> {
+6
View File
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
displayArtist?: string;
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
discTitles?: SubsonicDiscTitle[];
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicDiscTitle {
@@ -90,6 +92,8 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
@@ -144,6 +148,8 @@ export interface SubsonicArtist {
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicGenre {
+17 -2
View File
@@ -38,7 +38,11 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
import { useOrbitGuest } from '../hooks/useOrbitGuest';
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
import {
hasOfflineBrowsingContent,
} from '../utils/offline/favoritesOfflineBrowse';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
@@ -106,7 +110,12 @@ export function AppShell() {
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const activeServerId = useAuthStore(s => s.activeServerId);
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId));
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
const hasManualOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const hasOfflineContent = hasOfflineBrowsingContent(offlineAlbums);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
@@ -134,7 +143,13 @@ export function AppShell() {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname, location.state]);
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
useOfflineAutoNav(
connStatus,
hasManualOfflineContent,
favoritesOfflineBrowse,
location.pathname,
navigate,
);
useEffect(() => {
initializeFromServerQueue();
+28
View File
@@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
@@ -98,6 +104,28 @@ export default function MainApp() {
return initHotCachePrefetch();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
void (async () => {
await runLegacyOfflineFileMigration();
const servers = useAuthStore.getState().servers;
for (const server of servers) {
await reconcileLibraryTierForServer(server.id);
}
scheduleResumeIncompleteOfflinePins();
})();
const stopInvalidation = initLocalPlaybackInvalidation();
const stopFavoritesSync = initFavoritesOfflineSync();
const stopPinnedOfflineSync = initPinnedOfflineSync();
const stopOfflineResume = initResumeIncompleteOfflinePins();
return () => {
stopInvalidation();
stopFavoritesSync();
stopPinnedOfflineSync();
stopOfflineResume();
};
}, [migrationReady, serverIdsKey]);
useEffect(() => {
if (!migrationReady) return;
useGlobalShortcutsStore.getState().registerAll();
+21 -11
View File
@@ -7,8 +7,9 @@ import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
@@ -23,6 +24,8 @@ import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -63,21 +66,28 @@ function AlbumCard({
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const offlineServerId = album.serverId ?? activeServerId;
const localEntries = useLocalPlaybackStore(s => s.entries);
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
const albumLinkQuery = useMemo(
() => appendServerQuery(linkQuery, album.serverId),
[linkQuery, album.serverId],
);
void localEntries;
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
const coverServerScope = useMemo(
() => coverServerScopeForServerId(album.serverId),
[album.serverId],
);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
@@ -88,7 +98,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: linkQuery });
navigateToAlbum(album.id, { search: albumLinkQuery });
};
return (
+19 -1
View File
@@ -74,7 +74,7 @@ interface AlbumHeaderProps {
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
@@ -300,6 +300,15 @@ export default function AlbumHeader({
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'queued' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
</button>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
@@ -400,6 +409,15 @@ export default function AlbumHeader({
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'queued' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
+1 -1
View File
@@ -251,7 +251,7 @@ export default function AlbumRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
+10 -3
View File
@@ -1,11 +1,13 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React from 'react';
import React, { useMemo } from 'react';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface Props {
artist: SubsonicArtist;
@@ -18,12 +20,17 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const coverServerScope = useMemo(
() => coverServerScopeForServerId(artist.serverId),
[artist.serverId],
);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
return (
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
>
<div className="artist-card-avatar">
{coverRef ? (
+1 -1
View File
@@ -117,7 +117,7 @@ export default function ArtistRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<ArtistCardLocal
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
+12
View File
@@ -7,7 +7,10 @@ import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -15,6 +18,12 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
const { status: connStatus } = useConnectionStatus();
const isServerOffline = connStatus === 'disconnected';
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const luckyMixBase = useLuckyMixAvailable();
@@ -29,6 +38,9 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(cfg.id, favoritesOfflineBrowse)) {
return false;
}
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
+1 -1
View File
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
// Scrubber touch/mouse drag
+135
View File
@@ -0,0 +1,135 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../utils/format/formatBytes';
const OPEN_DELAY_MS = 450;
interface Props {
label: string;
totalBytes: number | null;
libraryBytes: number | null;
favoritesBytes: number | null;
}
export function OfflineLibraryDiskStat({
label,
totalBytes,
libraryBytes,
favoritesBytes,
}: Props) {
const { t } = useTranslation();
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const openTimerRef = useRef<number | null>(null);
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
const clearOpenTimer = () => {
if (openTimerRef.current !== null) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
};
const scheduleOpen = () => {
if (!breakdownReady) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
};
const close = () => {
clearOpenTimer();
setOpen(false);
};
const breakdownAriaLabel = breakdownReady
? [
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
].join('. ')
: undefined;
useLayoutEffect(() => {
if (!open || !anchorRef.current || !popoverRef.current) {
setStyle({ opacity: 0 });
return;
}
const anchor = anchorRef.current.getBoundingClientRect();
const box = popoverRef.current.getBoundingClientRect();
const GAP = 8;
const MARGIN = 8;
let top = anchor.bottom + GAP;
let left = anchor.left + anchor.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
setStyle({ opacity: 1, top, left });
}, [open, libraryBytes, favoritesBytes]);
useEffect(() => () => clearOpenTimer(), []);
return (
<>
<div
ref={anchorRef}
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
aria-live="polite"
aria-label={breakdownAriaLabel}
onMouseEnter={scheduleOpen}
onMouseLeave={close}
onFocus={scheduleOpen}
onBlur={close}
tabIndex={breakdownReady ? 0 : undefined}
>
<span className="offline-library-disk-label">{label}</span>
<span className="offline-library-disk-value">
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
</span>
</div>
{open && breakdownReady && createPortal(
<div
ref={popoverRef}
className="offline-library-disk-breakdown"
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
role="tooltip"
onMouseEnter={() => {
clearOpenTimer();
setOpen(true);
}}
onMouseLeave={close}
>
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
aria-hidden
>
<HardDriveDownload size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(libraryBytes)}
</span>
</div>
<div className="offline-library-disk-breakdown-divider" aria-hidden />
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
aria-hidden
>
<Heart size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(favoritesBytes)}
</span>
</div>
</div>,
document.body,
)}
</>
);
}
+1 -1
View File
@@ -136,7 +136,7 @@ export default function PlayerBar() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
+7 -5
View File
@@ -33,6 +33,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
import { QueueList } from './queuePanel/QueueList';
import { QueueTabBar } from './queuePanel/QueueTabBar';
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -170,11 +171,12 @@ function QueuePanelHostOrSolo() {
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = async () => {
if (queueItems.length === 0) return;
const exportTrackIds = activeServerQueueTrackIds(queueItems);
if (exportTrackIds.length === 0) return;
if (activePlaylist) {
setSaveState('saving');
try {
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
await updatePlaylist(activePlaylist.id, exportTrackIds);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500);
} catch (e) {
@@ -196,7 +198,8 @@ function QueuePanelHostOrSolo() {
};
const handleCopyQueueShare = async () => {
if (queueItems.length === 0) {
const ids = activeServerQueueTrackIds(queueItems);
if (ids.length === 0) {
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
return;
}
@@ -207,7 +210,6 @@ function QueuePanelHostOrSolo() {
if (!active) return;
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = queueItems.map(r => r.trackId);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
@@ -381,7 +383,7 @@ function QueuePanelHostOrSolo() {
onSave={async (name) => {
try {
const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false);
} catch (e) {
+30 -4
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
@@ -23,7 +24,10 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
import SidebarNavBody from './sidebar/SidebarNavBody';
@@ -41,8 +45,16 @@ export default function Sidebar({
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineJobStore(s => s.jobs);
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
const pinQueue = useOfflineJobStore(s => s.pinQueue);
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const activePin = pinQueue.find(p => p.status === 'downloading')
?? pinQueue.find(p => p.status === 'queued');
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
const cancelAllDownloads = () => {
clearOfflinePinTasks();
cancelAllDownloadsStore();
};
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
const syncJobDone = useDeviceSyncJobStore(s => s.done);
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
@@ -61,7 +73,12 @@ export default function Sidebar({
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const { status: connStatus } = useConnectionStatus();
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const isServerOffline = connStatus === 'disconnected';
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
@@ -101,13 +118,20 @@ export default function Sidebar({
libraryItemsForReorder.filter(c => {
if (!c.visible) return false;
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(c.id, favoritesOfflineBrowse)) {
return false;
}
return true;
}),
[libraryItemsForReorder, luckyMixAvailable],
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible),
[systemItemsForReorder],
() => systemItemsForReorder.filter(c => {
if (!c.visible) return false;
if (isServerOffline) return false;
return true;
}),
[systemItemsForReorder, isServerOffline],
);
const sidebarItemsRef = useRef(sidebarItems);
@@ -231,6 +255,8 @@ export default function Sidebar({
nowPlayingAtTop={nowPlayingAtTop}
hasOfflineContent={hasOfflineContent}
activeJobsCount={activeJobs.length}
activePinName={activePin?.albumName ?? null}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
@@ -7,8 +7,8 @@ import {
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
import { useOfflineStore } from '../../store/offlineStore';
import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useAuthStore } from '../../store/authStore';
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
import { useIsMobile } from '../../hooks/useIsMobile';
import { ArtistHeroCover } from '../../cover/artistHero';
import { useCoverLightboxSrc } from '../../cover/lightbox';
@@ -55,8 +55,13 @@ export default function ArtistDetailHero({
const isMobile = useIsMobile();
const imageInputRef = useRef<HTMLInputElement>(null);
const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const artistAlbumIds = useMemo(() => albums.map(a => a.id), [albums]);
const { status: artistOfflineStatus, progress: artistOfflineProgress } = useArtistOfflineState(
id ?? '',
activeServerId,
artistAlbumIds,
);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
@@ -217,28 +222,51 @@ export default function ArtistDetailHero({
<Share2 size={16} />
</button>
)}
{albums.length > 0 && (() => {
const progress = id ? bulkProgress[id] : undefined;
const isDone = progress && progress.done === progress.total;
const isDownloading = progress && !isDone;
return (
<button
className="btn btn-surface"
disabled={!!isDownloading}
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
data-tooltip={isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
{!isMobile && (isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
</button>
);
})()}
{albums.length > 0 && (
<button
className="btn btn-surface"
disabled={
artistOfflineStatus === 'downloading'
|| artistOfflineStatus === 'queued'
|| artistOfflineStatus === 'cached'
}
onClick={() => {
if (id && artist && artistOfflineStatus !== 'cached') {
downloadArtist(id, artist.name, activeServerId);
}
}}
data-tooltip={
artistOfflineStatus === 'downloading' && artistOfflineProgress
? t('artistDetail.offlineDownloading', {
done: artistOfflineProgress.done,
total: artistOfflineProgress.total,
})
: artistOfflineStatus === 'queued'
? t('artistDetail.offlineQueued')
: artistOfflineStatus === 'cached'
? t('artistDetail.offlineCached')
: t('artistDetail.cacheOffline')
}
>
{artistOfflineStatus === 'downloading'
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: artistOfflineStatus === 'cached'
? <Check size={16} />
: <HardDriveDownload size={16} />}
{!isMobile && (
artistOfflineStatus === 'downloading' && artistOfflineProgress
? t('artistDetail.offlineDownloading', {
done: artistOfflineProgress.done,
total: artistOfflineProgress.total,
})
: artistOfflineStatus === 'queued'
? t('artistDetail.offlineQueued')
: artistOfflineStatus === 'cached'
? t('artistDetail.offlineCached')
: t('artistDetail.cacheOffline')
)}
</button>
)}
</div>
</div>
</div>
@@ -61,6 +61,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
const meta = {
serverId: album.serverId,
name: album.name,
artist: album.artist,
artistId: album.artistId,
@@ -54,7 +54,11 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
const meta = { name: artist.name, albumCount: artist.albumCount };
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
return starred
? unstar(artist.id, 'artist', meta)
: star(artist.id, 'artist', meta);
@@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred));
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
@@ -121,7 +121,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred));
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
@@ -303,7 +303,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
queueSongStar(song.id, false);
queueSongStar(song.id, false, song.serverId);
})}>
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
</div>
+4 -4
View File
@@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks {
startPreview: (song: SubsonicSong) => void;
rate: (songId: string, rating: number) => void;
remove: (songId: string) => void;
navArtist: (artistId: string) => void;
navAlbum: (albumId: string) => void;
navArtist: (artistId: string, serverId?: string) => void;
navAlbum: (albumId: string, serverId?: string) => void;
}
interface Props {
@@ -100,12 +100,12 @@ function FavoriteSongRow({
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist}</span>
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album}</span>
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{song.album}</span>
</div>
);
case 'genre': return (
@@ -0,0 +1,74 @@
import { HardDrive } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
import {
useFavoritesOfflineStatus,
type FavoritesOfflineSemaphore,
} from '../../hooks/useFavoritesOfflineStatus';
import {
disableFavoritesOfflineSync,
scheduleFavoritesOfflineSync,
} from '../../utils/offline/favoritesOfflineSync';
function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string {
switch (semaphore) {
case 'red':
return 'favorites.offlineSemaphoreError';
case 'yellow':
return 'favorites.offlineSemaphoreSyncing';
case 'green':
return 'favorites.offlineSemaphoreSynced';
}
}
export default function FavoritesOfflineHeader() {
const { t } = useTranslation();
const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled);
const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus();
const semaphoreLabel = semaphore
? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount })
: undefined;
return (
<div className="favorites-offline-control">
{enabled && semaphore && (
<span
className={`favorites-offline-led favorites-offline-led--${semaphore}`}
role="status"
aria-live="polite"
aria-label={semaphoreLabel}
data-tooltip={semaphoreLabel}
data-tooltip-pos="bottom"
/>
)}
<div
className="favorites-offline-toggle"
data-tooltip={t('favorites.offlineTooltip')}
data-tooltip-pos="bottom"
>
<HardDrive
size={16}
className={`favorites-offline-disk-icon${enabled ? ' favorites-offline-disk-icon--on' : ''}`}
aria-hidden
/>
<label className="toggle-switch" aria-label={t('favorites.offlineTooltip')}>
<input
type="checkbox"
checked={enabled}
onChange={async e => {
const next = e.target.checked;
if (!next) {
await disableFavoritesOfflineSync();
} else {
setEnabled(true);
scheduleFavoritesOfflineSync();
}
}}
/>
<span className="toggle-track" />
</label>
</div>
</div>
);
}
@@ -13,6 +13,7 @@ import { useThemeStore } from '../../store/themeStore';
import { useDragDrop } from '../../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack';
import { appendServerQuery } from '../../utils/navigation/detailServerScope';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import { useElementClientHeightById } from '../../hooks/useResizeClientHeight';
import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering';
@@ -127,8 +128,14 @@ export default function FavoritesSongsTracklist({
),
rate: (songId, r) => latest.current.handleRate(songId, r),
remove: (songId) => latest.current.removeSong(songId),
navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`),
navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`),
navArtist: (artistId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`);
},
navAlbum: (albumId, serverId) => {
const query = appendServerQuery(undefined, serverId);
latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`);
},
}), []);
const listWrapRef = useRef<HTMLDivElement | null>(null);
@@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
import { coverServerScopeForServerId } from '../../cover/serverScope';
export interface TopFavoriteArtist {
id: string;
name: string;
count: number;
coverArtId: string;
/** Present when favorites are merged across servers. */
serverId?: string;
/** Raw artist id for song filtering (without server prefix). */
artistId?: string;
}
interface TopFavoriteArtistsRowProps {
@@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps {
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId;
const artistEntityId = artist.artistId ?? artist.coverArtId;
return (
<div
@@ -94,8 +100,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
<div className="artist-card-avatar">
{coverId ? (
<ArtistCoverArtImage
artistId={artist.id}
artistId={artistEntityId}
coverArt={artist.coverArtId}
serverScope={coverServerScopeForServerId(artist.serverId)}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
@@ -43,7 +43,7 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
+24 -11
View File
@@ -7,6 +7,8 @@ import {
} from 'lucide-react';
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
import type { ZipDownload } from '../../store/zipDownloadStore';
import type { AlbumOfflineStatus } from '../../hooks/useAlbumOfflineState';
import { dequeueOfflinePin } from '../../utils/offline/offlinePinQueue';
import { useThemeStore } from '../../store/themeStore';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
import {
@@ -29,8 +31,7 @@ interface Props {
searchOpen: boolean;
csvImporting: boolean;
activeZip: ZipDownload | undefined;
isCached: boolean;
isDownloading: boolean;
offlineStatus: AlbumOfflineStatus;
offlineProgress: { done: number; total: number } | null;
activeServerId: string;
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
@@ -52,7 +53,7 @@ export default function PlaylistHero({
playlist, songs, id,
customCoverId, coverQuadIds,
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
isCached, isDownloading, offlineProgress, activeServerId,
offlineStatus, offlineProgress, activeServerId,
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
setSelectedSearchIds, setSearchPlPickerOpen,
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
@@ -206,27 +207,39 @@ export default function PlaylistHero({
</button>
)
)}
{isLayoutVisible('offlineCache') && songs.length > 0 && id && (
{isLayoutVisible('offlineCache') && songs.length > 0 && id
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
<button
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
disabled={isDownloading}
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
disabled={offlineStatus === 'downloading'}
onClick={() => {
if (isCached) {
if (offlineStatus === 'cached') {
deleteAlbum(id, activeServerId);
} else if (offlineStatus === 'queued') {
dequeueOfflinePin(id);
} else if (playlist) {
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
}
}}
data-tooltip={isDownloading
data-tooltip={offlineStatus === 'downloading'
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
: offlineStatus === 'queued'
? t('albumDetail.removeFromOfflineQueue')
: offlineStatus === 'cached'
? t('playlists.removeOffline')
: t('playlists.cacheOffline')}
>
{isDownloading ? (
{offlineStatus === 'downloading' ? (
<>
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
</>
) : isCached ? (
) : offlineStatus === 'queued' ? (
<>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
</>
) : offlineStatus === 'cached' ? (
<>
<Trash2 size={16} />
{t('playlists.removeOffline')}
@@ -1,5 +1,5 @@
import { Image, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import SettingsSubSection from '../SettingsSubSection';
@@ -16,18 +16,46 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { showToast } from '../../utils/ui/toast';
import { formatBytes } from '../../utils/format/formatBytes';
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
import {
COVER_CACHE_STRATEGIES,
type CoverCacheStrategy,
} from '../../utils/library/coverStrategy';
type ClearTarget = {
serverId: string;
indexKey: string;
label: string;
type ClearTarget =
| { kind: 'image' }
| { kind: 'disk'; serverId: string; indexKey: string; label: string };
const ROW_BORDER = { borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' } as const;
const TH_STYLE = {
textAlign: 'left' as const,
padding: '8px 10px',
fontSize: 12,
color: 'var(--text-muted)',
};
const TABLE_STYLE = {
width: '100%',
borderCollapse: 'collapse',
minWidth: 520,
tableLayout: 'fixed',
} as const;
const STRATEGY_GAP_TH: CSSProperties = { ...TH_STYLE, padding: '8px 10px' };
const STRATEGY_GAP_TD: CSSProperties = { padding: '10px' };
function CoverCacheColGroup() {
return (
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '38%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '18%' }} />
</colgroup>
);
}
type ServerRowState = {
bytes: number;
entryCount: number;
@@ -38,12 +66,14 @@ type ServerRowState = {
export default function CoverCacheStrategySection() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const auth = useAuthStore();
const servers = auth.servers;
const activeServerId = auth.activeServerId;
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
const [clearingKey, setClearingKey] = useState<string | null>(null);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const activeIndexKeys = useMemo(
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
@@ -85,6 +115,7 @@ export default function CoverCacheStrategySection() {
// safety net for changes made outside this view (e.g. browsing-time caching); it is
// not needed for correctness, so we avoid re-walking the cover dirs in a tight loop.
useEffect(() => {
void getImageCacheSize().then(setImageCacheBytes);
refreshAll();
const id = window.setInterval(refreshAll, 300_000);
return () => window.clearInterval(id);
@@ -168,8 +199,19 @@ export default function CoverCacheStrategySection() {
}
};
const handleClearCoverCache = async () => {
const handleClearConfirm = async () => {
if (!clearTarget) return;
if (clearTarget.kind === 'image') {
setClearingKey('image');
try {
await clearImageCache();
setImageCacheBytes(await getImageCacheSize());
} finally {
setClearingKey(null);
setClearTarget(null);
}
return;
}
setClearingKey(clearTarget.indexKey);
try {
await coverCacheClearServer(clearTarget.indexKey);
@@ -191,21 +233,62 @@ export default function CoverCacheStrategySection() {
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
<div className="settings-card">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 520 }}>
<table style={TABLE_STYLE}>
<CoverCacheColGroup />
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyServerLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyProgressLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyActionsLabel')}
</th>
<th style={TH_STYLE}>{t('settings.imageCacheScopeLabel')}</th>
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
{t('settings.imageCacheSubTitle')}
</td>
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
{imageCacheBytes !== null
? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(imageCacheBytes) })
: '—'}
</td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() => setClearTarget({ kind: 'image' })}
>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
style={{
margin: '20px 0 10px',
fontSize: 13,
fontWeight: 500,
color: 'var(--text-primary)',
}}
>
{t('settings.coverDiskCacheSubTitle')}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={TABLE_STYLE}>
<CoverCacheColGroup />
<thead>
<tr>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyServerLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
</tr>
</thead>
<tbody>
@@ -215,7 +298,7 @@ export default function CoverCacheStrategySection() {
const row = rowState[key];
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<tr key={server.id} style={ROW_BORDER}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
<td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
@@ -245,7 +328,7 @@ export default function CoverCacheStrategySection() {
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() =>
setClearTarget({ serverId: server.id, indexKey: key, label })}
setClearTarget({ kind: 'disk', serverId: server.id, indexKey: key, label })}
>
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
</button>
@@ -254,19 +337,20 @@ export default function CoverCacheStrategySection() {
);
})}
{removedServerKeys.map(key => (
<tr key={`removed-${key}`} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<tr key={`removed-${key}`} style={ROW_BORDER}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
{key}
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
</td>
<td colSpan={2} />
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() =>
setClearTarget({ serverId: key, indexKey: key, label: key })}
setClearTarget({ kind: 'disk', serverId: key, indexKey: key, label: key })}
>
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
</button>
@@ -291,19 +375,30 @@ export default function CoverCacheStrategySection() {
fontSize: 13,
}}
>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
</div>
{clearTarget.kind === 'image' ? (
<>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.cacheClearBtn')}</div>
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>{t('settings.cacheClearWarning')}</div>
</>
) : (
<>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
</div>
</>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
disabled={clearingKey !== null}
onClick={() => void handleClearCoverCache()}
onClick={() => void handleClearConfirm()}
>
{t('settings.coverCacheStrategyClearConfirm')}
{clearTarget.kind === 'image'
? t('settings.cacheClearConfirm')
: t('settings.coverCacheStrategyClearConfirm')}
</button>
<button
type="button"
@@ -312,7 +407,9 @@ export default function CoverCacheStrategySection() {
disabled={clearingKey !== null}
onClick={() => setClearTarget(null)}
>
{t('settings.coverCacheStrategyClearCancel')}
{clearTarget.kind === 'image'
? t('settings.cacheClearCancel')
: t('settings.coverCacheStrategyClearCancel')}
</button>
</div>
</div>
+48 -178
View File
@@ -4,9 +4,8 @@ import { invoke } from '@tauri-apps/api/core';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { Download, FolderOpen, Trash2, X } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { useOfflineStore } from '../../store/offlineStore';
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
import { countHotCacheTracks } from '../../store/hotCacheStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
import SettingsSubSection from '../SettingsSubSection';
import CoverCacheStrategySection from './CoverCacheStrategySection';
@@ -14,79 +13,50 @@ import CoverCacheStrategySection from './CoverCacheStrategySection';
export function StorageTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const serverId = auth.activeServerId ?? '';
const clearAllOffline = useOfflineStore(s => s.clearAll);
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
const hotCacheEntries = useHotCacheStore(s => s.entries);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const clearHotCacheDisk = useLocalPlaybackStore(s => s.purgeEphemeralDisk);
const localPlaybackEntries = useLocalPlaybackStore(s => s.entries);
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
const hotCacheTrackCount = useMemo(() => {
const prefix = `${serverId}:`;
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
}, [hotCacheEntries, serverId]);
const mediaDir = auth.mediaDir || null;
/** Match ephemeral disk usage (all servers); resolve UUID vs URL index keys. */
const hotCacheTrackCount = useMemo(
() => countHotCacheTracks(localPlaybackEntries),
[localPlaybackEntries],
);
const refreshHotCacheSize = useCallback(() => {
invoke<number>('get_media_tier_size', { tier: 'ephemeral', mediaDir })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}, [mediaDir]);
// Load all three size readouts on mount.
useEffect(() => {
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}, [auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
refreshHotCacheSize();
}, [refreshHotCacheSize]);
/** Live disk usage for hot cache (interval + refresh when index changes). */
useEffect(() => {
const customDir = auth.hotCacheDownloadDir || null;
const refresh = () => {
invoke<number>('get_hot_cache_size', { customDir })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
};
refresh();
if (!auth.hotCacheEnabled) return;
const interval = window.setInterval(refresh, 15_000);
refreshHotCacheSize();
const interval = window.setInterval(refreshHotCacheSize, 15_000);
return () => window.clearInterval(interval);
}, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
}, [auth.hotCacheEnabled, refreshHotCacheSize]);
useEffect(() => {
if (!auth.hotCacheEnabled) return;
const handle = window.setTimeout(() => {
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}, 400);
const handle = window.setTimeout(refreshHotCacheSize, 400);
return () => window.clearTimeout(handle);
}, [hotCacheEntries, auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
}, [localPlaybackEntries, auth.hotCacheEnabled, refreshHotCacheSize]);
const handleClearCache = useCallback(async () => {
setClearing(true);
await clearImageCache();
await clearAllOffline(serverId);
const [imgBytes, offBytes] = await Promise.all([
getImageCacheSize(),
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
]);
setImageCacheBytes(imgBytes);
setOfflineCacheBytes(offBytes);
setShowClearConfirm(false);
setClearing(false);
}, [clearAllOffline, serverId, auth.offlineDownloadDir]);
const pickOfflineDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
const pickMediaDir = async () => {
const selected = await openDialog({
directory: true,
multiple: false,
title: t('settings.mediaDirChange'),
});
if (selected && typeof selected === 'string') {
auth.setOfflineDownloadDir(selected);
}
};
const pickHotCacheDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') });
if (selected && typeof selected === 'string') {
auth.setHotCacheDownloadDir(selected);
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
auth.setMediaDir(selected);
refreshHotCacheSize();
}
};
@@ -99,111 +69,51 @@ export function StorageTab() {
return (
<>
{/* Offline Library (In-App) — includes cache settings */}
<SettingsSubSection
title={t('settings.offlineDirTitle')}
icon={<Download size={16} />}
title={t('settings.mediaDirTitle')}
icon={<FolderOpen size={16} />}
>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.offlineDirDesc')}
{t('settings.mediaDirDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
value={auth.mediaDir || t('settings.mediaDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.offlineDownloadDir && (
{auth.mediaDir && (
<button
className="btn btn-ghost"
onClick={() => auth.setOfflineDownloadDir('')}
data-tooltip={t('settings.offlineDirClear')}
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
data-tooltip={t('settings.mediaDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
</button>
</div>
{auth.offlineDownloadDir && (
{auth.mediaDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.offlineDirHint')}
{t('settings.mediaDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
<input
className="input"
type="number"
min={100}
max={50000}
step={100}
value={auth.maxCacheMb}
onChange={e => {
const v = Number(e.target.value);
if (v >= 100) auth.setMaxCacheMb(v);
}}
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
id="cache-size-input"
/>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
</div>
{showClearConfirm ? (
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
onClick={handleClearCache}
disabled={clearing}
>
{t('settings.cacheClearConfirm')}
</button>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
{t('settings.cacheClearCancel')}
</button>
</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
</div>
</SettingsSubSection>
<CoverCacheStrategySection />
{/* Buffering */}
<SettingsSubSection
title={t('settings.nextTrackBufferingTitle')}
icon={<Download size={16} />}
>
<div className="settings-card">
{/* Hot Cache */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
@@ -216,14 +126,12 @@ export function StorageTab() {
onChange={async e => {
const enabled = e.target.checked;
if (!enabled) {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
await clearHotCacheDisk(mediaDir);
setHotCacheBytes(0);
auth.setHotCacheEnabled(false);
} else {
auth.setHotCacheEnabled(true);
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
refreshHotCacheSize();
}
}}
id="hot-cache-enabled-toggle"
@@ -234,41 +142,6 @@ export function StorageTab() {
{auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
className="input"
type="text"
readOnly
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
style={{ flex: 1, minWidth: 0, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.hotCacheDownloadDir && (
<button
type="button"
className="btn btn-ghost"
onClick={() => {
auth.setHotCacheDownloadDir('');
useHotCacheStore.setState({ entries: {} });
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}}
data-tooltip={t('settings.hotCacheDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
</button>
</div>
{auth.hotCacheDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.hotCacheDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
@@ -305,20 +178,17 @@ export function StorageTab() {
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={async () => {
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
setHotCacheBytes(b);
await clearHotCacheDisk(mediaDir);
refreshHotCacheSize();
}}
>
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
</button>
</div>
)}
</div>
</SettingsSubSection>
{/* ZIP Export & Archiving */}
<SettingsSubSection
title={t('settings.downloadsTitle')}
icon={<FolderOpen size={16} />}
+2 -2
View File
@@ -55,8 +55,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server' },
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server image idb preview limit clear' },
{ tab: 'storage', titleKey: 'settings.mediaDirTitle', keywords: 'media folder offline library cache directory local playback' },
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' },
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
{ tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
+19 -6
View File
@@ -4,6 +4,8 @@ import { HardDriveDownload, HardDriveUpload, X } from 'lucide-react';
interface Props {
isCollapsed: boolean;
activeJobsCount: number;
activePinName: string | null;
queuedPinCount: number;
cancelAllDownloads: () => void;
isSyncing: boolean;
syncJobDone: number;
@@ -13,21 +15,32 @@ interface Props {
}
export default function SidebarActiveJobs({
isCollapsed, activeJobsCount, cancelAllDownloads,
isCollapsed, activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
}: Props) {
const { t } = useTranslation();
const showPinQueue = !!activePinName || queuedPinCount > 0;
const offlineQueueLabel = showPinQueue
? (queuedPinCount > 0
? t('sidebar.offlinePinActiveQueued', { name: activePinName ?? '', queued: queuedPinCount })
: t('sidebar.offlinePinActive', { name: activePinName ?? '' }))
: t('sidebar.downloadingTracks', { n: activeJobsCount });
const syncLabel = t('sidebar.syncingTracks', {
done: syncJobDone + syncJobSkip + syncJobFail,
total: syncJobTotal,
});
return (
<>
{activeJobsCount > 0 && (
{(activeJobsCount > 0 || showPinQueue) && (
<div
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobsCount }) : undefined}
data-tooltip={offlineQueueLabel}
data-tooltip-pos="right"
>
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{t('sidebar.downloadingTracks', { n: activeJobsCount })}</span>
<span>{offlineQueueLabel}</span>
)}
<button
className="sidebar-offline-cancel"
@@ -44,12 +57,12 @@ export default function SidebarActiveJobs({
{isSyncing && (
<div
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
data-tooltip={syncLabel}
data-tooltip-pos="right"
>
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
<span>{syncLabel}</span>
)}
</div>
)}
+5 -1
View File
@@ -44,6 +44,8 @@ interface Props {
nowPlayingAtTop: boolean;
hasOfflineContent: boolean;
activeJobsCount: number;
activePinName: string | null;
queuedPinCount: number;
cancelAllDownloads: () => void;
isSyncing: boolean;
syncJobDone: number;
@@ -62,7 +64,7 @@ export default function SidebarNavBody(props: Props) {
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent,
activeJobsCount, cancelAllDownloads,
activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
} = props;
const { t } = useTranslation();
@@ -289,6 +291,8 @@ export default function SidebarNavBody(props: Props) {
<SidebarActiveJobs
isCollapsed={isCollapsed}
activeJobsCount={activeJobsCount}
activePinName={activePinName}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
+1
View File
@@ -152,6 +152,7 @@ const CONTRIBUTOR_ENTRIES = [
'Performance Probe: throughput (analysis tpm, cover cpm) measured over a trailing 5s window so the rate reacts promptly instead of coasting on minute-long inertia (PR #948)',
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)',
'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)',
],
},
{
+3 -3
View File
@@ -261,7 +261,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
showToast(i18n.t('contextMenu.cliMixNeedsTrack', { defaultValue: 'Load a track first.' }), 5000, 'error');
return;
}
queueSongStar(track.id, true);
queueSongStar(track.id, true, track.serverId);
},
},
'open-help': {
@@ -309,7 +309,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
queueSongStar(track.id, true);
queueSongStar(track.id, true, track.serverId);
},
cli: { verb: 'star', description: 'star' },
},
@@ -322,7 +322,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
return;
}
queueSongStar(track.id, false);
queueSongStar(track.id, false, track.serverId);
},
cli: { verb: 'unstar', description: 'unstar' },
},
+1
View File
@@ -2,6 +2,7 @@
* Unified cover art pipeline see workdocs tasks/2026-05-cover-art-pipeline/contracts.md
*/
export * from './types';
export { coverServerScopeForServerId } from './serverScope';
export * from './tiers';
export * from './ids';
export * from './storageKeys';
+4 -14
View File
@@ -1,6 +1,6 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { coverServerScopeForServerId } from './serverScope';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
import {
@@ -187,18 +187,8 @@ export function coverArtRef(
export function resolvePlaybackCoverScope(): CoverServerScope {
const playbackSid = getPlaybackServerId();
if (!playbackSid) return { kind: 'playback' };
const activeSid = useAuthStore.getState().activeServerId;
if (playbackSid && activeSid && playbackSid !== activeSid) {
const server = findServerByIdOrIndexKey(playbackSid);
if (server) {
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
}
return { kind: 'playback' };
if (playbackSid === activeSid) return { kind: 'playback' };
return coverServerScopeForServerId(playbackSid);
}
+34
View File
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '../store/authStore';
import { COVER_SCOPE_ACTIVE } from './types';
import { coverServerScopeForServerId } from './serverScope';
describe('coverServerScopeForServerId', () => {
beforeEach(() => {
useAuthStore.setState({
activeServerId: 'srv-active',
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
],
});
});
it('returns active scope when serverId is missing', () => {
expect(coverServerScopeForServerId(undefined)).toBe(COVER_SCOPE_ACTIVE);
expect(coverServerScopeForServerId(null)).toBe(COVER_SCOPE_ACTIVE);
});
it('returns explicit server scope for a known profile id', () => {
expect(coverServerScopeForServerId('srv-a')).toEqual({
kind: 'server',
serverId: 'srv-a',
url: 'https://a.test',
username: 'u',
password: 'p',
});
});
it('falls back to active scope for unknown ids', () => {
expect(coverServerScopeForServerId('missing')).toBe(COVER_SCOPE_ACTIVE);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types';
/** Explicit server bucket for cover disk/IDB — use when entity carries `serverId` (e.g. cross-server favorites). */
export function coverServerScopeForServerId(
serverId: string | null | undefined,
): CoverServerScope {
if (!serverId?.trim()) return COVER_SCOPE_ACTIVE;
const server = findServerByIdOrIndexKey(serverId);
if (!server) return COVER_SCOPE_ACTIVE;
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
+19 -4
View File
@@ -10,6 +10,9 @@ import {
resolveDistinctDiscCoversForAlbum,
resolvePlaybackCoverScope,
} from './ref';
import { coverServerScopeForServerId } from './serverScope';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import {
resolveAlbumCoverRefFromLibrary,
resolveArtistCoverRefFromLibrary,
@@ -200,6 +203,8 @@ export function usePlaybackTrackCoverRef(
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
): CoverArtRef | undefined {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueItems = usePlayerStore(s => s.queueItems);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
@@ -208,10 +213,20 @@ export function usePlaybackTrackCoverRef(
.join('\u0002'),
);
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
);
const scope = useMemo(() => {
if (track?.id) {
const ref = queueItems[queueIndex];
if (ref && sameQueueTrackId(ref.trackId, track.id)) {
const profileId = resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
return coverServerScopeForServerId(profileId);
}
const scopedTrack = track as { serverId?: string };
if (scopedTrack.serverId) {
return coverServerScopeForServerId(scopedTrack.serverId);
}
}
return resolvePlaybackCoverScope();
}, [track, queueItems, queueIndex, queueServerId, queueLength, activeServerId, serversFingerprint]);
const scopeKey = coverScopeKey(scope);
const trackId = track?.id;
+5 -1
View File
@@ -12,6 +12,10 @@ export function usePlaybackCoverArt(
opts?: { fullRes?: boolean },
): CoverArtHandle {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playingServerId = usePlayerStore(
s => s.queueItems[s.queueIndex]?.serverId ?? '',
);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
@@ -22,7 +26,7 @@ export function usePlaybackCoverArt(
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
[queueServerId, queueIndex, playingServerId, queueLength, activeServerId, serversFingerprint],
);
const refWithScope = useMemo(
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
File diff suppressed because one or more lines are too long
+98 -12
View File
@@ -1,7 +1,20 @@
import { useEffect, useState } from 'react';
import { getAlbum } from '../api/subsonicLibrary';
import { getArtist } from '../api/subsonicArtists';
import { useSearchParams } from 'react-router-dom';
import { getAlbum, getAlbumForServer } from '../api/subsonicLibrary';
import { getArtist, getArtistForServer } from '../api/subsonicArtists';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { useConnectionStatus } from './useConnectionStatus';
import {
loadAlbumFromLibraryIndex,
loadArtistFromLibraryIndex,
resolveAlbumForServer,
} from '../utils/offline/favoritesOfflineBrowse';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
type AlbumPayload = Awaited<ReturnType<typeof getAlbum>>;
@@ -20,12 +33,6 @@ interface UseAlbumDetailDataResult {
* Load an album payload by id, then resolve the artist's other albums in
* a follow-up call so the related-albums grid can render without blocking
* the initial paint.
*
* On every id change we reset `relatedAlbums` to an empty array so the
* grid doesn't briefly show the previous album's neighbours while the
* new fetch is in flight. The two starred state pieces (`isStarred`,
* `starredSongs`) are seeded from the response so optimistic toggles
* have a baseline to revert to.
*/
export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataResult {
const [album, setAlbum] = useState<AlbumPayload | null>(null);
@@ -33,26 +40,105 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const [loading, setLoading] = useState(true);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
const { status: connStatus } = useConnectionStatus();
useEffect(() => {
if (!id) return;
setLoading(true);
setRelatedAlbums([]);
getAlbum(id).then(async data => {
const applyAlbumPayload = (data: AlbumPayload) => {
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
};
const loadRelatedAlbums = async (
serverId: string | null,
artistId: string | undefined,
useLocalArtist: boolean,
) => {
if (!artistId) return;
try {
const artistData = await getArtist(data.album.artistId);
if (useLocalArtist && serverId) {
const artistLocal = await loadArtistFromLibraryIndex(serverId, artistId);
if (artistLocal) {
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
return;
}
}
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
if (!relatedServerId || !shouldAttemptSubsonicForServer(relatedServerId)) return;
const artistData = detailServerId
? await getArtistForServer(detailServerId, artistId)
: await getArtist(artistId);
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
} catch (e) {
console.error('Failed to fetch related albums', e);
}
}).catch(() => setLoading(false));
}, [id]);
};
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
void (async () => {
if (libraryFirst && detailServerId) {
try {
const local = await resolveAlbumForServer(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
return;
}
} catch { /* fall through */ }
}
const detailNetworkAllowed = detailServerId
? shouldAttemptSubsonicForServer(detailServerId)
: shouldAttemptSubsonicForActiveServer();
if (!detailNetworkAllowed) {
if (favoritesOfflineEnabled && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
return;
}
try {
const data = detailServerId
? await getAlbumForServer(detailServerId, id)
: await getAlbum(id);
applyAlbumPayload(data);
await loadRelatedAlbums(detailServerId, data.album.artistId, false);
} catch {
if (favoritesOfflineEnabled && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
}
})();
}, [id, connStatus, favoritesOfflineEnabled, detailServerId, searchParams]);
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAlbumOfflineState } from './useAlbumOfflineState';
describe('useAlbumOfflineState', () => {
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
});
it('reports queued when the album waits in the pin queue', () => {
useOfflineJobStore.setState({
pinQueue: [{
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
status: 'queued',
queuedAt: Date.now(),
}],
});
const { result } = renderHook(() => useAlbumOfflineState('alb-1', 'srv', ['t1']));
expect(result.current.resolvedOfflineStatus).toBe('queued');
expect(result.current.offlineProgress).toBeNull();
});
it('prefers downloading over queued when jobs are active', () => {
useOfflineJobStore.setState({
pinQueue: [{
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
status: 'downloading',
queuedAt: Date.now(),
}],
jobs: [{
trackId: 't1',
albumId: 'alb-1',
albumName: 'One',
trackTitle: 'Track',
trackIndex: 0,
totalTracks: 1,
status: 'downloading',
downloadId: 'dl-1',
}],
});
const { result } = renderHook(() => useAlbumOfflineState('alb-1', 'srv', ['t1']));
expect(result.current.resolvedOfflineStatus).toBe('downloading');
expect(result.current.offlineProgress).toEqual({ done: 0, total: 1 });
});
});
+36 -18
View File
@@ -1,8 +1,11 @@
import { useOfflineStore } from '../store/offlineStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
export type AlbumOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
interface UseAlbumOfflineStateResult {
resolvedOfflineStatus: 'none' | 'downloading' | 'cached';
resolvedOfflineStatus: AlbumOfflineStatus;
offlineProgress: { done: number; total: number } | null;
}
@@ -14,36 +17,51 @@ interface UseAlbumOfflineStateResult {
* downloads (each track flip would otherwise trigger a full page render).
*
* Resolution rules:
* - If there's any queued / downloading job for this album, status is
* `downloading` and we expose a `{ done, total }` progress tuple.
* - Else we look at the persisted cache map: a fully-cached album is
* one where every trackId in the album-meta has a matching track entry.
* - Fully pinned `cached`.
* - Active pin jobs or pin-queue `downloading` `downloading` + progress.
* - Pin-queue `queued` (waiting behind another album) `queued`.
* - Else `none`.
*
* `albumId` is allowed to be empty (e.g. while the page is still
* fetching) in that case every selector short-circuits to a benign
* default.
*/
export function useAlbumOfflineState(albumId: string, serverId: string): UseAlbumOfflineStateResult {
const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => {
if (!albumId) return 'none';
const meta = s.albums[`${serverId}:${albumId}`];
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
return isDownloaded ? 'cached' : 'none';
});
export function useAlbumOfflineState(
albumId: string,
serverId: string,
songIds?: string[],
): UseAlbumOfflineStateResult {
useLocalPlaybackStore(s => s.entries);
const pinComplete = !!albumId && isOfflinePinComplete(albumId, serverId, songIds);
const isPinQueued = useOfflineJobStore(s =>
!pinComplete
&& !!albumId
&& s.pinQueue.some(p => p.albumId === albumId && p.status === 'queued'),
);
const isOfflineDownloading = useOfflineJobStore(s =>
!!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')),
!pinComplete
&& !!albumId
&& (
s.pinQueue.some(p => p.albumId === albumId && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
),
);
const offlineProgressDone = useOfflineJobStore(s => {
if (!albumId) return 0;
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => {
if (!albumId) return 0;
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId).length;
});
const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus;
const offlineProgress = offlineProgressTotal > 0
const resolvedOfflineStatus = pinComplete
? 'cached'
: isOfflineDownloading
? 'downloading'
: isPinQueued
? 'queued'
: 'none';
const offlineProgress = isOfflineDownloading && offlineProgressTotal > 0
? { done: offlineProgressDone, total: offlineProgressTotal }
: null;
+9 -3
View File
@@ -10,6 +10,8 @@
* otherwise persist into IndexedDB.
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
@@ -33,6 +35,10 @@ afterEach(() => {
vi.clearAllMocks();
});
function routerWrapper({ children }: { children: React.ReactNode }) {
return React.createElement(MemoryRouter, null, children);
}
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e?: unknown) => void;
@@ -55,7 +61,7 @@ describe('useArtistDetailData — id-gated info', () => {
const { result, rerender } = renderHook(
({ id }: { id: string }) => useArtistDetailData(id),
{ initialProps: { id: 'A' } },
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
);
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
@@ -95,7 +101,7 @@ describe('useArtistDetailData — id-gated info', () => {
],
} as any);
const { result } = renderHook(() => useArtistDetailData('A'));
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
@@ -117,7 +123,7 @@ describe('useArtistDetailData — id-gated info', () => {
const { result, rerender } = renderHook(
({ id }: { id: string }) => useArtistDetailData(id),
{ initialProps: { id: 'A' } },
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
);
rerender({ id: 'B' });
+52 -10
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { search } from '../api/subsonicSearch';
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
} from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { useConnectionStatus } from './useConnectionStatus';
import { loadArtistFromLibraryIndex } from '../utils/offline/favoritesOfflineBrowse';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
@@ -45,11 +49,18 @@ export function useArtistDetailData(
options: UseArtistDetailDataOptions = {},
): ArtistDetailDataResult {
const losslessOnly = options.losslessOnly ?? false;
const serverId = useAuthStore(s => s.activeServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const serverId = readDetailServerId(searchParams, activeServerId);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const { status: connStatus } = useConnectionStatus();
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const preferLocalArtist = connStatus === 'disconnected'
&& favoritesOfflineEnabled
&& !!serverId;
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -71,11 +82,26 @@ export function useArtistDetailData(
(async () => {
try {
if (preferLocalArtist && serverId && id) {
const local = await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
setIsStarred(!!local.artist.starred);
setAlbums(local.albums);
setTopSongs([]);
setLoading(false);
return;
}
}
if (losslessOnly && serverId) {
const local = await runLocalArtistLosslessBrowse(serverId, id);
if (cancelled) return;
if (local) {
const artistData = await getArtist(id).catch(() => null);
const artistData = serverId
? await getArtistForServer(serverId, id).catch(() => null)
: await getArtist(id).catch(() => null);
if (cancelled) return;
if (artistData) {
setArtist(artistData.artist);
@@ -88,7 +114,9 @@ export function useArtistDetailData(
}
}
const artistData = await getArtist(id);
const artistData = serverId
? await getArtistForServer(serverId, id)
: await getArtist(id);
if (cancelled) return;
setArtist(artistData.artist);
let nextAlbums = artistData.albums;
@@ -105,6 +133,20 @@ export function useArtistDetailData(
setTopSongs(nextSongs);
} catch (err) {
if (!cancelled) {
if (preferLocalArtist && serverId && id) {
try {
const local = await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
setIsStarred(!!local.artist.starred);
setAlbums(local.albums);
setTopSongs([]);
setLoading(false);
return;
}
} catch { /* ignore */ }
}
console.error(err);
setLoading(false);
}
@@ -112,10 +154,10 @@ export function useArtistDetailData(
})();
return () => { cancelled = true; };
}, [id, losslessOnly, serverId]);
}, [id, losslessOnly, serverId, preferLocalArtist, searchParams]);
useEffect(() => {
if (!id) return;
if (!id || preferLocalArtist) return;
let cancelled = false;
setArtistInfoLoading(true);
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
@@ -129,10 +171,10 @@ export function useArtistDetailData(
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled]);
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
useEffect(() => {
if (!id || !artist) return;
if (!id || !artist || preferLocalArtist) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
setFeaturedLoading(true);
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
@@ -172,7 +214,7 @@ export function useArtistDetailData(
setFeaturedLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums]);
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useArtistOfflineState } from './useArtistOfflineState';
describe('useArtistOfflineState', () => {
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
useLocalPlaybackStore.setState({ entries: {} });
});
it('reports cached when every album is pinned', () => {
useLocalPlaybackStore.setState({
entries: {
'srv:al-1': {
serverIndexKey: 'srv',
trackId: 't1',
localPath: '/x',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: 'al-1' },
},
'srv:al-2': {
serverIndexKey: 'srv',
trackId: 't2',
localPath: '/y',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: 'al-2' },
},
},
});
const { result } = renderHook(() =>
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
);
expect(result.current.status).toBe('cached');
});
it('reports queued when bulk progress is active but albums only wait in pin queue', () => {
useOfflineJobStore.setState({
bulkProgress: { 'artist-1': { done: 0, total: 2 } },
pinQueue: [
{ albumId: 'al-1', albumName: 'One', pinKind: 'artist', status: 'queued', queuedAt: 1 },
{ albumId: 'al-2', albumName: 'Two', pinKind: 'artist', status: 'queued', queuedAt: 2 },
],
jobs: [],
});
const { result } = renderHook(() =>
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
);
expect(result.current.status).toBe('queued');
expect(result.current.progress).toEqual({ done: 0, total: 2 });
});
});
+56
View File
@@ -0,0 +1,56 @@
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
export type ArtistOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
interface UseArtistOfflineStateResult {
status: ArtistOfflineStatus;
progress: { done: number; total: number } | null;
}
/**
* Offline discography status for an artist page. Uses persisted library pins
* (not ephemeral bulkProgress) so "Discography cached" survives navigation.
*/
export function useArtistOfflineState(
artistId: string,
serverId: string,
albumIds: string[],
): UseArtistOfflineStateResult {
useLocalPlaybackStore(s => s.entries);
const allPinned = albumIds.length > 0
&& albumIds.every(id => isOfflinePinComplete(id, serverId));
const bulkDone = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.done : undefined));
const bulkTotal = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.total : undefined));
const hasQueuedAlbums = useOfflineJobStore(s =>
albumIds.length > 0
&& albumIds.some(id => s.pinQueue.some(p => p.albumId === id && p.status === 'queued')),
);
const hasDownloadingAlbums = useOfflineJobStore(s =>
albumIds.length > 0
&& albumIds.some(id =>
s.pinQueue.some(p => p.albumId === id && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')),
),
);
const bulkActive = bulkTotal !== undefined && bulkDone !== undefined && bulkDone < bulkTotal;
const waitingInQueue = bulkActive && hasQueuedAlbums && !hasDownloadingAlbums;
const status: ArtistOfflineStatus = allPinned
? 'cached'
: hasDownloadingAlbums || (bulkActive && !waitingInQueue)
? 'downloading'
: waitingInQueue
? 'queued'
: 'none';
const progress = bulkActive && bulkDone !== undefined && bulkTotal !== undefined
? { done: bulkDone, total: bulkTotal }
: null;
return { status, progress };
}
+9 -1
View File
@@ -8,6 +8,7 @@ import {
isLanUrl,
type ServerEndpointKind,
} from '../utils/server/serverEndpoint';
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
// Backward-compatible re-export for call sites that still import from the hook.
@@ -29,11 +30,13 @@ export function useConnectionStatus() {
const check = useCallback(async () => {
const server = useAuthStore.getState().getActiveServer();
if (!server) {
setActiveServerReachable(false);
setStatus('disconnected');
return;
}
if (!navigator.onLine) {
setActiveServerReachable(false);
setStatus('disconnected');
return;
}
@@ -58,6 +61,7 @@ export function useConnectionStatus() {
} else {
setActiveEndpointKind(null);
}
setActiveServerReachable(probe.ok);
setStatus(probe.ok ? 'connected' : 'disconnected');
}, []);
@@ -77,6 +81,7 @@ export function useConnectionStatus() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setActiveServerReachable(true);
setStatus('connected');
return;
}
@@ -90,7 +95,10 @@ export function useConnectionStatus() {
if (sid) invalidateReachableEndpointCache(sid);
check();
};
const handleOffline = () => setStatus('disconnected');
const handleOffline = () => {
setActiveServerReachable(false);
setStatus('disconnected');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
+66 -17
View File
@@ -7,6 +7,12 @@ import type {
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
import { useConnectionStatus } from './useConnectionStatus';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
import {
loadStarredFromAllLibraryIndexes,
loadStarredFromAllServersOnline,
} from '../utils/offline/favoritesOfflineBrowse';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
@@ -20,6 +26,12 @@ export interface FavoritesDataResult {
unfavoriteStation: (id: string) => void;
}
function topArtistKey(song: SubsonicSong): string {
const artistKey = song.artistId || song.artist;
if (!artistKey) return '';
return song.serverId ? `${song.serverId}:${artistKey}` : artistKey;
}
export function useFavoritesData(): FavoritesDataResult {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
@@ -28,39 +40,74 @@ export function useFavoritesData(): FavoritesDataResult {
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const servers = useAuthStore(s => s.servers);
const { status: connStatus } = useConnectionStatus();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
useEffect(() => {
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
getStarred(),
]);
if (starredResult.status === 'fulfilled') {
setAlbums(starredResult.value.albums);
setArtists(starredResult.value.artists);
setSongs(starredResult.value.songs);
}
let cancelled = false;
// Radio favorites: read IDs from localStorage, fetch all stations, filter
const applyStarred = (starred: {
albums: SubsonicAlbum[];
artists: SubsonicArtist[];
songs: SubsonicSong[];
}) => {
if (cancelled) return;
setAlbums(starred.albums);
setArtists(starred.artists);
setSongs(starred.songs);
};
const loadRadioFavorites = async () => {
if (!isActiveServerReachable()) return;
try {
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size > 0) {
const all = await getInternetRadioStations();
if (favIds.size === 0) return;
const all = await getInternetRadioStations();
if (!cancelled) {
setRadioStations(all.filter(s => favIds.has(s.id)));
}
} catch { /* ignore */ }
setLoading(false);
};
loadAll();
}, [musicLibraryFilterVersion]);
const loadAll = async () => {
setLoading(true);
if (favoritesOfflineEnabled) {
try {
applyStarred(await loadStarredFromAllLibraryIndexes());
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
if (connStatus === 'connected' && isActiveServerReachable()) {
try {
applyStarred(await loadStarredFromAllServersOnline());
} catch { /* keep library snapshot */ }
}
} else {
if (connStatus === 'connected' && isActiveServerReachable()) {
const [starredResult] = await Promise.allSettled([getStarred()]);
if (starredResult.status === 'fulfilled') {
applyStarred(starredResult.value);
}
}
if (!cancelled) setLoading(false);
}
void loadRadioFavorites();
};
void loadAll();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, servers]);
// ── Top Favorite Artists aggregated from favorited songs ─────────────
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
const counts = new Map<string, TopFavoriteArtist>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = s.artistId || s.artist;
const key = topArtistKey(s);
if (!key) continue;
const existing = counts.get(key);
if (existing) {
@@ -71,6 +118,8 @@ export function useFavoritesData(): FavoritesDataResult {
name: s.artist || key,
count: 1,
coverArtId: s.artistId || '',
serverId: s.serverId,
artistId: s.artistId || s.artist,
});
}
}
+87
View File
@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { useFavoritesOfflineSyncStore } from '../store/favoritesOfflineSyncStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { FAVORITES_OFFLINE_JOB_ID } from '../utils/offline/favoritesOfflineConstants';
import { entryBelongsToServer } from '../utils/offline/offlineLibraryHelpers';
export type FavoritesOfflineUiStatus =
| 'disabled'
| 'syncing'
| 'complete'
| 'partial'
| 'error'
| 'idle';
export type FavoritesOfflineSemaphore = 'red' | 'yellow' | 'green';
export interface FavoritesOfflineStatusResult {
enabled: boolean;
status: FavoritesOfflineUiStatus;
semaphore: FavoritesOfflineSemaphore | null;
savedCount: number;
targetCount: number;
jobDone: number;
jobTotal: number;
}
export function useFavoritesOfflineStatus(): FavoritesOfflineStatusResult {
const enabled = useAuthStore(s => s.favoritesOfflineEnabled);
const serverId = useAuthStore(s => s.activeServerId);
const entries = useLocalPlaybackStore(s => s.entries);
const running = useFavoritesOfflineSyncStore(s => s.running);
const lastError = useFavoritesOfflineSyncStore(s => s.lastError);
const targetTrackIds = useFavoritesOfflineSyncStore(s => s.targetTrackIds);
const jobs = useOfflineJobStore(s => s.jobs);
return useMemo(() => {
if (!enabled) {
return {
enabled: false,
status: 'disabled' as const,
semaphore: null,
savedCount: 0,
targetCount: 0,
jobDone: 0,
jobTotal: 0,
};
}
const favJobs = jobs.filter(j => j.albumId === FAVORITES_OFFLINE_JOB_ID);
const jobDone = favJobs.filter(j => j.status === 'done').length;
const jobTotal = favJobs.length;
const hasActiveJobs = favJobs.some(j => j.status === 'downloading' || j.status === 'queued');
const hasJobErrors = favJobs.some(j => j.status === 'error');
const savedCount = serverId
? Object.values(entries).filter(
e => e.tier === 'favorite-auto' && entryBelongsToServer(e, serverId),
).length
: 0;
const targetCount = targetTrackIds.length;
let status: FavoritesOfflineUiStatus = 'idle';
if (running || hasActiveJobs) {
status = 'syncing';
} else if (lastError || hasJobErrors) {
status = 'error';
} else if (targetCount > 0 && savedCount >= targetCount) {
status = 'complete';
} else if (savedCount > 0 && targetCount > 0 && savedCount < targetCount) {
status = 'partial';
} else if (savedCount > 0) {
status = 'complete';
}
let semaphore: FavoritesOfflineSemaphore = 'green';
if (lastError || hasJobErrors) {
semaphore = 'red';
} else if (running || hasActiveJobs || (targetCount > 0 && savedCount < targetCount)) {
semaphore = 'yellow';
}
return { enabled, status, semaphore, savedCount, targetCount, jobDone, jobTotal };
}, [enabled, serverId, entries, running, lastError, targetTrackIds, jobs]);
}
+8 -4
View File
@@ -78,11 +78,15 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
// Remove unfavorited
if (starredOverrides[s.id] === false) return false;
// Artist filter
// Artist filter (composite key when favorites span servers)
if (selectedArtist) {
const artistMatch = s.artistId === selectedArtist ||
s.artist === selectedArtist ||
s.albumArtist === selectedArtist;
const compositeKey = s.serverId
? `${s.serverId}:${s.artistId || s.artist}`
: (s.artistId || s.artist);
const artistMatch = selectedArtist === compositeKey
|| s.artistId === selectedArtist
|| s.artist === selectedArtist
|| s.albumArtist === selectedArtist;
if (!artistMatch) return false;
}
+3
View File
@@ -17,6 +17,9 @@ vi.mock('../api/subsonicArtists');
vi.mock('../api/subsonicLibrary');
vi.mock('../api/bandsintown');
vi.mock('../api/lastfm');
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
+18 -10
View File
@@ -8,6 +8,8 @@ import {
type LastfmArtistStats, type LastfmTrackInfo,
} from '../api/lastfm';
import { makeCache } from '../utils/cache/nowPlayingCache';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { useConnectionStatus } from './useConnectionStatus';
// Module-level TTL caches (shared across mounts)
const songMetaCache = makeCache<SubsonicSong | null>();
@@ -79,6 +81,7 @@ export async function prewarmNowPlayingFetchers(
} = deps;
if (!fetchEnabled || !subsonicServerId) return;
if (!shouldAttemptSubsonicForServer(subsonicServerId, songId)) return;
const jobs: Array<Promise<unknown>> = [];
@@ -196,9 +199,14 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
const [lfmArtistEntry, setLfmArtistEntry] = useState<KeySlot<LastfmArtistStats | null>>(() =>
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
const { status: connStatus } = useConnectionStatus();
const subsonicFetchAllowed = fetchEnabled
&& !!subsonicServerId
&& shouldAttemptSubsonicForServer(subsonicServerId, songId);
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !songId) { setSongMetaEntry(null); return; }
if (!subsonicFetchAllowed || !songId) { setSongMetaEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
const cached = songMetaCache.get(cacheKey);
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
@@ -208,10 +216,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, songId]);
}, [subsonicFetchAllowed, subsonicServerId, songId, connStatus]);
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
if (!subsonicFetchAllowed || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
@@ -221,10 +229,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, artistId, audiomuseNavidromeEnabled]);
}, [subsonicFetchAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !albumId) { setAlbumDataEntry(null); return; }
if (!subsonicFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
const cached = albumCache.get(cacheKey);
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
@@ -234,10 +242,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } })
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, albumId]);
}, [subsonicFetchAllowed, subsonicServerId, albumId, connStatus]);
useEffect(() => {
if (!fetchEnabled || !topSongsKey) { setTopSongsEntry(null); return; }
if (!subsonicFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
const cached = topSongsCache.get(topSongsKey);
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
setTopSongsEntry(null);
@@ -246,7 +254,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
return () => { cancelled = true; };
}, [fetchEnabled, topSongsKey, subsonicServerId, artistName]);
}, [subsonicFetchAllowed, topSongsKey, subsonicServerId, artistName, connStatus]);
useEffect(() => {
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
@@ -263,7 +271,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
// Discography via getArtist
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !artistId) { setDiscographyEntry(null); return; }
if (!subsonicFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = discographyCache.get(cacheKey);
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
@@ -273,7 +281,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscographyEntry({ id: artistId, value: v.albums }); } })
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, artistId]);
}, [subsonicFetchAllowed, subsonicServerId, artistId, connStatus]);
// Last.fm track info (per-track)
useEffect(() => {
+2 -1
View File
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { usePlaybackServerId } from './usePlaybackServerId';
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
const NOW_PLAYING_COVER_CSS_PX = 800;
@@ -64,7 +65,7 @@ export function useNowPlayingPrewarm(): void {
lastfmUsername,
currentTrack,
subsonicServerId: playbackServerId,
fetchEnabled: true,
fetchEnabled: shouldAttemptSubsonicForServer(playbackServerId, currentTrack.id),
});
if (currentTrack.albumId && currentTrack.id) {
+3 -2
View File
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { queueSongStar } from '../store/pendingStarSync';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import {
lastfmLoveTrack, lastfmUnloveTrack,
type LastfmTrackInfo,
} from '../api/lastfm';
export interface NowPlayingStarLoveDeps {
currentTrack: { id: string; title: string; artist: string } | null;
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
songMeta: SubsonicSong | null;
lfmTrack: LastfmTrackInfo | null;
lfmLoveEnabled: boolean;
@@ -31,7 +32,7 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
if (!currentTrack) return;
const next = !starred;
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
queueSongStar(currentTrack.id, next);
queueSongStar(currentTrack.id, next, currentTrack.serverId);
}, [currentTrack, starred]);
// Last.fm love (seeded from track.getInfo, toggle via love/unlove)
+24 -8
View File
@@ -4,10 +4,11 @@ import type { NavigateFunction } from 'react-router-dom';
type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
/**
* Auto-route the user between the offline library and main pages based on
* Auto-route the user between offline-capable pages and main pages based on
* connection status:
* - Disconnect with cached content push `/offline`.
* - Reconnect while sitting on `/offline` push back to `/`.
* - Disconnect with manual offline pins push `/offline`.
* - Disconnect with favorites offline browse enabled push `/favorites`.
* - Reconnect while sitting on `/offline` or `/favorites` push back to `/`.
*
* Only fires on transitions (not on every render). Reconnect-bounce is
* gated on `prev === 'disconnected'` so a user who navigates to `/offline`
@@ -15,7 +16,8 @@ type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
*/
export function useOfflineAutoNav(
connStatus: ConnStatus | string,
hasOfflineContent: boolean,
hasManualOfflineContent: boolean,
favoritesOfflineBrowse: boolean,
pathname: string,
navigate: NavigateFunction,
): void {
@@ -24,11 +26,25 @@ export function useOfflineAutoNav(
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
navigate('/offline', { replace: true });
if (connStatus === 'disconnected' && prev !== 'disconnected') {
if (hasManualOfflineContent) {
navigate('/offline', { replace: true });
} else if (favoritesOfflineBrowse) {
navigate('/favorites', { replace: true });
}
}
if (connStatus === 'connected' && prev === 'disconnected' && pathname === '/offline') {
if (
connStatus === 'connected'
&& prev === 'disconnected'
&& (pathname === '/offline' || pathname === '/favorites')
) {
navigate('/', { replace: true });
}
}, [connStatus, hasOfflineContent, pathname, navigate]);
}, [
connStatus,
hasManualOfflineContent,
favoritesOfflineBrowse,
pathname,
navigate,
]);
}
+17
View File
@@ -31,6 +31,23 @@ describe('usePlaybackServerId', () => {
expect(result.current).toBe('a');
});
it('updates when the playing slot moves in a mixed-server queue', () => {
useAuthStore.setState({ activeServerId: 'a' });
usePlayerStore.setState({
queueItems: [
{ serverId: 'a.test', trackId: 't1' },
{ serverId: 'b.test', trackId: 't2' },
],
queueServerId: 'a.test',
queueIndex: 0,
});
const { result, rerender } = renderHook(() => usePlaybackServerId());
expect(result.current).toBe('a');
usePlayerStore.setState({ queueIndex: 1 });
rerender();
expect(result.current).toBe('b');
});
it('does not call switchActiveServer when browsed server changes', async () => {
const { switchActiveServer } = await import('../utils/server/switchActiveServer');
vi.mocked(switchActiveServer).mockClear();
+5 -2
View File
@@ -9,10 +9,13 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
*/
export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queueItems.length);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playingServerId = usePlayerStore(
s => s.queueItems[s.queueIndex]?.serverId ?? '',
);
const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo(
() => getPlaybackServerId(),
[queueServerId, queueLength, activeServerId],
[queueServerId, queueIndex, playingServerId, activeServerId],
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistSta
return next;
});
// F4: optimistic override + retried server sync via the central helper (no rollback).
queueSongStar(song.id, !isStarred);
queueSongStar(song.id, !isStarred, song.serverId);
};
return { handleRate, handleToggleStar };
+2 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAlbumList } from '../api/subsonicLibrary';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
import {
NEW_RELEASES_RESET_DELAY_MS,
NEW_RELEASES_SEEN_MAX_IDS,
@@ -62,7 +63,7 @@ export function useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pa
const seq = ++newReleasesRefreshSeqRef.current;
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
if (!isLoggedIn || !serverId) {
if (!isLoggedIn || !serverId || !isActiveServerReachable()) {
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
+59 -23
View File
@@ -4,8 +4,10 @@ import type { QueueItemRef } from './store/playerStoreTypes';
import { resolveQueueTrack } from './utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './store/authStore';
import { useHotCacheStore } from './store/hotCacheStore';
import { useOfflineStore } from './store/offlineStore';
import { selectHotCacheEntries, useHotCacheStore } from './store/hotCacheStore';
import { useLocalPlaybackStore } from './store/localPlaybackStore';
import { getMediaDir } from './utils/media/mediaDir';
import { librarySqlServerId } from './api/coverCache';
import { usePlayerStore } from './store/playerStore';
import {
bumpHotCachePreviousTrackGrace,
@@ -25,6 +27,11 @@ import {
scheduleAnalysisQueuePruneFromPlaybackQueue,
resetAnalysisPruneState,
} from './hotCachePrefetch/analysisPrune';
import { reconcileEphemeralCache } from './utils/cache/ephemeralTierReconcile';
import { hasLocalPersistentPlaybackBytes } from './utils/offline/offlineLibraryHelpers';
/** Periodic index↔disk sync (stale rows + empty dirs); unindexed files evicted only on budget pressure. */
const EPHEMERAL_MAINTENANCE_MS = 10 * 60 * 1000;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
@@ -99,12 +106,16 @@ async function runWorker() {
continue;
}
const offline = useOfflineStore.getState();
if (offline.isDownloaded(job.trackId, job.serverId)) {
hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'offline-library' });
if (hasLocalPersistentPlaybackBytes(job.trackId, job.serverId)) {
hotCacheFrontendDebug({
event: 'prefetch-skip-job',
trackId: job.trackId,
reason: 'persistent-local-bytes',
});
continue;
}
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) {
const hotIndex = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
if (hotIndex[entryKey(job.serverId, job.trackId)]) {
hotCacheFrontendDebug({
event: 'prefetch-skip-job',
trackId: job.trackId,
@@ -141,7 +152,7 @@ async function runWorker() {
continue;
}
const track = resolveQueueTrack(jobRef);
const hotEntries = useHotCacheStore.getState().entries;
const hotEntries = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
const occupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, job.serverId, hotEntries);
const est = estimateTrackHotCacheBytes(track);
const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId;
@@ -159,16 +170,27 @@ async function runWorker() {
const url = buildStreamUrlForServer(job.serverId, job.trackId);
try {
const customDir = auth.hotCacheDownloadDir || null;
const mediaDir = getMediaDir();
hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId });
const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', {
const res = await invoke<{ path: string; size: number; layoutFingerprint: string }>('download_track_local', {
tier: 'ephemeral',
trackId: job.trackId,
serverId: job.serverId,
serverIndexKey: job.serverId,
libraryServerId: librarySqlServerId(job.serverId),
url,
suffix: job.suffix,
customDir,
mediaDir,
downloadId: null,
});
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size, 'prefetch');
useHotCacheStore.getState().setEntry(
job.trackId,
job.serverId,
res.path,
res.size,
'prefetch',
res.layoutFingerprint,
job.suffix,
);
hotCacheFrontendDebug({ event: 'prefetch-stored', trackId: job.trackId, sizeBytes: res.size });
const fresh = usePlayerStore.getState();
const authAfter = useAuthStore.getState();
@@ -178,10 +200,19 @@ async function runWorker() {
fresh.queueIndex,
maxAfter,
getPlaybackCacheServerKey(),
authAfter.hotCacheDownloadDir || null,
getMediaDir(),
);
} catch (e: unknown) {
hotCacheFrontendDebug({ event: 'prefetch-download-failed', trackId: job.trackId, error: String(e) });
const msg = String(e);
if (msg.includes('TRACK_NOT_INDEXED')) {
hotCacheFrontendDebug({
event: 'prefetch-skip-job',
trackId: job.trackId,
reason: 'track-not-indexed',
});
continue;
}
hotCacheFrontendDebug({ event: 'prefetch-download-failed', trackId: job.trackId, error: msg });
}
}
} finally {
@@ -216,7 +247,7 @@ async function replanNow() {
const serverId = playbackSid;
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
const customDir = auth.hotCacheDownloadDir || null;
const mediaDir = getMediaDir();
if (maxBytes <= 0) return;
const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState();
@@ -225,13 +256,11 @@ async function replanNow() {
return;
}
const offline = useOfflineStore.getState();
await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, customDir);
await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, mediaDir);
// Must read entries after eviction: the pre-evict snapshot still lists removed keys and would
// skip prefetch for upcoming tracks that no longer have on-disk rows.
const hotEntries = useHotCacheStore.getState().entries;
const hotEntries = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
// Thin-state: resolve only the small upcoming window (within the resolver-warm
// range) to full Tracks for the size estimates / suffix.
@@ -242,8 +271,8 @@ async function replanNow() {
const jobs: PrefetchJob[] = [];
const skipped: { trackId: string; reason: string }[] = [];
for (const t of targets) {
if (offline.isDownloaded(t.id, serverId)) {
skipped.push({ trackId: t.id, reason: 'offline-library' });
if (hasLocalPersistentPlaybackBytes(t.id, serverId)) {
skipped.push({ trackId: t.id, reason: 'persistent-local-bytes' });
continue;
}
if (hotEntries[entryKey(serverId, t.id)]) {
@@ -307,7 +336,7 @@ export function initHotCachePrefetch(): () => void {
let lastAuthSig = '';
const unsubAuth = useAuthStore.subscribe((state, prev) => {
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.mediaDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
if (sig === lastAuthSig) return;
lastAuthSig = sig;
@@ -326,7 +355,7 @@ export function initHotCachePrefetch(): () => void {
const budgetSettingsChanged =
!prev ||
state.hotCacheMaxMb !== prev.hotCacheMaxMb ||
state.hotCacheDownloadDir !== prev.hotCacheDownloadDir ||
state.mediaDir !== prev.mediaDir ||
state.hotCacheEnabled !== prev.hotCacheEnabled ||
state.activeServerId !== prev.activeServerId ||
state.isLoggedIn !== prev.isLoggedIn;
@@ -356,6 +385,12 @@ export function initHotCachePrefetch(): () => void {
void replanNow();
scheduleAnalysisQueuePruneFromPlaybackQueue();
const maintenanceTimer = window.setInterval(() => {
const auth = useAuthStore.getState();
if (!auth.isLoggedIn || !auth.hotCacheEnabled) return;
void reconcileEphemeralCache();
}, EPHEMERAL_MAINTENANCE_MS);
return () => {
unsubPlayer();
unsubAuth();
@@ -363,6 +398,7 @@ export function initHotCachePrefetch(): () => void {
debounceTimer = null;
if (graceEvictTimer) clearTimeout(graceEvictTimer);
graceEvictTimer = null;
window.clearInterval(maintenanceTimer);
resetAnalysisPruneState();
pendingQueue.length = 0;
clearHotCachePreviousGrace();
+64
View File
@@ -0,0 +1,64 @@
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { libraryGetTrack } from './api/library';
import { useAuthStore } from './store/authStore';
import { useLocalPlaybackStore } from './store/localPlaybackStore';
import { layoutFingerprintFromLibraryTrack } from './utils/media/mediaLayout';
import { getMediaDir } from './utils/media/mediaDir';
import { runLegacyOfflineFileMigration } from './utils/migrations/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from './utils/offline/libraryTierReconcile';
import { resolveServerIdForIndexKey } from './utils/server/serverLookup';
import { serverIndexKeyFromUrl } from './utils/server/serverIndexKey';
async function invalidateEntriesForLibraryServer(libraryServerId: string): Promise<void> {
const store = useLocalPlaybackStore.getState();
const mediaDir = getMediaDir();
const targets = Object.values(store.entries).filter(
e =>
(e.tier === 'library' || e.tier === 'favorite-auto')
&& resolveServerIdForIndexKey(e.serverIndexKey) === libraryServerId,
);
for (const entry of targets) {
const track = await libraryGetTrack(libraryServerId, entry.trackId).catch(() => null);
if (!track) {
await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {});
store.removeEntry(entry.trackId, entry.serverIndexKey, 'sync-track-removed');
continue;
}
if (!entry.layoutFingerprint) continue;
const nextFp = layoutFingerprintFromLibraryTrack(track, entry.suffix);
if (nextFp !== entry.layoutFingerprint) {
await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {});
store.removeEntry(entry.trackId, entry.serverIndexKey, 'sync-layout-changed');
}
}
}
function serverIndexKeyForLibraryId(libraryServerId: string): string | undefined {
const server = useAuthStore.getState().servers.find(s => s.id === libraryServerId);
if (!server) return undefined;
return serverIndexKeyFromUrl(server.url) || server.id;
}
/** Drop stale local files after library sync; relocate legacy offline bytes when index is ready. */
export function initLocalPlaybackInvalidation(): () => void {
let unlisten: (() => void) | null = null;
void listen<{ serverId?: string }>('library:sync-idle', ({ payload }) => {
const scopeId = payload?.serverId?.trim();
if (!scopeId) return;
void (async () => {
const profileId = resolveServerIdForIndexKey(scopeId) || scopeId;
const indexKey = serverIndexKeyForLibraryId(profileId);
await runLegacyOfflineFileMigration(indexKey);
await reconcileLibraryTierForServer(profileId);
await invalidateEntriesForLibraryServer(profileId);
})();
}).then(fn => {
unlisten = fn;
});
return () => {
unlisten?.();
unlisten = null;
};
}
+2
View File
@@ -13,8 +13,10 @@ export const albumDetail = {
downloading: 'Lade…',
cacheOffline: 'Offline verfügbar machen',
offlineCached: 'Offline verfügbar',
offlineQueued: 'In Warteschlange',
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
removeOffline: 'Offline-Cache löschen',
removeFromOfflineQueue: 'Aus Offline-Warteschlange entfernen',
offlineStorageFull: 'Offline-Speicher voll (Limit: {{mb}} MB). Lösche ein Album aus der Offline-Bibliothek oder erhöhe das Limit in den Einstellungen.',
offlineStorageGoToSettings: 'Einstellungen',
offlineStorageGoToLibrary: 'Offline-Bibliothek',
+1
View File
@@ -33,6 +33,7 @@ export const artistDetail = {
similarArtists: 'Ähnliche Künstler',
cacheOffline: 'Diskografie offline speichern',
offlineCached: 'Diskografie gecacht',
offlineQueued: 'In Warteschlange',
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
uploadImage: 'Künstlerbild hochladen',
uploadImageError: 'Bild konnte nicht hochgeladen werden',
+10
View File
@@ -10,6 +10,11 @@ export const connection = {
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
offlineLibraryTitle: 'Offline-Bibliothek',
offlineLibraryDiskLabel: 'Auf dem Datenträger',
offlineLibraryDiskUsage: 'Auf dem Datenträger: {{size}}',
offlineLibraryDiskUsageLoading: 'Auf dem Datenträger: …',
offlineLibraryDiskTierLibrary: 'Offline-Bibliothek (media/library): {{size}}',
offlineLibraryDiskTierFavorites: 'Favoriten (media/favorites): {{size}}',
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
offlineCachedOnServer: 'Auf {{server}}',
offlineAlbumCount: '{{n}} Album',
@@ -18,6 +23,11 @@ export const connection = {
offlineFilterAlbums: 'Alben',
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Diskografien',
offlineCacheQueueTitle: 'Offline-Cache',
offlineCacheQueueSubtitle: '{{n}} Titel auf dem Datenträger — mischen und abspielen',
offlineCacheQueuePlayAria: 'Alle gecachten Titel mischen und abspielen',
offlineFavoritesQueueTitle: 'Favoriten',
offlineFavoritesQueuePlayAria: 'Alle offline gespeicherten Favoriten mischen und abspielen',
retry: 'Erneut versuchen',
serverSettings: 'Server-Einstellungen',
switchServerTitle: 'Server wechseln',
+5
View File
@@ -16,4 +16,9 @@ export const favorites = {
topArtists: 'Top-Künstler nach Favoriten',
topArtistsSongCount_one: '{{count}} Song',
topArtistsSongCount_other: '{{count}} Songs',
offlineTooltip: 'Favoriten für Offline-Wiedergabe speichern',
offlineJobName: 'Favoriten',
offlineSemaphoreSyncing: 'Synchronisiere Favoriten… {{saved}} von {{total}}',
offlineSemaphoreSynced: 'Alle Favoriten für Offline gespeichert',
offlineSemaphoreError: 'Sync-Fehler — einige Titel konnten nicht gespeichert werden',
};
+10 -3
View File
@@ -145,16 +145,23 @@ export const settings = {
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
behavior: 'App-Verhalten',
cacheTitle: 'Max. Speichergröße',
cacheDesc: 'Cover und Künstlerbilder. Wenn voll, werden die ältesten Einträge automatisch entfernt. Offline-Alben werden nicht automatisch gelöscht, aber beim manuellen Cache leeren entfernt.',
cacheDesc: 'Browser-Cache für Cover und Künstlerbilder (IndexedDB). Wenn voll, werden die ältesten Einträge automatisch entfernt.',
cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Offline-Tracks:',
cacheUsedHot: 'Belegter Speicher:',
hotCacheTrackCount: 'Titel im Cache:',
cacheMaxLabel: 'Max. Größe',
cacheClearBtn: 'Cache leeren',
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
cacheClearConfirm: 'Alles löschen',
cacheClearWarning: 'Löscht alle gecachten Cover- und Künstlerbilder im Browser-Speicher. Sie werden bei Bedarf neu geladen.',
cacheClearConfirm: 'Bild-Cache leeren',
cacheClearCancel: 'Abbrechen',
mediaDirTitle: 'Medienordner',
mediaDirDesc: 'Stammordner für lokale Wiedergabe. Offline-Bibliothek: library/; Warteschlangen-Puffer: cache/.',
mediaDirDefault: 'Standard (App-Daten)',
mediaDirChange: 'Ordner ändern',
mediaDirClear: 'Auf Standard zurücksetzen',
mediaDirHint: 'Neue Downloads nutzen diesen Ort. Bestehende Dateien bleiben am ursprünglichen Pfad, bis du sie entfernst.',
offlineStorageTitle: 'Offline-Speicher',
offlineDirTitle: 'Offline-Bibliothek (In-App)',
offlineDirDesc: 'Speicherort für Titel, die du innerhalb von Psysonic offline verfügbar machst.',
offlineDirDefault: 'Standard (App-Daten)',
+2
View File
@@ -13,8 +13,10 @@ export const albumDetail = {
downloading: 'Loading…',
cacheOffline: 'Make available offline',
offlineCached: 'Available offline',
offlineQueued: 'In queue',
offlineDownloading: 'Caching… ({{n}}/{{total}})',
removeOffline: 'Remove offline cache',
removeFromOfflineQueue: 'Remove from offline queue',
offlineStorageFull: 'Offline storage full (limit: {{mb}} MB). Free up space by deleting an album from your Offline Library, or increase the limit in Settings.',
offlineStorageGoToLibrary: 'Offline Library',
offlineStorageGoToSettings: 'Settings',
+1
View File
@@ -35,6 +35,7 @@ export const artistDetail = {
similarArtists: 'Similar Artists',
cacheOffline: 'Save discography offline',
offlineCached: 'Discography cached',
offlineQueued: 'In queue',
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
uploadImage: 'Upload artist image',
uploadImageError: 'Failed to upload image',
+11
View File
@@ -10,7 +10,13 @@ export const connection = {
offlineModeBanner: 'Offline Mode — playing from local cache',
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
offlineLibraryTitle: 'Offline Library',
offlineLibraryDiskLabel: 'On disk',
offlineLibraryDiskUsage: 'On disk: {{size}}',
offlineLibraryDiskUsageLoading: 'On disk: …',
offlineLibraryDiskTierLibrary: 'Offline library (media/library): {{size}}',
offlineLibraryDiskTierFavorites: 'Favorites (media/favorites): {{size}}',
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
offlinePlaybackUnavailable: 'Local files for this album are missing or not indexed.',
offlineCachedOnServer: 'On {{server}}',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
@@ -18,6 +24,11 @@ export const connection = {
offlineFilterAlbums: 'Albums',
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Discographies',
offlineCacheQueueTitle: 'Queue cache',
offlineCacheQueueSubtitle: '{{n}} tracks in media/cache — shuffle and play',
offlineCacheQueuePlayAria: 'Shuffle and play all cached tracks',
offlineFavoritesQueueTitle: 'Favorites',
offlineFavoritesQueuePlayAria: 'Shuffle and play all favorites saved offline',
retry: 'Retry',
serverSettings: 'Server Settings',
switchServerTitle: 'Switch server',
+5
View File
@@ -16,4 +16,9 @@ export const favorites = {
topArtists: 'Top Artists by Favorites',
topArtistsSongCount_one: '{{count}} song',
topArtistsSongCount_other: '{{count}} songs',
offlineTooltip: 'Save your favorites for offline listening',
offlineJobName: 'Favorites',
offlineSemaphoreSyncing: 'Syncing favorites… {{saved}} of {{total}}',
offlineSemaphoreSynced: 'All favorites saved for offline listening',
offlineSemaphoreError: 'Sync error — some tracks could not be saved',
};
+13 -3
View File
@@ -145,12 +145,15 @@ export const settings = {
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
behavior: 'App Behavior',
cacheTitle: 'Max. Storage Size',
cacheDesc: 'Cover art and artist images. When full, the oldest entries are removed automatically. Offline albums are not auto-removed, but will be deleted when clearing the cache manually.',
cacheDesc: 'In-browser cache for cover art and artist images (IndexedDB). Oldest entries are removed automatically when space runs low.',
cacheUsedImages: 'Images:',
cacheUsedOffline: 'Offline tracks:',
cacheUsedHot: 'Size on disk:',
hotCacheTrackCount: 'Tracks in cache:',
cacheMaxLabel: 'Max. size',
imageCacheScopeLabel: 'Scope',
imageCacheSubTitle: 'In-browser previews',
coverDiskCacheSubTitle: 'On-disk cover cache',
coverCacheStrategyTitle: 'Cover art cache',
coverCacheStrategyDesc: 'You can choose a strategy for each server: Lazy — load covers as needed; Aggressive — load as needed plus extra background preloading.',
coverCacheStrategyLabel: 'Strategy',
@@ -172,12 +175,19 @@ export const settings = {
coverCacheStrategyClearError: 'Failed to clear cover cache.',
coverCacheStrategyServerRemoved: 'removed server',
cacheClearBtn: 'Clear Cache',
cacheClearWarning: 'This will also remove all offline albums from the library.',
cacheClearConfirm: 'Clear Everything',
cacheClearWarning: 'Clears all cached cover and artist images from in-browser storage. They will be re-downloaded when needed.',
cacheClearConfirm: 'Clear image cache',
cacheClearCancel: 'Cancel',
waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).',
waveformCacheClearFailed: 'Failed to clear waveform cache.',
storageTabTitle: 'Offline & cache',
mediaDirTitle: 'Media folder',
mediaDirDesc: 'Root folder for local playback. Offline library uses library/; queue prefetch uses cache/.',
mediaDirDefault: 'Default (App Data)',
mediaDirChange: 'Change folder',
mediaDirClear: 'Reset to default',
mediaDirHint: 'New downloads use this location. Existing files stay at their original paths until removed.',
offlineStorageTitle: 'Offline library storage',
offlineDirTitle: 'Offline Library (In-App)',
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
offlineDirDefault: 'Default (App Data)',
+2
View File
@@ -18,6 +18,8 @@ export const sidebar = {
collapse: 'Collapse Sidebar',
downloadingTracks: 'Caching {{n}} tracks…',
offlinePinActive: 'Caching «{{name}}»…',
offlinePinActiveQueued: 'Caching «{{name}}» · +{{queued}} queued',
syncingTracks: 'Syncing {{done}}/{{total}}…',
cancelDownload: 'Cancel download',
offlineLibrary: 'Offline Library',

Some files were not shown because too many files have changed in this diff Show More