mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge pull request #850 from Psychotoxical/fix/library-index-exclude-busy-ui
fix(settings): library index exclude/include busy feedback
This commit is contained in:
@@ -156,6 +156,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Settings — local library index exclude/include feedback
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#850](https://github.com/Psychotoxical/psysonic/pull/850)**
|
||||
|
||||
* **Settings → Library:** **Exclude from sync** and **Include again** show immediate busy labels and block repeat clicks while bind/unbind runs; exclude cancels an in-flight sync first.
|
||||
|
||||
|
||||
|
||||
## [1.46.0] - 2026-05-18
|
||||
|
||||
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
bootstrapIndexedServer,
|
||||
type BindServerResult,
|
||||
} from '../../utils/library/librarySession';
|
||||
import { enqueueLibrarySync } from '../../utils/library/librarySyncQueue';
|
||||
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';
|
||||
@@ -58,6 +59,8 @@ export default function LibraryIndexSection() {
|
||||
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);
|
||||
@@ -130,6 +133,8 @@ export default function LibraryIndexSection() {
|
||||
setConnectionByServer({});
|
||||
setProgressByServer({});
|
||||
setBusyServerId(null);
|
||||
setExcludingServerId(null);
|
||||
setIncludingServerId(null);
|
||||
return;
|
||||
}
|
||||
void runBootstrap();
|
||||
@@ -248,23 +253,52 @@ export default function LibraryIndexSection() {
|
||||
};
|
||||
|
||||
const handleIncludeServer = async (serverId: string) => {
|
||||
setServerSyncExcluded(serverId, false);
|
||||
if (includingServerId || excludingServerId) return;
|
||||
const srv = servers.find(s => s.id === serverId);
|
||||
if (srv) {
|
||||
setBootstrapping(true);
|
||||
try {
|
||||
const result = await bootstrapIndexedServer(srv);
|
||||
applyConnectionResults({ [serverId]: result });
|
||||
await refreshAllStatuses();
|
||||
} finally {
|
||||
setBootstrapping(false);
|
||||
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) => {
|
||||
setBootstrapping(true);
|
||||
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 => {
|
||||
@@ -277,10 +311,18 @@ export default function LibraryIndexSection() {
|
||||
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 {
|
||||
setBootstrapping(false);
|
||||
setExcludingServerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,7 +334,8 @@ export default function LibraryIndexSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const globalBusy = bootstrapping || busyServerId != null;
|
||||
const globalBusy =
|
||||
bootstrapping || busyServerId != null || excludingServerId != null || includingServerId != null;
|
||||
|
||||
return (
|
||||
<SettingsSubSection
|
||||
@@ -320,7 +363,7 @@ export default function LibraryIndexSection() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={masterEnabled}
|
||||
disabled={servers.length === 0 || bootstrapping}
|
||||
disabled={servers.length === 0 || bootstrapping || includingServerId != null || excludingServerId != null}
|
||||
onChange={e => void handleMasterToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
@@ -349,7 +392,13 @@ export default function LibraryIndexSection() {
|
||||
connection={connectionByServer[srv.id] ?? 'unknown'}
|
||||
progressLabel={progressByServer[srv.id] ?? null}
|
||||
busy={busyServerId === srv.id}
|
||||
actionsDisabled={globalBusy && 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')}
|
||||
@@ -376,10 +425,15 @@ export default function LibraryIndexSection() {
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
disabled={bootstrapping}
|
||||
disabled={
|
||||
includingServerId != null || excludingServerId != null
|
||||
}
|
||||
aria-busy={includingServerId === srv.id}
|
||||
onClick={() => void handleIncludeServer(srv.id)}
|
||||
>
|
||||
{t('settings.libraryIndexIncludeServer')}
|
||||
{includingServerId === srv.id
|
||||
? t('settings.libraryIndexIncludingServer')
|
||||
: t('settings.libraryIndexIncludeServer')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface LibraryIndexServerRowProps {
|
||||
connection: LibraryServerConnection;
|
||||
progressLabel: string | null;
|
||||
busy: boolean;
|
||||
including: boolean;
|
||||
excluding: boolean;
|
||||
actionsDisabled: boolean;
|
||||
onFullSync: () => void;
|
||||
onDeltaSync: () => void;
|
||||
@@ -33,6 +35,8 @@ export default function LibraryIndexServerRow({
|
||||
connection,
|
||||
progressLabel,
|
||||
busy,
|
||||
including,
|
||||
excluding,
|
||||
actionsDisabled,
|
||||
onFullSync,
|
||||
onDeltaSync,
|
||||
@@ -91,6 +95,9 @@ export default function LibraryIndexServerRow({
|
||||
{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}
|
||||
@@ -133,11 +140,14 @@ export default function LibraryIndexServerRow({
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', color: 'var(--text-muted)' }}
|
||||
disabled={actionsDisabled}
|
||||
disabled={actionsDisabled || excluding}
|
||||
aria-busy={excluding}
|
||||
onClick={onExclude}
|
||||
>
|
||||
<Ban size={13} />
|
||||
{t('settings.libraryIndexExcludeServer')}
|
||||
{excluding
|
||||
? t('settings.libraryIndexExcludingServer')
|
||||
: t('settings.libraryIndexExcludeServer')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +125,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)',
|
||||
'Library browse: local-vs-network text search race, All Albums/Artists catalog from index, DevTools browse-race logging (PR #847)',
|
||||
'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
|
||||
'Settings → Library: exclude/include index buttons show busy state and block repeat clicks (PR #850)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -261,8 +261,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Vollständige Neusynchronisation',
|
||||
libraryIndexDeltaSync: 'Delta-Synchronisation',
|
||||
libraryIndexExcludeServer: 'Von Synchronisation ausschließen',
|
||||
libraryIndexExcludingServer: 'Wird ausgeschlossen…',
|
||||
libraryIndexExcludedTitle: 'Von Synchronisation ausgeschlossen',
|
||||
libraryIndexIncludeServer: 'Wieder einschließen',
|
||||
libraryIndexIncludingServer: 'Wird eingeschlossen…',
|
||||
libraryIndexStatus: 'Status',
|
||||
libraryIndexStatusIdle: 'Bereit',
|
||||
libraryIndexStatusProbing: 'Server wird geprüft…',
|
||||
|
||||
@@ -264,8 +264,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Full resync',
|
||||
libraryIndexDeltaSync: 'Delta sync',
|
||||
libraryIndexExcludeServer: 'Exclude from sync',
|
||||
libraryIndexExcludingServer: 'Excluding…',
|
||||
libraryIndexExcludedTitle: 'Excluded from sync',
|
||||
libraryIndexIncludeServer: 'Include again',
|
||||
libraryIndexIncludingServer: 'Including…',
|
||||
libraryIndexStatus: 'Status',
|
||||
libraryIndexStatusIdle: 'Idle',
|
||||
libraryIndexStatusProbing: 'Checking server…',
|
||||
|
||||
@@ -259,8 +259,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Resincronización completa',
|
||||
libraryIndexDeltaSync: 'Sincronización delta',
|
||||
libraryIndexExcludeServer: 'Excluir de la sincronización',
|
||||
libraryIndexExcludingServer: 'Excluyendo…',
|
||||
libraryIndexExcludedTitle: 'Excluidos de la sincronización',
|
||||
libraryIndexIncludeServer: 'Incluir de nuevo',
|
||||
libraryIndexIncludingServer: 'Incluyendo…',
|
||||
libraryIndexStatus: 'Estado',
|
||||
libraryIndexStatusIdle: 'Inactivo',
|
||||
libraryIndexStatusProbing: 'Comprobando servidor…',
|
||||
|
||||
@@ -257,8 +257,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Resynchronisation complète',
|
||||
libraryIndexDeltaSync: 'Synchronisation delta',
|
||||
libraryIndexExcludeServer: 'Exclure de la synchronisation',
|
||||
libraryIndexExcludingServer: 'Exclusion…',
|
||||
libraryIndexExcludedTitle: 'Exclus de la synchronisation',
|
||||
libraryIndexIncludeServer: 'Réinclure',
|
||||
libraryIndexIncludingServer: 'Réinclusion…',
|
||||
libraryIndexStatus: 'État',
|
||||
libraryIndexStatusIdle: 'Inactif',
|
||||
libraryIndexStatusProbing: 'Vérification du serveur…',
|
||||
|
||||
@@ -256,8 +256,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Full resynkronisering',
|
||||
libraryIndexDeltaSync: 'Delta-synkronisering',
|
||||
libraryIndexExcludeServer: 'Ekskluder fra synkronisering',
|
||||
libraryIndexExcludingServer: 'Ekskluderer…',
|
||||
libraryIndexExcludedTitle: 'Ekskludert fra synkronisering',
|
||||
libraryIndexIncludeServer: 'Inkluder igjen',
|
||||
libraryIndexIncludingServer: 'Inkluderer…',
|
||||
libraryIndexStatus: 'Status',
|
||||
libraryIndexStatusIdle: 'Inaktiv',
|
||||
libraryIndexStatusProbing: 'Sjekker server…',
|
||||
|
||||
@@ -257,8 +257,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Volledige resync',
|
||||
libraryIndexDeltaSync: 'Delta-sync',
|
||||
libraryIndexExcludeServer: 'Uitsluiten van synchronisatie',
|
||||
libraryIndexExcludingServer: 'Uitsluiten…',
|
||||
libraryIndexExcludedTitle: 'Uitgesloten van synchronisatie',
|
||||
libraryIndexIncludeServer: 'Weer opnemen',
|
||||
libraryIndexIncludingServer: 'Opnemen…',
|
||||
libraryIndexStatus: 'Status',
|
||||
libraryIndexStatusIdle: 'Inactief',
|
||||
libraryIndexStatusProbing: 'Server controleren…',
|
||||
|
||||
@@ -263,8 +263,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Resincronizare completă',
|
||||
libraryIndexDeltaSync: 'Sincronizare delta',
|
||||
libraryIndexExcludeServer: 'Exclude din sincronizare',
|
||||
libraryIndexExcludingServer: 'Se exclude…',
|
||||
libraryIndexExcludedTitle: 'Excluse din sincronizare',
|
||||
libraryIndexIncludeServer: 'Include din nou',
|
||||
libraryIndexIncludingServer: 'Se include…',
|
||||
libraryIndexStatus: 'Stare',
|
||||
libraryIndexStatusIdle: 'Inactiv',
|
||||
libraryIndexStatusProbing: 'Se verifică serverul…',
|
||||
|
||||
@@ -269,8 +269,10 @@ export const settings = {
|
||||
libraryIndexFullResync: 'Полная пересинхронизация',
|
||||
libraryIndexDeltaSync: 'Быстрая дельта',
|
||||
libraryIndexExcludeServer: 'Исключить из синхронизации',
|
||||
libraryIndexExcludingServer: 'Отключается…',
|
||||
libraryIndexExcludedTitle: 'Исключены из синхронизации',
|
||||
libraryIndexIncludeServer: 'Включить снова',
|
||||
libraryIndexIncludingServer: 'Подключается…',
|
||||
libraryIndexStatus: 'Статус',
|
||||
libraryIndexStatusIdle: 'Ожидание',
|
||||
libraryIndexStatusProbing: 'Проверка сервера…',
|
||||
|
||||
@@ -256,8 +256,10 @@ export const settings = {
|
||||
libraryIndexFullResync: '完全重新同步',
|
||||
libraryIndexDeltaSync: '增量同步',
|
||||
libraryIndexExcludeServer: '排除同步',
|
||||
libraryIndexExcludingServer: '正在排除…',
|
||||
libraryIndexExcludedTitle: '已排除同步',
|
||||
libraryIndexIncludeServer: '重新纳入',
|
||||
libraryIndexIncludingServer: '正在纳入…',
|
||||
libraryIndexStatus: '状态',
|
||||
libraryIndexStatusIdle: '空闲',
|
||||
libraryIndexStatusProbing: '正在检查服务器…',
|
||||
|
||||
@@ -62,6 +62,25 @@ function waitForServerIdle(serverId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait until a server emits `library:sync-idle`, or time out (best-effort). */
|
||||
export function waitForLibrarySyncIdle(serverId: string, timeoutMs = 15_000): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const timer = setTimeout(() => {
|
||||
unlisten?.();
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
void subscribeLibrarySyncIdle(p => {
|
||||
if (p.serverId !== serverId) return;
|
||||
clearTimeout(timer);
|
||||
unlisten?.();
|
||||
resolve();
|
||||
}).then(fn => {
|
||||
unlisten = fn;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeSync(serverId: string, kind: LibrarySyncQueueKind): Promise<void> {
|
||||
if (kind === 'verify') {
|
||||
await librarySyncVerifyIntegrity({ serverId });
|
||||
|
||||
Reference in New Issue
Block a user