From 2c0aef9538f45091cdd0f2c88320586c0b9ef40a Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 23:44:17 +0200 Subject: [PATCH] =?UTF-8?q?refactor(auth):=20E.46=20=E2=80=94=20extract=20?= =?UTF-8?q?logic-bearing=20factories=20(#611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three factories for the actions that carry real logic (not just `set({ field: v })` pass-through): - `createSkipStarActions` — skip-to-1★ counter with threshold check and per-`` storage key. Disabling the feature wipes the counter map so a re-enable doesn't resume from stale partial counts. Crossing the threshold deletes the key so the next session starts fresh. - `createMusicLibraryActions` — `setMusicFolders` falls back to `'all'` when the persisted filter points at a folder the server no longer reports; `setMusicLibraryFilter` bumps `musicLibraryFilterVersion` so subscribed pages refetch. - `createPerServerCapabilityActions` — `setEntityRatingSupport`, `setAudiomuseNavidromeEnabled`, `setSubsonicServerIdentity`, `setInstantMixProbe`, `setAudiomuseNavidromeIssue`. Each branch knows which neighbouring per-server maps to wipe when the input invalidates them (identity not AudioMuse-eligible / probe empty / audiomuse disabled). Pure code-move. authStore.ts: 384 → 270 LOC (−114). All trivial-setter and logic-bearing action bodies are now out of the store body; only the persist config + `onRehydrateStorage` migration block remains as a non-factory chunk (target for E.47). --- src/store/authMusicLibraryActions.ts | 47 ++++++++ src/store/authPerServerCapabilityActions.ts | 93 +++++++++++++++ src/store/authSkipStarActions.ts | 65 ++++++++++ src/store/authStore.ts | 126 +------------------- 4 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 src/store/authMusicLibraryActions.ts create mode 100644 src/store/authPerServerCapabilityActions.ts create mode 100644 src/store/authSkipStarActions.ts diff --git a/src/store/authMusicLibraryActions.ts b/src/store/authMusicLibraryActions.ts new file mode 100644 index 00000000..6ad7b5fe --- /dev/null +++ b/src/store/authMusicLibraryActions.ts @@ -0,0 +1,47 @@ +import type { AuthState } from './authStoreTypes'; + +type SetState = ( + partial: Partial | ((state: AuthState) => Partial), +) => void; +type GetState = () => AuthState; + +/** + * Per-server music-folder selection. `setMusicFolders` is called + * after login / server change with the fresh Subsonic folder list; + * if the currently-persisted filter for that server points at a + * folder that no longer exists on the server, it falls back to + * `'all'` so the page doesn't end up filtering by a stale id. + * + * `setMusicLibraryFilter` writes the new filter and bumps + * `musicLibraryFilterVersion` so subscribed pages refetch their + * catalog data. + */ +export function createMusicLibraryActions(set: SetState, get: GetState): Pick< + AuthState, + 'setMusicFolders' | 'setMusicLibraryFilter' +> { + return { + setMusicFolders: (folders) => { + const sid = get().activeServerId; + set(s => { + const f = sid ? s.musicLibraryFilterByServer[sid] : undefined; + const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f); + return { + musicFolders: folders, + ...(sid && invalidFilter + ? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } } + : {}), + }; + }); + }, + + setMusicLibraryFilter: (folderId) => { + const sid = get().activeServerId; + if (!sid) return; + set(s => ({ + musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId }, + musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1, + })); + }, + }; +} diff --git a/src/store/authPerServerCapabilityActions.ts b/src/store/authPerServerCapabilityActions.ts new file mode 100644 index 00000000..2ede58cb --- /dev/null +++ b/src/store/authPerServerCapabilityActions.ts @@ -0,0 +1,93 @@ +import { isNavidromeAudiomuseSoftwareEligible } from '../utils/subsonicServerIdentity'; +import type { AuthState } from './authStoreTypes'; + +type SetState = ( + partial: Partial | ((state: AuthState) => Partial), +) => void; + +/** + * Per-server capability flags learned from pings/probes: + * + * - `setEntityRatingSupport` — does setRating apply to album/artist + * ids on this server? Stored as `unknown`/`yes`/`no`. + * - `setAudiomuseNavidromeEnabled` — user opted in/out of the + * AudioMuse-AI Instant Mix path for this Navidrome. Disable also + * clears the related issue flag. + * - `setSubsonicServerIdentity` — server's identity from ping. If the + * ping reveals the server isn't AudioMuse-eligible (wrong type or + * too old), wipe the related caps for that id so the UI doesn't + * keep a stale toggle. + * - `setInstantMixProbe` — probe result for getSimilarSongs. If + * `empty`, wipe the related AudioMuse caps so the row hides. + * - `setAudiomuseNavidromeIssue` — set/clear the "current session + * saw a failure" flag. + */ +export function createPerServerCapabilityActions(set: SetState): Pick< + AuthState, + | 'setEntityRatingSupport' + | 'setAudiomuseNavidromeEnabled' + | 'setSubsonicServerIdentity' + | 'setInstantMixProbe' + | 'setAudiomuseNavidromeIssue' +> { + return { + setEntityRatingSupport: (serverId, level) => + set(s => ({ + entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level }, + })), + + setAudiomuseNavidromeEnabled: (serverId, enabled) => + set(s => { + const audiomuseNavidromeByServer = enabled + ? { ...s.audiomuseNavidromeByServer, [serverId]: true } + : (() => { + const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer; + return rest; + })(); + const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer; + return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest }; + }), + + setSubsonicServerIdentity: (serverId, identity) => + set(s => { + const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } }; + if (!isNavidromeAudiomuseSoftwareEligible(identity)) { + const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; + const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; + const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer; + return { + subsonicServerIdentityByServer, + audiomuseNavidromeByServer: audiomuseRest, + audiomuseNavidromeIssueByServer: issueRest, + instantMixProbeByServer: probeRest, + }; + } + return { subsonicServerIdentityByServer }; + }), + + setInstantMixProbe: (serverId, result) => + set(s => { + const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result }; + if (result === 'empty') { + const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; + const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; + return { + instantMixProbeByServer, + audiomuseNavidromeByServer: audiomuseRest, + audiomuseNavidromeIssueByServer: issueRest, + }; + } + return { instantMixProbeByServer }; + }), + + setAudiomuseNavidromeIssue: (serverId, hasIssue) => + set(s => + hasIssue + ? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } } + : (() => { + const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer; + return { audiomuseNavidromeIssueByServer: rest }; + })(), + ), + }; +} diff --git a/src/store/authSkipStarActions.ts b/src/store/authSkipStarActions.ts new file mode 100644 index 00000000..77076090 --- /dev/null +++ b/src/store/authSkipStarActions.ts @@ -0,0 +1,65 @@ +import { + clampSkipStarThreshold, + skipStarCountStorageKey, +} from './authStoreHelpers'; +import type { AuthState } from './authStoreTypes'; + +type SetState = ( + partial: Partial | ((state: AuthState) => Partial), +) => void; +type GetState = () => AuthState; + +/** + * Skip-to-1★ feature: after N manual skips of the same track, set + * its rating to 1 if still unrated. Counter is per + * `` key, persisted in + * `skipStarManualSkipCountsByKey`, and cleared either when the + * threshold is crossed (so the next session starts fresh) or when + * the track ends naturally without a skip. + * + * Disabling the feature wipes the counter map so re-enabling later + * doesn't resume from stale partial counts. + */ +export function createSkipStarActions(set: SetState, get: GetState): Pick< + AuthState, + | 'setSkipStarOnManualSkipsEnabled' + | 'setSkipStarManualSkipThreshold' + | 'recordSkipStarManualAdvance' + | 'clearSkipStarManualCountForTrack' +> { + return { + setSkipStarOnManualSkipsEnabled: (v) => + set({ + skipStarOnManualSkipsEnabled: v, + ...(v ? {} : { skipStarManualSkipCountsByKey: {} }), + }), + setSkipStarManualSkipThreshold: (v) => + set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }), + + recordSkipStarManualAdvance: (trackId: string) => { + const s = get(); + if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null; + const key = skipStarCountStorageKey(s.activeServerId, trackId); + const prev = s.skipStarManualSkipCountsByKey[key] ?? 0; + const threshold = s.skipStarManualSkipThreshold; + const next = prev + 1; + if (next >= threshold) { + const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey; + set({ skipStarManualSkipCountsByKey: rest }); + return { crossedThreshold: true }; + } + set({ + skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next }, + }); + return { crossedThreshold: false }; + }, + + clearSkipStarManualCountForTrack: (trackId: string) => { + const s = get(); + const key = skipStarCountStorageKey(s.activeServerId, trackId); + if (s.skipStarManualSkipCountsByKey[key] === undefined) return; + const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey; + set({ skipStarManualSkipCountsByKey: rest }); + }, + }; +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 41d2f15a..99ec29c5 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,8 +1,5 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -import { - isNavidromeAudiomuseSoftwareEligible, -} from '../utils/subsonicServerIdentity'; import { IS_LINUX } from '../utils/platform'; import { LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS, @@ -14,8 +11,11 @@ import { createCacheStorageActions } from './authCacheStorageActions'; import { createDiscordSettingsActions } from './authDiscordSettingsActions'; import { createDiscoveryActions } from './authDiscoveryActions'; import { createLyricsSettingsActions } from './authLyricsSettingsActions'; +import { createMusicLibraryActions } from './authMusicLibraryActions'; +import { createPerServerCapabilityActions } from './authPerServerCapabilityActions'; import { createPlumbingSettingsActions } from './authPlumbingActions'; import { createServerProfileActions } from './authServerProfileActions'; +import { createSkipStarActions } from './authSkipStarActions'; import { createTrackPreviewActions } from './authTrackPreviewActions'; import { createUiAppearanceActions } from './authUiAppearanceActions'; import { @@ -26,11 +26,9 @@ import { import { clampMixFilterMinStars, clampRandomMixSize, - clampSkipStarThreshold, sanitizeLoudnessLufsPreset, sanitizeLoudnessPreAnalysisFromStorage, sanitizeSkipStarCounts, - skipStarCountStorageKey, } from './authStoreHelpers'; import type { AuthState, @@ -143,121 +141,9 @@ export const useAuthStore = create()( ...createTrackPreviewActions(set), ...createDiscoveryActions(set), ...createPlumbingSettingsActions(set), - - setSkipStarOnManualSkipsEnabled: (v) => - set({ - skipStarOnManualSkipsEnabled: v, - ...(v ? {} : { skipStarManualSkipCountsByKey: {} }), - }), - setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }), - - recordSkipStarManualAdvance: (trackId: string) => { - const s = get(); - if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null; - const key = skipStarCountStorageKey(s.activeServerId, trackId); - const prev = s.skipStarManualSkipCountsByKey[key] ?? 0; - const threshold = s.skipStarManualSkipThreshold; - const next = prev + 1; - if (next >= threshold) { - const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey; - set({ skipStarManualSkipCountsByKey: rest }); - return { crossedThreshold: true }; - } - set({ - skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next }, - }); - return { crossedThreshold: false }; - }, - - clearSkipStarManualCountForTrack: (trackId: string) => { - const s = get(); - const key = skipStarCountStorageKey(s.activeServerId, trackId); - if (s.skipStarManualSkipCountsByKey[key] === undefined) return; - const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey; - set({ skipStarManualSkipCountsByKey: rest }); - }, - - setMusicFolders: (folders) => { - const sid = get().activeServerId; - set(s => { - const f = sid ? s.musicLibraryFilterByServer[sid] : undefined; - const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f); - return { - musicFolders: folders, - ...(sid && invalidFilter - ? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } } - : {}), - }; - }); - }, - - setMusicLibraryFilter: (folderId) => { - const sid = get().activeServerId; - if (!sid) return; - set(s => ({ - musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId }, - musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1, - })); - }, - - setEntityRatingSupport: (serverId, level) => - set(s => ({ - entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level }, - })), - - setAudiomuseNavidromeEnabled: (serverId, enabled) => - set(s => { - const audiomuseNavidromeByServer = enabled - ? { ...s.audiomuseNavidromeByServer, [serverId]: true } - : (() => { - const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer; - return rest; - })(); - const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer; - return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest }; - }), - - setSubsonicServerIdentity: (serverId, identity) => - set(s => { - const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } }; - if (!isNavidromeAudiomuseSoftwareEligible(identity)) { - const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; - const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; - const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer; - return { - subsonicServerIdentityByServer, - audiomuseNavidromeByServer: audiomuseRest, - audiomuseNavidromeIssueByServer: issueRest, - instantMixProbeByServer: probeRest, - }; - } - return { subsonicServerIdentityByServer }; - }), - - setInstantMixProbe: (serverId, result) => - set(s => { - const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result }; - if (result === 'empty') { - const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; - const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; - return { - instantMixProbeByServer, - audiomuseNavidromeByServer: audiomuseRest, - audiomuseNavidromeIssueByServer: issueRest, - }; - } - return { instantMixProbeByServer }; - }), - - setAudiomuseNavidromeIssue: (serverId, hasIssue) => - set(s => - hasIssue - ? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } } - : (() => { - const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer; - return { audiomuseNavidromeIssueByServer: rest }; - })(), - ), + ...createSkipStarActions(set, get), + ...createMusicLibraryActions(set, get), + ...createPerServerCapabilityActions(set), getBaseUrl: () => { const s = get();