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
+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>
);
}