mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
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:
@@ -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
|
## Changed
|
||||||
|
|
||||||
### Dependencies — npm and Rust refresh
|
### Dependencies — npm and Rust refresh
|
||||||
|
|||||||
Generated
+1
@@ -4266,6 +4266,7 @@ dependencies = [
|
|||||||
"psysonic-analysis",
|
"psysonic-analysis",
|
||||||
"psysonic-audio",
|
"psysonic-audio",
|
||||||
"psysonic-core",
|
"psysonic-core",
|
||||||
|
"psysonic-library",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ resolver = "2"
|
|||||||
version = "1.48.0-dev"
|
version = "1.48.0-dev"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -18,7 +19,7 @@ name = "psysonic"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license.workspace = true
|
||||||
repository = ""
|
repository = ""
|
||||||
default-run = "psysonic"
|
default-run = "psysonic"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ accepted = [
|
|||||||
"OpenSSL",
|
"OpenSSL",
|
||||||
"BSL-1.0",
|
"BSL-1.0",
|
||||||
"CDLA-Permissive-2.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
|
# 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-build-dependencies = false
|
||||||
ignore-dev-dependencies = true
|
ignore-dev-dependencies = true
|
||||||
ignore-transitive-dependencies = false
|
ignore-transitive-dependencies = false
|
||||||
filter-noassertion = false
|
|
||||||
workarounds = ["ring"]
|
workarounds = ["ring"]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[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
|
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`].
|
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
|
||||||
pub async fn enqueue_analysis_seed(
|
pub async fn enqueue_analysis_seed(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
|
|||||||
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
|
|||||||
track_id: &str,
|
track_id: &str,
|
||||||
content_hash: &str,
|
content_hash: &str,
|
||||||
) -> TrackAnalysisPlan {
|
) -> TrackAnalysisPlan {
|
||||||
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
|
||||||
let enrichment = enrichment_plan(app, 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 {
|
TrackAnalysisPlan {
|
||||||
need_waveform,
|
need_waveform,
|
||||||
need_loudness,
|
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(
|
fn enrichment_plan(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
@@ -117,6 +156,45 @@ fn enrichment_plan(
|
|||||||
.unwrap_or_default()
|
.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(
|
fn cache_gaps_for_content(
|
||||||
cache: Option<&AnalysisCache>,
|
cache: Option<&AnalysisCache>,
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
@@ -194,4 +272,14 @@ mod tests {
|
|||||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name = "psysonic-core"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
|||||||
|| id.starts_with("ra-")
|
|| 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).
|
/// 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 {
|
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||||
let trimmed = segment.trim();
|
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return "_".to_string();
|
return "_".to_string();
|
||||||
}
|
}
|
||||||
trimmed
|
let cleaned: String = trimmed
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
.map(|c| {
|
||||||
.collect()
|
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.
|
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||||
@@ -256,6 +277,13 @@ mod tests {
|
|||||||
base
|
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]
|
#[test]
|
||||||
fn segment_disk_usage_counts_canonical_only() {
|
fn segment_disk_usage_counts_canonical_only() {
|
||||||
let server = test_server_dir("usage");
|
let server = test_server_dir("usage");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||||
|
|
||||||
pub mod cover_cache_layout;
|
pub mod cover_cache_layout;
|
||||||
|
pub mod media_layout;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod track_analysis;
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name = "psysonic-library"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album(
|
|||||||
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
|
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]
|
#[tauri::command]
|
||||||
pub async fn library_get_artifact(
|
pub async fn library_get_artifact(
|
||||||
runtime: State<'_, LibraryRuntime>,
|
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
|
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
|
||||||
/// preserve their order in the result; unknown / deleted refs
|
/// preserve their order in the result; unknown / deleted refs
|
||||||
/// are silently dropped (frontend reads `tracks.length` against
|
/// 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.
|
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
|
||||||
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
|
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
|
||||||
pub fn list_analysis_hash_bpm_ids_after(
|
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 \
|
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";
|
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, \
|
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, \
|
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, \
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
psysonic-core = { path = "../psysonic-core" }
|
psysonic-core = { path = "../psysonic-core" }
|
||||||
|
psysonic-library = { path = "../psysonic-library" }
|
||||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||||
psysonic-audio = { path = "../psysonic-audio" }
|
psysonic-audio = { path = "../psysonic-audio" }
|
||||||
|
|
||||||
|
|||||||
+100
-2
@@ -1,4 +1,4 @@
|
|||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Recursively sums the size of all files under `root`.
|
/// Recursively sums the size of all files under `root`.
|
||||||
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
|
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
|
||||||
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
|||||||
total
|
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`
|
/// 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
|
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
|
||||||
/// boundary is reached, or removal fails.
|
/// 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
|
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
|
||||||
/// not under `boundary`, the function is a no-op.
|
/// not under `boundary`, the function is a no-op.
|
||||||
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
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());
|
let mut current = Some(start_dir.to_path_buf());
|
||||||
while let Some(dir) = current {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
match std::fs::read_dir(&dir) {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -87,6 +153,17 @@ mod tests {
|
|||||||
assert!(path.exists(), "boundary dir must never be removed");
|
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]
|
#[test]
|
||||||
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
|
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
@@ -98,4 +175,25 @@ mod tests {
|
|||||||
assert!(!child.exists(), "empty leaf should be pruned");
|
assert!(!child.exists(), "empty leaf should be pruned");
|
||||||
assert!(parent.exists(), "non-empty parent must stay");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1422
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ mod fs_utils;
|
|||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
pub mod hot;
|
pub mod hot;
|
||||||
|
pub mod local;
|
||||||
|
|||||||
@@ -715,6 +715,7 @@ pub fn run() {
|
|||||||
psysonic_library::commands::library_get_track,
|
psysonic_library::commands::library_get_track,
|
||||||
psysonic_library::commands::library_get_tracks_batch,
|
psysonic_library::commands::library_get_tracks_batch,
|
||||||
psysonic_library::commands::library_get_tracks_by_album,
|
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_artifact,
|
||||||
psysonic_library::commands::library_get_facts,
|
psysonic_library::commands::library_get_facts,
|
||||||
psysonic_library::commands::library_get_offline_path,
|
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::clear_offline_cancel,
|
||||||
psysonic_syncfs::cache::offline::delete_offline_track,
|
psysonic_syncfs::cache::offline::delete_offline_track,
|
||||||
psysonic_syncfs::cache::offline::get_offline_cache_size,
|
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::download_track_hot_cache,
|
||||||
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
||||||
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
||||||
|
|||||||
@@ -473,6 +473,18 @@ export function libraryGetTrack(
|
|||||||
.then(track => (track ? { ...track, serverId } : track));
|
.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[]> {
|
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||||
const indexKeyMap = new Map<string, string>();
|
const indexKeyMap = new Map<string, string>();
|
||||||
const remapped = refs.map(ref => {
|
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(
|
export function libraryGetTracksByAlbum(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
albumId: string,
|
albumId: string,
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
|
|||||||
default: { get: vi.fn() },
|
default: { get: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||||
|
shouldAttemptSubsonicForActiveServer: () => true,
|
||||||
|
shouldAttemptSubsonicForServer: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { pingWithCredentials, ping } from './subsonic';
|
import { pingWithCredentials, ping } from './subsonic';
|
||||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import {
|
||||||
|
shouldAttemptSubsonicForActiveServer,
|
||||||
|
shouldAttemptSubsonicForServer,
|
||||||
|
} from '../utils/network/subsonicNetworkGuard';
|
||||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||||
import type {
|
import type {
|
||||||
RandomSongsFilters,
|
RandomSongsFilters,
|
||||||
@@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||||
|
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||||
type: 'random',
|
type: 'random',
|
||||||
size,
|
size,
|
||||||
@@ -71,6 +76,7 @@ export async function getAlbumList(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
extra: Record<string, unknown> = {}
|
extra: Record<string, unknown> = {}
|
||||||
): Promise<SubsonicAlbum[]> {
|
): Promise<SubsonicAlbum[]> {
|
||||||
|
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||||
type,
|
type,
|
||||||
size,
|
size,
|
||||||
@@ -212,6 +218,7 @@ export async function getAlbumListForServer(
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
extra: Record<string, unknown> = {},
|
extra: Record<string, unknown> = {},
|
||||||
): Promise<SubsonicAlbum[]> {
|
): Promise<SubsonicAlbum[]> {
|
||||||
|
if (!shouldAttemptSubsonicForServer(serverId)) return [];
|
||||||
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
|
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
|
||||||
type,
|
type,
|
||||||
size,
|
size,
|
||||||
@@ -224,6 +231,7 @@ export async function getAlbumListForServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||||
|
if (!shouldAttemptSubsonicForActiveServer()) return null;
|
||||||
try {
|
try {
|
||||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||||
return data.song ?? null;
|
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> {
|
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
|
||||||
|
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
|
||||||
try {
|
try {
|
||||||
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
|
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
|
||||||
return data.song ?? null;
|
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[] }> {
|
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 data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||||
const { song, ...album } = data.album;
|
const { song, ...album } = data.album;
|
||||||
return { album, songs: song ?? [] };
|
return { album, songs: song ?? [] };
|
||||||
@@ -251,6 +263,9 @@ export async function getAlbumForServer(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
): 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 data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
|
||||||
const { song, ...album } = data.album;
|
const { song, ...album } = data.album;
|
||||||
return { album, songs: song ?? [] };
|
return { album, songs: song ?? [] };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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';
|
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
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 ?? [] };
|
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> {
|
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
|
||||||
const params: Record<string, unknown> = { name };
|
const params: Record<string, unknown> = { name };
|
||||||
if (songIds && songIds.length > 0) {
|
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),
|
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
void import('../utils/offline/pinnedOfflineSync')
|
||||||
|
.then(m => m.schedulePinnedPlaylistSync(id))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlaylistMeta(
|
export async function updatePlaylistMeta(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
|
|||||||
api: vi.fn(),
|
api: vi.fn(),
|
||||||
apiForServer: apiForServerMock,
|
apiForServer: apiForServerMock,
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||||
|
shouldAttemptSubsonicForServer: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('subsonicScrobble', () => {
|
describe('subsonicScrobble', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api, apiForServer } from './subsonicClient';
|
import { api, apiForServer } from './subsonicClient';
|
||||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||||
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
||||||
|
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||||
|
|
||||||
async function scrobbleOnServer(
|
async function scrobbleOnServer(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
@@ -8,6 +9,7 @@ async function scrobbleOnServer(
|
|||||||
submission: boolean,
|
submission: boolean,
|
||||||
time?: number,
|
time?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
|
||||||
const params: Record<string, unknown> = { id, submission };
|
const params: Record<string, unknown> = { id, submission };
|
||||||
if (time !== undefined) params.time = time;
|
if (time !== undefined) params.time = time;
|
||||||
await apiForServer(serverId, 'scrobble.view', params);
|
await apiForServer(serverId, 'scrobble.view', params);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api, libraryFilterParams } from './subsonicClient';
|
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||||
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
|
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
|
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
|
||||||
@@ -15,6 +15,17 @@ import type {
|
|||||||
SubsonicSong,
|
SubsonicSong,
|
||||||
} from './subsonicTypes';
|
} 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> {
|
export async function getStarred(): Promise<StarredResults> {
|
||||||
const data = await api<{
|
const data = await api<{
|
||||||
starred2: {
|
starred2: {
|
||||||
@@ -23,21 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
|
|||||||
song?: SubsonicSong[];
|
song?: SubsonicSong[];
|
||||||
}
|
}
|
||||||
}>('getStarred2.view', { ...libraryFilterParams() });
|
}>('getStarred2.view', { ...libraryFilterParams() });
|
||||||
const r = data.starred2 ?? {};
|
return parseStarred2Response(data);
|
||||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
}
|
||||||
|
|
||||||
|
/** 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(
|
export async function star(
|
||||||
id: string,
|
id: string,
|
||||||
type: 'song' | 'album' | 'artist' = 'album',
|
type: 'song' | 'album' | 'artist' = 'album',
|
||||||
_meta?: StarPatchMeta,
|
meta?: StarPatchMeta,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (type === 'song') params.id = id;
|
if (type === 'song') params.id = id;
|
||||||
if (type === 'album') params.albumId = id;
|
if (type === 'album') params.albumId = id;
|
||||||
if (type === 'artist') params.artistId = id;
|
if (type === 'artist') params.artistId = id;
|
||||||
await api('star.view', params);
|
const serverId = resolveStarServerId(meta);
|
||||||
const serverId = useAuthStore.getState().activeServerId;
|
await starApi(serverId, 'star.view', params);
|
||||||
if (type === 'song') {
|
if (type === 'song') {
|
||||||
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
|
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
|
||||||
} else if (type === 'album' && serverId) {
|
} else if (type === 'album' && serverId) {
|
||||||
@@ -45,19 +85,22 @@ export async function star(
|
|||||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||||
}
|
}
|
||||||
|
void import('../utils/offline/favoritesOfflineSync')
|
||||||
|
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unstar(
|
export async function unstar(
|
||||||
id: string,
|
id: string,
|
||||||
type: 'song' | 'album' | 'artist' = 'album',
|
type: 'song' | 'album' | 'artist' = 'album',
|
||||||
_meta?: StarPatchMeta,
|
meta?: StarPatchMeta,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (type === 'song') params.id = id;
|
if (type === 'song') params.id = id;
|
||||||
if (type === 'album') params.albumId = id;
|
if (type === 'album') params.albumId = id;
|
||||||
if (type === 'artist') params.artistId = id;
|
if (type === 'artist') params.artistId = id;
|
||||||
await api('unstar.view', params);
|
const serverId = resolveStarServerId(meta);
|
||||||
const serverId = useAuthStore.getState().activeServerId;
|
await starApi(serverId, 'unstar.view', params);
|
||||||
if (type === 'song') {
|
if (type === 'song') {
|
||||||
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
|
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
|
||||||
} else if (type === 'album' && serverId) {
|
} else if (type === 'album' && serverId) {
|
||||||
@@ -65,6 +108,9 @@ export async function unstar(
|
|||||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
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> {
|
export async function setRating(id: string, rating: number): Promise<void> {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
|
|||||||
displayArtist?: string;
|
displayArtist?: string;
|
||||||
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
|
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
|
||||||
discTitles?: SubsonicDiscTitle[];
|
discTitles?: SubsonicDiscTitle[];
|
||||||
|
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||||
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsonicDiscTitle {
|
export interface SubsonicDiscTitle {
|
||||||
@@ -90,6 +92,8 @@ export interface SubsonicSong {
|
|||||||
trackPeak?: number;
|
trackPeak?: number;
|
||||||
albumPeak?: number;
|
albumPeak?: number;
|
||||||
};
|
};
|
||||||
|
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||||
|
serverId?: string;
|
||||||
/** OpenSubsonic: structured composer credit (string for back-compat). */
|
/** OpenSubsonic: structured composer credit (string for back-compat). */
|
||||||
displayComposer?: string;
|
displayComposer?: string;
|
||||||
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
|
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
|
||||||
@@ -144,6 +148,8 @@ export interface SubsonicArtist {
|
|||||||
starred?: string;
|
starred?: string;
|
||||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||||
userRating?: number;
|
userRating?: number;
|
||||||
|
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||||
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsonicGenre {
|
export interface SubsonicGenre {
|
||||||
|
|||||||
+17
-2
@@ -38,7 +38,11 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
|||||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||||
|
import {
|
||||||
|
hasOfflineBrowsingContent,
|
||||||
|
} from '../utils/offline/favoritesOfflineBrowse';
|
||||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||||
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||||
@@ -106,7 +110,12 @@ export function AppShell() {
|
|||||||
useNowPlayingPrewarm();
|
useNowPlayingPrewarm();
|
||||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
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 floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
|
|
||||||
@@ -134,7 +143,13 @@ export function AppShell() {
|
|||||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||||
}, [location.pathname, location.state]);
|
}, [location.pathname, location.state]);
|
||||||
|
|
||||||
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
|
useOfflineAutoNav(
|
||||||
|
connStatus,
|
||||||
|
hasManualOfflineContent,
|
||||||
|
favoritesOfflineBrowse,
|
||||||
|
location.pathname,
|
||||||
|
navigate,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeFromServerQueue();
|
initializeFromServerQueue();
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
|
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
|
||||||
import { initHotCachePrefetch } from '../hotCachePrefetch';
|
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 { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
|
||||||
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
|
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
|
||||||
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
|
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
|
||||||
@@ -98,6 +104,28 @@ export default function MainApp() {
|
|||||||
return initHotCachePrefetch();
|
return initHotCachePrefetch();
|
||||||
}, [migrationReady]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!migrationReady) return;
|
if (!migrationReady) return;
|
||||||
useGlobalShortcutsStore.getState().registerAll();
|
useGlobalShortcutsStore.getState().registerAll();
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
|||||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||||
|
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||||
@@ -23,6 +24,8 @@ import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
|||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||||
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||||
|
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||||
|
|
||||||
interface AlbumCardProps {
|
interface AlbumCardProps {
|
||||||
album: SubsonicAlbum;
|
album: SubsonicAlbum;
|
||||||
@@ -63,21 +66,28 @@ function AlbumCard({
|
|||||||
}: AlbumCardProps) {
|
}: AlbumCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isHolding, pressBind } = useLongPressAction({
|
const { isHolding, pressBind } = useLongPressAction({
|
||||||
onShortPress: () => playAlbum(album.id),
|
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||||
onLongPress: () => playAlbumShuffled(album.id),
|
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||||
});
|
});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateToAlbum = useNavigateToAlbum();
|
const navigateToAlbum = useNavigateToAlbum();
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
const isOffline = useOfflineStore(s => {
|
const offlineServerId = album.serverId ?? activeServerId;
|
||||||
const meta = s.albums[`${serverId}:${album.id}`];
|
const localEntries = useLocalPlaybackStore(s => s.entries);
|
||||||
if (!meta || meta.trackIds.length === 0) return false;
|
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
|
||||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
const albumLinkQuery = useMemo(
|
||||||
});
|
() => appendServerQuery(linkQuery, album.serverId),
|
||||||
|
[linkQuery, album.serverId],
|
||||||
|
);
|
||||||
|
void localEntries;
|
||||||
const psyDrag = useDragDrop();
|
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(() => {
|
const dragCoverKey = useMemo(() => {
|
||||||
if (!coverRef) return '';
|
if (!coverRef) return '';
|
||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||||
@@ -88,7 +98,7 @@ function AlbumCard({
|
|||||||
|
|
||||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||||
navigateToAlbum(album.id, { search: linkQuery });
|
navigateToAlbum(album.id, { search: albumLinkQuery });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ interface AlbumHeaderProps {
|
|||||||
resolvedCoverUrl: string | null;
|
resolvedCoverUrl: string | null;
|
||||||
isStarred: boolean;
|
isStarred: boolean;
|
||||||
downloadProgress: number | null;
|
downloadProgress: number | null;
|
||||||
offlineStatus: 'none' | 'downloading' | 'cached';
|
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
|
||||||
offlineProgress: { done: number; total: number } | null;
|
offlineProgress: { done: number; total: number } | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
bioOpen: boolean;
|
bioOpen: boolean;
|
||||||
@@ -300,6 +300,15 @@ export default function AlbumHeader({
|
|||||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
</div>
|
</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' ? (
|
) : offlineStatus === 'cached' ? (
|
||||||
<button
|
<button
|
||||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
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" />
|
<Loader2 size={14} className="spin" />
|
||||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||||
</div>
|
</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' ? (
|
) : offlineStatus === 'cached' ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export default function AlbumRow({
|
|||||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{uniqueAlbums.map((a, idx) => (
|
{uniqueAlbums.map((a, idx) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={a.id}
|
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||||
album={a}
|
album={a}
|
||||||
showRating={showRating}
|
showRating={showRating}
|
||||||
linkQuery={albumLinkQuery}
|
linkQuery={albumLinkQuery}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||||
|
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||||
|
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: SubsonicArtist;
|
artist: SubsonicArtist;
|
||||||
@@ -18,12 +20,17 @@ interface Props {
|
|||||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigateToArtist = useNavigateToArtist();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="artist-card"
|
className="artist-card"
|
||||||
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
|
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
|
||||||
>
|
>
|
||||||
<div className="artist-card-avatar">
|
<div className="artist-card-avatar">
|
||||||
{coverRef ? (
|
{coverRef ? (
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function ArtistRow({
|
|||||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{artists.map(a => (
|
{artists.map(a => (
|
||||||
<ArtistCardLocal
|
<ArtistCardLocal
|
||||||
key={a.id}
|
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||||
artist={a}
|
artist={a}
|
||||||
linkQuery={artistLinkQuery}
|
linkQuery={artistLinkQuery}
|
||||||
libraryResolve={libraryResolve}
|
libraryResolve={libraryResolve}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||||
|
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
||||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
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']);
|
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||||
|
|
||||||
@@ -15,6 +18,12 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarItems = useSidebarStore(s => s.items);
|
const sidebarItems = useSidebarStore(s => s.items);
|
||||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
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 offlineAlbums = useOfflineStore(s => s.albums);
|
||||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||||
const luckyMixBase = useLuckyMixAvailable();
|
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 === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||||
|
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(cfg.id, favoritesOfflineBrowse)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
|
|||||||
|
|
||||||
const toggleStar = useCallback(() => {
|
const toggleStar = useCallback(() => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
queueSongStar(currentTrack.id, !isStarred);
|
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||||
}, [currentTrack, isStarred]);
|
}, [currentTrack, isStarred]);
|
||||||
|
|
||||||
// Scrubber touch/mouse drag
|
// Scrubber touch/mouse drag
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ export default function PlayerBar() {
|
|||||||
|
|
||||||
const toggleStar = useCallback(() => {
|
const toggleStar = useCallback(() => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
queueSongStar(currentTrack.id, !isStarred);
|
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||||
}, [currentTrack, isStarred]);
|
}, [currentTrack, isStarred]);
|
||||||
|
|
||||||
const duration = currentTrack?.duration ?? 0;
|
const duration = currentTrack?.duration ?? 0;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
|||||||
import { QueueList } from './queuePanel/QueueList';
|
import { QueueList } from './queuePanel/QueueList';
|
||||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||||
|
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
|
||||||
|
|
||||||
export default function QueuePanel() {
|
export default function QueuePanel() {
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
@@ -170,11 +171,12 @@ function QueuePanelHostOrSolo() {
|
|||||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (queueItems.length === 0) return;
|
const exportTrackIds = activeServerQueueTrackIds(queueItems);
|
||||||
|
if (exportTrackIds.length === 0) return;
|
||||||
if (activePlaylist) {
|
if (activePlaylist) {
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
try {
|
try {
|
||||||
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
await updatePlaylist(activePlaylist.id, exportTrackIds);
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
setTimeout(() => setSaveState('idle'), 1500);
|
setTimeout(() => setSaveState('idle'), 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -196,7 +198,8 @@ function QueuePanelHostOrSolo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyQueueShare = async () => {
|
const handleCopyQueueShare = async () => {
|
||||||
if (queueItems.length === 0) {
|
const ids = activeServerQueueTrackIds(queueItems);
|
||||||
|
if (ids.length === 0) {
|
||||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -207,7 +210,6 @@ function QueuePanelHostOrSolo() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
const srv = serverShareBaseUrl(active);
|
const srv = serverShareBaseUrl(active);
|
||||||
if (!srv) return;
|
if (!srv) return;
|
||||||
const ids = queueItems.map(r => r.trackId);
|
|
||||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||||
@@ -381,7 +383,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
onSave={async (name) => {
|
onSave={async (name) => {
|
||||||
try {
|
try {
|
||||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
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 });
|
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||||
setSaveModalOpen(false);
|
setSaveModalOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||||
|
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
||||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useSidebarStore } from '../store/sidebarStore';
|
import { useSidebarStore } from '../store/sidebarStore';
|
||||||
@@ -23,7 +24,10 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
|||||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||||
|
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
||||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||||
|
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||||
@@ -41,8 +45,16 @@ export default function Sidebar({
|
|||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
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 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 syncJobStatus = useDeviceSyncJobStore(s => s.status);
|
||||||
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
||||||
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||||
@@ -61,7 +73,12 @@ export default function Sidebar({
|
|||||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
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 hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||||
|
const isServerOffline = connStatus === 'disconnected';
|
||||||
const sidebarItems = useSidebarStore(s => s.items);
|
const sidebarItems = useSidebarStore(s => s.items);
|
||||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||||
@@ -101,13 +118,20 @@ export default function Sidebar({
|
|||||||
libraryItemsForReorder.filter(c => {
|
libraryItemsForReorder.filter(c => {
|
||||||
if (!c.visible) return false;
|
if (!c.visible) return false;
|
||||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||||
|
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(c.id, favoritesOfflineBrowse)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[libraryItemsForReorder, luckyMixAvailable],
|
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse],
|
||||||
);
|
);
|
||||||
const visibleSystemConfigs = useMemo(
|
const visibleSystemConfigs = useMemo(
|
||||||
() => systemItemsForReorder.filter(c => c.visible),
|
() => systemItemsForReorder.filter(c => {
|
||||||
[systemItemsForReorder],
|
if (!c.visible) return false;
|
||||||
|
if (isServerOffline) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[systemItemsForReorder, isServerOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarItemsRef = useRef(sidebarItems);
|
const sidebarItemsRef = useRef(sidebarItems);
|
||||||
@@ -231,6 +255,8 @@ export default function Sidebar({
|
|||||||
nowPlayingAtTop={nowPlayingAtTop}
|
nowPlayingAtTop={nowPlayingAtTop}
|
||||||
hasOfflineContent={hasOfflineContent}
|
hasOfflineContent={hasOfflineContent}
|
||||||
activeJobsCount={activeJobs.length}
|
activeJobsCount={activeJobs.length}
|
||||||
|
activePinName={activePin?.albumName ?? null}
|
||||||
|
queuedPinCount={queuedPinCount}
|
||||||
cancelAllDownloads={cancelAllDownloads}
|
cancelAllDownloads={cancelAllDownloads}
|
||||||
isSyncing={isSyncing}
|
isSyncing={isSyncing}
|
||||||
syncJobDone={syncJobDone}
|
syncJobDone={syncJobDone}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||||
import { useOfflineStore } from '../../store/offlineStore';
|
import { useOfflineStore } from '../../store/offlineStore';
|
||||||
import { useOfflineJobStore } from '../../store/offlineJobStore';
|
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||||
@@ -55,8 +55,13 @@ export default function ArtistDetailHero({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
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 entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||||
|
|
||||||
@@ -217,28 +222,51 @@ export default function ArtistDetailHero({
|
|||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{albums.length > 0 && (() => {
|
{albums.length > 0 && (
|
||||||
const progress = id ? bulkProgress[id] : undefined;
|
<button
|
||||||
const isDone = progress && progress.done === progress.total;
|
className="btn btn-surface"
|
||||||
const isDownloading = progress && !isDone;
|
disabled={
|
||||||
return (
|
artistOfflineStatus === 'downloading'
|
||||||
<button
|
|| artistOfflineStatus === 'queued'
|
||||||
className="btn btn-surface"
|
|| artistOfflineStatus === 'cached'
|
||||||
disabled={!!isDownloading}
|
}
|
||||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
onClick={() => {
|
||||||
data-tooltip={isDownloading
|
if (id && artist && artistOfflineStatus !== 'cached') {
|
||||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
downloadArtist(id, artist.name, activeServerId);
|
||||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
}
|
||||||
>
|
}}
|
||||||
{isDownloading
|
data-tooltip={
|
||||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
? t('artistDetail.offlineDownloading', {
|
||||||
{!isMobile && (isDownloading
|
done: artistOfflineProgress.done,
|
||||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
total: artistOfflineProgress.total,
|
||||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
|
})
|
||||||
</button>
|
: 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
const starred = isStarred(album.id, album.starred);
|
const starred = isStarred(album.id, album.starred);
|
||||||
setStarredOverride(album.id, !starred);
|
setStarredOverride(album.id, !starred);
|
||||||
const meta = {
|
const meta = {
|
||||||
|
serverId: album.serverId,
|
||||||
name: album.name,
|
name: album.name,
|
||||||
artist: album.artist,
|
artist: album.artist,
|
||||||
artistId: album.artistId,
|
artistId: album.artistId,
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
const starred = isStarred(artist.id, artist.starred);
|
const starred = isStarred(artist.id, artist.starred);
|
||||||
setStarredOverride(artist.id, !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
|
return starred
|
||||||
? unstar(artist.id, 'artist', meta)
|
? unstar(artist.id, 'artist', meta)
|
||||||
: star(artist.id, 'artist', meta);
|
: star(artist.id, 'artist', meta);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<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'} />
|
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<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'} />
|
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
@@ -303,7 +303,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
<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')}
|
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks {
|
|||||||
startPreview: (song: SubsonicSong) => void;
|
startPreview: (song: SubsonicSong) => void;
|
||||||
rate: (songId: string, rating: number) => void;
|
rate: (songId: string, rating: number) => void;
|
||||||
remove: (songId: string) => void;
|
remove: (songId: string) => void;
|
||||||
navArtist: (artistId: string) => void;
|
navArtist: (artistId: string, serverId?: string) => void;
|
||||||
navAlbum: (albumId: string) => void;
|
navAlbum: (albumId: string, serverId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -100,12 +100,12 @@ function FavoriteSongRow({
|
|||||||
);
|
);
|
||||||
case 'artist': return (
|
case 'artist': return (
|
||||||
<div key="artist" className="track-artist-cell">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
case 'album': return (
|
case 'album': return (
|
||||||
<div key="album" className="track-artist-cell">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
case 'genre': return (
|
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 { useDragDrop } from '../../contexts/DragDropContext';
|
||||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
|
import { appendServerQuery } from '../../utils/navigation/detailServerScope';
|
||||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||||
import { useElementClientHeightById } from '../../hooks/useResizeClientHeight';
|
import { useElementClientHeightById } from '../../hooks/useResizeClientHeight';
|
||||||
import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering';
|
import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering';
|
||||||
@@ -127,8 +128,14 @@ export default function FavoritesSongsTracklist({
|
|||||||
),
|
),
|
||||||
rate: (songId, r) => latest.current.handleRate(songId, r),
|
rate: (songId, r) => latest.current.handleRate(songId, r),
|
||||||
remove: (songId) => latest.current.removeSong(songId),
|
remove: (songId) => latest.current.removeSong(songId),
|
||||||
navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`),
|
navArtist: (artistId, serverId) => {
|
||||||
navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`),
|
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);
|
const listWrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
|||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||||
|
import { coverServerScopeForServerId } from '../../cover/serverScope';
|
||||||
|
|
||||||
export interface TopFavoriteArtist {
|
export interface TopFavoriteArtist {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
count: number;
|
count: number;
|
||||||
coverArtId: string;
|
coverArtId: string;
|
||||||
|
/** Present when favorites are merged across servers. */
|
||||||
|
serverId?: string;
|
||||||
|
/** Raw artist id for song filtering (without server prefix). */
|
||||||
|
artistId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TopFavoriteArtistsRowProps {
|
interface TopFavoriteArtistsRowProps {
|
||||||
@@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps {
|
|||||||
|
|
||||||
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
||||||
const coverId = artist.coverArtId;
|
const coverId = artist.coverArtId;
|
||||||
|
const artistEntityId = artist.artistId ?? artist.coverArtId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -94,8 +100,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
|
|||||||
<div className="artist-card-avatar">
|
<div className="artist-card-avatar">
|
||||||
{coverId ? (
|
{coverId ? (
|
||||||
<ArtistCoverArtImage
|
<ArtistCoverArtImage
|
||||||
artistId={artist.id}
|
artistId={artistEntityId}
|
||||||
coverArt={artist.coverArtId}
|
coverArt={artist.coverArtId}
|
||||||
|
serverScope={coverServerScopeForServerId(artist.serverId)}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
|
|||||||
});
|
});
|
||||||
const toggleStar = useCallback(() => {
|
const toggleStar = useCallback(() => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
queueSongStar(currentTrack.id, !isStarred);
|
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||||
}, [currentTrack, isStarred]);
|
}, [currentTrack, isStarred]);
|
||||||
|
|
||||||
const duration = currentTrack?.duration ?? 0;
|
const duration = currentTrack?.duration ?? 0;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import type { ZipDownload } from '../../store/zipDownloadStore';
|
import type { ZipDownload } from '../../store/zipDownloadStore';
|
||||||
|
import type { AlbumOfflineStatus } from '../../hooks/useAlbumOfflineState';
|
||||||
|
import { dequeueOfflinePin } from '../../utils/offline/offlinePinQueue';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
|
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
|
||||||
import {
|
import {
|
||||||
@@ -29,8 +31,7 @@ interface Props {
|
|||||||
searchOpen: boolean;
|
searchOpen: boolean;
|
||||||
csvImporting: boolean;
|
csvImporting: boolean;
|
||||||
activeZip: ZipDownload | undefined;
|
activeZip: ZipDownload | undefined;
|
||||||
isCached: boolean;
|
offlineStatus: AlbumOfflineStatus;
|
||||||
isDownloading: boolean;
|
|
||||||
offlineProgress: { done: number; total: number } | null;
|
offlineProgress: { done: number; total: number } | null;
|
||||||
activeServerId: string;
|
activeServerId: string;
|
||||||
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -52,7 +53,7 @@ export default function PlaylistHero({
|
|||||||
playlist, songs, id,
|
playlist, songs, id,
|
||||||
customCoverId, coverQuadIds,
|
customCoverId, coverQuadIds,
|
||||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||||
isCached, isDownloading, offlineProgress, activeServerId,
|
offlineStatus, offlineProgress, activeServerId,
|
||||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||||
setSelectedSearchIds, setSearchPlPickerOpen,
|
setSelectedSearchIds, setSearchPlPickerOpen,
|
||||||
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
||||||
@@ -206,27 +207,39 @@ export default function PlaylistHero({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id && (
|
{isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||||
|
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
|
||||||
disabled={isDownloading}
|
disabled={offlineStatus === 'downloading'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isCached) {
|
if (offlineStatus === 'cached') {
|
||||||
deleteAlbum(id, activeServerId);
|
deleteAlbum(id, activeServerId);
|
||||||
|
} else if (offlineStatus === 'queued') {
|
||||||
|
dequeueOfflinePin(id);
|
||||||
} else if (playlist) {
|
} else if (playlist) {
|
||||||
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
|
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 })
|
? 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' }} />
|
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||||
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
|
{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} />
|
<Trash2 size={16} />
|
||||||
{t('playlists.removeOffline')}
|
{t('playlists.removeOffline')}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Image, Trash2 } from 'lucide-react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import SettingsSubSection from '../SettingsSubSection';
|
import SettingsSubSection from '../SettingsSubSection';
|
||||||
@@ -16,18 +16,46 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
|||||||
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
||||||
import { showToast } from '../../utils/ui/toast';
|
import { showToast } from '../../utils/ui/toast';
|
||||||
import { formatBytes } from '../../utils/format/formatBytes';
|
import { formatBytes } from '../../utils/format/formatBytes';
|
||||||
|
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
||||||
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
|
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
|
||||||
import {
|
import {
|
||||||
COVER_CACHE_STRATEGIES,
|
COVER_CACHE_STRATEGIES,
|
||||||
type CoverCacheStrategy,
|
type CoverCacheStrategy,
|
||||||
} from '../../utils/library/coverStrategy';
|
} from '../../utils/library/coverStrategy';
|
||||||
|
|
||||||
type ClearTarget = {
|
type ClearTarget =
|
||||||
serverId: string;
|
| { kind: 'image' }
|
||||||
indexKey: string;
|
| { kind: 'disk'; serverId: string; indexKey: string; label: 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 = {
|
type ServerRowState = {
|
||||||
bytes: number;
|
bytes: number;
|
||||||
entryCount: number;
|
entryCount: number;
|
||||||
@@ -38,12 +66,14 @@ type ServerRowState = {
|
|||||||
|
|
||||||
export default function CoverCacheStrategySection() {
|
export default function CoverCacheStrategySection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const servers = useAuthStore(s => s.servers);
|
const auth = useAuthStore();
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const servers = auth.servers;
|
||||||
|
const activeServerId = auth.activeServerId;
|
||||||
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
|
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
|
||||||
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
|
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
|
||||||
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
|
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
|
||||||
const [clearingKey, setClearingKey] = useState<string | null>(null);
|
const [clearingKey, setClearingKey] = useState<string | null>(null);
|
||||||
|
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||||||
|
|
||||||
const activeIndexKeys = useMemo(
|
const activeIndexKeys = useMemo(
|
||||||
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
|
() => 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
|
// 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.
|
// not needed for correctness, so we avoid re-walking the cover dirs in a tight loop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
void getImageCacheSize().then(setImageCacheBytes);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
const id = window.setInterval(refreshAll, 300_000);
|
const id = window.setInterval(refreshAll, 300_000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
@@ -168,8 +199,19 @@ export default function CoverCacheStrategySection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearCoverCache = async () => {
|
const handleClearConfirm = async () => {
|
||||||
if (!clearTarget) return;
|
if (!clearTarget) return;
|
||||||
|
if (clearTarget.kind === 'image') {
|
||||||
|
setClearingKey('image');
|
||||||
|
try {
|
||||||
|
await clearImageCache();
|
||||||
|
setImageCacheBytes(await getImageCacheSize());
|
||||||
|
} finally {
|
||||||
|
setClearingKey(null);
|
||||||
|
setClearTarget(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setClearingKey(clearTarget.indexKey);
|
setClearingKey(clearTarget.indexKey);
|
||||||
try {
|
try {
|
||||||
await coverCacheClearServer(clearTarget.indexKey);
|
await coverCacheClearServer(clearTarget.indexKey);
|
||||||
@@ -191,21 +233,62 @@ export default function CoverCacheStrategySection() {
|
|||||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 520 }}>
|
<table style={TABLE_STYLE}>
|
||||||
|
<CoverCacheColGroup />
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
<th style={TH_STYLE}>{t('settings.imageCacheScopeLabel')}</th>
|
||||||
{t('settings.coverCacheStrategyServerLabel')}
|
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
|
||||||
</th>
|
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||||
{t('settings.coverCacheStrategyLabel')}
|
</tr>
|
||||||
</th>
|
</thead>
|
||||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
<tbody>
|
||||||
{t('settings.coverCacheStrategyProgressLabel')}
|
<tr>
|
||||||
</th>
|
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
{t('settings.imageCacheSubTitle')}
|
||||||
{t('settings.coverCacheStrategyActionsLabel')}
|
</td>
|
||||||
</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -215,7 +298,7 @@ export default function CoverCacheStrategySection() {
|
|||||||
const row = rowState[key];
|
const row = rowState[key];
|
||||||
const label = serverListDisplayLabel(server, servers);
|
const label = serverListDisplayLabel(server, servers);
|
||||||
return (
|
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', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||||
<td style={{ padding: '10px' }}>
|
<td style={{ padding: '10px' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
@@ -245,7 +328,7 @@ export default function CoverCacheStrategySection() {
|
|||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
style={{ fontSize: 12 }}
|
style={{ fontSize: 12 }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setClearTarget({ serverId: server.id, indexKey: key, label })}
|
setClearTarget({ kind: 'disk', serverId: server.id, indexKey: key, label })}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||||
</button>
|
</button>
|
||||||
@@ -254,19 +337,20 @@ export default function CoverCacheStrategySection() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{removedServerKeys.map(key => (
|
{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)' }}>
|
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
{key}
|
{key}
|
||||||
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
||||||
</td>
|
</td>
|
||||||
<td colSpan={2} />
|
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||||
|
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||||
<td style={{ padding: '10px' }}>
|
<td style={{ padding: '10px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
style={{ fontSize: 12 }}
|
style={{ fontSize: 12 }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setClearTarget({ serverId: key, indexKey: key, label: key })}
|
setClearTarget({ kind: 'disk', serverId: key, indexKey: key, label: key })}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||||
</button>
|
</button>
|
||||||
@@ -291,19 +375,30 @@ export default function CoverCacheStrategySection() {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
|
{clearTarget.kind === 'image' ? (
|
||||||
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
|
<>
|
||||||
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.cacheClearBtn')}</div>
|
||||||
</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 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||||
disabled={clearingKey !== null}
|
disabled={clearingKey !== null}
|
||||||
onClick={() => void handleClearCoverCache()}
|
onClick={() => void handleClearConfirm()}
|
||||||
>
|
>
|
||||||
{t('settings.coverCacheStrategyClearConfirm')}
|
{clearTarget.kind === 'image'
|
||||||
|
? t('settings.cacheClearConfirm')
|
||||||
|
: t('settings.coverCacheStrategyClearConfirm')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -312,7 +407,9 @@ export default function CoverCacheStrategySection() {
|
|||||||
disabled={clearingKey !== null}
|
disabled={clearingKey !== null}
|
||||||
onClick={() => setClearTarget(null)}
|
onClick={() => setClearTarget(null)}
|
||||||
>
|
>
|
||||||
{t('settings.coverCacheStrategyClearCancel')}
|
{clearTarget.kind === 'image'
|
||||||
|
? t('settings.cacheClearCancel')
|
||||||
|
: t('settings.coverCacheStrategyClearCancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { Download, FolderOpen, Trash2, X } from 'lucide-react';
|
import { Download, FolderOpen, Trash2, X } from 'lucide-react';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useHotCacheStore } from '../../store/hotCacheStore';
|
import { countHotCacheTracks } from '../../store/hotCacheStore';
|
||||||
import { useOfflineStore } from '../../store/offlineStore';
|
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||||
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
|
||||||
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||||
import SettingsSubSection from '../SettingsSubSection';
|
import SettingsSubSection from '../SettingsSubSection';
|
||||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||||
@@ -14,79 +13,50 @@ import CoverCacheStrategySection from './CoverCacheStrategySection';
|
|||||||
export function StorageTab() {
|
export function StorageTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const serverId = auth.activeServerId ?? '';
|
const clearHotCacheDisk = useLocalPlaybackStore(s => s.purgeEphemeralDisk);
|
||||||
const clearAllOffline = useOfflineStore(s => s.clearAll);
|
const localPlaybackEntries = useLocalPlaybackStore(s => s.entries);
|
||||||
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 [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
|
||||||
const [clearing, setClearing] = useState(false);
|
|
||||||
|
|
||||||
const hotCacheTrackCount = useMemo(() => {
|
const mediaDir = auth.mediaDir || null;
|
||||||
const prefix = `${serverId}:`;
|
|
||||||
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
|
/** Match ephemeral disk usage (all servers); resolve UUID vs URL index keys. */
|
||||||
}, [hotCacheEntries, serverId]);
|
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(() => {
|
useEffect(() => {
|
||||||
getImageCacheSize().then(setImageCacheBytes);
|
refreshHotCacheSize();
|
||||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
}, [refreshHotCacheSize]);
|
||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
|
||||||
}, [auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
|
||||||
|
|
||||||
/** Live disk usage for hot cache (interval + refresh when index changes). */
|
|
||||||
useEffect(() => {
|
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;
|
if (!auth.hotCacheEnabled) return;
|
||||||
const interval = window.setInterval(refresh, 15_000);
|
refreshHotCacheSize();
|
||||||
|
const interval = window.setInterval(refreshHotCacheSize, 15_000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
}, [auth.hotCacheEnabled, refreshHotCacheSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.hotCacheEnabled) return;
|
if (!auth.hotCacheEnabled) return;
|
||||||
const handle = window.setTimeout(() => {
|
const handle = window.setTimeout(refreshHotCacheSize, 400);
|
||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
|
||||||
.then(setHotCacheBytes)
|
|
||||||
.catch(() => setHotCacheBytes(0));
|
|
||||||
}, 400);
|
|
||||||
return () => window.clearTimeout(handle);
|
return () => window.clearTimeout(handle);
|
||||||
}, [hotCacheEntries, auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
}, [localPlaybackEntries, auth.hotCacheEnabled, refreshHotCacheSize]);
|
||||||
|
|
||||||
const handleClearCache = useCallback(async () => {
|
const pickMediaDir = async () => {
|
||||||
setClearing(true);
|
const selected = await openDialog({
|
||||||
await clearImageCache();
|
directory: true,
|
||||||
await clearAllOffline(serverId);
|
multiple: false,
|
||||||
const [imgBytes, offBytes] = await Promise.all([
|
title: t('settings.mediaDirChange'),
|
||||||
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') });
|
|
||||||
if (selected && typeof selected === 'string') {
|
if (selected && typeof selected === 'string') {
|
||||||
auth.setOfflineDownloadDir(selected);
|
auth.setMediaDir(selected);
|
||||||
}
|
refreshHotCacheSize();
|
||||||
};
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,111 +69,51 @@ export function StorageTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Offline Library (In-App) — includes cache settings */}
|
|
||||||
<SettingsSubSection
|
<SettingsSubSection
|
||||||
title={t('settings.offlineDirTitle')}
|
title={t('settings.mediaDirTitle')}
|
||||||
icon={<Download size={16} />}
|
icon={<FolderOpen size={16} />}
|
||||||
>
|
>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||||
{t('settings.offlineDirDesc')}
|
{t('settings.mediaDirDesc')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
|
value={auth.mediaDir || t('settings.mediaDirDefault')}
|
||||||
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||||
/>
|
/>
|
||||||
{auth.offlineDownloadDir && (
|
{auth.mediaDir && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => auth.setOfflineDownloadDir('')}
|
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
|
||||||
data-tooltip={t('settings.offlineDirClear')}
|
data-tooltip={t('settings.mediaDirClear')}
|
||||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
|
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
|
||||||
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
|
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{auth.offlineDownloadDir && (
|
{auth.mediaDir && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||||
{t('settings.offlineDirHint')}
|
{t('settings.mediaDirHint')}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
<CoverCacheStrategySection />
|
<CoverCacheStrategySection />
|
||||||
|
|
||||||
{/* Buffering */}
|
|
||||||
<SettingsSubSection
|
<SettingsSubSection
|
||||||
title={t('settings.nextTrackBufferingTitle')}
|
title={t('settings.nextTrackBufferingTitle')}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
>
|
>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
{/* Hot Cache */}
|
|
||||||
<div className="settings-toggle-row">
|
<div className="settings-toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
||||||
@@ -216,14 +126,12 @@ export function StorageTab() {
|
|||||||
onChange={async e => {
|
onChange={async e => {
|
||||||
const enabled = e.target.checked;
|
const enabled = e.target.checked;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
await clearHotCacheDisk(mediaDir);
|
||||||
setHotCacheBytes(0);
|
setHotCacheBytes(0);
|
||||||
auth.setHotCacheEnabled(false);
|
auth.setHotCacheEnabled(false);
|
||||||
} else {
|
} else {
|
||||||
auth.setHotCacheEnabled(true);
|
auth.setHotCacheEnabled(true);
|
||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
refreshHotCacheSize();
|
||||||
.then(setHotCacheBytes)
|
|
||||||
.catch(() => setHotCacheBytes(0));
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
id="hot-cache-enabled-toggle"
|
id="hot-cache-enabled-toggle"
|
||||||
@@ -234,41 +142,6 @@ export function StorageTab() {
|
|||||||
|
|
||||||
{auth.hotCacheEnabled && (
|
{auth.hotCacheEnabled && (
|
||||||
<div style={{ marginTop: '1.25rem' }}>
|
<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={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<div style={{ color: 'var(--text-secondary)' }}>
|
<div style={{ color: 'var(--text-secondary)' }}>
|
||||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||||
@@ -305,20 +178,17 @@ export function StorageTab() {
|
|||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ fontSize: 13 }}
|
style={{ fontSize: 13 }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
await clearHotCacheDisk(mediaDir);
|
||||||
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
refreshHotCacheSize();
|
||||||
setHotCacheBytes(b);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
{/* ZIP Export & Archiving */}
|
|
||||||
<SettingsSubSection
|
<SettingsSubSection
|
||||||
title={t('settings.downloadsTitle')}
|
title={t('settings.downloadsTitle')}
|
||||||
icon={<FolderOpen size={16} />}
|
icon={<FolderOpen size={16} />}
|
||||||
|
|||||||
@@ -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.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.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
{ 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.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server image idb preview limit clear' },
|
||||||
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
|
{ 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.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' },
|
||||||
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
|
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
|
||||||
{ tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
|
{ tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { HardDriveDownload, HardDriveUpload, X } from 'lucide-react';
|
|||||||
interface Props {
|
interface Props {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
activeJobsCount: number;
|
activeJobsCount: number;
|
||||||
|
activePinName: string | null;
|
||||||
|
queuedPinCount: number;
|
||||||
cancelAllDownloads: () => void;
|
cancelAllDownloads: () => void;
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
syncJobDone: number;
|
syncJobDone: number;
|
||||||
@@ -13,21 +15,32 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SidebarActiveJobs({
|
export default function SidebarActiveJobs({
|
||||||
isCollapsed, activeJobsCount, cancelAllDownloads,
|
isCollapsed, activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
|
||||||
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{activeJobsCount > 0 && (
|
{(activeJobsCount > 0 || showPinQueue) && (
|
||||||
<div
|
<div
|
||||||
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
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"
|
data-tooltip-pos="right"
|
||||||
>
|
>
|
||||||
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span>{t('sidebar.downloadingTracks', { n: activeJobsCount })}</span>
|
<span>{offlineQueueLabel}</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="sidebar-offline-cancel"
|
className="sidebar-offline-cancel"
|
||||||
@@ -44,12 +57,12 @@ export default function SidebarActiveJobs({
|
|||||||
{isSyncing && (
|
{isSyncing && (
|
||||||
<div
|
<div
|
||||||
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
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"
|
data-tooltip-pos="right"
|
||||||
>
|
>
|
||||||
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
|
<span>{syncLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ interface Props {
|
|||||||
nowPlayingAtTop: boolean;
|
nowPlayingAtTop: boolean;
|
||||||
hasOfflineContent: boolean;
|
hasOfflineContent: boolean;
|
||||||
activeJobsCount: number;
|
activeJobsCount: number;
|
||||||
|
activePinName: string | null;
|
||||||
|
queuedPinCount: number;
|
||||||
cancelAllDownloads: () => void;
|
cancelAllDownloads: () => void;
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
syncJobDone: number;
|
syncJobDone: number;
|
||||||
@@ -62,7 +64,7 @@ export default function SidebarNavBody(props: Props) {
|
|||||||
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
|
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
|
||||||
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
|
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
|
||||||
isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent,
|
isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent,
|
||||||
activeJobsCount, cancelAllDownloads,
|
activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
|
||||||
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -289,6 +291,8 @@ export default function SidebarNavBody(props: Props) {
|
|||||||
<SidebarActiveJobs
|
<SidebarActiveJobs
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
activeJobsCount={activeJobsCount}
|
activeJobsCount={activeJobsCount}
|
||||||
|
activePinName={activePinName}
|
||||||
|
queuedPinCount={queuedPinCount}
|
||||||
cancelAllDownloads={cancelAllDownloads}
|
cancelAllDownloads={cancelAllDownloads}
|
||||||
isSyncing={isSyncing}
|
isSyncing={isSyncing}
|
||||||
syncJobDone={syncJobDone}
|
syncJobDone={syncJobDone}
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
|
|||||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack', { defaultValue: 'Load a track first.' }), 5000, 'error');
|
showToast(i18n.t('contextMenu.cliMixNeedsTrack', { defaultValue: 'Load a track first.' }), 5000, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueSongStar(track.id, true);
|
queueSongStar(track.id, true, track.serverId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'open-help': {
|
'open-help': {
|
||||||
@@ -309,7 +309,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
|
|||||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueSongStar(track.id, true);
|
queueSongStar(track.id, true, track.serverId);
|
||||||
},
|
},
|
||||||
cli: { verb: 'star', description: 'star' },
|
cli: { verb: 'star', description: 'star' },
|
||||||
},
|
},
|
||||||
@@ -322,7 +322,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
|
|||||||
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueSongStar(track.id, false);
|
queueSongStar(track.id, false, track.serverId);
|
||||||
},
|
},
|
||||||
cli: { verb: 'unstar', description: 'unstar' },
|
cli: { verb: 'unstar', description: 'unstar' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Unified cover art pipeline — see workdocs tasks/2026-05-cover-art-pipeline/contracts.md
|
* Unified cover art pipeline — see workdocs tasks/2026-05-cover-art-pipeline/contracts.md
|
||||||
*/
|
*/
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export { coverServerScopeForServerId } from './serverScope';
|
||||||
export * from './tiers';
|
export * from './tiers';
|
||||||
export * from './ids';
|
export * from './ids';
|
||||||
export * from './storageKeys';
|
export * from './storageKeys';
|
||||||
|
|||||||
+4
-14
@@ -1,6 +1,6 @@
|
|||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
import { coverServerScopeForServerId } from './serverScope';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
|
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
|
||||||
import {
|
import {
|
||||||
@@ -187,18 +187,8 @@ export function coverArtRef(
|
|||||||
|
|
||||||
export function resolvePlaybackCoverScope(): CoverServerScope {
|
export function resolvePlaybackCoverScope(): CoverServerScope {
|
||||||
const playbackSid = getPlaybackServerId();
|
const playbackSid = getPlaybackServerId();
|
||||||
|
if (!playbackSid) return { kind: 'playback' };
|
||||||
const activeSid = useAuthStore.getState().activeServerId;
|
const activeSid = useAuthStore.getState().activeServerId;
|
||||||
if (playbackSid && activeSid && playbackSid !== activeSid) {
|
if (playbackSid === activeSid) return { kind: 'playback' };
|
||||||
const server = findServerByIdOrIndexKey(playbackSid);
|
return coverServerScopeForServerId(playbackSid);
|
||||||
if (server) {
|
|
||||||
return {
|
|
||||||
kind: 'server',
|
|
||||||
serverId: server.id,
|
|
||||||
url: server.url,
|
|
||||||
username: server.username,
|
|
||||||
password: server.password,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { kind: 'playback' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
resolveDistinctDiscCoversForAlbum,
|
resolveDistinctDiscCoversForAlbum,
|
||||||
resolvePlaybackCoverScope,
|
resolvePlaybackCoverScope,
|
||||||
} from './ref';
|
} from './ref';
|
||||||
|
import { coverServerScopeForServerId } from './serverScope';
|
||||||
|
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||||
|
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
||||||
import {
|
import {
|
||||||
resolveAlbumCoverRefFromLibrary,
|
resolveAlbumCoverRefFromLibrary,
|
||||||
resolveArtistCoverRefFromLibrary,
|
resolveArtistCoverRefFromLibrary,
|
||||||
@@ -200,6 +203,8 @@ export function usePlaybackTrackCoverRef(
|
|||||||
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
|
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
|
||||||
): CoverArtRef | undefined {
|
): CoverArtRef | undefined {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
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 queueLength = usePlayerStore(s => s.queueItems.length);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const serversFingerprint = useAuthStore(s =>
|
const serversFingerprint = useAuthStore(s =>
|
||||||
@@ -208,10 +213,20 @@ export function usePlaybackTrackCoverRef(
|
|||||||
.join('\u0002'),
|
.join('\u0002'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const scope = useMemo(
|
const scope = useMemo(() => {
|
||||||
() => resolvePlaybackCoverScope(),
|
if (track?.id) {
|
||||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
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 scopeKey = coverScopeKey(scope);
|
||||||
|
|
||||||
const trackId = track?.id;
|
const trackId = track?.id;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export function usePlaybackCoverArt(
|
|||||||
opts?: { fullRes?: boolean },
|
opts?: { fullRes?: boolean },
|
||||||
): CoverArtHandle {
|
): CoverArtHandle {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
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 queueLength = usePlayerStore(s => s.queueItems.length);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const serversFingerprint = useAuthStore(s =>
|
const serversFingerprint = useAuthStore(s =>
|
||||||
@@ -22,7 +26,7 @@ export function usePlaybackCoverArt(
|
|||||||
|
|
||||||
const scope = useMemo(
|
const scope = useMemo(
|
||||||
() => resolvePlaybackCoverScope(),
|
() => resolvePlaybackCoverScope(),
|
||||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
[queueServerId, queueIndex, playingServerId, queueLength, activeServerId, serversFingerprint],
|
||||||
);
|
);
|
||||||
const refWithScope = useMemo(
|
const refWithScope = useMemo(
|
||||||
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
|
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getAlbum } from '../api/subsonicLibrary';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { getArtist } from '../api/subsonicArtists';
|
import { getAlbum, getAlbumForServer } from '../api/subsonicLibrary';
|
||||||
|
import { getArtist, getArtistForServer } from '../api/subsonicArtists';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
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>>;
|
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
|
* 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
|
* a follow-up call so the related-albums grid can render without blocking
|
||||||
* the initial paint.
|
* 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 {
|
export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataResult {
|
||||||
const [album, setAlbum] = useState<AlbumPayload | null>(null);
|
const [album, setAlbum] = useState<AlbumPayload | null>(null);
|
||||||
@@ -33,26 +40,105 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
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(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setRelatedAlbums([]);
|
setRelatedAlbums([]);
|
||||||
getAlbum(id).then(async data => {
|
|
||||||
|
const applyAlbumPayload = (data: AlbumPayload) => {
|
||||||
setAlbum(data);
|
setAlbum(data);
|
||||||
setIsStarred(!!data.album.starred);
|
setIsStarred(!!data.album.starred);
|
||||||
const initialStarred = new Set<string>();
|
const initialStarred = new Set<string>();
|
||||||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||||
setStarredSongs(initialStarred);
|
setStarredSongs(initialStarred);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRelatedAlbums = async (
|
||||||
|
serverId: string | null,
|
||||||
|
artistId: string | undefined,
|
||||||
|
useLocalArtist: boolean,
|
||||||
|
) => {
|
||||||
|
if (!artistId) return;
|
||||||
try {
|
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));
|
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch related albums', 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 };
|
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||||
|
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
|
||||||
|
|
||||||
|
export type AlbumOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
|
||||||
|
|
||||||
interface UseAlbumOfflineStateResult {
|
interface UseAlbumOfflineStateResult {
|
||||||
resolvedOfflineStatus: 'none' | 'downloading' | 'cached';
|
resolvedOfflineStatus: AlbumOfflineStatus;
|
||||||
offlineProgress: { done: number; total: number } | null;
|
offlineProgress: { done: number; total: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,36 +17,51 @@ interface UseAlbumOfflineStateResult {
|
|||||||
* downloads (each track flip would otherwise trigger a full page render).
|
* downloads (each track flip would otherwise trigger a full page render).
|
||||||
*
|
*
|
||||||
* Resolution rules:
|
* Resolution rules:
|
||||||
* - If there's any queued / downloading job for this album, status is
|
* - Fully pinned → `cached`.
|
||||||
* `downloading` and we expose a `{ done, total }` progress tuple.
|
* - Active pin jobs or pin-queue `downloading` → `downloading` + progress.
|
||||||
* - Else we look at the persisted cache map: a fully-cached album is
|
* - Pin-queue `queued` (waiting behind another album) → `queued`.
|
||||||
* one where every trackId in the album-meta has a matching track entry.
|
|
||||||
* - Else `none`.
|
* - Else `none`.
|
||||||
*
|
*
|
||||||
* `albumId` is allowed to be empty (e.g. while the page is still
|
* `albumId` is allowed to be empty (e.g. while the page is still
|
||||||
* fetching) — in that case every selector short-circuits to a benign
|
* fetching) — in that case every selector short-circuits to a benign
|
||||||
* default.
|
* default.
|
||||||
*/
|
*/
|
||||||
export function useAlbumOfflineState(albumId: string, serverId: string): UseAlbumOfflineStateResult {
|
export function useAlbumOfflineState(
|
||||||
const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => {
|
albumId: string,
|
||||||
if (!albumId) return 'none';
|
serverId: string,
|
||||||
const meta = s.albums[`${serverId}:${albumId}`];
|
songIds?: string[],
|
||||||
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
): UseAlbumOfflineStateResult {
|
||||||
return isDownloaded ? 'cached' : 'none';
|
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 =>
|
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 => {
|
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;
|
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
|
||||||
});
|
});
|
||||||
const offlineProgressTotal = useOfflineJobStore(s => {
|
const offlineProgressTotal = useOfflineJobStore(s => {
|
||||||
if (!albumId) return 0;
|
if (!albumId || pinComplete) return 0;
|
||||||
return s.jobs.filter(j => j.albumId === albumId).length;
|
return s.jobs.filter(j => j.albumId === albumId).length;
|
||||||
});
|
});
|
||||||
const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus;
|
const resolvedOfflineStatus = pinComplete
|
||||||
const offlineProgress = offlineProgressTotal > 0
|
? 'cached'
|
||||||
|
: isOfflineDownloading
|
||||||
|
? 'downloading'
|
||||||
|
: isPinQueued
|
||||||
|
? 'queued'
|
||||||
|
: 'none';
|
||||||
|
const offlineProgress = isOfflineDownloading && offlineProgressTotal > 0
|
||||||
? { done: offlineProgressDone, total: offlineProgressTotal }
|
? { done: offlineProgressDone, total: offlineProgressTotal }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* otherwise persist into IndexedDB.
|
* otherwise persist into IndexedDB.
|
||||||
*/
|
*/
|
||||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function routerWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return React.createElement(MemoryRouter, null, children);
|
||||||
|
}
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
let resolve!: (v: T) => void;
|
let resolve!: (v: T) => void;
|
||||||
let reject!: (e?: unknown) => void;
|
let reject!: (e?: unknown) => void;
|
||||||
@@ -55,7 +61,7 @@ describe('useArtistDetailData — id-gated info', () => {
|
|||||||
|
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ id }: { id: string }) => useArtistDetailData(id),
|
({ id }: { id: string }) => useArtistDetailData(id),
|
||||||
{ initialProps: { id: 'A' } },
|
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||||
@@ -95,7 +101,7 @@ describe('useArtistDetailData — id-gated info', () => {
|
|||||||
],
|
],
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const { result } = renderHook(() => useArtistDetailData('A'));
|
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
|
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
|
||||||
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
|
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
|
||||||
@@ -117,7 +123,7 @@ describe('useArtistDetailData — id-gated info', () => {
|
|||||||
|
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ id }: { id: string }) => useArtistDetailData(id),
|
({ id }: { id: string }) => useArtistDetailData(id),
|
||||||
{ initialProps: { id: 'A' } },
|
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
rerender({ id: 'B' });
|
rerender({ id: 'B' });
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { search } from '../api/subsonicSearch';
|
import { search } from '../api/subsonicSearch';
|
||||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||||
import type {
|
import type {
|
||||||
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||||
} from '../api/subsonicTypes';
|
} from '../api/subsonicTypes';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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 { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||||
|
|
||||||
@@ -45,11 +49,18 @@ export function useArtistDetailData(
|
|||||||
options: UseArtistDetailDataOptions = {},
|
options: UseArtistDetailDataOptions = {},
|
||||||
): ArtistDetailDataResult {
|
): ArtistDetailDataResult {
|
||||||
const losslessOnly = options.losslessOnly ?? false;
|
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(
|
const audiomuseNavidromeEnabled = useAuthStore(
|
||||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
|
||||||
);
|
);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const preferLocalArtist = connStatus === 'disconnected'
|
||||||
|
&& favoritesOfflineEnabled
|
||||||
|
&& !!serverId;
|
||||||
|
|
||||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -71,11 +82,26 @@ export function useArtistDetailData(
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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) {
|
if (losslessOnly && serverId) {
|
||||||
const local = await runLocalArtistLosslessBrowse(serverId, id);
|
const local = await runLocalArtistLosslessBrowse(serverId, id);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (local) {
|
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 (cancelled) return;
|
||||||
if (artistData) {
|
if (artistData) {
|
||||||
setArtist(artistData.artist);
|
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;
|
if (cancelled) return;
|
||||||
setArtist(artistData.artist);
|
setArtist(artistData.artist);
|
||||||
let nextAlbums = artistData.albums;
|
let nextAlbums = artistData.albums;
|
||||||
@@ -105,6 +133,20 @@ export function useArtistDetailData(
|
|||||||
setTopSongs(nextSongs);
|
setTopSongs(nextSongs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
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);
|
console.error(err);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -112,10 +154,10 @@ export function useArtistDetailData(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [id, losslessOnly, serverId]);
|
}, [id, losslessOnly, serverId, preferLocalArtist, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id || preferLocalArtist) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setArtistInfoLoading(true);
|
setArtistInfoLoading(true);
|
||||||
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||||
@@ -129,10 +171,10 @@ export function useArtistDetailData(
|
|||||||
if (!cancelled) setArtistInfoLoading(false);
|
if (!cancelled) setArtistInfoLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [id, audiomuseNavidromeEnabled]);
|
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || !artist) return;
|
if (!id || !artist || preferLocalArtist) return;
|
||||||
const ownAlbumIds = new Set(albums.map(a => a.id));
|
const ownAlbumIds = new Set(albums.map(a => a.id));
|
||||||
setFeaturedLoading(true);
|
setFeaturedLoading(true);
|
||||||
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
||||||
@@ -172,7 +214,7 @@ export function useArtistDetailData(
|
|||||||
setFeaturedLoading(false);
|
setFeaturedLoading(false);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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;
|
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isLanUrl,
|
isLanUrl,
|
||||||
type ServerEndpointKind,
|
type ServerEndpointKind,
|
||||||
} from '../utils/server/serverEndpoint';
|
} from '../utils/server/serverEndpoint';
|
||||||
|
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
|
|
||||||
// Backward-compatible re-export for call sites that still import from the hook.
|
// 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 check = useCallback(async () => {
|
||||||
const server = useAuthStore.getState().getActiveServer();
|
const server = useAuthStore.getState().getActiveServer();
|
||||||
if (!server) {
|
if (!server) {
|
||||||
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setStatus('disconnected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
|
setActiveServerReachable(false);
|
||||||
setStatus('disconnected');
|
setStatus('disconnected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,7 @@ export function useConnectionStatus() {
|
|||||||
} else {
|
} else {
|
||||||
setActiveEndpointKind(null);
|
setActiveEndpointKind(null);
|
||||||
}
|
}
|
||||||
|
setActiveServerReachable(probe.ok);
|
||||||
setStatus(probe.ok ? 'connected' : 'disconnected');
|
setStatus(probe.ok ? 'connected' : 'disconnected');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ export function useConnectionStatus() {
|
|||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
setActiveServerReachable(true);
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,7 +95,10 @@ export function useConnectionStatus() {
|
|||||||
if (sid) invalidateReachableEndpointCache(sid);
|
if (sid) invalidateReachableEndpointCache(sid);
|
||||||
check();
|
check();
|
||||||
};
|
};
|
||||||
const handleOffline = () => setStatus('disconnected');
|
const handleOffline = () => {
|
||||||
|
setActiveServerReachable(false);
|
||||||
|
setStatus('disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
window.addEventListener('online', handleOnline);
|
||||||
window.addEventListener('offline', handleOffline);
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import type {
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
|
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 {
|
export interface FavoritesDataResult {
|
||||||
albums: SubsonicAlbum[];
|
albums: SubsonicAlbum[];
|
||||||
@@ -20,6 +26,12 @@ export interface FavoritesDataResult {
|
|||||||
unfavoriteStation: (id: string) => void;
|
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 {
|
export function useFavoritesData(): FavoritesDataResult {
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||||
@@ -28,39 +40,74 @@ export function useFavoritesData(): FavoritesDataResult {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
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);
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAll = async () => {
|
let cancelled = false;
|
||||||
const [starredResult] = await Promise.allSettled([
|
|
||||||
getStarred(),
|
|
||||||
]);
|
|
||||||
if (starredResult.status === 'fulfilled') {
|
|
||||||
setAlbums(starredResult.value.albums);
|
|
||||||
setArtists(starredResult.value.artists);
|
|
||||||
setSongs(starredResult.value.songs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
|
||||||
if (favIds.size > 0) {
|
if (favIds.size === 0) return;
|
||||||
const all = await getInternetRadioStations();
|
const all = await getInternetRadioStations();
|
||||||
|
if (!cancelled) {
|
||||||
setRadioStations(all.filter(s => favIds.has(s.id)));
|
setRadioStations(all.filter(s => favIds.has(s.id)));
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} 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 ─────────────
|
// ── Top Favorite Artists aggregated from favorited songs ─────────────
|
||||||
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
|
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
|
||||||
const counts = new Map<string, TopFavoriteArtist>();
|
const counts = new Map<string, TopFavoriteArtist>();
|
||||||
for (const s of songs) {
|
for (const s of songs) {
|
||||||
if (starredOverrides[s.id] === false) continue;
|
if (starredOverrides[s.id] === false) continue;
|
||||||
const key = s.artistId || s.artist;
|
const key = topArtistKey(s);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const existing = counts.get(key);
|
const existing = counts.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -71,6 +118,8 @@ export function useFavoritesData(): FavoritesDataResult {
|
|||||||
name: s.artist || key,
|
name: s.artist || key,
|
||||||
count: 1,
|
count: 1,
|
||||||
coverArtId: s.artistId || '',
|
coverArtId: s.artistId || '',
|
||||||
|
serverId: s.serverId,
|
||||||
|
artistId: s.artistId || s.artist,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -78,11 +78,15 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
|
|||||||
// Remove unfavorited
|
// Remove unfavorited
|
||||||
if (starredOverrides[s.id] === false) return false;
|
if (starredOverrides[s.id] === false) return false;
|
||||||
|
|
||||||
// Artist filter
|
// Artist filter (composite key when favorites span servers)
|
||||||
if (selectedArtist) {
|
if (selectedArtist) {
|
||||||
const artistMatch = s.artistId === selectedArtist ||
|
const compositeKey = s.serverId
|
||||||
s.artist === selectedArtist ||
|
? `${s.serverId}:${s.artistId || s.artist}`
|
||||||
s.albumArtist === selectedArtist;
|
: (s.artistId || s.artist);
|
||||||
|
const artistMatch = selectedArtist === compositeKey
|
||||||
|
|| s.artistId === selectedArtist
|
||||||
|
|| s.artist === selectedArtist
|
||||||
|
|| s.albumArtist === selectedArtist;
|
||||||
if (!artistMatch) return false;
|
if (!artistMatch) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ vi.mock('../api/subsonicArtists');
|
|||||||
vi.mock('../api/subsonicLibrary');
|
vi.mock('../api/subsonicLibrary');
|
||||||
vi.mock('../api/bandsintown');
|
vi.mock('../api/bandsintown');
|
||||||
vi.mock('../api/lastfm');
|
vi.mock('../api/lastfm');
|
||||||
|
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||||
|
shouldAttemptSubsonicForServer: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
||||||
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
type LastfmArtistStats, type LastfmTrackInfo,
|
type LastfmArtistStats, type LastfmTrackInfo,
|
||||||
} from '../api/lastfm';
|
} from '../api/lastfm';
|
||||||
import { makeCache } from '../utils/cache/nowPlayingCache';
|
import { makeCache } from '../utils/cache/nowPlayingCache';
|
||||||
|
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||||
|
import { useConnectionStatus } from './useConnectionStatus';
|
||||||
|
|
||||||
// Module-level TTL caches (shared across mounts)
|
// Module-level TTL caches (shared across mounts)
|
||||||
const songMetaCache = makeCache<SubsonicSong | null>();
|
const songMetaCache = makeCache<SubsonicSong | null>();
|
||||||
@@ -79,6 +81,7 @@ export async function prewarmNowPlayingFetchers(
|
|||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
if (!fetchEnabled || !subsonicServerId) return;
|
if (!fetchEnabled || !subsonicServerId) return;
|
||||||
|
if (!shouldAttemptSubsonicForServer(subsonicServerId, songId)) return;
|
||||||
|
|
||||||
const jobs: Array<Promise<unknown>> = [];
|
const jobs: Array<Promise<unknown>> = [];
|
||||||
|
|
||||||
@@ -196,9 +199,14 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
|||||||
const [lfmArtistEntry, setLfmArtistEntry] = useState<KeySlot<LastfmArtistStats | null>>(() =>
|
const [lfmArtistEntry, setLfmArtistEntry] = useState<KeySlot<LastfmArtistStats | null>>(() =>
|
||||||
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
|
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)
|
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fetchEnabled || !subsonicServerId || !songId) { setSongMetaEntry(null); return; }
|
if (!subsonicFetchAllowed || !songId) { setSongMetaEntry(null); return; }
|
||||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||||
const cached = songMetaCache.get(cacheKey);
|
const cached = songMetaCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
|
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 }); } })
|
.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 }); } });
|
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchEnabled, subsonicServerId, songId]);
|
}, [subsonicFetchAllowed, subsonicServerId, songId, connStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fetchEnabled || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
|
if (!subsonicFetchAllowed || !artistId) { setArtistInfoEntry(null); return; }
|
||||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||||
const cached = artistInfoCache.get(cacheKey);
|
const cached = artistInfoCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
|
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 }); } })
|
.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 }); } });
|
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchEnabled, subsonicServerId, artistId, audiomuseNavidromeEnabled]);
|
}, [subsonicFetchAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fetchEnabled || !subsonicServerId || !albumId) { setAlbumDataEntry(null); return; }
|
if (!subsonicFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
|
||||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||||
const cached = albumCache.get(cacheKey);
|
const cached = albumCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
|
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 }); } })
|
.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 }); } });
|
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchEnabled, subsonicServerId, albumId]);
|
}, [subsonicFetchAllowed, subsonicServerId, albumId, connStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fetchEnabled || !topSongsKey) { setTopSongsEntry(null); return; }
|
if (!subsonicFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
|
||||||
const cached = topSongsCache.get(topSongsKey);
|
const cached = topSongsCache.get(topSongsKey);
|
||||||
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
|
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
|
||||||
setTopSongsEntry(null);
|
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 }); } })
|
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
|
||||||
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
|
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchEnabled, topSongsKey, subsonicServerId, artistName]);
|
}, [subsonicFetchAllowed, topSongsKey, subsonicServerId, artistName, connStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
|
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
|
||||||
@@ -263,7 +271,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
|||||||
|
|
||||||
// Discography via getArtist
|
// Discography via getArtist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fetchEnabled || !subsonicServerId || !artistId) { setDiscographyEntry(null); return; }
|
if (!subsonicFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
|
||||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||||
const cached = discographyCache.get(cacheKey);
|
const cached = discographyCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
|
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 }); } })
|
.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: [] }); } });
|
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchEnabled, subsonicServerId, artistId]);
|
}, [subsonicFetchAllowed, subsonicServerId, artistId, connStatus]);
|
||||||
|
|
||||||
// Last.fm track info (per-track)
|
// Last.fm track info (per-track)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { usePlaybackServerId } from './usePlaybackServerId';
|
import { usePlaybackServerId } from './usePlaybackServerId';
|
||||||
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
||||||
|
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||||
|
|
||||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export function useNowPlayingPrewarm(): void {
|
|||||||
lastfmUsername,
|
lastfmUsername,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
subsonicServerId: playbackServerId,
|
subsonicServerId: playbackServerId,
|
||||||
fetchEnabled: true,
|
fetchEnabled: shouldAttemptSubsonicForServer(playbackServerId, currentTrack.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentTrack.albumId && currentTrack.id) {
|
if (currentTrack.albumId && currentTrack.id) {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { queueSongStar } from '../store/pendingStarSync';
|
import { queueSongStar } from '../store/pendingStarSync';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import {
|
import {
|
||||||
lastfmLoveTrack, lastfmUnloveTrack,
|
lastfmLoveTrack, lastfmUnloveTrack,
|
||||||
type LastfmTrackInfo,
|
type LastfmTrackInfo,
|
||||||
} from '../api/lastfm';
|
} from '../api/lastfm';
|
||||||
|
|
||||||
export interface NowPlayingStarLoveDeps {
|
export interface NowPlayingStarLoveDeps {
|
||||||
currentTrack: { id: string; title: string; artist: string } | null;
|
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
|
||||||
songMeta: SubsonicSong | null;
|
songMeta: SubsonicSong | null;
|
||||||
lfmTrack: LastfmTrackInfo | null;
|
lfmTrack: LastfmTrackInfo | null;
|
||||||
lfmLoveEnabled: boolean;
|
lfmLoveEnabled: boolean;
|
||||||
@@ -31,7 +32,7 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
|
|||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
const next = !starred;
|
const next = !starred;
|
||||||
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
|
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]);
|
}, [currentTrack, starred]);
|
||||||
|
|
||||||
// Last.fm love (seeded from track.getInfo, toggle via love/unlove)
|
// Last.fm love (seeded from track.getInfo, toggle via love/unlove)
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import type { NavigateFunction } from 'react-router-dom';
|
|||||||
type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
|
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:
|
* connection status:
|
||||||
* - Disconnect with cached content → push `/offline`.
|
* - Disconnect with manual offline pins → push `/offline`.
|
||||||
* - Reconnect while sitting on `/offline` → push back to `/`.
|
* - 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
|
* Only fires on transitions (not on every render). Reconnect-bounce is
|
||||||
* gated on `prev === 'disconnected'` so a user who navigates to `/offline`
|
* gated on `prev === 'disconnected'` so a user who navigates to `/offline`
|
||||||
@@ -15,7 +16,8 @@ type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
|
|||||||
*/
|
*/
|
||||||
export function useOfflineAutoNav(
|
export function useOfflineAutoNav(
|
||||||
connStatus: ConnStatus | string,
|
connStatus: ConnStatus | string,
|
||||||
hasOfflineContent: boolean,
|
hasManualOfflineContent: boolean,
|
||||||
|
favoritesOfflineBrowse: boolean,
|
||||||
pathname: string,
|
pathname: string,
|
||||||
navigate: NavigateFunction,
|
navigate: NavigateFunction,
|
||||||
): void {
|
): void {
|
||||||
@@ -24,11 +26,25 @@ export function useOfflineAutoNav(
|
|||||||
const prev = prevConnStatus.current;
|
const prev = prevConnStatus.current;
|
||||||
prevConnStatus.current = connStatus;
|
prevConnStatus.current = connStatus;
|
||||||
|
|
||||||
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
|
if (connStatus === 'disconnected' && prev !== 'disconnected') {
|
||||||
navigate('/offline', { replace: true });
|
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 });
|
navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
}, [connStatus, hasOfflineContent, pathname, navigate]);
|
}, [
|
||||||
|
connStatus,
|
||||||
|
hasManualOfflineContent,
|
||||||
|
favoritesOfflineBrowse,
|
||||||
|
pathname,
|
||||||
|
navigate,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ describe('usePlaybackServerId', () => {
|
|||||||
expect(result.current).toBe('a');
|
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 () => {
|
it('does not call switchActiveServer when browsed server changes', async () => {
|
||||||
const { switchActiveServer } = await import('../utils/server/switchActiveServer');
|
const { switchActiveServer } = await import('../utils/server/switchActiveServer');
|
||||||
vi.mocked(switchActiveServer).mockClear();
|
vi.mocked(switchActiveServer).mockClear();
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
|||||||
*/
|
*/
|
||||||
export function usePlaybackServerId(): string {
|
export function usePlaybackServerId(): string {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
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);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => getPlaybackServerId(),
|
() => getPlaybackServerId(),
|
||||||
[queueServerId, queueLength, activeServerId],
|
[queueServerId, queueIndex, playingServerId, activeServerId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistSta
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// F4: optimistic override + retried server sync via the central helper (no rollback).
|
// 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 };
|
return { handleRate, handleToggleStar };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getAlbumList } from '../api/subsonicLibrary';
|
import { getAlbumList } from '../api/subsonicLibrary';
|
||||||
|
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
|
||||||
import {
|
import {
|
||||||
NEW_RELEASES_RESET_DELAY_MS,
|
NEW_RELEASES_RESET_DELAY_MS,
|
||||||
NEW_RELEASES_SEEN_MAX_IDS,
|
NEW_RELEASES_SEEN_MAX_IDS,
|
||||||
@@ -62,7 +63,7 @@ export function useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pa
|
|||||||
const seq = ++newReleasesRefreshSeqRef.current;
|
const seq = ++newReleasesRefreshSeqRef.current;
|
||||||
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
|
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
|
||||||
|
|
||||||
if (!isLoggedIn || !serverId) {
|
if (!isLoggedIn || !serverId || !isActiveServerReachable()) {
|
||||||
if (isCurrent()) setNewReleasesUnreadCount(0);
|
if (isCurrent()) setNewReleasesUnreadCount(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-23
@@ -4,8 +4,10 @@ import type { QueueItemRef } from './store/playerStoreTypes';
|
|||||||
import { resolveQueueTrack } from './utils/library/queueTrackView';
|
import { resolveQueueTrack } from './utils/library/queueTrackView';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { useHotCacheStore } from './store/hotCacheStore';
|
import { selectHotCacheEntries, useHotCacheStore } from './store/hotCacheStore';
|
||||||
import { useOfflineStore } from './store/offlineStore';
|
import { useLocalPlaybackStore } from './store/localPlaybackStore';
|
||||||
|
import { getMediaDir } from './utils/media/mediaDir';
|
||||||
|
import { librarySqlServerId } from './api/coverCache';
|
||||||
import { usePlayerStore } from './store/playerStore';
|
import { usePlayerStore } from './store/playerStore';
|
||||||
import {
|
import {
|
||||||
bumpHotCachePreviousTrackGrace,
|
bumpHotCachePreviousTrackGrace,
|
||||||
@@ -25,6 +27,11 @@ import {
|
|||||||
scheduleAnalysisQueuePruneFromPlaybackQueue,
|
scheduleAnalysisQueuePruneFromPlaybackQueue,
|
||||||
resetAnalysisPruneState,
|
resetAnalysisPruneState,
|
||||||
} from './hotCachePrefetch/analysisPrune';
|
} 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;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
|
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
|
||||||
@@ -99,12 +106,16 @@ async function runWorker() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offline = useOfflineStore.getState();
|
if (hasLocalPersistentPlaybackBytes(job.trackId, job.serverId)) {
|
||||||
if (offline.isDownloaded(job.trackId, job.serverId)) {
|
hotCacheFrontendDebug({
|
||||||
hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'offline-library' });
|
event: 'prefetch-skip-job',
|
||||||
|
trackId: job.trackId,
|
||||||
|
reason: 'persistent-local-bytes',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) {
|
const hotIndex = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
|
||||||
|
if (hotIndex[entryKey(job.serverId, job.trackId)]) {
|
||||||
hotCacheFrontendDebug({
|
hotCacheFrontendDebug({
|
||||||
event: 'prefetch-skip-job',
|
event: 'prefetch-skip-job',
|
||||||
trackId: job.trackId,
|
trackId: job.trackId,
|
||||||
@@ -141,7 +152,7 @@ async function runWorker() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const track = resolveQueueTrack(jobRef);
|
const track = resolveQueueTrack(jobRef);
|
||||||
const hotEntries = useHotCacheStore.getState().entries;
|
const hotEntries = selectHotCacheEntries(useLocalPlaybackStore.getState().entries);
|
||||||
const occupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, job.serverId, hotEntries);
|
const occupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, job.serverId, hotEntries);
|
||||||
const est = estimateTrackHotCacheBytes(track);
|
const est = estimateTrackHotCacheBytes(track);
|
||||||
const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId;
|
const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId;
|
||||||
@@ -159,16 +170,27 @@ async function runWorker() {
|
|||||||
|
|
||||||
const url = buildStreamUrlForServer(job.serverId, job.trackId);
|
const url = buildStreamUrlForServer(job.serverId, job.trackId);
|
||||||
try {
|
try {
|
||||||
const customDir = auth.hotCacheDownloadDir || null;
|
const mediaDir = getMediaDir();
|
||||||
hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId });
|
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,
|
trackId: job.trackId,
|
||||||
serverId: job.serverId,
|
serverIndexKey: job.serverId,
|
||||||
|
libraryServerId: librarySqlServerId(job.serverId),
|
||||||
url,
|
url,
|
||||||
suffix: job.suffix,
|
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 });
|
hotCacheFrontendDebug({ event: 'prefetch-stored', trackId: job.trackId, sizeBytes: res.size });
|
||||||
const fresh = usePlayerStore.getState();
|
const fresh = usePlayerStore.getState();
|
||||||
const authAfter = useAuthStore.getState();
|
const authAfter = useAuthStore.getState();
|
||||||
@@ -178,10 +200,19 @@ async function runWorker() {
|
|||||||
fresh.queueIndex,
|
fresh.queueIndex,
|
||||||
maxAfter,
|
maxAfter,
|
||||||
getPlaybackCacheServerKey(),
|
getPlaybackCacheServerKey(),
|
||||||
authAfter.hotCacheDownloadDir || null,
|
getMediaDir(),
|
||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} 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 {
|
} finally {
|
||||||
@@ -216,7 +247,7 @@ async function replanNow() {
|
|||||||
|
|
||||||
const serverId = playbackSid;
|
const serverId = playbackSid;
|
||||||
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
||||||
const customDir = auth.hotCacheDownloadDir || null;
|
const mediaDir = getMediaDir();
|
||||||
if (maxBytes <= 0) return;
|
if (maxBytes <= 0) return;
|
||||||
|
|
||||||
const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState();
|
const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState();
|
||||||
@@ -225,13 +256,11 @@ async function replanNow() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offline = useOfflineStore.getState();
|
await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, mediaDir);
|
||||||
|
|
||||||
await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, customDir);
|
|
||||||
|
|
||||||
// Must read entries after eviction: the pre-evict snapshot still lists removed keys and would
|
// 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.
|
// 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
|
// Thin-state: resolve only the small upcoming window (within the resolver-warm
|
||||||
// range) to full Tracks for the size estimates / suffix.
|
// range) to full Tracks for the size estimates / suffix.
|
||||||
@@ -242,8 +271,8 @@ async function replanNow() {
|
|||||||
const jobs: PrefetchJob[] = [];
|
const jobs: PrefetchJob[] = [];
|
||||||
const skipped: { trackId: string; reason: string }[] = [];
|
const skipped: { trackId: string; reason: string }[] = [];
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
if (offline.isDownloaded(t.id, serverId)) {
|
if (hasLocalPersistentPlaybackBytes(t.id, serverId)) {
|
||||||
skipped.push({ trackId: t.id, reason: 'offline-library' });
|
skipped.push({ trackId: t.id, reason: 'persistent-local-bytes' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (hotEntries[entryKey(serverId, t.id)]) {
|
if (hotEntries[entryKey(serverId, t.id)]) {
|
||||||
@@ -307,7 +336,7 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
|
|
||||||
let lastAuthSig = '';
|
let lastAuthSig = '';
|
||||||
const unsubAuth = useAuthStore.subscribe((state, prev) => {
|
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;
|
if (sig === lastAuthSig) return;
|
||||||
lastAuthSig = sig;
|
lastAuthSig = sig;
|
||||||
|
|
||||||
@@ -326,7 +355,7 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
const budgetSettingsChanged =
|
const budgetSettingsChanged =
|
||||||
!prev ||
|
!prev ||
|
||||||
state.hotCacheMaxMb !== prev.hotCacheMaxMb ||
|
state.hotCacheMaxMb !== prev.hotCacheMaxMb ||
|
||||||
state.hotCacheDownloadDir !== prev.hotCacheDownloadDir ||
|
state.mediaDir !== prev.mediaDir ||
|
||||||
state.hotCacheEnabled !== prev.hotCacheEnabled ||
|
state.hotCacheEnabled !== prev.hotCacheEnabled ||
|
||||||
state.activeServerId !== prev.activeServerId ||
|
state.activeServerId !== prev.activeServerId ||
|
||||||
state.isLoggedIn !== prev.isLoggedIn;
|
state.isLoggedIn !== prev.isLoggedIn;
|
||||||
@@ -356,6 +385,12 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
void replanNow();
|
void replanNow();
|
||||||
scheduleAnalysisQueuePruneFromPlaybackQueue();
|
scheduleAnalysisQueuePruneFromPlaybackQueue();
|
||||||
|
|
||||||
|
const maintenanceTimer = window.setInterval(() => {
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled) return;
|
||||||
|
void reconcileEphemeralCache();
|
||||||
|
}, EPHEMERAL_MAINTENANCE_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubPlayer();
|
unsubPlayer();
|
||||||
unsubAuth();
|
unsubAuth();
|
||||||
@@ -363,6 +398,7 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
debounceTimer = null;
|
debounceTimer = null;
|
||||||
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
||||||
graceEvictTimer = null;
|
graceEvictTimer = null;
|
||||||
|
window.clearInterval(maintenanceTimer);
|
||||||
resetAnalysisPruneState();
|
resetAnalysisPruneState();
|
||||||
pendingQueue.length = 0;
|
pendingQueue.length = 0;
|
||||||
clearHotCachePreviousGrace();
|
clearHotCachePreviousGrace();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ export const albumDetail = {
|
|||||||
downloading: 'Lade…',
|
downloading: 'Lade…',
|
||||||
cacheOffline: 'Offline verfügbar machen',
|
cacheOffline: 'Offline verfügbar machen',
|
||||||
offlineCached: 'Offline verfügbar',
|
offlineCached: 'Offline verfügbar',
|
||||||
|
offlineQueued: 'In Warteschlange',
|
||||||
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
|
offlineDownloading: 'Wird gecacht… ({{n}}/{{total}})',
|
||||||
removeOffline: 'Offline-Cache löschen',
|
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.',
|
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',
|
offlineStorageGoToSettings: 'Einstellungen',
|
||||||
offlineStorageGoToLibrary: 'Offline-Bibliothek',
|
offlineStorageGoToLibrary: 'Offline-Bibliothek',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const artistDetail = {
|
|||||||
similarArtists: 'Ähnliche Künstler',
|
similarArtists: 'Ähnliche Künstler',
|
||||||
cacheOffline: 'Diskografie offline speichern',
|
cacheOffline: 'Diskografie offline speichern',
|
||||||
offlineCached: 'Diskografie gecacht',
|
offlineCached: 'Diskografie gecacht',
|
||||||
|
offlineQueued: 'In Warteschlange',
|
||||||
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
|
||||||
uploadImage: 'Künstlerbild hochladen',
|
uploadImage: 'Künstlerbild hochladen',
|
||||||
uploadImageError: 'Bild konnte nicht hochgeladen werden',
|
uploadImageError: 'Bild konnte nicht hochgeladen werden',
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export const connection = {
|
|||||||
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
|
offlineModeBanner: 'Offline-Modus — Wiedergabe aus lokalem Cache',
|
||||||
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
|
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
|
||||||
offlineLibraryTitle: 'Offline-Bibliothek',
|
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.',
|
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||||
offlineCachedOnServer: 'Auf {{server}}',
|
offlineCachedOnServer: 'Auf {{server}}',
|
||||||
offlineAlbumCount: '{{n}} Album',
|
offlineAlbumCount: '{{n}} Album',
|
||||||
@@ -18,6 +23,11 @@ export const connection = {
|
|||||||
offlineFilterAlbums: 'Alben',
|
offlineFilterAlbums: 'Alben',
|
||||||
offlineFilterPlaylists: 'Playlists',
|
offlineFilterPlaylists: 'Playlists',
|
||||||
offlineFilterArtists: 'Diskografien',
|
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',
|
retry: 'Erneut versuchen',
|
||||||
serverSettings: 'Server-Einstellungen',
|
serverSettings: 'Server-Einstellungen',
|
||||||
switchServerTitle: 'Server wechseln',
|
switchServerTitle: 'Server wechseln',
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ export const favorites = {
|
|||||||
topArtists: 'Top-Künstler nach Favoriten',
|
topArtists: 'Top-Künstler nach Favoriten',
|
||||||
topArtistsSongCount_one: '{{count}} Song',
|
topArtistsSongCount_one: '{{count}} Song',
|
||||||
topArtistsSongCount_other: '{{count}} Songs',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,16 +145,23 @@ export const settings = {
|
|||||||
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
|
scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden',
|
||||||
behavior: 'App-Verhalten',
|
behavior: 'App-Verhalten',
|
||||||
cacheTitle: 'Max. Speichergröße',
|
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:',
|
cacheUsedImages: 'Bilder:',
|
||||||
cacheUsedOffline: 'Offline-Tracks:',
|
cacheUsedOffline: 'Offline-Tracks:',
|
||||||
cacheUsedHot: 'Belegter Speicher:',
|
cacheUsedHot: 'Belegter Speicher:',
|
||||||
hotCacheTrackCount: 'Titel im Cache:',
|
hotCacheTrackCount: 'Titel im Cache:',
|
||||||
cacheMaxLabel: 'Max. Größe',
|
cacheMaxLabel: 'Max. Größe',
|
||||||
cacheClearBtn: 'Cache leeren',
|
cacheClearBtn: 'Cache leeren',
|
||||||
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
|
cacheClearWarning: 'Löscht alle gecachten Cover- und Künstlerbilder im Browser-Speicher. Sie werden bei Bedarf neu geladen.',
|
||||||
cacheClearConfirm: 'Alles löschen',
|
cacheClearConfirm: 'Bild-Cache leeren',
|
||||||
cacheClearCancel: 'Abbrechen',
|
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)',
|
offlineDirTitle: 'Offline-Bibliothek (In-App)',
|
||||||
offlineDirDesc: 'Speicherort für Titel, die du innerhalb von Psysonic offline verfügbar machst.',
|
offlineDirDesc: 'Speicherort für Titel, die du innerhalb von Psysonic offline verfügbar machst.',
|
||||||
offlineDirDefault: 'Standard (App-Daten)',
|
offlineDirDefault: 'Standard (App-Daten)',
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export const albumDetail = {
|
|||||||
downloading: 'Loading…',
|
downloading: 'Loading…',
|
||||||
cacheOffline: 'Make available offline',
|
cacheOffline: 'Make available offline',
|
||||||
offlineCached: 'Available offline',
|
offlineCached: 'Available offline',
|
||||||
|
offlineQueued: 'In queue',
|
||||||
offlineDownloading: 'Caching… ({{n}}/{{total}})',
|
offlineDownloading: 'Caching… ({{n}}/{{total}})',
|
||||||
removeOffline: 'Remove offline cache',
|
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.',
|
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',
|
offlineStorageGoToLibrary: 'Offline Library',
|
||||||
offlineStorageGoToSettings: 'Settings',
|
offlineStorageGoToSettings: 'Settings',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const artistDetail = {
|
|||||||
similarArtists: 'Similar Artists',
|
similarArtists: 'Similar Artists',
|
||||||
cacheOffline: 'Save discography offline',
|
cacheOffline: 'Save discography offline',
|
||||||
offlineCached: 'Discography cached',
|
offlineCached: 'Discography cached',
|
||||||
|
offlineQueued: 'In queue',
|
||||||
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
|
||||||
uploadImage: 'Upload artist image',
|
uploadImage: 'Upload artist image',
|
||||||
uploadImageError: 'Failed to upload image',
|
uploadImageError: 'Failed to upload image',
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export const connection = {
|
|||||||
offlineModeBanner: 'Offline Mode — playing from local cache',
|
offlineModeBanner: 'Offline Mode — playing from local cache',
|
||||||
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
|
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
|
||||||
offlineLibraryTitle: 'Offline Library',
|
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".',
|
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}}',
|
offlineCachedOnServer: 'On {{server}}',
|
||||||
offlineAlbumCount: '{{n}} album',
|
offlineAlbumCount: '{{n}} album',
|
||||||
offlineAlbumCount_plural: '{{n}} albums',
|
offlineAlbumCount_plural: '{{n}} albums',
|
||||||
@@ -18,6 +24,11 @@ export const connection = {
|
|||||||
offlineFilterAlbums: 'Albums',
|
offlineFilterAlbums: 'Albums',
|
||||||
offlineFilterPlaylists: 'Playlists',
|
offlineFilterPlaylists: 'Playlists',
|
||||||
offlineFilterArtists: 'Discographies',
|
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',
|
retry: 'Retry',
|
||||||
serverSettings: 'Server Settings',
|
serverSettings: 'Server Settings',
|
||||||
switchServerTitle: 'Switch server',
|
switchServerTitle: 'Switch server',
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ export const favorites = {
|
|||||||
topArtists: 'Top Artists by Favorites',
|
topArtists: 'Top Artists by Favorites',
|
||||||
topArtistsSongCount_one: '{{count}} song',
|
topArtistsSongCount_one: '{{count}} song',
|
||||||
topArtistsSongCount_other: '{{count}} songs',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,12 +145,15 @@ export const settings = {
|
|||||||
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
|
scrobbleDesc: 'Send songs to Last.fm after 50% playtime',
|
||||||
behavior: 'App Behavior',
|
behavior: 'App Behavior',
|
||||||
cacheTitle: 'Max. Storage Size',
|
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:',
|
cacheUsedImages: 'Images:',
|
||||||
cacheUsedOffline: 'Offline tracks:',
|
cacheUsedOffline: 'Offline tracks:',
|
||||||
cacheUsedHot: 'Size on disk:',
|
cacheUsedHot: 'Size on disk:',
|
||||||
hotCacheTrackCount: 'Tracks in cache:',
|
hotCacheTrackCount: 'Tracks in cache:',
|
||||||
cacheMaxLabel: 'Max. size',
|
cacheMaxLabel: 'Max. size',
|
||||||
|
imageCacheScopeLabel: 'Scope',
|
||||||
|
imageCacheSubTitle: 'In-browser previews',
|
||||||
|
coverDiskCacheSubTitle: 'On-disk cover cache',
|
||||||
coverCacheStrategyTitle: 'Cover art 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.',
|
coverCacheStrategyDesc: 'You can choose a strategy for each server: Lazy — load covers as needed; Aggressive — load as needed plus extra background preloading.',
|
||||||
coverCacheStrategyLabel: 'Strategy',
|
coverCacheStrategyLabel: 'Strategy',
|
||||||
@@ -172,12 +175,19 @@ export const settings = {
|
|||||||
coverCacheStrategyClearError: 'Failed to clear cover cache.',
|
coverCacheStrategyClearError: 'Failed to clear cover cache.',
|
||||||
coverCacheStrategyServerRemoved: 'removed server',
|
coverCacheStrategyServerRemoved: 'removed server',
|
||||||
cacheClearBtn: 'Clear Cache',
|
cacheClearBtn: 'Clear Cache',
|
||||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
cacheClearWarning: 'Clears all cached cover and artist images from in-browser storage. They will be re-downloaded when needed.',
|
||||||
cacheClearConfirm: 'Clear Everything',
|
cacheClearConfirm: 'Clear image cache',
|
||||||
cacheClearCancel: 'Cancel',
|
cacheClearCancel: 'Cancel',
|
||||||
waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).',
|
waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).',
|
||||||
waveformCacheClearFailed: 'Failed to clear waveform cache.',
|
waveformCacheClearFailed: 'Failed to clear waveform cache.',
|
||||||
storageTabTitle: 'Offline & 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)',
|
offlineDirTitle: 'Offline Library (In-App)',
|
||||||
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
||||||
offlineDirDefault: 'Default (App Data)',
|
offlineDirDefault: 'Default (App Data)',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const sidebar = {
|
|||||||
collapse: 'Collapse Sidebar',
|
collapse: 'Collapse Sidebar',
|
||||||
|
|
||||||
downloadingTracks: 'Caching {{n}} tracks…',
|
downloadingTracks: 'Caching {{n}} tracks…',
|
||||||
|
offlinePinActive: 'Caching «{{name}}»…',
|
||||||
|
offlinePinActiveQueued: 'Caching «{{name}}» · +{{queued}} queued',
|
||||||
syncingTracks: 'Syncing {{done}}/{{total}}…',
|
syncingTracks: 'Syncing {{done}}/{{total}}…',
|
||||||
cancelDownload: 'Cancel download',
|
cancelDownload: 'Cancel download',
|
||||||
offlineLibrary: 'Offline Library',
|
offlineLibrary: 'Offline Library',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user