mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix: close multi-server ownership gaps
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user