feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)

* feat(analysis): align index settings and per-server strategies

Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.

* feat(analysis): add progress tracking and server analysis deletion functionality

Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.

* feat(server): implement server index key migration and enhance server ID resolution

Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.

* refactor(library): simplify server ID handling in sync progress and idle subscriptions

Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.

* refactor(analytics): rename advanced strategy to aggressive and update descriptions

Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.

* fix(audio): update server ID handling in audio progress functions

Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.

* refactor(analysis): update server ID handling and drop legacy keys

Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.

* refactor(migration): switch to strategy C dual-db flow

Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.

* fix(migration): harden runtime db switch and startup gate

Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.

* feat(migration): enhance migration reporting with skipped server rows tracking

Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.

* fix(migration): avoid startup blocking modal on no-op runs

Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.

* fix(migration): enforce startup precheck and purge unknown rows

Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.

* fix(migration): block UI during done-flag precheck

Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.

* fix(migration): hide precheck modal when no migration is needed

Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.

* fix(migration): cleanup legacy db files after path migration

Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.

* docs(changelog): add PR #864 release notes and contributor credit

Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.

* test(analysis): raise hot-path coverage for analysis cache

Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.

* fix(migration): make rebind pass resilient to foreign key ordering

Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.

* feat(backup): add dual-database backup flow and blocking UX

Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.

* docs(changelog): add PR #864 backup notes and contributor credit

Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.

* docs(changelog): sort 1.47.0 entries from old to new

Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.

