fix: close multi-server ownership gaps

This commit is contained in:
cucadmuh
2026-07-20 18:57:22 +03:00
parent f8799228e2
commit 92954fbbc3
28 changed files with 724 additions and 13993 deletions
File diff suppressed because it is too large Load Diff
+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.
@@ -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();
@@ -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);
}
@@ -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]
@@ -1526,6 +1526,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 +1608,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 +1819,25 @@ mod tests {
);
}
#[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();
@@ -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);
+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) {