mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts
Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.
* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports
Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:
- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
(Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON
lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.
* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets
Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).
- client.ts: transport wrapper over audioscrobbler_request, classifying
failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
token-poll presets (both endpoints verified live)
Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.
* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets
Add the scrobble-destination wires and presets:
- ListenBrainz wire (scrobble + now playing via playing_now), backing both
the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
(scrobble-only, session-key paste, bundled keys — verified live), and
custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix
maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.
* feat(music-network): registry, orchestrator, enrichment router + runtime facade
Wire the framework together behind a single facade:
- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
final authority (lets two presets on one wire diverge, e.g. Rocksky's
nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel
Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.
* feat(music-network): auth-store state + lossless legacy migration + runtime bridge
Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.
- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
username and scrobbling preference; fills bundled Last.fm key from the preset;
sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap
nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.
* feat(music-network): route playback, enrichment and love through the runtime
Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:
- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
the ArtistDetail similar-artists gate now use the runtime, gated on an
enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
lastfmActions -> networkLoveActions; loved cache storage renamed with a
lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
throw) before the runtime is initialized
src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.
* feat(music-network): manifest-driven Integrations UI + scrobble batch format
Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.
- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey
Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).
* fix(music-network): clearer Integrations layout + scrobble batch fix
Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
liked tracks/similar artists/stats come from it while scrobbling still goes
to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)
Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.
* feat(music-network): indicator + remove legacy lastfm path
Phase 7b/7c — finish the cutover and delete the old Last.fm path.
- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
tests; update settingsTabs + src/CLAUDE.md
No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.
* test(music-network): cover scrobble shape + error classification, drop dead i18n keys
Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.
Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.
* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets
- de-hardcode the single provider name across every enrichment surface
(love labels, now-playing badge, stats title); derive it from the
enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry
* fix(music-network): validate paste-auth keys on connect + UI polish
- AudioscrobblerWire.probe now validates an api_key_only session with a
signed call and reports scrobble:'error' only on a genuine auth failure
(a scrobble-only service that rejects user.getInfo is not a bad key), so
an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour
* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments
Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.
* docs(changelog): Music Network entry (PR #1066)
Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
* fix(music-network): bound request timeout on the provider transports
audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.
* refactor(music-network): dedupe wire transport + no-enrichment helpers
The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.
* refactor(music-network): drop write-only malojaWireMode dead state
malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.
* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback
The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.
---------
Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||
import { getMusicNetworkRuntimeOrNull } from '../music-network';
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
|
||||
import {
|
||||
@@ -187,7 +187,7 @@ export function handleAudioProgress(
|
||||
}
|
||||
}
|
||||
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
// Scrobble at 50%: Music Network + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
scrobbleSong(
|
||||
@@ -195,10 +195,13 @@ export function handleAudioProgress(
|
||||
Date.now(),
|
||||
playbackProfileIdForTrack(track, store.queueItems[store.queueIndex]),
|
||||
);
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (scrobblingEnabled && lastfmSessionKey) {
|
||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||
}
|
||||
void getMusicNetworkRuntimeOrNull()?.dispatchScrobble({
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
if (progressUiDisabled) return;
|
||||
// Critical architectural guard: avoid high-frequency writes to the persisted
|
||||
@@ -445,7 +448,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
networkLoved: false,
|
||||
currentPlaybackSource: switchPlaybackSource,
|
||||
});
|
||||
emitNormalizationDebug('track-switched', {
|
||||
@@ -457,20 +460,27 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
void refreshLoudnessForTrack(nextTrack.id);
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
|
||||
// Report Now Playing to Navidrome + Last.fm
|
||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
// Navidrome now-playing follows nowPlayingEnabled; Music Network now-playing
|
||||
// follows scrobbling, as Last.fm now-playing did (the runtime gates on the
|
||||
// master toggle, per-account enable and the nowPlaying capability internally).
|
||||
const { nowPlayingEnabled } = useAuthStore.getState();
|
||||
if (nowPlayingEnabled) {
|
||||
reportNowPlaying(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
|
||||
}
|
||||
if (lastfmSessionKey) {
|
||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
|
||||
usePlayerStore.setState(s => ({
|
||||
lastfmLoved: loved,
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
void runtime?.dispatchNowPlaying({
|
||||
title: nextTrack.title,
|
||||
artist: nextTrack.artist,
|
||||
album: nextTrack.album,
|
||||
duration: nextTrack.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (runtime?.getEnrichmentPrimaryId()) {
|
||||
void runtime
|
||||
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
|
||||
.then(loved => {
|
||||
usePlayerStore.getState().setNetworkLoved(loved);
|
||||
});
|
||||
}
|
||||
syncQueueToServer(queueItems, nextTrack, 0);
|
||||
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
|
||||
|
||||
@@ -12,8 +12,8 @@ import { refreshWaveformForTrack } from '../waveformRefresh';
|
||||
* and primes waveform / loudness caches for the boot track. No cleanup needed.
|
||||
*/
|
||||
export function runInitialAudioSync(): void {
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
usePlayerStore.getState().syncLastfmLovedTracks();
|
||||
// Sync loved tracks cache on startup.
|
||||
usePlayerStore.getState().syncNetworkLovedTracks();
|
||||
|
||||
// Initial sync of audio settings to Rust engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Last.fm account settings on the auth side: credentials, session
|
||||
* connect/disconnect, error flag, and the master scrobbling toggle.
|
||||
* The actual scrobble/love network calls live in `lastfmActions.ts`
|
||||
* inside the playerStore — these here only manage the persisted
|
||||
* account state.
|
||||
*/
|
||||
export function createAuthLastfmActions(set: SetState): Pick<
|
||||
AuthState,
|
||||
| 'setLastfm'
|
||||
| 'connectLastfm'
|
||||
| 'disconnectLastfm'
|
||||
| 'setLastfmSessionError'
|
||||
| 'setScrobblingEnabled'
|
||||
> {
|
||||
return {
|
||||
setLastfm: (apiKey, apiSecret, sessionKey, username) =>
|
||||
set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }),
|
||||
|
||||
connectLastfm: (sessionKey, username) =>
|
||||
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
|
||||
|
||||
disconnectLastfm: () =>
|
||||
set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }),
|
||||
|
||||
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PersistedAccount } from '../music-network';
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Music Network persisted state actions. These back the runtime's
|
||||
* MusicNetworkStore port (see musicNetworkBridge.ts) — the runtime is the only
|
||||
* caller. Kept synchronous on localStorage like the rest of authStore.
|
||||
*/
|
||||
export function createMusicNetworkActions(set: SetState): Pick<
|
||||
AuthState,
|
||||
'setMusicNetworkAccounts' | 'setEnrichmentPrimaryId' | 'setScrobblingMasterEnabled'
|
||||
> {
|
||||
return {
|
||||
setMusicNetworkAccounts: (accounts: PersistedAccount[]) =>
|
||||
set({ musicNetworkAccounts: accounts }),
|
||||
setEnrichmentPrimaryId: (id: string | null) =>
|
||||
set({ enrichmentPrimaryId: id }),
|
||||
setScrobblingMasterEnabled: (v: boolean) =>
|
||||
set({ scrobblingMasterEnabled: v }),
|
||||
};
|
||||
}
|
||||
@@ -102,38 +102,3 @@ describe('logout', () => {
|
||||
expect(s.activeServerId).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Last.fm session', () => {
|
||||
it('setLastfm stores api key + secret + session key + username together', () => {
|
||||
useAuthStore.getState().setLastfm('api', 'sec', 'session', 'frank');
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.lastfmApiKey).toBe('api');
|
||||
expect(s.lastfmApiSecret).toBe('sec');
|
||||
expect(s.lastfmSessionKey).toBe('session');
|
||||
expect(s.lastfmUsername).toBe('frank');
|
||||
});
|
||||
|
||||
it('connectLastfm sets only sessionKey + username (preserves api key / secret)', () => {
|
||||
useAuthStore.getState().setLastfm('app-key', 'app-sec', '', '');
|
||||
useAuthStore.getState().connectLastfm('user-session', 'frank');
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.lastfmApiKey).toBe('app-key');
|
||||
expect(s.lastfmApiSecret).toBe('app-sec');
|
||||
expect(s.lastfmSessionKey).toBe('user-session');
|
||||
expect(s.lastfmUsername).toBe('frank');
|
||||
});
|
||||
|
||||
it('disconnectLastfm clears session key + username + session error, keeps app credentials', () => {
|
||||
useAuthStore.getState().setLastfm('app-key', 'app-sec', 'session', 'frank');
|
||||
useAuthStore.getState().setLastfmSessionError(true);
|
||||
|
||||
useAuthStore.getState().disconnectLastfm();
|
||||
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.lastfmSessionKey).toBe('');
|
||||
expect(s.lastfmUsername).toBe('');
|
||||
expect(s.lastfmSessionError).toBe(false);
|
||||
expect(s.lastfmApiKey).toBe('app-key');
|
||||
expect(s.lastfmApiSecret).toBe('app-sec');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,13 +41,13 @@ describe('hydration — loads existing localStorage shape', () => {
|
||||
});
|
||||
|
||||
it('defaults missing fields to their initial values', async () => {
|
||||
// Minimal payload — no scrobblingEnabled, no replayGain settings, etc.
|
||||
// Minimal payload — no trackPreviewsEnabled, no replayGain settings, etc.
|
||||
writePersistedState({ servers: [], activeServerId: null });
|
||||
|
||||
await useAuthStore.persist.rehydrate();
|
||||
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.scrobblingEnabled).toBe(true);
|
||||
expect(s.trackPreviewsEnabled).toBe(true);
|
||||
expect(s.crossfadeEnabled).toBe(false);
|
||||
expect(s.gaplessEnabled).toBe(false);
|
||||
expect(s.replayGainEnabled).toBe(false);
|
||||
@@ -58,7 +58,7 @@ describe('hydration — loads existing localStorage shape', () => {
|
||||
writePersistedState({
|
||||
servers: [],
|
||||
activeServerId: null,
|
||||
scrobblingEnabled: false,
|
||||
trackPreviewsEnabled: false,
|
||||
crossfadeEnabled: true,
|
||||
gaplessEnabled: false,
|
||||
crossfadeSecs: 7,
|
||||
@@ -67,7 +67,7 @@ describe('hydration — loads existing localStorage shape', () => {
|
||||
await useAuthStore.persist.rehydrate();
|
||||
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.scrobblingEnabled).toBe(false);
|
||||
expect(s.trackPreviewsEnabled).toBe(false);
|
||||
expect(s.crossfadeEnabled).toBe(true);
|
||||
expect(s.crossfadeSecs).toBe(7);
|
||||
});
|
||||
@@ -81,7 +81,7 @@ describe('hydration — corrupt / unexpected input', () => {
|
||||
const s = useAuthStore.getState();
|
||||
// No servers loaded; defaults remain.
|
||||
expect(s.servers).toEqual([]);
|
||||
expect(s.scrobblingEnabled).toBe(true);
|
||||
expect(s.trackPreviewsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is robust to a missing top-level `state` field', async () => {
|
||||
@@ -150,7 +150,7 @@ describe('partialize — what gets persisted', () => {
|
||||
it('strips `musicFolders` from the persisted payload', () => {
|
||||
useAuthStore.setState({ musicFolders: [{ id: 'mf-1', name: 'Music' }] });
|
||||
// Trigger persist (Zustand persist writes on every state change).
|
||||
useAuthStore.setState({ scrobblingEnabled: false });
|
||||
useAuthStore.setState({ trackPreviewsEnabled: false });
|
||||
|
||||
const raw = localStorage.getItem(PERSIST_KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
|
||||
@@ -47,7 +47,6 @@ describe('trivial pass-through setters', () => {
|
||||
// a refactor that renames a key without renaming the setter (or vice
|
||||
// versa) breaks these.
|
||||
it.each([
|
||||
['setScrobblingEnabled', 'scrobblingEnabled', false],
|
||||
['setExcludeAudiobooks', 'excludeAudiobooks', true],
|
||||
['setInfiniteQueueEnabled', 'infiniteQueueEnabled', true],
|
||||
['setPreservePlayNextOrder', 'preservePlayNextOrder', true],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { createAudioSettingsActions } from './authAudioSettingsActions';
|
||||
import { createAuthLastfmActions } from './authLastfmActions';
|
||||
import { createCacheStorageActions } from './authCacheStorageActions';
|
||||
import { createDiscordSettingsActions } from './authDiscordSettingsActions';
|
||||
import { createDiscoveryActions } from './authDiscoveryActions';
|
||||
import { createLyricsSettingsActions } from './authLyricsSettingsActions';
|
||||
import { createMusicLibraryActions } from './authMusicLibraryActions';
|
||||
import { createMusicNetworkActions } from './authMusicNetworkActions';
|
||||
import { createPerServerCapabilityActions } from './authPerServerCapabilityActions';
|
||||
import { createPlumbingSettingsActions } from './authPlumbingActions';
|
||||
import { createServerProfileActions } from './authServerProfileActions';
|
||||
@@ -31,11 +31,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
(set, get) => ({
|
||||
servers: [],
|
||||
activeServerId: null,
|
||||
lastfmApiKey: '',
|
||||
lastfmApiSecret: '',
|
||||
lastfmSessionKey: '',
|
||||
lastfmUsername: '',
|
||||
scrobblingEnabled: true,
|
||||
musicNetworkAccounts: [],
|
||||
enrichmentPrimaryId: null,
|
||||
scrobblingMasterEnabled: true,
|
||||
maxCacheMb: 0,
|
||||
coverRevalidateCycleDays: 30,
|
||||
coverRevalidateMaxProbesPerSession: 500,
|
||||
@@ -126,10 +124,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
lastfmSessionError: false,
|
||||
|
||||
...createServerProfileActions(set),
|
||||
...createAuthLastfmActions(set),
|
||||
...createMusicNetworkActions(set),
|
||||
...createAudioSettingsActions(set),
|
||||
...createCacheStorageActions(set),
|
||||
...createDiscordSettingsActions(set),
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
QueueDisplayMode,
|
||||
SeekbarStyle,
|
||||
} from './authStoreTypes';
|
||||
import { migrateLegacyLastfm, sanitizeAccounts } from '../music-network';
|
||||
|
||||
/**
|
||||
* Computes the post-rehydration patch for the auth store. Runs all
|
||||
@@ -160,6 +161,49 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Music Network: one-time migration of the legacy flat lastfm* fields into the
|
||||
// accounts[] model. Runs exactly once (guarded by a sentinel) so a later
|
||||
// disconnect can't resurrect the account from the still-present legacy fields.
|
||||
// Subsequent rehydrates only sanitize the persisted account list.
|
||||
const musicNetworkMigrationKey = 'psysonic-music-network-migrated-v1';
|
||||
let musicNetworkMigrated: Partial<AuthState> = {
|
||||
musicNetworkAccounts: sanitizeAccounts(
|
||||
(state as { musicNetworkAccounts?: unknown }).musicNetworkAccounts,
|
||||
),
|
||||
};
|
||||
try {
|
||||
if (!localStorage.getItem(musicNetworkMigrationKey)) {
|
||||
// The legacy lastfm* fields no longer exist on AuthState; read them off the
|
||||
// persisted blob (present on upgrade) via a cast.
|
||||
const legacy = state as unknown as {
|
||||
lastfmSessionKey?: string;
|
||||
lastfmUsername?: string;
|
||||
scrobblingEnabled?: boolean;
|
||||
};
|
||||
const migrated = migrateLegacyLastfm(
|
||||
{
|
||||
lastfmSessionKey: legacy.lastfmSessionKey,
|
||||
lastfmUsername: legacy.lastfmUsername,
|
||||
scrobblingEnabled: legacy.scrobblingEnabled,
|
||||
},
|
||||
() => crypto.randomUUID(),
|
||||
);
|
||||
musicNetworkMigrated = {
|
||||
musicNetworkAccounts: migrated.accounts,
|
||||
enrichmentPrimaryId: migrated.enrichmentPrimaryId,
|
||||
scrobblingMasterEnabled: migrated.scrobblingMasterEnabled,
|
||||
};
|
||||
localStorage.setItem(musicNetworkMigrationKey, '1');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Strip the legacy flat lastfm* fields from the persisted blob (spec §6.1.3).
|
||||
// The migration above maps them into accounts[]; the sentinel guards
|
||||
// re-migration, so these now sit as pure cruft. Drop them on every rehydrate.
|
||||
for (const k of ['lastfmApiKey', 'lastfmApiSecret', 'lastfmSessionKey', 'lastfmUsername', 'lastfmSessionError', 'scrobblingEnabled']) {
|
||||
delete (state as unknown as Record<string, unknown>)[k];
|
||||
}
|
||||
|
||||
let mediaDirMigrated: { mediaDir?: string } = {};
|
||||
const stMedia = state as { mediaDir?: unknown; offlineDownloadDir?: string; hotCacheDownloadDir?: string };
|
||||
if (!stMedia.mediaDir || (typeof stMedia.mediaDir === 'string' && stMedia.mediaDir.trim() === '')) {
|
||||
@@ -174,6 +218,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
|
||||
return {
|
||||
...mediaDirMigrated,
|
||||
...musicNetworkMigrated,
|
||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
|
||||
|
||||
+10
-12
@@ -4,6 +4,7 @@ import type {
|
||||
InstantMixProbeResult,
|
||||
SubsonicServerIdentity,
|
||||
} from '../utils/server/subsonicServerIdentity';
|
||||
import type { PersistedAccount } from '../music-network';
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
@@ -76,14 +77,12 @@ export interface AuthState {
|
||||
servers: ServerProfile[];
|
||||
activeServerId: string | null;
|
||||
|
||||
// Last.fm (global)
|
||||
lastfmApiKey: string;
|
||||
lastfmApiSecret: string;
|
||||
lastfmSessionKey: string;
|
||||
lastfmUsername: string;
|
||||
// Music Network — multi-provider scrobble/enrichment framework state.
|
||||
musicNetworkAccounts: PersistedAccount[];
|
||||
enrichmentPrimaryId: string | null;
|
||||
scrobblingMasterEnabled: boolean;
|
||||
|
||||
// Settings (global)
|
||||
scrobblingEnabled: boolean;
|
||||
maxCacheMb: number;
|
||||
coverRevalidateCycleDays: number;
|
||||
coverRevalidateMaxProbesPerSession: number;
|
||||
@@ -285,7 +284,6 @@ export interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionError: string | null;
|
||||
lastfmSessionError: boolean;
|
||||
|
||||
// Actions
|
||||
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
|
||||
@@ -296,11 +294,11 @@ export interface AuthState {
|
||||
setLoggedIn: (v: boolean) => void;
|
||||
setConnecting: (v: boolean) => void;
|
||||
setConnectionError: (e: string | null) => void;
|
||||
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
|
||||
connectLastfm: (sessionKey: string, username: string) => void;
|
||||
disconnectLastfm: () => void;
|
||||
setLastfmSessionError: (v: boolean) => void;
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
|
||||
// Music Network actions (backing the runtime's MusicNetworkStore port).
|
||||
setMusicNetworkAccounts: (accounts: PersistedAccount[]) => void;
|
||||
setEnrichmentPrimaryId: (id: string | null) => void;
|
||||
setScrobblingMasterEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
setDownloadFolder: (v: string) => void;
|
||||
setOfflineDownloadDir: (v: string) => void;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
lastfmGetAllLovedTracks,
|
||||
lastfmLoveTrack,
|
||||
lastfmUnloveTrack,
|
||||
} from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => void;
|
||||
type GetState = () => PlayerState;
|
||||
|
||||
/**
|
||||
* Four Last.fm love-related actions. The `lastfmLovedCache` is a map
|
||||
* keyed by `${title}::${artist}` (not by track id) so other queue rows
|
||||
* showing the same song update too when one is loved/unloved.
|
||||
* `syncLastfmLovedTracks` merges the server's loved list with local
|
||||
* cache — local likes win on conflict.
|
||||
*/
|
||||
export function createLastfmActions(set: SetState, get: GetState): Pick<
|
||||
PlayerState,
|
||||
'toggleLastfmLove' | 'setLastfmLoved' | 'setLastfmLovedForSong' | 'syncLastfmLovedTracks'
|
||||
> {
|
||||
return {
|
||||
toggleLastfmLove: () => {
|
||||
const { currentTrack, lastfmLoved } = get();
|
||||
const { lastfmSessionKey } = useAuthStore.getState();
|
||||
if (!currentTrack || !lastfmSessionKey) return;
|
||||
const newLoved = !lastfmLoved;
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ lastfmLoved: newLoved, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: newLoved } }));
|
||||
if (newLoved) {
|
||||
lastfmLoveTrack(currentTrack, lastfmSessionKey);
|
||||
} else {
|
||||
lastfmUnloveTrack(currentTrack, lastfmSessionKey);
|
||||
}
|
||||
},
|
||||
|
||||
setLastfmLoved: (v) => {
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ lastfmLoved: v, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v } }));
|
||||
} else {
|
||||
set({ lastfmLoved: v });
|
||||
}
|
||||
},
|
||||
|
||||
syncLastfmLovedTracks: async () => {
|
||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore.getState();
|
||||
if (!lastfmSessionKey || !lastfmUsername) return;
|
||||
const tracks = await lastfmGetAllLovedTracks(lastfmUsername, lastfmSessionKey);
|
||||
const newCache: Record<string, boolean> = {};
|
||||
for (const t of tracks) newCache[`${t.title}::${t.artist}`] = true;
|
||||
// Merge with existing cache (local likes take precedence)
|
||||
set(s => ({ lastfmLovedCache: { ...newCache, ...s.lastfmLovedCache } }));
|
||||
// Update current track's loved state if it's in the new cache
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false;
|
||||
set({ lastfmLoved: loved });
|
||||
}
|
||||
},
|
||||
|
||||
setLastfmLovedForSong: (title, artist, v) => {
|
||||
const cacheKey = `${title}::${artist}`;
|
||||
const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist;
|
||||
set(s => ({
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v },
|
||||
...(isCurrentTrack ? { lastfmLoved: v } : {}),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
persistLastfmLovedCache,
|
||||
readInitialLastfmLovedCache,
|
||||
} from './lastfmLovedCacheStorage';
|
||||
|
||||
const CACHE_KEY = 'psysonic_lastfm_loved_cache';
|
||||
const LEGACY_KEY = 'psysonic-player';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('readInitialLastfmLovedCache', () => {
|
||||
it('defaults to an empty object when nothing is stored', () => {
|
||||
expect(readInitialLastfmLovedCache()).toEqual({});
|
||||
});
|
||||
|
||||
it('reads the dedicated cache key and drops non-boolean entries', () => {
|
||||
window.localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ 'Hello::Adele': true, 'Bad::': false, '': true, n: 1 }),
|
||||
);
|
||||
expect(readInitialLastfmLovedCache()).toEqual({ 'Hello::Adele': true, 'Bad::': false });
|
||||
});
|
||||
|
||||
it('falls back to the legacy psysonic-player blob', () => {
|
||||
window.localStorage.setItem(
|
||||
LEGACY_KEY,
|
||||
JSON.stringify({
|
||||
state: {
|
||||
lastfmLovedCache: { 'T::A': true },
|
||||
queueItems: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(readInitialLastfmLovedCache()).toEqual({ 'T::A': true });
|
||||
});
|
||||
|
||||
it('prefers the dedicated key over the legacy blob', () => {
|
||||
window.localStorage.setItem(CACHE_KEY, JSON.stringify({ 'A::B': false }));
|
||||
window.localStorage.setItem(
|
||||
LEGACY_KEY,
|
||||
JSON.stringify({ state: { lastfmLovedCache: { 'A::B': true } } }),
|
||||
);
|
||||
expect(readInitialLastfmLovedCache()).toEqual({ 'A::B': false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistLastfmLovedCache', () => {
|
||||
it('round-trips through readInitialLastfmLovedCache', () => {
|
||||
persistLastfmLovedCache({ 'Song::Artist': true });
|
||||
expect(readInitialLastfmLovedCache()).toEqual({ 'Song::Artist': true });
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Last.fm loved-track cache keyed by `${title}::${artist}`. Kept out of the
|
||||
* main `psysonic-player` blob so a large queue cannot block writes (thin-state
|
||||
* #872 quota issue). Same split-storage pattern as `playerPrefsStorage.ts`.
|
||||
*/
|
||||
const CACHE_STORAGE_KEY = 'psysonic_lastfm_loved_cache';
|
||||
const LEGACY_PLAYER_STORAGE_KEY = 'psysonic-player';
|
||||
|
||||
function sanitizeCache(raw: unknown): Record<string, boolean> {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readLegacyCacheFromPlayerBlob(): Record<string, boolean> | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LEGACY_PLAYER_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { state?: { lastfmLovedCache?: unknown } };
|
||||
const cache = parsed.state?.lastfmLovedCache;
|
||||
if (!cache) return null;
|
||||
const sanitized = sanitizeCache(cache);
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readInitialLastfmLovedCache(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CACHE_STORAGE_KEY);
|
||||
if (raw) return sanitizeCache(JSON.parse(raw));
|
||||
} catch {
|
||||
// fall through to legacy blob / empty
|
||||
}
|
||||
|
||||
return readLegacyCacheFromPlayerBlob() ?? {};
|
||||
}
|
||||
|
||||
export function persistLastfmLovedCache(cache: Record<string, boolean>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache)));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getMusicNetworkRuntimeOrNull } from '../music-network';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
) => void;
|
||||
type GetState = () => PlayerState;
|
||||
|
||||
/**
|
||||
* Loved-track actions routed through the Music Network runtime (enrichment
|
||||
* primary). `networkLovedCache` is keyed by `${title}::${artist}` (not track id)
|
||||
* so other queue rows showing the same song update too. `syncNetworkLovedTracks`
|
||||
* merges the primary's loved list with the local cache — local likes win.
|
||||
*
|
||||
* The love write itself is best-effort on the runtime; the cache update is
|
||||
* optimistic so the UI reflects the toggle immediately.
|
||||
*/
|
||||
export function createNetworkLoveActions(set: SetState, get: GetState): Pick<
|
||||
PlayerState,
|
||||
'toggleNetworkLove' | 'setNetworkLoved' | 'setNetworkLovedForSong' | 'syncNetworkLovedTracks'
|
||||
> {
|
||||
return {
|
||||
toggleNetworkLove: () => {
|
||||
const { currentTrack, networkLoved } = get();
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
if (!currentTrack || !runtime?.getEnrichmentPrimaryId()) return;
|
||||
const newLoved = !networkLoved;
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ networkLoved: newLoved, networkLovedCache: { ...s.networkLovedCache, [cacheKey]: newLoved } }));
|
||||
void runtime.setTrackLoved({ title: currentTrack.title, artist: currentTrack.artist }, newLoved);
|
||||
},
|
||||
|
||||
setNetworkLoved: (v) => {
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const cacheKey = `${currentTrack.title}::${currentTrack.artist}`;
|
||||
set(s => ({ networkLoved: v, networkLovedCache: { ...s.networkLovedCache, [cacheKey]: v } }));
|
||||
} else {
|
||||
set({ networkLoved: v });
|
||||
}
|
||||
},
|
||||
|
||||
syncNetworkLovedTracks: async () => {
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
if (!runtime?.getEnrichmentPrimaryId()) return;
|
||||
const newCache = await runtime.syncLovedTracks();
|
||||
// Merge with existing cache (local likes take precedence).
|
||||
set(s => ({ networkLovedCache: { ...newCache, ...s.networkLovedCache } }));
|
||||
const { currentTrack } = get();
|
||||
if (currentTrack) {
|
||||
const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false;
|
||||
set({ networkLoved: loved });
|
||||
}
|
||||
},
|
||||
|
||||
setNetworkLovedForSong: (title, artist, v) => {
|
||||
const cacheKey = `${title}::${artist}`;
|
||||
const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist;
|
||||
set(s => ({
|
||||
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: v },
|
||||
...(isCurrentTrack ? { networkLoved: v } : {}),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Loved-track cache keyed by `${title}::${artist}` — provider-agnostic (the key
|
||||
* format predates and survives the Music Network rename). Kept out of the main
|
||||
* `psysonic-player` blob so a large queue cannot block writes (thin-state #872).
|
||||
*
|
||||
* Migration: reads fall back from the current key to the legacy
|
||||
* `psysonic_lastfm_loved_cache`, then to the legacy player blob, so no loved
|
||||
* state is lost across the Last.fm → Music Network rename.
|
||||
*/
|
||||
const CACHE_STORAGE_KEY = 'psysonic_network_loved_cache';
|
||||
const LEGACY_CACHE_STORAGE_KEY = 'psysonic_lastfm_loved_cache';
|
||||
const LEGACY_PLAYER_STORAGE_KEY = 'psysonic-player';
|
||||
|
||||
function sanitizeCache(raw: unknown): Record<string, boolean> {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readKey(key: string): Record<string, boolean> | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const sanitized = sanitizeCache(JSON.parse(raw));
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLegacyCacheFromPlayerBlob(): Record<string, boolean> | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LEGACY_PLAYER_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { state?: { lastfmLovedCache?: unknown; networkLovedCache?: unknown } };
|
||||
const cache = parsed.state?.networkLovedCache ?? parsed.state?.lastfmLovedCache;
|
||||
if (!cache) return null;
|
||||
const sanitized = sanitizeCache(cache);
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readInitialNetworkLovedCache(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
return (
|
||||
readKey(CACHE_STORAGE_KEY)
|
||||
?? readKey(LEGACY_CACHE_STORAGE_KEY)
|
||||
?? readLegacyCacheFromPlayerBlob()
|
||||
?? {}
|
||||
);
|
||||
}
|
||||
|
||||
export function persistNetworkLovedCache(cache: Record<string, boolean>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache)));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { reportNowPlaying } from '../api/subsonicScrobble';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||
import { getMusicNetworkRuntimeOrNull } from '../music-network';
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
||||
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
||||
@@ -330,7 +330,7 @@ export function runPlayTrack(
|
||||
buffered: 0,
|
||||
currentTime: initialTime,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
networkLoved: false,
|
||||
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
|
||||
// extrapolate while RangedHttpSource / legacy reader is still buffering.
|
||||
isPlaying: playbackSourceHint !== 'stream',
|
||||
@@ -412,18 +412,28 @@ export function runPlayTrack(
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
// Navidrome now-playing follows nowPlayingEnabled; Music Network now-playing
|
||||
// follows scrobbling, as Last.fm now-playing did (runtime gates internally).
|
||||
const { nowPlayingEnabled: npEnabled } = useAuthStore.getState();
|
||||
if (npEnabled) reportNowPlaying(scopedTrack.id, playbackSid);
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(scopedTrack, lfmKey);
|
||||
lastfmGetTrackLoved(scopedTrack.title, scopedTrack.artist, lfmKey).then(loved => {
|
||||
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
|
||||
set(s => ({
|
||||
lastfmLoved: loved,
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
void runtime?.dispatchNowPlaying({
|
||||
title: scopedTrack.title,
|
||||
artist: scopedTrack.artist,
|
||||
album: scopedTrack.album,
|
||||
duration: scopedTrack.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (runtime?.getEnrichmentPrimaryId()) {
|
||||
void runtime
|
||||
.isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist })
|
||||
.then(loved => {
|
||||
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
|
||||
set(s => ({
|
||||
networkLoved: loved,
|
||||
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
}
|
||||
syncQueueToServer(get().queueItems, scopedTrack, initialTime);
|
||||
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
|
||||
|
||||
@@ -4,6 +4,9 @@ import { persist } from 'zustand/middleware';
|
||||
export type PlayerBarLayoutItemId =
|
||||
| 'starRating'
|
||||
| 'favorite'
|
||||
// 'lastfmLove' is the enrichment-primary love button. The id is kept (not
|
||||
// renamed to 'networkLove') because it is persisted in user layouts — renaming
|
||||
// would silently drop the button from existing configs. Label is provider-neutral.
|
||||
| 'lastfmLove'
|
||||
| 'playbackRate'
|
||||
| 'equalizer'
|
||||
|
||||
@@ -28,14 +28,20 @@ vi.mock('@/api/subsonic', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock('@/music-network', () => {
|
||||
const runtime = {
|
||||
getEnrichmentPrimaryId: vi.fn(() => null),
|
||||
dispatchScrobble: vi.fn(async () => undefined),
|
||||
dispatchNowPlaying: vi.fn(async () => undefined),
|
||||
isTrackLoved: vi.fn(async () => false),
|
||||
setTrackLoved: vi.fn(async () => undefined),
|
||||
syncLovedTracks: vi.fn(async () => ({})),
|
||||
};
|
||||
return {
|
||||
getMusicNetworkRuntime: () => runtime,
|
||||
getMusicNetworkRuntimeOrNull: () => runtime,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
@@ -160,13 +166,13 @@ describe('audio:track_switched', () => {
|
||||
expect(s.queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => {
|
||||
it('resets scrobbled + networkLoved flags so the new track can be rescored', () => {
|
||||
const queue = makeTracks(2);
|
||||
seedQueue(queue, { index: 0, currentTrack: queue[0] });
|
||||
usePlayerStore.setState({ scrobbled: true, lastfmLoved: true });
|
||||
usePlayerStore.setState({ scrobbled: true, networkLoved: true });
|
||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||
expect(usePlayerStore.getState().scrobbled).toBe(false);
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
|
||||
expect(usePlayerStore.getState().networkLoved).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
* Covers the smaller surfaces 2a / 2b / 2c skipped: shuffleQueue,
|
||||
* shuffleUpcomingQueue, stop, setStarredOverride / setUserRatingOverride,
|
||||
* toggleQueue / setQueueVisible, toggleFullscreen, openContextMenu /
|
||||
* closeContextMenu, openSongInfo / closeSongInfo, setLastfmLoved /
|
||||
* setLastfmLovedForSong, pruneUpcomingToCurrent, setProgress.
|
||||
* closeContextMenu, openSongInfo / closeSongInfo, setNetworkLoved /
|
||||
* setNetworkLovedForSong, pruneUpcomingToCurrent, setProgress.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runtimeMock = {
|
||||
getEnrichmentPrimaryId: vi.fn<() => string | null>(() => null),
|
||||
setTrackLoved: vi.fn(async () => undefined),
|
||||
isTrackLoved: vi.fn(async () => false),
|
||||
syncLovedTracks: vi.fn(async () => ({})),
|
||||
};
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
@@ -30,13 +37,9 @@ vi.mock('@/api/subsonic', () => ({
|
||||
unstar: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
vi.mock('@/music-network', () => ({
|
||||
getMusicNetworkRuntime: () => runtimeMock,
|
||||
getMusicNetworkRuntimeOrNull: () => runtimeMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
@@ -44,7 +47,6 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
}));
|
||||
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
@@ -52,6 +54,10 @@ import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
beforeEach(() => {
|
||||
resetPlayerStore();
|
||||
resetAuthStore();
|
||||
runtimeMock.getEnrichmentPrimaryId.mockReturnValue(null);
|
||||
runtimeMock.setTrackLoved.mockClear();
|
||||
runtimeMock.isTrackLoved.mockResolvedValue(false);
|
||||
runtimeMock.syncLovedTracks.mockResolvedValue({});
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
@@ -147,53 +153,55 @@ describe('toggleFullscreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastfmLoved / toggleLastfmLove', () => {
|
||||
it('setLastfmLoved writes the flag verbatim (no session-key gate inside the setter)', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack(), lastfmLoved: false });
|
||||
usePlayerStore.getState().setLastfmLoved(true);
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(true);
|
||||
describe('setNetworkLoved / toggleNetworkLove', () => {
|
||||
it('setNetworkLoved writes the flag verbatim (no primary gate inside the setter)', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack(), networkLoved: false });
|
||||
usePlayerStore.getState().setNetworkLoved(true);
|
||||
expect(usePlayerStore.getState().networkLoved).toBe(true);
|
||||
});
|
||||
|
||||
it('setLastfmLoved also caches the value under "title::artist" when there is a current track', () => {
|
||||
it('setNetworkLoved also caches the value under "title::artist" when there is a current track', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ title: 'Hello', artist: 'Adele' }),
|
||||
lastfmLoved: false,
|
||||
networkLoved: false,
|
||||
});
|
||||
usePlayerStore.getState().setLastfmLoved(true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['Hello::Adele']).toBe(true);
|
||||
usePlayerStore.getState().setNetworkLoved(true);
|
||||
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
|
||||
});
|
||||
|
||||
it('setLastfmLoved without a current track only updates the flag, not the cache', () => {
|
||||
usePlayerStore.setState({ currentTrack: null, lastfmLoved: false, lastfmLovedCache: {} });
|
||||
usePlayerStore.getState().setLastfmLoved(true);
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache).toEqual({});
|
||||
it('setNetworkLoved without a current track only updates the flag, not the cache', () => {
|
||||
usePlayerStore.setState({ currentTrack: null, networkLoved: false, networkLovedCache: {} });
|
||||
usePlayerStore.getState().setNetworkLoved(true);
|
||||
expect(usePlayerStore.getState().networkLoved).toBe(true);
|
||||
expect(usePlayerStore.getState().networkLovedCache).toEqual({});
|
||||
});
|
||||
|
||||
it('toggleLastfmLove is a no-op without a current track', () => {
|
||||
useAuthStore.setState({ lastfmSessionKey: 'session-key' });
|
||||
usePlayerStore.setState({ currentTrack: null, lastfmLoved: false });
|
||||
usePlayerStore.getState().toggleLastfmLove();
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
|
||||
it('toggleNetworkLove is a no-op without a current track', () => {
|
||||
runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
|
||||
usePlayerStore.setState({ currentTrack: null, networkLoved: false });
|
||||
usePlayerStore.getState().toggleNetworkLove();
|
||||
expect(usePlayerStore.getState().networkLoved).toBe(false);
|
||||
expect(runtimeMock.setTrackLoved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggleLastfmLove flips state when a track + session are present', () => {
|
||||
useAuthStore.setState({ lastfmSessionKey: 'session-key' });
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ title: 'T', artist: 'A' }), lastfmLoved: false });
|
||||
it('toggleNetworkLove flips state and writes through the runtime when a track + primary are present', () => {
|
||||
runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ title: 'T', artist: 'A' }), networkLoved: false });
|
||||
|
||||
usePlayerStore.getState().toggleLastfmLove();
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['T::A']).toBe(true);
|
||||
usePlayerStore.getState().toggleNetworkLove();
|
||||
expect(usePlayerStore.getState().networkLoved).toBe(true);
|
||||
expect(usePlayerStore.getState().networkLovedCache['T::A']).toBe(true);
|
||||
expect(runtimeMock.setTrackLoved).toHaveBeenCalledWith({ title: 'T', artist: 'A' }, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastfmLovedForSong', () => {
|
||||
describe('setNetworkLovedForSong', () => {
|
||||
it('caches loved state under the "title::artist" key', () => {
|
||||
usePlayerStore.getState().setLastfmLovedForSong('Hello', 'Adele', true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['Hello::Adele']).toBe(true);
|
||||
usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', true);
|
||||
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
|
||||
|
||||
usePlayerStore.getState().setLastfmLovedForSong('Hello', 'Adele', false);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['Hello::Adele']).toBe(false);
|
||||
usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', false);
|
||||
expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -67,12 +67,6 @@ vi.mock('@/api/subsonicStarRating', () => ({
|
||||
probeEntityRatingSupport: vi.fn(async () => 'track_only'),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* produces controlled error state, not partial mutation. Audio event
|
||||
* handlers live in `playerStore.events.test.ts`.
|
||||
*
|
||||
* Heavy module-level mocking: `subsonic.ts` (server APIs) and `lastfm.ts`
|
||||
* (scrobble + loved lookups) are mocked to no-ops so navigation-style
|
||||
* Heavy module-level mocking: `subsonic.ts` (server APIs) and the music-network
|
||||
* runtime (scrobble + loved lookups) are mocked to no-ops so navigation-style
|
||||
* `playTrack` calls (from `next` / `previous`) don't try to hit a real
|
||||
* server. The store's own action bodies still run for real.
|
||||
*/
|
||||
@@ -32,14 +32,20 @@ vi.mock('@/api/subsonic', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock('@/music-network', () => {
|
||||
const runtime = {
|
||||
getEnrichmentPrimaryId: vi.fn(() => null),
|
||||
dispatchScrobble: vi.fn(async () => undefined),
|
||||
dispatchNowPlaying: vi.fn(async () => undefined),
|
||||
isTrackLoved: vi.fn(async () => false),
|
||||
setTrackLoved: vi.fn(async () => undefined),
|
||||
syncLovedTracks: vi.fn(async () => ({})),
|
||||
};
|
||||
return {
|
||||
getMusicNetworkRuntime: () => runtime,
|
||||
getMusicNetworkRuntimeOrNull: () => runtime,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
|
||||
@@ -31,12 +31,6 @@ vi.mock('@/api/subsonic', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { readInitialLastfmLovedCache, persistLastfmLovedCache } from './lastfmLovedCacheStorage';
|
||||
import { readInitialNetworkLovedCache, persistNetworkLovedCache } from './networkLovedCacheStorage';
|
||||
import { readInitialPlayerPrefs, persistPlayerPrefs } from './playerPrefsStorage';
|
||||
import { createHydrationGatedStorage, createSafeJSONStorage } from './safeStorage';
|
||||
import { emitPlaybackProgress } from './playbackProgress';
|
||||
@@ -8,7 +8,7 @@ import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
|
||||
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||
import { canonicalQueueServerKey } from '../utils/server/serverIndexKey';
|
||||
import { readInitialQueueVisibility } from './queueVisibilityStorage';
|
||||
import { createLastfmActions } from './lastfmActions';
|
||||
import { createNetworkLoveActions } from './networkLoveActions';
|
||||
import { createMiscActions } from './miscActions';
|
||||
import { runNext } from './nextAction';
|
||||
import { runPlayTrack } from './playTrackAction';
|
||||
@@ -22,7 +22,7 @@ import { createUiStateActions } from './uiStateActions';
|
||||
import { createUndoRedoActions } from './undoRedoActions';
|
||||
|
||||
const initialPlayerPrefs = readInitialPlayerPrefs();
|
||||
const initialLastfmLovedCache = readInitialLastfmLovedCache();
|
||||
const initialNetworkLovedCache = readInitialNetworkLovedCache();
|
||||
let playerPersistWritesEnabled = false;
|
||||
|
||||
export const usePlayerStore = create<PlayerState>()(
|
||||
@@ -56,8 +56,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTime: 0,
|
||||
volume: initialPlayerPrefs.volume,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
lastfmLovedCache: initialLastfmLovedCache,
|
||||
networkLoved: false,
|
||||
networkLovedCache: initialNetworkLovedCache,
|
||||
starredOverrides: {},
|
||||
userRatingOverrides: {},
|
||||
isQueueVisible: readInitialQueueVisibility(),
|
||||
@@ -71,7 +71,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
songInfoModal: { isOpen: false, songId: null },
|
||||
|
||||
...createUiStateActions(set),
|
||||
...createLastfmActions(set, get),
|
||||
...createNetworkLoveActions(set, get),
|
||||
...createQueueMutationActions(set, get),
|
||||
...createTransportLightActions(set, get),
|
||||
...createUndoRedoActions(set, get),
|
||||
@@ -97,7 +97,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
),
|
||||
partialize: (state) => ({
|
||||
// volume/repeatMode → psysonic_player_prefs; isQueueVisible →
|
||||
// psysonic_queue_visible; lastfmLovedCache → psysonic_lastfm_loved_cache.
|
||||
// psysonic_queue_visible; networkLovedCache → psysonic_network_loved_cache.
|
||||
// Kept out of this blob so a huge queue cannot block their writes.
|
||||
currentTrack: state.currentTrack,
|
||||
queueServerId: state.queueServerId,
|
||||
@@ -159,6 +159,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
delete blob.repeatMode;
|
||||
delete blob.isQueueVisible;
|
||||
delete blob.lastfmLovedCache;
|
||||
delete blob.networkLovedCache;
|
||||
// Persist the canonical form back onto the merged blob so subsequent
|
||||
// reads of state.queueServerId always see the index key.
|
||||
if (canonicalSid !== null) {
|
||||
@@ -187,8 +188,8 @@ usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.volume !== prev.volume || state.repeatMode !== prev.repeatMode) {
|
||||
persistPlayerPrefs({ volume: state.volume, repeatMode: state.repeatMode });
|
||||
}
|
||||
if (state.lastfmLovedCache !== prev.lastfmLovedCache) {
|
||||
persistLastfmLovedCache(state.lastfmLovedCache);
|
||||
if (state.networkLovedCache !== prev.networkLovedCache) {
|
||||
persistNetworkLovedCache(state.networkLovedCache);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ export interface PlayerState {
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
scrobbled: boolean;
|
||||
lastfmLoved: boolean;
|
||||
lastfmLovedCache: Record<string, boolean>;
|
||||
networkLoved: boolean;
|
||||
networkLovedCache: Record<string, boolean>;
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
/** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */
|
||||
@@ -190,10 +190,10 @@ export interface PlayerState {
|
||||
/** Ctrl+Shift+Z / Cmd+Shift+Z — opposite of `undoLastQueueEdit` while redo stack is non-empty. */
|
||||
redoLastQueueEdit: () => boolean;
|
||||
|
||||
toggleLastfmLove: () => void;
|
||||
setLastfmLoved: (v: boolean) => void;
|
||||
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
|
||||
syncLastfmLovedTracks: () => Promise<void>;
|
||||
toggleNetworkLove: () => void;
|
||||
setNetworkLoved: (v: boolean) => void;
|
||||
setNetworkLovedForSong: (title: string, artist: string, v: boolean) => void;
|
||||
syncNetworkLovedTracks: () => Promise<void>;
|
||||
|
||||
resetAudioPause: () => void;
|
||||
initializeFromServerQueue: () => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user