diff --git a/CHANGELOG.md b/CHANGELOG.md index af4266ee..791494d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/components/settings/LibraryIndexSection.tsx b/src/components/settings/LibraryIndexSection.tsx index b3d578bb..01ba34e7 100644 --- a/src/components/settings/LibraryIndexSection.tsx +++ b/src/components/settings/LibraryIndexSection.tsx @@ -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>({}); const [progressByServer, setProgressByServer] = useState>({}); const [busyServerId, setBusyServerId] = useState(null); + const [excludingServerId, setExcludingServerId] = useState(null); + const [includingServerId, setIncludingServerId] = useState(null); const [bootstrapping, setBootstrapping] = useState(false); const pollTimer = useRef | 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 ( void handleMasterToggle(e.target.checked)} /> @@ -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')} ))} diff --git a/src/components/settings/LibraryIndexServerRow.tsx b/src/components/settings/LibraryIndexServerRow.tsx index 4156b9a5..0bf613c4 100644 --- a/src/components/settings/LibraryIndexServerRow.tsx +++ b/src/components/settings/LibraryIndexServerRow.tsx @@ -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 && ( {t('settings.libraryIndexServerSyncing')} )} + {including && !busy && ( + {t('settings.libraryIndexIncludingServer')} + )}
{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} > - {t('settings.libraryIndexExcludeServer')} + {excluding + ? t('settings.libraryIndexExcludingServer') + : t('settings.libraryIndexExcludeServer')}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index aa3d1855..72c22859 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 50343613..ecfa21d6 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -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…', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 9d5910ba..1bede627 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -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…', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index f101ba95..7d64187c 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -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…', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 575f3348..56488e97 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -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…', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 39f94da1..ac836c86 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -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…', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index a1816bc5..82564ebc 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -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…', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index ed051bb0..2be0cf8a 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -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…', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 872b170f..3be265ca 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -269,8 +269,10 @@ export const settings = { libraryIndexFullResync: 'Полная пересинхронизация', libraryIndexDeltaSync: 'Быстрая дельта', libraryIndexExcludeServer: 'Исключить из синхронизации', + libraryIndexExcludingServer: 'Отключается…', libraryIndexExcludedTitle: 'Исключены из синхронизации', libraryIndexIncludeServer: 'Включить снова', + libraryIndexIncludingServer: 'Подключается…', libraryIndexStatus: 'Статус', libraryIndexStatusIdle: 'Ожидание', libraryIndexStatusProbing: 'Проверка сервера…', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 712eb6d6..49b1ca55 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -256,8 +256,10 @@ export const settings = { libraryIndexFullResync: '完全重新同步', libraryIndexDeltaSync: '增量同步', libraryIndexExcludeServer: '排除同步', + libraryIndexExcludingServer: '正在排除…', libraryIndexExcludedTitle: '已排除同步', libraryIndexIncludeServer: '重新纳入', + libraryIndexIncludingServer: '正在纳入…', libraryIndexStatus: '状态', libraryIndexStatusIdle: '空闲', libraryIndexStatusProbing: '正在检查服务器…', diff --git a/src/utils/library/librarySyncQueue.ts b/src/utils/library/librarySyncQueue.ts index c45cb469..6aede145 100644 --- a/src/utils/library/librarySyncQueue.ts +++ b/src/utils/library/librarySyncQueue.ts @@ -62,6 +62,25 @@ function waitForServerIdle(serverId: string): Promise { }); } +/** Wait until a server emits `library:sync-idle`, or time out (best-effort). */ +export function waitForLibrarySyncIdle(serverId: string, timeoutMs = 15_000): Promise { + 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 { if (kind === 'verify') { await librarySyncVerifyIntegrity({ serverId });