* fix(playback): align offline/hot cache lookup with indexKey scope

Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
This commit is contained in:
cucadmuh
2026-05-24 21:11:04 +03:00
committed by GitHub
parent 003b280a77
commit 11974e1438
111 changed files with 7537 additions and 1673 deletions
+128 -7
View File
@@ -1,8 +1,11 @@
import { save, open as openDialog } from '@tauri-apps/plugin-dialog';
import { writeFile, readTextFile } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import { version as appVersion } from '../../../package.json';
const BACKUP_VERSION = 1;
export type ImportedBackupKind = 'config' | 'databases' | 'full';
export type BackupExportMode = 'full' | 'library' | 'config';
const BACKUP_KEYS = [
'psysonic-auth',
@@ -17,7 +20,7 @@ const BACKUP_KEYS = [
'psysonic_home',
];
export async function exportBackup(): Promise<string | null> {
function collectStores(): Record<string, unknown> {
const stores: Record<string, unknown> = {};
for (const key of BACKUP_KEYS) {
const val = localStorage.getItem(key);
@@ -29,24 +32,98 @@ export async function exportBackup(): Promise<string | null> {
}
}
}
return stores;
}
const manifest = {
function buildSettingsManifest() {
return {
version: BACKUP_VERSION,
app_version: appVersion,
created_at: new Date().toISOString(),
stores,
stores: collectStores(),
};
}
export async function pickBackupExportPath(mode: BackupExportMode): Promise<string | null> {
const today = new Date().toISOString().slice(0, 10);
const path = await save({
if (mode === 'full') {
return save({
filters: [{ name: 'Psysonic Full Backup', extensions: ['psyfull', 'zip'] }],
defaultPath: `psysonic-full-${today}.psyfull`,
});
}
if (mode === 'library') {
return save({
filters: [{ name: 'Psysonic Library Databases Archive', extensions: ['psylib', 'zip'] }],
defaultPath: `psysonic-library-databases-${today}.psylib`,
});
}
return save({
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
defaultPath: `psysonic-backup-${today}.psybkp`,
});
}
if (!path) return null;
const content = JSON.stringify(manifest, null, 2);
export async function exportBackupToPath(mode: BackupExportMode, path: string): Promise<void> {
if (mode === 'full') {
await invoke('backup_export_full', {
destinationPath: path,
stores: collectStores(),
appVersion,
});
return;
}
if (mode === 'library') {
await invoke('backup_export_library_db', { destinationPath: path });
return;
}
const content = JSON.stringify(buildSettingsManifest(), null, 2);
await writeFile(path, new TextEncoder().encode(content));
}
export async function pickBackupImportPath(): Promise<string | null> {
const path = await openDialog({
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp', 'psylib', 'psyfull', 'zip'] }],
multiple: false,
title: 'Import Backup',
});
return path && typeof path === 'string' ? path : null;
}
export async function importAnyBackupFromPath(path: string): Promise<ImportedBackupKind> {
try {
const raw = await readTextFile(path);
const manifest = JSON.parse(raw);
if (typeof manifest.version === 'number' && manifest.stores && typeof manifest.stores === 'object') {
for (const [key, value] of Object.entries(manifest.stores as Record<string, unknown>)) {
localStorage.setItem(key, JSON.stringify(value));
}
window.location.reload();
return 'config';
}
} catch {
// Not a plain JSON settings backup, continue detection.
}
try {
const stores = await invoke<Record<string, unknown>>('backup_import_full', { sourcePath: path });
for (const [key, value] of Object.entries(stores)) {
localStorage.setItem(key, JSON.stringify(value));
}
window.location.reload();
return 'full';
} catch {
// Not a full backup archive, continue detection.
}
await invoke('backup_import_library_db', { sourcePath: path });
return 'databases';
}
export async function exportBackup(): Promise<string | null> {
const path = await pickBackupExportPath('config');
if (!path) return null;
await exportBackupToPath('config', path);
return path;
}
@@ -72,3 +149,47 @@ export async function importBackup(): Promise<void> {
window.location.reload();
}
export async function exportLibraryDatabaseBackup(): Promise<string | null> {
const path = await pickBackupExportPath('library');
if (!path) return null;
await exportBackupToPath('library', path);
return path;
}
export async function importLibraryDatabaseBackup(): Promise<void> {
const path = await openDialog({
filters: [{ name: 'Psysonic Library Databases Archive', extensions: ['psylib', 'zip'] }],
multiple: false,
title: 'Import Library Databases Archive',
});
if (!path || typeof path !== 'string') return;
await invoke('backup_import_library_db', { sourcePath: path });
}
export async function exportFullBackup(): Promise<string | null> {
const path = await pickBackupExportPath('full');
if (!path) return null;
await exportBackupToPath('full', path);
return path;
}
export async function importFullBackup(): Promise<void> {
const path = await openDialog({
filters: [{ name: 'Psysonic Full Backup', extensions: ['psyfull', 'zip'] }],
multiple: false,
title: 'Import Full Backup',
});
if (!path || typeof path !== 'string') return;
const stores = await invoke<Record<string, unknown>>('backup_import_full', { sourcePath: path });
for (const [key, value] of Object.entries(stores)) {
localStorage.setItem(key, JSON.stringify(value));
}
window.location.reload();
}
export async function importAnyBackup(): Promise<ImportedBackupKind | null> {
const path = await pickBackupImportPath();
if (!path) return null;
return importAnyBackupFromPath(path);
}
@@ -28,7 +28,7 @@ const ready = () =>
describe('runLocalAdvancedSearch', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
useLibraryIndexStore.setState({ masterEnabled: true });
});
it('returns null (→ network fallback) when the index is not ready', async () => {
@@ -38,7 +38,7 @@ describe('runLocalAdvancedSearch', () => {
});
it('returns null when the index is disabled for the server', async () => {
useLibraryIndexStore.getState().setIndexEnabled('s1', false);
useLibraryIndexStore.setState({ masterEnabled: false });
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
expect(res).toBeNull();
});
@@ -155,7 +155,7 @@ describe('runLocalAdvancedSearch', () => {
describe('runLocalSongBrowse', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
useLibraryIndexStore.setState({ masterEnabled: true });
});
it('returns null for a missing server id (→ network browse)', async () => {
+17
View File
@@ -0,0 +1,17 @@
export const ANALYTICS_STRATEGIES = ['lazy', 'advanced'] as const;
export type AnalyticsStrategy = (typeof ANALYTICS_STRATEGIES)[number];
export const DEFAULT_ANALYTICS_STRATEGY: AnalyticsStrategy = 'lazy';
export const ADVANCED_PARALLELISM_MIN = 1;
export const ADVANCED_PARALLELISM_MAX = 20;
export const DEFAULT_ADVANCED_PARALLELISM = 1;
export function clampAdvancedParallelism(value: number): number {
const rounded = Math.round(value);
return Math.min(
ADVANCED_PARALLELISM_MAX,
Math.max(ADVANCED_PARALLELISM_MIN, rounded),
);
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import {
computeLibraryBackfillTargetDepth,
libraryBackfillNeedsTopUp,
libraryBackfillPipelineBacklog,
libraryBackfillTopUpLimit,
} from './libraryAnalysisBackfillPolicy';
describe('libraryAnalysisBackfillPolicy', () => {
it('targets workers × 3 with floor and cap', () => {
expect(computeLibraryBackfillTargetDepth(1)).toBe(8);
expect(computeLibraryBackfillTargetDepth(4)).toBe(12);
expect(computeLibraryBackfillTargetDepth(8)).toBe(24);
expect(computeLibraryBackfillTargetDepth(100)).toBe(240);
});
it('measures backlog as queued plus active downloads', () => {
expect(
libraryBackfillPipelineBacklog({ httpQueued: 5, httpDownloadActive: 3 }),
).toBe(8);
});
it('requests top-up while backlog stays below target', () => {
const stats = { httpQueued: 2, httpDownloadActive: 1 };
expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(true);
expect(libraryBackfillTopUpLimit(stats, 8)).toBe(20);
});
it('stops top-up when backlog meets target', () => {
const stats = { httpQueued: 20, httpDownloadActive: 4 };
expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(false);
expect(libraryBackfillTopUpLimit(stats, 8)).toBe(0);
});
});
@@ -0,0 +1,39 @@
import type { AnalysisPipelineQueueStatsDto } from '../../api/analysis';
import { LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE } from '../../api/analysis';
/** Target backlog ≈ workers × multiplier (min floor, max cap). */
export const LIBRARY_BACKLOG_DEPTH_MULTIPLIER = 3;
export const LIBRARY_BACKLOG_MIN = 8;
export const LIBRARY_BACKLOG_MAX = 240;
export function computeLibraryBackfillTargetDepth(workers: number): number {
const w = Math.max(1, Math.round(workers));
return Math.min(
LIBRARY_BACKLOG_MAX,
Math.max(LIBRARY_BACKLOG_MIN, w * LIBRARY_BACKLOG_DEPTH_MULTIPLIER),
);
}
/** HTTP jobs waiting or actively downloading (keeps workers fed after decode decoupling). */
export function libraryBackfillPipelineBacklog(
stats: Pick<AnalysisPipelineQueueStatsDto, 'httpQueued' | 'httpDownloadActive'>,
): number {
return stats.httpQueued + stats.httpDownloadActive;
}
export function libraryBackfillNeedsTopUp(
stats: Pick<AnalysisPipelineQueueStatsDto, 'httpQueued' | 'httpDownloadActive'>,
workers: number,
): boolean {
return libraryBackfillPipelineBacklog(stats) < computeLibraryBackfillTargetDepth(workers);
}
export function libraryBackfillTopUpLimit(
stats: Pick<AnalysisPipelineQueueStatsDto, 'httpQueued' | 'httpDownloadActive'>,
workers: number,
): number {
const target = computeLibraryBackfillTargetDepth(workers);
const deficit = target - libraryBackfillPipelineBacklog(stats);
if (deficit <= 0) return 0;
return Math.min(LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE, deficit);
}
+24 -7
View File
@@ -8,6 +8,7 @@ import type { ServerProfile } from '../../store/authStoreTypes';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { serverProfileBaseUrl } from '../server/serverBaseUrl';
import { serverIndexKeyForProfile } from '../server/serverIndexKey';
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
export type BindServerResult = 'bound' | 'offline' | 'error';
@@ -60,7 +61,8 @@ export async function bindIndexedServer(server: ServerProfile): Promise<BindServ
export async function bootstrapIndexedServer(server: ServerProfile): Promise<BindServerResult> {
const bound = await bindIndexedServer(server);
if (bound !== 'bound') return bound;
await queueInitialSyncIfNeeded(server.id);
const indexKey = serverIndexKeyForProfile(server);
await queueInitialSyncIfNeeded(indexKey);
return 'bound';
}
@@ -68,14 +70,29 @@ export async function bootstrapIndexedServer(server: ServerProfile): Promise<Bin
export async function bootstrapAllIndexedServers(): Promise<Record<string, BindServerResult>> {
const lib = useLibraryIndexStore.getState();
if (!lib.masterEnabled) return {};
const indexed = useAuthStore.getState().servers.filter(s => lib.isIndexEnabled(s.id));
const results: Record<string, BindServerResult> = {};
const auth = useAuthStore.getState();
const active = auth.activeServerId
? auth.servers.find(s => s.id === auth.activeServerId) ?? null
: null;
const indexed = auth.servers.filter(s => lib.isIndexEnabled(s.id));
const primaryByKey = new Map<string, ServerProfile>();
for (const server of indexed) {
results[server.id] = await bindIndexedServer(server);
const key = serverIndexKeyForProfile(server);
if (!primaryByKey.has(key)) primaryByKey.set(key, server);
}
for (const server of indexed) {
if (results[server.id] === 'bound') {
await queueInitialSyncIfNeeded(server.id);
if (active) {
const key = serverIndexKeyForProfile(active);
if (primaryByKey.has(key)) primaryByKey.set(key, active);
}
const results: Record<string, BindServerResult> = {};
for (const server of primaryByKey.values()) {
const key = serverIndexKeyForProfile(server);
results[key] = await bindIndexedServer(server);
}
for (const server of primaryByKey.values()) {
const key = serverIndexKeyForProfile(server);
if (results[key] === 'bound') {
await queueInitialSyncIfNeeded(key);
}
}
return results;
+4
View File
@@ -120,6 +120,9 @@ export function enqueueLibrarySync(args: {
kind: LibrarySyncQueueKind;
}): Promise<void> {
logQueue(`enqueue ${args.serverId}`, args.serverId, args.kind);
if (queue.some(item => item.serverId === args.serverId && item.kind === args.kind)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
queue.push({ ...args, resolve, reject });
void drainQueue();
@@ -130,6 +133,7 @@ export function enqueueLibrarySync(args: {
export async function queueInitialSyncIfNeeded(serverId: string): Promise<void> {
try {
const status = await libraryGetStatus(serverId);
if (status.syncPhase === 'initial_sync') return;
if (status.syncPhase === 'ready' || status.lastFullSyncAt) return;
await enqueueLibrarySync({ serverId, kind: 'full' });
} catch {
+2 -2
View File
@@ -5,7 +5,7 @@ import { patchLibraryTrackOnUse } from './patchOnUse';
describe('patchLibraryTrackOnUse', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
useLibraryIndexStore.setState({ masterEnabled: true });
onInvoke('library_patch_track', () => undefined);
});
@@ -30,7 +30,7 @@ describe('patchLibraryTrackOnUse', () => {
});
it('is a no-op when the index is disabled for the server', async () => {
useLibraryIndexStore.getState().setIndexEnabled('s1', false);
useLibraryIndexStore.setState({ masterEnabled: false });
patchLibraryTrackOnUse('s1', 't1', { userRating: 4 });
await Promise.resolve();
expect(invokeMock).not.toHaveBeenCalled();
+1 -1
View File
@@ -46,7 +46,7 @@ function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {
describe('hydrateQueueFromIndex', () => {
beforeEach(() => {
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
useLibraryIndexStore.setState({ masterEnabled: true });
seedStore();
});
+1 -1
View File
@@ -39,7 +39,7 @@ const ref = (trackId: string, extra: Partial<QueueItemRef> = {}): QueueItemRef =
describe('queueTrackResolver', () => {
beforeEach(() => {
_resetQueueResolverForTest();
useLibraryIndexStore.getState().setIndexEnabled('s1', true);
useLibraryIndexStore.setState({ masterEnabled: true });
usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} });
vi.restoreAllMocks();
});
+6 -3
View File
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore';
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
import { switchActiveServer } from '../server/switchActiveServer';
import type { Track } from '../../store/playerStoreTypes';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../server/serverLookup';
export function hasAnyOfflineAlbums(albums: Record<string, OfflineAlbumMeta>): boolean {
return Object.keys(albums).length > 0;
@@ -45,7 +46,7 @@ export function offlineAlbumCoverArt(
size: number,
): { src: string; cacheKey: string } {
if (!album.coverArt) return { src: '', cacheKey: '' };
const server = useAuthStore.getState().servers.find(s => s.id === album.serverId);
const server = findServerByIdOrIndexKey(album.serverId);
if (!server) return { src: '', cacheKey: '' };
return {
src: buildCoverArtUrlForServer(server.url, server.username, server.password, album.coverArt, size),
@@ -56,8 +57,10 @@ export function offlineAlbumCoverArt(
/** Switch active server when it differs from the album's source server (for offline play). */
export async function ensureServerForOfflineAlbum(album: OfflineAlbumMeta): Promise<boolean> {
const { activeServerId, servers } = useAuthStore.getState();
if (album.serverId === activeServerId) return true;
const server = servers.find(s => s.id === album.serverId);
const resolved = resolveServerIdForIndexKey(album.serverId);
if (resolved === activeServerId) return true;
const server = servers.find(s => s.id === resolved)
?? findServerByIdOrIndexKey(album.serverId);
if (!server) return false;
return switchActiveServer(server);
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
import {
getAnalysisTracksPerMinute,
recordAnalysisTrackPerf,
resetAnalysisPerfStateForTest,
} from './analysisPerfStore';
beforeEach(() => {
resetAnalysisPerfStateForTest();
});
afterEach(() => {
vi.useRealTimers();
});
describe('analysisPerfStore', () => {
it('records last track timings and rolling tpm', () => {
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
recordAnalysisTrackPerf({
trackId: 't1',
fetchMs: 1000,
seedMs: 2000,
bpmMs: 500,
totalMs: 3500,
});
vi.advanceTimersByTime(100);
expect(getAnalysisTracksPerMinute()).toBeGreaterThan(0);
});
it('prunes completions older than one minute from tpm window', () => {
vi.useFakeTimers();
vi.setSystemTime(2_000_000);
recordAnalysisTrackPerf({
trackId: 'old',
fetchMs: 1,
seedMs: 1,
bpmMs: 1,
totalMs: 3,
});
vi.advanceTimersByTime(61_000);
expect(getAnalysisTracksPerMinute()).toBe(0);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { useSyncExternalStore } from 'react';
export type AnalysisTrackPerfSample = {
trackId: string;
fetchMs: number;
seedMs: number;
bpmMs: number;
totalMs: number;
at: number;
};
type AnalysisPerfState = {
last: AnalysisTrackPerfSample | null;
completedAt: number[];
};
const WINDOW_MS = 60_000;
let state: AnalysisPerfState = { last: null, completedAt: [] };
const listeners = new Set<() => void>();
function emit(): void {
listeners.forEach(fn => fn());
}
function pruneCompletedAt(now: number): number[] {
const cutoff = now - WINDOW_MS;
return state.completedAt.filter(t => t >= cutoff);
}
export function recordAnalysisTrackPerf(payload: {
trackId: string;
fetchMs: number;
seedMs: number;
bpmMs: number;
totalMs: number;
}): void {
const now = Date.now();
const completedAt = [...pruneCompletedAt(now), now];
state = {
completedAt,
last: {
trackId: payload.trackId,
fetchMs: payload.fetchMs,
seedMs: payload.seedMs,
bpmMs: payload.bpmMs,
totalMs: payload.totalMs,
at: now,
},
};
emit();
}
export function getAnalysisTracksPerMinute(now = Date.now()): number {
const completedAt = pruneCompletedAt(now);
if (completedAt.length === 0) return 0;
const spanMs = Math.max(1, Math.min(WINDOW_MS, now - completedAt[0]));
return (completedAt.length / spanMs) * WINDOW_MS;
}
export function getAnalysisPerfState(): AnalysisPerfState {
return state;
}
export function subscribeAnalysisPerf(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
/** Last completed track sample — stable reference until the next completion. */
export function useAnalysisPerfLast(): AnalysisTrackPerfSample | null {
return useSyncExternalStore(
subscribeAnalysisPerf,
() => state.last,
() => null,
);
}
/** Test-only reset. */
export function resetAnalysisPerfStateForTest(): void {
state = { last: null, completedAt: [] };
emit();
}
export function formatPerfMs(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '0';
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms)}ms`;
}
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import {
formatAnalysisPipelineQueueOverlay,
formatAnalysisTierQueue,
} from './formatAnalysisQueueStats';
describe('formatAnalysisQueueStats', () => {
it('formats tier queue as total(high,middle,low)', () => {
expect(formatAnalysisTierQueue(20, 1, 5, 14)).toBe('20(1,5,14)');
});
it('formats pipeline overlay lines with queued + active tiers', () => {
expect(
formatAnalysisPipelineQueueOverlay({
pipelineWorkers: 8,
httpQueued: 20,
httpQueuedHigh: 1,
httpQueuedMiddle: 5,
httpQueuedLow: 14,
httpDownloadActive: 2,
httpDownloadActiveHigh: 0,
httpDownloadActiveMiddle: 1,
httpDownloadActiveLow: 1,
cpuQueued: 12,
cpuQueuedHigh: 0,
cpuQueuedMiddle: 2,
cpuQueuedLow: 10,
cpuDecodeActive: 3,
cpuDecodeActiveHigh: 0,
cpuDecodeActiveMiddle: 0,
cpuDecodeActiveLow: 3,
}),
).toEqual([
'http 22(1,6,15) · dl 2/8',
'cpu 15(0,2,13) · run 3/8',
]);
});
it('shows active-only backlog when nothing is waiting in deque', () => {
expect(
formatAnalysisPipelineQueueOverlay({
pipelineWorkers: 20,
httpQueued: 0,
httpQueuedHigh: 0,
httpQueuedMiddle: 0,
httpQueuedLow: 0,
httpDownloadActive: 15,
httpDownloadActiveHigh: 0,
httpDownloadActiveMiddle: 0,
httpDownloadActiveLow: 15,
cpuQueued: 0,
cpuQueuedHigh: 0,
cpuQueuedMiddle: 0,
cpuQueuedLow: 0,
cpuDecodeActive: 1,
cpuDecodeActiveHigh: 0,
cpuDecodeActiveMiddle: 0,
cpuDecodeActiveLow: 1,
}),
).toEqual([
'http 15(0,0,15) · dl 15/20',
'cpu 1(0,0,1) · run 1/20',
]);
});
});
@@ -0,0 +1,49 @@
import type { AnalysisPipelineQueueStatsDto } from '../../api/analysis';
export function formatAnalysisTierQueue(
total: number,
high: number,
middle: number,
low: number,
): string {
return `${total}(${high},${middle},${low})`;
}
function combineTierCounts(
queuedHigh: number,
queuedMiddle: number,
queuedLow: number,
activeHigh: number,
activeMiddle: number,
activeLow: number,
): { total: number; high: number; middle: number; low: number } {
const high = queuedHigh + activeHigh;
const middle = queuedMiddle + activeMiddle;
const low = queuedLow + activeLow;
return { total: high + middle + low, high, middle, low };
}
export function formatAnalysisPipelineQueueOverlay(
stats: AnalysisPipelineQueueStatsDto,
): string[] {
const http = combineTierCounts(
stats.httpQueuedHigh,
stats.httpQueuedMiddle,
stats.httpQueuedLow,
stats.httpDownloadActiveHigh,
stats.httpDownloadActiveMiddle,
stats.httpDownloadActiveLow,
);
const cpu = combineTierCounts(
stats.cpuQueuedHigh,
stats.cpuQueuedMiddle,
stats.cpuQueuedLow,
stats.cpuDecodeActiveHigh,
stats.cpuDecodeActiveMiddle,
stats.cpuDecodeActiveLow,
);
const w = stats.pipelineWorkers;
const httpLine = `http ${formatAnalysisTierQueue(http.total, http.high, http.middle, http.low)} · dl ${stats.httpDownloadActive}/${w}`;
const cpuLine = `cpu ${formatAnalysisTierQueue(cpu.total, cpu.high, cpu.middle, cpu.low)} · run ${stats.cpuDecodeActive}/${w}`;
return [httpLine, cpuLine];
}
+3
View File
@@ -3,6 +3,8 @@ import { useSyncExternalStore } from 'react';
export type PerfProbeFlags = {
/** On-screen rAF-based FPS counter (Performance Probe). */
showFpsOverlay: boolean;
/** On-screen analysis throughput + last-track timing (Performance Probe). */
showAnalysisPerfOverlay: boolean;
disableWaveformCanvas: boolean;
disablePlayerProgressUi: boolean;
disableMarqueeScroll: boolean;
@@ -32,6 +34,7 @@ const STORAGE_KEY = 'psysonic_perf_probe_flags_v1';
const DEFAULT_FLAGS: PerfProbeFlags = {
showFpsOverlay: false,
showAnalysisPerfOverlay: false,
disableWaveformCanvas: false,
disablePlayerProgressUi: false,
disableMarqueeScroll: false,
+29 -3
View File
@@ -9,14 +9,39 @@ import { usePlayerStore } from '../../store/playerStore';
import { switchActiveServer } from '../server/switchActiveServer';
import { sameQueueTrackId } from './queueIdentity';
import type { Track } from '../../store/playerStoreTypes';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { resolveIndexKey, serverIndexKeyFromUrl } from '../server/serverIndexKey';
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
export function getPlaybackServerId(): string {
const { queueServerId, queue } = usePlayerStore.getState();
if ((queue?.length ?? 0) > 0 && queueServerId) return queueServerId;
if ((queue?.length ?? 0) > 0 && queueServerId) {
return resolveServerIdForIndexKey(queueServerId);
}
return useAuthStore.getState().activeServerId ?? '';
}
export function getPlaybackIndexKey(): string {
const { queueServerId, queue } = usePlayerStore.getState();
if ((queue?.length ?? 0) > 0 && queueServerId) {
return resolveIndexKey(queueServerId);
}
const activeId = useAuthStore.getState().activeServerId ?? '';
if (!activeId) return '';
const server = useAuthStore.getState().servers.find(s => s.id === activeId);
return server ? serverIndexKeyFromUrl(server.url) || activeId : activeId;
}
/**
* Canonical cache/storage key for playback-owned artifacts (offline/hot-cache).
* Falls back to legacy UUID when an indexKey cannot be resolved yet.
*/
export function getPlaybackCacheServerKey(): string {
const indexKey = getPlaybackIndexKey();
if (indexKey) return indexKey;
return getPlaybackServerId();
}
export function bindQueueServerForPlayback(): void {
const sid = useAuthStore.getState().activeServerId;
if (!sid) return;
@@ -31,7 +56,8 @@ export function playbackServerDiffersFromActive(): boolean {
const { queueServerId, queue } = usePlayerStore.getState();
if ((queue?.length ?? 0) === 0 || !queueServerId) return false;
const activeSid = useAuthStore.getState().activeServerId;
return !!activeSid && queueServerId !== activeSid;
const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
return !!activeSid && resolvedQueue !== activeSid;
}
/**
@@ -44,7 +70,7 @@ export function shouldHandoffQueueToActiveServer(): boolean {
const { queue, queueServerId } = usePlayerStore.getState();
if ((queue?.length ?? 0) === 0) return false;
if (!queueServerId) return true;
return queueServerId !== activeSid;
return resolveServerIdForIndexKey(queueServerId) !== activeSid;
}
/**
+18 -6
View File
@@ -1,7 +1,7 @@
import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl';
import { useOfflineStore } from '../../store/offlineStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { getPlaybackServerId } from './playbackServer';
import { getPlaybackCacheServerKey, getPlaybackServerId } from './playbackServer';
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
@@ -41,8 +41,15 @@ export function getPlaybackSourceKind(
serverId: string,
enginePreloadedTrackId: string | null = null,
): PlaybackSourceKind {
if (useOfflineStore.getState().getLocalUrl(trackId, serverId)) return 'offline';
if (useHotCacheStore.getState().getLocalUrl(trackId, serverId)) return 'hot';
const legacySid = getPlaybackServerId();
const offline =
useOfflineStore.getState().getLocalUrl(trackId, serverId)
|| (legacySid && legacySid !== serverId ? useOfflineStore.getState().getLocalUrl(trackId, legacySid) : null);
if (offline) return 'offline';
const hot =
useHotCacheStore.getState().getLocalUrl(trackId, serverId)
|| (legacySid && legacySid !== serverId ? useHotCacheStore.getState().getLocalUrl(trackId, legacySid) : null);
if (hot) return 'hot';
const resolved = resolvePlaybackUrl(trackId, serverId);
if (
!resolved.startsWith('psysonic-local://')
@@ -56,10 +63,15 @@ export function getPlaybackSourceKind(
/** Offline library → hot playback cache → HTTP stream. */
export function resolvePlaybackUrl(trackId: string, serverId?: string): string {
const sid = serverId && serverId.length > 0 ? serverId : getPlaybackServerId();
const offline = useOfflineStore.getState().getLocalUrl(trackId, sid);
const sid = serverId && serverId.length > 0 ? serverId : getPlaybackCacheServerKey();
const legacySid = getPlaybackServerId();
const offline =
useOfflineStore.getState().getLocalUrl(trackId, sid)
|| (legacySid && legacySid !== sid ? useOfflineStore.getState().getLocalUrl(trackId, legacySid) : null);
if (offline) return offline;
const hot = useHotCacheStore.getState().getLocalUrl(trackId, sid);
const hot =
useHotCacheStore.getState().getLocalUrl(trackId, sid)
|| (legacySid && legacySid !== sid ? useHotCacheStore.getState().getLocalUrl(trackId, legacySid) : null);
if (hot) return hot;
return buildStreamUrlForServer(sid, trackId);
}
@@ -0,0 +1,111 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { useOfflineStore } from '../../store/offlineStore';
import { serverIndexKeyFromUrl } from './serverIndexKey';
type Mapping = { legacyId: string; indexKey: string };
function buildMappings(servers: ServerProfile[]): Mapping[] {
return servers
.map(server => ({
legacyId: server.id.trim(),
indexKey: serverIndexKeyFromUrl(server.url).trim(),
}))
.filter(mapping => mapping.legacyId.length > 0 && mapping.indexKey.length > 0);
}
function rewriteOfflineStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useOfflineStore.setState((state) => {
const tracks = { ...state.tracks };
for (const [key, meta] of Object.entries(state.tracks)) {
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 (!tracks[nextKey]) {
tracks[nextKey] = { ...meta, serverId: indexKey };
}
delete tracks[key];
}
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];
}
return { tracks, albums };
});
}
function rewriteHotCacheStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useHotCacheStore.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;
}
delete entries[key];
}
return { entries };
});
}
function rewriteAnalysisStrategyStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useAnalysisStrategyStore.setState((state) => {
const strategyByServer = { ...state.strategyByServer };
for (const [key, value] of Object.entries(state.strategyByServer)) {
const indexKey = map.get(key);
if (!indexKey || value === undefined) continue;
if (strategyByServer[indexKey] === undefined) {
strategyByServer[indexKey] = value;
}
delete strategyByServer[key];
}
const advancedParallelismByServer = { ...state.advancedParallelismByServer };
for (const [key, value] of Object.entries(state.advancedParallelismByServer)) {
const indexKey = map.get(key);
if (!indexKey || value === undefined) continue;
if (advancedParallelismByServer[indexKey] === undefined) {
advancedParallelismByServer[indexKey] = value;
}
delete advancedParallelismByServer[key];
}
return { strategyByServer, advancedParallelismByServer };
});
}
export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promise<void> {
const mappings = buildMappings(servers);
if (mappings.length === 0) return;
rewriteOfflineStoreKeys(mappings);
rewriteHotCacheStoreKeys(mappings);
rewriteAnalysisStrategyStoreKeys(mappings);
// Keep migration explicit: Zustand persist writes the current state snapshot.
useAnalysisStrategyStore.getState().migrateServerOverrides(servers);
useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled }));
}
+5 -4
View File
@@ -1,7 +1,7 @@
import type { ServerProfile } from '../../store/authStoreTypes';
/** Host (+ port) from a server base URL, e.g. `https://music.one.com/foo` → `music.one.com`. */
export function shortHostFromServerUrl(urlRaw: string): string {
const t = urlRaw.trim();
export function shortHostFromServerUrl(urlRaw?: string | null): string {
const t = typeof urlRaw === 'string' ? urlRaw.trim() : '';
if (!t) return '';
try {
const u = new URL(t.includes('://') ? t : `https://${t}`);
@@ -21,7 +21,8 @@ export function shortHostFromServerUrl(urlRaw: string): string {
*/
export function serverListDisplayLabel(server: ServerProfile, all: ServerProfile[]): string {
const nameTrim = (server.name || '').trim();
const shortHost = shortHostFromServerUrl(server.url);
const safeUrl = typeof server.url === 'string' ? server.url : '';
const shortHost = shortHostFromServerUrl(safeUrl);
const key = nameTrim || shortHost;
const collisions = all.filter(s => {
const nt = (s.name || '').trim();
@@ -29,7 +30,7 @@ export function serverListDisplayLabel(server: ServerProfile, all: ServerProfile
return (nt || sh) === key;
});
if (collisions.length < 2) {
return nameTrim || shortHost || server.url.trim();
return nameTrim || shortHost || safeUrl.trim();
}
return `${server.username}@${shortHost}`;
}
+19
View File
@@ -0,0 +1,19 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import { useAuthStore } from '../../store/authStore';
import { serverProfileBaseUrl } from './serverBaseUrl';
/** Stable index key derived from a server URL (host + optional path, no scheme). */
export function serverIndexKeyFromUrl(urlRaw: string): string {
const base = serverProfileBaseUrl({ url: urlRaw });
return base.replace(/^https?:\/\//, '');
}
export function serverIndexKeyForProfile(server: Pick<ServerProfile, 'url'>): string {
return serverIndexKeyFromUrl(server.url);
}
export function resolveIndexKey(serverIdOrKey: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverIdOrKey);
if (!server) return serverIdOrKey;
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
+10
View File
@@ -0,0 +1,10 @@
import { useAuthStore } from '../../store/authStore';
import { rewriteFrontendStoreKeys } from './rewriteFrontendStoreKeys';
/**
* Legacy compatibility shim. The migration lifecycle now runs through
* `useMigrationOrchestrator` + blocking gate.
*/
export async function migrateServerIndexKeysIfNeeded(): Promise<void> {
await rewriteFrontendStoreKeys(useAuthStore.getState().servers);
}
+22
View File
@@ -0,0 +1,22 @@
import { useAuthStore } from '../../store/authStore';
import type { ServerProfile } from '../../store/authStoreTypes';
import { serverIndexKeyForProfile, serverIndexKeyFromUrl } from './serverIndexKey';
export function findServerByIdOrIndexKey(serverIdOrKey: string): ServerProfile | undefined {
const servers = useAuthStore.getState().servers;
const direct = servers.find(s => s.id === serverIdOrKey);
if (direct) return direct;
return servers.find(s => serverIndexKeyForProfile(s) === serverIdOrKey);
}
export function resolveServerIdForIndexKey(serverIdOrKey: string): string {
const { servers, activeServerId } = useAuthStore.getState();
const direct = servers.find(s => s.id === serverIdOrKey);
if (direct) return direct.id;
const active = servers.find(
s => s.id === activeServerId && serverIndexKeyFromUrl(s.url) === serverIdOrKey,
);
if (active) return active.id;
const fallback = servers.find(s => serverIndexKeyFromUrl(s.url) === serverIdOrKey);
return fallback?.id ?? serverIdOrKey;
}
+5 -1
View File
@@ -14,6 +14,7 @@ import { songToTrack } from '../playback/songToTrack';
import type { Track } from '../../store/playerStoreTypes';
import { orbitBulkGuard } from '../orbitBulkGuard';
import { findServerIdForShareUrl } from './shareLink';
import { serverIndexKeyFromUrl } from '../server/serverIndexKey';
import type {
AlbumShareSearchPayload,
ArtistShareSearchPayload,
@@ -61,7 +62,10 @@ function lookupShareServer(shareSrv: string): ShareServerLookupResult {
}
const serverId = findServerIdForShareUrl(servers, shareSrv);
const server = serverId ? servers.find(s => s.id === serverId) : undefined;
const server = serverId
? servers.find(s => s.id === serverId)
?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId)
: undefined;
if (!serverId || !server) {
return { type: 'no-matching-server', url: shareSrv };
}
+8 -2
View File
@@ -1,5 +1,6 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import { serverListDisplayLabel } from '../server/serverDisplayName';
import { serverIndexKeyFromUrl } from '../server/serverIndexKey';
import { findServerIdForShareUrl } from './shareLink';
import type { ShareSearchMatch } from './shareSearch';
@@ -18,7 +19,8 @@ export function shareServerOriginLabel(
const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
if (!shareServerId || shareServerId === activeServerId) return null;
const server = servers.find(s => s.id === shareServerId);
const server = servers.find(s => s.id === shareServerId)
?? servers.find(s => serverIndexKeyFromUrl(s.url) === shareServerId);
if (!server) return null;
return serverListDisplayLabel(server, servers);
@@ -37,6 +39,10 @@ export function shareQueueServerContext(
const label = shareServerOriginLabel(match, servers, activeServerId);
const serverId = findServerIdForShareUrl(servers, shareSrv);
const coverServer =
serverId && serverId !== activeServerId ? servers.find(s => s.id === serverId) ?? null : null;
serverId && serverId !== activeServerId
? servers.find(s => s.id === serverId)
?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId)
?? null
: null;
return { label, coverServer };
}