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
+121
View File
@@ -0,0 +1,121 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
export interface AnalysisBackfillQueueStatsDto {
queued: number;
inProgressCount: number;
inProgressTrackId: string | null;
}
export interface AnalysisPipelineQueueStatsDto {
pipelineWorkers: number;
httpQueued: number;
httpQueuedHigh: number;
httpQueuedMiddle: number;
httpQueuedLow: number;
httpDownloadActive: number;
httpDownloadActiveHigh: number;
httpDownloadActiveMiddle: number;
httpDownloadActiveLow: number;
cpuQueued: number;
cpuQueuedHigh: number;
cpuQueuedMiddle: number;
cpuQueuedLow: number;
cpuDecodeActive: number;
cpuDecodeActiveHigh: number;
cpuDecodeActiveMiddle: number;
cpuDecodeActiveLow: number;
}
export interface LibraryAnalysisBackfillBatchDto {
trackIds: string[];
nextCursor: string | null;
exhausted: boolean;
}
export interface LibraryAnalysisProgressDto {
totalTracks: number;
pendingTracks: number;
doneTracks: number;
}
export interface AnalysisDeleteServerReportDto {
analysisTracks: number;
waveforms: number;
loudness: number;
}
export const LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE = 20;
function serverIndexKeyForId(serverId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (!server) return serverId;
return serverIndexKeyFromUrl(server.url) || serverId;
}
export function analysisGetBackfillQueueStats(): Promise<AnalysisBackfillQueueStatsDto> {
return invoke<AnalysisBackfillQueueStatsDto>('analysis_get_backfill_queue_stats');
}
export function analysisGetPipelineQueueStats(): Promise<AnalysisPipelineQueueStatsDto> {
return invoke<AnalysisPipelineQueueStatsDto>('analysis_get_pipeline_queue_stats');
}
export function libraryAnalysisBackfillBatch(
serverId: string,
cursor?: string | null,
limit = LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE,
): Promise<LibraryAnalysisBackfillBatchDto> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryAnalysisBackfillBatchDto>('library_analysis_backfill_batch', {
serverId: indexKey,
cursor: cursor ?? null,
limit,
});
}
export function libraryAnalysisProgress(
serverId: string,
): Promise<LibraryAnalysisProgressDto> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryAnalysisProgressDto>('library_analysis_progress', { serverId: indexKey });
}
export function analysisDeleteAllForServer(
serverId: string,
): Promise<AnalysisDeleteServerReportDto> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<AnalysisDeleteServerReportDto>('analysis_delete_all_for_server', { serverId: indexKey });
}
export type AnalysisBackfillPriority = 'high' | 'middle' | 'low';
export function analysisSetPipelineParallelism(workers: number): Promise<void> {
return invoke('analysis_set_pipeline_parallelism', { workers });
}
export type AnalysisPriorityHintDto = {
serverId: string;
trackId: string;
};
export function analysisSetPlaybackPriorityHints(
middleTrackRefs: AnalysisPriorityHintDto[],
): Promise<void> {
const remapped = middleTrackRefs.map(ref => ({
...ref,
serverId: serverIndexKeyForId(ref.serverId),
}));
return invoke('analysis_set_playback_priority_hints', { middleTrackRefs: remapped });
}
export function analysisEnqueueSeedFromUrl(
trackId: string,
url: string,
serverId: string,
priority: AnalysisBackfillPriority = 'low',
): Promise<void> {
const indexKey = serverIndexKeyForId(serverId);
return invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId: indexKey, priority });
}
+130 -24
View File
@@ -8,6 +8,9 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useAuthStore } from '../store/authStore';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─
@@ -258,13 +261,37 @@ export interface LibraryCrossServerSearchResponse {
serversSearched: string[];
}
function serverIndexKeyForId(serverId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (!server) return serverId;
return serverIndexKeyFromUrl(server.url) || serverId;
}
function mapServerIdFromIndexKey(serverId: string, fallback?: string): string {
if (fallback) return fallback;
return resolveServerIdForIndexKey(serverId);
}
function mapTracksServerId(
tracks: LibraryTrackDto[],
fallbackServerId?: string,
): LibraryTrackDto[] {
if (tracks.length === 0) return tracks;
return tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId, fallbackServerId),
}));
}
// ── Read commands (PR-5a) ─────────────────────────────────────────────
export function libraryGetStatus(
serverId: string,
libraryScope?: string,
): Promise<SyncStateDto> {
return invoke<SyncStateDto>('library_get_status', { serverId, libraryScope });
const indexKey = serverIndexKeyForId(serverId);
return invoke<SyncStateDto>('library_get_status', { serverId: indexKey, libraryScope })
.then(status => ({ ...status, serverId }));
}
export function librarySearch(
@@ -272,13 +299,17 @@ export function librarySearch(
query: string,
options?: { limit?: number; offset?: number; libraryScope?: string },
): Promise<LibraryTracksEnvelope> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryTracksEnvelope>('library_search', {
serverId,
serverId: indexKey,
query,
limit: options?.limit,
offset: options?.offset,
libraryScope: options?.libraryScope,
});
}).then(envelope => ({
...envelope,
tracks: mapTracksServerId(envelope.tracks, serverId),
}));
}
/**
@@ -289,7 +320,21 @@ export function librarySearch(
export function libraryAdvancedSearch(
request: LibraryAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_advanced_search', { request });
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAdvancedSearchResponse>('library_advanced_search', {
request: { ...request, serverId: indexKey },
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
tracks: mapTracksServerId(response.tracks, request.serverId),
}));
}
export interface LibraryLiveSearchResponse {
@@ -313,7 +358,21 @@ export interface LibraryLiveSearchRequest {
}
export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<LibraryLiveSearchResponse> {
return invoke<LibraryLiveSearchResponse>('library_live_search', { request });
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryLiveSearchResponse>('library_live_search', {
request: { ...request, serverId: indexKey },
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
tracks: mapTracksServerId(response.tracks, request.serverId),
}));
}
/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */
@@ -322,25 +381,48 @@ export function librarySearchCrossServer(args: {
limit?: number;
servers?: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cross_server', args);
const indexServers = args.servers?.map(serverIndexKeyForId);
return invoke<LibraryCrossServerSearchResponse>('library_search_cross_server', {
...args,
servers: indexServers,
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
): Promise<LibraryTrackDto | null> {
return invoke<LibraryTrackDto | null>('library_get_track', { serverId, trackId });
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryTrackDto | null>('library_get_track', { serverId: indexKey, trackId })
.then(track => (track ? { ...track, serverId } : track));
}
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
return invoke<LibraryTrackDto[]>('library_get_tracks_batch', { refs });
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
const indexKey = serverIndexKeyForId(ref.serverId);
if (!indexKeyMap.has(indexKey)) indexKeyMap.set(indexKey, ref.serverId);
return { ...ref, serverId: indexKey };
});
return invoke<LibraryTrackDto[]>('library_get_tracks_batch', { refs: remapped })
.then(tracks => tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId, indexKeyMap.get(track.serverId)),
})));
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
): Promise<LibraryTrackDto[]> {
return invoke<LibraryTrackDto[]>('library_get_tracks_by_album', { serverId, albumId });
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryTrackDto[]>('library_get_tracks_by_album', { serverId: indexKey, albumId })
.then(tracks => mapTracksServerId(tracks, serverId));
}
export function libraryGetArtifact(
@@ -349,14 +431,15 @@ export function libraryGetArtifact(
artifactKind: string,
options?: { sourceKind?: string; sourceId?: string; format?: string },
): Promise<TrackArtifactDto | null> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<TrackArtifactDto | null>('library_get_artifact', {
serverId,
serverId: indexKey,
trackId,
artifactKind,
sourceKind: options?.sourceKind,
sourceId: options?.sourceId,
format: options?.format,
});
}).then(artifact => (artifact ? { ...artifact, serverId } : artifact));
}
export function libraryGetFacts(
@@ -364,14 +447,18 @@ export function libraryGetFacts(
trackId: string,
factKinds?: string[],
): Promise<TrackFactDto[]> {
return invoke<TrackFactDto[]>('library_get_facts', { serverId, trackId, factKinds });
const indexKey = serverIndexKeyForId(serverId);
return invoke<TrackFactDto[]>('library_get_facts', { serverId: indexKey, trackId, factKinds })
.then(facts => facts.map(fact => ({ ...fact, serverId })));
}
export function libraryGetOfflinePath(
serverId: string,
trackId: string,
): Promise<OfflinePathDto> {
return invoke<OfflinePathDto>('library_get_offline_path', { serverId, trackId });
const indexKey = serverIndexKeyForId(serverId);
return invoke<OfflinePathDto>('library_get_offline_path', { serverId: indexKey, trackId })
.then(path => ({ ...path, serverId }));
}
// ── Session + lifecycle (PR-5b) ───────────────────────────────────────
@@ -383,11 +470,13 @@ export function librarySyncBindSession(args: {
password: string;
libraryScope?: string;
}): Promise<void> {
return invoke<void>('library_sync_bind_session', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_sync_bind_session', { ...args, serverId: indexKey });
}
export function librarySyncClearSession(serverId: string): Promise<void> {
return invoke<void>('library_sync_clear_session', { serverId });
const indexKey = serverIndexKeyForId(serverId);
return invoke<void>('library_sync_clear_session', { serverId: indexKey });
}
export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active';
@@ -407,7 +496,9 @@ export function librarySyncStart(args: {
mode: SyncMode;
libraryScope?: string;
}): Promise<SyncJobDto> {
return invoke<SyncJobDto>('library_sync_start', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<SyncJobDto>('library_sync_start', { ...args, serverId: indexKey })
.then(job => ({ ...job, serverId: args.serverId }));
}
/** Forced full-budget tombstone delta — Settings → «Verify integrity». */
@@ -415,7 +506,9 @@ export function librarySyncVerifyIntegrity(args: {
serverId: string;
libraryScope?: string;
}): Promise<SyncJobDto> {
return invoke<SyncJobDto>('library_sync_verify_integrity', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<SyncJobDto>('library_sync_verify_integrity', { ...args, serverId: indexKey })
.then(job => ({ ...job, serverId: args.serverId }));
}
export function librarySyncCancel(jobId?: string): Promise<void> {
@@ -435,7 +528,8 @@ export function libraryPatchTrack(args: {
contentHash?: string | null;
};
}): Promise<void> {
return invoke<void>('library_patch_track', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_patch_track', { ...args, serverId: indexKey });
}
export function libraryPutArtifact(args: {
@@ -443,7 +537,8 @@ export function libraryPutArtifact(args: {
trackId: string;
artifact: ArtifactInputDto;
}): Promise<void> {
return invoke<void>('library_put_artifact', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_put_artifact', { ...args, serverId: indexKey });
}
export function libraryPutFact(args: {
@@ -451,7 +546,8 @@ export function libraryPutFact(args: {
trackId: string;
fact: FactInputDto;
}): Promise<void> {
return invoke<void>('library_put_fact', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<void>('library_put_fact', { ...args, serverId: indexKey });
}
export function libraryPurgeServer(args: {
@@ -459,11 +555,13 @@ export function libraryPurgeServer(args: {
includeAnalysis?: boolean;
includeOffline?: boolean;
}): Promise<PurgeReportDto> {
return invoke<PurgeReportDto>('library_purge_server', args);
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<PurgeReportDto>('library_purge_server', { ...args, serverId: indexKey });
}
export function libraryDeleteServerData(serverId: string): Promise<void> {
return invoke<void>('library_delete_server_data', { serverId });
const indexKey = serverIndexKeyForId(serverId);
return invoke<void>('library_delete_server_data', { serverId: indexKey });
}
// ── Player stats (local listening history) ────────────────────────────
@@ -532,7 +630,8 @@ export type PlaySessionRecentDay = {
};
export function libraryRecordPlaySession(input: PlaySessionInput): Promise<void> {
return invoke<void>('library_record_play_session', { input });
const indexKey = serverIndexKeyForId(input.serverId);
return invoke<void>('library_record_play_session', { input: { ...input, serverId: indexKey } });
}
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
@@ -544,7 +643,14 @@ export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionH
}
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso });
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso })
.then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
+56
View File
@@ -0,0 +1,56 @@
import { invoke } from '@tauri-apps/api/core';
export interface ServerIndexMapping {
legacyId: string;
indexKey: string;
}
export interface MigrationInspectScope {
totalLegacyRows: number;
skippedUnknownServerRows: number;
tables: Record<string, number>;
}
export interface MigrationInspectReport {
needsMigration: boolean;
hasSkippedUnknownServerRows: boolean;
canRun: boolean;
warnings: string[];
unmappedEmptyBucket: boolean;
library: MigrationInspectScope;
analysis: MigrationInspectScope;
mappings: ServerIndexMapping[];
}
export interface MigrationProgressEvent {
stage: string;
table: string;
done: number;
total: number;
}
export interface MigrationRunScope {
importedRows: number;
sourceRows: number;
skippedUnknownServerRows: number;
}
export interface MigrationRunResult {
library: MigrationRunScope;
analysis: MigrationRunScope;
hasSkippedUnknownServerRows: boolean;
switched: boolean;
backupRemoved: boolean;
}
export function migrationInspect(
mappings: ServerIndexMapping[],
): Promise<MigrationInspectReport> {
return invoke<MigrationInspectReport>('migration_inspect', { mappings });
}
export function migrationRun(
mappings: ServerIndexMapping[],
): Promise<MigrationRunResult> {
return invoke<MigrationRunResult>('migration_run', { mappings });
}
+4 -2
View File
@@ -3,6 +3,7 @@ import md5 from 'md5';
import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
@@ -53,7 +54,7 @@ export function getClient() {
}
export function getServerById(serverId: string): ServerProfile | undefined {
return useAuthStore.getState().servers.find(s => s.id === serverId);
return findServerByIdOrIndexKey(serverId);
}
/** Subsonic REST call against an explicit saved server (not necessarily the active one). */
@@ -95,7 +96,8 @@ export function libraryFilterParams(): Record<string, string | number> {
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
export function libraryScopeForServer(serverId: string): string | undefined {
const f = useAuthStore.getState().musicLibraryFilterByServer[serverId];
const resolved = resolveServerIdForIndexKey(serverId);
const f = useAuthStore.getState().musicLibraryFilterByServer[resolved];
if (f === undefined || f === 'all') return undefined;
return f;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
@@ -39,7 +40,7 @@ function streamUrlFromProfile(
}
export function buildStreamUrlForServer(serverId: string, id: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
const server = findServerByIdOrIndexKey(serverId);
if (!server) return buildStreamUrl(id);
return streamUrlFromProfile(server.url, server.username, server.password, id);
}
+1
View File
@@ -132,6 +132,7 @@ export function AppShell() {
usePlaybackRateStore.getState().syncToRust();
}, []);
useEffect(() => {
getCurrentWebview().setZoom(uiScale).catch(() => {
/* setZoom may fail on platforms where the capability is unavailable;
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react';
import { retryServerIndexMigration } from '../hooks/useMigrationOrchestrator';
import { useMigrationStore } from '../store/migrationStore';
function MigrationModal() {
const phase = useMigrationStore(s => s.phase);
const progress = useMigrationStore(s => s.progress);
const inspect = useMigrationStore(s => s.inspect);
const error = useMigrationStore(s => s.lastError);
const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0);
return (
<div style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
}}
>
<div style={{
width: 'min(560px, 92vw)',
background: 'var(--bg-card)',
borderRadius: 14,
padding: '1.5rem 1.75rem',
color: 'var(--text)',
}}
>
{phase === 'inspecting' && (
<>
<h3>Preparing data update</h3>
<p style={{ color: 'var(--text-muted)' }}>Looking at your library and analysis cache</p>
</>
)}
{phase === 'running' && (
<>
<h3>Migrating data</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{progress ? `${progress.stage} - ${progress.table}` : 'running'}
</p>
<p style={{ color: 'var(--text-muted)' }}>
{progress ? `${progress.done} / ${progress.total}` : 'working…'}
</p>
{inspect?.hasSkippedUnknownServerRows ? (
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
Rows for removed servers were skipped and old backup DB will be removed after successful switch.
</p>
) : null}
</>
)}
{phase === 'error' && (
<>
<h3>Migration failed</h3>
<p style={{ color: 'var(--text-muted)' }}>{String(error ?? '').slice(0, 200)}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="btn-primary" onClick={() => retryServerIndexMigration()}>Retry</button>
<button className="btn-surface" onClick={() => navigator.clipboard.writeText(String(error ?? ''))}>
Copy details
</button>
</div>
</>
)}
{phase === 'completed' && (
<>
<h3>Update complete</h3>
<p style={{ color: 'var(--text-muted)' }}>{migratedRows} rows migrated</p>
</>
)}
</div>
</div>
);
}
export default function BlockingMigrationGate({ children }: { children: ReactNode }) {
const phase = useMigrationStore(s => s.phase);
const isBlocking = phase === 'inspecting' || phase === 'running' || phase === 'error';
return (
<>
{children}
{isBlocking ? <MigrationModal /> : null}
</>
);
}
+41 -26
View File
@@ -17,10 +17,14 @@ import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
import { hydrateQueueFromIndex } from '../utils/library/queueRestore';
import { useLibraryAnalysisBackfill } from '../hooks/useLibraryAnalysisBackfill';
import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator';
import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell';
import BlockingMigrationGate from './BlockingMigrationGate';
import RequireAuth from './RequireAuth';
import { useMigrationStore } from '../store/migrationStore';
const Login = lazy(() => import('../pages/Login'));
@@ -45,12 +49,18 @@ export default function MainApp() {
const activeServerId = useAuthStore(s => s.activeServerId);
const serverIdsKey = useAuthStore(s => s.servers.map(srv => srv.id).join(','));
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const migrationPhase = useMigrationStore(s => s.phase);
const migrationReady = migrationPhase === 'completed';
useMigrationOrchestrator();
useEffect(() => {
if (!migrationReady) return;
void (async () => {
await bootstrapAllIndexedServers();
void hydrateQueueFromIndex();
})();
}, [activeServerId, serverIdsKey, masterEnabled]);
}, [activeServerId, serverIdsKey, masterEnabled, migrationReady]);
useLibraryAnalysisBackfill(migrationReady);
// Push playback state to mini window + handle control events.
useEffect(() => {
@@ -62,21 +72,24 @@ export default function MainApp() {
// a hang workaround — skip here to avoid double-building.
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
useEffect(() => {
if (IS_WINDOWS || !preloadMiniPlayer) return;
if (!migrationReady || IS_WINDOWS || !preloadMiniPlayer) return;
invoke('preload_mini_player').catch(() => {});
}, [preloadMiniPlayer]);
}, [preloadMiniPlayer, migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
return initAudioListeners();
}, []);
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
return initHotCachePrefetch();
}, []);
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return;
useGlobalShortcutsStore.getState().registerAll();
}, []);
}, [migrationReady]);
// ── Easter egg: Ctrl+Shift+Alt+N → export new albums image ──
useEffect(() => {
@@ -128,26 +141,28 @@ export default function MainApp() {
return (
<WindowVisibilityProvider>
<BrowserRouter>
<PasteClipboardHandler />
<TauriEventBridge />
<Suspense fallback={null}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
</Suspense>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
<FpsOverlay />
<BlockingMigrationGate>
<PasteClipboardHandler />
<TauriEventBridge />
<Suspense fallback={null}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<DragDropProvider>
<AppShell />
</DragDropProvider>
</RequireAuth>
}
/>
</Routes>
</Suspense>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
<FpsOverlay />
</BlockingMigrationGate>
</BrowserRouter>
</WindowVisibilityProvider>
);
+92 -5
View File
@@ -1,13 +1,63 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { analysisGetPipelineQueueStats, type AnalysisPipelineQueueStatsDto } from '../api/analysis';
import { usePerfProbeFlag } from '../utils/perf/perfFlags';
import {
formatPerfMs,
getAnalysisTracksPerMinute,
useAnalysisPerfLast,
} from '../utils/perf/analysisPerfStore';
import { formatAnalysisPipelineQueueOverlay } from '../utils/perf/formatAnalysisQueueStats';
import { useAnalysisPerfListener } from '../hooks/useAnalysisPerfListener';
const SAMPLE_MS = 500;
const TPM_REFRESH_MS = 500;
const QUEUE_STATS_MS = 750;
/** FPS from rAF callbacks over sliding ~500ms windows; only runs when Performance Probe enables the overlay. */
/** FPS + analysis throughput overlay (Performance Probe). */
export default function FpsOverlay() {
const showFpsOverlay = usePerfProbeFlag('showFpsOverlay');
const showAnalysisPerfOverlay = usePerfProbeFlag('showAnalysisPerfOverlay');
const [fps, setFps] = useState(0);
const [tpm, setTpm] = useState(0);
const [queueStats, setQueueStats] = useState<AnalysisPipelineQueueStatsDto | null>(null);
const last = useAnalysisPerfLast();
useAnalysisPerfListener(showAnalysisPerfOverlay);
useEffect(() => {
if (!showAnalysisPerfOverlay) {
setTpm(0);
return;
}
const refresh = () => setTpm(getAnalysisTracksPerMinute());
refresh();
const id = window.setInterval(refresh, TPM_REFRESH_MS);
return () => window.clearInterval(id);
}, [showAnalysisPerfOverlay, last?.at]);
useEffect(() => {
if (!showAnalysisPerfOverlay) {
setQueueStats(null);
return;
}
let cancelled = false;
const refresh = () => {
void analysisGetPipelineQueueStats()
.then(stats => {
if (!cancelled) setQueueStats(stats);
})
.catch(() => {
if (!cancelled) setQueueStats(null);
});
};
refresh();
const id = window.setInterval(refresh, QUEUE_STATS_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [showAnalysisPerfOverlay]);
useEffect(() => {
if (!showFpsOverlay) {
@@ -35,13 +85,50 @@ export default function FpsOverlay() {
return () => cancelAnimationFrame(rafId);
}, [showFpsOverlay]);
if (!showFpsOverlay) return null;
if (!showFpsOverlay && !showAnalysisPerfOverlay) return null;
return createPortal(
<div className="fps-overlay" aria-hidden="true">
{fps}
{' '}
<span className="fps-overlay__unit">FPS</span>
{showFpsOverlay && (
<div className="fps-overlay__row">
{fps}
{' '}
<span className="fps-overlay__unit">FPS</span>
</div>
)}
{showAnalysisPerfOverlay && (
<>
<div className="fps-overlay__row">
{tpm.toFixed(1)}
{' '}
<span className="fps-overlay__unit">tpm</span>
</div>
{last && (
<>
<div className="fps-overlay__row fps-overlay__row--detail">
last
{' '}
{formatPerfMs(last.totalMs)}
</div>
<div className="fps-overlay__row fps-overlay__row--steps">
f
{formatPerfMs(last.fetchMs)}
{' · '}
s
{formatPerfMs(last.seedMs)}
{' · '}
b
{formatPerfMs(last.bpmMs)}
</div>
</>
)}
{queueStats && formatAnalysisPipelineQueueOverlay(queueStats).map(line => (
<div key={line} className="fps-overlay__row fps-overlay__row--steps">
{line}
</div>
))}
</>
)}
</div>,
document.body,
);
+4 -2
View File
@@ -26,6 +26,7 @@ import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './Cached
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
@@ -98,13 +99,14 @@ export default function LiveSearch() {
if (!indexEnabled || !serverId) return;
let unlistenProgress: (() => void) | undefined;
let unlistenIdle: (() => void) | undefined;
const indexKey = resolveIndexKey(serverId);
void subscribeLibrarySyncIdle(payload => {
if (payload.serverId === serverId) void refreshLocalReady();
if (payload.serverId === indexKey) void refreshLocalReady();
}).then(fn => {
unlistenIdle = fn;
});
void subscribeLibrarySyncProgress(p => {
if (p.serverId === serverId && p.kind === 'phase_changed') void refreshLocalReady();
if (p.serverId === indexKey && p.kind === 'phase_changed') void refreshLocalReady();
}).then(fn => {
unlistenProgress = fn;
});
+2 -1
View File
@@ -132,7 +132,7 @@ export default function Sidebar({
isLoggedIn,
pathname: location.pathname,
});
const { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates } = useSidebarPerfProbe();
const { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates, analysisPerf } = useSidebarPerfProbe();
const perfFlags = usePerfProbeFlags();
@@ -260,6 +260,7 @@ export default function Sidebar({
perfFlags={perfFlags}
perfCpu={perfCpu}
perfDiagRates={perfDiagRates}
analysisPerf={analysisPerf}
hotCacheEnabled={hotCacheEnabled}
setHotCacheEnabled={setHotCacheEnabled}
normalizationEngine={normalizationEngine}
@@ -0,0 +1,371 @@
import { AlertTriangle, BarChart3, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import SettingsSubSection from '../SettingsSubSection';
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
import { useAuthStore } from '../../store/authStore';
import {
analysisDeleteAllForServer,
libraryAnalysisProgress,
type LibraryAnalysisProgressDto,
} from '../../api/analysis';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { showToast } from '../../utils/ui/toast';
import {
ANALYTICS_STRATEGIES,
ADVANCED_PARALLELISM_MAX,
ADVANCED_PARALLELISM_MIN,
type AnalyticsStrategy,
} from '../../utils/library/analysisStrategy';
type ClearTarget = {
serverId: string;
label: string;
};
export default function AnalyticsStrategySection() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const {
strategyByServer,
advancedParallelismByServer,
setServerStrategy,
setServerAdvancedParallelism,
clearServerOverrides,
getStrategyForServer,
getAdvancedParallelismForServer,
} = useAnalysisStrategyStore();
const [progressByServer, setProgressByServer] = useState<Record<string, LibraryAnalysisProgressDto | null>>({});
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
const [clearingServerId, setClearingServerId] = useState<string | null>(null);
const activeServerIds = useMemo(
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
[servers],
);
const removedServerIds = useMemo(() => {
const known = new Set([
...Object.keys(strategyByServer),
...Object.keys(advancedParallelismByServer),
]);
return Array.from(known).filter(id => !activeServerIds.has(id));
}, [strategyByServer, advancedParallelismByServer, activeServerIds]);
const anyAggressive = useMemo(() => {
return servers.some(server => getStrategyForServer(server.id) === 'advanced');
}, [servers, getStrategyForServer]);
useEffect(() => {
if (servers.length === 0) return;
let cancelled = false;
const refresh = () => {
void Promise.all(
servers.map(server => {
const key = serverIndexKeyForProfile(server);
return libraryAnalysisProgress(server.id)
.then(progress => ({ key, progress }))
.catch(() => ({ key, progress: null }));
}),
).then(results => {
if (cancelled) return;
setProgressByServer(prev => {
const next = { ...prev };
results.forEach(({ key, progress }) => {
next[key] = progress;
});
return next;
});
});
};
refresh();
const id = window.setInterval(refresh, 5000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [servers]);
const progressLabel = (progress: LibraryAnalysisProgressDto | null) => {
if (!progress || progress.totalTracks <= 0) return null;
const done = progress.doneTracks;
const total = progress.totalTracks;
const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100)));
return t('settings.analyticsStrategyProgressValue', {
percent,
done: done.toLocaleString(),
total: total.toLocaleString(),
});
};
const strategyLabel = (s: AnalyticsStrategy) => {
switch (s) {
case 'lazy':
return t('settings.analyticsStrategyLazy');
case 'advanced':
return t('settings.analyticsStrategyAdvanced');
}
};
const handleClearAnalysis = async () => {
if (!clearTarget) return;
setClearingServerId(clearTarget.serverId);
try {
await analysisDeleteAllForServer(clearTarget.serverId);
clearServerOverrides(clearTarget.serverId);
showToast(t('settings.analyticsStrategyClearSuccess'), 4000, 'success');
} catch {
showToast(t('settings.analyticsStrategyClearError'), 5000, 'error');
} finally {
setClearingServerId(null);
setClearTarget(null);
}
};
return (
<SettingsSubSection
title={t('settings.analyticsStrategyTitle')}
icon={<BarChart3 size={16} />}
>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyDesc')}
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyServerLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyParallelismLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyProgressLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyActionsLabel')}
</th>
</tr>
</thead>
<tbody>
{servers.map(server => {
const strategy = getStrategyForServer(server.id);
const advancedParallelism = getAdvancedParallelismForServer(server.id);
const key = serverIndexKeyForProfile(server);
const progress = progressByServer[key] ?? null;
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
{label}
</td>
<td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{ANALYTICS_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setServerStrategy(server.id, s)}
>
{strategyLabel(s)}
</button>
))}
</div>
</td>
<td style={{ padding: '10px', minWidth: 160 }}>
{strategy === 'advanced' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<input
type="range"
min={ADVANCED_PARALLELISM_MIN}
max={ADVANCED_PARALLELISM_MAX}
step={1}
value={advancedParallelism}
onChange={e => {
const value = parseInt(e.target.value, 10);
setServerAdvancedParallelism(server.id, value);
}}
style={{ flex: 1, minWidth: 80, maxWidth: 160 }}
aria-valuemin={ADVANCED_PARALLELISM_MIN}
aria-valuemax={ADVANCED_PARALLELISM_MAX}
aria-valuenow={advancedParallelism}
/>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', minWidth: 64 }}>
{t('settings.analyticsStrategyParallelismValue', { n: advancedParallelism })}
</span>
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
)}
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
{progressLabel(progress) ?? '—'}
</td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-sm btn-surface"
onClick={() => setClearTarget({ serverId: server.id, label })}
disabled={clearingServerId === server.id}
>
{t('settings.analyticsStrategyClearAction')}
</button>
</td>
</tr>
);
})}
{removedServerIds.map(serverId => (
<tr
key={serverId}
style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}
>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-secondary)' }}>
<div>{serverId}</div>
<div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}>
{t('settings.analyticsStrategyServerRemoved')}
</div>
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-muted)' }}></td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-muted)' }}></td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-muted)' }}></td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-sm btn-surface"
onClick={() => setClearTarget({ serverId, label: serverId })}
disabled={clearingServerId === serverId}
>
{t('settings.analyticsStrategyClearAction')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div
style={{
marginTop: '0.85rem',
padding: '0.65rem 0.75rem',
borderRadius: 8,
background: 'var(--surface-elevated, rgba(255,255,255,0.03))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: '0.45rem', color: 'var(--text-secondary)' }}>
{t('settings.analyticsStrategyPriorityTitle')}
</div>
<ul style={{ margin: 0, paddingLeft: '1.1rem', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
<li>{t('settings.analyticsStrategyPriorityHigh')}</li>
<li>{t('settings.analyticsStrategyPriorityMiddle')}</li>
<li>{t('settings.analyticsStrategyPriorityLow')}</li>
</ul>
</div>
<div
style={{
marginTop: '0.85rem',
padding: '0.65rem 0.75rem',
borderRadius: 8,
background: 'var(--surface-elevated, rgba(255,255,255,0.03))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
fontSize: 12,
color: 'var(--text-muted)',
lineHeight: 1.55,
}}
>
<div style={{ marginBottom: '0.4rem' }}>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>
{t('settings.analyticsStrategyLazy')}
</span>
{' '}
{t('settings.analyticsStrategyLazyDesc')}
</div>
<div>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>
{t('settings.analyticsStrategyAdvanced')}
</span>
{' '}
{t('settings.analyticsStrategyAdvancedDesc')}
</div>
</div>
{anyAggressive && (
<div
className="settings-hint settings-hint-info"
role="note"
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
>
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
{t('settings.analyticsStrategyAdvancedWarning')}
</span>
</div>
)}
</div>
{clearTarget &&
createPortal(
<div
className="modal-overlay"
onClick={() => setClearTarget(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '420px' }}
>
<button
className="modal-close"
onClick={() => setClearTarget(null)}
aria-label={t('settings.analyticsStrategyClearCancel')}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.analyticsStrategyClearTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyClearDesc', { server: clearTarget.label })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-primary"
onClick={() => setClearTarget(null)}
autoFocus
disabled={clearingServerId === clearTarget.serverId}
>
{t('settings.analyticsStrategyClearCancel')}
</button>
<button
className="btn btn-surface"
onClick={handleClearAnalysis}
disabled={clearingServerId === clearTarget.serverId}
style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<AlertTriangle size={14} />
{t('settings.analyticsStrategyClearConfirm')}
</span>
</button>
</div>
</div>
</div>,
document.body,
)}
</SettingsSubSection>
);
}
+146 -27
View File
@@ -1,41 +1,152 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, HardDrive, Upload } from 'lucide-react';
import { exportBackup, importBackup } from '../../utils/export/backup';
import { Clock3, Download, HardDrive, Upload } from 'lucide-react';
import { createPortal } from 'react-dom';
import {
exportBackupToPath,
importAnyBackupFromPath,
pickBackupExportPath,
pickBackupImportPath,
} from '../../utils/export/backup';
import { showToast } from '../../utils/ui/toast';
type BackupMode = 'full' | 'library' | 'config';
type BackupAction = 'export' | 'import';
export function BackupSection() {
const { t } = useTranslation();
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [mode, setMode] = useState<BackupMode>('full');
const [busyAction, setBusyAction] = useState<BackupAction | null>(null);
const waitForPaint = async () => {
await new Promise(resolve => window.setTimeout(resolve, 0));
};
const busy = exporting || importing;
const handleExport = async () => {
const exportMode = mode;
const path = await pickBackupExportPath(exportMode);
if (!path) return;
setBusyAction('export');
setExporting(true);
try {
const path = await exportBackup();
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
await waitForPaint();
await exportBackupToPath(exportMode, path);
const successKey = exportMode === 'full'
? 'settings.backupFullExportSuccess'
: exportMode === 'library'
? 'settings.backupLibraryExportSuccess'
: 'settings.backupSuccess';
showToast(t(successKey), 3000, 'info');
} catch (e) {
console.error('Export failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
const errorKey = mode === 'full'
? 'settings.backupFullImportError'
: mode === 'library'
? 'settings.backupLibraryImportError'
: 'settings.backupImportError';
showToast(t(errorKey), 4000, 'error');
} finally {
setExporting(false);
setBusyAction(null);
}
};
const handleImport = async () => {
if (!window.confirm(t('settings.backupImportConfirm'))) return;
if (!window.confirm(t('settings.backupImportAnyConfirm'))) return;
const path = await pickBackupImportPath();
if (!path) return;
setBusyAction('import');
setImporting(true);
try {
await importBackup();
// importBackup reloads the page — this toast will briefly show before reload
showToast(t('settings.backupImportSuccess'), 3000, 'info');
await waitForPaint();
const importedKind = await importAnyBackupFromPath(path);
if (importedKind === 'full') {
showToast(t('settings.backupFullImportSuccess'), 3000, 'info');
} else if (importedKind === 'databases') {
showToast(t('settings.backupLibraryImportSuccess'), 3000, 'info');
} else if (importedKind === 'config') {
showToast(t('settings.backupImportSuccess'), 3000, 'info');
}
} catch (e) {
console.error('Import failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
} finally {
setImporting(false);
setBusyAction(null);
}
};
const modeTitle = mode === 'full'
? t('settings.backupModeFull')
: mode === 'library'
? t('settings.backupModeLibrary')
: t('settings.backupModeConfig');
const modeDesc = mode === 'full'
? t('settings.backupFullDesc')
: mode === 'library'
? t('settings.backupLibraryExportDesc')
: t('settings.backupExportDesc');
const exportLabel = mode === 'full'
? t('settings.backupFullExport')
: mode === 'library'
? t('settings.backupLibraryExport')
: t('settings.backupExport');
const importLabel = t('settings.backupImportAny');
const overlayTitle = busyAction === 'import'
? t('settings.backupOverlayImportTitle')
: t('settings.backupOverlayExportTitle');
const overlayHint = t('settings.backupOverlayHint');
const busyOverlay = busy && typeof document !== 'undefined'
? createPortal(
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.38)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
zIndex: 99999,
pointerEvents: 'all',
boxSizing: 'border-box',
}}
aria-live="polite"
aria-busy="true"
>
<div
className="settings-card"
style={{
width: 'clamp(280px, 52vw, 560px)',
maxWidth: 'calc(100vw - 32px)',
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: '999px',
background: 'var(--surface-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0.6rem',
}}
>
<Clock3 size={18} />
</div>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: '0.5rem' }}>{overlayTitle}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{overlayHint}</div>
</div>
</div>,
document.body,
)
: null;
return (
<section className="settings-section">
<div className="settings-section-header">
@@ -43,43 +154,51 @@ export function BackupSection() {
<h2>{t('settings.backupTitle')}</h2>
</div>
{/* Export */}
<div className="settings-card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}>
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
<button
key={candidate}
type="button"
className={`btn btn-sm ${mode === candidate ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setMode(candidate)}
>
{candidate === 'full'
? t('settings.backupModeFull')
: candidate === 'library'
? t('settings.backupModeLibrary')
: t('settings.backupModeConfig')}
</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', marginBottom: '0.9rem' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{modeTitle}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{modeDesc}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
style={{ flexShrink: 0 }}
>
<Upload size={14} />
{exporting ? '…' : t('settings.backupExport')}
{exporting ? '…' : exportLabel}
</button>
</div>
</div>
{/* Import */}
<div className="settings-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
</div>
<button
className="btn btn-surface"
onClick={handleImport}
disabled={importing}
style={{ flexShrink: 0 }}
>
<Download size={14} />
{importing ? '…' : t('settings.backupImport')}
{importing ? '…' : importLabel}
</button>
</div>
</div>
{busyOverlay}
</section>
);
}
@@ -1,474 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { DatabaseZap } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { showToast } from '../../utils/ui/toast';
import SettingsSubSection from '../SettingsSubSection';
import {
libraryGetStatus,
librarySyncCancel,
librarySyncClearSession,
subscribeLibrarySyncIdle,
subscribeLibrarySyncProgress,
type SyncStateDto,
} from '../../api/library';
import {
bootstrapAllIndexedServers,
bootstrapIndexedServer,
type BindServerResult,
} from '../../utils/library/librarySession';
import { enqueueLibrarySync, waitForLibrarySyncIdle } from '../../utils/library/librarySyncQueue';
import { syncIngestDisplayCount } from '../../utils/library/libraryReady';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import LibraryIndexServerRow, { type LibraryServerConnection } from './LibraryIndexServerRow';
const STATUS_POLL_MS = 3000;
const SYNC_POLL_MS = 2500;
const OFFLINE_RETRY_MS = 60_000;
export default function LibraryIndexSection() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const setMasterEnabled = useLibraryIndexStore(s => s.setMasterEnabled);
const setServerSyncExcluded = useLibraryIndexStore(s => s.setServerSyncExcluded);
const autoReconcile = useLibraryIndexStore(s => s.autoReconcileEnabled);
const setAutoReconcile = useLibraryIndexStore(s => s.setAutoReconcileEnabled);
const indexedIds = useMemo(() => {
if (!masterEnabled) return [];
return servers.map(s => s.id).filter(id => syncExcludedByServer[id] !== true);
}, [masterEnabled, syncExcludedByServer, servers]);
const indexedServers = useMemo(
() => servers.filter(s => indexedIds.includes(s.id)),
[servers, indexedIds],
);
const excludedServers = useMemo(
() => servers.filter(s => syncExcludedByServer[s.id] === true),
[servers, syncExcludedByServer],
);
const [statusByServer, setStatusByServer] = useState<Record<string, SyncStateDto | null>>({});
const [connectionByServer, setConnectionByServer] = useState<Record<string, LibraryServerConnection>>({});
const [progressByServer, setProgressByServer] = useState<Record<string, string | null>>({});
const [busyServerId, setBusyServerId] = useState<string | null>(null);
const [excludingServerId, setExcludingServerId] = useState<string | null>(null);
const [includingServerId, setIncludingServerId] = useState<string | null>(null);
const [bootstrapping, setBootstrapping] = useState(false);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const ingestCountRef = useRef<Record<string, number>>({});
const syncPhaseRef = useRef<Record<string, string | null>>({});
const applyConnectionResults = useCallback((results: Record<string, BindServerResult>) => {
setConnectionByServer(prev => {
const next = { ...prev };
for (const [id, result] of Object.entries(results)) {
next[id] = result === 'offline' ? 'offline' : result === 'bound' ? 'online' : 'unknown';
}
return next;
});
}, []);
const refreshAllStatuses = useCallback(async () => {
if (!masterEnabled || indexedServers.length === 0) return;
const entries = await Promise.all(
indexedServers.map(async srv => {
try {
const fresh = await libraryGetStatus(srv.id);
syncPhaseRef.current[srv.id] = fresh.syncPhase;
if (fresh.syncPhase === 'initial_sync') {
const next = Math.max(ingestCountRef.current[srv.id] ?? 0, syncIngestDisplayCount(fresh));
ingestCountRef.current[srv.id] = next;
setProgressByServer(p => ({
...p,
[srv.id]: t('settings.libraryIndexProgressIngest', { count: next }),
}));
} else if (fresh.syncPhase === 'ready' || fresh.syncPhase === 'idle') {
ingestCountRef.current[srv.id] = 0;
}
return [srv.id, fresh] as const;
} catch {
return [srv.id, null] as const;
}
}),
);
setStatusByServer(Object.fromEntries(entries));
}, [masterEnabled, indexedServers, t]);
const runBootstrap = useCallback(async () => {
if (!masterEnabled) return;
setBootstrapping(true);
try {
const results = await bootstrapAllIndexedServers();
applyConnectionResults(results);
await refreshAllStatuses();
} finally {
setBootstrapping(false);
}
}, [masterEnabled, applyConnectionResults, refreshAllStatuses]);
const retryOfflineServers = useCallback(async () => {
if (!masterEnabled) return;
const offline = indexedServers.filter(s => connectionByServer[s.id] === 'offline');
if (offline.length === 0) return;
const results: Record<string, BindServerResult> = {};
for (const srv of offline) {
results[srv.id] = await bootstrapIndexedServer(srv);
}
applyConnectionResults(results);
void refreshAllStatuses();
}, [masterEnabled, indexedServers, connectionByServer, applyConnectionResults, refreshAllStatuses]);
useEffect(() => {
if (!masterEnabled) {
setStatusByServer({});
setConnectionByServer({});
setProgressByServer({});
setBusyServerId(null);
setExcludingServerId(null);
setIncludingServerId(null);
return;
}
void runBootstrap();
}, [masterEnabled, indexedIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!masterEnabled) return;
const poll = () => {
void refreshAllStatuses();
const anyInitial = indexedServers.some(
s => syncPhaseRef.current[s.id] === 'initial_sync',
);
pollTimer.current = setTimeout(poll, anyInitial ? SYNC_POLL_MS : STATUS_POLL_MS);
};
poll();
return () => {
if (pollTimer.current) clearTimeout(pollTimer.current);
pollTimer.current = null;
};
}, [masterEnabled, indexedServers, refreshAllStatuses]);
useEffect(() => {
if (!masterEnabled) return;
const retryTimer = setInterval(() => {
void retryOfflineServers();
}, OFFLINE_RETRY_MS);
return () => clearInterval(retryTimer);
}, [masterEnabled, retryOfflineServers]);
useEffect(() => {
if (!masterEnabled) return;
const unsubs: Array<Promise<() => void>> = [
subscribeLibrarySyncProgress(p => {
if (!indexedIds.includes(p.serverId)) return;
setBusyServerId(p.serverId);
if (p.kind === 'ingest_page') {
const next = Math.max(ingestCountRef.current[p.serverId] ?? 0, p.ingestedTotal ?? 0);
ingestCountRef.current[p.serverId] = next;
setProgressByServer(prev => ({
...prev,
[p.serverId]: t('settings.libraryIndexProgressIngest', { count: next }),
}));
} else if (p.kind === 'tombstoned') {
setProgressByServer(prev => ({
...prev,
[p.serverId]: t('settings.libraryIndexProgressVerify', {
checked: p.tombstonesChecked ?? 0,
deleted: p.tombstonesDeleted ?? 0,
}),
}));
} else if (p.kind === 'phase_changed' && p.phase) {
setProgressByServer(prev => ({ ...prev, [p.serverId]: p.phase ?? null }));
}
}),
subscribeLibrarySyncIdle(p => {
if (!indexedIds.includes(p.serverId)) return;
setBusyServerId(cur => (cur === p.serverId ? null : cur));
ingestCountRef.current[p.serverId] = 0;
setProgressByServer(prev => ({ ...prev, [p.serverId]: null }));
void refreshAllStatuses();
if (!p.ok && p.error) {
showToast(t('settings.libraryIndexSyncError', { error: p.error }), 5000, 'error');
}
}),
];
return () => {
unsubs.forEach(u => void u.then(fn => fn()));
};
}, [masterEnabled, indexedIds, refreshAllStatuses, t]);
const handleMasterToggle = async (enabled: boolean) => {
if (enabled) {
setMasterEnabled(true);
await runBootstrap();
return;
}
setBootstrapping(true);
try {
for (const srv of servers) {
try {
await librarySyncClearSession(srv.id);
} catch {
/* best-effort */
}
}
setMasterEnabled(false);
setStatusByServer({});
setConnectionByServer({});
setProgressByServer({});
setBusyServerId(null);
} finally {
setBootstrapping(false);
}
};
const runServerAction = async (
serverId: string,
action: 'full' | 'delta' | 'verify',
) => {
setBusyServerId(serverId);
try {
const kind =
action === 'verify'
? 'verify'
: action === 'full'
? 'full'
: statusByServer[serverId]?.lastFullSyncAt
? 'delta'
: 'full';
ingestCountRef.current[serverId] = 0;
await enqueueLibrarySync({ serverId, kind });
} catch (e) {
setBusyServerId(null);
showToast(t('settings.libraryIndexSyncError', { error: String(e) }), 5000, 'error');
}
};
const handleIncludeServer = async (serverId: string) => {
if (includingServerId || excludingServerId) return;
const srv = servers.find(s => s.id === serverId);
if (!srv) return;
flushSync(() => {
setIncludingServerId(serverId);
setServerSyncExcluded(serverId, false);
});
try {
const result = await bootstrapIndexedServer(srv);
applyConnectionResults({ [serverId]: result });
if (result === 'error') {
setServerSyncExcluded(serverId, true);
showToast(t('settings.libraryIndexBindError', { error: t('settings.libraryIndexStatusError') }), 5000, 'error');
return;
}
try {
const fresh = await libraryGetStatus(serverId);
syncPhaseRef.current[serverId] = fresh.syncPhase;
setStatusByServer(prev => ({ ...prev, [serverId]: fresh }));
} catch {
/* status poll is best-effort */
}
} catch (e) {
setServerSyncExcluded(serverId, true);
showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error');
} finally {
setIncludingServerId(null);
}
};
const handleExcludeServer = async (serverId: string) => {
if (excludingServerId || includingServerId) return;
flushSync(() => setExcludingServerId(serverId));
try {
const syncing =
busyServerId === serverId ||
statusByServer[serverId]?.syncPhase === 'initial_sync' ||
statusByServer[serverId]?.syncPhase === 'probing';
if (syncing) {
try {
await librarySyncCancel();
await waitForLibrarySyncIdle(serverId);
} catch {
/* best-effort — proceed with unbind */
}
}
await librarySyncClearSession(serverId);
setServerSyncExcluded(serverId, true);
setStatusByServer(prev => {
const next = { ...prev };
delete next[serverId];
return next;
});
setConnectionByServer(prev => {
const next = { ...prev };
delete next[serverId];
return next;
});
setProgressByServer(prev => {
const next = { ...prev };
delete next[serverId];
return next;
});
if (busyServerId === serverId) {
setBusyServerId(null);
}
} catch (e) {
showToast(t('settings.libraryIndexBindError', { error: String(e) }), 5000, 'error');
} finally {
setExcludingServerId(null);
}
};
const handleCancel = async () => {
try {
await librarySyncCancel();
} catch {
/* best-effort */
}
};
const globalBusy =
bootstrapping || busyServerId != null || excludingServerId != null || includingServerId != null;
return (
<SettingsSubSection
title={t('settings.libraryIndexTitle')}
icon={<DatabaseZap size={16} />}
>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.libraryIndexDesc')}
</p>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.libraryIndexDeltaHint')}
</p>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.libraryIndexEnable')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{servers.length > 0
? t('settings.libraryIndexEnableAllDesc')
: t('settings.libraryIndexNoServer')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.libraryIndexEnable')}>
<input
type="checkbox"
checked={masterEnabled}
disabled={servers.length === 0 || bootstrapping || includingServerId != null || excludingServerId != null}
onChange={e => void handleMasterToggle(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
{masterEnabled && (
<>
<div className="settings-section-divider" />
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.65rem' }}>
{t('settings.libraryIndexServerListTitle')}
</div>
{indexedServers.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{t('settings.libraryIndexAllExcluded')}
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{indexedServers.map(srv => (
<LibraryIndexServerRow
key={srv.id}
server={srv}
allServers={servers}
isActive={srv.id === activeServerId}
status={statusByServer[srv.id] ?? null}
connection={connectionByServer[srv.id] ?? 'unknown'}
progressLabel={progressByServer[srv.id] ?? null}
busy={busyServerId === srv.id}
including={includingServerId === srv.id}
excluding={excludingServerId === srv.id}
actionsDisabled={
(globalBusy && busyServerId !== srv.id)
|| excludingServerId != null
|| includingServerId != null
}
onFullSync={() => void runServerAction(srv.id, 'full')}
onDeltaSync={() => void runServerAction(srv.id, 'delta')}
onVerify={() => void runServerAction(srv.id, 'verify')}
onExclude={() => void handleExcludeServer(srv.id)}
/>
))}
</div>
)}
{excludedServers.length > 0 && (
<>
<div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem' }}>
{t('settings.libraryIndexExcludedTitle')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.45rem' }}>
{excludedServers.map(srv => (
<div
key={srv.id}
className="settings-card"
style={{ padding: '0.65rem 1rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}
>
<span style={{ fontSize: 13 }}>{serverListDisplayLabel(srv, servers)}</span>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={
includingServerId != null || excludingServerId != null
}
aria-busy={includingServerId === srv.id}
onClick={() => void handleIncludeServer(srv.id)}
>
{includingServerId === srv.id
? t('settings.libraryIndexIncludingServer')
: t('settings.libraryIndexIncludeServer')}
</button>
</div>
))}
</div>
</>
)}
{busyServerId && (
<div style={{ marginTop: '0.75rem' }}>
<button type="button" className="btn btn-ghost" onClick={() => void handleCancel()}>
{t('settings.libraryIndexCancel')}
</button>
</div>
)}
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.libraryIndexAutoReconcile')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.libraryIndexAutoReconcileDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.libraryIndexAutoReconcile')}>
<input
type="checkbox"
checked={autoReconcile}
onChange={e => setAutoReconcile(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</>
)}
</div>
</SettingsSubSection>
);
}
@@ -1,155 +0,0 @@
import { RefreshCw, ShieldCheck, WifiOff, Zap, Ban } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ServerProfile } from '../../store/authStoreTypes';
import type { SyncStateDto } from '../../api/library';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import {
libraryStatusDisplayTrackCount,
libraryStatusIsReady,
} from '../../utils/library/libraryReady';
export type LibraryServerConnection = 'online' | 'offline' | 'unknown';
interface LibraryIndexServerRowProps {
server: ServerProfile;
allServers: ServerProfile[];
isActive: boolean;
status: SyncStateDto | null;
connection: LibraryServerConnection;
progressLabel: string | null;
busy: boolean;
including: boolean;
excluding: boolean;
actionsDisabled: boolean;
onFullSync: () => void;
onDeltaSync: () => void;
onVerify: () => void;
onExclude: () => void;
}
export default function LibraryIndexServerRow({
server,
allServers,
isActive,
status,
connection,
progressLabel,
busy,
including,
excluding,
actionsDisabled,
onFullSync,
onDeltaSync,
onVerify,
onExclude,
}: LibraryIndexServerRowProps) {
const { t } = useTranslation();
const name = serverListDisplayLabel(server, allServers);
const phaseLabel = (() => {
if (connection === 'offline') {
return t('settings.libraryIndexServerOffline');
}
if (progressLabel) return progressLabel;
if (!status) return t('settings.libraryIndexStatusIdle');
if (libraryStatusIsReady(status)) {
return t('settings.libraryIndexStatusReady', {
count: libraryStatusDisplayTrackCount(status),
});
}
switch (status.syncPhase) {
case 'initial_sync':
return t('settings.libraryIndexStatusInitial');
case 'error':
return t('settings.libraryIndexStatusError');
case 'probing':
return t('settings.libraryIndexStatusProbing');
default:
return t('settings.libraryIndexStatusIdle');
}
})();
return (
<div
className="settings-card"
style={{
padding: '0.85rem 1rem',
border: isActive ? '1px solid color-mix(in srgb, var(--accent) 45%, transparent)' : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>{name}</span>
{isActive && (
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: 'var(--radius-sm)', fontWeight: 600 }}>
{t('settings.serverActive')}
</span>
)}
{connection === 'offline' && (
<span style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-muted)' }}>
<WifiOff size={12} />
{t('settings.libraryIndexServerDeferred')}
</span>
)}
{busy && (
<span style={{ fontSize: 11, color: 'var(--accent)' }}>{t('settings.libraryIndexServerSyncing')}</span>
)}
{including && !busy && (
<span style={{ fontSize: 11, color: 'var(--accent)' }}>{t('settings.libraryIndexIncludingServer')}</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4, lineHeight: 1.45 }}>
{phaseLabel}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.65rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onFullSync}
>
<RefreshCw size={13} />
{t('settings.libraryIndexFullResync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onDeltaSync}
>
<Zap size={13} />
{t('settings.libraryIndexDeltaSync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onVerify}
>
<ShieldCheck size={13} />
{t('settings.libraryIndexVerify')}
</button>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', color: 'var(--text-muted)' }}
disabled={actionsDisabled || excluding}
aria-busy={excluding}
onClick={onExclude}
>
<Ban size={13} />
{excluding
? t('settings.libraryIndexExcludingServer')
: t('settings.libraryIndexExcludeServer')}
</button>
</div>
</div>
);
}
+3 -4
View File
@@ -5,19 +5,18 @@ import { useAuthStore } from '../../store/authStore';
import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
import SettingsSubSection from '../SettingsSubSection';
import StarRating from '../StarRating';
import LibraryIndexSection from './LibraryIndexSection';
import AnalyticsStrategySection from './AnalyticsStrategySection';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
export function LibraryTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const [newGenre, setNewGenre] = useState('');
const auth = useAuthStore();
return (
<>
{/* Local library index (spec §7.3) */}
<LibraryIndexSection />
<AnalyticsStrategySection />
{/* Random Mix Blacklist */}
<SettingsSubSection
@@ -0,0 +1,124 @@
import { RefreshCw, ShieldCheck, WifiOff, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SyncStateDto } from '../../api/library';
import {
libraryStatusDisplayTrackCount,
libraryStatusIsReady,
} from '../../utils/library/libraryReady';
import type { LibraryServerConnection } from '../../hooks/useLibraryIndexSync';
interface ServerLibraryIndexControlsProps {
status: SyncStateDto | null;
connection: LibraryServerConnection;
progressLabel: string | null;
busy: boolean;
actionsDisabled: boolean;
onFullSync: () => void;
onDeltaSync: () => void;
onVerify: () => void;
onCancel: () => void;
}
export default function ServerLibraryIndexControls({
status,
connection,
progressLabel,
busy,
actionsDisabled,
onFullSync,
onDeltaSync,
onVerify,
onCancel,
}: ServerLibraryIndexControlsProps) {
const { t } = useTranslation();
const phaseLabel = (() => {
if (connection === 'offline') {
return t('settings.libraryIndexServerOffline');
}
if (progressLabel) return progressLabel;
if (!status) return t('settings.libraryIndexStatusIdle');
if (libraryStatusIsReady(status)) {
return t('settings.libraryIndexStatusReady', {
count: libraryStatusDisplayTrackCount(status),
});
}
switch (status.syncPhase) {
case 'initial_sync':
return t('settings.libraryIndexStatusInitial');
case 'error':
return t('settings.libraryIndexStatusError');
case 'probing':
return t('settings.libraryIndexStatusProbing');
default:
return t('settings.libraryIndexStatusIdle');
}
})();
return (
<div
style={{
marginTop: '0.75rem',
paddingTop: '0.75rem',
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)',
}}
>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45, marginBottom: '0.5rem' }}>
{connection === 'offline' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: '0.5rem' }}>
<WifiOff size={12} />
{t('settings.libraryIndexServerDeferred')}
</span>
)}
{busy && (
<span style={{ color: 'var(--accent)', marginRight: '0.5rem' }}>
{t('settings.libraryIndexServerSyncing')}
</span>
)}
{phaseLabel}
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onFullSync}
>
<RefreshCw size={13} />
{t('settings.libraryIndexFullResync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onDeltaSync}
>
<Zap size={13} />
{t('settings.libraryIndexDeltaSync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onVerify}
>
<ShieldCheck size={13} />
{t('settings.libraryIndexVerify')}
</button>
{busy && (
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={onCancel}
>
{t('settings.libraryIndexCancel')}
</button>
)}
</div>
</div>
);
}
+17 -5
View File
@@ -7,12 +7,15 @@ import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
import type { ServerProfile } from '../../store/authStoreTypes';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
import { useDragDrop } from '../../contexts/DragDropContext';
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { switchActiveServer } from '../../utils/server/switchActiveServer';
import { AddServerForm } from './AddServerForm';
import { ServerGripHandle } from './ServerGripHandle';
@@ -30,6 +33,7 @@ export function ServersTab({
const navigate = useNavigate();
const auth = useAuthStore();
const psyDragState = useDragDrop();
const librarySync = useLibraryIndexSync();
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
@@ -147,7 +151,6 @@ export function ServersTab({
const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary'));
auth.removeServer(server.id);
useLibraryIndexStore.getState().setIndexEnabled(server.id, false);
try {
await librarySyncClearSession(server.id);
if (purgeLibrary) {
@@ -180,10 +183,8 @@ export function ServersTab({
auth.setSubsonicServerIdentity(id, identity);
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
if (useLibraryIndexStore.getState().masterEnabled) {
const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) void bootstrapIndexedServer(added);
}
const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) void bootstrapIndexedServer(added);
} else {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
@@ -349,6 +350,17 @@ export function ServersTab({
</div>
</div>
</div>
<ServerLibraryIndexControls
status={librarySync.statusByServer[serverIndexKeyForProfile(srv)] ?? null}
connection={librarySync.connectionByServer[serverIndexKeyForProfile(srv)] ?? 'unknown'}
progressLabel={librarySync.progressByServer[serverIndexKeyForProfile(srv)] ?? null}
busy={librarySync.busyServerId === serverIndexKeyForProfile(srv)}
actionsDisabled={librarySync.globalBusy && librarySync.busyServerId !== serverIndexKeyForProfile(srv)}
onFullSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'full')}
onDeltaSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'delta')}
onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')}
onCancel={() => void librarySync.handleCancel()}
/>
{showAudiomuseNavidromeServerSetting(
auth.subsonicServerIdentityByServer[srv.id],
auth.instantMixProbeByServer[srv.id],
+2 -1
View File
@@ -50,7 +50,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' },
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
{ tab: 'library', titleKey: 'settings.libraryIndexTitle', keywords: 'local library index sync offline search sqlite background delta' },
{ tab: 'servers', titleKey: 'settings.servers', keywords: 'local library index sync resync verify integrity offline delta background sqlite search' },
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
@@ -15,12 +15,21 @@ interface PerfDiagRates {
home: number;
}
interface AnalysisPerfDiag {
tracksPerMinute: number;
lastTotalMs: number | null;
lastFetchMs: number | null;
lastSeedMs: number | null;
lastBpmMs: number | null;
}
interface Props {
open: boolean;
onClose: () => void;
perfFlags: PerfProbeFlags;
perfCpu: PerfCpu | null;
perfDiagRates: PerfDiagRates | null;
analysisPerf: AnalysisPerfDiag | null;
hotCacheEnabled: boolean;
setHotCacheEnabled: (v: boolean) => void;
normalizationEngine: string;
@@ -30,7 +39,7 @@ interface Props {
}
export default function SidebarPerfProbeModal({
open, onClose, perfFlags, perfCpu, perfDiagRates,
open, onClose, perfFlags, perfCpu, perfDiagRates, analysisPerf,
hotCacheEnabled, setHotCacheEnabled,
normalizationEngine, setNormalizationEngine,
loggingMode, setLoggingMode,
@@ -56,6 +65,14 @@ export default function SidebarPerfProbeModal({
/>
<span>Show FPS overlay (requestAnimationFrame rate)</span>
</label>
<label className="sidebar-perf-modal__item">
<input
type="checkbox"
checked={perfFlags.showAnalysisPerfOverlay}
onChange={e => setPerfProbeFlag('showAnalysisPerfOverlay', e.target.checked)}
/>
<span>Show analysis throughput overlay (tpm + last track timings)</span>
</label>
<div className="sidebar-perf-modal__cpu">
<div className="sidebar-perf-modal__cpu-title">Live CPU (approx)</div>
{perfCpu == null ? (
@@ -71,6 +88,18 @@ export default function SidebarPerfProbeModal({
<div className="sidebar-perf-modal__cpu-row">Home commits rate: {perfDiagRates.home.toFixed(1)}/s</div>
</>
)}
{analysisPerf && (
<>
<div className="sidebar-perf-modal__cpu-row">analysis tpm (1m avg): {analysisPerf.tracksPerMinute.toFixed(1)}</div>
{analysisPerf.lastTotalMs != null && (
<div className="sidebar-perf-modal__cpu-row">
last track: {(analysisPerf.lastTotalMs / 1000).toFixed(1)}s
{' '}
(fetch {(analysisPerf.lastFetchMs ?? 0) / 1000}s · seed {(analysisPerf.lastSeedMs ?? 0) / 1000}s · bpm {(analysisPerf.lastBpmMs ?? 0) / 1000}s)
</div>
)}
</>
)}
</>
) : (
<div className="sidebar-perf-modal__cpu-row">Unavailable on this platform/build.</div>
@@ -12,7 +12,6 @@ import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh
import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled';
import PlayerStatsHeatmap from './PlayerStatsHeatmap';
import PlayerStatsIndexRequiredNotice from './PlayerStatsIndexRequiredNotice';
import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice';
import PlayerStatsRecentDays from './PlayerStatsRecentDays';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
@@ -99,7 +98,6 @@ export default function PlayerStatisticsPanel() {
return (
<div className="stats-page">
<PlayerStatsPartialIndexNotice />
<div className="player-stats-year-nav">
<button
type="button"
@@ -15,7 +15,7 @@ export default function PlayerStatsIndexRequiredNotice() {
<button
type="button"
className="player-stats-partial-index-link"
onClick={() => navigate('/settings', { state: { tab: 'library' } })}
onClick={() => navigate('/settings', { state: { tab: 'servers' } })}
>
{t('statistics.playerPartialIndexSettings')}
</button>
@@ -1,40 +0,0 @@
import { Info } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
export default function PlayerStatsPartialIndexNotice() {
const { t } = useTranslation();
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const excludedCount = useMemo(
() => servers.filter(s => syncExcludedByServer[s.id] === true).length,
[servers, syncExcludedByServer],
);
if (!masterEnabled || excludedCount === 0 || servers.length <= 1) {
return null;
}
return (
<div className="settings-hint settings-hint-info player-stats-partial-index-notice" role="status">
<Info size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<span>
{t('statistics.playerPartialIndexNotice')}
{' '}
<button
type="button"
className="player-stats-partial-index-link"
onClick={() => navigate('/settings', { state: { tab: 'library' } })}
>
{t('statistics.playerPartialIndexSettings')}
</button>
</span>
</div>
);
}
+2
View File
@@ -128,6 +128,7 @@ const CONTRIBUTOR_ENTRIES = [
'Playback speed: global 0.52.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)',
'Local library index: full resync orphan sweep (IS-7) — remove server-deleted tracks after successful re-sync (PR #861)',
'Track enrichment: oximedia BPM/mood analysis, mood-group Advanced Search, queue display, unified playback analysis dispatch (PR #863)',
'Server index-key rebuild follow-up: startup-safe migration orchestration, per-server analysis strategy controls, playback/cache scope hardening, and backup/restore for library databases with blocking progress UX (PR #864)',
],
},
{
@@ -324,6 +325,7 @@ const CONTRIBUTOR_ENTRIES = [
'Servers: inline edit for existing profiles (PR #780)',
'Interface Scale: scales the entire window — sidebar, queue, player bar, modals and the fullscreen player follow the main content (PR #781)',
'Local library index (preview): SQLite per-server track store, background initial and delta sync, live and Advanced Search against the local index, integrity verify and auto-reconcile on count drop (PR #846)',
'Server index-key rebuild: safe dual-DB migration flow, per-server analysis strategy controls, and playback/index scope hardening (PR #864)',
],
},
] as const;
+40
View File
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { recordAnalysisTrackPerf } from '../utils/perf/analysisPerfStore';
type AnalysisTrackPerfPayload = {
trackId: string;
fetchMs: number;
seedMs: number;
bpmMs: number;
totalMs: number;
};
/** Wire Rust `analysis:track-perf` events into the perf probe store. */
export function useAnalysisPerfListener(active: boolean): void {
useEffect(() => {
if (!active) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
void listen<AnalysisTrackPerfPayload>('analysis:track-perf', ({ payload }) => {
if (cancelled || !payload?.trackId) return;
recordAnalysisTrackPerf({
trackId: payload.trackId,
fetchMs: payload.fetchMs ?? 0,
seedMs: payload.seedMs ?? 0,
bpmMs: payload.bpmMs ?? 0,
totalMs: payload.totalMs ?? 0,
});
}).then(fn => {
if (cancelled) {
fn();
return;
}
unlisten = fn;
}).catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
}, [active]);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useRef } from 'react';
import { buildStreamUrlForServer } from '../api/subsonicStreamUrl';
import {
analysisEnqueueSeedFromUrl,
analysisGetPipelineQueueStats,
analysisSetPipelineParallelism,
libraryAnalysisBackfillBatch,
} from '../api/analysis';
import { useAuthStore } from '../store/authStore';
import { useAnalysisStrategyStore } from '../store/analysisStrategyStore';
import { DEFAULT_ADVANCED_PARALLELISM } from '../utils/library/analysisStrategy';
import {
libraryBackfillNeedsTopUp,
libraryBackfillTopUpLimit,
} from '../utils/library/libraryAnalysisBackfillPolicy';
import { libraryIsReady } from '../utils/library/libraryReady';
const TOP_UP_POLL_MS = 500;
const STEADY_POLL_MS = 2000;
const READY_POLL_MS = 5000;
const EXHAUSTED_PAUSE_MS = 60_000;
const EMPTY_PIPELINE_STATS = {
pipelineWorkers: 1,
httpQueued: 0,
httpQueuedHigh: 0,
httpQueuedMiddle: 0,
httpQueuedLow: 0,
httpDownloadActive: 0,
httpDownloadActiveHigh: 0,
httpDownloadActiveMiddle: 0,
httpDownloadActiveLow: 0,
cpuQueued: 0,
cpuQueuedHigh: 0,
cpuQueuedMiddle: 0,
cpuQueuedLow: 0,
cpuDecodeActive: 0,
cpuDecodeActiveHigh: 0,
cpuDecodeActiveMiddle: 0,
cpuDecodeActiveLow: 0,
};
/**
* Advanced analytics strategy: keep the HTTP backfill backlog above worker count
* (watermark target ≈ workers × 3) so parallel downloads stay busy. Library
* tracks enqueue at low priority; playback tiers stay ahead in Rust.
*/
export function useLibraryAnalysisBackfill(enabled = true): void {
const activeServerId = useAuthStore(s => s.activeServerId);
const strategy = useAnalysisStrategyStore(s => s.getStrategyForServer(activeServerId));
const advancedParallelism = useAnalysisStrategyStore(
s => s.getAdvancedParallelismForServer(activeServerId),
);
const cursorRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return;
const workers =
strategy === 'advanced' ? advancedParallelism : DEFAULT_ADVANCED_PARALLELISM;
void analysisSetPipelineParallelism(workers).catch(() => {});
}, [strategy, advancedParallelism, enabled]);
useEffect(() => {
if (!enabled || strategy !== 'advanced' || !activeServerId) return;
let cancelled = false;
const serverId = activeServerId;
void (async () => {
while (!cancelled) {
if (!(await libraryIsReady(serverId))) {
await new Promise(r => setTimeout(r, READY_POLL_MS));
continue;
}
const workers = advancedParallelism;
const stats = await analysisGetPipelineQueueStats().catch(
() => EMPTY_PIPELINE_STATS,
);
if (!libraryBackfillNeedsTopUp(stats, workers)) {
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
continue;
}
const fetchLimit = libraryBackfillTopUpLimit(stats, workers);
if (fetchLimit <= 0) {
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
continue;
}
const batch = await libraryAnalysisBackfillBatch(
serverId,
cursorRef.current,
fetchLimit,
).catch(() => null);
if (!batch || cancelled) {
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
continue;
}
cursorRef.current = batch.nextCursor;
await Promise.all(
batch.trackIds.map(trackId =>
analysisEnqueueSeedFromUrl(
trackId,
buildStreamUrlForServer(serverId, trackId),
serverId,
'low',
).catch(() => {
/* Rust skips completed tracks */
}),
),
);
if (cancelled) return;
if (batch.exhausted) {
cursorRef.current = null;
await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS));
} else if (batch.trackIds.length === 0) {
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
}
// else: loop immediately if still below watermark
}
})();
return () => {
cancelled = true;
cursorRef.current = null;
};
}, [strategy, activeServerId, advancedParallelism, enabled]);
}
+243
View File
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { showToast } from '../utils/ui/toast';
import { resolveIndexKey, serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
import {
libraryGetStatus,
librarySyncCancel,
subscribeLibrarySyncIdle,
subscribeLibrarySyncProgress,
type SyncStateDto,
} from '../api/library';
import {
bootstrapAllIndexedServers,
bootstrapIndexedServer,
type BindServerResult,
} from '../utils/library/librarySession';
import { enqueueLibrarySync } from '../utils/library/librarySyncQueue';
import { syncIngestDisplayCount } from '../utils/library/libraryReady';
export type LibraryServerConnection = 'online' | 'offline' | 'unknown';
const STATUS_POLL_MS = 3000;
const SYNC_POLL_MS = 2500;
const OFFLINE_RETRY_MS = 60_000;
export function useLibraryIndexSync() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const serverKeyById = useMemo(
() => Object.fromEntries(servers.map(s => [s.id, serverIndexKeyForProfile(s)])),
[servers],
);
const indexedKeys = useMemo(
() => Array.from(new Set(Object.values(serverKeyById))),
[serverKeyById],
);
const indexedServers = useMemo(() => {
const primary = new Map<string, { key: string; server: typeof servers[number] }>();
for (const server of servers) {
const key = serverKeyById[server.id];
if (!primary.has(key)) primary.set(key, { key, server });
}
if (activeServerId) {
const active = servers.find(s => s.id === activeServerId);
if (active) {
const key = serverKeyById[active.id];
if (primary.has(key)) primary.set(key, { key, server: active });
}
}
return Array.from(primary.values());
}, [servers, serverKeyById, activeServerId]);
const [statusByServer, setStatusByServer] = useState<Record<string, SyncStateDto | null>>({});
const [connectionByServer, setConnectionByServer] = useState<Record<string, LibraryServerConnection>>({});
const [progressByServer, setProgressByServer] = useState<Record<string, string | null>>({});
const [busyServerId, setBusyServerId] = useState<string | null>(null);
const [bootstrapping, setBootstrapping] = useState(false);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const ingestCountRef = useRef<Record<string, number>>({});
const syncPhaseRef = useRef<Record<string, string | null>>({});
const applyConnectionResults = useCallback((results: Record<string, BindServerResult>) => {
setConnectionByServer(prev => {
const next = { ...prev };
for (const [id, result] of Object.entries(results)) {
next[id] = result === 'offline' ? 'offline' : result === 'bound' ? 'online' : 'unknown';
}
return next;
});
}, []);
const refreshAllStatuses = useCallback(async () => {
if (!masterEnabled || indexedServers.length === 0) return;
const entries = await Promise.all(
indexedServers.map(async ({ key, server }) => {
try {
const fresh = await libraryGetStatus(key);
syncPhaseRef.current[key] = fresh.syncPhase;
if (fresh.syncPhase === 'initial_sync') {
const next = Math.max(ingestCountRef.current[key] ?? 0, syncIngestDisplayCount(fresh));
ingestCountRef.current[key] = next;
setProgressByServer(p => ({
...p,
[key]: t('settings.libraryIndexProgressIngest', { count: next }),
}));
} else if (fresh.syncPhase === 'ready' || fresh.syncPhase === 'idle') {
ingestCountRef.current[key] = 0;
}
return [key, fresh] as const;
} catch {
return [key, null] as const;
}
}),
);
setStatusByServer(Object.fromEntries(entries));
}, [masterEnabled, indexedServers, t]);
const runBootstrap = useCallback(async () => {
if (!masterEnabled) return;
setBootstrapping(true);
try {
const results = await bootstrapAllIndexedServers();
applyConnectionResults(results);
await refreshAllStatuses();
} finally {
setBootstrapping(false);
}
}, [masterEnabled, applyConnectionResults, refreshAllStatuses]);
const retryOfflineServers = useCallback(async () => {
if (!masterEnabled) return;
const offline = indexedServers.filter(s => connectionByServer[s.key] === 'offline');
if (offline.length === 0) return;
const results: Record<string, BindServerResult> = {};
for (const srv of offline) {
results[srv.key] = await bootstrapIndexedServer(srv.server);
}
applyConnectionResults(results);
void refreshAllStatuses();
}, [masterEnabled, indexedServers, connectionByServer, applyConnectionResults, refreshAllStatuses]);
useEffect(() => {
if (!masterEnabled || indexedKeys.length === 0) return;
void runBootstrap();
}, [masterEnabled, indexedKeys.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!masterEnabled) return;
const poll = () => {
void refreshAllStatuses();
const anyInitial = indexedKeys.some(
key => syncPhaseRef.current[key] === 'initial_sync',
);
pollTimer.current = setTimeout(poll, anyInitial ? SYNC_POLL_MS : STATUS_POLL_MS);
};
poll();
return () => {
if (pollTimer.current) clearTimeout(pollTimer.current);
pollTimer.current = null;
};
}, [masterEnabled, indexedServers, refreshAllStatuses]);
useEffect(() => {
if (!masterEnabled) return;
const retryTimer = setInterval(() => {
void retryOfflineServers();
}, OFFLINE_RETRY_MS);
return () => clearInterval(retryTimer);
}, [masterEnabled, retryOfflineServers]);
useEffect(() => {
if (!masterEnabled) return;
const unsubs: Array<Promise<() => void>> = [
subscribeLibrarySyncProgress(p => {
const key = resolveIndexKey(p.serverId);
if (!indexedKeys.includes(key)) return;
setBusyServerId(key);
if (p.kind === 'ingest_page') {
const next = Math.max(ingestCountRef.current[key] ?? 0, p.ingestedTotal ?? 0);
ingestCountRef.current[key] = next;
setProgressByServer(prev => ({
...prev,
[key]: t('settings.libraryIndexProgressIngest', { count: next }),
}));
} else if (p.kind === 'tombstoned') {
setProgressByServer(prev => ({
...prev,
[key]: t('settings.libraryIndexProgressVerify', {
checked: p.tombstonesChecked ?? 0,
deleted: p.tombstonesDeleted ?? 0,
}),
}));
} else if (p.kind === 'phase_changed' && p.phase) {
setProgressByServer(prev => ({ ...prev, [key]: p.phase ?? null }));
}
}),
subscribeLibrarySyncIdle(p => {
const key = resolveIndexKey(p.serverId);
if (!indexedKeys.includes(key)) return;
setBusyServerId(cur => (cur === key ? null : cur));
ingestCountRef.current[key] = 0;
setProgressByServer(prev => ({ ...prev, [key]: null }));
void refreshAllStatuses();
if (!p.ok && p.error) {
showToast(t('settings.libraryIndexSyncError', { error: p.error }), 5000, 'error');
}
}),
];
return () => {
unsubs.forEach(u => void u.then(fn => fn()));
};
}, [masterEnabled, indexedKeys, refreshAllStatuses, t]);
const runServerAction = useCallback(async (
serverId: string,
action: 'full' | 'delta' | 'verify',
) => {
const key = resolveIndexKey(serverId);
setBusyServerId(key);
try {
const kind =
action === 'verify'
? 'verify'
: action === 'full'
? 'full'
: statusByServer[key]?.lastFullSyncAt
? 'delta'
: 'full';
ingestCountRef.current[key] = 0;
await enqueueLibrarySync({ serverId: key, kind });
} catch (e) {
setBusyServerId(null);
showToast(t('settings.libraryIndexSyncError', { error: String(e) }), 5000, 'error');
}
}, [statusByServer, t]);
const handleCancel = useCallback(async () => {
try {
await librarySyncCancel();
} catch {
/* best-effort */
}
}, []);
const globalBusy = bootstrapping || busyServerId != null;
return {
statusByServer,
connectionByServer,
progressByServer,
busyServerId,
bootstrapping,
globalBusy,
runServerAction,
handleCancel,
};
}
+148
View File
@@ -0,0 +1,148 @@
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '../store/authStore';
import { useMigrationStore } from '../store/migrationStore';
const migrationInspectMock = vi.fn();
const migrationRunMock = vi.fn();
const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined);
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock('../api/migration', () => ({
migrationInspect: (mappings: unknown) => migrationInspectMock(mappings),
migrationRun: (mappings: unknown) => migrationRunMock(mappings),
}));
vi.mock('../utils/server/rewriteFrontendStoreKeys', () => ({
rewriteFrontendStoreKeys: (servers: unknown) => rewriteFrontendStoreKeysMock(servers),
}));
import { useMigrationOrchestrator } from './useMigrationOrchestrator';
const DONE_FLAG = 'psysonic-server-key-migration-v1';
const REAL_MIGRATION_TEST_OVERRIDE = '__PSYSONIC_REAL_MIGRATION_TEST__';
describe('useMigrationOrchestrator', () => {
beforeEach(() => {
migrationInspectMock.mockReset();
migrationRunMock.mockReset();
rewriteFrontendStoreKeysMock.mockClear();
localStorage.clear();
useAuthStore.setState({
servers: [
{ id: 'legacy-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
],
activeServerId: 'legacy-a',
isLoggedIn: true,
});
useMigrationStore.setState({
phase: 'inspecting',
needsMigration: false,
inspect: null,
progress: null,
lastError: null,
});
(globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE] = true;
});
afterEach(() => {
delete (globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE];
});
it('orchestrator_completes_when_no_needsMigration_but_hasSkippedUnknownServerRows', async () => {
migrationInspectMock.mockResolvedValue({
needsMigration: false,
hasSkippedUnknownServerRows: true,
canRun: true,
warnings: ['rows for removed servers were skipped'],
unmappedEmptyBucket: false,
library: { totalLegacyRows: 0, skippedUnknownServerRows: 12, tables: {} },
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 4, tables: {} },
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
});
renderHook(() => useMigrationOrchestrator());
await waitFor(() => {
expect(useMigrationStore.getState().phase).toBe('completed');
});
expect(useMigrationStore.getState().lastError).toBeNull();
});
it('done flag is set when no_needsMigration_and_hasSkippedUnknownServerRows', async () => {
migrationInspectMock.mockResolvedValue({
needsMigration: false,
hasSkippedUnknownServerRows: true,
canRun: true,
warnings: ['rows for removed servers were skipped'],
unmappedEmptyBucket: false,
library: { totalLegacyRows: 0, skippedUnknownServerRows: 7, tables: {} },
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
});
renderHook(() => useMigrationOrchestrator());
await waitFor(() => {
expect(useMigrationStore.getState().phase).toBe('completed');
});
expect(localStorage.getItem(DONE_FLAG)).toBe('1');
});
it('keeps completed phase on startup when done flag exists and no migration is needed', async () => {
localStorage.setItem(DONE_FLAG, '1');
useMigrationStore.setState({ phase: 'completed' });
migrationInspectMock.mockResolvedValue({
needsMigration: false,
hasSkippedUnknownServerRows: false,
canRun: true,
warnings: [],
unmappedEmptyBucket: false,
library: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
});
renderHook(() => useMigrationOrchestrator());
await waitFor(() => {
expect(useMigrationStore.getState().phase).toBe('completed');
});
expect(migrationRunMock).not.toHaveBeenCalled();
expect(rewriteFrontendStoreKeysMock).not.toHaveBeenCalled();
});
it('keeps startup non-blocking while done-flag precheck is pending', async () => {
localStorage.setItem(DONE_FLAG, '1');
let resolveInspect: ((value: any) => void) | undefined;
migrationInspectMock.mockImplementation(
() => new Promise(resolve => { resolveInspect = resolve; }),
);
renderHook(() => useMigrationOrchestrator());
await waitFor(() => {
expect(useMigrationStore.getState().phase).toBe('idle');
});
expect(migrationRunMock).not.toHaveBeenCalled();
if (!resolveInspect) throw new Error('inspect resolver not captured');
resolveInspect({
needsMigration: false,
hasSkippedUnknownServerRows: false,
canRun: true,
warnings: [],
unmappedEmptyBucket: false,
library: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
});
await waitFor(() => {
expect(useMigrationStore.getState().phase).toBe('completed');
});
});
});
+134
View File
@@ -0,0 +1,134 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { migrationInspect, migrationRun, type ServerIndexMapping } from '../api/migration';
import { useAuthStore } from '../store/authStore';
import { useMigrationStore } from '../store/migrationStore';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
import { rewriteFrontendStoreKeys } from '../utils/server/rewriteFrontendStoreKeys';
const MIGRATION_DONE_FLAG = 'psysonic-server-key-migration-v1';
let migrationInFlight: Promise<void> | null = null;
const REAL_MIGRATION_TEST_OVERRIDE = '__PSYSONIC_REAL_MIGRATION_TEST__';
function logSkippedUnknownRowsOnce(
report: Awaited<ReturnType<typeof migrationInspect>>,
alreadyLogged: boolean,
): boolean {
if (!alreadyLogged && report.hasSkippedUnknownServerRows) {
console.warn('[migration] rows for removed servers were skipped');
return true;
}
return alreadyLogged;
}
function buildMappings(): ServerIndexMapping[] {
return useAuthStore.getState().servers
.map(server => ({
legacyId: server.id,
indexKey: serverIndexKeyFromUrl(server.url),
}))
.filter(mapping => mapping.legacyId.trim().length > 0 && mapping.indexKey.trim().length > 0);
}
async function runOrchestrator(force = false): Promise<void> {
if (migrationInFlight) {
await migrationInFlight;
return;
}
migrationInFlight = (async () => {
const state = useMigrationStore.getState();
let skippedLogged = false;
if (import.meta.env.MODE === 'test' && !(globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE]) {
state.setNeedsMigration(false);
state.setPhase('completed');
return;
}
const servers = useAuthStore.getState().servers;
if (servers.length === 0) {
state.setNeedsMigration(false);
state.setPhase('completed');
return;
}
const mappings = buildMappings();
const hasDoneFlag = localStorage.getItem(MIGRATION_DONE_FLAG) === '1';
state.setError(null);
state.setProgress(null);
state.setPhase(force ? 'inspecting' : 'idle');
let inspect = null as Awaited<ReturnType<typeof migrationInspect>> | null;
if (!force && hasDoneFlag) {
inspect = await migrationInspect(mappings);
state.setInspect(inspect);
state.setNeedsMigration(inspect.needsMigration);
skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged);
if (!inspect.needsMigration) {
state.setPhase('completed');
return;
}
}
if (!inspect) {
inspect = await migrationInspect(mappings);
}
state.setInspect(inspect);
state.setNeedsMigration(inspect.needsMigration);
skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged);
if (!inspect.needsMigration) {
await rewriteFrontendStoreKeys(servers);
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
state.setPhase('completed');
return;
}
state.setPhase('inspecting');
state.setPhase('running');
await migrationRun(mappings);
await rewriteFrontendStoreKeys(servers);
state.setPhase('inspecting');
const after = await migrationInspect(mappings);
state.setInspect(after);
state.setNeedsMigration(after.needsMigration);
skippedLogged = logSkippedUnknownRowsOnce(after, skippedLogged);
if (!after.needsMigration) {
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
state.setPhase('completed');
return;
}
state.setError('Migration incomplete. Retry after adding missing server mapping.');
state.setPhase('error');
})()
.catch((error: unknown) => {
useMigrationStore.getState().setError(String(error));
useMigrationStore.getState().setPhase('error');
})
.finally(() => {
migrationInFlight = null;
});
await migrationInFlight;
}
export function retryServerIndexMigration(): void {
void runOrchestrator(true);
}
export function useMigrationOrchestrator(): void {
const servers = useAuthStore(s => s.servers);
useEffect(() => {
let disposed = false;
const sub = listen('migration:progress', (event) => {
if (disposed) return;
useMigrationStore.getState().setProgress(event.payload as {
stage: string;
table: string;
done: number;
total: number;
});
});
return () => {
disposed = true;
void sub.then(unlisten => unlisten());
};
}, []);
useEffect(() => {
void runOrchestrator();
}, [servers]);
}
+2 -2
View File
@@ -5,6 +5,6 @@ import { useLibraryIndexStore } from '../store/libraryIndexStore';
export function usePlayerStatsRecordingEnabled(): boolean {
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
return masterEnabled && servers.some(s => syncExcludedByServer[s.id] !== true);
const indexedServerIds = useLibraryIndexStore(s => s.indexedServerIds);
return masterEnabled && indexedServerIds(servers.map(s => s.id)).length > 0;
}
+4 -1
View File
@@ -10,6 +10,7 @@ import type { ServerProfile } from '../store/authStoreTypes';
import { findServerIdForShareUrl } from '../utils/share/shareLink';
import { shareServerOriginLabel } from '../utils/share/shareServerOriginLabel';
import { parseShareSearchText } from '../utils/share/shareSearch';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
import { useShareSearchPreview } from './useShareSearchPreview';
export function useShareSearch(query: string, onSuccess?: () => void) {
@@ -26,7 +27,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
if (!shareMatch || shareMatch.type === 'unsupported') return null;
const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
if (!serverId || serverId === activeServerId) return null;
return servers.find(s => s.id === serverId) ?? null;
return servers.find(s => s.id === serverId)
?? servers.find(s => serverIndexKeyFromUrl(s.url) === serverId)
?? null;
}, [shareMatch, servers, activeServerId]);
const preview = useShareSearchPreview(shareMatch);
const [shareQueueBusy, setShareQueueBusy] = useState(false);
+42 -1
View File
@@ -1,6 +1,11 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { setPerfProbeTelemetryActive } from '../utils/perf/perfTelemetry';
import {
getAnalysisTracksPerMinute,
useAnalysisPerfLast,
} from '../utils/perf/analysisPerfStore';
import { useAnalysisPerfListener } from './useAnalysisPerfListener';
interface PerfCpu {
app: number;
@@ -14,11 +19,20 @@ interface PerfDiagRates {
home: number;
}
interface AnalysisPerfDiag {
tracksPerMinute: number;
lastTotalMs: number | null;
lastFetchMs: number | null;
lastSeedMs: number | null;
lastBpmMs: number | null;
}
interface Result {
perfProbeOpen: boolean;
setPerfProbeOpen: (open: boolean) => void;
perfCpu: PerfCpu | null;
perfDiagRates: PerfDiagRates | null;
analysisPerf: AnalysisPerfDiag | null;
}
/** Wires up Ctrl+Shift+D to open the perf probe; polls CPU + diag-rate counters
@@ -27,6 +41,10 @@ export function useSidebarPerfProbe(): Result {
const [perfProbeOpen, setPerfProbeOpen] = useState(false);
const [perfCpu, setPerfCpu] = useState<PerfCpu | null>(null);
const [perfDiagRates, setPerfDiagRates] = useState<PerfDiagRates | null>(null);
const [analysisTpm, setAnalysisTpm] = useState(0);
const analysisLast = useAnalysisPerfLast();
useAnalysisPerfListener(perfProbeOpen);
useEffect(() => {
setPerfProbeTelemetryActive(perfProbeOpen);
@@ -111,10 +129,19 @@ export function useSidebarPerfProbe(): Result {
};
}, [perfProbeOpen]);
useEffect(() => {
if (!perfProbeOpen) return;
const refresh = () => setAnalysisTpm(getAnalysisTracksPerMinute());
refresh();
const id = window.setInterval(refresh, 2000);
return () => window.clearInterval(id);
}, [perfProbeOpen, analysisLast?.at]);
useEffect(() => {
if (!perfProbeOpen) {
setPerfCpu(null);
setPerfDiagRates(null);
setAnalysisTpm(0);
}
}, [perfProbeOpen]);
@@ -136,5 +163,19 @@ export function useSidebarPerfProbe(): Result {
return () => window.removeEventListener('keydown', onKey);
}, []);
return { perfProbeOpen, setPerfProbeOpen, perfCpu, perfDiagRates };
return {
perfProbeOpen,
setPerfProbeOpen,
perfCpu,
perfDiagRates,
analysisPerf: perfProbeOpen
? {
tracksPerMinute: analysisTpm,
lastTotalMs: analysisLast?.totalMs ?? null,
lastFetchMs: analysisLast?.fetchMs ?? null,
lastSeedMs: analysisLast?.seedMs ?? null,
lastBpmMs: analysisLast?.bpmMs ?? null,
}
: null,
};
}
+6 -6
View File
@@ -1,5 +1,5 @@
import { buildStreamUrlForServer } from './api/subsonicStreamUrl';
import { getPlaybackServerId } from './utils/playback/playbackServer';
import { getPlaybackCacheServerKey } from './utils/playback/playbackServer';
import type { Track } from './store/playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './store/authStore';
@@ -74,7 +74,7 @@ async function runWorker() {
try {
while (pendingQueue.length > 0) {
const auth = useAuthStore.getState();
const playbackSid = getPlaybackServerId();
const playbackSid = getPlaybackCacheServerKey();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) {
hotCacheFrontendDebug({
event: 'prefetch-worker-stop',
@@ -175,7 +175,7 @@ async function runWorker() {
fresh.queue,
fresh.queueIndex,
maxAfter,
getPlaybackServerId(),
getPlaybackCacheServerKey(),
authAfter.hotCacheDownloadDir || null,
);
} catch (e: unknown) {
@@ -190,7 +190,7 @@ async function runWorker() {
function scheduleReplan() {
const auth = useAuthStore.getState();
const playbackSid = getPlaybackServerId();
const playbackSid = getPlaybackCacheServerKey();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) {
if (debounceTimer) {
clearTimeout(debounceTimer);
@@ -209,7 +209,7 @@ function scheduleReplan() {
async function replanNow() {
const auth = useAuthStore.getState();
const playbackSid = getPlaybackServerId();
const playbackSid = getPlaybackCacheServerKey();
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) return;
const serverId = playbackSid;
@@ -290,7 +290,7 @@ export function initHotCachePrefetch(): () => void {
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
const left = (prevQ as Track[])[prevIdx];
const a = useAuthStore.getState();
const graceSid = getPlaybackServerId();
const graceSid = getPlaybackCacheServerKey();
if (left && graceSid) {
bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec);
scheduleEvictAfterPreviousGrace();
+23 -3
View File
@@ -1,5 +1,10 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { collectPlaybackMiddlePriorityTrackIds } from '../store/loudnessBackfillWindow';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { analysisSetPlaybackPriorityHints } from '../api/analysis';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
import { hotCacheFrontendDebug } from './helpers';
let analysisPruneTimer: ReturnType<typeof setTimeout> | null = null;
@@ -14,7 +19,11 @@ type AnalysisPrunePendingResult = {
};
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
const { queue, currentTrack } = usePlayerStore.getState();
const { queue, currentTrack, queueIndex } = usePlayerStore.getState();
const { preloadMode } = useAuthStore.getState();
const rawServerId = getPlaybackServerId() ?? '';
const server = useAuthStore.getState().servers.find(s => s.id === rawServerId);
const serverId = server ? serverIndexKeyFromUrl(server.url) : rawServerId;
const keepTrackIds: string[] = [];
const seen = new Set<string>();
const pushId = (id: string | undefined | null) => {
@@ -29,7 +38,13 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
pushId(track.id);
if (keepTrackIds.length >= 1000) break;
}
const sig = JSON.stringify(keepTrackIds);
const middleTrackIds = collectPlaybackMiddlePriorityTrackIds(
queue,
queueIndex,
currentTrack,
preloadMode,
);
const sig = JSON.stringify({ keepTrackIds, middleTrackIds, serverId });
if (sig === lastAnalysisPruneSig) return;
lastAnalysisPruneSig = sig;
if (analysisPruneTimer) {
@@ -38,7 +53,12 @@ export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
}
analysisPruneTimer = setTimeout(() => {
analysisPruneTimer = null;
void invoke<AnalysisPrunePendingResult>('analysis_prune_pending_to_track_ids', { trackIds: keepTrackIds })
const middleTrackRefs = middleTrackIds.map(trackId => ({ serverId, trackId }));
void analysisSetPlaybackPriorityHints(middleTrackRefs).catch(() => {});
void invoke<AnalysisPrunePendingResult>('analysis_prune_pending_to_track_ids', {
trackIds: keepTrackIds,
serverId,
})
.then(result => {
if (!result) return;
hotCacheFrontendDebug({
+50
View File
@@ -283,6 +283,33 @@ export const settings = {
libraryIndexAutoReconcileDesc: 'Automatically check for removed tracks when the server reports fewer than expected.',
libraryIndexSyncError: 'Library sync failed: {{error}}',
libraryIndexBindError: 'Could not enable index: {{error}}',
analyticsStrategyTitle: 'Analytics strategies',
analyticsStrategyDesc: 'Choose the analysis style for the library.',
analyticsStrategyLabel: 'Strategy',
analyticsStrategyLazy: 'Lazy',
analyticsStrategyLazyDesc: 'Analyze only when tracks play or enter the hot/offline cache — minimal CPU and network use.',
analyticsStrategyAdvanced: 'Aggressive',
analyticsStrategyAdvancedDesc: 'More aggressive background scan. Once finished, you get BPM for every track plus instant loudness + waveform loads.',
analyticsStrategyPriorityTitle: 'Analysis priority (always enforced)',
analyticsStrategyPriorityHigh: '1 — Currently playing: download + analyze first (playback bytes, loudness backfill, or HTTP when needed).',
analyticsStrategyPriorityMiddle: '2 — Up next: the next ~5 queue tracks, preload next, and hot-cache neighbours — analyze from cached/downloaded bytes when possible; HTTP backfill only on miss.',
analyticsStrategyPriorityLow: '3 — Library batch (Aggressive only): automatic background queue at the tail; refilled by watermark (~3× workers) without waiting for an empty queue.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Analysis progress:',
analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})',
analyticsStrategyActionsLabel: 'Actions',
analyticsStrategyParallelismLabel: 'Parallel workers',
analyticsStrategyParallelismValue: '{{n}} threads',
analyticsStrategyParallelismDesc: 'How many tracks to download and analyze at once (HTTP + CPU). Higher values speed up library catch-up but use more network and CPU.',
analyticsStrategyAdvancedWarning: 'Aggressive uses more CPU and network bandwidth while the library batch runs in the background.',
analyticsStrategyServerRemoved: 'Removed server',
analyticsStrategyClearAction: 'Clear analysis',
analyticsStrategyClearTitle: 'Clear analysis?',
analyticsStrategyClearDesc: 'Clearing analysis for {{server}} removes cached waveforms and loudness. Re-adding this server later may require long re-indexing.',
analyticsStrategyClearCancel: 'Cancel',
analyticsStrategyClearConfirm: 'Clear analysis',
analyticsStrategyClearSuccess: 'Analysis cleared for the removed server.',
analyticsStrategyClearError: 'Failed to clear analysis for the removed server.',
randomMixTitle: 'Random Mix Blacklist',
luckyMixMenuTitle: 'Show Lucky Mix in menu',
luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.',
@@ -383,9 +410,32 @@ export const settings = {
backupImport: 'Import settings',
backupImportDesc: 'Restores settings from a .psybkp file. The app will reload after import.',
backupImportConfirm: 'This will overwrite all current settings. Continue?',
backupImportAny: 'Import backup',
backupImportAnyConfirm: 'Import selected backup file and apply its contents (settings, library databases, or full). Continue?',
backupSuccess: 'Backup saved',
backupImportSuccess: 'Settings restored — reloading…',
backupImportError: 'Invalid or corrupted backup file.',
backupModeFull: 'Full',
backupModeLibrary: 'Library databases',
backupModeConfig: 'Config',
backupFullDesc: 'Archives settings and library databases into one backup file.',
backupFullExport: 'Export full backup',
backupFullImport: 'Import full backup',
backupFullImportConfirm: 'This will overwrite settings, replace current library databases, and move them to .bak. Continue?',
backupFullExportSuccess: 'Full backup exported.',
backupFullImportSuccess: 'Full backup imported — reloading…',
backupFullImportError: 'Could not import full backup.',
backupLibraryExport: 'Export databases',
backupLibraryExportDesc: 'Creates a compressed archive with clean SQLite snapshots of library databases.',
backupLibraryImport: 'Import databases',
backupLibraryImportDesc: 'Restores library databases from an archive file. Current databases are moved to .bak before switch.',
backupLibraryImportConfirm: 'This will replace current library databases and move them to .bak. Continue?',
backupLibraryExportSuccess: 'Databases backup exported.',
backupLibraryImportSuccess: 'Databases backup imported.',
backupLibraryImportError: 'Could not import databases backup.',
backupOverlayExportTitle: 'Creating backup…',
backupOverlayImportTitle: 'Restoring backup…',
backupOverlayHint: 'You can keep the window open while we process files. Input is temporarily locked.',
shortcutsReset: 'Reset to defaults',
shortcutListening: 'Press a key…',
shortcutUnbound: '—',
+3 -4
View File
@@ -64,7 +64,7 @@ export const statistics = {
exportSaveFailed: 'Could not save the image.',
tabServer: 'Server stats',
tabPlayer: 'Player stats',
playerEmpty: 'Start listening — your local play history will appear here once the library index is enabled.',
playerEmpty: 'Start listening — your local play history will appear here.',
playerSummaryTime: 'Listening time',
playerListeningDayShort: '{{count}}d',
playerListeningHourShort: '{{count}}h',
@@ -73,9 +73,8 @@ export const statistics = {
playerSummaryTracks: 'Track plays',
playerSummaryUniqueTracks: 'Unique tracks',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Some servers are excluded from the local library index. Listening on those servers is not recorded in player statistics.',
playerIndexRequired: 'Player statistics are not available until you enable the local library index.',
playerPartialIndexSettings: 'Library settings',
playerIndexRequired: 'Player statistics require at least one configured server.',
playerPartialIndexSettings: 'Server settings',
playerSummaryCompletion: 'Full / partial',
playerYearPrev: 'Previous year',
playerYearNext: 'Next year',
+54
View File
@@ -288,6 +288,33 @@ export const settings = {
libraryIndexAutoReconcileDesc: 'Автоматически искать удалённые треки, когда на сервере их меньше ожидаемого.',
libraryIndexSyncError: 'Ошибка синхронизации: {{error}}',
libraryIndexBindError: 'Не удалось включить индекс: {{error}}',
analyticsStrategyTitle: 'Стратегии аналитики',
analyticsStrategyDesc: 'Выберите стиль анализа для библиотеки.',
analyticsStrategyLabel: 'Стратегия',
analyticsStrategyLazy: 'Ленивая',
analyticsStrategyLazyDesc: 'Анализ только при проигрывании или попадании в горячий/офлайн-кэш — минимальная нагрузка на CPU и сеть.',
analyticsStrategyAdvanced: 'Агрессивный',
analyticsStrategyAdvancedDesc: 'Более агрессивный фоновый прогон. После завершения — BPM для всех треков и мгновенная загрузка loudness + waveform.',
analyticsStrategyPriorityTitle: 'Приоритет анализа (всегда)',
analyticsStrategyPriorityHigh: '1 — Сейчас играет: скачивание и анализ в первую очередь (байты воспроизведения, loudness backfill или HTTP при необходимости).',
analyticsStrategyPriorityMiddle: '2 — Следующие: ~5 треков в очереди, preload next и соседи hot cache — анализ из уже скачанных байтов, HTTP backfill только при промахе.',
analyticsStrategyPriorityLow: '3 — Библиотечный batch (только Агрессивный): автоматическая фоновая очередь в хвосте; пополняется по watermark (~3× потоков), не дожидаясь полного опустошения.',
analyticsStrategyServerLabel: 'Сервер',
analyticsStrategyProgressLabel: 'Прогресс анализа:',
analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})',
analyticsStrategyActionsLabel: 'Действия',
analyticsStrategyParallelismLabel: 'Параллельные потоки',
analyticsStrategyParallelismValue: '{{n}} потоков',
analyticsStrategyParallelismDesc: 'Сколько треков одновременно скачивать и анализировать (HTTP + CPU). Больше значение — быстрее догоняет библиотеку, но выше нагрузка на сеть и CPU.',
analyticsStrategyAdvancedWarning: 'Агрессивный режим даёт большую нагрузку на CPU и сеть, пока идёт фоновый прогон библиотеки.',
analyticsStrategyServerRemoved: 'Удалённый сервер',
analyticsStrategyClearAction: 'Очистить анализ',
analyticsStrategyClearTitle: 'Очистить анализ?',
analyticsStrategyClearDesc: 'Очистка анализа для {{server}} удалит кэш волн и loudness. При повторном добавлении сервера индексация может занять много времени.',
analyticsStrategyClearCancel: 'Отмена',
analyticsStrategyClearConfirm: 'Очистить анализ',
analyticsStrategyClearSuccess: 'Анализ удалён для удалённого сервера.',
analyticsStrategyClearError: 'Не удалось очистить анализ для удалённого сервера.',
randomMixTitle: 'Чёрный список случайного микса',
luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню',
luckyMixMenuDesc:
@@ -395,9 +422,36 @@ export const settings = {
backupImport: 'Импорт настроек',
backupImportDesc: 'Восстановить из .psybkp. Приложение перезапустится.',
backupImportConfirm: 'Текущие настройки будут заменены. Продолжить?',
backupImportAny: 'Импорт резервной копии',
backupImportAnyConfirm:
'Импортировать выбранный файл резервной копии и применить его содержимое (настройки, базы библиотеки или полный архив). Продолжить?',
backupSuccess: 'Копия сохранена',
backupImportSuccess: 'Настройки восстановлены — перезапуск…',
backupImportError: 'Файл повреждён или не подходит.',
backupModeFull: 'Полный',
backupModeLibrary: 'Базы библиотеки',
backupModeConfig: 'Настройки',
backupFullDesc: 'Архивирует настройки и базы библиотеки в один файл резервной копии.',
backupFullExport: 'Экспорт полного архива',
backupFullImport: 'Импорт полного архива',
backupFullImportConfirm:
'Настройки будут перезаписаны, текущие базы библиотеки будут заменены и сохранены в .bak. Продолжить?',
backupFullExportSuccess: 'Полный архив экспортирован.',
backupFullImportSuccess: 'Полный архив импортирован — перезапуск…',
backupFullImportError: 'Не удалось импортировать полный архив.',
backupLibraryExport: 'Экспорт баз',
backupLibraryExportDesc: 'Создаёт сжатый архив с чистыми SQLite-снимками баз библиотеки.',
backupLibraryImport: 'Импорт баз',
backupLibraryImportDesc:
'Восстанавливает базы библиотеки из архива. Текущие базы перед заменой переносятся в .bak.',
backupLibraryImportConfirm:
'Текущие базы библиотеки будут заменены и сохранены в .bak. Продолжить?',
backupLibraryExportSuccess: 'Копия баз экспортирована.',
backupLibraryImportSuccess: 'Копия баз импортирована.',
backupLibraryImportError: 'Не удалось импортировать копию баз.',
backupOverlayExportTitle: 'Создаём резервную копию…',
backupOverlayImportTitle: 'Восстанавливаем резервную копию…',
backupOverlayHint: 'Окно можно не закрывать, идёт обработка файлов. Ввод временно заблокирован.',
shortcutsReset: 'Сбросить',
shortcutListening: 'Нажмите клавишу…',
shortcutUnbound: '—',
+4 -4
View File
@@ -57,7 +57,7 @@ export const statistics = {
noRatedArtists: 'Нет оценённых исполнителей.',
tabServer: 'Статистика сервера',
tabPlayer: 'Статистика плеера',
playerEmpty: 'Начните слушать — локальная история появится здесь при включённом индексе библиотеки.',
playerEmpty: 'Начните слушать — локальная история появится здесь.',
playerSummaryTime: 'Время прослушивания',
playerListeningDayShort: '{{count}}д',
playerListeningHourShort: '{{count}}ч',
@@ -65,10 +65,10 @@ export const statistics = {
playerSummarySessions: 'Сессии',
playerSummaryTracks: 'Треки',
playerSummaryUniqueTracks: 'Уникальные треки',
playerSummaryDays: 'Days',
playerSummaryDays: 'Дни',
playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.',
playerIndexRequired: 'Статистика плеера недоступна, пока не включён локальный индекс библиотеки.',
playerPartialIndexSettings: 'Настройки библиотеки',
playerIndexRequired: 'Статистика плеера требует хотя бы одного настроенного сервера.',
playerPartialIndexSettings: 'Настройки серверов',
playerSummaryCompletion: 'Полные / частичные',
playerYearPrev: 'Предыдущий год',
playerYearNext: 'Следующий год',
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAnalysisStrategyStore } from './analysisStrategyStore';
import {
DEFAULT_ADVANCED_PARALLELISM,
DEFAULT_ANALYTICS_STRATEGY,
} from '../utils/library/analysisStrategy';
describe('analysisStrategyStore', () => {
beforeEach(() => {
useAnalysisStrategyStore.setState({
strategy: DEFAULT_ANALYTICS_STRATEGY,
advancedParallelism: DEFAULT_ADVANCED_PARALLELISM,
strategyByServer: {},
advancedParallelismByServer: {},
});
});
it('defaults to lazy', () => {
expect(useAnalysisStrategyStore.getState().strategy).toBe('lazy');
});
it('defaults advanced parallelism to 1', () => {
expect(useAnalysisStrategyStore.getState().advancedParallelism).toBe(1);
});
it('persists strategy changes in memory', () => {
useAnalysisStrategyStore.getState().setStrategy('advanced');
expect(useAnalysisStrategyStore.getState().strategy).toBe('advanced');
});
it('clamps advanced parallelism to 120', () => {
useAnalysisStrategyStore.getState().setAdvancedParallelism(99);
expect(useAnalysisStrategyStore.getState().advancedParallelism).toBe(20);
useAnalysisStrategyStore.getState().setAdvancedParallelism(0);
expect(useAnalysisStrategyStore.getState().advancedParallelism).toBe(1);
});
it('tracks per-server strategy overrides', () => {
const store = useAnalysisStrategyStore.getState();
store.setServerStrategy('s1', 'advanced');
expect(store.getStrategyForServer('s1')).toBe('advanced');
expect(store.getStrategyForServer('s2')).toBe(DEFAULT_ANALYTICS_STRATEGY);
});
it('tracks per-server parallelism overrides', () => {
const store = useAnalysisStrategyStore.getState();
store.setServerAdvancedParallelism('s1', 8);
expect(store.getAdvancedParallelismForServer('s1')).toBe(8);
expect(store.getAdvancedParallelismForServer('s2')).toBe(DEFAULT_ADVANCED_PARALLELISM);
});
it('clears per-server overrides', () => {
const store = useAnalysisStrategyStore.getState();
store.setServerStrategy('s1', 'advanced');
store.setServerAdvancedParallelism('s1', 6);
store.clearServerOverrides('s1');
expect(store.getStrategyForServer('s1')).toBe(DEFAULT_ANALYTICS_STRATEGY);
expect(store.getAdvancedParallelismForServer('s1')).toBe(DEFAULT_ADVANCED_PARALLELISM);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import {
clampAdvancedParallelism,
DEFAULT_ADVANCED_PARALLELISM,
DEFAULT_ANALYTICS_STRATEGY,
type AnalyticsStrategy,
} from '../utils/library/analysisStrategy';
import { useAuthStore } from './authStore';
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
import type { ServerProfile } from './authStoreTypes';
const resolveStrategyKey = (serverId: string): string => {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (!server) return serverId;
return serverIndexKeyFromUrl(server.url) || serverId;
};
interface AnalysisStrategyState {
strategy: AnalyticsStrategy;
advancedParallelism: number;
strategyByServer: Record<string, AnalyticsStrategy | undefined>;
advancedParallelismByServer: Record<string, number | undefined>;
setStrategy: (strategy: AnalyticsStrategy) => void;
setAdvancedParallelism: (workers: number) => void;
setServerStrategy: (serverId: string, strategy: AnalyticsStrategy) => void;
setServerAdvancedParallelism: (serverId: string, workers: number) => void;
clearServerOverrides: (serverId: string) => void;
migrateServerOverrides: (servers: ServerProfile[]) => void;
getStrategyForServer: (serverId: string | null | undefined) => AnalyticsStrategy;
getAdvancedParallelismForServer: (serverId: string | null | undefined) => number;
}
export const useAnalysisStrategyStore = create<AnalysisStrategyState>()(
persist(
(set, get) => ({
strategy: DEFAULT_ANALYTICS_STRATEGY,
advancedParallelism: DEFAULT_ADVANCED_PARALLELISM,
strategyByServer: {},
advancedParallelismByServer: {},
setStrategy: strategy => set({ strategy }),
setAdvancedParallelism: workers =>
set({ advancedParallelism: clampAdvancedParallelism(workers) }),
setServerStrategy: (serverId, strategy) =>
set(s => ({
strategyByServer: { ...s.strategyByServer, [resolveStrategyKey(serverId)]: strategy },
})),
setServerAdvancedParallelism: (serverId, workers) =>
set(s => ({
advancedParallelismByServer: {
...s.advancedParallelismByServer,
[resolveStrategyKey(serverId)]: clampAdvancedParallelism(workers),
},
})),
clearServerOverrides: (serverId) =>
set(s => {
const key = resolveStrategyKey(serverId);
const { [serverId]: _, [key]: __, ...strategyByServer } = s.strategyByServer;
const { [serverId]: ___, [key]: ____, ...advancedParallelismByServer } = s.advancedParallelismByServer;
return { strategyByServer, advancedParallelismByServer };
}),
migrateServerOverrides: (servers) =>
set(s => {
if (servers.length === 0) return {};
let changed = false;
const strategyByServer = { ...s.strategyByServer };
const advancedParallelismByServer = { ...s.advancedParallelismByServer };
for (const server of servers) {
const key = serverIndexKeyFromUrl(server.url) || server.id;
if (key === server.id) continue;
const legacyStrategy = strategyByServer[server.id];
const nextStrategy = strategyByServer[key];
if (legacyStrategy !== undefined && nextStrategy !== undefined) {
delete strategyByServer[server.id];
changed = true;
} else if (legacyStrategy !== undefined && nextStrategy === undefined) {
strategyByServer[key] = legacyStrategy;
delete strategyByServer[server.id];
changed = true;
}
const legacyParallel = advancedParallelismByServer[server.id];
const nextParallel = advancedParallelismByServer[key];
if (legacyParallel !== undefined && nextParallel !== undefined) {
delete advancedParallelismByServer[server.id];
changed = true;
} else if (legacyParallel !== undefined && nextParallel === undefined) {
advancedParallelismByServer[key] = legacyParallel;
delete advancedParallelismByServer[server.id];
changed = true;
}
}
return changed ? { strategyByServer, advancedParallelismByServer } : {};
}),
getStrategyForServer: serverId => {
if (!serverId) return DEFAULT_ANALYTICS_STRATEGY;
const key = resolveStrategyKey(serverId);
return get().strategyByServer[key] ?? get().strategyByServer[serverId] ?? get().strategy;
},
getAdvancedParallelismForServer: serverId => {
if (!serverId) return DEFAULT_ADVANCED_PARALLELISM;
const key = resolveStrategyKey(serverId);
return get().advancedParallelismByServer[key]
?? get().advancedParallelismByServer[serverId]
?? get().advancedParallelism;
},
}),
{
name: 'psysonic-analytics-strategy',
storage: createJSONStorage(() => localStorage),
version: 1,
migrate: (persisted, version) => {
const fallback = {
strategy: DEFAULT_ANALYTICS_STRATEGY,
advancedParallelism: DEFAULT_ADVANCED_PARALLELISM,
strategyByServer: {} as Record<string, AnalyticsStrategy | undefined>,
advancedParallelismByServer: {} as Record<string, number | undefined>,
};
if (version < 1) {
const old = persisted as {
strategy?: AnalyticsStrategy;
advancedParallelism?: number;
};
return {
strategy: old.strategy ?? fallback.strategy,
advancedParallelism: clampAdvancedParallelism(old.advancedParallelism ?? fallback.advancedParallelism),
strategyByServer: fallback.strategyByServer,
advancedParallelismByServer: fallback.advancedParallelismByServer,
};
}
const current = persisted as Partial<typeof fallback>;
return {
strategy: current.strategy ?? fallback.strategy,
advancedParallelism: clampAdvancedParallelism(current.advancedParallelism ?? fallback.advancedParallelism),
strategyByServer: current.strategyByServer ?? fallback.strategyByServer,
advancedParallelismByServer: current.advancedParallelismByServer ?? fallback.advancedParallelismByServer,
};
},
partialize: s => ({
strategy: s.strategy,
advancedParallelism: s.advancedParallelism,
strategyByServer: s.strategyByServer,
advancedParallelismByServer: s.advancedParallelismByServer,
}),
},
),
);
+12 -7
View File
@@ -12,7 +12,11 @@ import {
} from './playListenSession';
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { showToast } from '../utils/ui/toast';
@@ -266,7 +270,8 @@ export function handleAudioProgress(
const shouldBytePreloadForGaplessBackup =
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
const serverId = getPlaybackServerId();
const serverId = getPlaybackCacheServerKey();
const analysisServerId = getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
const nextIsLocalFile = nextUrl.startsWith('psysonic-local://');
@@ -295,7 +300,7 @@ export function handleAudioProgress(
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: serverId || null,
serverId: analysisServerId || null,
}).catch(() => {});
}
@@ -328,7 +333,7 @@ export function handleAudioProgress(
fallbackDb: authState.replayGainFallbackDb,
hiResEnabled: authState.enableHiRes,
analysisTrackId: nextTrack.id,
serverId: serverId || null,
serverId: analysisServerId || null,
}).catch(() => {});
}
}
@@ -363,7 +368,7 @@ export function handleAudioEnded(): void {
void (async () => {
if (repeatMode === 'one' && currentTrack) {
const authState = useAuthStore.getState();
const repeatPromoteSid = getPlaybackServerId();
const repeatPromoteSid = getPlaybackCacheServerKey();
if (authState.hotCacheEnabled && repeatPromoteSid) {
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
@@ -416,7 +421,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
void playListenSessionOnTrackSwitched(nextTrack);
const switchServerId = getPlaybackServerId();
const switchServerId = getPlaybackCacheServerKey();
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
@@ -459,7 +464,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
});
}
syncQueueToServer(queue, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, getPlaybackServerId());
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
}
export function handleAudioError(message: string): void {
+10 -68
View File
@@ -1,89 +1,31 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type PersistedV0 = {
indexEnabledByServer?: Record<string, boolean>;
autoReconcileEnabled?: boolean;
};
/**
* Settings for the local library index (spec §7.3).
* Master toggle indexes all configured servers; per-server exclusion opt-out.
* Index is always on for configured servers; sync controls live under Settings Servers.
*/
interface LibraryIndexState {
/** Always true (kept for persisted-state migration and existing call sites). */
masterEnabled: boolean;
/** `serverId → true` excludes that server while master is on. */
syncExcludedByServer: Record<string, boolean>;
autoReconcileEnabled: boolean;
setMasterEnabled: (enabled: boolean) => void;
/** Legacy API — enables master and clears exclusion, or excludes one server. */
setIndexEnabled: (serverId: string, enabled: boolean) => void;
setServerSyncExcluded: (serverId: string, excluded: boolean) => void;
setAutoReconcileEnabled: (enabled: boolean) => void;
isIndexEnabled: (serverId: string | null | undefined) => boolean;
indexedServerIds: (allServerIds: string[]) => string[];
}
export const useLibraryIndexStore = create<LibraryIndexState>()(
persist(
(set, get) => ({
masterEnabled: false,
syncExcludedByServer: {},
autoReconcileEnabled: true,
setMasterEnabled: enabled => set({ masterEnabled: enabled }),
setIndexEnabled: (serverId, enabled) => {
if (enabled) {
set(s => {
const { [serverId]: _omit, ...syncExcludedByServer } = s.syncExcludedByServer;
return { masterEnabled: true, syncExcludedByServer };
});
} else {
set(s => ({
syncExcludedByServer: { ...s.syncExcludedByServer, [serverId]: true },
}));
}
},
setServerSyncExcluded: (serverId, excluded) => {
if (excluded) {
set(s => ({
syncExcludedByServer: { ...s.syncExcludedByServer, [serverId]: true },
}));
} else {
set(s => {
const { [serverId]: _omit, ...syncExcludedByServer } = s.syncExcludedByServer;
return { syncExcludedByServer };
});
}
},
setAutoReconcileEnabled: enabled => set({ autoReconcileEnabled: enabled }),
isIndexEnabled: serverId => {
if (!serverId || !get().masterEnabled) return false;
return get().syncExcludedByServer[serverId] !== true;
},
indexedServerIds: allServerIds => {
if (!get().masterEnabled) return [];
return allServerIds.filter(id => get().syncExcludedByServer[id] !== true);
},
(_set, get) => ({
masterEnabled: true,
isIndexEnabled: serverId => !!serverId && get().masterEnabled,
indexedServerIds: allServerIds => (get().masterEnabled ? allServerIds : []),
}),
{
name: 'psysonic-library-index',
version: 1,
version: 2,
storage: createJSONStorage(() => localStorage),
migrate: (persisted, version) => {
if (version < 1) {
const old = persisted as PersistedV0;
const masterEnabled = Object.values(old.indexEnabledByServer ?? {}).some(v => v === true);
return {
masterEnabled,
syncExcludedByServer: {},
autoReconcileEnabled: old.autoReconcileEnabled ?? true,
};
}
return persisted as {
masterEnabled: boolean;
syncExcludedByServer: Record<string, boolean>;
autoReconcileEnabled: boolean;
};
migrate: (persisted, _version) => {
const previous = persisted as { masterEnabled?: boolean } | undefined;
return { masterEnabled: previous?.masterEnabled ?? true };
},
},
),
+39
View File
@@ -45,3 +45,42 @@ export function collectLoudnessBackfillWindowTrackIds(
}
return Array.from(ids);
}
/** Next ~5 queue neighbours (+ immediate next when preload is on) for middle-tier analysis. */
export function collectPlaybackMiddlePriorityTrackIds(
queue: Track[],
queueIndex: number,
currentTrack: Track | null,
preloadMode: 'off' | 'balanced' | 'early' | 'custom',
): string[] {
const ids = new Set<string>();
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
const tid = queue[i]?.id;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
if (preloadMode !== 'off') {
const nextIdx = queueIndex + 1;
if (nextIdx >= 0 && nextIdx < queue.length) {
const tid = queue[nextIdx]?.id;
if (tid && tid !== currentTrack?.id) ids.add(tid);
}
}
return Array.from(ids);
}
export function loudnessBackfillPriorityForTrack(
trackId: string,
queue: Track[],
queueIndex: number,
currentTrack: Track | null,
): 'high' | 'middle' | 'low' {
if (currentTrack?.id === trackId) return 'high';
const start = Math.max(0, queueIndex + 1);
const end = Math.min(queue.length, start + LOUDNESS_BACKFILL_WINDOW_AHEAD);
for (let i = start; i < end; i++) {
if (queue[i]?.id === trackId) return 'middle';
}
return 'low';
}
+1
View File
@@ -63,6 +63,7 @@ vi.mock('./loudnessBackfillState', () => ({
vi.mock('./loudnessBackfillWindow', () => ({
LOUDNESS_BACKFILL_WINDOW_AHEAD: 5,
isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock,
loudnessBackfillPriorityForTrack: vi.fn(() => 'middle'),
}));
import {
+11 -3
View File
@@ -1,6 +1,6 @@
import { buildStreamUrl } from '../api/subsonicStreamUrl';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackIndexKey } from '../utils/playback/playbackServer';
import { redactSubsonicUrlForLog } from '../utils/server/redactSubsonicUrl';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
@@ -20,6 +20,7 @@ import {
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow,
loudnessBackfillPriorityForTrack,
} from './loudnessBackfillWindow';
/** Subsonic-server loudness-cache row as Rust hands it back. */
@@ -70,7 +71,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
const serverId = getPlaybackServerId() || null;
const serverId = getPlaybackIndexKey() || null;
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: requestedTarget,
@@ -100,12 +101,19 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
}
markBackfillInFlight(trackId, attempts + 1);
const url = buildStreamUrl(trackId);
const priority = loudnessBackfillPriorityForTrack(
trackId,
live.queue,
live.queueIndex,
live.currentTrack,
);
emitNormalizationDebug('backfill:enqueue', {
trackId,
url: redactSubsonicUrlForLog(url),
attempt: attempts + 1,
priority,
});
void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId })
void invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId, priority })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { buildStreamUrl } from '../api/subsonicStreamUrl';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackIndexKey } from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
@@ -43,7 +43,7 @@ export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
normalizationTargetLufs: auth.loudnessTargetLufs,
normalizationEngineLive: 'loudness',
});
const serverId = getPlaybackServerId() || null;
const serverId = getPlaybackIndexKey() || null;
try {
await invoke('analysis_delete_waveform_for_track', { trackId, serverId });
} catch (e) {
+30
View File
@@ -0,0 +1,30 @@
import { create } from 'zustand';
import type { MigrationInspectReport, MigrationProgressEvent } from '../api/migration';
export type MigrationPhase = 'idle' | 'inspecting' | 'running' | 'completed' | 'error';
interface MigrationState {
phase: MigrationPhase;
needsMigration: boolean;
inspect: MigrationInspectReport | null;
progress: MigrationProgressEvent | null;
lastError: string | null;
setPhase: (phase: MigrationPhase) => void;
setNeedsMigration: (needsMigration: boolean) => void;
setInspect: (report: MigrationInspectReport | null) => void;
setProgress: (event: MigrationProgressEvent | null) => void;
setError: (error: string | null) => void;
}
export const useMigrationStore = create<MigrationState>(set => ({
phase: 'idle',
needsMigration: false,
inspect: null,
progress: null,
lastError: null,
setPhase: phase => set({ phase }),
setNeedsMigration: needsMigration => set({ needsMigration }),
setInspect: inspect => set({ inspect }),
setProgress: progress => set({ progress }),
setError: lastError => set({ lastError }),
}));
-1
View File
@@ -37,7 +37,6 @@ describe('playListenSession', () => {
_resetPlayListenSessionForTest();
useLibraryIndexStore.setState({
masterEnabled: true,
syncExcludedByServer: {},
});
usePlayerStore.setState({
currentRadio: null,
+10 -7
View File
@@ -6,6 +6,8 @@ import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import {
bindQueueServerForPlayback,
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
shouldBindQueueServerForPlay,
} from '../utils/playback/playbackServer';
@@ -218,19 +220,20 @@ export function runPlayTrack(
prevTrack
&& sameQueueTrackId(prevTrack.id, track.id)
&& authState.hotCacheEnabled
&& getPlaybackServerId(),
&& getPlaybackCacheServerKey(),
);
const runPlayTrackBody = () => {
const authStateNow = useAuthStore.getState();
const playbackSid = getPlaybackServerId();
const url = resolvePlaybackUrl(track.id, playbackSid);
const playbackCacheSid = getPlaybackCacheServerKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === track.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
track.id,
playbackSid,
playbackCacheSid,
url,
);
if (import.meta.env.DEV) {
@@ -270,7 +273,7 @@ export function runPlayTrack(
&& !sameQueueTrackId(prevTrack.id, track.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = getPlaybackServerId();
const prevPromoteSid = getPlaybackCacheServerKey();
if (prevPromoteSid) {
void promoteCompletedStreamToHotCache(
prevTrack,
@@ -301,7 +304,7 @@ export function runPlayTrack(
manual,
hiResEnabled: authStateNow.enableHiRes,
analysisTrackId: track.id,
serverId: getPlaybackServerId() || null,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
@@ -354,10 +357,10 @@ export function runPlayTrack(
});
}
syncQueueToServer(newQueue, track, initialTime);
touchHotCacheOnPlayback(track.id, playbackSid);
touchHotCacheOnPlayback(track.id, playbackCacheSid);
};
const hotPromoteSid = getPlaybackServerId();
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
track,
+1
View File
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const auth = {
activeServerId: 'srv',
servers: [],
replayGainMode: 'track' as 'track' | 'album',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
+11 -5
View File
@@ -1,7 +1,11 @@
import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
@@ -40,10 +44,12 @@ export function queueUndoRestoreAudioEngine(opts: {
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const playbackSid = getPlaybackServerId();
const url = resolvePlaybackUrl(track.id, playbackSid);
const playbackCacheSid = getPlaybackCacheServerKey();
const playbackIndexKey = getPlaybackIndexKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackSid, url),
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
});
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
setDeferHotCachePrefetch(true);
@@ -59,7 +65,7 @@ export function queueUndoRestoreAudioEngine(opts: {
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
serverId: playbackSid || null,
serverId: playbackIndexKey || null,
streamFormatSuffix: track.suffix ?? null,
})
.then(() => {
@@ -94,5 +100,5 @@ export function queueUndoRestoreAudioEngine(opts: {
.finally(() => {
setDeferHotCachePrefetch(false);
});
touchHotCacheOnPlayback(track.id, getPlaybackServerId());
touchHotCacheOnPlayback(track.id, playbackCacheSid);
}
+9 -5
View File
@@ -2,7 +2,11 @@ import { getSong } from '../api/subsonicLibrary';
import { invoke } from '@tauri-apps/api/core';
import { estimateLivePosition } from '../api/orbit';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { songToTrack } from '../utils/playback/songToTrack';
@@ -118,7 +122,7 @@ export function runResume(set: SetState, get: GetState): void {
invoke('audio_resume').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, getPlaybackServerId());
touchHotCacheOnPlayback(currentTrack.id, getPlaybackCacheServerKey());
} else {
// Engine has no loaded paused stream (app relaunch, or track ended and user
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
@@ -129,7 +133,7 @@ export function runResume(set: SetState, get: GetState): void {
void (async () => {
const authHot = useAuthStore.getState();
const resumePromoteSid = getPlaybackServerId();
const resumePromoteSid = getPlaybackCacheServerKey();
if (authHot.hotCacheEnabled && resumePromoteSid) {
await promoteCompletedStreamToHotCache(
currentTrack,
@@ -151,7 +155,7 @@ export function runResume(set: SetState, get: GetState): void {
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = getPlaybackServerId();
const coldServerId = getPlaybackIndexKey();
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
@@ -191,7 +195,7 @@ export function runResume(set: SetState, get: GetState): void {
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = getPlaybackServerId();
const coldServerId = getPlaybackIndexKey();
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
+2 -2
View File
@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { coerceWaveformBins } from '../utils/waveform/waveformParse';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackIndexKey } from '../utils/playback/playbackServer';
import { usePlayerStore } from './playerStore';
import { getWaveformRefreshGen } from './waveformRefreshGen';
@@ -28,7 +28,7 @@ export async function refreshWaveformForTrack(trackId: string): Promise<void> {
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', {
trackId,
serverId: getPlaybackServerId() || null,
serverId: getPlaybackIndexKey() || null,
});
if (getWaveformRefreshGen(trackId) !== gen) return;
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
@@ -1524,6 +1524,21 @@
background: color-mix(in srgb, var(--ctp-crust, #111) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle, #444) 70%, transparent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.25;
}
.fps-overlay__row--detail {
font-size: 11px;
color: var(--text-muted, #aaa);
}
.fps-overlay__row--steps {
font-size: 10px;
color: var(--text-muted, #888);
letter-spacing: 0.01em;
}
.fps-overlay__unit {
+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 };
}