Compare commits

...

4 Commits

Author SHA1 Message Date
cucadmuh ebbbc39014 fix(library): bound post-sync maintenance 2026-07-21 00:42:51 +03:00
cucadmuh 92954fbbc3 fix: close multi-server ownership gaps 2026-07-20 18:57:22 +03:00
cucadmuh f8799228e2 test: await most-played cover wake effect 2026-07-20 17:46:27 +03:00
cucadmuh b4623a3eaa docs: describe simultaneous multi-server support 2026-07-20 17:33:10 +03:00
48 changed files with 1651 additions and 14615 deletions
File diff suppressed because it is too large Load Diff
+34 -9
View File
@@ -13,30 +13,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Added
### Multi-server library — browse selected servers as one catalogue
### True simultaneous multi-server support — use every server as one music library
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* The library scope now combines selected libraries across configured servers for Home, albums, artists, composers, genres, favourites, playlists, search, statistics and detail pages, while de-duplicating shared music by scope priority.
* Actions preserve the concrete owning server even when identical track, album or artist IDs exist elsewhere. Source pickers expose equivalent copies, and playback can offer another selected source when the first one fails.
* Select music folders from several configured servers in the same priority-ordered library scope. Psysonic browses them simultaneously without making you switch the active server before every search, album, artist or playback action.
* **Home, Albums, Artists, Composers, Genres, Favourites, Playlists, Folder Browser, Search, Most Played, Statistics, album details and artist details** aggregate the selected servers into one catalogue. Shared music is de-duplicated by scope priority instead of appearing once per server.
* De-duplication no longer discards physical ownership: each logical track, album and artist retains every concrete server source. Psysonic can therefore show one clean catalogue while still knowing exactly which server must handle playback, artwork, metadata and mutations.
### Mixed-server playback — one queue can play tracks from different servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* A single queue can contain tracks owned by different servers. Each item resolves its stream, cover, lyrics, ReplayGain data and analysis against its own server instead of whichever server is currently active.
* Server play queues are pulled, updated and reconciled per owner, so mixed queues survive restart without one server replacing another server's tracks. Gapless, crossfade, infinite queue, shuffle, history and queue restore keep the same ownership.
* When one copy cannot play, a new source chooser can offer equivalent copies from the selected servers rather than failing the whole action. Album, artist and track source controls also let you choose a specific physical copy when that distinction matters.
### Server-aware destinations and actions
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Playlist creation, smart-playlist editing, radio actions and other destination-sensitive flows select the target server explicitly instead of silently using the active server.
* Context menus, ratings, favourites, sharing, offline pins, device sync and Orbit carry the item's owner through the complete action. The same numeric Subsonic ID may now safely exist on several servers at once.
## Changed
### Large libraries — incremental browse and identity maintenance
### Library index — designed for several live servers at once
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Scoped browse projections, identity matching and sync recovery now update incrementally instead of repeatedly rebuilding the whole catalogue, keeping startup and foreground refreshes responsive on large multi-server libraries.
* Home feeds, text search, detail reads, most-played results and statistics use indexed local scope queries where available, with network fallback retained for servers that are not indexed yet.
* The local SQLite library now keeps server ownership, music-folder scope and cross-server identity as separate indexed concepts. Scoped browse projections power combined catalogues without scanning or merging every server response in the UI.
* Home feeds, text search, genre counts, details, Most Played and Statistics use indexed multi-server reads when local data is ready, while retaining network fallbacks for a server that has not completed its index yet.
* Identity matching and browse projections update incrementally after each server sync. A durable invalidation journal resumes unfinished work after restart instead of forcing repeated full-catalogue rebuilds on startup.
### Sync and reachability — one unavailable server no longer blocks the others
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Psysonic tracks readiness, sync progress and reachability independently for every configured server. Available servers remain browsable while another selected server is offline, still indexing or recovering from an interrupted sync.
* Full and delta sync preserve source ownership through remaps, deletions and tombstones; no-op syncs avoid unnecessary catalogue refreshes, and stale multi-server state is repaired automatically.
## Fixed
### Multi-server ownership — mutations and playback stay on the correct server
### Cross-server ownership — IDs no longer collide or drift to the active server
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Queue restore and top-up, Orbit sessions, ratings, favourites, playlists, device sync, offline pins, covers, lyrics, radio, sharing and context-menu actions no longer drift to the active server when an item belongs to another selected server.
* Sync interruptions and unavailable servers no longer leave stale scope state blocking browse refreshes; pending identity work resumes safely after restart.
* Album and artist navigation, covers, lyrics, ratings, favourites, playlists, radio, sharing, offline browse, device sync and context-menu actions no longer jump to the wrong server when two servers reuse the same entity ID.
* Orbit host/guest state, queue suggestions and cleanup stay bound to the session server; switching the visible library or active server cannot redirect an existing session.
* Composer, genre, favourite, playlist and folder views preserve the selected multi-server scope through filtering, sorting, selection and playback instead of collapsing back to a single server.
### Startup — keep the loading splash until initial content is ready
+1 -1
View File
@@ -312,7 +312,7 @@ The 5-minute TTL is a conservative compromise: long enough to survive a brief ap
### Lifecycle
- `src/utils/orbit.ts``startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
- `src/utils/server/switchActiveServer.ts`switches the active account without tearing down the source-bound Orbit session.
### Hooks
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
@@ -0,0 +1,8 @@
-- Resumable cursor for bounded post-sync library membership tagging.
CREATE TABLE IF NOT EXISTS library_tag_cursor (
server_id TEXT PRIMARY KEY,
folders_hash TEXT NOT NULL,
next_folder_id TEXT NOT NULL,
next_album_offset INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
@@ -377,23 +377,6 @@ fn inspect_album(store: &LibraryStore) -> Result<ScopeBrowseProjectionInspectDto
done_tracks: total.max(0) as u64,
});
}
let migration_started: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
params![MIGRATION_ID],
|r| r.get(0),
)?;
let has_projection: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM album_browse_projection)",
[],
|r| r.get(0),
)?;
if !migration_started && has_projection {
return Ok(ScopeBrowseProjectionInspectDto {
needed: false,
total_tracks: total.max(0) as u64,
done_tracks: total.max(0) as u64,
});
}
let cursor = cursor_rowid(conn)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
@@ -436,18 +419,8 @@ pub fn is_ready(store: &LibraryStore) -> Result<bool, String> {
if migration_completed(conn)? {
return Ok(true);
}
// Fresh installs have no legacy catalog to backfill: sync maintains
// projection rows incrementally before the first browse request.
let migration_started: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
params![MIGRATION_ID],
|r| r.get(0),
)?;
if migration_started {
return Ok(false);
}
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM album_browse_projection)",
"SELECT NOT EXISTS(SELECT 1 FROM track WHERE deleted = 0)",
[],
|r| r.get(0),
)
@@ -694,6 +667,47 @@ mod tests {
assert_eq!(count, 1);
}
#[test]
fn partial_incremental_projection_does_not_imply_completion() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("t1", "a1", "Album One", "lib"),
track("t2", "a2", "Album Two", "lib"),
])
.unwrap();
store
.with_conn_mut("test.partial_projection", |conn| {
conn.execute(
"DELETE FROM album_browse_projection WHERE album_id = 'a2'",
[],
)?;
conn.execute(
"DELETE FROM library_data_migration WHERE id = ?1",
params![MIGRATION_ID],
)?;
Ok(())
})
.unwrap();
let status = inspect_album(&store).unwrap();
assert!(status.needed);
assert_eq!(status.total_tracks, 2);
assert_eq!(status.done_tracks, 0);
assert!(!is_ready(&store).unwrap());
run_backfill_impl(&store, None).unwrap();
assert!(is_ready(&store).unwrap());
let count: i64 = store
.with_read_conn(|conn| {
conn.query_row("SELECT COUNT(*) FROM album_browse_projection", [], |row| {
row.get(0)
})
})
.unwrap();
assert_eq!(count, 2);
}
#[test]
fn ordinary_browse_reconciles_partial_keys_to_one_canonical_album_partition() {
let store = LibraryStore::open_in_memory();
@@ -1467,7 +1467,7 @@ async fn library_sync_start_inner(
let _ = app_for_emit.emit(LibrarySyncProgressPayload::PROGRESS_EVENT_NAME, &payload);
}
// Wait for the runner to finish + emit sync-idle.
let outcome = match runner_handle.await {
let mut outcome = match runner_handle.await {
Ok(Ok(())) => {
LibrarySyncIdlePayload::ok(
&server_id_for_emit,
@@ -1520,6 +1520,7 @@ async fn library_sync_start_inner(
server_id_for_emit,
error
);
outcome.mark_failed(format!("identity maintenance failed: {error}"));
}
}
}
@@ -1921,6 +1922,10 @@ fn purge_server_data(
"DELETE FROM library_tag_state WHERE server_id = ?1",
params![server_id],
)?;
tx.execute(
"DELETE FROM library_tag_cursor WHERE server_id = ?1",
params![server_id],
)?;
tx.execute(
"DELETE FROM cluster.track_cluster_key WHERE server_id = ?1",
params![server_id],
@@ -2213,8 +2218,10 @@ mod tests {
VALUES ('{server_id}', '{track_id}', 'Rock', '{album_id}');
INSERT INTO artist_artwork_lookup(server_id, artist_id, surface_kind, status, updated_at)
VALUES ('{server_id}', '{artist_id}', 'fanart', 'hit', 1);
INSERT INTO library_tag_state(server_id, folders_hash, completed_at)
VALUES ('{server_id}', 'hash', 1);
INSERT INTO library_tag_state(server_id, folders_hash, completed_at)
VALUES ('{server_id}', 'hash', 1);
INSERT INTO library_tag_cursor(server_id, folders_hash, next_folder_id, updated_at)
VALUES ('{server_id}', 'hash', 'folder-1', 1);
INSERT INTO entity_user_rating(server_id, entity_kind, entity_id, rating, fetched_at)
VALUES ('{server_id}', 'track', '{track_id}', 5, 1);
INSERT INTO album_browse_projection(
@@ -2648,6 +2655,7 @@ mod tests {
("track_genre", "server_id"),
("artist_artwork_lookup", "server_id"),
("library_tag_state", "server_id"),
("library_tag_cursor", "server_id"),
("entity_user_rating", "server_id"),
("album_browse_projection", "server_id"),
("canonical_enrichment_link", "owner_server_id"),
@@ -305,23 +305,6 @@ pub(crate) fn inspect(store: &LibraryStore) -> Result<ScopeBrowseProjectionInspe
done_tracks: total.max(0) as u64,
});
}
let migration_started: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
params![MIGRATION_ID],
|row| row.get(0),
)?;
let has_projection: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM composer_album_projection)",
[],
|row| row.get(0),
)?;
if !migration_started && has_projection {
return Ok(ScopeBrowseProjectionInspectDto {
needed: false,
total_tracks: total.max(0) as u64,
done_tracks: total.max(0) as u64,
});
}
let cursor = cursor_rowid(conn)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
@@ -139,6 +139,10 @@ fn backfill_is_idempotent_and_marks_completion() {
store
.with_conn_mut("test", |conn| {
conn.execute("DELETE FROM composer_album_projection", [])?;
conn.execute(
"DELETE FROM library_data_migration WHERE id = ?1",
params![MIGRATION_ID],
)?;
Ok(())
})
.unwrap();
@@ -154,3 +158,49 @@ fn backfill_is_idempotent_and_marks_completion() {
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn partial_incremental_projection_does_not_imply_completion() {
let store = LibraryStore::open_in_memory();
let raw_one = serde_json::json!({
"contributors": [{ "role": "composer", "artistId": "c1", "name": "One" }]
});
let raw_two = serde_json::json!({
"contributors": [{ "role": "composer", "artistId": "c2", "name": "Two" }]
});
TrackRepository::new(&store)
.upsert_batch(&[
track("t1", "a1", raw_one),
track("t2", "a2", raw_two),
])
.unwrap();
store
.with_conn_mut("test.partial_composer_projection", |conn| {
conn.execute(
"DELETE FROM composer_album_projection WHERE composer_id = 'c2'",
[],
)?;
conn.execute(
"DELETE FROM library_data_migration WHERE id = ?1",
params![MIGRATION_ID],
)?;
Ok(())
})
.unwrap();
let status = inspect(&store).unwrap();
assert!(status.needed);
assert_eq!(status.total_tracks, 2);
assert_eq!(status.done_tracks, 0);
run_backfill(&store, None).unwrap();
assert!(!inspect(&store).unwrap().needed);
let count: i64 = store
.with_conn("test", |conn| {
conn.query_row("SELECT COUNT(*) FROM composer_album_projection", [], |row| {
row.get(0)
})
})
.unwrap();
assert_eq!(count, 2);
}
@@ -479,7 +479,22 @@ pub fn rebuild_cluster_keys(store: &LibraryStore, server_id: Option<&str>) -> Re
/// - durable invalidations are pending for this server. Initial/resync ingestion
/// records a server invalidation; ordinary mutations record exact entities.
pub fn ensure_cluster_keys_built(store: &LibraryStore, server_id: &str) -> Result<u64, String> {
let pending = match store
.with_read_conn(|conn| Ok(pending_rebuild(conn, server_id)?.is_some()))
{
Ok(pending) => pending,
// Shared-cache in-memory tests and a busy sidecar can briefly reject a
// read while another owner commits cluster metadata. Serialize through
// the writer and re-check instead of surfacing a maintenance failure.
Err(error) if error.contains("locked") => true,
Err(error) => return Err(error),
};
if !pending {
return Ok(0);
}
store.with_conn_mut("identity.ensure_cluster_keys_built", |conn| {
// Re-check under the writer lock because another maintenance owner may
// have drained the journal after the read-only preflight.
let Some(pending) = pending_rebuild(conn, server_id)? else {
return Ok(0);
};
@@ -1142,6 +1157,59 @@ mod tests {
assert_eq!(rebuilt.into_iter().sum::<u64>(), 1);
}
#[test]
fn clean_identity_ensure_does_not_wait_for_writer_lock() {
use std::sync::{mpsc, Arc};
use std::time::Duration;
let store = Arc::new(LibraryStore::open_in_memory());
TrackRepository::new(&store)
.upsert_batch(&[track_row(
"s1",
"t1",
"Title",
Some("Artist"),
"Album",
None,
180,
"lib",
)])
.unwrap();
rebuild_cluster_keys(&store, None).unwrap();
let (writer_started_tx, writer_started_rx) = mpsc::channel();
let (release_writer_tx, release_writer_rx) = mpsc::channel();
let writer_store = Arc::clone(&store);
let writer = std::thread::spawn(move || {
writer_store
.with_conn_mut("test.hold_writer", |_conn| {
writer_started_tx.send(()).unwrap();
release_writer_rx.recv().unwrap();
Ok(())
})
.unwrap();
});
writer_started_rx.recv().unwrap();
let (ensure_tx, ensure_rx) = mpsc::channel();
let ensure_store = Arc::clone(&store);
let ensure = std::thread::spawn(move || {
ensure_tx
.send(ensure_cluster_keys_built(&ensure_store, "s1"))
.unwrap();
});
let result = ensure_rx.recv_timeout(Duration::from_secs(2));
release_writer_tx.send(()).unwrap();
writer.join().unwrap();
ensure.join().unwrap();
assert_eq!(
result
.expect("clean identity preflight blocked on writer")
.unwrap(),
0
);
}
#[test]
fn repeated_forced_rebuild_skips_unchanged_derived_rows() {
let store = LibraryStore::open_in_memory();
@@ -138,6 +138,11 @@ impl LibrarySyncIdlePayload {
self.job_id = Some(job_id.to_string());
self
}
pub fn mark_failed(&mut self, message: impl Into<String>) {
self.ok = false;
self.error = Some(message.into());
}
}
#[cfg(test)]
@@ -175,6 +180,18 @@ mod tests {
assert!(background.get("jobId").is_none());
}
#[test]
fn idle_payload_failure_preserves_job_context() {
let mut payload =
LibrarySyncIdlePayload::ok("s1", "scope", "delta_sync", "foreground")
.with_job_id("job-1");
payload.mark_failed("identity maintenance failed");
assert!(!payload.ok);
assert_eq!(payload.error.as_deref(), Some("identity maintenance failed"));
assert_eq!(payload.job_id.as_deref(), Some("job-1"));
}
#[test]
fn phase_changed_maps_to_phase_kind() {
let p = LibrarySyncProgressPayload::from_event(
@@ -48,51 +48,10 @@ impl<'a> ArtifactRepository<'a> {
format: Option<&str>,
now: i64,
) -> Result<Option<TrackArtifactDto>, String> {
if self.store.bulk_ingest_active() {
return self.get_readonly(
server_id,
track_id,
artifact_kind,
source_kind,
source_id,
format,
);
}
self.store
.with_conn_mut("artifact.get_gc", |conn| {
// Lazy TTL cleanup, scoped to the looked-up kind.
conn.execute(
"DELETE FROM track_artifact \
WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3 \
AND expires_at IS NOT NULL AND expires_at < ?4",
params![server_id, track_id, artifact_kind, now],
)?;
Self::query_one(
conn,
server_id,
track_id,
artifact_kind,
source_kind,
source_id,
format,
)
})
.map_err(|e| e.to_string())
}
fn get_readonly(
&self,
server_id: &str,
track_id: &str,
artifact_kind: &str,
source_kind: Option<&str>,
source_id: Option<&str>,
format: Option<&str>,
) -> Result<Option<TrackArtifactDto>, String> {
self.store
let (artifact, has_expired) = self
.store
.with_read_conn(|conn| {
Self::query_one(
let artifact = Self::query_one(
conn,
server_id,
track_id,
@@ -100,11 +59,35 @@ impl<'a> ArtifactRepository<'a> {
source_kind,
source_id,
format,
)
now,
)?;
let has_expired = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM track_artifact \
WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3 \
AND expires_at IS NOT NULL AND expires_at < ?4)",
params![server_id, track_id, artifact_kind, now],
|row| row.get::<_, bool>(0),
)?;
Ok((artifact, has_expired))
})
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
if has_expired && !self.store.bulk_ingest_active() {
self.store
.with_conn_mut("artifact.get_gc", |conn| {
conn.execute(
"DELETE FROM track_artifact \
WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3 \
AND expires_at IS NOT NULL AND expires_at < ?4",
params![server_id, track_id, artifact_kind, now],
)?;
Ok(())
})
.map_err(|e| e.to_string())?;
}
Ok(artifact)
}
#[allow(clippy::too_many_arguments)]
fn query_one(
conn: &rusqlite::Connection,
server_id: &str,
@@ -113,19 +96,22 @@ impl<'a> ArtifactRepository<'a> {
source_kind: Option<&str>,
source_id: Option<&str>,
format: Option<&str>,
now: i64,
) -> rusqlite::Result<Option<TrackArtifactDto>> {
let mut sql = String::from(
"SELECT server_id, track_id, artifact_kind, format, source_kind, source_id, \
language, content_text, content_bytes, not_found, content_hash, fetched_at, \
expires_at FROM track_artifact \
WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3",
language, content_text, content_bytes, not_found, content_hash, fetched_at, \
expires_at FROM track_artifact \
WHERE server_id = ?1 AND track_id = ?2 AND artifact_kind = ?3 \
AND (expires_at IS NULL OR expires_at >= ?4)",
);
let mut bound: Vec<Value> = vec![
Value::Text(server_id.to_string()),
Value::Text(track_id.to_string()),
Value::Text(artifact_kind.to_string()),
Value::Integer(now),
];
let mut next = 4;
let mut next = 5;
if let Some(sk) = source_kind {
sql.push_str(&format!(" AND source_kind = ?{next}"));
bound.push(Value::Text(sk.to_string()));
@@ -256,6 +242,9 @@ const UPSERT_ARTIFACT: &str = "INSERT INTO track_artifact \
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{mpsc, Arc};
use std::thread;
use std::time::Duration;
fn artifact(
kind: &str,
@@ -324,6 +313,48 @@ mod tests {
assert_eq!(total, 0, "expired row deleted, not just filtered");
}
#[test]
fn get_without_expired_rows_does_not_wait_for_writer_lock() {
let store = Arc::new(LibraryStore::open_in_memory());
seed_track(&store, "s1", "t1");
ArtifactRepository::new(&store)
.put("s1", "t1", &artifact("lyrics", "plain", "lrclib", "lrclib"), 100)
.unwrap();
let (writer_started_tx, writer_started_rx) = mpsc::channel();
let (release_writer_tx, release_writer_rx) = mpsc::channel();
let writer_store = Arc::clone(&store);
let writer = thread::spawn(move || {
writer_store
.with_conn_mut("test.hold_writer", |_conn| {
writer_started_tx.send(()).unwrap();
release_writer_rx.recv().unwrap();
Ok(())
})
.unwrap();
});
writer_started_rx.recv().unwrap();
let (read_tx, read_rx) = mpsc::channel();
let reader_store = Arc::clone(&store);
let reader = thread::spawn(move || {
read_tx
.send(
ArtifactRepository::new(&reader_store)
.get("s1", "t1", "lyrics", None, None, None, 200),
)
.unwrap();
});
let result = read_rx.recv_timeout(Duration::from_secs(2));
release_writer_tx.send(()).unwrap();
writer.join().unwrap();
reader.join().unwrap();
let artifact = result
.expect("read-only artifact lookup blocked on writer")
.unwrap();
assert!(artifact.is_some());
}
#[test]
fn negative_cache_row_is_returned_until_it_expires() {
let store = LibraryStore::open_in_memory();
@@ -34,33 +34,34 @@ impl<'a> FactRepository<'a> {
fact_kinds: &[String],
now: i64,
) -> Result<Vec<TrackFactDto>, String> {
if self.store.bulk_ingest_active() {
return self.get_readonly(server_id, track_id, fact_kinds);
}
self.store
.with_conn_mut("fact.get_gc", |conn| {
// Lazy TTL cleanup for this track.
conn.execute(
"DELETE FROM track_fact \
let (facts, has_expired) = self
.store
.with_read_conn(|conn| {
let facts = Self::query_facts(conn, server_id, track_id, fact_kinds, now)?;
let has_expired = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM track_fact \
WHERE server_id = ?1 AND track_id = ?2 \
AND expires_at IS NOT NULL AND expires_at < ?3",
AND expires_at IS NOT NULL AND expires_at < ?3)",
params![server_id, track_id, now],
|row| row.get::<_, bool>(0),
)?;
Self::query_facts(conn, server_id, track_id, fact_kinds)
Ok((facts, has_expired))
})
.map_err(|e| e.to_string())
}
fn get_readonly(
&self,
server_id: &str,
track_id: &str,
fact_kinds: &[String],
) -> Result<Vec<TrackFactDto>, String> {
self.store
.with_read_conn(|conn| Self::query_facts(conn, server_id, track_id, fact_kinds))
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
if has_expired && !self.store.bulk_ingest_active() {
self.store
.with_conn_mut("fact.get_gc", |conn| {
conn.execute(
"DELETE FROM track_fact \
WHERE server_id = ?1 AND track_id = ?2 \
AND expires_at IS NOT NULL AND expires_at < ?3",
params![server_id, track_id, now],
)?;
Ok(())
})
.map_err(|e| e.to_string())?;
}
Ok(facts)
}
fn query_facts(
@@ -68,16 +69,17 @@ impl<'a> FactRepository<'a> {
server_id: &str,
track_id: &str,
fact_kinds: &[String],
now: i64,
) -> rusqlite::Result<Vec<TrackFactDto>> {
if fact_kinds.is_empty() {
let mut stmt = conn.prepare(SELECT_FACTS)?;
let rows: rusqlite::Result<Vec<TrackFactDto>> = stmt
.query_map(params![server_id, track_id], row_to_fact_dto)?
.query_map(params![server_id, track_id, now], row_to_fact_dto)?
.collect();
rows
} else {
let placeholders = (0..fact_kinds.len())
.map(|i| format!("?{}", i + 3))
.map(|i| format!("?{}", i + 4))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
@@ -87,6 +89,7 @@ impl<'a> FactRepository<'a> {
let mut bound: Vec<rusqlite::types::Value> = vec![
rusqlite::types::Value::Text(server_id.to_string()),
rusqlite::types::Value::Text(track_id.to_string()),
rusqlite::types::Value::Integer(now),
];
for k in fact_kinds {
bound.push(rusqlite::types::Value::Text(k.clone()));
@@ -163,11 +166,13 @@ fn row_to_fact_dto(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrackFactDto> {
const SELECT_FACTS_BASE: &str = "SELECT server_id, track_id, fact_kind, value_real, value_int, \
value_text, unit, source_kind, source_id, confidence, content_hash, fetched_at, expires_at \
FROM track_fact WHERE server_id = ?1 AND track_id = ?2";
FROM track_fact WHERE server_id = ?1 AND track_id = ?2 \
AND (expires_at IS NULL OR expires_at >= ?3)";
const SELECT_FACTS: &str = "SELECT server_id, track_id, fact_kind, value_real, value_int, \
value_text, unit, source_kind, source_id, confidence, content_hash, fetched_at, expires_at \
FROM track_fact WHERE server_id = ?1 AND track_id = ?2 \
AND (expires_at IS NULL OR expires_at >= ?3) \
ORDER BY fact_kind ASC, fetched_at DESC";
const UPSERT_FACT: &str = "INSERT INTO track_fact \
@@ -187,6 +192,9 @@ const UPSERT_FACT: &str = "INSERT INTO track_fact \
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{mpsc, Arc};
use std::thread;
use std::time::Duration;
fn fact(kind: &str, source_kind: &str, value_int: Option<i64>, expires_at: Option<i64>) -> FactInputDto {
FactInputDto {
@@ -248,6 +256,43 @@ mod tests {
assert_eq!(total, 1);
}
#[test]
fn get_without_expired_rows_does_not_wait_for_writer_lock() {
let store = Arc::new(LibraryStore::open_in_memory());
seed_track(&store, "s1", "t1");
FactRepository::new(&store)
.put("s1", "t1", &fact("bpm", "analysis", Some(120), None), 100)
.unwrap();
let (writer_started_tx, writer_started_rx) = mpsc::channel();
let (release_writer_tx, release_writer_rx) = mpsc::channel();
let writer_store = Arc::clone(&store);
let writer = thread::spawn(move || {
writer_store
.with_conn_mut("test.hold_writer", |_conn| {
writer_started_tx.send(()).unwrap();
release_writer_rx.recv().unwrap();
Ok(())
})
.unwrap();
});
writer_started_rx.recv().unwrap();
let (read_tx, read_rx) = mpsc::channel();
let reader_store = Arc::clone(&store);
let reader = thread::spawn(move || {
read_tx
.send(FactRepository::new(&reader_store).get("s1", "t1", &[], 200))
.unwrap();
});
let result = read_rx.recv_timeout(Duration::from_secs(2));
release_writer_tx.send(()).unwrap();
writer.join().unwrap();
reader.join().unwrap();
let facts = result.expect("read-only fact lookup blocked on writer").unwrap();
assert_eq!(facts.len(), 1);
}
#[test]
fn get_filters_by_kind() {
let store = LibraryStore::open_in_memory();
@@ -285,9 +285,11 @@ impl<'a> TrackRepository<'a> {
params![server_id, library_scope, resync_gen, now],
)?
};
crate::browse_projection::rebuild_scope(&tx, server_id, library_scope)?;
crate::identity::prune_cluster_keys_for_scope(&tx, server_id, library_scope)?;
crate::identity::mark_cluster_keys_dirty(&tx, [server_id])?;
if changed > 0 {
crate::browse_projection::rebuild_scope(&tx, server_id, library_scope)?;
crate::identity::prune_cluster_keys_for_scope(&tx, server_id, library_scope)?;
crate::identity::mark_cluster_keys_dirty(&tx, [server_id])?;
}
tx.commit()?;
Ok(changed)
})?;
@@ -619,34 +621,62 @@ impl<'a> TrackRepository<'a> {
let tx = conn.transaction()?;
for chunk in album_ids.chunks(CHUNK) {
let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
let changed_album_sql = format!(
"SELECT DISTINCT album_id FROM track \
WHERE server_id = ? AND deleted = 0 \
AND album_id IN ({placeholders}) \
AND (library_id IS NULL OR library_id = '')"
);
let mut changed_params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
changed_params.extend(chunk.iter().cloned().map(Into::into));
let changed_album_ids = {
let mut statement = tx.prepare(&changed_album_sql)?;
let rows = statement
.query_map(params_from_iter(changed_params.iter()), |row| row.get(0))?
.collect::<rusqlite::Result<Vec<String>>>()?;
rows
};
if changed_album_ids.is_empty() {
continue;
}
let changed_placeholders = (0..changed_album_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"UPDATE track SET library_id = ?1 \
WHERE server_id = ?2 AND deleted = 0 \
AND album_id IN ({placeholders}) \
AND album_id IN ({changed_placeholders}) \
AND (library_id IS NULL OR library_id = '')"
);
let mut params: Vec<rusqlite::types::Value> = vec![
rusqlite::types::Value::Text(library_id.to_string()),
rusqlite::types::Value::Text(server_id.to_string()),
];
for id in chunk {
params.push(id.clone().into());
}
params.extend(changed_album_ids.iter().cloned().map(Into::into));
let n = tx.execute(&sql, params_from_iter(params.iter()))?;
total += n as u64;
tx.execute(
&format!(
"UPDATE track_genre SET library_id = ?1 \
WHERE server_id = ?2 AND track_id IN ( \
SELECT id FROM track WHERE server_id = ?2 \
AND album_id IN ({placeholders}) AND library_id = ?1 \
)"
SELECT id FROM track WHERE server_id = ?2 \
AND album_id IN ({changed_placeholders}) AND library_id = ?1 \
) AND COALESCE(library_id, '') != ?1"
),
params_from_iter(params.iter()),
)?;
crate::identity::refresh_library_ids_for_albums(&tx, server_id, chunk)?;
crate::identity::refresh_library_ids_for_albums(
&tx,
server_id,
&changed_album_ids,
)?;
crate::browse_projection::refresh_library_tagged_albums(
&tx, server_id, library_id, chunk,
&tx,
server_id,
library_id,
&changed_album_ids,
)?;
}
tx.commit()?;
@@ -1210,6 +1240,24 @@ mod tests {
assert_eq!(orphan_deleted, 1);
}
#[test]
fn resync_sweep_with_no_orphans_does_not_rewrite_derived_state() {
let store = LibraryStore::open_in_memory();
let repo = TrackRepository::new(&store);
repo.upsert_batch_initial_ingest_timed(&[row("s1", "seen", "Seen")], Some(2))
.unwrap();
let before = store
.with_conn("test.total_changes", |conn| Ok(conn.total_changes()))
.unwrap();
assert_eq!(repo.sweep_resync_orphans("s1", "", 2).unwrap(), 0);
let after = store
.with_conn("test.total_changes", |conn| Ok(conn.total_changes()))
.unwrap();
assert_eq!(after, before);
}
#[test]
fn scoped_resync_sweep_preserves_other_library_and_refreshes_derived_rows() {
let store = LibraryStore::open_in_memory();
@@ -1441,6 +1489,30 @@ mod tests {
assert_eq!(genre_tagged, 2);
}
#[test]
fn tag_library_by_album_ids_with_no_empty_rows_is_write_free() {
let store = LibraryStore::open_in_memory();
let repo = TrackRepository::new(&store);
let mut tagged = row("s1", "t1", "First");
tagged.library_id = Some("1".into());
tagged.album_id = Some("al1".into());
repo.upsert_batch(&[tagged]).unwrap();
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
let before = store
.with_conn("test.total_changes", |conn| Ok(conn.total_changes()))
.unwrap();
let changed = repo
.tag_library_by_album_ids("s1", "1", &["al1".into()])
.unwrap();
let after = store
.with_conn("test.total_changes", |conn| Ok(conn.total_changes()))
.unwrap();
assert_eq!(changed, 0);
assert_eq!(after, before);
}
#[test]
fn count_untagged_tracks_excludes_deleted_and_populated_rows() {
let store = LibraryStore::open_in_memory();
@@ -693,23 +693,33 @@ pub(crate) fn list_albums_layer1_filtered(
),
format!(
"{cte}, \
per_lib AS ( \
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, \
COUNT(*) AS song_count, SUM(t.duration_sec) AS duration_total, \
s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, \
MIN({ALBUM_PICK_KEY}) AS _pick \
{base_where} \
GROUP BY album_dedup, t.server_id, t.album_id, s.pr \
base AS ( \
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at, \
t.duration_sec, t.id, s.pr, {ALBUM_DEDUP_KEY} AS album_dedup, \
{TRACK_DEDUP_KEY} AS track_dedup \
{base_where} \
), \
track_winners AS ( \
SELECT * FROM ( \
SELECT base.*, ROW_NUMBER() OVER ( \
PARTITION BY album_dedup, track_dedup \
ORDER BY pr, server_id, album_id, id \
) AS track_rank \
FROM base \
) WHERE track_rank = 1 \
) \
SELECT server_id, album_id, album, artist, artist_id, album_artist, \
song_count, duration_total, year, genre, cover_art_id, starred_at, synced_at \
FROM ( \
SELECT server_id, album_id, album, artist, artist_id, album_artist, \
year, genre, cover_art_id, starred_at, synced_at, \
SUM(song_count) AS song_count, SUM(duration_total) AS duration_total, \
MIN(_pick) AS _pick \
FROM per_lib GROUP BY album_dedup \
SELECT server_id, album_id, album, artist, artist_id, album_artist, \
year, genre, cover_art_id, starred_at, synced_at, \
COUNT(*) AS song_count, SUM(duration_sec) AS duration_total, \
MIN(_pick) AS _pick \
FROM ( \
SELECT track_winners.*, {ALBUM_PICK_KEY} AS _pick \
FROM track_winners \
) GROUP BY album_dedup \
) \
{deduped_order_sql} \
LIMIT ? OFFSET ?"
@@ -3030,6 +3040,8 @@ mod tests {
assert_eq!(albums_a[0].id, "alb-a");
assert_eq!(albums_a[0].year, Some(2001));
assert_eq!(albums_a[0].genre.as_deref(), Some("Rock"));
assert_eq!(albums_a[0].song_count, Some(1));
assert_eq!(albums_a[0].duration_sec, Some(200));
let req_b_first = LibraryScopeListRequest {
scopes: vec![scope_pair("s1", "lib-b"), scope_pair("s1", "lib-a")],
@@ -3041,6 +3053,8 @@ mod tests {
assert_eq!(albums_b.len(), 1);
assert_eq!(albums_b[0].id, "alb-b");
assert_eq!(albums_b[0].year, Some(1999));
assert_eq!(albums_b[0].song_count, Some(1));
assert_eq!(albums_b[0].duration_sec, Some(200));
}
#[test]
+104 -1
View File
@@ -12,7 +12,7 @@ use tauri::Manager;
///
/// Migration checklist (wiring, data backfill, open/swap path):
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 25;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 26;
/// One-time data repair after migration 014 (`artist.name_sort`).
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
@@ -86,6 +86,9 @@ pub(crate) const MIGRATION_024_COMPOSER_BROWSE_PROJECTION: &str =
/// Version 25: durable invalidation journal for incremental identity maintenance.
pub(crate) const MIGRATION_025_IDENTITY_INVALIDATION: &str =
include_str!("../migrations/025_identity_invalidation.sql");
/// Version 26: resumable cursor for bounded post-sync library tagging.
pub(crate) const MIGRATION_026_LIBRARY_TAG_CURSOR: &str =
include_str!("../migrations/026_library_tag_cursor.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
@@ -105,6 +108,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
(23, MIGRATION_023_STARRED_BROWSE_INDEXES),
(24, MIGRATION_024_COMPOSER_BROWSE_PROJECTION),
(25, MIGRATION_025_IDENTITY_INVALIDATION),
(26, MIGRATION_026_LIBRARY_TAG_CURSOR),
];
/// Idempotent repair — also runs after the migration runner on every open so
@@ -1526,6 +1530,35 @@ fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
)
}
fn mark_projection_migration_complete_if_empty(
conn: &Connection,
migration_id: &str,
) -> rusqlite::Result<()> {
let required_tables: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('track', 'library_data_migration')",
[],
|row| row.get(0),
)?;
if required_tables != 2 {
return Ok(());
}
let has_live_tracks: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM track WHERE deleted = 0)",
[],
|row| row.get(0),
)?;
if has_live_tracks {
return Ok(());
}
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
VALUES (?1, 0, strftime('%s','now'), strftime('%s','now')) \
ON CONFLICT(id) DO UPDATE SET completed_at = excluded.completed_at",
params![migration_id],
)?;
Ok(())
}
/// Test-friendly entry point. Production code goes through `run_migrations`,
/// which fixes `migrations`, `min_compatible`, and `hook` to the prod values.
pub(crate) fn run_migrations_with(
@@ -1579,6 +1612,17 @@ pub(crate) fn run_migrations_with(
continue;
}
conn.execute_batch(sql)?;
match version {
20 => mark_projection_migration_complete_if_empty(
conn,
crate::browse_projection::MIGRATION_ID,
)?,
24 => mark_projection_migration_complete_if_empty(
conn,
crate::composer_projection::MIGRATION_ID,
)?,
_ => {}
}
record_schema_migration(conn, version)?;
}
Ok(MigrationOutcome::Applied)
@@ -1779,6 +1823,65 @@ mod tests {
);
}
#[test]
fn migration_026_adds_tag_cursor_without_rewriting_completion_state() {
let conn = Connection::open_in_memory().unwrap();
run_migrations_with(
&conn,
&MIGRATIONS[..MIGRATIONS.len() - 1],
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
handle_breaking_schema_bump,
)
.unwrap();
conn.execute(
"INSERT INTO library_tag_state \
(server_id, folders_hash, last_untagged_count, completed_at) \
VALUES ('s1', 'folders', 7, 123)",
[],
)
.unwrap();
run_migrations(&conn).unwrap();
let state: (String, i64, i64) = conn
.query_row(
"SELECT folders_hash, last_untagged_count, completed_at \
FROM library_tag_state WHERE server_id = 's1'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(state, ("folders".into(), 7, 123));
let cursor_table: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'table' AND name = 'library_tag_cursor'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(cursor_table, 1);
}
#[test]
fn fresh_database_marks_projection_backfills_complete() {
let store = LibraryStore::open_in_memory();
let completed: i64 = store
.with_conn("test", |conn| {
conn.query_row(
"SELECT COUNT(*) FROM library_data_migration \
WHERE id IN (?1, ?2) AND completed_at IS NOT NULL",
params![
crate::browse_projection::MIGRATION_ID,
crate::composer_projection::MIGRATION_ID,
],
|row| row.get(0),
)
})
.unwrap();
assert_eq!(completed, 2);
}
#[test]
fn migration_022_backfills_unicode_artist_name_fold() {
let store = LibraryStore::open_in_memory();
@@ -19,6 +19,7 @@ use super::now_unix_ms;
use super::progress::{Progress, ProgressEvent};
const ALBUM_PAGE_SIZE: u32 = 500;
const MAX_ALBUM_LIST_REQUESTS_PER_PASS: u32 = 8;
/// Summary of a library-tagging pass.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -28,6 +29,7 @@ pub struct TagReport {
pub tracks_tagged: u64,
pub untagged_remaining: u64,
pub skipped: bool,
pub completed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -36,6 +38,13 @@ pub(crate) struct TagStateRow {
last_untagged_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TagCursorRow {
folders_hash: String,
next_folder_id: String,
next_album_offset: u32,
}
/// Stable fingerprint of the server's music-folder list for gating.
pub(crate) fn folders_hash(folders: &[MusicFolder]) -> String {
let mut pairs: Vec<(String, String)> = folders
@@ -55,11 +64,15 @@ pub(crate) fn folders_hash(folders: &[MusicFolder]) -> String {
pub(crate) fn should_run_tagging_pass(
untagged: u64,
prior: Option<&TagStateRow>,
cursor_active: bool,
folders_hash: &str,
) -> bool {
if untagged == 0 {
return false;
}
if cursor_active {
return true;
}
if let Some(p) = prior {
if p.last_untagged_count == untagged && p.folders_hash == folders_hash {
return false;
@@ -68,6 +81,29 @@ pub(crate) fn should_run_tagging_pass(
true
}
fn read_tag_cursor(
store: &LibraryStore,
server_id: &str,
) -> Result<Option<TagCursorRow>, SyncError> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT folders_hash, next_folder_id, next_album_offset \
FROM library_tag_cursor WHERE server_id = ?1",
rusqlite::params![server_id],
|row| {
Ok(TagCursorRow {
folders_hash: row.get(0)?,
next_folder_id: row.get(1)?,
next_album_offset: row.get::<_, i64>(2)?.max(0) as u32,
})
},
)
.optional()
})
.map_err(|e| SyncError::Storage(e.to_string()))
}
fn read_tag_state(store: &LibraryStore, server_id: &str) -> Result<Option<TagStateRow>, SyncError> {
store
.with_read_conn(|conn| {
@@ -86,7 +122,39 @@ fn read_tag_state(store: &LibraryStore, server_id: &str) -> Result<Option<TagSta
.map_err(|e| SyncError::Storage(e.to_string()))
}
fn write_tag_state(
fn write_tag_cursor(
store: &LibraryStore,
server_id: &str,
folders_hash: &str,
next_folder_id: &str,
next_album_offset: u32,
) -> Result<(), SyncError> {
let now = now_unix_ms();
store
.with_conn_mut("library_tag.write_cursor", |conn| {
conn.execute(
"INSERT INTO library_tag_cursor \
(server_id, folders_hash, next_folder_id, next_album_offset, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5) \
ON CONFLICT(server_id) DO UPDATE SET \
folders_hash = excluded.folders_hash, \
next_folder_id = excluded.next_folder_id, \
next_album_offset = excluded.next_album_offset, \
updated_at = excluded.updated_at",
rusqlite::params![
server_id,
folders_hash,
next_folder_id,
next_album_offset as i64,
now
],
)
})
.map_err(|e| SyncError::Storage(e.to_string()))?;
Ok(())
}
fn write_tag_completion(
store: &LibraryStore,
server_id: &str,
folders_hash: &str,
@@ -94,19 +162,25 @@ fn write_tag_state(
) -> Result<(), SyncError> {
let now = now_unix_ms();
store
.with_conn_mut("library_tag.write_state", |conn| {
conn.execute(
"INSERT INTO library_tag_state (server_id, folders_hash, last_untagged_count, completed_at) \
.with_conn_mut("library_tag.write_completion", |conn| {
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO library_tag_state \
(server_id, folders_hash, last_untagged_count, completed_at) \
VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT(server_id) DO UPDATE SET \
folders_hash = excluded.folders_hash, \
last_untagged_count = excluded.last_untagged_count, \
completed_at = excluded.completed_at",
rusqlite::params![server_id, folders_hash, untagged as i64, now],
)
)?;
tx.execute(
"DELETE FROM library_tag_cursor WHERE server_id = ?1",
rusqlite::params![server_id],
)?;
tx.commit()
})
.map_err(|e| SyncError::Storage(e.to_string()))?;
Ok(())
.map_err(|e| SyncError::Storage(e.to_string()))
}
fn check_cancel(cancel: Option<&Arc<AtomicBool>>) -> Result<(), SyncError> {
@@ -142,6 +216,7 @@ pub async fn tag_library_membership(
tracks_tagged: 0,
untagged_remaining: 0,
skipped: true,
completed: true,
});
}
@@ -156,18 +231,24 @@ pub async fn tag_library_membership(
tracks_tagged: 0,
untagged_remaining: untagged,
skipped: true,
completed: true,
});
}
let mut folders = folders;
folders.sort_by(|a, b| a.id.cmp(&b.id));
let hash = folders_hash(&folders);
let prior = read_tag_state(store, server_id)?;
if !should_run_tagging_pass(untagged, prior.as_ref(), &hash) {
let cursor = read_tag_cursor(store, server_id)?;
let active_cursor = cursor.as_ref().filter(|cursor| cursor.folders_hash == hash);
if !should_run_tagging_pass(untagged, prior.as_ref(), active_cursor.is_some(), &hash) {
return Ok(TagReport {
folders_processed: 0,
albums_processed: 0,
tracks_tagged: 0,
untagged_remaining: untagged,
skipped: true,
completed: true,
});
}
@@ -178,12 +259,31 @@ pub async fn tag_library_membership(
let mut folders_processed = 0u32;
let mut albums_processed = 0u32;
let mut tracks_tagged = 0u64;
let mut requests_made = 0u32;
let mut completed = true;
let (start_folder_index, start_offset) = active_cursor
.and_then(|cursor| {
folders
.iter()
.position(|folder| folder.id == cursor.next_folder_id)
.map(|index| (index, cursor.next_album_offset))
})
.unwrap_or((0, 0));
for folder in &folders {
'folders: for (folder_index, folder) in folders.iter().enumerate().skip(start_folder_index) {
check_cancel(cancel.as_ref())?;
let mut offset = 0u32;
let mut offset = if folder_index == start_folder_index {
start_offset
} else {
0
};
loop {
check_cancel(cancel.as_ref())?;
if requests_made >= MAX_ALBUM_LIST_REQUESTS_PER_PASS {
write_tag_cursor(store, server_id, &hash, &folder.id, offset)?;
completed = false;
break 'folders;
}
let page = subsonic
.get_album_list2(
"alphabeticalByName",
@@ -193,7 +293,12 @@ pub async fn tag_library_membership(
)
.await
.map_err(SyncError::from)?;
requests_made += 1;
if page.is_empty() {
folders_processed += 1;
if let Some(next_folder) = folders.get(folder_index + 1) {
write_tag_cursor(store, server_id, &hash, &next_folder.id, 0)?;
}
break;
}
let album_ids: Vec<String> = page.iter().map(|a| a.id.clone()).collect();
@@ -204,17 +309,23 @@ pub async fn tag_library_membership(
tracks_tagged += tagged;
if page.len() < ALBUM_PAGE_SIZE as usize {
folders_processed += 1;
if let Some(next_folder) = folders.get(folder_index + 1) {
write_tag_cursor(store, server_id, &hash, &next_folder.id, 0)?;
}
break;
}
offset = offset.saturating_add(ALBUM_PAGE_SIZE);
write_tag_cursor(store, server_id, &hash, &folder.id, offset)?;
}
folders_processed += 1;
}
let untagged_remaining = tracks
.count_untagged_tracks(server_id)
.map_err(SyncError::Storage)?;
write_tag_state(store, server_id, &hash, untagged_remaining)?;
if completed {
write_tag_completion(store, server_id, &hash, untagged_remaining)?;
}
Ok(TagReport {
folders_processed,
@@ -222,6 +333,7 @@ pub async fn tag_library_membership(
tracks_tagged,
untagged_remaining,
skipped: false,
completed,
})
}
@@ -247,11 +359,12 @@ pub async fn run_tag_pass_best_effort(
{
Ok(report) if !report.skipped => {
crate::app_eprintln!(
"[library-tag] server `{server_id}`: tagged {} tracks across {} folders ({} albums), {} untagged left",
"[library-tag] server `{server_id}`: tagged {} tracks across {} folders ({} albums), {} untagged left, completed={}",
report.tracks_tagged,
report.folders_processed,
report.albums_processed,
report.untagged_remaining,
report.completed,
);
}
Ok(_) => {}
@@ -265,247 +378,4 @@ pub async fn run_tag_pass_best_effort(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::store::LibraryStore;
use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials};
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn track_row(server: &str, id: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: id.into(),
title_sort: None,
artist: Some("A".into()),
artist_id: Some("ar1".into()),
album: "Al".into(),
album_id: Some(album_id.into()),
album_artist: Some("A".into()),
duration_sec: 100,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn test_client(base: &str) -> SubsonicClient {
SubsonicClient::with_static_credentials(
base.to_string(),
SubsonicCredentials {
username: "u".into(),
token: "t".into(),
salt: "s".into(),
},
reqwest::Client::new(),
)
}
#[test]
fn folders_hash_is_order_independent() {
let a = vec![
MusicFolder {
id: "2".into(),
name: "B".into(),
},
MusicFolder {
id: "1".into(),
name: "A".into(),
},
];
let b = vec![
MusicFolder {
id: "1".into(),
name: "A".into(),
},
MusicFolder {
id: "2".into(),
name: "B".into(),
},
];
assert_eq!(folders_hash(&a), folders_hash(&b));
assert_eq!(folders_hash(&a), "1:A|2:B");
}
#[test]
fn should_run_tagging_pass_gates_no_progress() {
let prior = TagStateRow {
folders_hash: "1:Main".into(),
last_untagged_count: 5,
};
assert!(!should_run_tagging_pass(0, None, "1:Main"));
assert!(!should_run_tagging_pass(5, Some(&prior), "1:Main"));
assert!(should_run_tagging_pass(4, Some(&prior), "1:Main"));
assert!(should_run_tagging_pass(5, Some(&prior), "1:Other"));
}
#[tokio::test(flavor = "multi_thread")]
async fn tag_library_membership_tags_by_album_and_respects_prior_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": {
"musicFolder": [
{ "id": 1, "name": "Main" },
{ "id": 2, "name": "Other" }
]
}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/getAlbumList2.view"))
.and(query_param("musicFolderId", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"albumList2": {
"album": [{ "id": "alb-a", "name": "A" }]
}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/getAlbumList2.view"))
.and(query_param("musicFolderId", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"albumList2": {
"album": [{ "id": "alb-b", "name": "B" }]
}
}
})))
.mount(&server)
.await;
let store = LibraryStore::open_in_memory();
let mut already = track_row("srv", "t0", "alb-a");
already.library_id = Some("9".into());
TrackRepository::new(&store)
.upsert_batch(&[
track_row("srv", "t1", "alb-a"),
track_row("srv", "t2", "alb-b"),
already,
])
.unwrap();
let report = tag_library_membership(
&store,
&test_client(&server.uri()),
"srv",
None,
Arc::new(super::super::progress::NoopProgress),
false,
)
.await
.unwrap();
assert!(!report.skipped);
assert_eq!(report.folders_processed, 2);
assert_eq!(report.tracks_tagged, 2);
assert_eq!(report.untagged_remaining, 0);
let lib1: String = store
.with_read_conn(|c| {
c.query_row(
"SELECT library_id FROM track WHERE id = 't1'",
[],
|r| r.get(0),
)
})
.unwrap();
let lib2: String = store
.with_read_conn(|c| {
c.query_row(
"SELECT library_id FROM track WHERE id = 't2'",
[],
|r| r.get(0),
)
})
.unwrap();
let kept: String = store
.with_read_conn(|c| {
c.query_row(
"SELECT library_id FROM track WHERE id = 't0'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(lib1, "1");
assert_eq!(lib2, "2");
assert_eq!(kept, "9");
}
#[tokio::test(flavor = "multi_thread")]
async fn tag_library_membership_skips_when_no_progress_possible() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": {
"musicFolder": { "id": 1, "name": "Main" }
}
}
})))
.expect(1)
.mount(&server)
.await;
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track_row("srv", "orphan", "no-album")])
.unwrap();
write_tag_state(&store, "srv", "1:Main", 1).unwrap();
let report = tag_library_membership(
&store,
&test_client(&server.uri()),
"srv",
None,
Arc::new(super::super::progress::NoopProgress),
false,
)
.await
.unwrap();
assert!(report.skipped);
assert_eq!(report.albums_processed, 0);
assert_eq!(report.tracks_tagged, 0);
assert_eq!(report.untagged_remaining, 1);
}
}
mod tests;
@@ -0,0 +1,324 @@
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::store::LibraryStore;
use psysonic_integration::subsonic::{SubsonicClient, SubsonicCredentials};
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn track_row(server: &str, id: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: id.into(),
title_sort: None,
artist: Some("A".into()),
artist_id: Some("ar1".into()),
album: "Al".into(),
album_id: Some(album_id.into()),
album_artist: Some("A".into()),
duration_sec: 100,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn test_client(base: &str) -> SubsonicClient {
SubsonicClient::with_static_credentials(
base.to_string(),
SubsonicCredentials {
username: "u".into(),
token: "t".into(),
salt: "s".into(),
},
reqwest::Client::new(),
)
}
#[test]
fn folders_hash_is_order_independent() {
let a = vec![
MusicFolder {
id: "2".into(),
name: "B".into(),
},
MusicFolder {
id: "1".into(),
name: "A".into(),
},
];
let b = vec![
MusicFolder {
id: "1".into(),
name: "A".into(),
},
MusicFolder {
id: "2".into(),
name: "B".into(),
},
];
assert_eq!(folders_hash(&a), folders_hash(&b));
assert_eq!(folders_hash(&a), "1:A|2:B");
}
#[test]
fn should_run_tagging_pass_gates_no_progress() {
let prior = TagStateRow {
folders_hash: "1:Main".into(),
last_untagged_count: 5,
};
assert!(!should_run_tagging_pass(0, None, false, "1:Main"));
assert!(!should_run_tagging_pass(5, Some(&prior), false, "1:Main"));
assert!(should_run_tagging_pass(5, Some(&prior), true, "1:Main"));
assert!(should_run_tagging_pass(4, Some(&prior), false, "1:Main"));
assert!(should_run_tagging_pass(5, Some(&prior), false, "1:Other"));
}
#[tokio::test(flavor = "multi_thread")]
async fn tag_library_membership_tags_by_album_and_respects_prior_tags() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": {
"musicFolder": [
{ "id": 1, "name": "Main" },
{ "id": 2, "name": "Other" }
]
}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/getAlbumList2.view"))
.and(query_param("musicFolderId", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"albumList2": { "album": [{ "id": "alb-a", "name": "A" }] }
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/getAlbumList2.view"))
.and(query_param("musicFolderId", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"albumList2": { "album": [{ "id": "alb-b", "name": "B" }] }
}
})))
.mount(&server)
.await;
let store = LibraryStore::open_in_memory();
let mut already = track_row("srv", "t0", "alb-a");
already.library_id = Some("9".into());
TrackRepository::new(&store)
.upsert_batch(&[
track_row("srv", "t1", "alb-a"),
track_row("srv", "t2", "alb-b"),
already,
])
.unwrap();
let report = tag_library_membership(
&store,
&test_client(&server.uri()),
"srv",
None,
Arc::new(super::super::progress::NoopProgress),
false,
)
.await
.unwrap();
assert!(!report.skipped);
assert_eq!(report.folders_processed, 2);
assert_eq!(report.tracks_tagged, 2);
assert_eq!(report.untagged_remaining, 0);
assert!(report.completed);
let read_library = |id: &str| -> String {
store
.with_read_conn(|conn| {
conn.query_row("SELECT library_id FROM track WHERE id = ?1", [id], |row| {
row.get(0)
})
})
.unwrap()
};
assert_eq!(read_library("t1"), "1");
assert_eq!(read_library("t2"), "2");
assert_eq!(read_library("t0"), "9");
}
#[tokio::test(flavor = "multi_thread")]
async fn tag_library_membership_skips_when_no_progress_possible() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": { "musicFolder": { "id": 1, "name": "Main" } }
}
})))
.expect(1)
.mount(&server)
.await;
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track_row("srv", "orphan", "no-album")])
.unwrap();
write_tag_completion(&store, "srv", "1:Main", 1).unwrap();
let report = tag_library_membership(
&store,
&test_client(&server.uri()),
"srv",
None,
Arc::new(super::super::progress::NoopProgress),
false,
)
.await
.unwrap();
assert!(report.skipped);
assert_eq!(report.albums_processed, 0);
assert_eq!(report.tracks_tagged, 0);
assert_eq!(report.untagged_remaining, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn tag_library_membership_resumes_from_persisted_page_cursor() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": { "musicFolder": { "id": 1, "name": "Main" } }
}
})))
.expect(3)
.mount(&server)
.await;
for page_index in 0..MAX_ALBUM_LIST_REQUESTS_PER_PASS {
let offset = page_index * ALBUM_PAGE_SIZE;
let albums = (0..ALBUM_PAGE_SIZE)
.map(|index| json!({ "id": format!("album-{}", offset + index), "name": "A" }))
.collect::<Vec<_>>();
Mock::given(method("GET"))
.and(path("/rest/getAlbumList2.view"))
.and(query_param("musicFolderId", "1"))
.and(query_param("offset", offset.to_string()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"albumList2": { "album": albums }
}
})))
.expect(1)
.mount(&server)
.await;
}
let resume_offset = MAX_ALBUM_LIST_REQUESTS_PER_PASS * ALBUM_PAGE_SIZE;
Mock::given(method("GET"))
.and(path("/rest/getAlbumList2.view"))
.and(query_param("musicFolderId", "1"))
.and(query_param("offset", resume_offset.to_string()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"albumList2": { "album": [{ "id": "last-album", "name": "Last" }] }
}
})))
.expect(1)
.mount(&server)
.await;
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track_row("srv", "orphan", "no-album")])
.unwrap();
let client = test_client(&server.uri());
let progress = Arc::new(super::super::progress::NoopProgress);
let first = tag_library_membership(
&store,
&client,
"srv",
None,
progress.clone(),
false,
)
.await
.unwrap();
assert!(!first.completed);
assert_eq!(first.albums_processed, resume_offset);
let persisted_offset: i64 = store
.with_read_conn(|conn| {
conn.query_row(
"SELECT next_album_offset FROM library_tag_cursor WHERE server_id = 'srv'",
[],
|row| row.get(0),
)
})
.unwrap();
assert_eq!(persisted_offset, i64::from(resume_offset));
let second = tag_library_membership(
&store,
&client,
"srv",
None,
progress.clone(),
false,
)
.await
.unwrap();
assert!(second.completed);
assert_eq!(second.albums_processed, 1);
let cursor_count: i64 = store
.with_read_conn(|conn| {
conn.query_row("SELECT COUNT(*) FROM library_tag_cursor", [], |row| row.get(0))
})
.unwrap();
assert_eq!(cursor_count, 0);
let third = tag_library_membership(&store, &client, "srv", None, progress, false)
.await
.unwrap();
assert!(third.skipped);
}
+33 -20
View File
@@ -675,7 +675,7 @@ pub fn run() {
.map_err(|e| format!("library store init failed: {e}"))?;
let runtime = psysonic_library::LibraryRuntime::new(std::sync::Arc::new(store));
app.manage(runtime);
library_identity_maintenance::setup_library_sync_idle_listener(app.handle());
library_identity_maintenance::setup_library_identity_maintenance(app.handle());
let app_for_sched = app.handle().clone();
tauri::async_runtime::spawn(async move {
@@ -779,31 +779,44 @@ pub fn run() {
Ok(report) => {
let identity_store = Arc::clone(&runtime.store);
let identity_server_id = session.server_id.clone();
match tokio::task::spawn_blocking(move || {
psysonic_library::identity::ensure_cluster_keys_built(
&identity_store,
&identity_server_id,
)
})
let identity_error = match tokio::task::spawn_blocking(
move || {
psysonic_library::identity::ensure_cluster_keys_built(
&identity_store,
&identity_server_id,
)
},
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(error)) => crate::app_eprintln!(
"[library-cluster] background maintenance failed server_id={}: {}",
session.server_id,
error
),
Err(error) => crate::app_eprintln!(
"[library-cluster] background maintenance task failed server_id={}: {}",
session.server_id,
error
),
}
if let Some(payload) = scheduler_idle_payload(
Ok(Ok(_)) => None,
Ok(Err(error)) => {
crate::app_eprintln!(
"[library-cluster] background maintenance failed server_id={}: {}",
session.server_id,
error
);
Some(error)
}
Err(error) => {
crate::app_eprintln!(
"[library-cluster] background maintenance task failed server_id={}: {}",
session.server_id,
error
);
Some(error.to_string())
}
};
if let Some(mut payload) = scheduler_idle_payload(
&report,
&session.server_id,
&scope,
) {
if let Some(error) = identity_error {
payload.mark_failed(format!(
"identity maintenance failed: {error}"
));
}
let _ = app_for_session.emit(
psysonic_library::LibrarySyncProgressPayload::IDLE_EVENT_NAME,
&payload,
@@ -23,6 +23,7 @@ const LIBRARY_TABLES: &[ScopedTable] = &[
ScopedTable { table: "track_genre", column: "server_id" },
ScopedTable { table: "artist_artwork_lookup", column: "server_id" },
ScopedTable { table: "library_tag_state", column: "server_id" },
ScopedTable { table: "library_tag_cursor", column: "server_id" },
ScopedTable { table: "entity_user_rating", column: "server_id" },
ScopedTable { table: "album_browse_projection", column: "server_id" },
ScopedTable { table: "composer_album_projection", column: "server_id" },
@@ -810,6 +811,8 @@ mod tests {
include_str!("../../../crates/psysonic-library/migrations/022_artist_name_fold.sql"),
include_str!("../../../crates/psysonic-library/migrations/023_starred_browse_indexes.sql"),
include_str!("../../../crates/psysonic-library/migrations/024_composer_browse_projection.sql"),
include_str!("../../../crates/psysonic-library/migrations/025_identity_invalidation.sql"),
include_str!("../../../crates/psysonic-library/migrations/026_library_tag_cursor.sql"),
] {
conn.execute_batch(migration).expect("apply library migration");
}
@@ -844,8 +847,10 @@ mod tests {
VALUES ('legacy-a', 'track-1', 'Rock', 'album-1');
INSERT INTO artist_artwork_lookup(server_id, artist_id, surface_kind, status, updated_at)
VALUES ('legacy-a', 'artist-1', 'fanart', 'hit', 1);
INSERT INTO library_tag_state(server_id, folders_hash, completed_at)
VALUES ('legacy-a', 'hash', 1);
INSERT INTO library_tag_state(server_id, folders_hash, completed_at)
VALUES ('legacy-a', 'hash', 1);
INSERT INTO library_tag_cursor(server_id, folders_hash, next_folder_id, updated_at)
VALUES ('legacy-a', 'hash', 'folder-1', 1);
INSERT INTO entity_user_rating(server_id, entity_kind, entity_id, rating, fetched_at)
VALUES ('legacy-a', 'track', 'track-1', 5, 1);
INSERT INTO album_browse_projection(
+4 -39
View File
@@ -1,17 +1,10 @@
use std::sync::Arc;
use serde::Deserialize;
use tauri::{AppHandle, Listener, Manager};
use tauri::{AppHandle, Manager};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncIdlePayload {
server_id: String,
ok: bool,
}
/// Consume durable identity invalidations even when no webview page is mounted.
pub fn setup_library_sync_idle_listener(app: &AppHandle) {
/// Drain durable identity invalidations left by an interrupted prior process.
/// Active sync orchestrators own post-sync maintenance before emitting idle.
pub fn setup_library_identity_maintenance(app: &AppHandle) {
if let Some(runtime) = app.try_state::<psysonic_library::LibraryRuntime>() {
let store = Arc::clone(&runtime.store);
tauri::async_runtime::spawn_blocking(move || {
@@ -20,32 +13,4 @@ pub fn setup_library_sync_idle_listener(app: &AppHandle) {
}
});
}
let app_handle = app.clone();
let _ = app.listen(
psysonic_library::LibrarySyncProgressPayload::IDLE_EVENT_NAME,
move |event| {
let Ok(payload) = serde_json::from_str::<SyncIdlePayload>(event.payload()) else {
return;
};
if !payload.ok {
return;
}
let Some(runtime) = app_handle.try_state::<psysonic_library::LibraryRuntime>() else {
return;
};
let store = Arc::clone(&runtime.store);
tauri::async_runtime::spawn_blocking(move || {
if let Err(error) =
psysonic_library::identity::ensure_cluster_keys_built(&store, &payload.server_id)
{
crate::app_eprintln!(
"[library-cluster] sync-idle maintenance failed server_id={}: {}",
payload.server_id,
error
);
}
});
},
);
}
-3
View File
@@ -17,7 +17,6 @@ import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '@/features/offline/utils/favoritesOfflineSync';
import { initPinnedOfflineSync } from '@/features/offline/utils/pinnedOfflineSync';
import { initClusterRebuildOnSync } from '@/lib/library/clusterRebuildOnSync';
import {
initResumeIncompleteOfflinePins,
scheduleResumeIncompleteOfflinePins,
@@ -130,13 +129,11 @@ export default function MainApp() {
const stopInvalidation = initLocalPlaybackInvalidation();
const stopFavoritesSync = initFavoritesOfflineSync();
const stopPinnedOfflineSync = initPinnedOfflineSync();
const stopClusterRebuild = initClusterRebuildOnSync();
const stopOfflineResume = initResumeIncompleteOfflinePins();
return () => {
stopInvalidation();
stopFavoritesSync();
stopPinnedOfflineSync();
stopClusterRebuild();
stopOfflineResume();
};
}, [migrationReady, serverIdsKey]);
+1 -1
View File
@@ -208,7 +208,7 @@ const CONTRIBUTOR_ENTRIES = [
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)',
'Playlists browse — scoped header search by playlist name (PR #1308)',
'Multi-server library scope — unified cross-server browse, source ownership, playback fallback, and incremental identity maintenance (PR #1326)',
'True simultaneous multi-server support — unified catalogue, mixed-server playback, owner-safe actions, source fallback, and incremental indexing (PR #1326)',
],
},
{
+3 -1
View File
@@ -167,7 +167,9 @@ describe('MostPlayed owner-scoped artwork and actions', () => {
serverScope: expectedScope,
}),
}));
expect(mocks.wakeMissingMetadata).toHaveBeenCalledWith(ownerServer.id);
await waitFor(() => {
expect(mocks.wakeMissingMetadata).toHaveBeenCalledWith(ownerServer.id);
});
expect(mocks.useAlbumCoverRef).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
@@ -17,6 +17,7 @@ export function useDeviceSyncDeviceScan(
targetDir: string | null,
sourcesLength: number,
driveDetected: boolean,
ownerServerIndexKey: string | null,
t: TFunction,
): DeviceSyncDeviceScanResult {
const setDeviceFilePaths = useDeviceSyncStore.getState().setDeviceFilePaths;
@@ -64,14 +65,14 @@ export function useDeviceSyncDeviceScan(
'read_device_manifest', { destDir: targetDir }
).then(manifest => {
if (useDeviceSyncStore.getState().targetDir !== requestTarget) return;
const manifestSources = deviceSyncSourcesFromManifest(manifest);
const manifestSources = deviceSyncSourcesFromManifest(manifest, ownerServerIndexKey);
if (manifestSources.length > 0) {
useDeviceSyncStore.getState().clearSources();
manifestSources.forEach(s => useDeviceSyncStore.getState().addSource(s));
showToast(t('deviceSync.manifestImported', { count: manifestSources.length }), 4000, 'info');
}
}).catch(() => {});
}, [targetDir, driveDetected, t]);
}, [targetDir, driveDetected, ownerServerIndexKey, t]);
// Clear device file list and reset import flag when stick is unplugged
useEffect(() => {
+22 -11
View File
@@ -85,8 +85,23 @@ export default function DeviceSync() {
const isRunning = jobStatus === 'running';
// Browser (playlists / albums / artists tabs + their loaders + debounced search)
const {
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
artists, loadingBrowser,
expandedArtistIds, artistAlbumsMap, loadingArtistIds,
toggleArtistExpand,
serverIndexKey: browserServerIndexKey,
} = useDeviceSyncBrowser(activeTab, search, resetSearch);
// ─── Device scan + manifest auto-import ─────────────────────────────────
const { scanDevice } = useDeviceSyncDeviceScan(targetDir, sources.length, driveDetected, t);
const { scanDevice } = useDeviceSyncDeviceScan(
targetDir,
sources.length,
driveDetected,
browserServerIndexKey,
t,
);
// Source status (path map + derived synced/pending/deletion)
const { sourcePathsMap, sourceStatuses } = useDeviceSyncSourceStatuses(
@@ -126,15 +141,6 @@ export default function DeviceSync() {
// ─── Listen for background sync events ──────────────────────────────────
useDeviceSyncJobEvents(t, scanDevice);
// Browser (playlists / albums / artists tabs + their loaders + debounced search)
const {
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
artists, loadingBrowser,
expandedArtistIds, artistAlbumsMap, loadingArtistIds,
toggleArtistExpand,
serverIndexKey: browserServerIndexKey,
} = useDeviceSyncBrowser(activeTab, search, resetSearch);
// ─── Migration handlers ─────────────────────────────────────────────────
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
@@ -156,7 +162,12 @@ export default function DeviceSync() {
setMigrationOldTemplate('');
};
const handleChooseFolder = () => runDeviceSyncChooseFolder({ t, setTargetDir, scanDevice });
const handleChooseFolder = () => runDeviceSyncChooseFolder({
t,
ownerServerIndexKey: browserServerIndexKey,
setTargetDir,
scanDevice,
});
// ─── Sync (non-blocking) ────────────────────────────────────────────────
@@ -3,6 +3,7 @@ import {
deviceSyncOwnerKey,
deviceSyncSourceKey,
deviceSyncSourcesFromManifest,
migrateDeviceSyncPersistedState,
useDeviceSyncStore,
type DeviceSyncSource,
} from './deviceSyncStore';
@@ -25,6 +26,7 @@ describe('deviceSyncStore ownership', () => {
useDeviceSyncStore.setState({
targetDir: null,
sources: [],
legacySources: [],
checkedIds: [],
pendingDeletion: [],
deviceFilePaths: [],
@@ -60,10 +62,36 @@ describe('deviceSyncStore ownership', () => {
sources: [{ type: 'album', id: 'legacy', name: 'Legacy' }],
})).toEqual([]);
expect(deviceSyncSourcesFromManifest({
version: 2,
sources: [{ type: 'album', id: 'legacy', name: 'Legacy' }],
}, sourceA.serverIndexKey)).toEqual([{
type: 'album',
id: 'legacy',
name: 'Legacy',
serverIndexKey: sourceA.serverIndexKey,
}]);
expect(deviceSyncSourcesFromManifest({
version: 3,
ownerServerIndexKey: sourceB.serverIndexKey,
sources: [sourceA],
})).toEqual([]);
});
it('preserves ownerless v0 selections until a server is explicitly selected', () => {
const legacy = { type: 'album' as const, id: 'legacy', name: 'Legacy' };
const migrated = migrateDeviceSyncPersistedState({ sources: [legacy] });
expect(migrated.sources).toEqual([]);
expect(migrated.legacySources).toEqual([legacy]);
useDeviceSyncStore.setState(migrated);
useDeviceSyncStore.getState().addSource(sourceA);
expect(useDeviceSyncStore.getState().legacySources).toEqual([]);
expect(useDeviceSyncStore.getState().sources).toEqual([
{ ...legacy, serverIndexKey: sourceA.serverIndexKey },
sourceA,
]);
});
});
@@ -11,6 +11,8 @@ export interface DeviceSyncSource {
artist?: string;
}
export type LegacyDeviceSyncSource = Omit<DeviceSyncSource, 'serverIndexKey'>;
export type DeviceSyncManifest = {
version?: number;
ownerServerIndexKey?: string;
@@ -38,23 +40,62 @@ function isDeviceSyncSource(value: unknown): value is DeviceSyncSource {
);
}
export function deviceSyncSourcesFromManifest(manifest: DeviceSyncManifest | null): DeviceSyncSource[] {
function isLegacyDeviceSyncSource(value: unknown): value is LegacyDeviceSyncSource {
if (!value || typeof value !== 'object') return false;
const source = value as Partial<DeviceSyncSource>;
return (
(source.type === 'album' || source.type === 'playlist' || source.type === 'artist') &&
typeof source.id === 'string' && source.id.length > 0 &&
typeof source.name === 'string' &&
!source.serverIndexKey
);
}
export function deviceSyncSourcesFromManifest(
manifest: DeviceSyncManifest | null,
fallbackOwnerServerIndexKey?: string | null,
): DeviceSyncSource[] {
if (!manifest || !Array.isArray(manifest.sources)) return [];
const fallbackOwner = fallbackOwnerServerIndexKey
? resolveStorageServerIndexKey(fallbackOwnerServerIndexKey)
: null;
const manifestOwner = manifest.ownerServerIndexKey
? resolveStorageServerIndexKey(manifest.ownerServerIndexKey)
: null;
const sources = manifest.sources.filter(isDeviceSyncSource).flatMap(source => {
const serverIndexKey = resolveStorageServerIndexKey(source.serverIndexKey);
return serverIndexKey ? [{ ...source, serverIndexKey }] : [];
const sources = manifest.sources.flatMap(source => {
if (isDeviceSyncSource(source)) {
const serverIndexKey = resolveStorageServerIndexKey(source.serverIndexKey);
return serverIndexKey ? [{ ...source, serverIndexKey }] : [];
}
return isLegacyDeviceSyncSource(source) && fallbackOwner
? [{ ...source, serverIndexKey: fallbackOwner }]
: [];
});
const owner = deviceSyncOwnerKey(sources);
if (!owner || !manifestOwner || manifestOwner !== owner) return [];
if (!owner || (manifestOwner ? manifestOwner !== owner : fallbackOwner !== owner)) return [];
return sources;
}
export function migrateDeviceSyncPersistedState(persisted: unknown): Partial<DeviceSyncState> {
const state = persisted as Partial<DeviceSyncState> | undefined;
const persistedSources = Array.isArray(state?.sources) ? state.sources : [];
const persistedLegacySources = Array.isArray(state?.legacySources) ? state.legacySources : [];
return {
...state,
sources: persistedSources.filter(isDeviceSyncSource),
legacySources: [
...persistedLegacySources.filter(isLegacyDeviceSyncSource),
...persistedSources.filter(isLegacyDeviceSyncSource),
],
checkedIds: [],
pendingDeletion: [],
};
}
interface DeviceSyncState {
targetDir: string | null;
sources: DeviceSyncSource[]; // persistent device content list
legacySources: LegacyDeviceSyncSource[]; // ownerless v0 selections awaiting explicit recovery
checkedIds: string[]; // currently checked for bulk actions (not persisted)
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
@@ -64,6 +105,7 @@ interface DeviceSyncState {
addSource: (source: DeviceSyncSource) => void;
removeSource: (id: string) => void;
clearSources: () => void;
setLegacySources: (sources: LegacyDeviceSyncSource[]) => void;
toggleChecked: (id: string) => void;
setCheckedIds: (ids: string[]) => void;
markForDeletion: (ids: string[]) => void;
@@ -79,6 +121,7 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
(set) => ({
targetDir: null,
sources: [],
legacySources: [],
checkedIds: [],
pendingDeletion: [],
deviceFilePaths: [],
@@ -91,10 +134,16 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
const owner = deviceSyncOwnerKey(s.sources);
const key = deviceSyncSourceKey(source);
if (!source.serverIndexKey || (owner && owner !== source.serverIndexKey)) return s;
const recovered = s.legacySources.map(legacy => ({
...legacy,
serverIndexKey: owner ?? source.serverIndexKey,
}));
const nextSources = [...s.sources, ...recovered];
return {
sources: s.sources.some((x) => deviceSyncSourceKey(x) === key)
? s.sources
: [...s.sources, source],
sources: nextSources.some((x) => deviceSyncSourceKey(x) === key)
? nextSources
: [...nextSources, source],
legacySources: [],
};
}),
@@ -105,7 +154,8 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
})),
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
clearSources: () => set({ sources: [], legacySources: [], checkedIds: [], pendingDeletion: [] }),
setLegacySources: (legacySources) => set({ legacySources }),
toggleChecked: (id) =>
set((s) => ({
@@ -141,21 +191,12 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
}),
{
name: 'psysonic_device_sync',
version: 1,
migrate: (persisted) => {
const state = persisted as Partial<DeviceSyncState> | undefined;
return {
...state,
// Legacy entries had no owner. Dropping them is safer than binding
// raw IDs to whichever server happens to be active during rehydrate.
sources: Array.isArray(state?.sources) ? state.sources.filter(isDeviceSyncSource) : [],
checkedIds: [],
pendingDeletion: [],
} as DeviceSyncState;
},
version: 2,
migrate: (persisted) => migrateDeviceSyncPersistedState(persisted) as DeviceSyncState,
partialize: (s) => ({
targetDir: s.targetDir,
sources: s.sources,
legacySources: s.legacySources,
}),
}
)
@@ -10,12 +10,13 @@ import { showToast } from '@/lib/dom/toast';
export interface RunDeviceSyncChooseFolderDeps {
t: TFunction;
ownerServerIndexKey: string | null;
setTargetDir: (dir: string) => void;
scanDevice: () => Promise<void>;
}
export async function runDeviceSyncChooseFolder(deps: RunDeviceSyncChooseFolderDeps): Promise<void> {
const { t, setTargetDir, scanDevice } = deps;
const { t, ownerServerIndexKey, setTargetDir, scanDevice } = deps;
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
if (!sel) return;
@@ -28,7 +29,7 @@ export async function runDeviceSyncChooseFolder(deps: RunDeviceSyncChooseFolderD
'read_device_manifest', { destDir: dir }
);
if (useDeviceSyncStore.getState().targetDir !== dir) return;
const manifestSources = deviceSyncSourcesFromManifest(manifest);
const manifestSources = deviceSyncSourcesFromManifest(manifest, ownerServerIndexKey);
if (manifestSources.length > 0) {
useDeviceSyncStore.getState().clearSources();
manifestSources.forEach(s => useDeviceSyncStore.getState().addSource(s));
@@ -92,6 +92,7 @@ import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from '@/features/p
import { armAutodjMixing } from '@/features/playback/store/autodjTransitionUi';
import {
queueItemIdentityKey,
sameQueueItemRef,
sameQueueTrack,
} from '@/features/playback/utils/playback/queueIdentity';
import { reportPlaybackSourceFailure } from '@/features/playback/store/playbackAlternativeStore';
@@ -553,5 +554,19 @@ export function handleAudioError(message: string): void {
queueItems: store.queueItems,
track: store.currentTrack,
detail,
}, () => {
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
const live = usePlayerStore.getState();
const liveRef = live.queueItems[live.queueIndex];
const failedRef = store.queueItems[store.queueIndex];
if (
live.queueIndex !== store.queueIndex ||
!liveRef ||
!failedRef ||
!sameQueueItemRef(liveRef, failedRef)
) return;
live.next(false);
}, 1500);
});
}
@@ -101,7 +101,7 @@ function onStarSuccess(task: Extract<Task, { kind: 'star' }>): void {
// Thin-state: the queue's copy lives in the resolver cache. Patch it in place
// to the synced value rather than dropping it — a dropped entry would blank the
// visible queue row to a "…" placeholder until the next window re-resolve.
patchCachedTrack(task.id, { starred: starredVal }, task.serverId);
patchCachedTrack(task.id, { starred: starredVal }, task.serverId ?? '');
}
function onRatingSuccess(task: Extract<Task, { kind: 'rating' }>): void {
@@ -114,7 +114,9 @@ function onRatingSuccess(task: Extract<Task, { kind: 'rating' }>): void {
});
// Patch the cached queue track in place (see onStarSuccess) so the row keeps
// its title and shows the synced rating without flashing a placeholder.
if (rating !== undefined) patchCachedTrack(task.id, { userRating: rating }, task.serverId);
if (rating !== undefined) {
patchCachedTrack(task.id, { userRating: rating }, task.serverId ?? '');
}
}
/** Optimistically (un)star a song and sync it to the server with retry. */
@@ -9,6 +9,7 @@ import {
queueItemIdentityKey,
queueTrackIdentityKey,
queueTrackIdentityMatches,
sameQueueItemRef,
sameQueueTrack,
} from '@/features/playback/utils/playback/queueIdentity';
import {
@@ -606,6 +607,20 @@ export function runPlayTrack(
queueItems: failed.queueItems,
track: failed.currentTrack,
detail: String(err),
}, () => {
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
const live = get();
const liveRef = live.queueItems[live.queueIndex];
const failedRef = failed.queueItems[failed.queueIndex];
if (
live.queueIndex !== failed.queueIndex ||
!liveRef ||
!failedRef ||
!sameQueueItemRef(liveRef, failedRef)
) return;
live.next(false);
}, 500);
});
});
};
@@ -106,4 +106,36 @@ describe('reportPlaybackSourceFailure', () => {
expect(usePlaybackAlternativeStore.getState().failure?.generation).toBe(2);
});
it('reports unavailable only when no alternative source remains', async () => {
const unavailable = vi.fn();
mocks.resolveSources.mockResolvedValue([]);
reportPlaybackSourceFailure({
generation: 1,
queueIndex: 0,
queueItems: [{ serverId: 'srv-a', trackId: 'failed' }],
track: makeTrack({ id: 'failed', serverId: 'srv-a' }),
detail: 'decode error',
}, unavailable);
await waitFor(() => expect(usePlaybackAlternativeStore.getState().status).toBe('ready'));
expect(unavailable).toHaveBeenCalledOnce();
});
it('keeps the failed slot selected when an alternative exists', async () => {
const unavailable = vi.fn();
mocks.resolveSources.mockResolvedValue([source('srv-b', 'copy')]);
reportPlaybackSourceFailure({
generation: 1,
queueIndex: 0,
queueItems: [{ serverId: 'srv-a', trackId: 'failed' }],
track: makeTrack({ id: 'failed', serverId: 'srv-a' }),
detail: 'decode error',
}, unavailable);
await waitFor(() => expect(usePlaybackAlternativeStore.getState().status).toBe('ready'));
expect(unavailable).not.toHaveBeenCalled();
});
});
@@ -45,7 +45,7 @@ export function reportPlaybackSourceFailure(args: {
queueItems: QueueItemRef[];
track: Track | null;
detail: string;
}): void {
}, onUnavailable?: () => void): void {
const expectedRef = args.queueItems[args.queueIndex];
if (!expectedRef || !args.track) return;
@@ -85,10 +85,12 @@ export function reportPlaybackSourceFailure(args: {
expectedRef,
));
usePlaybackAlternativeStore.setState({ status: 'ready', sources: alternatives });
if (alternatives.length === 0) onUnavailable?.();
}).catch(error => {
console.error('[psysonic] alternative source lookup failed:', error);
if (usePlaybackAlternativeStore.getState().failure?.key !== key) return;
usePlaybackAlternativeStore.setState({ status: 'error', sources: [] });
onUnavailable?.();
});
}
@@ -65,6 +65,7 @@ import {
} from '@/features/playback/store/gaplessPreloadState';
import { queueTrackIdentityKey } from '@/features/playback/utils/playback/queueIdentity';
import { usePlaybackAlternativeStore } from '@/features/playback/store/playbackAlternativeStore';
import { bumpPlayGeneration } from '@/features/playback/store/engineState';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
@@ -245,18 +246,18 @@ describe('audio:ended', () => {
});
describe('audio:error', () => {
it('keeps the failed queue slot selected and opens the alternative-source flow', async () => {
it('skips the failed slot when no alternative source exists', async () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
const next = vi.fn();
usePlayerStore.setState({ isPlaying: true, next });
emitTauriEvent('audio:error', 'decoder failed');
vi.advanceTimersByTime(2_000);
await Promise.resolve();
await vi.waitFor(() => expect(usePlaybackAlternativeStore.getState().status).toBe('ready'));
vi.advanceTimersByTime(1_500);
const player = usePlayerStore.getState();
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(false);
expect(player.queueIndex).toBe(0);
expect(player.currentTrack?.id).toBe(queue[0].id);
expect(player.isPlaying).toBe(false);
@@ -265,6 +266,20 @@ describe('audio:error', () => {
detail: 'decoder failed',
}));
});
it('does not skip after the user changes playback generation', async () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
const next = vi.fn();
usePlayerStore.setState({ isPlaying: true, next });
emitTauriEvent('audio:error', 'decoder failed');
await vi.waitFor(() => expect(usePlaybackAlternativeStore.getState().status).toBe('ready'));
bumpPlayGeneration();
vi.advanceTimersByTime(1_500);
expect(next).not.toHaveBeenCalled();
});
});
describe('audio preload events', () => {
@@ -263,7 +263,7 @@ describe('mixed-server play selection', () => {
});
describe('audio_play failure', () => {
it('does not auto-skip and reports the frozen queue source', async () => {
it('auto-skips only after alternative lookup finds no source', async () => {
const server = makeServer({ id: 'srv-a', url: 'https://a.test' });
useAuthStore.setState({
servers: [server],
@@ -284,7 +284,7 @@ describe('audio_play failure', () => {
await Promise.resolve();
const player = usePlayerStore.getState();
expect(next).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(false);
expect(player.queueIndex).toBe(0);
expect(player.currentTrack?.id).toBe('track-0');
expect(usePlaybackAlternativeStore.getState().failure).toEqual(expect.objectContaining({
@@ -13,6 +13,7 @@ import {
applyQueueOverrides,
seedQueueResolver,
invalidateQueueResolver,
patchCachedTrack,
subscribeQueueResolver,
_resetQueueResolverForTest,
} from './queueTrackResolver';
@@ -144,12 +145,40 @@ describe('queueTrackResolver', () => {
.toBe('https://music.example.com/share/s/token-100');
});
it('invalidateQueueResolver drops only the owner-qualified cached entry', () => {
seedQueueResolver('s1', [
{ id: 't1', title: 'Server 1', artist: '', album: 'A', albumId: 'A', duration: 1 },
]);
seedQueueResolver('s2', [
{ id: 't1', title: 'Server 2', artist: '', album: 'A', albumId: 'A', duration: 1 },
]);
invalidateQueueResolver('t1', 's1');
expect(getCachedTrack(ref('t1'))).toBeUndefined();
expect(getCachedTrack(ref('t1', { serverId: 's2' }))?.title).toBe('Server 2');
});
it('patchCachedTrack updates only the owner-qualified cached entry', () => {
seedQueueResolver('s1', [
{ id: 't1', title: 'Server 1', artist: '', album: 'A', albumId: 'A', duration: 1 },
]);
seedQueueResolver('s2', [
{ id: 't1', title: 'Server 2', artist: '', album: 'A', albumId: 'A', duration: 1 },
]);
patchCachedTrack('t1', { starred: 'now' }, 's1');
expect(getCachedTrack(ref('t1'))?.starred).toBe('now');
expect(getCachedTrack(ref('t1', { serverId: 's2' }))?.starred).toBeUndefined();
});
it('invalidateQueueResolver drops the cached entry', async () => {
ready();
echoBatch();
await resolveBatch([ref('t1')]);
expect(getCachedTrack(ref('t1'))).toBeDefined();
invalidateQueueResolver('t1');
invalidateQueueResolver('t1', 's1');
expect(getCachedTrack(ref('t1'))).toBeUndefined();
});
@@ -275,32 +275,26 @@ export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx
if (end > start) void resolveBatch(refs.slice(start, end));
}
/** Drop cached entries for a track id, forcing the next resolve to re-fetch. */
export function invalidateQueueResolver(trackId: string): void {
let changed = false;
for (const key of [...cache.keys()]) {
if (key.endsWith(`:${trackId}`)) {
cache.delete(key);
changed = true;
}
}
if (changed) notify();
/** Drop one owner-qualified cached entry, forcing the next resolve to re-fetch. */
export function invalidateQueueResolver(trackId: string, serverId: string): void {
const key = refKey({ serverId: canonicalQueueServerKey(serverId), trackId });
if (cache.delete(key)) notify();
}
/** Patch cached entries for a track id in place (e.g. after a star/rating sync
* succeeds). Unlike {@link invalidateQueueResolver}, this keeps the entry so a
* visible queue row never blanks to a placeholder the row stays resolved and
* just reflects the synced value. No-op for refs not currently cached. */
export function patchCachedTrack(trackId: string, patch: Partial<Track>, serverId?: string): void {
let changed = false;
const scopedKey = serverId ? `${canonicalQueueServerKey(serverId)}:${trackId}` : null;
for (const [key, track] of cache) {
if ((scopedKey && key === scopedKey) || (!scopedKey && key.endsWith(`:${trackId}`))) {
cache.set(key, { ...track, ...patch });
changed = true;
}
}
if (changed) notify();
export function patchCachedTrack(
trackId: string,
patch: Partial<Track>,
serverId: string,
): void {
const key = refKey({ serverId: canonicalQueueServerKey(serverId), trackId });
const track = cache.get(key);
if (!track) return;
cache.set(key, { ...track, ...patch });
notify();
}
/** Test-only: clear cache + in-flight set. */
@@ -219,6 +219,29 @@ describe('maybeRefreshCurrentTrackMetadataFromIndex', () => {
expect(s.currentTrack?.id).toBe('t2');
expect(s.currentTrack?.replayGainTrackDb).toBe(-3.0);
});
it('no-ops when the same raw track id switches to another server during index fetch', async () => {
vi.spyOn(resolveSongMetaIndexFirst, 'resolveSongMetaIndexFirst').mockImplementation(async () => {
usePlayerStore.setState({
currentTrack: track('t1', { serverId: 's2', replayGainTrackDb: -3.0 }),
queueItems: [{ serverId: 's2', trackId: 't1' }],
queueIndex: 0,
});
return {
id: 't1',
title: 'Server 1 track',
album: 'Album',
duration: 200,
replayGain: { trackGain: -8.5, trackPeak: 0.91 },
} as Awaited<ReturnType<typeof resolveSongMetaIndexFirst.resolveSongMetaIndexFirst>>;
});
await maybeRefreshCurrentTrackMetadataFromIndex();
const s = usePlayerStore.getState();
expect(s.currentTrack?.serverId).toBe('s2');
expect(s.currentTrack?.replayGainTrackDb).toBe(-3.0);
});
});
describe('syncIdleAppliesToQueueRef', () => {
@@ -27,6 +27,7 @@ import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
import { useAuthStore } from '@/store/authStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import { sameQueueItemRef } from '@/features/playback/utils/playback/queueIdentity';
function replayGainNeighbours(
queueItems: QueueItemRef[],
@@ -84,13 +85,16 @@ function applyCurrentTrackMetadataUpgrade(
if (!shouldSyncCurrentTrackMetadata(prev, merged, queueItems, queueIndex)) return;
usePlayerStore.setState({ currentTrack: merged });
patchCachedTrack(prev.id, {
title: merged.title,
duration: merged.duration,
replayGainTrackDb: merged.replayGainTrackDb,
replayGainAlbumDb: merged.replayGainAlbumDb,
replayGainPeak: merged.replayGainPeak,
});
const ref = queueItems[queueIndex];
if (ref) {
patchCachedTrack(prev.id, {
title: merged.title,
duration: merged.duration,
replayGainTrackDb: merged.replayGainTrackDb,
replayGainAlbumDb: merged.replayGainAlbumDb,
replayGainPeak: merged.replayGainPeak,
}, ref.serverId);
}
if (
isReplayGainActive()
&& shouldUpgradeReplayGainMetadata(prev, merged, queueItems, queueIndex)
@@ -142,13 +146,18 @@ export async function maybeRefreshCurrentTrackMetadataFromIndex(): Promise<void>
if (!serverId) return;
const trackId = currentTrack.id;
const expectedRef = { ...ref };
const song = await resolveSongMetaIndexFirst(serverId, trackId);
if (!song) return;
const live = usePlayerStore.getState();
if (!live.isPlaying || live.currentRadio || !live.currentTrack) return;
const liveRef = live.queueItems[live.queueIndex];
if (live.currentTrack.id !== trackId || liveRef?.trackId !== trackId) return;
if (
live.currentTrack.id !== trackId ||
!liveRef ||
!sameQueueItemRef(liveRef, expectedRef)
) return;
const merged = mergePlaybackTrackMetadata(live.currentTrack, songToTrack(song));
applyCurrentTrackMetadataUpgrade(live.currentTrack, merged, live.queueItems, live.queueIndex);
@@ -1,95 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { LibrarySyncIdlePayload } from '@/lib/api/library/dto';
const rebuildMock = vi.fn();
let idleHandler: ((payload: LibrarySyncIdlePayload) => void) | undefined;
vi.mock('@/generated/bindings', () => ({
commands: {
libraryClusterRebuild: (...args: unknown[]) => rebuildMock(...args),
},
}));
vi.mock('@/lib/api/library/events', () => ({
subscribeLibrarySyncIdle: vi.fn(async (handler: (payload: LibrarySyncIdlePayload) => void) => {
idleHandler = handler;
return () => {
idleHandler = undefined;
};
}),
}));
vi.mock('@/lib/server/serverIndexKey', () => ({
resolveIndexKey: (id: string) => `key:${id}`,
}));
import { initClusterRebuildOnSync } from './clusterRebuildOnSync';
function idlePayload(overrides: Partial<LibrarySyncIdlePayload> = {}): LibrarySyncIdlePayload {
return {
serverId: 'srv-1',
libraryScope: 'default',
kind: 'delta_sync',
ok: true,
error: null,
...overrides,
};
}
describe('initClusterRebuildOnSync', () => {
beforeEach(() => {
rebuildMock.mockReset();
rebuildMock.mockResolvedValue({ status: 'ok', data: 1 });
idleHandler = undefined;
});
it('rebuilds cluster once on ok:true sync idle', async () => {
const stop = initClusterRebuildOnSync();
await Promise.resolve();
idleHandler!(idlePayload());
await vi.waitFor(() => expect(rebuildMock).toHaveBeenCalledOnce());
expect(rebuildMock).toHaveBeenCalledWith('key:srv-1');
stop();
});
it('does not rebuild on ok:false sync idle', async () => {
const stop = initClusterRebuildOnSync();
await Promise.resolve();
idleHandler!(idlePayload({ ok: false, error: 'boom' }));
await Promise.resolve();
expect(rebuildMock).not.toHaveBeenCalled();
stop();
});
it('dedupes concurrent rebuilds for the same server', async () => {
let release!: () => void;
rebuildMock.mockImplementation(
() =>
new Promise(resolve => {
release = () => resolve({ status: 'ok', data: 1 });
}),
);
const stop = initClusterRebuildOnSync();
await Promise.resolve();
idleHandler!(idlePayload());
idleHandler!(idlePayload());
idleHandler!(idlePayload());
expect(rebuildMock).toHaveBeenCalledOnce();
release();
await Promise.resolve();
await Promise.resolve();
idleHandler!(idlePayload());
await vi.waitFor(() => expect(rebuildMock).toHaveBeenCalledTimes(2));
stop();
});
});
-53
View File
@@ -1,53 +0,0 @@
/**
* Rebuild library-cluster identity keys after each successful library sync so
* multi-library dedup does not use stale precomputed keys.
*/
import { commands } from '@/generated/bindings';
import { subscribeLibrarySyncIdle } from '@/lib/api/library/events';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
const inFlight = new Map<string, Promise<boolean>>();
export function rebuildClusterForIndexKey(indexKey: string): Promise<boolean> {
const existing = inFlight.get(indexKey);
if (existing) return existing;
const promise = (async () => {
try {
const res = await commands.libraryClusterRebuild(indexKey);
if (res.status === 'error') {
console.warn('[psysonic] libraryClusterRebuild failed:', indexKey, res.error);
return false;
}
return true;
} catch (err) {
console.warn('[psysonic] libraryClusterRebuild error:', indexKey, err);
return false;
}
})();
inFlight.set(indexKey, promise);
void promise.finally(() => {
if (inFlight.get(indexKey) === promise) inFlight.delete(indexKey);
});
return promise;
}
/** Subscribe globally; call the returned fn on teardown (e.g. MainApp unmount). */
export function initClusterRebuildOnSync(): () => void {
let unlisten: (() => void) | undefined;
let stopped = false;
void subscribeLibrarySyncIdle(payload => {
if (!payload.ok) return;
const indexKey = resolveIndexKey(payload.serverId);
void rebuildClusterForIndexKey(indexKey);
}).then(fn => {
if (stopped) fn();
else unlisten = fn;
});
return () => {
stopped = true;
unlisten?.();
unlisten = undefined;
};
}
@@ -5,10 +5,6 @@ import { useAuthStore } from '@/store/authStore';
const syncIdleHandlerRef = vi.hoisted(() => ({
current: null as ((payload: LibrarySyncIdlePayload) => void) | null,
}));
const rebuildClusterMock = vi.hoisted(() =>
vi.fn<(indexKey: string) => Promise<boolean>>(async () => true),
);
vi.mock('@/lib/api/library/events', () => ({
subscribeLibrarySyncIdle: vi.fn(async (handler: (payload: LibrarySyncIdlePayload) => void) => {
syncIdleHandlerRef.current = handler;
@@ -18,10 +14,6 @@ vi.mock('@/lib/api/library/events', () => ({
}),
}));
vi.mock('@/lib/library/clusterRebuildOnSync', () => ({
rebuildClusterForIndexKey: (indexKey: string) => rebuildClusterMock(indexKey),
}));
import {
librarySyncRevision,
libraryScopeSyncRevision,
@@ -36,12 +28,10 @@ describe('offlineLocalLibrarySyncRevision', () => {
servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
});
resetOfflineLocalLibrarySyncRevisionForTests();
rebuildClusterMock.mockReset();
rebuildClusterMock.mockResolvedValue(true);
syncIdleHandlerRef.current = null;
});
it('bumps revision after derived keys are ready for index key and profile id', async () => {
it('bumps revision after Rust publishes derived-ready sync idle', () => {
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
syncIdleHandlerRef.current?.({
serverId: 'a.test',
@@ -50,8 +40,7 @@ describe('offlineLocalLibrarySyncRevision', () => {
ok: true,
error: null,
});
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
await vi.waitFor(() => expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1));
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1);
expect(offlineLocalLibrarySyncRevision('a.test')).toBe(1);
expect(librarySyncRevision()).toBe(1);
expect(libraryScopeSyncRevision(['srv-a'])).toBe(1);
@@ -70,7 +59,7 @@ describe('offlineLocalLibrarySyncRevision', () => {
expect(librarySyncRevision()).toBe(0);
});
it('changes the scoped revision when a different selected server completes', async () => {
it('changes the scoped revision when a different selected server completes', () => {
useAuthStore.setState({
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
@@ -81,47 +70,20 @@ describe('offlineLocalLibrarySyncRevision', () => {
syncIdleHandlerRef.current?.({
serverId: 'a.test', libraryScope: '', kind: 'delta_sync', ok: true,
});
await vi.waitFor(() => expect(libraryScopeSyncRevision(['srv-a', 'srv-b'])).toBe(1));
expect(libraryScopeSyncRevision(['srv-a', 'srv-b'])).toBe(1);
syncIdleHandlerRef.current?.({
serverId: 'b.test', libraryScope: '', kind: 'delta_sync', ok: true,
});
await vi.waitFor(() => expect(libraryScopeSyncRevision(['srv-a', 'srv-b'])).toBe(2));
expect(libraryScopeSyncRevision(['srv-a', 'srv-b'])).toBe(2);
});
it('bumps revision for successful background sync-idle events', async () => {
it('bumps revision for successful background sync-idle events', () => {
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
syncIdleHandlerRef.current?.({
serverId: 'a.test', libraryScope: '', kind: 'delta_sync', source: 'background', ok: true,
});
await vi.waitFor(() => expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1));
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1);
expect(librarySyncRevision()).toBe(1);
});
it('does not publish the revision while cluster maintenance is pending', async () => {
let release!: () => void;
rebuildClusterMock.mockImplementationOnce(() => new Promise(resolve => {
release = () => resolve(true);
}));
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
syncIdleHandlerRef.current?.({
serverId: 'a.test', libraryScope: '', kind: 'delta_sync', ok: true,
});
await Promise.resolve();
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
release();
await vi.waitFor(() => expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1));
});
it('does not publish the revision when cluster maintenance fails', async () => {
rebuildClusterMock.mockResolvedValueOnce(false);
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
syncIdleHandlerRef.current?.({
serverId: 'a.test', libraryScope: '', kind: 'delta_sync', ok: true,
});
await vi.waitFor(() => expect(rebuildClusterMock).toHaveBeenCalledOnce());
await Promise.resolve();
expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0);
expect(librarySyncRevision()).toBe(0);
});
});
+2 -5
View File
@@ -1,6 +1,5 @@
import { useSyncExternalStore } from 'react';
import { subscribeLibrarySyncIdle } from '@/lib/api/library/events';
import { rebuildClusterForIndexKey } from '@/lib/library/clusterRebuildOnSync';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
@@ -80,10 +79,8 @@ function ensureOfflineLocalLibrarySyncHook(): void {
if (typeof subscribeLibrarySyncIdle !== 'function') return;
void subscribeLibrarySyncIdle(payload => {
if (payload.ok) {
const indexKey = resolveIndexKey(payload.serverId);
void rebuildClusterForIndexKey(indexKey).then(ready => {
if (ready) bumpOfflineLocalLibrarySyncRevision(payload.serverId);
});
// Rust drains identity invalidations before publishing sync-idle.
bumpOfflineLocalLibrarySyncRevision(payload.serverId);
}
});
}
+11 -3
View File
@@ -9,12 +9,20 @@
// identical to today's behavior outside an Orbit session. A session can only start
// through the topbar, which loads the barrel (→ registers) before any session
// exists, so the registered runtime is always in place when it matters.
import type { OrbitRole, OrbitPhase, OrbitState } from '@/features/orbit'; // type-only (erased at runtime)
export type OrbitRole = 'host' | 'guest';
export type OrbitPhase = 'idle' | 'starting' | 'joining' | 'active' | 'ended' | 'error';
export interface OrbitRuntimeState {
isPlaying: boolean;
positionMs: number;
positionAt: number;
currentTrack: { trackId: string } | null;
}
export interface OrbitSnapshot {
role: OrbitRole | null;
phase: OrbitPhase;
state: OrbitState | null;
state: OrbitRuntimeState | null;
serverId: string | null;
}
@@ -75,6 +83,6 @@ export function isOrbitPlaybackSyncActive(): boolean {
return isSyncingPhase(role, phase);
}
export function estimateLivePosition(state: OrbitState, nowMs: number): number {
export function estimateLivePosition(state: OrbitRuntimeState, nowMs: number): number {
return state.isPlaying ? state.positionMs + (nowMs - state.positionAt) : state.positionMs;
}
@@ -1,10 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Scenario: server switch × active orbit + playing queue (closes the switchActiveServer
// QA). Switching servers must tear down a live orbit session (its playlists live on the
// old backend), flush the old server's play queue, rebind the active server, and mark a
// queue handoff. Deep modules are mocked (not the @/features/orbit barrel) to avoid
// partial-barrel collapse; the real orbit + auth stores drive the observable assertions.
// QA). Switching the active server must preserve a source-bound Orbit session, flush the
// old active server's play queue, rebind the active server, and mark a queue handoff.
const ensureConnectUrlResolved = vi.hoisted(() => vi.fn());
vi.mock('@/lib/server/serverEndpoint', async (io) => ({
@@ -31,18 +29,6 @@ vi.mock('@/lib/server/syncServerHttpContext', async (io) => ({
syncServerHttpContextForProfile: vi.fn(),
}));
const endOrbitSession = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/features/orbit/utils/host', async (io) => ({
...(await io<typeof import('@/features/orbit/utils/host')>()),
endOrbitSession,
}));
const leaveOrbitSession = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/features/orbit/utils/guest', async (io) => ({
...(await io<typeof import('@/features/orbit/utils/guest')>()),
leaveOrbitSession,
}));
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit';
@@ -72,32 +58,34 @@ describe('server switch × active orbit + playing queue', () => {
expect(useAuthStore.getState().activeServerId).toBe(oldServer.id);
});
it('as host: tears down orbit, flushes the old queue, rebinds, marks handoff', async () => {
useOrbitStore.setState({ role: 'host', phase: 'active' });
it('as host: preserves orbit, flushes the old queue, rebinds, marks handoff', async () => {
useOrbitStore.setState({ role: 'host', phase: 'active', serverId: oldServer.id });
const ok = await switchActiveServer(newServer);
expect(ok).toBe(true);
expect(endOrbitSession).toHaveBeenCalledTimes(1);
expect(leaveOrbitSession).not.toHaveBeenCalled();
expect(useOrbitStore.getState().role).toBeNull();
expect(useOrbitStore.getState()).toEqual(expect.objectContaining({
role: 'host',
phase: 'active',
serverId: oldServer.id,
}));
expect(flushPlayQueueForServer).toHaveBeenCalledWith(oldServer.id);
expect(useAuthStore.getState().activeServerId).toBe(newServer.id);
expect(markQueueHandoffPending).toHaveBeenCalledTimes(1);
});
it('as guest: leaves the session instead of ending it', async () => {
useOrbitStore.setState({ role: 'guest', phase: 'active' });
it('as guest: preserves the session on its bound server', async () => {
useOrbitStore.setState({ role: 'guest', phase: 'active', serverId: oldServer.id });
const ok = await switchActiveServer(newServer);
expect(ok).toBe(true);
expect(leaveOrbitSession).toHaveBeenCalledTimes(1);
expect(endOrbitSession).not.toHaveBeenCalled();
expect(useOrbitStore.getState().role).toBeNull();
expect(useOrbitStore.getState()).toEqual(expect.objectContaining({
role: 'guest',
phase: 'active',
serverId: oldServer.id,
}));
});
it('with no orbit session: still rebinds + flushes, no teardown call', async () => {
it('with no orbit session: still rebinds and flushes', async () => {
const ok = await switchActiveServer(newServer);
expect(ok).toBe(true);
expect(endOrbitSession).not.toHaveBeenCalled();
expect(leaveOrbitSession).not.toHaveBeenCalled();
expect(flushPlayQueueForServer).toHaveBeenCalledWith(oldServer.id);
expect(useAuthStore.getState().activeServerId).toBe(newServer.id);
});
@@ -5,7 +5,11 @@ import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineStore } from '@/features/offline';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { deviceSyncSourceKey, useDeviceSyncStore } from '@/features/deviceSync';
import { rewriteFrontendStoreKeysForRemap } from './rewriteFrontendStoreKeys';
import {
rewriteFrontendStoreKeys,
rewriteFrontendStoreKeysForRemap,
} from './rewriteFrontendStoreKeys';
import { makeServer } from '@/test/helpers/factories';
describe('rewriteFrontendStoreKeysForRemap', () => {
beforeEach(() => {
@@ -68,6 +72,53 @@ describe('rewriteFrontendStoreKeysForRemap', () => {
expect(state.albums).not.toHaveProperty('old:al-1');
});
it('matches the full server key when it contains a port', async () => {
useOfflineStore.setState({
albums: {
'old.test:4533:al-1': {
serverId: 'old.test:4533',
id: 'al-1',
name: 'X',
artist: 'Y',
trackIds: ['t1'],
},
},
});
await rewriteFrontendStoreKeysForRemap([
{ oldKey: 'old.test:4533', newKey: 'new.test:4533' },
]);
expect(useOfflineStore.getState().albums).toEqual({
'new.test:4533:al-1': expect.objectContaining({
serverId: 'new.test:4533',
id: 'al-1',
}),
});
});
it('merges offline album track IDs when the destination key already exists', async () => {
useOfflineStore.setState({
albums: {
'old:al-1': {
serverId: 'old', id: 'al-1', name: 'Old', artist: 'Artist', trackIds: ['t1', 't2'],
},
'new:al-1': {
serverId: 'new', id: 'al-1', name: 'New', artist: 'Artist', trackIds: ['t2', 't3'],
},
},
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
expect(useOfflineStore.getState().albums['new:al-1']).toEqual(expect.objectContaining({
serverId: 'new',
name: 'New',
trackIds: ['t2', 't3', 't1'],
}));
expect(useOfflineStore.getState().albums).not.toHaveProperty('old:al-1');
});
it('rewrites local playback entries under the new key', async () => {
useLocalPlaybackStore.setState({
entries: {
@@ -188,4 +239,77 @@ describe('rewriteFrontendStoreKeysForRemap', () => {
expect(entries['new:t1']?.localPath).toBe('/new');
expect(entries).not.toHaveProperty('old:t1');
});
it('keeps the more durable local playback tier on a key collision', async () => {
useLocalPlaybackStore.setState({
entries: {
'old:t1': {
serverIndexKey: 'old',
trackId: 't1',
localPath: '/old-library',
layoutFingerprint: 'old',
sizeBytes: 10,
tier: 'library',
cachedAt: 1,
lastPlayedAt: 5,
suffix: 'flac',
},
'new:t1': {
serverIndexKey: 'new',
trackId: 't1',
localPath: '/new-cache',
layoutFingerprint: 'new',
sizeBytes: 5,
tier: 'ephemeral',
cachedAt: 2,
lastPlayedAt: 10,
suffix: 'mp3',
},
},
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
expect(useLocalPlaybackStore.getState().entries['new:t1']).toEqual(expect.objectContaining({
serverIndexKey: 'new',
localPath: '/old-library',
tier: 'library',
lastPlayedAt: 10,
}));
expect(useLocalPlaybackStore.getState().entries).not.toHaveProperty('old:t1');
});
it('merges UUID-keyed upgrade collisions that converge on one URL key', async () => {
const serverA = makeServer({ id: 'profile-a', url: 'http://same.test:4533' });
const serverB = makeServer({ id: 'profile-b', url: 'https://same.test:4533' });
useOfflineStore.setState({
albums: {
'profile-a:al-1': {
serverId: 'profile-a', id: 'al-1', name: 'Album', artist: 'Artist', trackIds: ['t1'],
},
'profile-b:al-1': {
serverId: 'profile-b', id: 'al-1', name: 'Album', artist: 'Artist', trackIds: ['t2'],
},
},
});
useLocalPlaybackStore.setState({
entries: {
'profile-a:t1': {
serverIndexKey: 'profile-a', trackId: 't1', localPath: '/library',
layoutFingerprint: '', sizeBytes: 10, tier: 'library', cachedAt: 1, suffix: 'flac',
},
'profile-b:t1': {
serverIndexKey: 'profile-b', trackId: 't1', localPath: '/cache',
layoutFingerprint: '', sizeBytes: 5, tier: 'ephemeral', cachedAt: 2, suffix: 'mp3',
},
},
});
await rewriteFrontendStoreKeys([serverA, serverB]);
expect(useOfflineStore.getState().albums['same.test:4533:al-1']?.trackIds)
.toEqual(['t1', 't2']);
expect(useLocalPlaybackStore.getState().entries['same.test:4533:t1'])
.toEqual(expect.objectContaining({ localPath: '/library', tier: 'library' }));
});
});
+72 -26
View File
@@ -1,9 +1,13 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import {
useLocalPlaybackStore,
type LocalPlaybackEntry,
type LocalPlaybackTier,
} from '../../store/localPlaybackStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { useOfflineStore } from '@/features/offline';
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useDeviceSyncStore } from '@/features/deviceSync';
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
@@ -25,43 +29,85 @@ function buildMappings(servers: ServerProfile[]): Mapping[] {
.filter(mapping => mapping.legacyId.length > 0 && mapping.indexKey.length > 0);
}
function matchCompositeKey(key: string, mappings: Mapping[]): (Mapping & { suffix: string }) | null {
let matched: Mapping | null = null;
for (const mapping of mappings) {
if (
key.startsWith(`${mapping.legacyId}:`) &&
(!matched || mapping.legacyId.length > matched.legacyId.length)
) {
matched = mapping;
}
}
if (!matched) return null;
return { ...matched, suffix: key.slice(matched.legacyId.length + 1) };
}
function mergeOfflineAlbum(
existing: OfflineAlbumMeta,
incoming: OfflineAlbumMeta,
serverId: string,
): OfflineAlbumMeta {
return {
...incoming,
...existing,
serverId,
trackIds: [...new Set([...existing.trackIds, ...incoming.trackIds])],
};
}
const LOCAL_PLAYBACK_TIER_PRIORITY: Record<LocalPlaybackTier, number> = {
ephemeral: 0,
'favorite-auto': 1,
library: 2,
};
function mergeLocalPlaybackEntry(
existing: LocalPlaybackEntry,
incoming: LocalPlaybackEntry,
serverIndexKey: string,
): LocalPlaybackEntry {
const incomingWins =
LOCAL_PLAYBACK_TIER_PRIORITY[incoming.tier] > LOCAL_PLAYBACK_TIER_PRIORITY[existing.tier];
const winner = incomingWins ? incoming : existing;
const other = incomingWins ? existing : incoming;
return {
...winner,
serverIndexKey,
lastPlayedAt: Math.max(winner.lastPlayedAt ?? 0, other.lastPlayedAt ?? 0) || undefined,
pinSource: winner.pinSource ?? other.pinSource,
};
}
function rewriteOfflineStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useOfflineStore.setState((state) => {
const albums = { ...state.albums };
for (const [key, meta] of Object.entries(state.albums)) {
const i = key.indexOf(':');
if (i <= 0) continue;
const legacyId = key.slice(0, i);
const albumId = key.slice(i + 1);
const indexKey = map.get(legacyId);
if (!indexKey) continue;
const nextKey = `${indexKey}:${albumId}`;
if (!albums[nextKey]) {
albums[nextKey] = { ...meta, serverId: indexKey };
}
delete albums[key];
const match = matchCompositeKey(key, mappings);
if (!match) continue;
const nextKey = `${match.indexKey}:${match.suffix}`;
const existing = albums[nextKey];
albums[nextKey] = existing
? mergeOfflineAlbum(existing, meta, match.indexKey)
: { ...meta, serverId: match.indexKey };
if (key !== nextKey) delete albums[key];
}
return { albums };
});
}
function rewriteLocalPlaybackStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useLocalPlaybackStore.setState((state) => {
const entries = { ...state.entries };
for (const [key, entry] of Object.entries(state.entries)) {
const i = key.indexOf(':');
if (i <= 0) continue;
const legacyId = key.slice(0, i);
const trackId = key.slice(i + 1);
const indexKey = map.get(legacyId);
if (!indexKey) continue;
const nextKey = `${indexKey}:${trackId}`;
if (!entries[nextKey]) {
entries[nextKey] = { ...entry, serverIndexKey: indexKey };
}
delete entries[key];
const match = matchCompositeKey(key, mappings);
if (!match) continue;
const nextKey = `${match.indexKey}:${match.suffix}`;
const existing = entries[nextKey];
entries[nextKey] = existing
? mergeLocalPlaybackEntry(existing, entry, match.indexKey)
: { ...entry, serverIndexKey: match.indexKey };
if (key !== nextKey) delete entries[key];
}
return { entries };
});
-19
View File
@@ -5,10 +5,8 @@ import {
coverTrafficEndServerSwitch,
} from '../../cover/coverTraffic';
import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '@/features/orbit';
import { flushPlayQueueForServer } from '@/features/playback/store/queueSync';
import { markQueueHandoffPending } from '@/features/playback/store/queueSyncUiState';
import { endOrbitSession, leaveOrbitSession } from '@/features/orbit';
import { ensureConnectUrlResolved } from '@/lib/server/serverEndpoint';
import { syncServerHttpContextForProfile } from '@/lib/server/syncServerHttpContext';
import { publishServerConnectionStatus } from '@/lib/network/serverReachability';
@@ -23,23 +21,6 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
const probe = await ensureConnectUrlResolved(server);
if (!probe.ok) return false;
// Tear down any active Orbit session before we actually switch. The
// session's playlists live on the *old* server — once we flip the
// active server, every API call from the orbit hooks would hit the
// wrong backend, heartbeats would silently fail, and the next
// app-start cleanup would prune the still-live session as stale.
// Capped at 1.5 s so a slow network doesn't freeze the UI.
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise<void>(r => setTimeout(r, 1500)),
]);
// Ensure local store is idle even if the remote call timed out.
useOrbitStore.getState().reset();
}
const auth = useAuthStore.getState();
const oldActiveId = auth.activeServerId;
if (oldActiveId && oldActiveId !== server.id) {