From 1a82376f8cba78e60311948430cca812c2b1880b Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:56:30 +0200 Subject: [PATCH] feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- CHANGELOG.md | 9 + PRIVACY.md | 20 +- .../crates/psysonic-integration/src/remote.rs | 181 ++++++++- src-tauri/src/lib.rs | 4 +- src/api/lastfm.ts | 378 ------------------ src/app/AppShell.tsx | 4 +- src/app/bootstrap.ts | 2 + src/app/musicNetworkBridge.ts | 38 ++ src/components/ContextMenu.test.tsx | 8 - src/components/ContextMenu.tsx | 10 +- src/components/LastfmIndicator.tsx | 39 -- src/components/MiniPlayer.test.tsx | 6 - src/components/MusicNetworkIndicator.tsx | 47 +++ src/components/PlayerBar.test.tsx | 8 - src/components/PlayerBar.tsx | 15 +- src/components/QueuePanel.test.tsx | 6 - src/components/WaveformSeek.test.tsx | 6 - .../ArtistDetailSimilarArtists.tsx | 6 +- .../contextMenu/AlbumContextItems.tsx | 2 +- .../contextMenu/ArtistContextItems.tsx | 2 +- .../contextMenu/PlaylistContextItems.tsx | 2 +- .../contextMenu/QueueItemContextItems.tsx | 22 +- .../contextMenu/SongContextItems.tsx | 35 +- .../contextMenu/contextMenuItemTypes.ts | 4 +- src/components/nowPlaying/Hero.tsx | 82 ++-- src/components/playerBar/PlayerTrackInfo.tsx | 26 +- src/components/settings/IntegrationsTab.tsx | 123 +----- .../musicNetwork/ConnectProviderForm.tsx | 132 ++++++ .../musicNetwork/EnrichmentPrimarySelect.tsx | 53 +++ .../musicNetwork/MalojaProxyWarning.tsx | 21 + .../musicNetwork/MusicNetworkSection.tsx | 133 ++++++ .../musicNetwork/ScrobbleDestinationCard.tsx | 93 +++++ .../settings/musicNetwork/presetIcon.tsx | 27 ++ .../musicNetwork/useMusicNetworkState.ts | 37 ++ src/components/settings/settingsTabs.ts | 2 +- src/hooks/useArtistSimilarArtists.ts | 14 +- src/hooks/useEnrichmentPrimaryIcon.ts | 11 + src/hooks/useEnrichmentPrimaryLabel.ts | 11 + src/hooks/useNowPlayingFetchers.test.ts | 7 +- src/hooks/useNowPlayingFetchers.ts | 99 ++--- src/hooks/useNowPlayingPrewarm.ts | 6 +- src/hooks/useNowPlayingStarLove.ts | 35 +- src/locales/de/connection.ts | 2 - src/locales/de/contextMenu.ts | 4 +- src/locales/de/help.ts | 4 +- src/locales/de/index.ts | 2 + src/locales/de/musicNetwork.ts | 55 +++ src/locales/de/nowPlaying.ts | 3 - src/locales/de/settings.ts | 14 +- src/locales/de/statistics.ts | 3 +- src/locales/en/connection.ts | 2 - src/locales/en/contextMenu.ts | 4 +- src/locales/en/help.ts | 4 +- src/locales/en/index.ts | 2 + src/locales/en/musicNetwork.ts | 55 +++ src/locales/en/nowPlaying.ts | 3 - src/locales/en/settings.ts | 14 +- src/locales/en/statistics.ts | 3 +- src/locales/es/connection.ts | 2 - src/locales/es/contextMenu.ts | 4 +- src/locales/es/help.ts | 4 +- src/locales/es/index.ts | 2 + src/locales/es/musicNetwork.ts | 55 +++ src/locales/es/nowPlaying.ts | 3 - src/locales/es/settings.ts | 14 +- src/locales/es/statistics.ts | 3 +- src/locales/fr/connection.ts | 2 - src/locales/fr/contextMenu.ts | 4 +- src/locales/fr/help.ts | 4 +- src/locales/fr/index.ts | 2 + src/locales/fr/musicNetwork.ts | 55 +++ src/locales/fr/nowPlaying.ts | 3 - src/locales/fr/settings.ts | 14 +- src/locales/fr/statistics.ts | 3 +- src/locales/nb/connection.ts | 2 - src/locales/nb/contextMenu.ts | 4 +- src/locales/nb/help.ts | 4 +- src/locales/nb/index.ts | 2 + src/locales/nb/musicNetwork.ts | 55 +++ src/locales/nb/nowPlaying.ts | 3 - src/locales/nb/settings.ts | 14 +- src/locales/nb/statistics.ts | 3 +- src/locales/nl/connection.ts | 2 - src/locales/nl/contextMenu.ts | 4 +- src/locales/nl/help.ts | 4 +- src/locales/nl/index.ts | 2 + src/locales/nl/musicNetwork.ts | 55 +++ src/locales/nl/nowPlaying.ts | 3 - src/locales/nl/settings.ts | 14 +- src/locales/nl/statistics.ts | 3 +- src/locales/ro/connection.ts | 2 - src/locales/ro/contextMenu.ts | 4 +- src/locales/ro/help.ts | 4 +- src/locales/ro/index.ts | 2 + src/locales/ro/musicNetwork.ts | 55 +++ src/locales/ro/nowPlaying.ts | 3 - src/locales/ro/settings.ts | 14 +- src/locales/ro/statistics.ts | 3 +- src/locales/ru/connection.ts | 2 - src/locales/ru/contextMenu.ts | 4 +- src/locales/ru/help.ts | 4 +- src/locales/ru/index.ts | 2 + src/locales/ru/musicNetwork.ts | 55 +++ src/locales/ru/nowPlaying.ts | 3 - src/locales/ru/settings.ts | 15 +- src/locales/ru/statistics.ts | 3 +- src/locales/zh/connection.ts | 2 - src/locales/zh/contextMenu.ts | 4 +- src/locales/zh/help.ts | 4 +- src/locales/zh/index.ts | 2 + src/locales/zh/musicNetwork.ts | 55 +++ src/locales/zh/nowPlaying.ts | 3 - src/locales/zh/settings.ts | 14 +- src/locales/zh/statistics.ts | 3 +- src/music-network/contracts/AuthStrategy.ts | 16 + src/music-network/contracts/EnrichmentWire.ts | 47 +++ src/music-network/contracts/PresetManifest.ts | 76 ++++ src/music-network/contracts/ScrobbleWire.ts | 76 ++++ src/music-network/core/accounts.ts | 68 ++++ src/music-network/core/capabilities.ts | 72 ++++ src/music-network/core/errors.ts | 45 +++ src/music-network/core/types.ts | 95 +++++ src/music-network/index.ts | 50 +++ .../registry/presetRegistry.test.ts | 54 +++ src/music-network/registry/presetRegistry.ts | 48 +++ src/music-network/registry/presetTypes.ts | 15 + .../registry/registerBuiltinWires.ts | 17 + src/music-network/registry/wireRegistry.ts | 32 ++ .../runtime/CapabilityProbe.test.ts | 68 ++++ src/music-network/runtime/CapabilityProbe.ts | 32 ++ src/music-network/runtime/EnrichmentRouter.ts | 28 ++ .../runtime/MusicNetworkRuntime.test.ts | 190 +++++++++ .../runtime/MusicNetworkRuntime.ts | 304 ++++++++++++++ .../runtime/ScrobbleOrchestrator.ts | 55 +++ .../runtime/accountPersistence.test.ts | 69 ++++ .../runtime/accountPersistence.ts | 73 ++++ src/music-network/runtime/contextResolver.ts | 22 + .../runtime/getMusicNetworkRuntime.ts | 39 ++ src/music-network/runtime/store.ts | 22 + src/music-network/ui/useEnrichmentPrimary.ts | 36 ++ .../AudioscrobblerWire.probe.test.ts | 72 ++++ .../AudioscrobblerWire.scrobble.test.ts | 87 ++++ .../audioscrobbler/AudioscrobblerWire.ts | 307 ++++++++++++++ .../wires/audioscrobbler/auth/tokenPoll.ts | 76 ++++ .../wires/audioscrobbler/client.test.ts | 81 ++++ .../wires/audioscrobbler/client.ts | 54 +++ .../audioscrobbler/presets/customGnufm.ts | 53 +++ .../wires/audioscrobbler/presets/lastfm.ts | 41 ++ .../wires/audioscrobbler/presets/librefm.ts | 42 ++ .../audioscrobbler/presets/malojaCompat.ts | 50 +++ .../wires/audioscrobbler/presets/rocksky.ts | 51 +++ .../wires/audioscrobbler/sign.test.ts | 32 ++ .../wires/audioscrobbler/sign.ts | 22 + .../wires/listenbrainz/ListenBrainzWire.ts | 80 ++++ .../wires/listenbrainz/client.ts | 33 ++ .../wires/listenbrainz/presets/koito.ts | 48 +++ .../listenbrainz/presets/listenbrainz.ts | 40 ++ .../presets/malojaListenbrainz.ts | 44 ++ .../wires/maloja/MalojaNativeWire.ts | 58 +++ src/music-network/wires/maloja/client.ts | 31 ++ .../wires/maloja/presets/malojaNative.ts | 44 ++ .../wires/shared/apiKeyOnly.test.ts | 45 +++ src/music-network/wires/shared/apiKeyOnly.ts | 34 ++ .../wires/shared/invokeTransport.ts | 44 ++ src/pages/ArtistDetail.tsx | 10 +- src/pages/NowPlaying.tsx | 37 +- src/pages/Statistics.tsx | 81 ++-- src/store/audioEventHandlers.ts | 46 ++- .../audioListenerSetup/initialAudioSync.ts | 4 +- src/store/authLastfmActions.ts | 35 -- src/store/authMusicNetworkActions.ts | 25 ++ src/store/authStore.login.test.ts | 35 -- src/store/authStore.persistence.test.ts | 12 +- src/store/authStore.settings.test.ts | 1 - src/store/authStore.ts | 13 +- src/store/authStoreRehydrate.ts | 45 +++ src/store/authStoreTypes.ts | 22 +- src/store/lastfmActions.ts | 75 ---- src/store/lastfmLovedCacheStorage.test.ts | 59 --- src/store/lastfmLovedCacheStorage.ts | 55 --- src/store/networkLoveActions.ts | 65 +++ src/store/networkLovedCacheStorage.ts | 67 ++++ src/store/playTrackAction.ts | 36 +- src/store/playerBarLayoutStore.ts | 3 + src/store/playerStore.events.test.ts | 28 +- src/store/playerStore.misc.test.ts | 88 ++-- src/store/playerStore.persistence.test.ts | 6 - src/store/playerStore.playbackActions.test.ts | 26 +- src/store/playerStore.progress.test.ts | 6 - src/store/playerStore.ts | 19 +- src/store/playerStoreTypes.ts | 12 +- src/styles/components/np-dash.css | 22 +- 192 files changed, 5132 insertions(+), 1469 deletions(-) delete mode 100644 src/api/lastfm.ts create mode 100644 src/app/musicNetworkBridge.ts delete mode 100644 src/components/LastfmIndicator.tsx create mode 100644 src/components/MusicNetworkIndicator.tsx create mode 100644 src/components/settings/musicNetwork/ConnectProviderForm.tsx create mode 100644 src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx create mode 100644 src/components/settings/musicNetwork/MalojaProxyWarning.tsx create mode 100644 src/components/settings/musicNetwork/MusicNetworkSection.tsx create mode 100644 src/components/settings/musicNetwork/ScrobbleDestinationCard.tsx create mode 100644 src/components/settings/musicNetwork/presetIcon.tsx create mode 100644 src/components/settings/musicNetwork/useMusicNetworkState.ts create mode 100644 src/hooks/useEnrichmentPrimaryIcon.ts create mode 100644 src/hooks/useEnrichmentPrimaryLabel.ts create mode 100644 src/locales/de/musicNetwork.ts create mode 100644 src/locales/en/musicNetwork.ts create mode 100644 src/locales/es/musicNetwork.ts create mode 100644 src/locales/fr/musicNetwork.ts create mode 100644 src/locales/nb/musicNetwork.ts create mode 100644 src/locales/nl/musicNetwork.ts create mode 100644 src/locales/ro/musicNetwork.ts create mode 100644 src/locales/ru/musicNetwork.ts create mode 100644 src/locales/zh/musicNetwork.ts create mode 100644 src/music-network/contracts/AuthStrategy.ts create mode 100644 src/music-network/contracts/EnrichmentWire.ts create mode 100644 src/music-network/contracts/PresetManifest.ts create mode 100644 src/music-network/contracts/ScrobbleWire.ts create mode 100644 src/music-network/core/accounts.ts create mode 100644 src/music-network/core/capabilities.ts create mode 100644 src/music-network/core/errors.ts create mode 100644 src/music-network/core/types.ts create mode 100644 src/music-network/index.ts create mode 100644 src/music-network/registry/presetRegistry.test.ts create mode 100644 src/music-network/registry/presetRegistry.ts create mode 100644 src/music-network/registry/presetTypes.ts create mode 100644 src/music-network/registry/registerBuiltinWires.ts create mode 100644 src/music-network/registry/wireRegistry.ts create mode 100644 src/music-network/runtime/CapabilityProbe.test.ts create mode 100644 src/music-network/runtime/CapabilityProbe.ts create mode 100644 src/music-network/runtime/EnrichmentRouter.ts create mode 100644 src/music-network/runtime/MusicNetworkRuntime.test.ts create mode 100644 src/music-network/runtime/MusicNetworkRuntime.ts create mode 100644 src/music-network/runtime/ScrobbleOrchestrator.ts create mode 100644 src/music-network/runtime/accountPersistence.test.ts create mode 100644 src/music-network/runtime/accountPersistence.ts create mode 100644 src/music-network/runtime/contextResolver.ts create mode 100644 src/music-network/runtime/getMusicNetworkRuntime.ts create mode 100644 src/music-network/runtime/store.ts create mode 100644 src/music-network/ui/useEnrichmentPrimary.ts create mode 100644 src/music-network/wires/audioscrobbler/AudioscrobblerWire.probe.test.ts create mode 100644 src/music-network/wires/audioscrobbler/AudioscrobblerWire.scrobble.test.ts create mode 100644 src/music-network/wires/audioscrobbler/AudioscrobblerWire.ts create mode 100644 src/music-network/wires/audioscrobbler/auth/tokenPoll.ts create mode 100644 src/music-network/wires/audioscrobbler/client.test.ts create mode 100644 src/music-network/wires/audioscrobbler/client.ts create mode 100644 src/music-network/wires/audioscrobbler/presets/customGnufm.ts create mode 100644 src/music-network/wires/audioscrobbler/presets/lastfm.ts create mode 100644 src/music-network/wires/audioscrobbler/presets/librefm.ts create mode 100644 src/music-network/wires/audioscrobbler/presets/malojaCompat.ts create mode 100644 src/music-network/wires/audioscrobbler/presets/rocksky.ts create mode 100644 src/music-network/wires/audioscrobbler/sign.test.ts create mode 100644 src/music-network/wires/audioscrobbler/sign.ts create mode 100644 src/music-network/wires/listenbrainz/ListenBrainzWire.ts create mode 100644 src/music-network/wires/listenbrainz/client.ts create mode 100644 src/music-network/wires/listenbrainz/presets/koito.ts create mode 100644 src/music-network/wires/listenbrainz/presets/listenbrainz.ts create mode 100644 src/music-network/wires/listenbrainz/presets/malojaListenbrainz.ts create mode 100644 src/music-network/wires/maloja/MalojaNativeWire.ts create mode 100644 src/music-network/wires/maloja/client.ts create mode 100644 src/music-network/wires/maloja/presets/malojaNative.ts create mode 100644 src/music-network/wires/shared/apiKeyOnly.test.ts create mode 100644 src/music-network/wires/shared/apiKeyOnly.ts create mode 100644 src/music-network/wires/shared/invokeTransport.ts delete mode 100644 src/store/authLastfmActions.ts create mode 100644 src/store/authMusicNetworkActions.ts delete mode 100644 src/store/lastfmActions.ts delete mode 100644 src/store/lastfmLovedCacheStorage.test.ts delete mode 100644 src/store/lastfmLovedCacheStorage.ts create mode 100644 src/store/networkLoveActions.ts create mode 100644 src/store/networkLovedCacheStorage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cf2fd7..dd14bae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +### Music Network — scrobble to more than just Last.fm + +**By [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh), PR [#1066](https://github.com/Psychotoxical/psysonic/pull/1066)** + +* **Settings → Integrations** now hosts a **Music Network** that scrobbles your plays to one or more services at once: **Last.fm**, **Libre.fm**, **Rocksky**, **ListenBrainz** (the public service or any compatible server), **Maloja** (native, Audioscrobbler or ListenBrainz API), **Koito**, and any **custom GNU FM** instance. +* Choose a **primary** service — your loved tracks, similar artists and listening stats come from it — while scrobbles fan out to every connected service. A master switch turns the whole thing on or off. +* **Last.fm works exactly as before**, including love, similar artists and the Statistics page; your existing connection is migrated automatically on first launch. +* *Known limitation:* Rocksky currently rejects scrobbles whose title or artist contains non-standard (non-ASCII) characters — a Rocksky-side issue, not a Psysonic bug. + ### Sidebar — pin Now Playing to the top **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1000](https://github.com/Psychotoxical/psysonic/pull/1000), suggested by [@PHLAK](https://github.com/PHLAK)** diff --git a/PRIVACY.md b/PRIVACY.md index 07d90331..4e2cfa4a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until ### Your Subsonic / Navidrome server Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data. -### Last.fm -If you connect a Last.fm account in Settings, Psysonic sends: -- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback -- **Now Playing** — the currently playing track (title, artist, album) -- **Love / Unlove** — when you mark a track as loved or unloved +### Music Network (scrobble & enrichment services) +Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes: -All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings. +- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance +- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API +- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API) + +To each connected service, Psysonic may send: +- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback +- **Now Playing** — the currently playing track (title, artist, album), where the service supports it +- **Love / Unlove** — when you mark a track as loved, on services that support it + +Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings. ### LRCLIB (Lyrics) When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics. @@ -37,7 +43,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th The following data is stored exclusively on your device in the app's local storage directory and is never transmitted: - Server profiles (URL, username, password) -- Last.fm session key +- Scrobble service credentials (session keys / API tokens) - Playback preferences, themes, keybindings, and all other settings - Synced device manifests diff --git a/src-tauri/crates/psysonic-integration/src/remote.rs b/src-tauri/crates/psysonic-integration/src/remote.rs index ee64cca0..57e5fc1d 100644 --- a/src-tauri/crates/psysonic-integration/src/remote.rs +++ b/src-tauri/crates/psysonic-integration/src/remote.rs @@ -287,11 +287,31 @@ pub async fn resolve_stream_url(url: String) -> String { resolve_playlist_url(&client, &url).await.unwrap_or(url) } -/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions. -/// `params` is a list of [key, value] pairs (method must be included). -/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made. +/// Default Audioscrobbler v2 endpoint (Last.fm). Other presets (Libre.fm, +/// Rocksky, GNU FM, Maloja compat) pass their own `base_url`. +const LASTFM_API_BASE: &str = "https://ws.audioscrobbler.com/2.0/"; + +/// Generic Audioscrobbler v2 transport. Provider-agnostic: the caller supplies +/// the endpoint `base_url`, so Last.fm, Libre.fm, Rocksky, custom GNU FM and the +/// Shared HTTP client for the Music Network provider transports +/// (audioscrobbler / listenbrainz / maloja). A bounded timeout keeps a hung +/// provider from leaving scrobble/probe/loved-sync promises unresolved — the +/// sibling `fetch_*` commands in this module set the same kind of bound. +fn provider_http_client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string()) +} + +/// Maloja Audioscrobbler-compat surface all share this one command. +/// +/// `params` is a list of [key, value] pairs (method must be included). If `sign` +/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is +/// true a GET request is made, otherwise a form POST. #[tauri::command] -pub async fn lastfm_request( +pub async fn audioscrobbler_request( + base_url: String, params: Vec<[String; 2]>, sign: bool, get: bool, @@ -300,6 +320,8 @@ pub async fn lastfm_request( ) -> Result { use std::collections::HashMap; + let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url }; + let mut map: HashMap = params.into_iter().map(|[k, v]| (k, v)).collect(); map.insert("api_key".into(), api_key.clone()); @@ -317,17 +339,17 @@ pub async fn lastfm_request( map.insert("format".into(), "json".into()); - let client = reqwest::Client::new(); + let client = provider_http_client()?; let resp = if get { client - .get("https://ws.audioscrobbler.com/2.0/") + .get(&base) .query(&map) .header("User-Agent", subsonic_wire_user_agent()) .send() .await } else { client - .post("https://ws.audioscrobbler.com/2.0/") + .post(&base) .form(&map) .header("User-Agent", subsonic_wire_user_agent()) .send() @@ -337,7 +359,88 @@ pub async fn lastfm_request( let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if let Some(err) = json.get("error") { - return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or(""))); + return Err(format!("Audioscrobbler {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or(""))); + } + + Ok(json) +} + +/// Generic ListenBrainz transport. Used by both the direct +/// `api.listenbrainz.org` preset and the Maloja `/apis/listenbrainz` compat +/// surface — they differ only by `base_url`. Auth is a `Token` header. +/// +/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body` +/// is present the request is a POST with that body; otherwise a GET. +#[tauri::command] +pub async fn listenbrainz_request( + base_url: String, + path: String, + auth_token: String, + json_body: Option, +) -> Result { + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + let client = provider_http_client()?; + + let mut req = if json_body.is_some() { + client.post(&url) + } else { + client.get(&url) + }; + req = req + .header("Authorization", format!("Token {}", auth_token)) + .header("User-Agent", subsonic_wire_user_agent()); + if let Some(body) = json_body { + req = req.json(&body); + } + + let resp = req.send().await.map_err(|e| e.to_string())?; + let status = resp.status(); + let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + + if !status.is_success() { + let msg = json.get("error").and_then(|m| m.as_str()).unwrap_or(""); + return Err(format!("ListenBrainz {} {}", status.as_u16(), msg)); + } + + Ok(json) +} + +/// Generic Maloja native (`/apis/mlj_1`) transport. Protocol-agnostic JSON: +/// the caller builds the body (including the Maloja key) and chooses the path. +/// +/// `path` is appended to `base_url`. When `json_body` is present the request is a +/// POST with that body; otherwise a GET with `query` pairs. +#[tauri::command] +pub async fn maloja_request( + base_url: String, + path: String, + query: Vec<[String; 2]>, + json_body: Option, +) -> Result { + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + let client = provider_http_client()?; + + let resp = if let Some(body) = json_body { + client.post(&url).json(&body) + } else { + let q: Vec<(String, String)> = query.into_iter().map(|[k, v]| (k, v)).collect(); + client.get(&url).query(&q) + } + .header("User-Agent", subsonic_wire_user_agent()) + .send() + .await + .map_err(|e| e.to_string())?; + + let status = resp.status(); + let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + + if !status.is_success() { + let msg = json + .get("error") + .and_then(|e| e.get("desc").or_else(|| e.get("type"))) + .and_then(|m| m.as_str()) + .unwrap_or(""); + return Err(format!("Maloja {} {}", status.as_u16(), msg)); } Ok(json) @@ -495,4 +598,66 @@ mod tests { Some("https://pls.example/audio".to_string()) ); } + + // ── audioscrobbler_request ──────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn audioscrobbler_request_uses_custom_base_url() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(wm_path("/2.0/")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + r#"{"similarartists":{"artist":[{"name":"Boards of Canada"}]}}"#, + "application/json", + )) + .mount(&server) + .await; + + let base = format!("{}/2.0/", server.uri()); + let json = audioscrobbler_request( + base, + vec![ + ["method".into(), "artist.getSimilar".into()], + ["artist".into(), "Aphex Twin".into()], + ], + false, + true, + "key".into(), + "secret".into(), + ) + .await + .expect("request should succeed"); + + assert_eq!( + json["similarartists"]["artist"][0]["name"].as_str(), + Some("Boards of Canada") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn audioscrobbler_request_surfaces_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(wm_path("/2.0/")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + r#"{"error":9,"message":"Invalid session key"}"#, + "application/json", + )) + .mount(&server) + .await; + + let base = format!("{}/2.0/", server.uri()); + let err = audioscrobbler_request( + base, + vec![["method".into(), "track.scrobble".into()]], + true, + false, + "key".into(), + "secret".into(), + ) + .await + .expect_err("api error should map to Err"); + + assert!(err.contains("Audioscrobbler 9"), "unexpected error: {err}"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2741ba88..c50e9aec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -718,7 +718,9 @@ pub fn run() { audio::commands::audio_chain_preload, psysonic_integration::discord::discord_update_presence, psysonic_integration::discord::discord_clear_presence, - psysonic_integration::remote::lastfm_request, + psysonic_integration::remote::audioscrobbler_request, + psysonic_integration::remote::listenbrainz_request, + psysonic_integration::remote::maloja_request, psysonic_integration::navidrome::covers::upload_playlist_cover, psysonic_integration::navidrome::covers::upload_radio_cover, psysonic_integration::navidrome::covers::upload_artist_image, diff --git a/src/api/lastfm.ts b/src/api/lastfm.ts deleted file mode 100644 index 46fb1690..00000000 --- a/src/api/lastfm.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; -import { useAuthStore } from '../store/authStore'; - -const API_KEY = '9917fb39049225a13bec225ad6d49054'; -const API_SECRET = '03817dda02bee87a178aab7581abae3b'; - -export function lastfmIsConfigured(): boolean { - return Boolean(API_KEY && API_SECRET); -} - -function errMsg(e: unknown): string { - if (typeof e === 'string') return e; - if (e instanceof Error) return e.message; - return String(e); -} - -async function call(params: Record, sign = false, get = false): Promise { - const entries = Object.entries(params) as [string, string][]; - try { - const result = await invoke('lastfm_request', { - params: entries, - sign, - get, - apiKey: API_KEY, - apiSecret: API_SECRET, - }); - // Clear session error on any successful authenticated call - if (sign) useAuthStore.getState().setLastfmSessionError(false); - return result; - } catch (e) { - // Last.fm error codes 4, 9, 14 = auth/session invalid - if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) { - useAuthStore.getState().setLastfmSessionError(true); - } - throw e; - } -} - -export async function lastfmGetToken(): Promise { - try { - const data = await call({ method: 'auth.getToken' }, false, true); - return data.token as string; - } catch (e) { - throw new Error(errMsg(e)); - } -} - -export function lastfmAuthUrl(token: string): string { - return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`; -} - -export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> { - try { - const data = await call({ method: 'auth.getSession', token }, true, false); - return { key: data.session.key as string, name: data.session.name as string }; - } catch (e) { - throw new Error(errMsg(e)); - } -} - -export async function lastfmGetSimilarArtists(artistName: string): Promise { - try { - const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true); - const artists = data?.similarartists?.artist; - if (!artists) return []; - const arr = Array.isArray(artists) ? artists : [artists]; - return arr.map((a: any) => a.name as string); - } catch { - return []; - } -} - -export async function lastfmGetAllLovedTracks( - username: string, - sessionKey: string, -): Promise> { - const results: Array<{ title: string; artist: string }> = []; - let page = 1; - const limit = 200; - - while (true) { - try { - const data = await call({ - method: 'user.getLovedTracks', - user: username, - sk: sessionKey, - limit: String(limit), - page: String(page), - }, false, true); - - const tracks = data?.lovedtracks?.track; - if (!tracks) break; - const arr = Array.isArray(tracks) ? tracks : [tracks]; - for (const t of arr) { - results.push({ title: t.name, artist: t.artist?.name ?? '' }); - } - - const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1); - if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks - page++; - } catch { - break; - } - } - - return results; -} - -export async function lastfmGetTrackLoved( - title: string, - artist: string, - sessionKey: string, -): Promise { - try { - const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true); - return data?.track?.userloved === '1' || data?.track?.userloved === 1; - } catch { - return false; - } -} - -export async function lastfmUpdateNowPlaying( - track: { title: string; artist: string; album: string; duration: number }, - sessionKey: string, -): Promise { - try { - await call({ - method: 'track.updateNowPlaying', - track: track.title, - artist: track.artist, - album: track.album, - duration: String(Math.round(track.duration)), - sk: sessionKey, - }, true, false); - } catch { - // best effort - } -} - -export async function lastfmLoveTrack( - track: { title: string; artist: string }, - sessionKey: string, -): Promise { - try { - await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false); - } catch { - // best effort - } -} - -export async function lastfmUnloveTrack( - track: { title: string; artist: string }, - sessionKey: string, -): Promise { - try { - await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false); - } catch { - // best effort - } -} - -export interface LastfmUserInfo { - playcount: number; - registeredAt: number; // unix timestamp -} - -export async function lastfmGetUserInfo( - username: string, - sessionKey: string, -): Promise { - try { - const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true); - const u = data?.user; - if (!u) return null; - return { - playcount: Number(u.playcount), - registeredAt: Number(u.registered?.unixtime ?? 0), - }; - } catch { - return null; - } -} - -export interface LastfmRecentTrack { - name: string; - artist: string; - album: string; - timestamp: number | null; // null = currently playing - nowPlaying: boolean; -} - -export async function lastfmGetRecentTracks( - username: string, - sessionKey: string, - limit = 20, -): Promise { - try { - const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true); - const items = data?.recenttracks?.track; - if (!items) return []; - const arr = Array.isArray(items) ? items : [items]; - return arr.map((t: any) => ({ - name: t.name, - artist: t.artist?.['#text'] ?? t.artist?.name ?? '', - album: t.album?.['#text'] ?? '', - timestamp: t.date?.uts ? Number(t.date.uts) : null, - nowPlaying: t['@attr']?.nowplaying === 'true', - })); - } catch { - return []; - } -} - -export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month'; - -export interface LastfmTopArtist { - name: string; - playcount: string; -} - -export interface LastfmTopAlbum { - name: string; - playcount: string; - artist: string; -} - -export interface LastfmTopTrack { - name: string; - playcount: string; - artist: string; -} - -export async function lastfmGetTopArtists( - username: string, - sessionKey: string, - period: LastfmPeriod, - limit = 10, -): Promise { - try { - const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true); - const items = data?.topartists?.artist; - if (!items) return []; - const arr = Array.isArray(items) ? items : [items]; - return arr.map((a: any) => ({ name: a.name, playcount: a.playcount })); - } catch { - return []; - } -} - -export async function lastfmGetTopAlbums( - username: string, - sessionKey: string, - period: LastfmPeriod, - limit = 10, -): Promise { - try { - const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true); - const items = data?.topalbums?.album; - if (!items) return []; - const arr = Array.isArray(items) ? items : [items]; - return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' })); - } catch { - return []; - } -} - -export async function lastfmGetTopTracks( - username: string, - sessionKey: string, - period: LastfmPeriod, - limit = 10, -): Promise { - try { - const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true); - const items = data?.toptracks?.track; - if (!items) return []; - const arr = Array.isArray(items) ? items : [items]; - return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' })); - } catch { - return []; - } -} - -export async function lastfmScrobble( - track: { title: string; artist: string; album: string; duration: number }, - timestamp: number, - sessionKey: string, -): Promise { - try { - await call({ - method: 'track.scrobble', - track: track.title, - artist: track.artist, - album: track.album, - duration: String(Math.round(track.duration)), - timestamp: String(Math.floor(timestamp / 1000)), - sk: sessionKey, - }, true, false); - } catch { - // best effort - } -} - -export interface LastfmTrackInfo { - listeners: number; - playcount: number; - userPlaycount: number | null; - userLoved: boolean; - tags: string[]; - url: string | null; -} - -export async function lastfmGetTrackInfo( - artist: string, - track: string, - username?: string, -): Promise { - try { - const params: Record = { method: 'track.getInfo', artist, track }; - if (username) params.username = username; - const data = await call(params, false, true); - const t = data?.track; - if (!t) return null; - const rawTags = t.toptags?.tag; - const tags = rawTags - ? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5) - : []; - const userPc = t.userplaycount != null ? Number(t.userplaycount) : null; - return { - listeners: Number(t.listeners) || 0, - playcount: Number(t.playcount) || 0, - userPlaycount: Number.isFinite(userPc) ? userPc : null, - userLoved: t.userloved === '1' || t.userloved === 1, - tags, - url: t.url ?? null, - }; - } catch { - return null; - } -} - -export interface LastfmArtistStats { - listeners: number; - playcount: number; - userPlaycount: number | null; - tags: string[]; - url: string | null; - bio: string | null; -} - -export async function lastfmGetArtistStats( - artist: string, - username?: string, -): Promise { - try { - const params: Record = { method: 'artist.getInfo', artist }; - if (username) params.username = username; - const data = await call(params, false, true); - const a = data?.artist; - if (!a) return null; - const rawTags = a.tags?.tag; - const tags = rawTags - ? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5) - : []; - const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null; - const bioRaw = (a.bio?.content || a.bio?.summary || '').trim(); - return { - listeners: Number(a.stats?.listeners) || 0, - playcount: Number(a.stats?.playcount) || 0, - userPlaycount: Number.isFinite(userPc) ? userPc : null, - tags, - url: a.url ?? null, - bio: bioRaw || null, - }; - } catch { - return null; - } -} diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 9acfc9f1..55c08e45 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -30,7 +30,7 @@ import { mainRouteInpageScrollViewportId, } from '../constants/appScroll'; import ConnectionIndicator from '../components/ConnectionIndicator'; -import LastfmIndicator from '../components/LastfmIndicator'; +import MusicNetworkIndicator from '../components/MusicNetworkIndicator'; import OfflineBanner from '../components/OfflineBanner'; import AppUpdater from '../components/AppUpdater'; import TitleBar from '../components/TitleBar'; @@ -259,7 +259,7 @@ export function AppShell() { {import.meta.env.DEV && }
- + {!isMobile && !isQueueVisible && ( diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 5957e92b..ef439772 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -1,5 +1,6 @@ import { installQueueUndoHotkey } from '../store/queueUndoHotkey'; import { configureStartupSplash } from './startupSplash'; +import { setupMusicNetworkRuntime } from './musicNetworkBridge'; import { invoke } from '@tauri-apps/api/core'; import { getWindowKind } from './windowKind'; import { migrateThemeSelection } from '../utils/themes/themeMigration'; @@ -119,4 +120,5 @@ export function runPreReactBootstrap(): void { pushUserAgentToBackend(); pushLoggingModeToBackend(); installQueueUndoHotkey(); + setupMusicNetworkRuntime(); } diff --git a/src/app/musicNetworkBridge.ts b/src/app/musicNetworkBridge.ts new file mode 100644 index 00000000..2378d1e8 --- /dev/null +++ b/src/app/musicNetworkBridge.ts @@ -0,0 +1,38 @@ +// Wires the Music Network runtime singleton to the app: the auth store backs the +// MusicNetworkStore port (reads are live via getState, so init order vs. rehydrate +// does not matter), and the Tauri shell backs the host (browser auth + uuid). + +import { open } from '@tauri-apps/plugin-shell'; +import { + initMusicNetworkRuntime, + type MusicNetworkStore, + type RuntimeHost, +} from '../music-network'; +import { useAuthStore } from '../store/authStore'; + +const store: MusicNetworkStore = { + getState: () => { + const s = useAuthStore.getState(); + return { + scrobblingMasterEnabled: s.scrobblingMasterEnabled, + enrichmentPrimaryId: s.enrichmentPrimaryId, + accounts: s.musicNetworkAccounts, + }; + }, + setAccounts: accounts => useAuthStore.getState().setMusicNetworkAccounts(accounts), + setEnrichmentPrimaryId: id => useAuthStore.getState().setEnrichmentPrimaryId(id), +}; + +const host: RuntimeHost = { + openExternal: url => open(url), + newId: () => crypto.randomUUID(), +}; + +let initialized = false; + +/** Initialize the Music Network runtime once, before any consumer calls it. */ +export function setupMusicNetworkRuntime(): void { + if (initialized) return; + initialized = true; + initMusicNetworkRuntime(store, host); +} diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index de08bdf5..0e483b2f 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -30,14 +30,6 @@ 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('@/utils/orbitBulkGuard', () => ({ orbitBulkGuard: vi.fn(async () => true), diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 02a2efa1..e6b05d16 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -48,7 +48,7 @@ export default function ContextMenu() { const navigate = useNavigate(); const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); const orbitRole = useOrbitStore(s => s.role); - const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( + const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, networkLovedCache, setNetworkLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( useShallow(s => ({ contextMenu: s.contextMenu, closeContextMenu: s.closeContextMenu, @@ -58,8 +58,8 @@ export default function ContextMenu() { queueItems: s.queueItems, currentTrack: s.currentTrack, removeTrack: s.removeTrack, - lastfmLovedCache: s.lastfmLovedCache, - setLastfmLovedForSong: s.setLastfmLovedForSong, + networkLovedCache: s.networkLovedCache, + setNetworkLovedForSong: s.setNetworkLovedForSong, starredOverrides: s.starredOverrides, setStarredOverride: s.setStarredOverride, openSongInfo: s.openSongInfo, @@ -234,8 +234,8 @@ export default function ContextMenu() { closeContextMenu={closeContextMenu} starredOverrides={starredOverrides} setStarredOverride={setStarredOverride} - lastfmLovedCache={lastfmLovedCache} - setLastfmLovedForSong={setLastfmLovedForSong} + networkLovedCache={networkLovedCache} + setNetworkLovedForSong={setNetworkLovedForSong} openSongInfo={openSongInfo} userRatingOverrides={userRatingOverrides} setKeyboardRating={setKeyboardRating} diff --git a/src/components/LastfmIndicator.tsx b/src/components/LastfmIndicator.tsx deleted file mode 100644 index 3c70cc7b..00000000 --- a/src/components/LastfmIndicator.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { useAuthStore } from '../store/authStore'; -import LastfmIcon from './LastfmIcon'; - -export default function LastfmIndicator() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { lastfmSessionKey, lastfmUsername, lastfmSessionError } = useAuthStore(); - - if (!lastfmSessionKey) return null; - - const tooltip = lastfmSessionError - ? t('connection.lastfmSessionInvalid') - : t('connection.lastfmConnected', { user: lastfmUsername }); - - return ( -
navigate('/settings', { state: { tab: 'integrations' } })} - data-tooltip={tooltip} - data-tooltip-pos="bottom" - > -
-
- - - Last.fm - - - {lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`} - -
-
- ); -} diff --git a/src/components/MiniPlayer.test.tsx b/src/components/MiniPlayer.test.tsx index 1295a152..42649672 100644 --- a/src/components/MiniPlayer.test.tsx +++ b/src/components/MiniPlayer.test.tsx @@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({ scrobbleSong: vi.fn(async () => undefined), })); -vi.mock('@/api/lastfm', () => ({ - lastfmScrobble: vi.fn(async () => undefined), - lastfmUpdateNowPlaying: vi.fn(async () => undefined), - lastfmGetTrackLoved: vi.fn(async () => false), - lastfmGetAllLovedTracks: vi.fn(async () => []), -})); import MiniPlayer from './MiniPlayer'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; diff --git a/src/components/MusicNetworkIndicator.tsx b/src/components/MusicNetworkIndicator.tsx new file mode 100644 index 00000000..0ef95ecd --- /dev/null +++ b/src/components/MusicNetworkIndicator.tsx @@ -0,0 +1,47 @@ +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useEnrichmentPrimary } from '../music-network'; +import { renderPresetIcon } from './settings/musicNetwork/presetIcon'; + +/** + * Sidebar status indicator for the enrichment primary (the account that drives + * love/similar/stats). Mirrors the old Last.fm indicator: green when connected, + * red on session error, click → Integrations. Hidden when no primary is set. + */ +export default function MusicNetworkIndicator() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const ep = useEnrichmentPrimary(); + if (!ep) return null; + const { account: primary, icon } = ep; + + const subtitle = primary.sessionError + ? t('musicNetwork.statusError') + : primary.username + ? `@${primary.username}` + : t('musicNetwork.statusConnected'); + const tooltip = primary.sessionError + ? t('musicNetwork.statusError') + : primary.username + ? `${primary.label} · @${primary.username}` + : primary.label; + + return ( +
navigate('/settings', { state: { tab: 'integrations' } })} + data-tooltip={tooltip} + data-tooltip-pos="bottom" + > +
+
+ + {renderPresetIcon(icon, 11)} + {primary.label} + + {subtitle} +
+
+ ); +} diff --git a/src/components/PlayerBar.test.tsx b/src/components/PlayerBar.test.tsx index f372eb05..e9d98bc1 100644 --- a/src/components/PlayerBar.test.tsx +++ b/src/components/PlayerBar.test.tsx @@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({ scrobbleSong: 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 () => []), -})); import PlayerBar from './PlayerBar'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index e4b13415..6e4a446c 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -19,7 +19,6 @@ import { useTranslation } from 'react-i18next'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useLyricsStore } from '../store/lyricsStore'; import MarqueeText from './MarqueeText'; -import LastfmIcon from './LastfmIcon'; import { useRadioMetadata } from '../hooks/useRadioMetadata'; import { useRadioMprisSync } from '../hooks/useRadioMprisSync'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; @@ -61,7 +60,7 @@ export default function PlayerBar() { currentTrack, currentRadio, isPlaying, volume, togglePlay, next, previous, setVolume, stop, toggleRepeat, repeatMode, toggleFullscreen, - lastfmLoved, toggleLastfmLove, + networkLoved, toggleNetworkLove, isQueueVisible, toggleQueue, starredOverrides, userRatingOverrides, @@ -79,15 +78,15 @@ export default function PlayerBar() { toggleRepeat: s.toggleRepeat, repeatMode: s.repeatMode, toggleFullscreen: s.toggleFullscreen, - lastfmLoved: s.lastfmLoved, - toggleLastfmLove: s.toggleLastfmLove, + networkLoved: s.networkLoved, + toggleNetworkLove: s.toggleNetworkLove, isQueueVisible: s.isQueueVisible, toggleQueue: s.toggleQueue, starredOverrides: s.starredOverrides, userRatingOverrides: s.userRatingOverrides, openContextMenu: s.openContextMenu, }))); - const { lastfmSessionKey } = useAuthStore(); + const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const playerBarRef = useRef(null); const perfFlags = usePerfProbeFlags(); @@ -208,9 +207,9 @@ export default function PlayerBar() { previewingTrack={previewingTrack} isStarred={isStarred} toggleStar={toggleStar} - lastfmSessionKey={lastfmSessionKey} - lastfmLoved={lastfmLoved} - toggleLastfmLove={toggleLastfmLove} + enrichmentPrimaryId={enrichmentPrimaryId} + networkLoved={networkLoved} + toggleNetworkLove={toggleNetworkLove} userRatingOverrides={userRatingOverrides} toggleFullscreen={toggleFullscreen} navigate={navigatePlaybackLibrary} diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx index e42a9e18..8a6189f6 100644 --- a/src/components/QueuePanel.test.tsx +++ b/src/components/QueuePanel.test.tsx @@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({ scrobbleSong: vi.fn(async () => undefined), })); -vi.mock('@/api/lastfm', () => ({ - lastfmScrobble: vi.fn(async () => undefined), - lastfmUpdateNowPlaying: vi.fn(async () => undefined), - lastfmGetTrackLoved: vi.fn(async () => false), - lastfmGetAllLovedTracks: vi.fn(async () => []), -})); vi.mock('@/utils/orbitBulkGuard', () => ({ orbitBulkGuard: vi.fn(async () => true), diff --git a/src/components/WaveformSeek.test.tsx b/src/components/WaveformSeek.test.tsx index 12e8e050..993c3632 100644 --- a/src/components/WaveformSeek.test.tsx +++ b/src/components/WaveformSeek.test.tsx @@ -24,12 +24,6 @@ vi.mock('@/api/subsonic', () => ({ scrobbleSong: vi.fn(async () => undefined), })); -vi.mock('@/api/lastfm', () => ({ - lastfmScrobble: vi.fn(async () => undefined), - lastfmUpdateNowPlaying: vi.fn(async () => undefined), - lastfmGetTrackLoved: vi.fn(async () => false), - lastfmGetAllLovedTracks: vi.fn(async () => []), -})); import WaveformSeek from './WaveformSeek'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; diff --git a/src/components/artistDetail/ArtistDetailSimilarArtists.tsx b/src/components/artistDetail/ArtistDetailSimilarArtists.tsx index 83ab04db..2137db63 100644 --- a/src/components/artistDetail/ArtistDetailSimilarArtists.tsx +++ b/src/components/artistDetail/ArtistDetailSimilarArtists.tsx @@ -8,7 +8,7 @@ import { useIsMobile } from '../../hooks/useIsMobile'; interface Props { marginTop: string; showAudiomuseSimilar: boolean; - showLastfmSimilar: boolean; + showNetworkSimilar: boolean; similarLoading: boolean; similarArtists: SubsonicArtist[]; serverSimilarArtists: SubsonicArtist[]; @@ -17,7 +17,7 @@ interface Props { } export default function ArtistDetailSimilarArtists({ - marginTop, showAudiomuseSimilar, showLastfmSimilar, + marginTop, showAudiomuseSimilar, showNetworkSimilar, similarLoading, similarArtists, serverSimilarArtists, similarCollapsed, setSimilarCollapsed, }: Props) { @@ -41,7 +41,7 @@ export default function ArtistDetailSimilarArtists({ ) : null; })()}
- {showLastfmSimilar && similarLoading ? ( + {showNetworkSimilar && similarLoading ? (
{t('artistDetail.loading')} diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx index 6d8dd54c..f45a6cca 100644 --- a/src/components/contextMenu/AlbumContextItems.tsx +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -15,7 +15,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx index c12b86b8..37e042a9 100644 --- a/src/components/contextMenu/ArtistContextItems.tsx +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -13,7 +13,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, diff --git a/src/components/contextMenu/PlaylistContextItems.tsx b/src/components/contextMenu/PlaylistContextItems.tsx index 25683996..3bff8bbe 100644 --- a/src/components/contextMenu/PlaylistContextItems.tsx +++ b/src/components/contextMenu/PlaylistContextItems.tsx @@ -11,7 +11,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index 7b73f988..c0f4228a 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next'; import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react'; import { queueSongStar } from '../../store/pendingStarSync'; -import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; +import { getMusicNetworkRuntime, useEnrichmentPrimary } from '../../music-network'; import type { Track } from '../../store/playerStoreTypes'; import { useAuthStore } from '../../store/authStore'; -import LastfmIcon from '../LastfmIcon'; +import { renderPresetIcon } from '../settings/musicNetwork/presetIcon'; import StarRating from '../StarRating'; import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu'; import type { ContextMenuItemsProps } from './contextMenuItemTypes'; @@ -13,7 +13,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, networkLovedCache, setNetworkLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, @@ -24,6 +24,9 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { } = props; const { t } = useTranslation(); const auth = useAuthStore(); + const networkPrimary = useEnrichmentPrimary(); + const networkLabel = networkPrimary?.label ?? ''; + const networkIcon = networkPrimary?.icon ?? 'custom'; return ( <> @@ -76,18 +79,17 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
- {auth.lastfmSessionKey && (() => { + {auth.enrichmentPrimaryId !== null && (() => { const loveKey = `${song.title}::${song.artist}`; - const loved = lastfmLovedCache[loveKey] ?? false; + const loved = networkLovedCache[loveKey] ?? false; return (
handleAction(() => { const newLoved = !loved; - setLastfmLovedForSong(song.title, song.artist, newLoved); - if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); - else lastfmUnloveTrack(song, auth.lastfmSessionKey); + setNetworkLovedForSong(song.title, song.artist, newLoved); + void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved); })}> - - {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} + {renderPresetIcon(networkIcon, 14)} + {loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
); })()} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index efb5784b..0b215f5f 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -4,14 +4,14 @@ import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum'; import { useNavigateToArtist } from '../../hooks/useNavigateToArtist'; import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve'; import { queueSongStar } from '../../store/pendingStarSync'; -import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; +import { getMusicNetworkRuntime, useEnrichmentPrimary } from '../../music-network'; import type { Track } from '../../store/playerStoreTypes'; import { useAuthStore } from '../../store/authStore'; import { usePlaylistStore } from '../../store/playlistStore'; import { songToTrack } from '../../utils/playback/songToTrack'; import { showToast } from '../../utils/ui/toast'; import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '../../utils/orbit'; -import LastfmIcon from '../LastfmIcon'; +import { renderPresetIcon } from '../settings/musicNetwork/presetIcon'; import StarRating from '../StarRating'; import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu'; import type { ContextMenuItemsProps } from './contextMenuItemTypes'; @@ -20,7 +20,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) { const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, - starredOverrides, lastfmLovedCache, setLastfmLovedForSong, + starredOverrides, networkLovedCache, setNetworkLovedForSong, openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave, playlistSongIds, setPlaylistSongIds, @@ -31,6 +31,9 @@ export default function SongContextItems(props: ContextMenuItemsProps) { } = props; const { t } = useTranslation(); const auth = useAuthStore(); + const networkPrimary = useEnrichmentPrimary(); + const networkLabel = networkPrimary?.label ?? ''; + const networkIcon = networkPrimary?.icon ?? 'custom'; const navigateToAlbum = useNavigateToAlbum(); const navigateToArtist = useNavigateToArtist(); @@ -134,18 +137,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) { {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
)} - {auth.lastfmSessionKey && (() => { + {auth.enrichmentPrimaryId !== null && (() => { const loveKey = `${song.title}::${song.artist}`; - const loved = lastfmLovedCache[loveKey] ?? false; + const loved = networkLovedCache[loveKey] ?? false; return (
handleAction(() => { const newLoved = !loved; - setLastfmLovedForSong(song.title, song.artist, newLoved); - if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); - else lastfmUnloveTrack(song, auth.lastfmSessionKey); + setNetworkLovedForSong(song.title, song.artist, newLoved); + void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved); })}> - - {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} + {renderPresetIcon(networkIcon, 14)} + {loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
); })()} @@ -279,18 +281,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) { {t('contextMenu.instantMix')}
)} - {auth.lastfmSessionKey && (() => { + {auth.enrichmentPrimaryId !== null && (() => { const loveKey = `${song.title}::${song.artist}`; - const loved = lastfmLovedCache[loveKey] ?? false; + const loved = networkLovedCache[loveKey] ?? false; return (
handleAction(() => { const newLoved = !loved; - setLastfmLovedForSong(song.title, song.artist, newLoved); - if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); - else lastfmUnloveTrack(song, auth.lastfmSessionKey); + setNetworkLovedForSong(song.title, song.artist, newLoved); + void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved); })}> - - {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')} + {renderPresetIcon(networkIcon, 14)} + {loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
); })()} diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts index 9cc63459..cd16f895 100644 --- a/src/components/contextMenu/contextMenuItemTypes.ts +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -30,8 +30,8 @@ export interface ContextMenuItemsProps { closeContextMenu: () => void; starredOverrides: Record; setStarredOverride: (id: string, starred: boolean) => void; - lastfmLovedCache: Record; - setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void; + networkLovedCache: Record; + setNetworkLovedForSong: (title: string, artist: string, loved: boolean) => void; openSongInfo: (id: string) => void; userRatingOverrides: Record; setKeyboardRating: React.Dispatch>; diff --git a/src/components/nowPlaying/Hero.tsx b/src/components/nowPlaying/Hero.tsx index ca3446f8..fa40c23c 100644 --- a/src/components/nowPlaying/Hero.tsx +++ b/src/components/nowPlaying/Hero.tsx @@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next'; import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react'; import { CoverArtImage } from '../../cover/CoverArtImage'; import type { CoverArtRef } from '../../cover/types'; -import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm'; +import type { ArtistStats, TrackStats } from '../../music-network'; import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes'; -import LastfmIcon from '../LastfmIcon'; import { OpenArtistRefInline } from '../OpenArtistRefInline'; import { formatTrackTime } from '../../utils/format/formatDuration'; +import { useEnrichmentPrimaryLabel } from '../../hooks/useEnrichmentPrimaryLabel'; +import { useEnrichmentPrimaryIcon } from '../../hooks/useEnrichmentPrimaryIcon'; +import { renderPresetIcon } from '../settings/musicNetwork/presetIcon'; interface HeroProps { track: { title: string; artist: string; album: string; year?: number; @@ -19,16 +21,16 @@ interface HeroProps { genre?: string; playCount?: number; userRatingOverride?: number; - lfmTrack: LastfmTrackInfo | null; - lfmArtist: LastfmArtistStats | null; + networkTrack: TrackStats | null; + networkArtist: ArtistStats | null; starred: boolean; - lfmLoved: boolean; - lfmLoveEnabled: boolean; + networkLoved: boolean; + networkLoveEnabled: boolean; activeLyricsTab: boolean; coverRef?: CoverArtRef; onNavigate: (path: string) => void; onToggleStar: () => void; - onToggleLfmLove: () => void; + onToggleNetworkLove: () => void; onOpenLyrics: () => void; } @@ -46,8 +48,10 @@ function renderStars(rating?: number) { ); } -const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) { +const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) { const { t } = useTranslation(); + const networkLabel = useEnrichmentPrimaryLabel() ?? ''; + const networkIcon = useEnrichmentPrimaryIcon(); const rating = userRatingOverride ?? track.userRating; const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000; const releaseAge = track.year ? new Date().getFullYear() - track.year : 0; @@ -117,11 +121,11 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}> - {lfmLoveEnabled && ( - )} )} - {currentTrack && !isRadio && lastfmSessionKey && isLayoutVisible('lastfmLove') && ( + {currentTrack && !isRadio && enrichmentPrimaryId !== null && isLayoutVisible('lastfmLove') && ( )}
diff --git a/src/components/settings/IntegrationsTab.tsx b/src/components/settings/IntegrationsTab.tsx index 5e964ef0..ff86e322 100644 --- a/src/components/settings/IntegrationsTab.tsx +++ b/src/components/settings/IntegrationsTab.tsx @@ -1,67 +1,12 @@ -import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react'; -import { open as openUrl } from '@tauri-apps/plugin-shell'; -import { lastfmAuthUrl, lastfmGetSession, lastfmGetToken, lastfmGetUserInfo, type LastfmUserInfo } from '../../api/lastfm'; import { useAuthStore } from '../../store/authStore'; -import LastfmIcon from '../LastfmIcon'; import SettingsSubSection from '../SettingsSubSection'; +import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection'; export function IntegrationsTab() { const { t } = useTranslation(); const auth = useAuthStore(); - const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle'); - // Polled token is kept here in case future flows need to display or cancel it explicitly. - const [, setLfmPendingToken] = useState(null); - const [lfmError, setLfmError] = useState(null); - const [lfmUserInfo, setLfmUserInfo] = useState(null); - - useEffect(() => { - if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } - lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {}); - }, [auth.lastfmSessionKey, auth.lastfmUsername]); - - const startLastfmConnect = useCallback(async () => { - setLfmError(null); - let token: string; - try { - token = await lastfmGetToken(); - setLfmPendingToken(token); - setLfmState('waiting'); - await openUrl(lastfmAuthUrl(token)); - } catch (e: any) { - setLfmError(e.message ?? 'Unknown error'); - setLfmState('error'); - return; - } - - // Poll every 2 s until the user authorises or we time out (2 min) - const deadline = Date.now() + 120_000; - const poll = async () => { - if (Date.now() > deadline) { - setLfmState('error'); - setLfmError('Timed out — please try again.'); - setLfmPendingToken(null); - return; - } - try { - const { key, name } = await lastfmGetSession(token); - auth.connectLastfm(key, name); - setLfmState('idle'); - setLfmPendingToken(null); - } catch (e: any) { - // Error 14 = not yet authorised, keep polling - if (e.message?.includes('14')) { - setTimeout(poll, 2000); - } else { - setLfmState('error'); - setLfmError(e.message ?? 'Unknown error'); - setLfmPendingToken(null); - } - } - }; - setTimeout(poll, 2000); - }, [auth]); return ( <> @@ -82,70 +27,8 @@ export function IntegrationsTab() { - {/* Last.fm */} - } - > -
- {auth.lastfmSessionKey ? ( -
-
-
-
-
@{auth.lastfmUsername}
- {lfmUserInfo && ( -
- {t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })} - {t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })} -
- )} -
- -
-
-
-
{t('settings.scrobbleEnabled')}
-
{t('settings.scrobbleDesc')}
-
- -
-
- ) : lfmState === 'waiting' ? ( -
-
-
- {t('settings.lfmConnecting')} -
- -
- ) : ( -
-

- {t('settings.lfmConnectDesc')} -

- {lfmState === 'error' && ( -

{lfmError}

- )} - -
- )} -
- + {/* Music Network — scrobbling + enrichment across multiple services */} + {/* Discord Rich Presence */} ) => Promise; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(null); + const [fields, setFields] = useState>({}); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + // Bundled single-instance presets disappear once connected; self-hosted / + // custom presets can be added repeatedly. + const available = listPresets().filter( + p => !(p.manifest.credentials === 'bundled' && connectedPresetIds.includes(p.manifest.presetId)), + ); + + const toMessage = (e: unknown): string => + isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed'); + + const run = async (presetId: PresetId, payload: Record) => { + // Enforce the manifest's `required` fields client-side so an empty URL/token + // gives a clear message instead of falling through to a confusing NETWORK error. + const preset = available.find(p => p.manifest.presetId === presetId); + const missing = preset?.manifest.fields.find(f => f.required && !(payload[f.name] ?? '').trim()); + if (missing) { + setError(t('musicNetwork.fieldRequired', { field: t(missing.labelKey) })); + return; + } + setBusy(presetId); + setError(null); + try { + await onConnect(presetId, payload); + setExpanded(null); + setFields({}); + } catch (e) { + setError(toMessage(e)); + } finally { + setBusy(null); + } + }; + + const onPrimaryAction = (preset: BuiltinPreset) => { + const id = preset.manifest.presetId; + if (preset.manifest.fields.length === 0) { + void run(id, {}); + } else { + setError(null); + setFields({}); + setExpanded(expanded === id ? null : id); + } + }; + + return ( +
+
{t('musicNetwork.addService')}
+ {available.map(preset => { + const id = preset.manifest.presetId; + const isExpanded = expanded === id; + const isBusy = busy === id; + return ( +
+
+ +
+
{preset.manifest.displayName}
+
{t(preset.manifest.descriptionKey)}
+
+ +
+ + {isExpanded && ( +
+ {preset.manifest.fields.map(field => ( +
+ + setFields(f => ({ ...f, [field.name]: e.target.value }))} + /> + {field.helpKey && ( +
+ {t(field.helpKey)} +
+ )} +
+ ))} + +
+ )} +
+ ); + })} + {error &&

{error}

} +
+ ); +} diff --git a/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx b/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx new file mode 100644 index 00000000..511e50fc --- /dev/null +++ b/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next'; +import CustomSelect from '../../CustomSelect'; +import type { Account } from '../../../music-network'; + +/** + * Picks the single enrichment primary (love / similar / stats source). Only + * enrichment-eligible accounts are offered; Maloja / ListenBrainz never appear. + * Hidden when there are no eligible accounts. + */ +export function EnrichmentPrimarySelect({ + accounts, + primaryId, + onChange, +}: { + accounts: Account[]; + primaryId: string | null; + onChange: (id: string | null) => void; +}) { + const { t } = useTranslation(); + const candidates = accounts.filter(a => a.roles.enrichmentEligible); + if (candidates.length === 0) return null; + + const options = [ + { value: '', label: t('musicNetwork.primaryNone') }, + ...candidates.map(a => ({ value: a.id, label: a.label })), + ]; + + return ( +
+
+
{t('musicNetwork.primaryLabel')}
+
{t('musicNetwork.primaryDesc')}
+
+ onChange(v || null)} + style={{ minWidth: 180 }} + /> +
+ ); +} diff --git a/src/components/settings/musicNetwork/MalojaProxyWarning.tsx b/src/components/settings/musicNetwork/MalojaProxyWarning.tsx new file mode 100644 index 00000000..b4efacfa --- /dev/null +++ b/src/components/settings/musicNetwork/MalojaProxyWarning.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'react-i18next'; +import { AlertTriangle } from 'lucide-react'; +import type { Account } from '../../../music-network'; + +/** + * Shown when a Maloja account is connected AND Last.fm scrobbling is enabled — + * Maloja can forward scrobbles to Last.fm, so both paths active risks duplicates. + */ +export function MalojaProxyWarning({ accounts }: { accounts: Account[] }) { + const { t } = useTranslation(); + const hasMaloja = accounts.some(a => a.presetId.startsWith('maloja')); + const lastfmScrobbling = accounts.some(a => a.presetId === 'lastfm' && a.scrobbleEnabled); + if (!hasMaloja || !lastfmScrobbling) return null; + + return ( +
+
+ ); +} diff --git a/src/components/settings/musicNetwork/MusicNetworkSection.tsx b/src/components/settings/musicNetwork/MusicNetworkSection.tsx new file mode 100644 index 00000000..1d83971a --- /dev/null +++ b/src/components/settings/musicNetwork/MusicNetworkSection.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Share2 } from 'lucide-react'; +import SettingsSubSection from '../../SettingsSubSection'; +import { showToast } from '../../../utils/ui/toast'; +import { useAuthStore } from '../../../store/authStore'; +import { + errorI18nKey, + getMusicNetworkRuntime, + isMusicNetworkError, + type PresetId, + type UserProfile, +} from '../../../music-network'; +import { useMusicNetworkState } from './useMusicNetworkState'; +import { ScrobbleDestinationCard } from './ScrobbleDestinationCard'; +import { EnrichmentPrimarySelect } from './EnrichmentPrimarySelect'; +import { ConnectProviderForm } from './ConnectProviderForm'; +import { MalojaProxyWarning } from './MalojaProxyWarning'; + +/** + * Integrations UI for the Music Network framework — replaces the old Last.fm + * card. Manifest-driven: connected destinations, the enrichment-primary picker, + * the Maloja proxy warning, and the add-a-service list all come from the + * registry. Mutations go through the runtime; state is read reactively from the + * auth store (see useMusicNetworkState). + */ +export function MusicNetworkSection() { + const { t } = useTranslation(); + const { accounts, enrichmentPrimaryId, scrobblingMasterEnabled } = useMusicNetworkState(); + const [primaryProfile, setPrimaryProfile] = useState(null); + + // Profile stats (scrobbles / member-since) for the enrichment primary. + useEffect(() => { + if (!enrichmentPrimaryId) { setPrimaryProfile(null); return; } + let cancelled = false; + setPrimaryProfile(null); + getMusicNetworkRuntime().getUserProfile() + .then(p => { if (!cancelled) setPrimaryProfile(p); }) + .catch(() => { if (!cancelled) setPrimaryProfile(null); }); + return () => { cancelled = true; }; + }, [enrichmentPrimaryId]); + + const setMaster = (v: boolean) => useAuthStore.getState().setScrobblingMasterEnabled(v); + const toggleScrobble = (id: string, v: boolean) => + getMusicNetworkRuntime().updateAccount(id, { scrobbleEnabled: v }); + const disconnect = (id: string) => getMusicNetworkRuntime().disconnect(id); + + const setPrimary = (id: string | null) => { + try { + getMusicNetworkRuntime().setEnrichmentPrimaryId(id); + } catch (e) { + showToast(isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed'), 4000, 'error'); + } + }; + + const connect = async (presetId: PresetId, fields: Record) => { + const account = await getMusicNetworkRuntime().connect(presetId, { fields }); + // The wire's connect only checks the credential is present; for paste-auth + // providers the real validation is the capability probe. Surface a probe + // error (e.g. an invalid token) so the connect does not look silently OK. + const scrobble = account.capabilities?.scrobble; + if (scrobble?.status === 'error') { + showToast( + t('musicNetwork.connectProbeFailed', { provider: account.label, message: scrobble.message ?? '' }), + 6000, + 'error', + ); + } + }; + + const connectedPresetIds = accounts.map(a => a.presetId); + + return ( + }> +
+

+ {t('musicNetwork.desc')} +

+ +
+
+
{t('musicNetwork.masterToggle')}
+
{t('musicNetwork.masterToggleDesc')}
+
+ +
+ +
+ +
+ + {accounts.length > 0 && ( + <> +
+
+ {accounts.map(account => ( + toggleScrobble(account.id, v)} + onDisconnect={() => disconnect(account.id)} + /> + ))} +
+ + + + )} + +
+ +
+ + ); +} diff --git a/src/components/settings/musicNetwork/ScrobbleDestinationCard.tsx b/src/components/settings/musicNetwork/ScrobbleDestinationCard.tsx new file mode 100644 index 00000000..c64fbff6 --- /dev/null +++ b/src/components/settings/musicNetwork/ScrobbleDestinationCard.tsx @@ -0,0 +1,93 @@ +import { useTranslation } from 'react-i18next'; +import { getPreset, type Account, type UserProfile } from '../../../music-network'; +import { renderPresetIcon } from './presetIcon'; + +/** + * One connected account as a single self-contained block: header (icon, label, + * status, optional profile stats for the enrichment primary) on top, and a + * footer row holding the per-account scrobble toggle + disconnect — so it is + * unambiguous which account the toggle belongs to. + */ +export function ScrobbleDestinationCard({ + account, + profile, + onToggleScrobble, + onDisconnect, +}: { + account: Account; + profile: UserProfile | null; + onToggleScrobble: (enabled: boolean) => void; + onDisconnect: () => void; +}) { + const { t } = useTranslation(); + const preset = getPreset(account.presetId); + const icon = preset?.manifest.icon ?? 'custom'; + + return ( +
+ {/* Header: identity + status + profile stats */} +
+ +
+
+ {account.label} + +
+ {account.username && ( +
@{account.username}
+ )} + {profile && ( +
+ {t('musicNetwork.scrobbles', { n: profile.playcount.toLocaleString() })} + {profile.registeredAt > 0 && ( + {t('musicNetwork.memberSince', { year: new Date(profile.registeredAt * 1000).getFullYear() })} + )} +
+ )} +
+
+ + {/* Footer: the scrobble toggle (clearly inside this account's block) + disconnect */} +
+ + +
+
+ ); +} diff --git a/src/components/settings/musicNetwork/presetIcon.tsx b/src/components/settings/musicNetwork/presetIcon.tsx new file mode 100644 index 00000000..ef771ca8 --- /dev/null +++ b/src/components/settings/musicNetwork/presetIcon.tsx @@ -0,0 +1,27 @@ +import { Globe, Radio, Server, Music2, Headphones } from 'lucide-react'; +import LastfmIcon from '../../LastfmIcon'; +import type { PresetIcon } from '../../../music-network'; + +/** + * Maps a preset manifest icon id to a rendered icon. Feature code references the + * manifest's `icon` field — never a provider name — so adding a provider is a + * data change, not a component edit. + */ +export function renderPresetIcon(icon: PresetIcon, size = 16): React.ReactNode { + switch (icon) { + case 'lastfm': + case 'librefm': + return ; + case 'rocksky': + return ; + case 'listenbrainz': + return ; + case 'koito': + return ; + case 'maloja': + return ; + case 'custom': + default: + return ; + } +} diff --git a/src/components/settings/musicNetwork/useMusicNetworkState.ts b/src/components/settings/musicNetwork/useMusicNetworkState.ts new file mode 100644 index 00000000..63ff97ec --- /dev/null +++ b/src/components/settings/musicNetwork/useMusicNetworkState.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useAuthStore } from '../../../store/authStore'; +import { getPreset, type Account } from '../../../music-network'; + +/** + * Reactive view of the persisted Music Network state for the Integrations UI. + * Accounts re-render on any auth-store change; roles are derived from the preset + * manifest (static). Mutations go through the runtime (see MusicNetworkSection), + * which writes back to the store and re-renders this. + */ +export function useMusicNetworkState(): { + accounts: Account[]; + enrichmentPrimaryId: string | null; + scrobblingMasterEnabled: boolean; +} { + const { accounts, enrichmentPrimaryId, scrobblingMasterEnabled } = useAuthStore( + useShallow(s => ({ + accounts: s.musicNetworkAccounts, + enrichmentPrimaryId: s.enrichmentPrimaryId, + scrobblingMasterEnabled: s.scrobblingMasterEnabled, + })), + ); + + const richAccounts = useMemo( + () => + accounts.map(a => ({ + ...a, + roles: + getPreset(a.presetId)?.manifest.defaultRoles + ?? { scrobble: false, enrichmentEligible: false }, + })), + [accounts], + ); + + return { accounts: richAccounts, enrichmentPrimaryId, scrobblingMasterEnabled }; +} diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index b70b7efc..67275708 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -40,7 +40,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' }, { tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' }, { tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' }, - { tab: 'integrations', titleKey: 'settings.lfmTitle', keywords: 'last.fm lastfm scrobble' }, + { tab: 'integrations', titleKey: 'musicNetwork.title', keywords: 'last.fm lastfm libre.fm rocksky listenbrainz maloja scrobble scrobbling music network' }, { tab: 'integrations', titleKey: 'settings.discordRichPresence', keywords: 'discord rich presence rpc' }, { tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' }, { tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' }, diff --git a/src/hooks/useArtistSimilarArtists.ts b/src/hooks/useArtistSimilarArtists.ts index 906c1378..3b110dce 100644 --- a/src/hooks/useArtistSimilarArtists.ts +++ b/src/hooks/useArtistSimilarArtists.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm'; +import { getMusicNetworkRuntime } from '../music-network'; import { search } from '../api/subsonicSearch'; import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes'; import { useAuthStore } from '../store/authStore'; @@ -24,15 +24,16 @@ export function useArtistSimilarArtists( s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]), ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null); const [similarArtists, setSimilarArtists] = useState([]); const [similarLoading, setSimilarLoading] = useState(false); useEffect(() => { - if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return; + if (!artist || audiomuseNavidromeEnabled || !enrichmentConfigured) return; setSimilarArtists([]); setSimilarLoading(true); - lastfmGetSimilarArtists(artist.name).then(async names => { + getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => { if (names.length === 0) { setSimilarLoading(false); return; } const results = await Promise.all( names.slice(0, 30).map(name => @@ -53,17 +54,17 @@ export function useArtistSimilarArtists( setSimilarLoading(false); }).catch(() => setSimilarLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]); + }, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled, enrichmentConfigured]); /** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */ useEffect(() => { - if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return; + if (!artist || !audiomuseNavidromeEnabled || !enrichmentConfigured) return; if (artistInfoLoading) return; if ((info?.similarArtist?.length ?? 0) > 0) return; setSimilarArtists([]); setSimilarLoading(true); - lastfmGetSimilarArtists(artist.name).then(async names => { + getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => { if (names.length === 0) { setSimilarLoading(false); return; } const results = await Promise.all( names.slice(0, 30).map(name => @@ -90,6 +91,7 @@ export function useArtistSimilarArtists( audiomuseNavidromeEnabled, artistInfoLoading, info?.similarArtist?.length, + enrichmentConfigured, ]); useEffect(() => { diff --git a/src/hooks/useEnrichmentPrimaryIcon.ts b/src/hooks/useEnrichmentPrimaryIcon.ts new file mode 100644 index 00000000..cca2a31c --- /dev/null +++ b/src/hooks/useEnrichmentPrimaryIcon.ts @@ -0,0 +1,11 @@ +import { useEnrichmentPrimary, type PresetIcon } from '../music-network'; + +/** + * Manifest icon id of the current enrichment-primary provider, or null when no + * primary is set. Use it to render the love affordance with the active + * provider's glyph (via `renderPresetIcon`) so the love button is never + * hardcoded to one provider's logo. + */ +export function useEnrichmentPrimaryIcon(): PresetIcon | null { + return useEnrichmentPrimary()?.icon ?? null; +} diff --git a/src/hooks/useEnrichmentPrimaryLabel.ts b/src/hooks/useEnrichmentPrimaryLabel.ts new file mode 100644 index 00000000..ce5c9449 --- /dev/null +++ b/src/hooks/useEnrichmentPrimaryLabel.ts @@ -0,0 +1,11 @@ +import { useEnrichmentPrimary } from '../music-network'; + +/** + * Display label of the current enrichment-primary account (e.g. "Last.fm", + * "Libre.fm"), or null when no primary is set. Use this for any user-facing + * "love / stats come from " copy so the UI never hardcodes a single + * provider name — the primary can be any enrichment-eligible service. + */ +export function useEnrichmentPrimaryLabel(): string | null { + return useEnrichmentPrimary()?.label ?? null; +} diff --git a/src/hooks/useNowPlayingFetchers.test.ts b/src/hooks/useNowPlayingFetchers.test.ts index 96aad498..7ae21724 100644 --- a/src/hooks/useNowPlayingFetchers.test.ts +++ b/src/hooks/useNowPlayingFetchers.test.ts @@ -16,7 +16,6 @@ import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum } from '../api/sub vi.mock('../api/subsonicArtists'); vi.mock('../api/subsonicLibrary'); vi.mock('../api/bandsintown'); -vi.mock('../api/lastfm'); vi.mock('../utils/network/subsonicNetworkGuard', () => ({ shouldAttemptSubsonicForServer: vi.fn(() => true), })); @@ -25,7 +24,6 @@ import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetwork import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists'; import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary'; import { fetchBandsintownEvents } from '../api/bandsintown'; -import { lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured } from '../api/lastfm'; import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers'; // The real getArtistInfo signature returns `Promise`, but @@ -44,7 +42,7 @@ const baseDeps: NowPlayingFetchersDeps = { artistName: '', enableBandsintown: false, audiomuseNavidromeEnabled: false, - lastfmUsername: '', + enrichmentKey: '', currentTrack: null, subsonicServerId: 'srv1', fetchEnabled: true, @@ -54,9 +52,6 @@ beforeEach(() => { vi.mocked(getTopSongsForServer).mockResolvedValue([]); vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as any); vi.mocked(fetchBandsintownEvents).mockResolvedValue([]); - vi.mocked(lastfmIsConfigured).mockReturnValue(false); - vi.mocked(lastfmGetTrackInfo).mockResolvedValue(null); - vi.mocked(lastfmGetArtistStats).mockResolvedValue(null); }); afterEach(() => { diff --git a/src/hooks/useNowPlayingFetchers.ts b/src/hooks/useNowPlayingFetchers.ts index ece531c5..e7251067 100644 --- a/src/hooks/useNowPlayingFetchers.ts +++ b/src/hooks/useNowPlayingFetchers.ts @@ -3,10 +3,8 @@ import { getArtistInfoForServer } from '../api/subsonicArtists'; import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '../utils/library/nowPlayingMetadataResolve'; import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; -import { - lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured, - type LastfmArtistStats, type LastfmTrackInfo, -} from '../api/lastfm'; +import type { ArtistStats, TrackStats } from '../music-network'; +import { getMusicNetworkRuntimeOrNull } from '../music-network'; import { makeCache } from '../utils/cache/nowPlayingCache'; import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard'; import { useConnectionStatus } from './useConnectionStatus'; @@ -18,8 +16,8 @@ const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] const topSongsCache = makeCache(); const tourCache = makeCache(); const discographyCache = makeCache(); -const lfmTrackCache = makeCache(); -const lfmArtistCache = makeCache(); +const networkTrackCache = makeCache(); +const networkArtistCache = makeCache(); export interface NowPlayingFetchersDeps { songId: string | undefined; @@ -28,7 +26,7 @@ export interface NowPlayingFetchersDeps { artistName: string; enableBandsintown: boolean; audiomuseNavidromeEnabled: boolean; - lastfmUsername: string; + enrichmentKey: string; currentTrack: { artist: string; title: string } | null; /** Subsonic server for API calls — must match the playing queue server. */ subsonicServerId: string; @@ -49,8 +47,8 @@ export interface NowPlayingFetchersResult { tourEvents: BandsintownEvent[]; tourLoading: boolean; discography: SubsonicAlbum[]; - lfmTrack: LastfmTrackInfo | null; - lfmArtist: LastfmArtistStats | null; + networkTrack: TrackStats | null; + networkArtist: ArtistStats | null; } function subsonicCacheKey(serverId: string, id: string): string { @@ -82,7 +80,7 @@ export async function prewarmNowPlayingFetchers( ): Promise { const { songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, - lastfmUsername, currentTrack, subsonicServerId, fetchEnabled = true, + enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true, } = deps; if (!fetchEnabled || !subsonicServerId) return; @@ -153,21 +151,22 @@ export async function prewarmNowPlayingFetchers( } } - if (lastfmIsConfigured() && currentTrack) { - const trackKey = `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}`; - if (lfmTrackCache.get(trackKey) === undefined) { + const prewarmRuntime = getMusicNetworkRuntimeOrNull(); + if (prewarmRuntime?.getEnrichmentPrimaryId() && currentTrack) { + const trackKey = `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}`; + if (networkTrackCache.get(trackKey) === undefined) { jobs.push( - lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined) - .then(v => lfmTrackCache.set(trackKey, v)) - .catch(() => lfmTrackCache.set(trackKey, null)), + prewarmRuntime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist }) + .then(v => networkTrackCache.set(trackKey, v)) + .catch(() => networkTrackCache.set(trackKey, null)), ); } - const artistKey = `${currentTrack.artist} ${lastfmUsername}`; - if (lfmArtistCache.get(artistKey) === undefined) { + const artistKey = `${currentTrack.artist} ${enrichmentKey}`; + if (networkArtistCache.get(artistKey) === undefined) { jobs.push( - lastfmGetArtistStats(currentTrack.artist, lastfmUsername || undefined) - .then(v => lfmArtistCache.set(artistKey, v)) - .catch(() => lfmArtistCache.set(artistKey, null)), + prewarmRuntime.getArtistStats(currentTrack.artist) + .then(v => networkArtistCache.set(artistKey, v)) + .catch(() => networkArtistCache.set(artistKey, null)), ); } } @@ -178,7 +177,7 @@ export async function prewarmNowPlayingFetchers( export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult { const { songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, - lastfmUsername, currentTrack, subsonicServerId, fetchEnabled = true, + enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true, } = deps; // id-keyed entity state — seeded from TTL cache so same-artist song switches @@ -200,12 +199,12 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF const [tourEventsEntry, setTourEventsEntry] = useState>(() => seedKeySlot(tourKey, k => tourCache.get(k))); const [tourLoading, setTourLoading] = useState(false); - const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : ''; - const lfmArtistKey = artistName ? `${artistName} ${lastfmUsername}` : ''; - const [lfmTrackEntry, setLfmTrackEntry] = useState>(() => - seedKeySlot(lfmTrackKey, k => lfmTrackCache.get(k))); - const [lfmArtistEntry, setLfmArtistEntry] = useState>(() => - seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k))); + const networkTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}` : ''; + const networkArtistKey = artistName ? `${artistName} ${enrichmentKey}` : ''; + const [networkTrackEntry, setNetworkTrackEntry] = useState>(() => + seedKeySlot(networkTrackKey, k => networkTrackCache.get(k))); + const [networkArtistEntry, setNetworkArtistEntry] = useState>(() => + seedKeySlot(networkArtistKey, k => networkArtistCache.get(k))); const { status: connStatus } = useConnectionStatus(); // Gate split (PR #1049): index-first resolvers run whenever there's a server id @@ -294,31 +293,33 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF return () => { cancelled = true; }; }, [indexFetchAllowed, subsonicServerId, artistId, connStatus]); - // Last.fm track info (per-track) + // Enrichment track stats (per-track, from the enrichment primary) useEffect(() => { - if (!lastfmIsConfigured() || !currentTrack || !lfmTrackKey) { setLfmTrackEntry(null); return; } - const cached = lfmTrackCache.get(lfmTrackKey); - if (cached !== undefined) { setLfmTrackEntry({ key: lfmTrackKey, value: cached }); return; } - setLfmTrackEntry(null); + const runtime = getMusicNetworkRuntimeOrNull(); + if (!runtime?.getEnrichmentPrimaryId() || !currentTrack || !networkTrackKey) { setNetworkTrackEntry(null); return; } + const cached = networkTrackCache.get(networkTrackKey); + if (cached !== undefined) { setNetworkTrackEntry({ key: networkTrackKey, value: cached }); return; } + setNetworkTrackEntry(null); let cancelled = false; - lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined) - .then(v => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, v); setLfmTrackEntry({ key: lfmTrackKey, value: v }); } }) - .catch(() => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, null); setLfmTrackEntry({ key: lfmTrackKey, value: null }); } }); + runtime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist }) + .then(v => { if (!cancelled) { networkTrackCache.set(networkTrackKey, v); setNetworkTrackEntry({ key: networkTrackKey, value: v }); } }) + .catch(() => { if (!cancelled) { networkTrackCache.set(networkTrackKey, null); setNetworkTrackEntry({ key: networkTrackKey, value: null }); } }); return () => { cancelled = true; }; - }, [lfmTrackKey, currentTrack, lastfmUsername]); + }, [networkTrackKey, currentTrack, enrichmentKey]); - // Last.fm artist stats (per-artist — shared across same-artist tracks) + // Enrichment artist stats (per-artist — shared across same-artist tracks) useEffect(() => { - if (!lastfmIsConfigured() || !artistName || !lfmArtistKey) { setLfmArtistEntry(null); return; } - const cached = lfmArtistCache.get(lfmArtistKey); - if (cached !== undefined) { setLfmArtistEntry({ key: lfmArtistKey, value: cached }); return; } - setLfmArtistEntry(null); + const runtime = getMusicNetworkRuntimeOrNull(); + if (!runtime?.getEnrichmentPrimaryId() || !artistName || !networkArtistKey) { setNetworkArtistEntry(null); return; } + const cached = networkArtistCache.get(networkArtistKey); + if (cached !== undefined) { setNetworkArtistEntry({ key: networkArtistKey, value: cached }); return; } + setNetworkArtistEntry(null); let cancelled = false; - lastfmGetArtistStats(artistName, lastfmUsername || undefined) - .then(v => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, v); setLfmArtistEntry({ key: lfmArtistKey, value: v }); } }) - .catch(() => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, null); setLfmArtistEntry({ key: lfmArtistKey, value: null }); } }); + runtime.getArtistStats(artistName) + .then(v => { if (!cancelled) { networkArtistCache.set(networkArtistKey, v); setNetworkArtistEntry({ key: networkArtistKey, value: v }); } }) + .catch(() => { if (!cancelled) { networkArtistCache.set(networkArtistKey, null); setNetworkArtistEntry({ key: networkArtistKey, value: null }); } }); return () => { cancelled = true; }; - }, [lfmArtistKey, artistName, lastfmUsername]); + }, [networkArtistKey, artistName, enrichmentKey]); // Gate id-keyed slots on id-match so consumers never see a value paired // with the wrong id, even on the single render between an id change and @@ -329,8 +330,8 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF const discography = discographyEntry && discographyEntry.id === artistId ? discographyEntry.value : []; const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : []; const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : []; - const lfmTrack = lfmTrackEntry && lfmTrackEntry.key === lfmTrackKey ? lfmTrackEntry.value : null; - const lfmArtist = lfmArtistEntry && lfmArtistEntry.key === lfmArtistKey ? lfmArtistEntry.value : null; + const networkTrack = networkTrackEntry && networkTrackEntry.key === networkTrackKey ? networkTrackEntry.value : null; + const networkArtist = networkArtistEntry && networkArtistEntry.key === networkArtistKey ? networkArtistEntry.value : null; - return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, lfmTrack, lfmArtist }; + return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, networkTrack, networkArtist }; } diff --git a/src/hooks/useNowPlayingPrewarm.ts b/src/hooks/useNowPlayingPrewarm.ts index 6279b392..3f0c87a7 100644 --- a/src/hooks/useNowPlayingPrewarm.ts +++ b/src/hooks/useNowPlayingPrewarm.ts @@ -48,7 +48,7 @@ export function useNowPlayingPrewarm(): void { const audiomuseNavidromeEnabled = useAuthStore( s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false), ); - const lastfmUsername = useAuthStore(s => s.lastfmUsername); + const enrichmentKey = useAuthStore(s => s.enrichmentPrimaryId ?? ''); useEffect(() => { if (!currentTrack || !playbackServerId) return; @@ -61,7 +61,7 @@ export function useNowPlayingPrewarm(): void { artistName: primary.name ?? currentTrack.artist, enableBandsintown, audiomuseNavidromeEnabled, - lastfmUsername, + enrichmentKey, currentTrack, subsonicServerId: playbackServerId, // No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single @@ -92,7 +92,7 @@ export function useNowPlayingPrewarm(): void { playbackServerId, enableBandsintown, audiomuseNavidromeEnabled, - lastfmUsername, + enrichmentKey, ]); useEffect(() => { diff --git a/src/hooks/useNowPlayingStarLove.ts b/src/hooks/useNowPlayingStarLove.ts index b6ccb873..fb17eebe 100644 --- a/src/hooks/useNowPlayingStarLove.ts +++ b/src/hooks/useNowPlayingStarLove.ts @@ -2,28 +2,25 @@ import { useCallback, useEffect, useState } from 'react'; import { queueSongStar } from '../store/pendingStarSync'; import type { SubsonicSong } from '../api/subsonicTypes'; import type { Track } from '../store/playerStoreTypes'; -import { - lastfmLoveTrack, lastfmUnloveTrack, - type LastfmTrackInfo, -} from '../api/lastfm'; +import type { TrackStats } from '../music-network'; +import { getMusicNetworkRuntime } from '../music-network'; export interface NowPlayingStarLoveDeps { currentTrack: Pick | null; songMeta: SubsonicSong | null; - lfmTrack: LastfmTrackInfo | null; - lfmLoveEnabled: boolean; - lastfmSessionKey: string; + networkTrack: TrackStats | null; + networkLoveEnabled: boolean; } export interface NowPlayingStarLoveResult { starred: boolean; - lfmLoved: boolean; + networkLoved: boolean; toggleStar: () => Promise; - toggleLfmLove: () => Promise; + toggleNetworkLove: () => Promise; } export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult { - const { currentTrack, songMeta, lfmTrack, lfmLoveEnabled, lastfmSessionKey } = deps; + const { currentTrack, songMeta, networkTrack, networkLoveEnabled } = deps; // Star const [starred, setStarred] = useState(false); @@ -35,15 +32,15 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS queueSongStar(currentTrack.id, next, currentTrack.serverId); }, [currentTrack, starred]); - // Last.fm love (seeded from track.getInfo, toggle via love/unlove) - const [lfmLoved, setLfmLoved] = useState(false); - useEffect(() => { setLfmLoved(!!lfmTrack?.userLoved); }, [lfmTrack]); - const toggleLfmLove = useCallback(async () => { - if (!currentTrack || !lfmLoveEnabled) return; + // Love (enrichment primary; seeded from track.getInfo, toggle via love/unlove) + const [networkLoved, setNetworkLoved] = useState(false); + useEffect(() => { setNetworkLoved(!!networkTrack?.userLoved); }, [networkTrack]); + const toggleNetworkLove = useCallback(async () => { + if (!currentTrack || !networkLoveEnabled) return; const track = { title: currentTrack.title, artist: currentTrack.artist }; - if (lfmLoved) { await lastfmUnloveTrack(track, lastfmSessionKey); setLfmLoved(false); } - else { await lastfmLoveTrack (track, lastfmSessionKey); setLfmLoved(true); } - }, [currentTrack, lfmLoved, lfmLoveEnabled, lastfmSessionKey]); + if (networkLoved) { await getMusicNetworkRuntime().setTrackLoved(track, false); setNetworkLoved(false); } + else { await getMusicNetworkRuntime().setTrackLoved(track, true); setNetworkLoved(true); } + }, [currentTrack, networkLoved, networkLoveEnabled]); - return { starred, lfmLoved, toggleStar, toggleLfmLove }; + return { starred, networkLoved, toggleStar, toggleNetworkLove }; } diff --git a/src/locales/de/connection.ts b/src/locales/de/connection.ts index efe6cd04..97cefb76 100644 --- a/src/locales/de/connection.ts +++ b/src/locales/de/connection.ts @@ -34,6 +34,4 @@ export const connection = { switchServerHint: 'Klicken, um einen anderen gespeicherten Server zu wählen.', manageServers: 'Server verwalten…', switchFailed: 'Wechsel fehlgeschlagen — Server nicht erreichbar.', - lastfmConnected: 'Last.fm verbunden als @{{user}}', - lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden', }; diff --git a/src/locales/de/contextMenu.ts b/src/locales/de/contextMenu.ts index 2666bc60..bd5838ab 100644 --- a/src/locales/de/contextMenu.ts +++ b/src/locales/de/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Instant Mix', instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.', cliMixNeedsTrack: 'Es läuft nichts — starte zuerst die Wiedergabe und führe den Mix-Befehl erneut aus.', - lfmLove: 'Auf Last.fm liken', - lfmUnlove: 'Last.fm-Like entfernen', + networkLove: 'Auf {{provider}} liken', + networkUnlove: '{{provider}}-Like entfernen', favorite: 'Favorisieren', favoriteArtist: 'Künstler favorisieren', favoriteAlbum: 'Album favorisieren', diff --git a/src/locales/de/help.ts b/src/locales/de/help.ts index 43f4d709..2446f04b 100644 --- a/src/locales/de/help.ts +++ b/src/locales/de/help.ts @@ -98,8 +98,8 @@ export const help = { a38: 'Device Sync (Sidebar) kopiert Alben, Playlists oder ganze Künstler auf ein externes Laufwerk für Offline-Hören auf anderen Geräten. Zielordner wählen, Quellen aus den Browser-Tabs auswählen, „Auf Gerät übertragen" klicken. Psysonic schreibt ein Manifest (psysonic-sync.json), sodass es weiß, welche Dateien bereits dort sind, auch beim Wieder-Öffnen unter einem anderen OS mit anderer Vorlage. Vorlagen unterstützen {artist} / {album} / {title} / {track_number} / {disc_number} / {year}-Tokens mit / als Ordner-Trennzeichen.', // ── Section 10: Integrationen & Fehlerbehebung ───────────────────────── s10: 'Integrationen & Fehlerbehebung', - q39: 'Last.fm-Scrobbling?', - a39: 'Einstellungen → Integrationen → Last.fm. Konto einmal verbinden, Psysonic scrobbelt direkt zu Last.fm — keine Navidrome-Konfiguration nötig. Ein Scrobble wird gesendet, wenn 50 % eines Tracks gehört wurden. Now-Playing-Pings gehen zu Beginn raus.', + q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?', + a39: 'Einstellungen → Integrationen → Music Network. Verbinde einen oder mehrere Scrobble-Dienste (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja oder einen beliebigen GNU-FM-kompatiblen Server) — keine Navidrome-Konfiguration nötig. Jeder verbundene Dienst mit aktiviertem Scrobbeln erhält den Play; der Hauptschalter „Scrobbeln aktivieren" schaltet das ganze Fan-out an oder aus. Ein Scrobble wird nach 50 % eines Tracks gesendet; Now-Playing-Pings gehen zu Beginn raus, sofern der Dienst sie unterstützt. Ein Dienst ist dein Hauptdienst — geliebte Titel, ähnliche Künstler und Hörstatistiken kommen von ihm. Tipp: Wenn dein Navidrome-Server bereits zu einem Dienst scrobbelt (z. B. Maloja), verbinde denselben Dienst nicht zusätzlich hier, sonst entstehen doppelte Scrobbles.', q40: 'Discord Rich Presence?', a40: 'Einstellungen → Integrationen → Discord zeigt deinen aktuellen Track auf deinem Discord-Profil. Wahl zwischen App-Icon, Cover-Art deines Servers (über getAlbumInfo2 — benötigt einen öffentlich erreichbaren Server) oder Apple-Music-Covern. Anzeige-Strings (Details, Status, Tooltip) per Token-Templates anpassen.', q41: 'Bandsintown-Tour-Daten?', diff --git a/src/locales/de/index.ts b/src/locales/de/index.ts index 7eec1952..328ae299 100644 --- a/src/locales/de/index.ts +++ b/src/locales/de/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const deTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/de/musicNetwork.ts b/src/locales/de/musicNetwork.ts new file mode 100644 index 00000000..f87c8cce --- /dev/null +++ b/src/locales/de/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Übertrage deine Wiedergaben an einen oder mehrere Dienste. „Geliebt", ähnliche Künstler und Hörstatistiken stammen vom gewählten Hauptdienst.', + masterToggle: 'Scrobbeln aktivieren', + masterToggleDesc: 'Hauptschalter für das Senden von Wiedergaben an alle verbundenen Dienste.', + addService: 'Dienst hinzufügen', + connect: 'Verbinden', + connecting: 'Verbinde…', + disconnect: 'Trennen', + scrobbleHere: 'Hierhin scrobbeln', + primaryLabel: 'Hauptdienst', + primaryDesc: 'Von hier kommen ❤-Titel, ähnliche Künstler und deine Hörstatistik — Scrobbeln läuft unabhängig davon an alle aktivierten Dienste.', + primaryNone: 'Keiner', + statusConnected: 'Verbunden', + statusError: 'Neu verbinden nötig', + scrobbles: '{{n}} Scrobbles', + memberSince: 'Mitglied seit {{year}}', + connectFailed: 'Verbindung fehlgeschlagen — bitte erneut versuchen.', + connectProbeFailed: '{{provider}} verbunden, konnte aber nicht validiert werden: {{message}}', + fieldRequired: '{{field}} ist erforderlich.', + malojaProxyWarning: 'Dein Maloja-Server leitet Scrobbles möglicherweise an Last.fm weiter. Deaktiviere das Scrobbeln auf einer Seite, um Duplikate zu vermeiden.', + presets: { + lastfm: { desc: 'Der ursprüngliche Scrobbling-Dienst, mit „Geliebt", ähnlichen Künstlern und Statistiken.' }, + librefm: { desc: 'Open-Source-Scrobbling auf dem Last.fm-kompatiblen Libre.fm.' }, + rocksky: { desc: 'Reines Scrobble-Ziel auf dem AT Protocol.' }, + listenbrainz: { desc: 'Offene Hörhistorie von MetaBrainz. Scrobble-Ziel.' }, + malojaNative: { desc: 'Selbst gehosteter Scrobble-Server (native API).' }, + malojaCompat: { desc: 'Selbstgehostetes Maloja über seine Audioscrobbler-(GNU-FM-)API.' }, + malojaListenbrainz: { desc: 'Selbst gehostetes Maloja über die ListenBrainz-kompatible API.' }, + koito: { desc: 'Selbstgehosteter Hörverlauf-Tracker über seine ListenBrainz-kompatible API.' }, + customGnufm: { desc: 'Beliebige selbst gehostete GNU-FM-/Last.fm-kompatible Instanz.' }, + }, + fields: { + lbToken: 'Benutzer-Token', + malojaUrl: 'Server-URL', + malojaKey: 'API-Schlüssel', + koitoUrl: 'Server-URL', + koitoToken: 'API-Schlüssel', + koitoTokenHelp: 'Erstelle in Koito unter Einstellungen → API Keys einen API-Schlüssel und füge ihn hier ein.', + rockskySessionKey: 'Sitzungsschlüssel', + rockskySessionKeyHelp: 'Rocksky vergibt diesen Schlüssel nur über seine CLI:\n1. npx -y @rocksky/cli@latest login dein-handle\n2. cat ~/.rocksky/token.json\n3. Den "token"-Wert hier einfügen.', + gnufmUrl: 'Server-URL', + apiKey: 'API-Schlüssel', + apiSecret: 'Geheimer Schlüssel', + }, + errors: { + AUTH_SESSION_INVALID: 'Sitzung abgelaufen — in den Einstellungen neu verbinden.', + AUTH_TIMEOUT: 'Autorisierung abgelaufen — bitte erneut versuchen.', + PROBE_FAILED: 'Diese Funktion ist bei diesem Dienst nicht verfügbar.', + CAPABILITY_UNSUPPORTED: 'Dieser Dienst unterstützt diese Funktion nicht.', + NETWORK: 'Netzwerkfehler — prüfe Verbindung oder URL.', + MALOJA_BAD_KEY: 'Ungültiger Maloja-API-Schlüssel.', + CUSTOM_URL_INVALID: 'Die Server-URL ist nicht erreichbar.', + }, +}; diff --git a/src/locales/de/nowPlaying.ts b/src/locales/de/nowPlaying.ts index 32f1a977..c962828d 100644 --- a/src/locales/de/nowPlaying.ts +++ b/src/locales/de/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: 'vor {{count}} Jahr', releasedYearsAgo_other: 'vor {{count}} Jahren', discography: 'Diskografie', - lastfmStats: 'Last.fm-Statistik', thisTrack: 'Dieser Titel', thisArtist: 'Dieser Künstler', listeners: 'Hörer', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} Hörer', scrobblesN: '{{n}} Scrobbles', playsByYouN: '{{n}}× von dir gespielt', - openTrackOnLastfm: 'Track auf Last.fm', - openArtistOnLastfm: 'Künstler auf Last.fm', rgTrackTooltip: 'ReplayGain (Track)', rgAlbumTooltip: 'ReplayGain (Album)', rgAutoTooltip: 'ReplayGain (Auto)', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 3a7814d2..7519b988 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -137,18 +137,6 @@ export const settings = { eqAutoEqError: 'Suche fehlgeschlagen', eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen', eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden', - lfmTitle: 'Last.fm', - lfmConnect: 'Mit Last.fm verbinden', - lfmConnecting: 'Warte auf Bestätigung…', - lfmConfirm: 'Ich habe die App autorisiert', - lfmConnected: 'Verbunden als', - lfmDisconnect: 'Trennen', - lfmConnectDesc: 'Verbinde deinen Last.fm Account, um Scrobbling und Now-Playing-Updates direkt aus Psysonic heraus zu aktivieren — keine Navidrome-Konfiguration erforderlich.', - lfmOpenBrowser: 'Es öffnet sich ein Browserfenster. Autorisiere Psysonic auf Last.fm und klicke dann unten auf den Button.', - lfmScrobbles: '{{n}} Scrobbles', - lfmMemberSince: 'Dabei seit {{year}}', - scrobbleEnabled: 'Scrobbling aktiviert', - scrobbleDesc: 'Songs nach 50% Laufzeit an Last.fm senden', behavior: 'App-Verhalten', cacheTitle: 'Max. Speichergröße', cacheDesc: 'Browser-Cache für Cover und Künstlerbilder (IndexedDB). Wenn voll, werden die ältesten Einträge automatisch entfernt.', @@ -438,7 +426,7 @@ export const settings = { playerBarReset: 'Zurücksetzen', playerBarStarRating: 'Sterne-Bewertung', playerBarFavorite: 'Favorit (Herz)', - playerBarLastfmLove: 'Last.fm Love', + playerBarLastfmLove: 'Love-Button', playerBarPlaybackRate: 'Wiedergabegeschwindigkeit', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Mini-Player', diff --git a/src/locales/de/statistics.ts b/src/locales/de/statistics.ts index c617e51b..416766f6 100644 --- a/src/locales/de/statistics.ts +++ b/src/locales/de/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} Album', decadeAlbums_other: '{{count}} Alben', decadeUnknown: 'Unbekannt', - lfmTitle: 'Last.fm Statistiken', + lfmTitle: '{{provider}} Statistiken', lfmTopArtists: 'Top-Künstler', lfmTopAlbums: 'Top-Alben', lfmTopTracks: 'Top-Titel', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 Monate', lfmPeriod6month: '6 Monate', lfmPeriod12month: '12 Monate', - lfmNotConnected: 'Verbinde Last.fm in den Einstellungen, um deine Statistiken zu sehen.', lfmRecentTracks: 'Zuletzt gescrobbelt', lfmNowPlaying: 'Läuft gerade', lfmJustNow: 'gerade eben', diff --git a/src/locales/en/connection.ts b/src/locales/en/connection.ts index 6473c5f5..272e2634 100644 --- a/src/locales/en/connection.ts +++ b/src/locales/en/connection.ts @@ -35,6 +35,4 @@ export const connection = { switchServerHint: 'Click to choose another saved server.', manageServers: 'Manage servers…', switchFailed: 'Could not switch — server unreachable.', - lastfmConnected: 'Last.fm connected as @{{user}}', - lastfmSessionInvalid: 'Session invalid — click to re-connect', }; diff --git a/src/locales/en/contextMenu.ts b/src/locales/en/contextMenu.ts index e1c7762a..0b641a3c 100644 --- a/src/locales/en/contextMenu.ts +++ b/src/locales/en/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Instant Mix', instantMixFailed: 'Could not build Instant Mix — server or plugin error.', cliMixNeedsTrack: 'Nothing is playing — start playback first, then run the mix command again.', - lfmLove: 'Love on Last.fm', - lfmUnlove: 'Unlove on Last.fm', + networkLove: 'Love on {{provider}}', + networkUnlove: 'Unlove on {{provider}}', favorite: 'Favorite', favoriteArtist: 'Favorite Artist', favoriteAlbum: 'Favorite Album', diff --git a/src/locales/en/help.ts b/src/locales/en/help.ts index c75e9014..aef144df 100644 --- a/src/locales/en/help.ts +++ b/src/locales/en/help.ts @@ -98,8 +98,8 @@ export const help = { a38: 'Device Sync (sidebar) copies albums, playlists or whole artists to an external drive for offline listening on other devices. Pick a target folder, choose sources from the browser tabs, click "Transfer to Device". Psysonic writes a manifest (psysonic-sync.json) so it knows which files are already there even if you reopen Device Sync on a different OS with a different filename template. Templates support {artist} / {album} / {title} / {track_number} / {disc_number} / {year} tokens with / for folder separators.', // ── Section 10: Integrations & Troubleshooting ───────────────────────── s10: 'Integrations & Troubleshooting', - q39: 'Last.fm scrobbling?', - a39: 'Settings → Integrations → Last.fm. Connect your account once and Psysonic scrobbles directly to Last.fm — no Navidrome configuration needed. A scrobble is sent after you have heard 50 % of a track. Now-playing pings are sent at the start.', + q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?', + a39: 'Settings → Integrations → Music Network. Connect one or more scrobble services (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja, or any GNU FM-compatible server) — no Navidrome configuration needed. Every connected service with scrobbling enabled receives the play; the master "Enable scrobbling" switch turns the whole fan-out on or off. A scrobble is sent after you have heard 50 % of a track; now-playing pings go out at the start, on services that support it. One service is your primary — your loved tracks, similar artists and listening stats are read from it. Tip: if your Navidrome server already scrobbles to a service (e.g. Maloja), don\'t also connect that same service here, or you\'ll get duplicate scrobbles.', q40: 'Discord Rich Presence?', a40: 'Settings → Integrations → Discord shows your current track on your Discord profile. Choose between the app icon, your server\'s cover art (via getAlbumInfo2 — needs a publicly reachable server), or Apple Music covers. Customize the display strings (details, state, tooltip) with token templates.', q41: 'Bandsintown tour dates?', diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 3ab87a07..99723a5c 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const enTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/en/musicNetwork.ts b/src/locales/en/musicNetwork.ts new file mode 100644 index 00000000..bc5a70ff --- /dev/null +++ b/src/locales/en/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Scrobble your plays to one or more services. Love, similar artists and listening stats come from your chosen primary.', + masterToggle: 'Enable scrobbling', + masterToggleDesc: 'Master switch for sending plays to every connected service.', + addService: 'Add a service', + connect: 'Connect', + connecting: 'Connecting…', + disconnect: 'Disconnect', + scrobbleHere: 'Scrobble here', + primaryLabel: 'Primary service', + primaryDesc: 'Liked tracks (❤), similar artists and your listening stats come from here — scrobbling goes to all enabled services regardless.', + primaryNone: 'None', + statusConnected: 'Connected', + statusError: 'Reconnect needed', + scrobbles: '{{n}} scrobbles', + memberSince: 'Member since {{year}}', + connectFailed: 'Could not connect — please try again.', + connectProbeFailed: 'Connected {{provider}}, but it could not be validated: {{message}}', + fieldRequired: '{{field}} is required.', + malojaProxyWarning: 'Your Maloja server may forward scrobbles to Last.fm. Disable scrobbling on one side to avoid duplicates.', + presets: { + lastfm: { desc: 'The original scrobbling service, with love, similar artists and stats.' }, + librefm: { desc: 'Open-source scrobbling on the Last.fm-compatible Libre.fm.' }, + rocksky: { desc: 'Scrobble-only destination on the AT Protocol.' }, + listenbrainz: { desc: 'Open listening history by MetaBrainz. Scrobble destination.' }, + malojaNative: { desc: 'Self-hosted scrobble server (native API).' }, + malojaCompat: { desc: 'Self-hosted Maloja via its Audioscrobbler (GNU FM) API.' }, + malojaListenbrainz: { desc: 'Self-hosted Maloja via its ListenBrainz-compatible API.' }, + koito: { desc: 'Self-hosted listening tracker via its ListenBrainz-compatible API.' }, + customGnufm: { desc: 'Any self-hosted GNU FM / Last.fm-compatible instance.' }, + }, + fields: { + lbToken: 'User token', + malojaUrl: 'Server URL', + malojaKey: 'API key', + koitoUrl: 'Server URL', + koitoToken: 'API key', + koitoTokenHelp: 'Generate an API key in Koito under Settings → API Keys, then paste it here.', + rockskySessionKey: 'Session key', + rockskySessionKeyHelp: 'Rocksky issues this key only via its CLI:\n1. npx -y @rocksky/cli@latest login your-handle\n2. cat ~/.rocksky/token.json\n3. Paste the "token" value here.', + gnufmUrl: 'Server URL', + apiKey: 'API key', + apiSecret: 'Shared secret', + }, + errors: { + AUTH_SESSION_INVALID: 'Session expired — reconnect in Settings.', + AUTH_TIMEOUT: 'Authorization timed out — please try again.', + PROBE_FAILED: 'This feature is unavailable on this service.', + CAPABILITY_UNSUPPORTED: 'This service does not support that feature.', + NETWORK: 'Network error — check your connection or URL.', + MALOJA_BAD_KEY: 'Invalid Maloja API key.', + CUSTOM_URL_INVALID: 'The server URL is unreachable.', + }, +}; diff --git a/src/locales/en/nowPlaying.ts b/src/locales/en/nowPlaying.ts index ca131044..353a8d9a 100644 --- a/src/locales/en/nowPlaying.ts +++ b/src/locales/en/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: '{{count}} year ago', releasedYearsAgo_other: '{{count}} years ago', discography: 'Discography', - lastfmStats: 'Last.fm stats', thisTrack: 'This track', thisArtist: 'This artist', listeners: 'listeners', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} listeners', scrobblesN: '{{n}} scrobbles', playsByYouN: 'played {{n}}× by you', - openTrackOnLastfm: 'Track on Last.fm', - openArtistOnLastfm: 'Artist on Last.fm', rgTrackTooltip: 'ReplayGain (track)', rgAlbumTooltip: 'ReplayGain (album)', rgAutoTooltip: 'ReplayGain (auto)', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index a8fdcbb0..ed757646 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -137,18 +137,6 @@ export const settings = { eqAutoEqError: 'Search failed', eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute', eqAutoEqFetchError: 'Failed to fetch EQ profile', - lfmTitle: 'Last.fm', - lfmConnect: 'Connect with Last.fm', - lfmConnecting: 'Waiting for authorisation…', - lfmConfirm: 'I have authorised the app', - lfmConnected: 'Connected as', - lfmDisconnect: 'Disconnect', - lfmConnectDesc: 'Connect your Last.fm account to enable scrobbling and Now Playing updates directly from Psysonic — no Navidrome configuration required.', - lfmOpenBrowser: 'A browser window will open. Authorise Psysonic on Last.fm, then click the button below.', - lfmScrobbles: '{{n}} scrobbles', - lfmMemberSince: 'Member since {{year}}', - scrobbleEnabled: 'Scrobbling enabled', - scrobbleDesc: 'Send songs to Last.fm after 50% playtime', behavior: 'App Behavior', cacheTitle: 'Max. Storage Size', cacheDesc: 'In-browser cache for cover art and artist images (IndexedDB). Oldest entries are removed automatically when space runs low.', @@ -482,7 +470,7 @@ export const settings = { playerBarReset: 'Reset to default', playerBarStarRating: 'Star rating', playerBarFavorite: 'Favorite (heart)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Love button', playerBarPlaybackRate: 'Playback speed', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Mini player', diff --git a/src/locales/en/statistics.ts b/src/locales/en/statistics.ts index ef110d7c..c782749e 100644 --- a/src/locales/en/statistics.ts +++ b/src/locales/en/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} Album', decadeAlbums_other: '{{count}} Albums', decadeUnknown: 'Unknown', - lfmTitle: 'Last.fm Stats', + lfmTitle: '{{provider}} Stats', lfmTopArtists: 'Top Artists', lfmTopAlbums: 'Top Albums', lfmTopTracks: 'Top Tracks', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 Months', lfmPeriod6month: '6 Months', lfmPeriod12month: '12 Months', - lfmNotConnected: 'Connect Last.fm in Settings to see your stats.', lfmRecentTracks: 'Recent Scrobbles', lfmNowPlaying: 'Now Playing', lfmJustNow: 'just now', diff --git a/src/locales/es/connection.ts b/src/locales/es/connection.ts index 8eac8f13..df37dbc2 100644 --- a/src/locales/es/connection.ts +++ b/src/locales/es/connection.ts @@ -34,6 +34,4 @@ export const connection = { switchServerHint: 'Clic para elegir otro servidor guardado.', manageServers: 'Gestionar servidores…', switchFailed: 'No se pudo cambiar — servidor inalcanzable.', - lastfmConnected: 'Last.fm conectado como @{{user}}', - lastfmSessionInvalid: 'Sesión inválida — click para reconectar', }; diff --git a/src/locales/es/contextMenu.ts b/src/locales/es/contextMenu.ts index 73edd119..5e8daf67 100644 --- a/src/locales/es/contextMenu.ts +++ b/src/locales/es/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Mezcla Instantánea', instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.', cliMixNeedsTrack: 'No hay nada en reproducción — inicia la reproducción y vuelve a ejecutar el comando de mezcla.', - lfmLove: 'Favorito en Last.fm', - lfmUnlove: 'Quitar favorito de Last.fm', + networkLove: 'Favorito en {{provider}}', + networkUnlove: 'Quitar favorito de {{provider}}', favorite: 'Favorito', favoriteArtist: 'Artista Favorito', favoriteAlbum: 'Álbum Favorito', diff --git a/src/locales/es/help.ts b/src/locales/es/help.ts index b383f9c9..d4d51a93 100644 --- a/src/locales/es/help.ts +++ b/src/locales/es/help.ts @@ -88,8 +88,8 @@ export const help = { q38: 'Device Sync — ¿copiar música a USB / SD?', a38: 'Device Sync (barra lateral) copia álbumes, listas o artistas enteros a una unidad externa para escucha offline en otros dispositivos. Elige una carpeta destino, elige fuentes de las pestañas del navegador, haz clic en «Transferir al dispositivo». Psysonic escribe un manifiesto (psysonic-sync.json) para saber qué archivos ya están allí, incluso si reabres Device Sync en otro SO con otra plantilla. Las plantillas soportan tokens {artist} / {album} / {title} / {track_number} / {disc_number} / {year} con / como separador de carpeta.', s10: 'Integraciones y Solución de Problemas', - q39: '¿Scrobbling de Last.fm?', - a39: 'Configuración → Integraciones → Last.fm. Conecta tu cuenta una vez y Psysonic scrobblea directamente a Last.fm — no se necesita configuración de Navidrome. Un scrobble se envía tras escuchar el 50 % de una pista. Los pings now-playing se envían al inicio.', + q39: '¿Scrobbling — Last.fm, ListenBrainz, Maloja…?', + a39: 'Configuración → Integraciones → Music Network. Conecta uno o más servicios de scrobbling (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja o cualquier servidor compatible con GNU FM) — no se necesita configuración de Navidrome. Cada servicio conectado con el scrobbling activado recibe la reproducción; el interruptor principal «Activar scrobbling» enciende o apaga toda la difusión. Un scrobble se envía tras escuchar el 50 % de una pista; los pings now-playing se envían al inicio, en los servicios que lo admiten. Un servicio es tu servicio principal — tus pistas favoritas, artistas similares y estadísticas de escucha provienen de él. Consejo: si tu servidor Navidrome ya scrobblea a un servicio (p. ej. Maloja), no conectes ese mismo servicio aquí o tendrás scrobbles duplicados.', q40: '¿Discord Rich Presence?', a40: 'Configuración → Integraciones → Discord muestra tu pista actual en tu perfil de Discord. Elige entre el icono de la app, las portadas de tu servidor (vía getAlbumInfo2 — requiere un servidor accesible públicamente), o portadas de Apple Music. Personaliza las cadenas mostradas (detalles, estado, tooltip) con plantillas de tokens.', q41: '¿Fechas de gira de Bandsintown?', diff --git a/src/locales/es/index.ts b/src/locales/es/index.ts index 92ff53b2..1760f48e 100644 --- a/src/locales/es/index.ts +++ b/src/locales/es/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const esTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/es/musicNetwork.ts b/src/locales/es/musicNetwork.ts new file mode 100644 index 00000000..1b5048d3 --- /dev/null +++ b/src/locales/es/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Envía tus reproducciones a uno o varios servicios. Los «me gusta», artistas similares y estadísticas provienen del servicio principal elegido.', + masterToggle: 'Activar scrobbling', + masterToggleDesc: 'Interruptor principal para enviar reproducciones a todos los servicios conectados.', + addService: 'Añadir un servicio', + connect: 'Conectar', + connecting: 'Conectando…', + disconnect: 'Desconectar', + scrobbleHere: 'Hacer scrobble aquí', + primaryLabel: 'Servicio principal', + primaryDesc: 'Los temas marcados (❤), artistas similares y tus estadísticas vienen de aquí — el scrobbling va a todos los servicios activados igualmente.', + primaryNone: 'Ninguno', + statusConnected: 'Conectado', + statusError: 'Reconexión necesaria', + scrobbles: '{{n}} scrobbles', + memberSince: 'Miembro desde {{year}}', + connectFailed: 'No se pudo conectar — inténtalo de nuevo.', + connectProbeFailed: '{{provider}} conectado, pero no se pudo validar: {{message}}', + fieldRequired: '{{field}} es obligatorio.', + malojaProxyWarning: 'Tu servidor Maloja podría reenviar los scrobbles a Last.fm. Desactiva el scrobbling en un lado para evitar duplicados.', + presets: { + lastfm: { desc: 'El servicio de scrobbling original, con «me gusta», artistas similares y estadísticas.' }, + librefm: { desc: 'Scrobbling de código abierto en Libre.fm, compatible con Last.fm.' }, + rocksky: { desc: 'Destino solo de scrobbling en el protocolo AT.' }, + listenbrainz: { desc: 'Historial de escucha abierto de MetaBrainz. Destino de scrobbling.' }, + malojaNative: { desc: 'Servidor de scrobbling autoalojado (API nativa).' }, + malojaCompat: { desc: 'Maloja autoalojado mediante su API Audioscrobbler (GNU FM).' }, + malojaListenbrainz: { desc: 'Maloja autoalojado mediante su API compatible con ListenBrainz.' }, + koito: { desc: 'Rastreador de escuchas autoalojado mediante su API compatible con ListenBrainz.' }, + customGnufm: { desc: 'Cualquier instancia autoalojada compatible con GNU FM / Last.fm.' }, + }, + fields: { + lbToken: 'Token de usuario', + malojaUrl: 'URL del servidor', + malojaKey: 'Clave API', + koitoUrl: 'URL del servidor', + koitoToken: 'Clave API', + koitoTokenHelp: 'Genera una clave API en Koito en Ajustes → API Keys y pégala aquí.', + rockskySessionKey: 'Clave de sesión', + rockskySessionKeyHelp: 'Rocksky solo entrega esta clave mediante su CLI:\n1. npx -y @rocksky/cli@latest login tu-handle\n2. cat ~/.rocksky/token.json\n3. Pega aquí el valor "token".', + gnufmUrl: 'URL del servidor', + apiKey: 'Clave API', + apiSecret: 'Secreto compartido', + }, + errors: { + AUTH_SESSION_INVALID: 'Sesión caducada — vuelve a conectar en Ajustes.', + AUTH_TIMEOUT: 'Autorización caducada — inténtalo de nuevo.', + PROBE_FAILED: 'Esta función no está disponible en este servicio.', + CAPABILITY_UNSUPPORTED: 'Este servicio no admite esa función.', + NETWORK: 'Error de red — comprueba la conexión o la URL.', + MALOJA_BAD_KEY: 'Clave API de Maloja no válida.', + CUSTOM_URL_INVALID: 'No se puede acceder a la URL del servidor.', + }, +}; diff --git a/src/locales/es/nowPlaying.ts b/src/locales/es/nowPlaying.ts index 22726605..90036110 100644 --- a/src/locales/es/nowPlaying.ts +++ b/src/locales/es/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: 'hace {{count}} año', releasedYearsAgo_other: 'hace {{count}} años', discography: 'Discografía', - lastfmStats: 'Estadísticas de Last.fm', thisTrack: 'Este tema', thisArtist: 'Este artista', listeners: 'oyentes', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} oyentes', scrobblesN: '{{n}} scrobbles', playsByYouN: 'reproducido {{n}}× por ti', - openTrackOnLastfm: 'Tema en Last.fm', - openArtistOnLastfm: 'Artista en Last.fm', rgTrackTooltip: 'ReplayGain (pista)', rgAlbumTooltip: 'ReplayGain (álbum)', rgAutoTooltip: 'ReplayGain (auto)', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 3fcb6049..94b8feab 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -136,18 +136,6 @@ export const settings = { eqAutoEqError: 'Error de búsqueda', eqAutoEqRateLimit: 'Límite de GitHub alcanzado — intenta de nuevo en un minuto', eqAutoEqFetchError: 'Error al obtener perfil EQ', - lfmTitle: 'Last.fm', - lfmConnect: 'Conectar con Last.fm', - lfmConnecting: 'Esperando autorización…', - lfmConfirm: 'He autorizado la aplicación', - lfmConnected: 'Conectado como', - lfmDisconnect: 'Desconectar', - lfmConnectDesc: 'Conecta tu cuenta de Last.fm para habilitar scrobbling y actualizaciones de "Reproduciendo Ahora" directamente desde Psysonic — no requiere configuración de Navidrome.', - lfmOpenBrowser: 'Se abrirá una ventana del navegador. Autoriza Psysonic en Last.fm, luego click en el botón de abajo.', - lfmScrobbles: '{{n}} scrobbles', - lfmMemberSince: 'Miembro desde {{year}}', - scrobbleEnabled: 'Scrobbling habilitado', - scrobbleDesc: 'Enviar canciones a Last.fm después del 50% de reproducción', behavior: 'Comportamiento de la App', cacheTitle: 'Tamaño Máx. de Almacenamiento', cacheDesc: 'Caché del navegador para portadas e imágenes de artistas (IndexedDB). Cuando está lleno, las entradas más antiguas se eliminan automáticamente.', @@ -437,7 +425,7 @@ export const settings = { playerBarReset: 'Restablecer a predeterminado', playerBarStarRating: 'Calificación con estrellas', playerBarFavorite: 'Favorito (corazón)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Botón Me gusta', playerBarPlaybackRate: 'Velocidad de reproducción', playerBarEqualizer: 'Ecualizador', playerBarMiniPlayer: 'Mini reproductor', diff --git a/src/locales/es/statistics.ts b/src/locales/es/statistics.ts index e2f01ccf..5be1877f 100644 --- a/src/locales/es/statistics.ts +++ b/src/locales/es/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} Álbum', decadeAlbums_other: '{{count}} Álbumes', decadeUnknown: 'Desconocido', - lfmTitle: 'Estadísticas de Last.fm', + lfmTitle: 'Estadísticas de {{provider}}', lfmTopArtists: 'Artistas Principales', lfmTopAlbums: 'Álbumes Principales', lfmTopTracks: 'Pistas Principales', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 Meses', lfmPeriod6month: '6 Meses', lfmPeriod12month: '12 Meses', - lfmNotConnected: 'Conecta Last.fm en Configuración para ver tus estadísticas.', lfmRecentTracks: 'Scrobbles Recientes', lfmNowPlaying: 'Reproduciendo Ahora', lfmJustNow: 'ahora mismo', diff --git a/src/locales/fr/connection.ts b/src/locales/fr/connection.ts index 41a61251..8b5ff348 100644 --- a/src/locales/fr/connection.ts +++ b/src/locales/fr/connection.ts @@ -34,6 +34,4 @@ export const connection = { switchServerHint: 'Cliquez pour choisir un autre serveur enregistré.', manageServers: 'Gérer les serveurs…', switchFailed: 'Impossible de changer — serveur injoignable.', - lastfmConnected: 'Last.fm connecté en tant que @{{user}}', - lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter', }; diff --git a/src/locales/fr/contextMenu.ts b/src/locales/fr/contextMenu.ts index 250e0edf..deb734ea 100644 --- a/src/locales/fr/contextMenu.ts +++ b/src/locales/fr/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Mix instantané', instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.', cliMixNeedsTrack: 'Aucune lecture en cours — lancez d’abord la lecture, puis relancez la commande de mix.', - lfmLove: 'Aimer sur Last.fm', - lfmUnlove: 'Ne plus aimer sur Last.fm', + networkLove: 'Aimer sur {{provider}}', + networkUnlove: 'Ne plus aimer sur {{provider}}', favorite: 'Favori', favoriteArtist: 'Artiste favori', favoriteAlbum: 'Album favori', diff --git a/src/locales/fr/help.ts b/src/locales/fr/help.ts index 251d33e1..b98525ff 100644 --- a/src/locales/fr/help.ts +++ b/src/locales/fr/help.ts @@ -88,8 +88,8 @@ export const help = { q38: 'Device Sync — copier la musique sur USB / SD ?', a38: 'Device Sync (barre latérale) copie albums, listes ou artistes entiers vers un disque externe pour l\'écoute hors ligne sur d\'autres appareils. Choisissez un dossier cible, sélectionnez les sources dans les onglets de navigation, cliquez « Transférer vers le périphérique ». Psysonic écrit un manifeste (psysonic-sync.json) pour savoir quels fichiers sont déjà là, même si vous rouvrez Device Sync sur un autre OS avec un autre modèle de nom. Les modèles supportent les jetons {artist} / {album} / {title} / {track_number} / {disc_number} / {year} avec / comme séparateur de dossier.', s10: 'Intégrations & Dépannage', - q39: 'Scrobbling Last.fm ?', - a39: 'Paramètres → Intégrations → Last.fm. Connectez votre compte une fois et Psysonic scrobble directement vers Last.fm — pas besoin de configurer Navidrome. Un scrobble est envoyé après 50 % d\'écoute. Les pings « now playing » partent au début.', + q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja… ?', + a39: 'Paramètres → Intégrations → Music Network. Connectez un ou plusieurs services de scrobbling (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja ou tout serveur compatible GNU FM) — pas besoin de configurer Navidrome. Chaque service connecté avec le scrobbling activé reçoit l\'écoute ; l\'interrupteur principal « Activer le scrobbling » active ou désactive toute la diffusion. Un scrobble est envoyé après 50 % d\'écoute ; les pings « now playing » partent au début, sur les services qui les prennent en charge. Un service est votre service principal — vos titres aimés, artistes similaires et statistiques d\'écoute en proviennent. Astuce : si votre serveur Navidrome scrobble déjà vers un service (par ex. Maloja), ne connectez pas ce même service ici, sinon vous aurez des scrobbles en double.', q40: 'Discord Rich Presence ?', a40: 'Paramètres → Intégrations → Discord affiche la piste en cours sur votre profil Discord. Choisissez entre l\'icône de l\'app, la pochette de votre serveur (via getAlbumInfo2 — serveur publiquement joignable nécessaire) ou les pochettes Apple Music. Personnalisez les chaînes affichées (détails, état, info-bulle) avec des templates de jetons.', q41: 'Dates de tournée Bandsintown ?', diff --git a/src/locales/fr/index.ts b/src/locales/fr/index.ts index d382e991..c169ef42 100644 --- a/src/locales/fr/index.ts +++ b/src/locales/fr/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const frTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/fr/musicNetwork.ts b/src/locales/fr/musicNetwork.ts new file mode 100644 index 00000000..b832c800 --- /dev/null +++ b/src/locales/fr/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Envoyez vos écoutes vers un ou plusieurs services. Les coups de cœur, artistes similaires et statistiques proviennent du service principal choisi.', + masterToggle: 'Activer le scrobbling', + masterToggleDesc: 'Interrupteur principal pour envoyer les écoutes à tous les services connectés.', + addService: 'Ajouter un service', + connect: 'Connecter', + connecting: 'Connexion…', + disconnect: 'Déconnecter', + scrobbleHere: 'Scrobbler ici', + primaryLabel: 'Service principal', + primaryDesc: 'Les titres aimés (❤), artistes similaires et tes statistiques d’écoute viennent d’ici — le scrobbling va à tous les services activés, indépendamment.', + primaryNone: 'Aucun', + statusConnected: 'Connecté', + statusError: 'Reconnexion nécessaire', + scrobbles: '{{n}} scrobbles', + memberSince: 'Membre depuis {{year}}', + connectFailed: 'Échec de la connexion — veuillez réessayer.', + connectProbeFailed: '{{provider}} connecté, mais la validation a échoué : {{message}}', + fieldRequired: '{{field}} est requis.', + malojaProxyWarning: 'Votre serveur Maloja peut transférer les scrobbles vers Last.fm. Désactivez le scrobbling d’un côté pour éviter les doublons.', + presets: { + lastfm: { desc: 'Le service de scrobbling original, avec coups de cœur, artistes similaires et statistiques.' }, + librefm: { desc: 'Scrobbling open source sur Libre.fm, compatible Last.fm.' }, + rocksky: { desc: 'Destination de scrobbling uniquement sur le protocole AT.' }, + listenbrainz: { desc: 'Historique d’écoute ouvert par MetaBrainz. Destination de scrobbling.' }, + malojaNative: { desc: 'Serveur de scrobbling auto-hébergé (API native).' }, + malojaCompat: { desc: 'Maloja auto-hébergé via son API Audioscrobbler (GNU FM).' }, + malojaListenbrainz: { desc: 'Maloja auto-hébergé via son API compatible ListenBrainz.' }, + koito: { desc: 'Suivi d’écoute auto-hébergé via son API compatible ListenBrainz.' }, + customGnufm: { desc: 'Toute instance auto-hébergée compatible GNU FM / Last.fm.' }, + }, + fields: { + lbToken: 'Jeton utilisateur', + malojaUrl: 'URL du serveur', + malojaKey: 'Clé API', + koitoUrl: 'URL du serveur', + koitoToken: 'Clé API', + koitoTokenHelp: 'Générez une clé API dans Koito sous Paramètres → API Keys, puis collez-la ici.', + rockskySessionKey: 'Clé de session', + rockskySessionKeyHelp: 'Rocksky ne fournit cette clé que via son CLI :\n1. npx -y @rocksky/cli@latest login votre-handle\n2. cat ~/.rocksky/token.json\n3. Collez ici la valeur « token ».', + gnufmUrl: 'URL du serveur', + apiKey: 'Clé API', + apiSecret: 'Secret partagé', + }, + errors: { + AUTH_SESSION_INVALID: 'Session expirée — reconnectez-vous dans les Réglages.', + AUTH_TIMEOUT: 'Autorisation expirée — veuillez réessayer.', + PROBE_FAILED: 'Cette fonctionnalité est indisponible sur ce service.', + CAPABILITY_UNSUPPORTED: 'Ce service ne prend pas en charge cette fonctionnalité.', + NETWORK: 'Erreur réseau — vérifiez la connexion ou l’URL.', + MALOJA_BAD_KEY: 'Clé API Maloja invalide.', + CUSTOM_URL_INVALID: 'L’URL du serveur est injoignable.', + }, +}; diff --git a/src/locales/fr/nowPlaying.ts b/src/locales/fr/nowPlaying.ts index b1a54dc6..1d0e3ac0 100644 --- a/src/locales/fr/nowPlaying.ts +++ b/src/locales/fr/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: 'il y a {{count}} an', releasedYearsAgo_other: 'il y a {{count}} ans', discography: 'Discographie', - lastfmStats: 'Statistiques Last.fm', thisTrack: 'Ce titre', thisArtist: 'Cet artiste', listeners: 'auditeurs', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} auditeurs', scrobblesN: '{{n}} scrobbles', playsByYouN: 'écouté {{n}}× par vous', - openTrackOnLastfm: 'Titre sur Last.fm', - openArtistOnLastfm: 'Artiste sur Last.fm', rgTrackTooltip: 'ReplayGain (piste)', rgAlbumTooltip: 'ReplayGain (album)', rgAutoTooltip: 'ReplayGain (auto)', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index e6a09b7b..8c524fe5 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -136,18 +136,6 @@ export const settings = { eqAutoEqError: 'Échec de la recherche', eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute', eqAutoEqFetchError: 'Impossible de charger le profil EQ', - lfmTitle: 'Last.fm', - lfmConnect: 'Connecter avec Last.fm', - lfmConnecting: 'En attente d\'autorisation…', - lfmConfirm: 'J\'ai autorisé l\'application', - lfmConnected: 'Connecté en tant que', - lfmDisconnect: 'Déconnecter', - lfmConnectDesc: 'Connectez votre compte Last.fm pour activer le scrobbling et les mises à jour « En cours de lecture » depuis Psysonic — aucune configuration Navidrome requise.', - lfmOpenBrowser: 'Une fenêtre de navigateur s\'ouvrira. Autorisez Psysonic sur Last.fm, puis cliquez sur le bouton ci-dessous.', - lfmScrobbles: '{{n}} scrobbles', - lfmMemberSince: 'Membre depuis {{year}}', - scrobbleEnabled: 'Scrobbling activé', - scrobbleDesc: 'Envoyer les morceaux à Last.fm après 50% d\'écoute', behavior: 'Comportement de l\'application', cacheTitle: 'Taille max. du stockage', cacheDesc: 'Cache navigateur pour pochettes et images d\'artistes (IndexedDB). Quand il est plein, les entrées les plus anciennes sont supprimées automatiquement.', @@ -435,7 +423,7 @@ export const settings = { playerBarReset: 'Réinitialiser', playerBarStarRating: 'Note par étoiles', playerBarFavorite: 'Favori (cœur)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Bouton J’aime', playerBarPlaybackRate: 'Vitesse de lecture', playerBarEqualizer: 'Égaliseur', playerBarMiniPlayer: 'Mini-lecteur', diff --git a/src/locales/fr/statistics.ts b/src/locales/fr/statistics.ts index 7b212b5c..ce19a04f 100644 --- a/src/locales/fr/statistics.ts +++ b/src/locales/fr/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} album', decadeAlbums_other: '{{count}} albums', decadeUnknown: 'Inconnu', - lfmTitle: 'Stats Last.fm', + lfmTitle: 'Stats {{provider}}', lfmTopArtists: 'Artistes favoris', lfmTopAlbums: 'Albums favoris', lfmTopTracks: 'Morceaux favoris', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 mois', lfmPeriod6month: '6 mois', lfmPeriod12month: '12 mois', - lfmNotConnected: 'Connectez Last.fm dans les Paramètres pour voir vos stats.', lfmRecentTracks: 'Scrobbles récents', lfmNowPlaying: 'En cours de lecture', lfmJustNow: 'à l\'instant', diff --git a/src/locales/nb/connection.ts b/src/locales/nb/connection.ts index 731a28a4..42ede0b2 100644 --- a/src/locales/nb/connection.ts +++ b/src/locales/nb/connection.ts @@ -34,6 +34,4 @@ export const connection = { switchServerHint: 'Klikk for å velge en annen lagret server.', manageServers: 'Administrer servere…', switchFailed: 'Klarte ikke å bytte — serveren er utilgjengelig.', - lastfmConnected: 'Last.fm tilkoblet som bruker @{{user}}', - lastfmSessionInvalid: 'Sesjonen er ugyldig - klikk her for å koble til på nytt', }; diff --git a/src/locales/nb/contextMenu.ts b/src/locales/nb/contextMenu.ts index b4ba24ea..0dd396ab 100644 --- a/src/locales/nb/contextMenu.ts +++ b/src/locales/nb/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Instant Mix', instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.', cliMixNeedsTrack: 'Ingenting spilles — start avspilling først, og kjør mix-kommandoen på nytt.', - lfmLove: 'Lik på Last.fm', - lfmUnlove: 'Fjern fra likte på Last.fm', + networkLove: 'Lik på {{provider}}', + networkUnlove: 'Fjern fra likte på {{provider}}', favorite: 'Favoritt', favoriteArtist: 'Favorittartist', favoriteAlbum: 'Favorittalbum', diff --git a/src/locales/nb/help.ts b/src/locales/nb/help.ts index be1713f1..74fd975d 100644 --- a/src/locales/nb/help.ts +++ b/src/locales/nb/help.ts @@ -88,8 +88,8 @@ export const help = { q38: 'Device Sync — kopiere musikk til USB / SD?', a38: 'Device Sync (sidefelt) kopierer album, spillelister eller hele artister til en ekstern stasjon for offline lytting på andre enheter. Velg en målmappe, velg kilder fra browserfanene, klikk «Overfør til enhet». Psysonic skriver et manifest (psysonic-sync.json) slik at det vet hvilke filer som allerede er der, selv hvis du åpner Device Sync på et annet OS med en annen filnavn-mal. Maler støtter {artist} / {album} / {title} / {track_number} / {disc_number} / {year}-tokens med / som mappe-skille.', s10: 'Integrasjoner & Feilsøking', - q39: 'Last.fm-scrobbling?', - a39: 'Innstillinger → Integrasjoner → Last.fm. Koble til kontoen din én gang og Psysonic scrobbler direkte til Last.fm — ingen Navidrome-konfigurasjon trengs. En scrobble sendes etter at du har hørt 50 % av en låt. Now-playing-pings sendes ved start.', + q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?', + a39: 'Innstillinger → Integrasjoner → Music Network. Koble til én eller flere scrobbletjenester (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja eller en GNU FM-kompatibel server) — ingen Navidrome-konfigurasjon trengs. Hver tilkoblet tjeneste med scrobbling på mottar avspillingen; hovedbryteren «Aktiver scrobbling» slår hele utsendingen av eller på. En scrobble sendes etter at du har hørt 50 % av en låt; now-playing-pings sendes ved start, på tjenester som støtter det. Én tjeneste er hovedtjenesten din — likte spor, lignende artister og lyttestatistikk hentes derfra. Tips: hvis Navidrome-serveren din allerede scrobbler til en tjeneste (f.eks. Maloja), ikke koble til samme tjeneste her også, ellers får du doble scrobbles.', q40: 'Discord Rich Presence?', a40: 'Innstillinger → Integrasjoner → Discord viser gjeldende låt på Discord-profilen din. Velg mellom app-ikonet, serverens cover-art (via getAlbumInfo2 — krever en offentlig nåbar server), eller Apple Music-cover. Tilpass visningsstrenger (detaljer, tilstand, verktøytips) med token-maler.', q41: 'Bandsintown-turnedato?', diff --git a/src/locales/nb/index.ts b/src/locales/nb/index.ts index 6f56bb4c..83dde7b3 100644 --- a/src/locales/nb/index.ts +++ b/src/locales/nb/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const nbTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/nb/musicNetwork.ts b/src/locales/nb/musicNetwork.ts new file mode 100644 index 00000000..2a215abe --- /dev/null +++ b/src/locales/nb/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Send avspillingene dine til én eller flere tjenester. Likte spor, lignende artister og lyttestatistikk hentes fra den valgte hovedtjenesten.', + masterToggle: 'Aktiver scrobbling', + masterToggleDesc: 'Hovedbryter for å sende avspillinger til alle tilkoblede tjenester.', + addService: 'Legg til en tjeneste', + connect: 'Koble til', + connecting: 'Kobler til…', + disconnect: 'Koble fra', + scrobbleHere: 'Scrobble her', + primaryLabel: 'Hovedtjeneste', + primaryDesc: 'Likte spor (❤), lignende artister og lyttestatistikken din kommer herfra — scrobbling går uansett til alle aktiverte tjenester.', + primaryNone: 'Ingen', + statusConnected: 'Tilkoblet', + statusError: 'Må kobles til på nytt', + scrobbles: '{{n}} scrobbles', + memberSince: 'Medlem siden {{year}}', + connectFailed: 'Kunne ikke koble til — prøv igjen.', + connectProbeFailed: '{{provider}} tilkoblet, men kunne ikke valideres: {{message}}', + fieldRequired: '{{field}} er påkrevd.', + malojaProxyWarning: 'Maloja-serveren din kan videresende scrobbles til Last.fm. Slå av scrobbling på én side for å unngå duplikater.', + presets: { + lastfm: { desc: 'Den opprinnelige scrobbletjenesten, med likte spor, lignende artister og statistikk.' }, + librefm: { desc: 'Åpen kildekode-scrobbling på det Last.fm-kompatible Libre.fm.' }, + rocksky: { desc: 'Mål kun for scrobbling på AT-protokollen.' }, + listenbrainz: { desc: 'Åpen lyttehistorikk fra MetaBrainz. Scrobblemål.' }, + malojaNative: { desc: 'Selvhostet scrobbleserver (nativt API).' }, + malojaCompat: { desc: 'Selvhostet Maloja via dens Audioscrobbler (GNU FM)-API.' }, + malojaListenbrainz: { desc: 'Selvhostet Maloja via dets ListenBrainz-kompatible API.' }, + koito: { desc: 'Selvhostet lyttesporing via dens ListenBrainz-kompatible API.' }, + customGnufm: { desc: 'Enhver selvhostet GNU FM-/Last.fm-kompatibel instans.' }, + }, + fields: { + lbToken: 'Brukertoken', + malojaUrl: 'Server-URL', + malojaKey: 'API-nøkkel', + koitoUrl: 'Server-URL', + koitoToken: 'API-nøkkel', + koitoTokenHelp: 'Generer en API-nøkkel i Koito under Innstillinger → API Keys, og lim den inn her.', + rockskySessionKey: 'Øktnøkkel', + rockskySessionKeyHelp: 'Rocksky utsteder denne nøkkelen kun via sitt CLI:\n1. npx -y @rocksky/cli@latest login ditt-handle\n2. cat ~/.rocksky/token.json\n3. Lim inn "token"-verdien her.', + gnufmUrl: 'Server-URL', + apiKey: 'API-nøkkel', + apiSecret: 'Delt hemmelighet', + }, + errors: { + AUTH_SESSION_INVALID: 'Økten er utløpt — koble til på nytt i Innstillinger.', + AUTH_TIMEOUT: 'Autorisasjonen tidsavbrutt — prøv igjen.', + PROBE_FAILED: 'Denne funksjonen er ikke tilgjengelig på denne tjenesten.', + CAPABILITY_UNSUPPORTED: 'Denne tjenesten støtter ikke den funksjonen.', + NETWORK: 'Nettverksfeil — sjekk tilkoblingen eller URL-en.', + MALOJA_BAD_KEY: 'Ugyldig Maloja-API-nøkkel.', + CUSTOM_URL_INVALID: 'Server-URL-en er utilgjengelig.', + }, +}; diff --git a/src/locales/nb/nowPlaying.ts b/src/locales/nb/nowPlaying.ts index c15db027..108aaef6 100644 --- a/src/locales/nb/nowPlaying.ts +++ b/src/locales/nb/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: 'for {{count}} år siden', releasedYearsAgo_other: 'for {{count}} år siden', discography: 'Diskografi', - lastfmStats: 'Last.fm-statistikk', thisTrack: 'Dette sporet', thisArtist: 'Denne artisten', listeners: 'lyttere', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} lyttere', scrobblesN: '{{n}} scrobbles', playsByYouN: 'spilt {{n}}× av deg', - openTrackOnLastfm: 'Spor på Last.fm', - openArtistOnLastfm: 'Artist på Last.fm', rgTrackTooltip: 'ReplayGain (spor)', rgAlbumTooltip: 'ReplayGain (album)', rgAutoTooltip: 'ReplayGain (auto)', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 2bd1c83f..45c41fb8 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -136,18 +136,6 @@ export const settings = { eqAutoEqError: 'Søk mislyktes', eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt', eqAutoEqFetchError: 'Kunne ikke hente EQ-profil', - lfmTitle: 'Last.fm', - lfmConnect: 'Koble til med Last.fm', - lfmConnecting: 'Venter på autorisasjon…', - lfmConfirm: 'Jeg har autorisert appen', - lfmConnected: 'Tilkoblet som', - lfmDisconnect: 'Koble fra', - lfmConnectDesc: 'Koble til din Last.fm-konto for å aktivere scrobbling og få "Nå spiller"-oppdateringer direkte fra Psysonic - ingen Navidrome-konfigurasjon kreves.', - lfmOpenBrowser: 'Et nettleservindu vil åpne seg. Autoriser Psysonic via Last.fm, og klikk deretter på knappen nedenfor.', - lfmScrobbles: '{{n}} scrobbles', - lfmMemberSince: 'Medlem siden {{year}}', - scrobbleEnabled: 'Scrobbling er aktivert', - scrobbleDesc: 'Send sanger til Last.fm etter 50 % avspilling', behavior: 'App-oppførsel', cacheTitle: 'Maks. lagringsstørrelse', cacheDesc: 'Nettlesercache for plateomslag og artistbilder (IndexedDB). Når den er full, fjernes de eldste oppføringene automatisk.', @@ -434,7 +422,7 @@ export const settings = { playerBarReset: 'Tilbakestill til standard', playerBarStarRating: 'Stjernevurdering', playerBarFavorite: 'Favoritt (hjerte)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Lik-knapp', playerBarPlaybackRate: 'Avspillingshastighet', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Miniavspiller', diff --git a/src/locales/nb/statistics.ts b/src/locales/nb/statistics.ts index 8621971a..0e0c29b0 100644 --- a/src/locales/nb/statistics.ts +++ b/src/locales/nb/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} Album', decadeAlbums_other: '{{count}} Album', decadeUnknown: 'Ukjent', - lfmTitle: 'Last.fm Statistikk', + lfmTitle: '{{provider}} Statistikk', lfmTopArtists: 'Toppartister', lfmTopAlbums: 'Toppalbum', lfmTopTracks: 'Toppspor', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 måneder', lfmPeriod6month: '6 måneder', lfmPeriod12month: '12 måneder', - lfmNotConnected: 'Koble til Last.fm i Innstillinger for å se statistikken din.', lfmRecentTracks: 'Siste Scrobble-innslag', lfmNowPlaying: 'Spilles nå', lfmJustNow: 'akkurat nå', diff --git a/src/locales/nl/connection.ts b/src/locales/nl/connection.ts index dd81b50c..c7daaa59 100644 --- a/src/locales/nl/connection.ts +++ b/src/locales/nl/connection.ts @@ -34,6 +34,4 @@ export const connection = { switchServerHint: 'Klik om een andere opgeslagen server te kiezen.', manageServers: 'Servers beheren…', switchFailed: 'Wisselen mislukt — server niet bereikbaar.', - lastfmConnected: 'Last.fm verbonden als @{{user}}', - lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden', }; diff --git a/src/locales/nl/contextMenu.ts b/src/locales/nl/contextMenu.ts index 87b678ee..5a287a0f 100644 --- a/src/locales/nl/contextMenu.ts +++ b/src/locales/nl/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Instant Mix', instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.', cliMixNeedsTrack: 'Er speelt niets — start eerst afspelen en voer het mix-commando opnieuw uit.', - lfmLove: 'Liken op Last.fm', - lfmUnlove: 'Niet meer liken op Last.fm', + networkLove: 'Liken op {{provider}}', + networkUnlove: 'Niet meer liken op {{provider}}', favorite: 'Favoriet', favoriteArtist: 'Favoriete artiest', favoriteAlbum: 'Favoriet album', diff --git a/src/locales/nl/help.ts b/src/locales/nl/help.ts index 6be3367a..fe695e97 100644 --- a/src/locales/nl/help.ts +++ b/src/locales/nl/help.ts @@ -88,8 +88,8 @@ export const help = { q38: 'Device Sync — muziek naar USB / SD?', a38: 'Device Sync (zijbalk) kopieert albums, afspeellijsten of hele artiesten naar een externe schijf voor offline luisteren op andere apparaten. Kies een doelmap, kies bronnen uit de browsertabbladen, klik "Naar apparaat overzetten". Psysonic schrijft een manifest (psysonic-sync.json) zodat het weet welke bestanden er al zijn, ook als u Device Sync op een ander OS opent met een ander bestandsnaamsjabloon. Sjablonen ondersteunen {artist} / {album} / {title} / {track_number} / {disc_number} / {year}-tokens met / als mapscheidingsteken.', s10: 'Integraties & Probleemoplossing', - q39: 'Last.fm scrobbling?', - a39: 'Instellingen → Integraties → Last.fm. Verbind uw account één keer en Psysonic scrobbled direct naar Last.fm — geen Navidrome-configuratie nodig. Een scrobble wordt verzonden nadat u 50% van een nummer heeft beluisterd. Now-playing pings worden bij het begin verzonden.', + q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?', + a39: 'Instellingen → Integraties → Music Network. Verbind een of meer scrobble-services (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja of een GNU FM-compatibele server) — geen Navidrome-configuratie nodig. Elke verbonden service met scrobbling aan ontvangt het afspelen; de hoofdschakelaar "Scrobbelen inschakelen" zet de hele fan-out aan of uit. Een scrobble wordt verzonden nadat je 50% van een nummer hebt beluisterd; now-playing pings gaan bij het begin uit, op services die dit ondersteunen. Eén service is je primaire service — je gelikete nummers, vergelijkbare artiesten en luisterstatistieken komen daarvandaan. Tip: als je Navidrome-server al naar een service scrobbelt (bijv. Maloja), verbind diezelfde service hier dan niet, anders krijg je dubbele scrobbles.', q40: 'Discord Rich Presence?', a40: 'Instellingen → Integraties → Discord toont uw huidige nummer op uw Discord-profiel. Kies tussen het app-pictogram, de hoezen van uw server (via getAlbumInfo2 — vereist een publiek bereikbare server) of Apple Music-hoezen. Pas de weergavestrings (details, status, tooltip) aan met token-templates.', q41: 'Bandsintown tour data?', diff --git a/src/locales/nl/index.ts b/src/locales/nl/index.ts index 72bb3466..d64dd563 100644 --- a/src/locales/nl/index.ts +++ b/src/locales/nl/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const nlTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/nl/musicNetwork.ts b/src/locales/nl/musicNetwork.ts new file mode 100644 index 00000000..5ca4cd12 --- /dev/null +++ b/src/locales/nl/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Stuur je afspeelacties naar een of meer diensten. Vind-ik-leuks, vergelijkbare artiesten en luisterstatistieken komen van de gekozen hoofddienst.', + masterToggle: 'Scrobbelen inschakelen', + masterToggleDesc: 'Hoofdschakelaar om afspeelacties naar alle verbonden diensten te sturen.', + addService: 'Dienst toevoegen', + connect: 'Verbinden', + connecting: 'Verbinden…', + disconnect: 'Verbreken', + scrobbleHere: 'Hier scrobbelen', + primaryLabel: 'Hoofddienst', + primaryDesc: 'Favorieten (❤), vergelijkbare artiesten en je luisterstatistieken komen van hier — scrobbelen gaat los daarvan naar alle ingeschakelde diensten.', + primaryNone: 'Geen', + statusConnected: 'Verbonden', + statusError: 'Opnieuw verbinden nodig', + scrobbles: '{{n}} scrobbles', + memberSince: 'Lid sinds {{year}}', + connectFailed: 'Verbinden mislukt — probeer het opnieuw.', + connectProbeFailed: '{{provider}} verbonden, maar kon niet worden gevalideerd: {{message}}', + fieldRequired: '{{field}} is verplicht.', + malojaProxyWarning: 'Je Maloja-server kan scrobbles doorsturen naar Last.fm. Schakel scrobbelen aan één kant uit om duplicaten te voorkomen.', + presets: { + lastfm: { desc: 'De originele scrobbeldienst, met vind-ik-leuks, vergelijkbare artiesten en statistieken.' }, + librefm: { desc: 'Open-source scrobbelen op het Last.fm-compatibele Libre.fm.' }, + rocksky: { desc: 'Bestemming alleen voor scrobbelen op het AT-protocol.' }, + listenbrainz: { desc: 'Open luistergeschiedenis van MetaBrainz. Scrobbelbestemming.' }, + malojaNative: { desc: 'Zelf-gehoste scrobbelserver (native API).' }, + malojaCompat: { desc: 'Zelf-gehost Maloja via zijn Audioscrobbler (GNU FM)-API.' }, + malojaListenbrainz: { desc: 'Zelf-gehost Maloja via zijn ListenBrainz-compatibele API.' }, + koito: { desc: 'Zelf-gehoste luistertracker via zijn ListenBrainz-compatibele API.' }, + customGnufm: { desc: 'Elke zelf-gehoste GNU FM-/Last.fm-compatibele instantie.' }, + }, + fields: { + lbToken: 'Gebruikerstoken', + malojaUrl: 'Server-URL', + malojaKey: 'API-sleutel', + koitoUrl: 'Server-URL', + koitoToken: 'API-sleutel', + koitoTokenHelp: 'Genereer een API-sleutel in Koito onder Instellingen → API Keys en plak deze hier.', + rockskySessionKey: 'Sessiesleutel', + rockskySessionKeyHelp: 'Rocksky geeft deze sleutel alleen via zijn CLI:\n1. npx -y @rocksky/cli@latest login je-handle\n2. cat ~/.rocksky/token.json\n3. Plak hier de "token"-waarde.', + gnufmUrl: 'Server-URL', + apiKey: 'API-sleutel', + apiSecret: 'Gedeeld geheim', + }, + errors: { + AUTH_SESSION_INVALID: 'Sessie verlopen — opnieuw verbinden in Instellingen.', + AUTH_TIMEOUT: 'Autorisatie verlopen — probeer het opnieuw.', + PROBE_FAILED: 'Deze functie is niet beschikbaar bij deze dienst.', + CAPABILITY_UNSUPPORTED: 'Deze dienst ondersteunt die functie niet.', + NETWORK: 'Netwerkfout — controleer de verbinding of URL.', + MALOJA_BAD_KEY: 'Ongeldige Maloja-API-sleutel.', + CUSTOM_URL_INVALID: 'De server-URL is onbereikbaar.', + }, +}; diff --git a/src/locales/nl/nowPlaying.ts b/src/locales/nl/nowPlaying.ts index 695852b4..5c21944d 100644 --- a/src/locales/nl/nowPlaying.ts +++ b/src/locales/nl/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: '{{count}} jaar geleden', releasedYearsAgo_other: '{{count}} jaar geleden', discography: 'Discografie', - lastfmStats: 'Last.fm-statistieken', thisTrack: 'Dit nummer', thisArtist: 'Deze artiest', listeners: 'luisteraars', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} luisteraars', scrobblesN: '{{n}} scrobbles', playsByYouN: '{{n}}× door jou afgespeeld', - openTrackOnLastfm: 'Nummer op Last.fm', - openArtistOnLastfm: 'Artiest op Last.fm', rgTrackTooltip: 'ReplayGain (track)', rgAlbumTooltip: 'ReplayGain (album)', rgAutoTooltip: 'ReplayGain (auto)', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 36481587..54c0b004 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -136,18 +136,6 @@ export const settings = { eqAutoEqError: 'Zoeken mislukt', eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw', eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen', - lfmTitle: 'Last.fm', - lfmConnect: 'Verbinden met Last.fm', - lfmConnecting: 'Wachten op autorisatie…', - lfmConfirm: 'Ik heb de app geautoriseerd', - lfmConnected: 'Verbonden als', - lfmDisconnect: 'Verbinding verbreken', - lfmConnectDesc: 'Verbind je Last.fm-account om scrobbling en Nu bezig-updates rechtstreeks vanuit Psysonic in te schakelen — geen Navidrome-configuratie vereist.', - lfmOpenBrowser: 'Er opent een browservenster. Autoriseer Psysonic op Last.fm en klik daarna op de knop hieronder.', - lfmScrobbles: '{{n}} scrobbles', - lfmMemberSince: 'Lid sinds {{year}}', - scrobbleEnabled: 'Scrobbling ingeschakeld', - scrobbleDesc: 'Nummers naar Last.fm sturen na 50% afspeeltijd', behavior: 'App-gedrag', cacheTitle: 'Max. opslaggrootte', cacheDesc: 'Browsercache voor albumhoezen en artiestafbeeldingen (IndexedDB). Als de cache vol is, worden de oudste items automatisch verwijderd.', @@ -435,7 +423,7 @@ export const settings = { playerBarReset: 'Standaard herstellen', playerBarStarRating: 'Sterbeoordeling', playerBarFavorite: 'Favoriet (hart)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Like-knop', playerBarPlaybackRate: 'Afspeelsnelheid', playerBarEqualizer: 'Equalizer', playerBarMiniPlayer: 'Mini-speler', diff --git a/src/locales/nl/statistics.ts b/src/locales/nl/statistics.ts index d7a528c0..3a6092f6 100644 --- a/src/locales/nl/statistics.ts +++ b/src/locales/nl/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} album', decadeAlbums_other: '{{count}} albums', decadeUnknown: 'Onbekend', - lfmTitle: 'Last.fm-statistieken', + lfmTitle: '{{provider}}-statistieken', lfmTopArtists: 'Topartiesten', lfmTopAlbums: 'Topalbums', lfmTopTracks: 'Topnummers', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 maanden', lfmPeriod6month: '6 maanden', lfmPeriod12month: '12 maanden', - lfmNotConnected: 'Verbind Last.fm in Instellingen om je statistieken te zien.', lfmRecentTracks: 'Recente scrobbles', lfmNowPlaying: 'Nu bezig', lfmJustNow: 'zojuist', diff --git a/src/locales/ro/connection.ts b/src/locales/ro/connection.ts index c0404691..5e73649f 100644 --- a/src/locales/ro/connection.ts +++ b/src/locales/ro/connection.ts @@ -34,6 +34,4 @@ export const connection = { switchServerHint: 'Apasă pentru a alege alt server salvat.', manageServers: 'Gestionează serverele…', switchFailed: 'Nu s-a putut schimba — nu s-a putut ajunge la server.', - lastfmConnected: 'Last.fm conectat ca @{{user}}', - lastfmSessionInvalid: 'Sesiune invalidă — apasă pentru a te reconecta', }; diff --git a/src/locales/ro/contextMenu.ts b/src/locales/ro/contextMenu.ts index 05a826cb..731f15b0 100644 --- a/src/locales/ro/contextMenu.ts +++ b/src/locales/ro/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: 'Mix Instant', instantMixFailed: 'Nu s-a putut crea Mixul Instant — eroare de server sau plugin.', cliMixNeedsTrack: 'Nimic nu este redat — pornește redarea mai întâi, apoi rulează comanda mix din nou.', - lfmLove: 'Apreciază în Last.fm', - lfmUnlove: 'Elimină aprecierea în Last.fm', + networkLove: 'Apreciază în {{provider}}', + networkUnlove: 'Elimină aprecierea în {{provider}}', favorite: 'Favorit', favoriteArtist: 'Artist Favorit', favoriteAlbum: 'Album Favorit', diff --git a/src/locales/ro/help.ts b/src/locales/ro/help.ts index a3424be1..8cc0373c 100644 --- a/src/locales/ro/help.ts +++ b/src/locales/ro/help.ts @@ -98,8 +98,8 @@ export const help = { a38: 'Sincronizare Dispozitiv (bara laterală) copiază albume, playlisturi sau artiști compleți pe un dispozitiv extern pentru ascultare offline. Alege un folder țintă, sursele din tab-urile browser, apasă "Transfer la Dispozitiv". Psysonic scrie un manifest (psysonic-sync.json) pentru ca să știe ce fișiere sunt deja acolo chiar dacă deschizi Sincronizare Dispozitiv pe un alt sistem de operare cu alt șablon de nume de fișiere. Șabloanele suportă {artist} / {album} / {title} / {track_number} / {disc_number} / {year} jetoane cu / fără separatoare de folder.', // ── Section 10: Integrations & Troubleshooting ───────────────────────── s10: 'Integrări & Depanare', - q39: 'Scrobbling Last.fm?', - a39: 'Setări → Integrări → Last.fm. Conectează-ți contul o singură dată și Psysonic efectuează scrobble direct către Last.fm — nicio configurare Navidrome necesară. Un scrobble este trimis după ce ai ascultat 50 % dintr-o piesă. Pingurile se redă acum sunt trimise la început.', + q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?', + a39: 'Setări → Integrări → Music Network. Conectează unul sau mai multe servicii de scrobbling (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja sau orice server compatibil GNU FM) — nicio configurare Navidrome necesară. Fiecare serviciu conectat cu scrobbling activat primește redarea; comutatorul principal „Activează scrobbling" pornește sau oprește întreaga difuzare. Un scrobble este trimis după ce ai ascultat 50 % dintr-o piesă; pingurile now-playing sunt trimise la început, pe serviciile care le acceptă. Un serviciu este serviciul tău principal — piesele apreciate, artiștii similari și statisticile de ascultare provin de la el. Sfat: dacă serverul tău Navidrome efectuează deja scrobble către un serviciu (de ex. Maloja), nu conecta același serviciu și aici, altfel vei avea scrobble-uri duplicate.', q40: 'Prezență Discord Rich?', a40: 'Setări → Integrări → Discord arată piesa curentă în ascultare pe profilul tău de Discord. Alege între iconița aplicației, arta de copertă a serverului (prin getAlbumInfo2 — necesită un server accesibil public), sau coperți Apple Music. Customizează stringurile de afișare (detalii, stadiu, tooltip) cu jetoane de șablon.', q41: 'Date turneu Bandsintown?', diff --git a/src/locales/ro/index.ts b/src/locales/ro/index.ts index 87d655ba..e338807b 100644 --- a/src/locales/ro/index.ts +++ b/src/locales/ro/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const roTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/ro/musicNetwork.ts b/src/locales/ro/musicNetwork.ts new file mode 100644 index 00000000..89521700 --- /dev/null +++ b/src/locales/ro/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Trimite redările tale către unul sau mai multe servicii. Aprecierile, artiștii similari și statisticile provin de la serviciul principal ales.', + masterToggle: 'Activează scrobbling-ul', + masterToggleDesc: 'Comutator principal pentru trimiterea redărilor către toate serviciile conectate.', + addService: 'Adaugă un serviciu', + connect: 'Conectează', + connecting: 'Se conectează…', + disconnect: 'Deconectează', + scrobbleHere: 'Scrobble aici', + primaryLabel: 'Serviciu principal', + primaryDesc: 'Piesele apreciate (❤), artiștii similari și statisticile tale vin de aici — scrobbling-ul merge oricum la toate serviciile activate.', + primaryNone: 'Niciunul', + statusConnected: 'Conectat', + statusError: 'Necesită reconectare', + scrobbles: '{{n}} scrobble-uri', + memberSince: 'Membru din {{year}}', + connectFailed: 'Conectarea a eșuat — încearcă din nou.', + connectProbeFailed: '{{provider}} conectat, dar nu a putut fi validat: {{message}}', + fieldRequired: '{{field}} este obligatoriu.', + malojaProxyWarning: 'Serverul tău Maloja poate redirecționa scrobble-urile către Last.fm. Dezactivează scrobbling-ul pe o parte pentru a evita duplicatele.', + presets: { + lastfm: { desc: 'Serviciul original de scrobbling, cu aprecieri, artiști similari și statistici.' }, + librefm: { desc: 'Scrobbling open-source pe Libre.fm, compatibil cu Last.fm.' }, + rocksky: { desc: 'Destinație doar pentru scrobbling pe protocolul AT.' }, + listenbrainz: { desc: 'Istoric de ascultare deschis de la MetaBrainz. Destinație de scrobbling.' }, + malojaNative: { desc: 'Server de scrobbling auto-găzduit (API nativ).' }, + malojaCompat: { desc: 'Maloja auto-găzduit prin API-ul său Audioscrobbler (GNU FM).' }, + malojaListenbrainz: { desc: 'Maloja auto-găzduit prin API-ul său compatibil ListenBrainz.' }, + koito: { desc: 'Tracker de ascultări auto-găzduit prin API-ul său compatibil ListenBrainz.' }, + customGnufm: { desc: 'Orice instanță auto-găzduită compatibilă GNU FM / Last.fm.' }, + }, + fields: { + lbToken: 'Token utilizator', + malojaUrl: 'URL server', + malojaKey: 'Cheie API', + koitoUrl: 'URL server', + koitoToken: 'Cheie API', + koitoTokenHelp: 'Generează o cheie API în Koito la Setări → API Keys, apoi lipește-o aici.', + rockskySessionKey: 'Cheie de sesiune', + rockskySessionKeyHelp: 'Rocksky emite această cheie doar prin CLI-ul său:\n1. npx -y @rocksky/cli@latest login handle-ul-tău\n2. cat ~/.rocksky/token.json\n3. Lipește aici valoarea "token".', + gnufmUrl: 'URL server', + apiKey: 'Cheie API', + apiSecret: 'Secret partajat', + }, + errors: { + AUTH_SESSION_INVALID: 'Sesiune expirată — reconectează-te în Setări.', + AUTH_TIMEOUT: 'Autorizarea a expirat — încearcă din nou.', + PROBE_FAILED: 'Această funcție nu este disponibilă pe acest serviciu.', + CAPABILITY_UNSUPPORTED: 'Acest serviciu nu acceptă această funcție.', + NETWORK: 'Eroare de rețea — verifică conexiunea sau URL-ul.', + MALOJA_BAD_KEY: 'Cheie API Maloja invalidă.', + CUSTOM_URL_INVALID: 'URL-ul serverului nu poate fi accesat.', + }, +}; diff --git a/src/locales/ro/nowPlaying.ts b/src/locales/ro/nowPlaying.ts index 802ca41b..da6f47d9 100644 --- a/src/locales/ro/nowPlaying.ts +++ b/src/locales/ro/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: 'acum {{count}} an', releasedYearsAgo_other: 'acum {{count}} ani', discography: 'Discografie', - lastfmStats: 'Statistici Last.fm', thisTrack: 'Această piesă', thisArtist: 'Acest artist', listeners: 'ascultători', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} ascultători', scrobblesN: '{{n}} scrobble-uri', playsByYouN: 'redate {{n}}× de tine', - openTrackOnLastfm: 'Piesă în Last.fm', - openArtistOnLastfm: 'Artist în Last.fm', rgTrackTooltip: 'ReplayGain (piesă)', rgAlbumTooltip: 'ReplayGain (album)', rgAutoTooltip: 'ReplayGain (auto)', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 57c2ebce..8df1cc07 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -136,18 +136,6 @@ export const settings = { eqAutoEqError: 'Căutarea a eșuat', eqAutoEqRateLimit: 'Limita ratei GitHub a fost atinsă — încearcă din nou într-un minut', eqAutoEqFetchError: 'Nu s-a putut lua profilul EQ', - lfmTitle: 'Last.fm', - lfmConnect: 'Conectează-te cu Last.fm', - lfmConnecting: 'Se așteaptă autorizarea…', - lfmConfirm: 'Am autorizat aplicația', - lfmConnected: 'Conectat ca', - lfmDisconnect: 'Deconectează-te', - lfmConnectDesc: 'Conectează contul tău Last.fm pentru a pornit scrobbling și update-uri Now Playing direct din Psysonic — nicio nevoie configurare Navidrome necesară.', - lfmOpenBrowser: 'O fereastră browser se va deschide. Autorizează Psysonic în Last.fm, apoi apasă pe butonul de mai jos.', - lfmScrobbles: '{{n}} scrobble-uri', - lfmMemberSince: 'Membru din {{year}}', - scrobbleEnabled: 'Scrobbling dezactivat', - scrobbleDesc: 'Trimite piese la Last.fm după 50% timp de redare', behavior: 'Comportamentul Aplicației', cacheTitle: 'Spațiu de stocare maxim', cacheDesc: 'Cache în browser pentru coperte și imagini de artiști (IndexedDB). Când este plin, cele mai vechi intrări sunt șterse automat.', @@ -440,7 +428,7 @@ export const settings = { playerBarReset: 'Resetare la implicit', playerBarStarRating: 'Evaluare cu stele', playerBarFavorite: 'Favorit (inimă)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Buton de apreciere', playerBarPlaybackRate: 'Viteză redare', playerBarEqualizer: 'Egalizator', playerBarMiniPlayer: 'Mini player', diff --git a/src/locales/ro/statistics.ts b/src/locales/ro/statistics.ts index 49eacf5d..81f2ad26 100644 --- a/src/locales/ro/statistics.ts +++ b/src/locales/ro/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} Album', decadeAlbums_other: '{{count}} Albume', decadeUnknown: 'Necunoscut', - lfmTitle: 'Statistici Last.fm', + lfmTitle: 'Statistici {{provider}}', lfmTopArtists: 'Artiști de top', lfmTopAlbums: 'Albume de top', lfmTopTracks: 'Piese de top', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 Luni', lfmPeriod6month: '6 Luni', lfmPeriod12month: '12 Luni', - lfmNotConnected: 'Conectează Last.fm în Setări pentru a îți vedea statisticile.', lfmRecentTracks: 'Scrobble-uri Recente', lfmNowPlaying: 'Now Playing', lfmJustNow: 'tocmai acum', diff --git a/src/locales/ru/connection.ts b/src/locales/ru/connection.ts index 6b3007b2..592e6a76 100644 --- a/src/locales/ru/connection.ts +++ b/src/locales/ru/connection.ts @@ -38,6 +38,4 @@ export const connection = { switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.', manageServers: 'Управление серверами…', switchFailed: 'Не удалось переключиться — сервер недоступен.', - lastfmConnected: 'Last.fm: @{{user}}', - lastfmSessionInvalid: 'Сессия недействительна — подключите снова', }; diff --git a/src/locales/ru/contextMenu.ts b/src/locales/ru/contextMenu.ts index 9da16aa2..8a587194 100644 --- a/src/locales/ru/contextMenu.ts +++ b/src/locales/ru/contextMenu.ts @@ -11,8 +11,8 @@ export const contextMenu = { instantMix: 'Instant Mix', instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.', cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.', - lfmLove: 'Любимое на Last.fm', - lfmUnlove: 'Убрать с Last.fm', + networkLove: 'Любимое на {{provider}}', + networkUnlove: 'Убрать с {{provider}}', favorite: 'В избранное', favoriteArtist: 'Исполнитель в избранное', favoriteAlbum: 'Альбом в избранное', diff --git a/src/locales/ru/help.ts b/src/locales/ru/help.ts index eb6fc58c..ae1a18c1 100644 --- a/src/locales/ru/help.ts +++ b/src/locales/ru/help.ts @@ -88,8 +88,8 @@ export const help = { q38: 'Device Sync — копирование музыки на USB / SD?', a38: 'Device Sync (боковая панель) копирует альбомы, плейлисты или целых исполнителей на внешний диск для офлайн-прослушивания на других устройствах. Выберите целевую папку, выберите источники из вкладок браузера, нажмите «Перенести на устройство». Psysonic пишет манифест (psysonic-sync.json), чтобы знать, какие файлы уже там, даже если переоткрыть Device Sync на другой ОС с другим шаблоном имени файла. Шаблоны поддерживают токены {artist} / {album} / {title} / {track_number} / {disc_number} / {year} с / как разделителем папок.', s10: 'Интеграции и Устранение неполадок', - q39: 'Скробблинг Last.fm?', - a39: 'Настройки → Интеграции → Last.fm. Подключите аккаунт один раз, и Psysonic скробблит напрямую в Last.fm — настройка Navidrome не нужна. Скроббл отправляется после прослушивания 50 % трека. Now-playing пинги отправляются в начале.', + q39: 'Скробблинг — Last.fm, ListenBrainz, Maloja…?', + a39: 'Настройки → Интеграции → Music Network. Подключите один или несколько сервисов скробблинга (Last.fm, Libre.fm, Rocksky, ListenBrainz, Maloja или любой GNU FM-совместимый сервер) — настройка Navidrome не нужна. Каждый подключённый сервис с включённым скробблингом получает воспроизведение; главный переключатель «Включить скробблинг» включает или выключает всю рассылку. Скроббл отправляется после прослушивания 50 % трека; now-playing пинги уходят в начале, на сервисах, которые их поддерживают. Один сервис — основной: любимые треки, похожие исполнители и статистика прослушиваний берутся из него. Совет: если ваш сервер Navidrome уже скробблит в сервис (например, Maloja), не подключайте тот же сервис здесь, иначе появятся дублирующиеся скробблы.', q40: 'Discord Rich Presence?', a40: 'Настройки → Интеграции → Discord показывает текущий трек в вашем профиле Discord. Выберите между иконкой приложения, обложками с вашего сервера (через getAlbumInfo2 — нужен публично доступный сервер) или обложками Apple Music. Кастомизируйте отображаемые строки (детали, состояние, тултип) с помощью токен-шаблонов.', q41: 'Даты туров Bandsintown?', diff --git a/src/locales/ru/index.ts b/src/locales/ru/index.ts index b05de2bf..f992c658 100644 --- a/src/locales/ru/index.ts +++ b/src/locales/ru/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const ruTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/ru/musicNetwork.ts b/src/locales/ru/musicNetwork.ts new file mode 100644 index 00000000..d8a99e8b --- /dev/null +++ b/src/locales/ru/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: 'Отправляйте прослушивания в один или несколько сервисов. Лайки, похожие исполнители и статистика берутся из выбранного основного сервиса.', + masterToggle: 'Включить скробблинг', + masterToggleDesc: 'Главный переключатель отправки прослушиваний во все подключённые сервисы.', + addService: 'Добавить сервис', + connect: 'Подключить', + connecting: 'Подключение…', + disconnect: 'Отключить', + scrobbleHere: 'Скробблить сюда', + primaryLabel: 'Основной сервис', + primaryDesc: 'Любимые треки (❤), похожие исполнители и статистика берутся отсюда — скробблинг при этом идёт во все включённые сервисы.', + primaryNone: 'Нет', + statusConnected: 'Подключено', + statusError: 'Нужно переподключение', + scrobbles: '{{n}} скробблов', + memberSince: 'Участник с {{year}}', + connectFailed: 'Не удалось подключиться — попробуйте ещё раз.', + connectProbeFailed: '{{provider}} подключён, но не удалось проверить: {{message}}', + fieldRequired: '{{field}} обязательно.', + malojaProxyWarning: 'Ваш сервер Maloja может пересылать скроббл в Last.fm. Отключите скробблинг на одной стороне, чтобы избежать дубликатов.', + presets: { + lastfm: { desc: 'Оригинальный сервис скробблинга с лайками, похожими исполнителями и статистикой.' }, + librefm: { desc: 'Скробблинг с открытым кодом на Libre.fm, совместимом с Last.fm.' }, + rocksky: { desc: 'Назначение только для скробблинга на протоколе AT.' }, + listenbrainz: { desc: 'Открытая история прослушивания от MetaBrainz. Назначение скробблинга.' }, + malojaNative: { desc: 'Самостоятельно размещённый сервер скробблинга (нативный API).' }, + malojaCompat: { desc: 'Самостоятельно размещённый Maloja через его Audioscrobbler-(GNU FM)-API.' }, + malojaListenbrainz: { desc: 'Самостоятельно размещённый Maloja через ListenBrainz-совместимый API.' }, + koito: { desc: 'Самостоятельно размещённый трекер прослушиваний через его ListenBrainz-совместимый API.' }, + customGnufm: { desc: 'Любой самостоятельно размещённый экземпляр, совместимый с GNU FM / Last.fm.' }, + }, + fields: { + lbToken: 'Токен пользователя', + malojaUrl: 'URL сервера', + malojaKey: 'API-ключ', + koitoUrl: 'URL сервера', + koitoToken: 'API-ключ', + koitoTokenHelp: 'Создайте API-ключ в Koito в разделе «Настройки → API Keys» и вставьте его сюда.', + rockskySessionKey: 'Ключ сессии', + rockskySessionKeyHelp: 'Rocksky выдаёт этот ключ только через свой CLI:\n1. npx -y @rocksky/cli@latest login ваш-handle\n2. cat ~/.rocksky/token.json\n3. Вставьте сюда значение "token".', + gnufmUrl: 'URL сервера', + apiKey: 'API-ключ', + apiSecret: 'Общий секрет', + }, + errors: { + AUTH_SESSION_INVALID: 'Сессия истекла — переподключитесь в Настройках.', + AUTH_TIMEOUT: 'Время авторизации истекло — попробуйте ещё раз.', + PROBE_FAILED: 'Эта функция недоступна в этом сервисе.', + CAPABILITY_UNSUPPORTED: 'Этот сервис не поддерживает эту функцию.', + NETWORK: 'Ошибка сети — проверьте подключение или URL.', + MALOJA_BAD_KEY: 'Неверный API-ключ Maloja.', + CUSTOM_URL_INVALID: 'URL сервера недоступен.', + }, +}; diff --git a/src/locales/ru/nowPlaying.ts b/src/locales/ru/nowPlaying.ts index 50360155..84a8069c 100644 --- a/src/locales/ru/nowPlaying.ts +++ b/src/locales/ru/nowPlaying.ts @@ -25,7 +25,6 @@ export const nowPlaying = { releasedYearsAgo_many: '{{count}} лет назад', releasedYearsAgo_other: '{{count}} лет назад', discography: 'Дискография', - lastfmStats: 'Статистика Last.fm', thisTrack: 'Этот трек', thisArtist: 'Этот исполнитель', listeners: 'слушателей', @@ -34,8 +33,6 @@ export const nowPlaying = { listenersN: '{{n}} слушателей', scrobblesN: '{{n}} прослушиваний', playsByYouN: 'прослушано вами {{n}}×', - openTrackOnLastfm: 'Трек на Last.fm', - openArtistOnLastfm: 'Исполнитель на Last.fm', rgTrackTooltip: 'ReplayGain (трек)', rgAlbumTooltip: 'ReplayGain (альбом)', rgAutoTooltip: 'ReplayGain (авто)', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 4762911f..a4b19e99 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -136,19 +136,6 @@ export const settings = { eqAutoEqError: 'Ошибка поиска', eqAutoEqRateLimit: 'Лимит GitHub — попробуйте через минуту', eqAutoEqFetchError: 'Не удалось загрузить профиль EQ', - lfmTitle: 'Last.fm', - lfmConnect: 'Подключить Last.fm', - lfmConnecting: 'Ожидание авторизации…', - lfmConfirm: 'Я разрешил доступ', - lfmConnected: 'Аккаунт', - lfmDisconnect: 'Отключить', - lfmConnectDesc: - 'Привяжите Last.fm для скробблинга и статуса «Сейчас слушаю» прямо из Psysonic — настраивать Navidrome не нужно.', - lfmOpenBrowser: 'Откроется браузер. Разрешите доступ на Last.fm, затем нажмите кнопку ниже.', - lfmScrobbles: 'Скробблов: {{n}}', - lfmMemberSince: 'С {{year}} года', - scrobbleEnabled: 'Скробблинг включён', - scrobbleDesc: 'Отправлять прослушивания на Last.fm после 50% трека', behavior: 'Поведение', cacheTitle: 'Макс. размер кэша', cacheDesc: @@ -496,7 +483,7 @@ export const settings = { playerBarReset: 'Сбросить', playerBarStarRating: 'Оценка звёздами', playerBarFavorite: 'Избранное (сердечко)', - playerBarLastfmLove: 'Last.fm love', + playerBarLastfmLove: 'Кнопка «Нравится»', playerBarPlaybackRate: 'Скорость', playerBarEqualizer: 'Эквалайзер', playerBarMiniPlayer: 'Мини-плеер', diff --git a/src/locales/ru/statistics.ts b/src/locales/ru/statistics.ts index 7671a574..266350d2 100644 --- a/src/locales/ru/statistics.ts +++ b/src/locales/ru/statistics.ts @@ -30,7 +30,7 @@ export const statistics = { decadeAlbums_many: '{{count}} альбомов', decadeAlbums_other: '{{count}} альбомов', decadeUnknown: 'Неизвестно', - lfmTitle: 'Статистика Last.fm', + lfmTitle: 'Статистика {{provider}}', lfmTopArtists: 'Топ исполнителей', lfmTopAlbums: 'Топ альбомов', lfmTopTracks: 'Топ треков', @@ -44,7 +44,6 @@ export const statistics = { lfmPeriod3month: '3 месяца', lfmPeriod6month: '6 месяцев', lfmPeriod12month: 'Год', - lfmNotConnected: 'Подключите Last.fm в настройках.', lfmRecentTracks: 'Последние скробблы', lfmNowPlaying: 'Сейчас играет', lfmJustNow: 'только что', diff --git a/src/locales/zh/connection.ts b/src/locales/zh/connection.ts index eda4cc7f..6cf9f7c5 100644 --- a/src/locales/zh/connection.ts +++ b/src/locales/zh/connection.ts @@ -30,6 +30,4 @@ export const connection = { switchServerHint: '点击选择其他已保存的服务器。', manageServers: '管理服务器…', switchFailed: '无法切换 — 无法连接服务器。', - lastfmConnected: 'Last.fm 已连接为 @{{user}}', - lastfmSessionInvalid: '会话无效 — 点击重新连接', }; diff --git a/src/locales/zh/contextMenu.ts b/src/locales/zh/contextMenu.ts index 63986f83..d9b34c8d 100644 --- a/src/locales/zh/contextMenu.ts +++ b/src/locales/zh/contextMenu.ts @@ -9,8 +9,8 @@ export const contextMenu = { instantMix: '即时混音', instantMixFailed: '无法生成即时混音 — 服务器或插件出错。', cliMixNeedsTrack: '当前没有正在播放的内容 — 请先开始播放,然后再执行混音命令。', - lfmLove: '在 Last.fm 上标记喜欢', - lfmUnlove: '取消 Last.fm 喜欢标记', + networkLove: '在 {{provider}} 上标记喜欢', + networkUnlove: '取消 {{provider}} 喜欢标记', favorite: '收藏', favoriteArtist: '收藏艺术家', favoriteAlbum: '收藏专辑', diff --git a/src/locales/zh/help.ts b/src/locales/zh/help.ts index f8391080..5dd53312 100644 --- a/src/locales/zh/help.ts +++ b/src/locales/zh/help.ts @@ -88,8 +88,8 @@ export const help = { q38: 'Device Sync — 将音乐复制到 USB / SD?', a38: 'Device Sync(侧边栏)将专辑、播放列表或整个艺术家复制到外部驱动器,以便在其他设备上离线收听。选择目标文件夹,从浏览器选项卡中选择源,点击"传输到设备"。Psysonic 写入清单(psysonic-sync.json),即使您在不同 OS 上以不同的文件名模板重新打开 Device Sync,也能知道哪些文件已经在那里。模板支持 {artist} / {album} / {title} / {track_number} / {disc_number} / {year} 令牌,使用 / 作为文件夹分隔符。', s10: '集成与故障排除', - q39: 'Last.fm 抓取?', - a39: '设置 → 集成 → Last.fm。一次连接您的账户,Psysonic 直接抓取到 Last.fm — 无需 Navidrome 配置。在听完曲目 50 % 后发送抓取。Now-playing pings 在开始时发送。', + q39: '抓取(scrobble)— Last.fm、ListenBrainz、Maloja…?', + a39: '设置 → 集成 → Music Network。连接一个或多个 scrobble 服务(Last.fm、Libre.fm、Rocksky、ListenBrainz、Maloja 或任何兼容 GNU FM 的服务器)— 无需 Navidrome 配置。每个启用了 scrobble 的已连接服务都会收到播放记录;主开关“启用 scrobble”可统一开启或关闭整个分发。听完曲目 50 % 后发送一次 scrobble;在支持的服务上,now-playing 状态在开始时发送。其中一个服务是您的主服务 — 喜欢的曲目、相似艺术家和收听统计都来自它。提示:如果您的 Navidrome 服务器已经向某个服务(如 Maloja)scrobble,请不要在此再连接同一服务,否则会产生重复的 scrobble。', q40: 'Discord Rich Presence?', a40: '设置 → 集成 → Discord 在您的 Discord 个人资料上显示当前曲目。在应用图标、您服务器的封面(通过 getAlbumInfo2 — 需要可公开访问的服务器)或 Apple Music 封面之间选择。使用令牌模板自定义显示字符串(详细信息、状态、提示)。', q41: 'Bandsintown 巡演日期?', diff --git a/src/locales/zh/index.ts b/src/locales/zh/index.ts index 1b62e2d7..26d8a4b1 100644 --- a/src/locales/zh/index.ts +++ b/src/locales/zh/index.ts @@ -25,6 +25,7 @@ import { common } from './common'; import { settings } from './settings'; import { changelog } from './changelog'; import { whatsNew } from './whatsNew'; +import { musicNetwork } from './musicNetwork'; import { help } from './help'; import { queue } from './queue'; import { miniPlayer } from './miniPlayer'; @@ -72,6 +73,7 @@ export const zhTranslation = { settings, changelog, whatsNew, + musicNetwork, help, queue, miniPlayer, diff --git a/src/locales/zh/musicNetwork.ts b/src/locales/zh/musicNetwork.ts new file mode 100644 index 00000000..c10cd03b --- /dev/null +++ b/src/locales/zh/musicNetwork.ts @@ -0,0 +1,55 @@ +export const musicNetwork = { + title: 'Music Network', + desc: '将你的播放记录发送到一个或多个服务。喜爱、相似艺人和收听统计来自你选择的主服务。', + masterToggle: '启用 scrobble', + masterToggleDesc: '向所有已连接服务发送播放记录的总开关。', + addService: '添加服务', + connect: '连接', + connecting: '连接中…', + disconnect: '断开连接', + scrobbleHere: '在此 scrobble', + primaryLabel: '主服务', + primaryDesc: '喜爱的曲目(❤)、相似艺人和你的收听统计来自这里 — scrobble 会独立发送到所有已启用的服务。', + primaryNone: '无', + statusConnected: '已连接', + statusError: '需要重新连接', + scrobbles: '{{n}} 次 scrobble', + memberSince: '注册于 {{year}} 年', + connectFailed: '无法连接 — 请重试。', + connectProbeFailed: '已连接 {{provider}},但无法验证:{{message}}', + fieldRequired: '{{field}} 为必填项。', + malojaProxyWarning: '你的 Maloja 服务器可能会将 scrobble 转发到 Last.fm。请在一侧关闭 scrobble 以避免重复。', + presets: { + lastfm: { desc: '最初的 scrobble 服务,提供喜爱、相似艺人和统计。' }, + librefm: { desc: '在兼容 Last.fm 的 Libre.fm 上的开源 scrobble。' }, + rocksky: { desc: 'AT 协议上的仅 scrobble 目标。' }, + listenbrainz: { desc: 'MetaBrainz 提供的开放收听历史。scrobble 目标。' }, + malojaNative: { desc: '自托管的 scrobble 服务器(原生 API)。' }, + malojaCompat: { desc: '通过其 Audioscrobbler(GNU FM)API 连接的自托管 Maloja。' }, + malojaListenbrainz: { desc: '通过其 ListenBrainz 兼容 API 的自托管 Maloja。' }, + koito: { desc: '通过其 ListenBrainz 兼容 API 连接的自托管收听记录器。' }, + customGnufm: { desc: '任何自托管的 GNU FM / Last.fm 兼容实例。' }, + }, + fields: { + lbToken: '用户令牌', + malojaUrl: '服务器 URL', + malojaKey: 'API 密钥', + koitoUrl: '服务器 URL', + koitoToken: 'API 密钥', + koitoTokenHelp: '在 Koito 的 设置 → API Keys 中生成一个 API 密钥,然后粘贴到此处。', + rockskySessionKey: '会话密钥', + rockskySessionKeyHelp: 'Rocksky 仅通过其 CLI 提供此密钥:\n1. npx -y @rocksky/cli@latest login 你的-handle\n2. cat ~/.rocksky/token.json\n3. 将"token"值粘贴到此处。', + gnufmUrl: '服务器 URL', + apiKey: 'API 密钥', + apiSecret: '共享密钥', + }, + errors: { + AUTH_SESSION_INVALID: '会话已过期 — 请在设置中重新连接。', + AUTH_TIMEOUT: '授权超时 — 请重试。', + PROBE_FAILED: '此服务不支持该功能。', + CAPABILITY_UNSUPPORTED: '此服务不支持该功能。', + NETWORK: '网络错误 — 请检查连接或 URL。', + MALOJA_BAD_KEY: 'Maloja API 密钥无效。', + CUSTOM_URL_INVALID: '无法访问服务器 URL。', + }, +}; diff --git a/src/locales/zh/nowPlaying.ts b/src/locales/zh/nowPlaying.ts index 4f4bee44..1b974f29 100644 --- a/src/locales/zh/nowPlaying.ts +++ b/src/locales/zh/nowPlaying.ts @@ -21,7 +21,6 @@ export const nowPlaying = { releasedYearsAgo_one: '{{count}} 年前', releasedYearsAgo_other: '{{count}} 年前', discography: '专辑列表', - lastfmStats: 'Last.fm 统计', thisTrack: '这首歌', thisArtist: '这位艺术家', listeners: '听众', @@ -30,8 +29,6 @@ export const nowPlaying = { listenersN: '{{n}} 位听众', scrobblesN: '{{n}} 次播放', playsByYouN: '你播放了 {{n}} 次', - openTrackOnLastfm: '在 Last.fm 上查看', - openArtistOnLastfm: '在 Last.fm 上查看艺术家', rgTrackTooltip: 'ReplayGain (曲目)', rgAlbumTooltip: 'ReplayGain (专辑)', rgAutoTooltip: 'ReplayGain (自动)', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 5b0150d0..4099ceac 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -136,18 +136,6 @@ export const settings = { eqAutoEqError: '搜索失败', eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试', eqAutoEqFetchError: '无法获取 EQ 配置', - lfmTitle: 'Last.fm', - lfmConnect: '连接 Last.fm', - lfmConnecting: '等待授权…', - lfmConfirm: '我已完成授权', - lfmConnected: '已连接为', - lfmDisconnect: '断开连接', - lfmConnectDesc: '连接您的 Last.fm 账户以启用歌曲记录和正在播放更新,直接从 Psysonic 发送 — 无需配置 Navidrome。', - lfmOpenBrowser: '将打开浏览器窗口。在 Last.fm 上授权 Psysonic,然后点击下方按钮。', - lfmScrobbles: '{{n}} 次记录', - lfmMemberSince: '{{year}} 年加入', - scrobbleEnabled: '启用歌曲记录', - scrobbleDesc: '播放 50% 后发送歌曲到 Last.fm', behavior: '应用行为', cacheTitle: '最大存储大小', cacheDesc: '浏览器内封面和艺术家图片缓存(IndexedDB)。存满时,最旧的条目将自动移除。', @@ -434,7 +422,7 @@ export const settings = { playerBarReset: '重置为默认', playerBarStarRating: '星级评分', playerBarFavorite: '收藏(心形)', - playerBarLastfmLove: 'Last.fm 喜欢', + playerBarLastfmLove: '喜欢按钮', playerBarPlaybackRate: '播放速度', playerBarEqualizer: '均衡器', playerBarMiniPlayer: '迷你播放器', diff --git a/src/locales/zh/statistics.ts b/src/locales/zh/statistics.ts index 74665359..8fa3b873 100644 --- a/src/locales/zh/statistics.ts +++ b/src/locales/zh/statistics.ts @@ -22,7 +22,7 @@ export const statistics = { decadeAlbums_one: '{{count}} 张专辑', decadeAlbums_other: '{{count}} 张专辑', decadeUnknown: '未知', - lfmTitle: 'Last.fm 统计', + lfmTitle: '{{provider}} 统计', lfmTopArtists: '热门艺术家', lfmTopAlbums: '热门专辑', lfmTopTracks: '热门曲目', @@ -33,7 +33,6 @@ export const statistics = { lfmPeriod3month: '3 个月', lfmPeriod6month: '6 个月', lfmPeriod12month: '12 个月', - lfmNotConnected: '在设置中连接 Last.fm 以查看您的统计。', lfmRecentTracks: '最近记录', lfmNowPlaying: '正在播放', lfmJustNow: '刚刚', diff --git a/src/music-network/contracts/AuthStrategy.ts b/src/music-network/contracts/AuthStrategy.ts new file mode 100644 index 00000000..b46f82b1 --- /dev/null +++ b/src/music-network/contracts/AuthStrategy.ts @@ -0,0 +1,16 @@ +// Music Network — AuthStrategy contract. +// +// Connect flows vary by provider family: +// - token_poll : open browser, poll for a session key (Last.fm / GNU FM) +// - callback : open browser, receive a callback token (some Libre.fm setups) +// - api_key_only : no browser; user pastes a token/key (ListenBrainz, Maloja) +// +// A wire delegates its connect() to one of these so the flow logic is shared and +// not duplicated per preset. + +import type { ConnectContext, ConnectResult } from './ScrobbleWire'; + +export interface AuthStrategy { + readonly id: 'token_poll' | 'callback' | 'api_key_only'; + connect(ctx: ConnectContext): Promise; +} diff --git a/src/music-network/contracts/EnrichmentWire.ts b/src/music-network/contracts/EnrichmentWire.ts new file mode 100644 index 00000000..4426ca02 --- /dev/null +++ b/src/music-network/contracts/EnrichmentWire.ts @@ -0,0 +1,47 @@ +// Music Network — EnrichmentWire contract. +// +// Extends ScrobbleWire with the read features that today are Last.fm-only +// (love, similar, stats, top lists, recent tracks, profile links). Only +// Audioscrobbler-class wires implement this; the EnrichmentRouter casts an +// account to EnrichmentWire only after the wire declares supportsEnrichment AND +// the probe confirmed the matching capability. + +import type { + ArtistStats, + RecentTrack, + StatsPeriod, + TopItem, + TopKind, + TrackRef, + TrackStats, + UserProfile, +} from '../core/types'; +import type { ScrobbleWire, WireContext } from './ScrobbleWire'; + +export interface EnrichmentWire extends ScrobbleWire { + readonly supportsEnrichment: true; + + getTrackLoved(ctx: WireContext, ref: TrackRef): Promise; + loveTrack(ctx: WireContext, ref: TrackRef, loved: boolean): Promise; + getAllLovedTracks(ctx: WireContext): Promise; + getSimilarArtists(ctx: WireContext, name: string): Promise; + getTrackStats(ctx: WireContext, ref: TrackRef): Promise; + getArtistStats(ctx: WireContext, name: string): Promise; + getUserProfile(ctx: WireContext): Promise; + getTopItems( + ctx: WireContext, + period: StatsPeriod, + kind: TopKind, + limit: number, + ): Promise; + getRecentTracks(ctx: WireContext, limit: number): Promise; + + buildProfileUrl(ctx: WireContext): string; + buildArtistUrl(ctx: WireContext, name: string): string; + buildTrackUrl(ctx: WireContext, ref: TrackRef): string; +} + +/** Type guard: does this wire implement the enrichment surface? */ +export function isEnrichmentWire(wire: ScrobbleWire): wire is EnrichmentWire { + return wire.supportsEnrichment === true; +} diff --git a/src/music-network/contracts/PresetManifest.ts b/src/music-network/contracts/PresetManifest.ts new file mode 100644 index 00000000..5cce64d9 --- /dev/null +++ b/src/music-network/contracts/PresetManifest.ts @@ -0,0 +1,76 @@ +// Music Network — PresetManifest contract. +// +// A preset is a declarative, data-only description of a built-in provider: +// endpoints, bundled-vs-user credentials, default roles, static capabilities, +// auth strategy, and the UI fields/warnings the Integrations section renders. +// +// Adding a provider = a new manifest file + a registry entry. No edits to the +// orchestrator, playback hooks, or the Integrations shell. + +import type { CapabilityId } from '../core/capabilities'; +import type { PresetId, WireId } from '../core/types'; + +export type PresetIcon = 'lastfm' | 'librefm' | 'rocksky' | 'maloja' | 'listenbrainz' | 'koito' | 'custom'; + +export type PresetCategory = 'public_audioscrobbler' | 'public_listenbrainz' | 'self_hosted' | 'custom'; + +/** Where credentials come from. */ +export type CredentialMode = + | 'bundled' // app-registered api key+secret, no user input + | 'user_api_key' // user supplies a key/token (e.g. Maloja key, LB token) + | 'user_full'; // user supplies url + key + secret (custom GNU FM) + +export type AuthStrategyId = 'token_poll' | 'callback' | 'api_key_only'; + +/** A connect-form field rendered by the Integrations sub-UI. */ +export interface PresetField { + /** Key written into ConnectContext.fields / account.customFields. */ + name: 'baseUrl' | 'apiKey' | 'apiSecret' | 'token' | (string & {}); + /** i18n key for the label. */ + labelKey: string; + /** Optional i18n key for a help/instructions hint shown below the field. */ + helpKey?: string; + type: 'text' | 'password' | 'url' | 'select'; + required: boolean; + placeholder?: string; + /** For `type: 'select'`. */ + options?: Array<{ value: string; labelKey: string }>; +} + +export type PresetWarningId = 'maloja_lastfm_proxy'; + +export interface PresetManifest { + presetId: PresetId; + wireId: WireId; + displayName: string; + descriptionKey: string; + icon: PresetIcon; + category: PresetCategory; + + endpoints?: { + /** Trailing slash required for the Libre.fm / GNU FM family. */ + apiBase?: string; + authBase?: string; + profileBase?: string; + }; + + /** + * For self-hosted presets: path appended to the user-supplied origin to form + * the API base (e.g. '/apis/listenbrainz', '/apis/audioscrobbler'). The wire + * then appends its own method path. Absent for fixed-host presets. + */ + selfHostedApiSuffix?: string; + + credentials: CredentialMode; + + defaultRoles: { + scrobble: boolean; + enrichmentEligible: boolean; + }; + + staticCapabilities: Partial>; + authStrategy: AuthStrategyId; + + fields: PresetField[]; + warnings?: PresetWarningId[]; +} diff --git a/src/music-network/contracts/ScrobbleWire.ts b/src/music-network/contracts/ScrobbleWire.ts new file mode 100644 index 00000000..22a47ec2 --- /dev/null +++ b/src/music-network/contracts/ScrobbleWire.ts @@ -0,0 +1,76 @@ +// Music Network — ScrobbleWire contract. +// +// A wire is a transport + protocol adapter. Every provider that can receive +// plays implements ScrobbleWire. Enrichment-capable wires additionally implement +// EnrichmentWire. The registry maps an account to its wire; the orchestrator and +// enrichment router only ever talk to these interfaces — never to a concrete +// provider. + +import type { CapabilitySet } from '../core/capabilities'; +import type { PersistedAccount } from '../core/accounts'; +import type { ScrobbleEvent, WireId } from '../core/types'; + +/** + * Per-call context resolved from a connected account. Wires read endpoints and + * credentials from here; they never reach into the auth store directly. + */ +export interface WireContext { + account: PersistedAccount; + /** Resolved API base URL (preset endpoint or user-supplied origin). */ + baseUrl: string; + /** Resolved profile base URL, for synchronous URL builders. '' when none. */ + profileBase: string; + apiKey: string; + apiSecret: string; + sessionKey: string; + username: string; + /** + * The preset's connect strategy. Lets a wire validate a pasted credential + * (`api_key_only`) at probe time, vs a session already validated by the browser + * flow (`token_poll`). Optional so hand-built contexts (tests) may omit it. + */ + authStrategy?: 'token_poll' | 'callback' | 'api_key_only'; +} + +/** Context for an initial connect attempt (before a session exists). */ +export interface ConnectContext { + presetId: PersistedAccount['presetId']; + wireId: WireId; + /** Which connect flow the preset declares — lets one wire serve multiple. */ + authStrategy: 'token_poll' | 'callback' | 'api_key_only'; + /** Resolved API base URL. */ + baseUrl: string; + /** Resolved browser-auth base URL (token-poll/callback flows). '' when none. */ + authBase: string; + apiKey: string; + apiSecret: string; + /** User-pasted token (ListenBrainz) or extra fields, by field name. */ + fields: Record; + /** Opens an external URL (Tauri shell). Used by token-poll/callback flows. */ + openExternal: (url: string) => Promise; + /** Resolves when the user cancels the connect dialog. */ + signal?: AbortSignal; +} + +/** Result of a successful connect. Persisted into the account record. */ +export interface ConnectResult { + sessionKey: string; + username: string; + /** Optional resolved base URL override (e.g. normalized trailing slash). */ + baseUrl?: string; + /** Optional initial capabilities if the connect flow already probed. */ + capabilities?: CapabilitySet; +} + +export interface ScrobbleWire { + readonly wireId: WireId; + readonly supportsEnrichment: boolean; + + connect(ctx: ConnectContext): Promise; + disconnect(ctx: WireContext): void; + + scrobble(ctx: WireContext, event: ScrobbleEvent): Promise; + updateNowPlaying(ctx: WireContext, event: ScrobbleEvent): Promise; + + probe(ctx: WireContext): Promise; +} diff --git a/src/music-network/core/accounts.ts b/src/music-network/core/accounts.ts new file mode 100644 index 00000000..ad293c2d --- /dev/null +++ b/src/music-network/core/accounts.ts @@ -0,0 +1,68 @@ +// Music Network — account model. +// +// An Account is a user-connected instance of a preset. The persisted shape lives +// in the auth store (see runtime/accountPersistence.ts for migration). Roles +// decide fan-out (scrobble) and enrichment eligibility. + +import type { CapabilitySet } from './capabilities'; +import type { PresetId, WireId } from './types'; + +export interface AccountRoles { + /** Account participates in scrobble fan-out when enabled + master on. */ + scrobble: boolean; + /** Account may be chosen as the single enrichment primary. */ + enrichmentEligible: boolean; +} + +/** + * Persisted account record. Stored inside the auth store's MusicNetworkState. + * Field names are intentionally generic — no `lastfm*` leakage. + */ +export interface PersistedAccount { + id: string; + presetId: PresetId; + wireId: WireId; + /** User-facing label (defaults to preset displayName, editable). */ + label: string; + /** '' for fixed-host presets (Last.fm, Libre.fm, Rocksky). */ + baseUrl: string; + scrobbleEnabled: boolean; + sessionKey: string; + username: string; + apiKey: string; + apiSecret: string; + sessionError: boolean; + capabilities: CapabilitySet; + customFields?: Record; +} + +/** Runtime account view — persisted record plus resolved role flags. */ +export interface Account extends PersistedAccount { + roles: AccountRoles; +} + +/** Partial update applied through the runtime. */ +export type AccountPatch = Partial< + Pick< + PersistedAccount, + | 'label' + | 'baseUrl' + | 'scrobbleEnabled' + | 'sessionKey' + | 'username' + | 'apiKey' + | 'apiSecret' + | 'sessionError' + | 'capabilities' + | 'customFields' + > +>; + +/** Persisted top-level Music Network state (replaces flat `lastfm*` fields). */ +export interface MusicNetworkState { + /** Master switch for all scrobble fan-out (migrates from `scrobblingEnabled`). */ + scrobblingMasterEnabled: boolean; + /** Single enrichment primary account id, or null. */ + enrichmentPrimaryId: string | null; + accounts: PersistedAccount[]; +} diff --git a/src/music-network/core/capabilities.ts b/src/music-network/core/capabilities.ts new file mode 100644 index 00000000..71e9cf51 --- /dev/null +++ b/src/music-network/core/capabilities.ts @@ -0,0 +1,72 @@ +// Music Network — capability model. +// +// Each provider account is probed on connect; the result is a CapabilitySet +// describing which features the live session actually supports. The runtime and +// UI gate features on this — never on the provider name. + +export type CapabilityId = + | 'scrobble' + | 'nowPlaying' + | 'love' + | 'lovedSync' + | 'similarArtists' + | 'trackStats' + | 'artistStats' + | 'userTopLists' + | 'recentTracks' + | 'profileLinks'; + +export const ALL_CAPABILITIES: readonly CapabilityId[] = [ + 'scrobble', + 'nowPlaying', + 'love', + 'lovedSync', + 'similarArtists', + 'trackStats', + 'artistStats', + 'userTopLists', + 'recentTracks', + 'profileLinks', +]; + +/** The enrichment-only capabilities (read features), i.e. everything past scrobbling. */ +export const ENRICHMENT_CAPABILITIES: readonly CapabilityId[] = [ + 'love', + 'lovedSync', + 'similarArtists', + 'trackStats', + 'artistStats', + 'userTopLists', + 'recentTracks', + 'profileLinks', +]; + +export type CapabilityStatus = 'yes' | 'no' | 'unknown' | 'error'; + +export interface CapabilityState { + status: CapabilityStatus; + /** Provider-supplied detail surfaced on probe error. */ + message?: string; +} + +export type CapabilitySet = Partial>; + +/** + * Marks every enrichment capability as unsupported (`no`) on the given set and + * returns it. Scrobble-only wires (Maloja native, ListenBrainz, paste-auth + * Audioscrobbler presets) call this after settling scrobble/now-playing. + */ +export function markNoEnrichment(caps: CapabilitySet): CapabilitySet { + for (const id of ENRICHMENT_CAPABILITIES) caps[id] = { status: 'no' }; + return caps; +} + +/** True when the set reports at least one enrichment capability as `yes`. */ +export function hasAnyEnrichment(caps: CapabilitySet): boolean { + return ENRICHMENT_CAPABILITIES.some(id => caps[id]?.status === 'yes'); +} + +/** Convenience: is this specific capability usable right now? */ +export function isCapable(caps: CapabilitySet, id: CapabilityId): boolean { + return caps[id]?.status === 'yes'; +} diff --git a/src/music-network/core/errors.ts b/src/music-network/core/errors.ts new file mode 100644 index 00000000..1d066b18 --- /dev/null +++ b/src/music-network/core/errors.ts @@ -0,0 +1,45 @@ +// Music Network — typed errors. +// +// Wires and the runtime throw MusicNetworkError with a stable code; the UI maps +// the code to an i18n key under `musicNetwork.errors.*` and shows a toast. The +// optional providerId / capability give the toast extra context. + +import type { CapabilityId } from './capabilities'; + +export type MusicNetworkErrorCode = + | 'AUTH_SESSION_INVALID' + | 'AUTH_TIMEOUT' + | 'PROBE_FAILED' + | 'CAPABILITY_UNSUPPORTED' + | 'NETWORK' + | 'MALOJA_BAD_KEY' + | 'CUSTOM_URL_INVALID'; + +export class MusicNetworkError extends Error { + readonly code: MusicNetworkErrorCode; + readonly providerId?: string; + readonly capability?: CapabilityId; + readonly cause?: unknown; + + constructor( + code: MusicNetworkErrorCode, + message: string, + opts: { providerId?: string; capability?: CapabilityId; cause?: unknown } = {}, + ) { + super(message); + this.name = 'MusicNetworkError'; + this.code = code; + this.providerId = opts.providerId; + this.capability = opts.capability; + this.cause = opts.cause; + } +} + +export function isMusicNetworkError(e: unknown): e is MusicNetworkError { + return e instanceof MusicNetworkError; +} + +/** Maps an error code to its i18n key under the `musicNetwork.errors` namespace. */ +export function errorI18nKey(code: MusicNetworkErrorCode): string { + return `musicNetwork.errors.${code}`; +} diff --git a/src/music-network/core/types.ts b/src/music-network/core/types.ts new file mode 100644 index 00000000..a67bb33c --- /dev/null +++ b/src/music-network/core/types.ts @@ -0,0 +1,95 @@ +// Music Network — core domain types. +// +// These are the provider-agnostic shapes the rest of the app sees through the +// runtime facade. No provider names, no wire/transport details here. Wires map +// their own protocol responses onto these types; the application never touches a +// provider-specific shape. + +/** Branded id for a registered wire implementation (transport + protocol). */ +export type WireId = 'audioscrobbler_v2' | 'maloja_native' | 'listenbrainz'; + +/** Branded id for a built-in provider preset. */ +export type PresetId = + | 'lastfm' + | 'librefm' + | 'rocksky' + | 'listenbrainz' + | 'maloja_compat' + | 'maloja_native' + | 'maloja_listenbrainz' + | 'koito' + | 'custom_gnufm'; + +/** + * Minimal identity of a track for enrichment lookups (love, stats, urls). + * Audioscrobbler-class providers key on artist + title, not on a server id, so + * this intentionally mirrors the `${title}::${artist}` cache key used today. + */ +export interface TrackRef { + title: string; + artist: string; + /** Optional — used by wires that accept an album hint (e.g. MBID-less LB). */ + album?: string; +} + +/** A playback event handed to scrobble destinations. */ +export interface ScrobbleEvent { + title: string; + artist: string; + album: string; + /** Seconds. */ + duration: number; + /** Epoch milliseconds the play started. Required for `scrobble`, ignored by now-playing. */ + timestamp: number; +} + +/** Per-track stats (Now Playing cards). */ +export interface TrackStats { + listeners: number; + playcount: number; + userPlaycount: number | null; + userLoved: boolean; + tags: string[]; + url: string | null; +} + +/** Per-artist stats incl. bio (Now Playing). */ +export interface ArtistStats { + listeners: number; + playcount: number; + userPlaycount: number | null; + tags: string[]; + url: string | null; + bio: string | null; +} + +/** Connected-user profile (Integrations card). */ +export interface UserProfile { + username: string; + playcount: number; + /** Unix timestamp (seconds). 0 when unknown. */ + registeredAt: number; +} + +/** Statistics-page period selector. */ +export type StatsPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month'; + +/** Which top-list to fetch. */ +export type TopKind = 'artists' | 'albums' | 'tracks'; + +/** A single top-list row. `artist` is absent for `kind: 'artists'`. */ +export interface TopItem { + name: string; + playcount: string; + artist?: string; +} + +/** A recent-scrobble row (Statistics page). */ +export interface RecentTrack { + name: string; + artist: string; + album: string; + /** Unix timestamp (seconds), or null when currently playing. */ + timestamp: number | null; + nowPlaying: boolean; +} diff --git a/src/music-network/index.ts b/src/music-network/index.ts new file mode 100644 index 00000000..669d236b --- /dev/null +++ b/src/music-network/index.ts @@ -0,0 +1,50 @@ +// Music Network — public surface for the rest of the app. +// +// App code imports ONLY from here (or from this package root). It must never +// reach into wires/, registry/, or a provider preset directly. + +export { MusicNetworkRuntime, type ConnectOptions } from './runtime/MusicNetworkRuntime'; +export { + getMusicNetworkRuntime, + getMusicNetworkRuntimeOrNull, + initMusicNetworkRuntime, +} from './runtime/getMusicNetworkRuntime'; +export type { MusicNetworkStore, RuntimeHost } from './runtime/store'; +export { listPresets, getPreset } from './registry/presetRegistry'; +export { useEnrichmentPrimary, type EnrichmentPrimary } from './ui/useEnrichmentPrimary'; +export { + migrateLegacyLastfm, + sanitizeAccounts, + type LegacyLastfmState, +} from './runtime/accountPersistence'; + +export type { + Account, + AccountPatch, + AccountRoles, + MusicNetworkState, + PersistedAccount, +} from './core/accounts'; +export type { + CapabilityId, + CapabilitySet, + CapabilityState, + CapabilityStatus, +} from './core/capabilities'; +export { MusicNetworkError, errorI18nKey, isMusicNetworkError } from './core/errors'; +export type { MusicNetworkErrorCode } from './core/errors'; +export type { + ArtistStats, + PresetId, + RecentTrack, + ScrobbleEvent, + StatsPeriod, + TopItem, + TopKind, + TrackRef, + TrackStats, + UserProfile, + WireId, +} from './core/types'; +export type { PresetManifest, PresetField, PresetIcon } from './contracts/PresetManifest'; +export type { BuiltinPreset } from './registry/presetTypes'; diff --git a/src/music-network/registry/presetRegistry.test.ts b/src/music-network/registry/presetRegistry.test.ts new file mode 100644 index 00000000..08bc06ec --- /dev/null +++ b/src/music-network/registry/presetRegistry.test.ts @@ -0,0 +1,54 @@ +// Built-in preset catalogue invariants. Guards spec §5 (the v1 provider set) and +// §12 ("Maloja — 3 wire modes") against accidental drops or duplicate ids. + +import { describe, expect, it } from 'vitest'; +import { getPreset, listPresets } from './presetRegistry'; +import type { PresetId } from '../core/types'; + +describe('built-in preset catalogue', () => { + it('registers exactly the v1 provider set with unique ids', () => { + const ids = listPresets().map(p => p.manifest.presetId).sort(); + expect(ids).toEqual([ + 'custom_gnufm', + 'koito', + 'lastfm', + 'librefm', + 'listenbrainz', + 'maloja_compat', + 'maloja_listenbrainz', + 'maloja_native', + 'rocksky', + ]); + expect(new Set(ids).size).toBe(ids.length); // no duplicates + }); + + it('exposes all three Maloja wire modes (spec §12)', () => { + const maloja: PresetId[] = ['maloja_native', 'maloja_compat', 'maloja_listenbrainz']; + for (const id of maloja) { + const p = getPreset(id); + expect(p, id).toBeDefined(); + expect(p!.manifest.category).toBe('self_hosted'); + } + // The three modes ride three distinct transports. + expect(getPreset('maloja_native')!.manifest.wireId).toBe('maloja_native'); + expect(getPreset('maloja_compat')!.manifest.wireId).toBe('audioscrobbler_v2'); + expect(getPreset('maloja_listenbrainz')!.manifest.wireId).toBe('listenbrainz'); + }); + + it('only enrichment-eligible presets ride the audioscrobbler family as primary', () => { + // Last.fm / Libre.fm / custom GNU FM are enrichment-eligible; the scrobble-only + // audioscrobbler presets (Rocksky, maloja_compat) are not. + const eligible = listPresets() + .filter(p => p.manifest.defaultRoles.enrichmentEligible) + .map(p => p.manifest.presetId) + .sort(); + expect(eligible).toEqual(['custom_gnufm', 'lastfm', 'librefm']); + }); + + it('every preset declares a static scrobble capability and a description key', () => { + for (const p of listPresets()) { + expect(p.manifest.staticCapabilities.scrobble, p.manifest.presetId).toBe(true); + expect(p.manifest.descriptionKey).toMatch(/^musicNetwork\.presets\./); + } + }); +}); diff --git a/src/music-network/registry/presetRegistry.ts b/src/music-network/registry/presetRegistry.ts new file mode 100644 index 00000000..d581fc71 --- /dev/null +++ b/src/music-network/registry/presetRegistry.ts @@ -0,0 +1,48 @@ +// Preset registry — the built-in provider catalogue. +// +// Data only: each entry is a manifest (+ bundled credentials where applicable). +// The Integrations UI is driven entirely off this list; the orchestrator never +// branches on a preset id. Adding a provider = add its preset file here. + +import type { PresetId } from '../core/types'; +import type { BuiltinPreset } from './presetTypes'; + +import { lastfmPreset } from '../wires/audioscrobbler/presets/lastfm'; +import { librefmPreset } from '../wires/audioscrobbler/presets/librefm'; +import { rockskyPreset } from '../wires/audioscrobbler/presets/rocksky'; +import { customGnufmPreset } from '../wires/audioscrobbler/presets/customGnufm'; +import { malojaCompatPreset } from '../wires/audioscrobbler/presets/malojaCompat'; +import { listenbrainzPreset } from '../wires/listenbrainz/presets/listenbrainz'; +import { malojaListenbrainzPreset } from '../wires/listenbrainz/presets/malojaListenbrainz'; +import { malojaNativePreset } from '../wires/maloja/presets/malojaNative'; +import { koitoPreset } from '../wires/listenbrainz/presets/koito'; + +const PRESETS: readonly BuiltinPreset[] = [ + lastfmPreset, + librefmPreset, + rockskyPreset, + customGnufmPreset, + listenbrainzPreset, + malojaNativePreset, + malojaCompatPreset, + malojaListenbrainzPreset, + koitoPreset, +]; + +const byId = new Map( + PRESETS.map(p => [p.manifest.presetId, p]), +); + +export function listPresets(): readonly BuiltinPreset[] { + return PRESETS; +} + +export function getPreset(id: PresetId): BuiltinPreset | undefined { + return byId.get(id); +} + +export function requirePreset(id: PresetId): BuiltinPreset { + const p = byId.get(id); + if (!p) throw new Error(`Unknown preset "${id}"`); + return p; +} diff --git a/src/music-network/registry/presetTypes.ts b/src/music-network/registry/presetTypes.ts new file mode 100644 index 00000000..44435f2e --- /dev/null +++ b/src/music-network/registry/presetTypes.ts @@ -0,0 +1,15 @@ +// Built-in preset bundle: the declarative manifest plus any app-registered +// (bundled) credentials. Bundled keys live next to their preset — never +// scattered across the codebase — and are consumed only by the registry/runtime +// when materializing an account from a `credentials: 'bundled'` preset. + +import type { PresetManifest } from '../contracts/PresetManifest'; + +export interface BuiltinPreset { + manifest: PresetManifest; + /** Present only for `credentials: 'bundled'` presets. */ + bundled?: { + apiKey: string; + apiSecret: string; + }; +} diff --git a/src/music-network/registry/registerBuiltinWires.ts b/src/music-network/registry/registerBuiltinWires.ts new file mode 100644 index 00000000..fcd490b4 --- /dev/null +++ b/src/music-network/registry/registerBuiltinWires.ts @@ -0,0 +1,17 @@ +// One-time side-effect registration of every built-in wire. Call once at app +// init (and from test setup) before the runtime resolves any account. + +import { registerWire } from './wireRegistry'; +import { audioscrobblerWire } from '../wires/audioscrobbler/AudioscrobblerWire'; +import { listenBrainzWire } from '../wires/listenbrainz/ListenBrainzWire'; +import { malojaNativeWire } from '../wires/maloja/MalojaNativeWire'; + +let registered = false; + +export function registerBuiltinWires(): void { + if (registered) return; + registered = true; + registerWire(audioscrobblerWire); + registerWire(listenBrainzWire); + registerWire(malojaNativeWire); +} diff --git a/src/music-network/registry/wireRegistry.ts b/src/music-network/registry/wireRegistry.ts new file mode 100644 index 00000000..6d9ea654 --- /dev/null +++ b/src/music-network/registry/wireRegistry.ts @@ -0,0 +1,32 @@ +// Wire registry — maps a WireId to its single wire implementation. +// +// The orchestrator and enrichment router resolve an account's wire through here; +// they never import a concrete wire. Adding a provider that needs a new protocol +// means registering one more wire — no edits to consumers. + +import { MusicNetworkError } from '../core/errors'; +import type { WireId } from '../core/types'; +import type { ScrobbleWire } from '../contracts/ScrobbleWire'; + +const wires = new Map(); + +export function registerWire(wire: ScrobbleWire): void { + wires.set(wire.wireId, wire); +} + +export function getWire(wireId: WireId): ScrobbleWire | undefined { + return wires.get(wireId); +} + +export function requireWire(wireId: WireId): ScrobbleWire { + const w = wires.get(wireId); + if (!w) { + throw new MusicNetworkError('PROBE_FAILED', `No wire registered for "${wireId}"`); + } + return w; +} + +/** Test seam — drop all registrations. */ +export function __resetWires(): void { + wires.clear(); +} diff --git a/src/music-network/runtime/CapabilityProbe.test.ts b/src/music-network/runtime/CapabilityProbe.test.ts new file mode 100644 index 00000000..f318a9b1 --- /dev/null +++ b/src/music-network/runtime/CapabilityProbe.test.ts @@ -0,0 +1,68 @@ +// CapabilityProbe — the preset manifest is the final authority over the wire's +// dynamic probe for the keys it declares. This is how two presets on the same +// wire diverge: Rocksky rides the Audioscrobbler wire (which optimistically +// probes nowPlaying:yes) but its manifest declares nowPlaying:false, so the +// merged result must report nowPlaying:no. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { probeAccount } from './CapabilityProbe'; +import { __resetWires, registerWire } from '../registry/wireRegistry'; +import type { ScrobbleWire } from '../contracts/ScrobbleWire'; +import type { CapabilitySet } from '../core/capabilities'; +import type { PersistedAccount } from '../core/accounts'; + +function makeWire(probed: CapabilitySet, wireId: ScrobbleWire['wireId'] = 'audioscrobbler_v2'): ScrobbleWire { + return { + wireId, + supportsEnrichment: false, + connect: vi.fn(), + disconnect: vi.fn(), + scrobble: vi.fn(), + updateNowPlaying: vi.fn(), + probe: async () => probed, + }; +} + +function account(over: Partial = {}): PersistedAccount { + return { + id: 'a1', presetId: 'rocksky', wireId: 'audioscrobbler_v2', label: 'Rocksky', + baseUrl: '', scrobbleEnabled: true, sessionKey: 'sk', username: 'me', + apiKey: 'k', apiSecret: 's', sessionError: false, capabilities: {}, + ...over, + }; +} + +beforeEach(() => { + __resetWires(); +}); + +describe('probeAccount — manifest overrides probe', () => { + it('forces nowPlaying:no for Rocksky even when the wire probes nowPlaying:yes', async () => { + registerWire(makeWire({ scrobble: { status: 'yes' }, nowPlaying: { status: 'yes' } })); + const caps = await probeAccount(account()); + expect(caps.nowPlaying?.status).toBe('no'); + expect(caps.scrobble?.status).toBe('yes'); + }); + + it('lets a runtime probe error survive a static "true" (invalid pasted token)', async () => { + // listenbrainz declares scrobble:true statically, but a bad token makes the + // probe report scrobble:error — the static flag must not mask it back to yes. + registerWire(makeWire({ + scrobble: { status: 'error', message: 'Token invalid' }, + nowPlaying: { status: 'error', message: 'Token invalid' }, + }, 'listenbrainz')); + const caps = await probeAccount(account({ presetId: 'listenbrainz', wireId: 'listenbrainz' })); + expect(caps.scrobble).toEqual({ status: 'error', message: 'Token invalid' }); + }); + + it('keeps probed keys the manifest does not declare', async () => { + registerWire(makeWire({ + scrobble: { status: 'yes' }, + nowPlaying: { status: 'yes' }, + similarArtists: { status: 'error', message: 'boom' }, + })); + const caps = await probeAccount(account()); + // similarArtists is not in Rocksky's staticCapabilities → the probe stands. + expect(caps.similarArtists).toEqual({ status: 'error', message: 'boom' }); + }); +}); diff --git a/src/music-network/runtime/CapabilityProbe.ts b/src/music-network/runtime/CapabilityProbe.ts new file mode 100644 index 00000000..cbd4be05 --- /dev/null +++ b/src/music-network/runtime/CapabilityProbe.ts @@ -0,0 +1,32 @@ +// Probes an account's capabilities on connect. +// +// The wire probes the dynamic surface (session validity, enrichment), then the +// preset manifest's staticCapabilities refine the keys they declare. A static +// `false` is a hard "not offered" (e.g. Rocksky nowPlaying:false overrides the +// Audioscrobbler wire's optimistic nowPlaying:yes). A static `true` only affirms +// support — it must NOT mask a runtime probe `error`, so an invalid pasted token +// (whose sole validation is the probe) still surfaces as an error. + +import type { CapabilityId, CapabilitySet } from '../core/capabilities'; +import type { PersistedAccount } from '../core/accounts'; +import { getPreset } from '../registry/presetRegistry'; +import { requireWire } from '../registry/wireRegistry'; +import { resolveWireContext } from './contextResolver'; + +export async function probeAccount(account: PersistedAccount): Promise { + const wire = requireWire(account.wireId); + const probed = await wire.probe(resolveWireContext(account)); + + const merged: CapabilitySet = { ...probed }; + const staticCaps = getPreset(account.presetId)?.manifest.staticCapabilities ?? {}; + for (const key of Object.keys(staticCaps) as CapabilityId[]) { + if (!staticCaps[key]) { + // Structurally not offered — hard override. + merged[key] = { status: 'no' }; + } else if (merged[key]?.status !== 'error') { + // Offered: affirm 'yes', but let a runtime probe 'error' stand. + merged[key] = { status: 'yes' }; + } + } + return merged; +} diff --git a/src/music-network/runtime/EnrichmentRouter.ts b/src/music-network/runtime/EnrichmentRouter.ts new file mode 100644 index 00000000..7747da93 --- /dev/null +++ b/src/music-network/runtime/EnrichmentRouter.ts @@ -0,0 +1,28 @@ +// Resolves the single enrichment primary to its EnrichmentWire + context. +// +// Returns null unless the primary account maps to a wire that actually +// implements enrichment. Maloja / ListenBrainz wires report +// supportsEnrichment=false, so they can never drive love/similar/stats even if +// somehow set as primary — the type guard rejects them here, and the facade +// additionally refuses to set a non-eligible account as primary. + +import type { PersistedAccount } from '../core/accounts'; +import type { EnrichmentWire } from '../contracts/EnrichmentWire'; +import { isEnrichmentWire } from '../contracts/EnrichmentWire'; +import type { WireContext } from '../contracts/ScrobbleWire'; +import { getWire } from '../registry/wireRegistry'; +import { resolveWireContext } from './contextResolver'; + +export interface ResolvedEnrichment { + wire: EnrichmentWire; + ctx: WireContext; +} + +export function resolveEnrichment( + primary: PersistedAccount | undefined, +): ResolvedEnrichment | null { + if (!primary) return null; + const wire = getWire(primary.wireId); + if (!wire || !isEnrichmentWire(wire)) return null; + return { wire, ctx: resolveWireContext(primary) }; +} diff --git a/src/music-network/runtime/MusicNetworkRuntime.test.ts b/src/music-network/runtime/MusicNetworkRuntime.test.ts new file mode 100644 index 00000000..eb5464df --- /dev/null +++ b/src/music-network/runtime/MusicNetworkRuntime.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MusicNetworkRuntime } from './MusicNetworkRuntime'; +import type { MusicNetworkStore, RuntimeHost } from './store'; +import { __resetWires, registerWire } from '../registry/wireRegistry'; +import { MusicNetworkError } from '../core/errors'; +import type { MusicNetworkState, PersistedAccount } from '../core/accounts'; +import type { EnrichmentWire } from '../contracts/EnrichmentWire'; +import type { ScrobbleWire } from '../contracts/ScrobbleWire'; +import type { ScrobbleEvent } from '../core/types'; + +// ── mock wires ─────────────────────────────────────────────────────────────── + +function makeAudioscrobblerMock() { + const calls = { scrobble: 0, nowPlaying: 0, loved: 0 }; + let failSession = false; + const wire: EnrichmentWire = { + wireId: 'audioscrobbler_v2', + supportsEnrichment: true, + async connect() { return { sessionKey: 'sk', username: 'u' }; }, + disconnect() {}, + async scrobble() { + if (failSession) throw new MusicNetworkError('AUTH_SESSION_INVALID', 'bad'); + calls.scrobble++; + }, + async updateNowPlaying() { calls.nowPlaying++; }, + async probe() { return {}; }, + async getTrackLoved() { calls.loved++; return true; }, + async loveTrack() {}, + async getAllLovedTracks() { return [{ title: 'T', artist: 'A' }]; }, + async getSimilarArtists() { return ['B']; }, + async getTrackStats() { return null; }, + async getArtistStats() { return null; }, + async getUserProfile() { return null; }, + async getTopItems() { return []; }, + async getRecentTracks() { return []; }, + buildProfileUrl() { return 'https://www.last.fm/user/u'; }, + buildArtistUrl() { return 'https://www.last.fm/music/A'; }, + buildTrackUrl() { return 'https://www.last.fm/music/A/_/T'; }, + }; + return { wire, calls, setFailSession: (v: boolean) => { failSession = v; } }; +} + +function makeListenBrainzMock() { + const calls = { scrobble: 0, nowPlaying: 0 }; + const wire: ScrobbleWire = { + wireId: 'listenbrainz', + supportsEnrichment: false, + async connect() { return { sessionKey: 'tok', username: '' }; }, + disconnect() {}, + async scrobble() { calls.scrobble++; }, + async updateNowPlaying() { calls.nowPlaying++; }, + async probe() { return {}; }, + }; + return { wire, calls }; +} + +// ── in-memory store ────────────────────────────────────────────────────────── + +function memStore(initial: MusicNetworkState): MusicNetworkStore { + let state = initial; + return { + getState: () => state, + setAccounts: a => { state = { ...state, accounts: a }; }, + setEnrichmentPrimaryId: id => { state = { ...state, enrichmentPrimaryId: id }; }, + }; +} + +const host: RuntimeHost = { openExternal: async () => {}, newId: () => 'new-id' }; + +function lastfmAccount(over: Partial = {}): PersistedAccount { + return { + id: 'a1', presetId: 'lastfm', wireId: 'audioscrobbler_v2', label: 'Last.fm', + baseUrl: '', scrobbleEnabled: true, sessionKey: 'sk1', username: 'u1', + apiKey: 'k', apiSecret: 's', sessionError: false, + capabilities: { scrobble: { status: 'yes' }, nowPlaying: { status: 'yes' } }, + ...over, + }; +} + +function lbAccount(over: Partial = {}): PersistedAccount { + return { + id: 'a2', presetId: 'listenbrainz', wireId: 'listenbrainz', label: 'ListenBrainz', + baseUrl: 'https://api.listenbrainz.org', scrobbleEnabled: true, sessionKey: 'tok', username: '', + apiKey: '', apiSecret: '', sessionError: false, + capabilities: { scrobble: { status: 'yes' }, nowPlaying: { status: 'yes' } }, + ...over, + }; +} + +const EVENT: ScrobbleEvent = { title: 'T', artist: 'A', album: 'Al', duration: 200, timestamp: 1_700_000_000_000 }; + +let as: ReturnType; +let lb: ReturnType; + +beforeEach(() => { + __resetWires(); + as = makeAudioscrobblerMock(); + lb = makeListenBrainzMock(); + registerWire(as.wire); + registerWire(lb.wire); +}); + +describe('scrobble fan-out', () => { + it('dispatches to every enabled destination when master is on', async () => { + const rt = new MusicNetworkRuntime( + memStore({ scrobblingMasterEnabled: true, enrichmentPrimaryId: null, accounts: [lastfmAccount(), lbAccount()] }), + host, + ); + await rt.dispatchScrobble(EVENT); + expect(as.calls.scrobble).toBe(1); + expect(lb.calls.scrobble).toBe(1); + }); + + it('dispatches nothing when the master toggle is off', async () => { + const rt = new MusicNetworkRuntime( + memStore({ scrobblingMasterEnabled: false, enrichmentPrimaryId: null, accounts: [lastfmAccount(), lbAccount()] }), + host, + ); + await rt.dispatchScrobble(EVENT); + expect(as.calls.scrobble).toBe(0); + expect(lb.calls.scrobble).toBe(0); + }); + + it('skips accounts with scrobbling disabled', async () => { + const rt = new MusicNetworkRuntime( + memStore({ scrobblingMasterEnabled: true, enrichmentPrimaryId: null, accounts: [lastfmAccount({ scrobbleEnabled: false }), lbAccount()] }), + host, + ); + await rt.dispatchScrobble(EVENT); + expect(as.calls.scrobble).toBe(0); + expect(lb.calls.scrobble).toBe(1); + }); + + it('only sends now-playing to capable destinations', async () => { + const rt = new MusicNetworkRuntime( + memStore({ + scrobblingMasterEnabled: true, + enrichmentPrimaryId: null, + accounts: [lastfmAccount(), lbAccount({ capabilities: { scrobble: { status: 'yes' }, nowPlaying: { status: 'no' } } })], + }), + host, + ); + await rt.dispatchNowPlaying(EVENT); + expect(as.calls.nowPlaying).toBe(1); + expect(lb.calls.nowPlaying).toBe(0); + }); + + it('flips session-error on AUTH_SESSION_INVALID and clears it on success', async () => { + const store = memStore({ scrobblingMasterEnabled: true, enrichmentPrimaryId: null, accounts: [lastfmAccount()] }); + const rt = new MusicNetworkRuntime(store, host); + + as.setFailSession(true); + await rt.dispatchScrobble(EVENT); + expect(store.getState().accounts[0].sessionError).toBe(true); + + as.setFailSession(false); + await rt.dispatchScrobble(EVENT); + expect(store.getState().accounts[0].sessionError).toBe(false); + }); +}); + +describe('enrichment primary', () => { + it('rejects a non-eligible account (ListenBrainz) as primary', () => { + const rt = new MusicNetworkRuntime( + memStore({ scrobblingMasterEnabled: true, enrichmentPrimaryId: null, accounts: [lastfmAccount(), lbAccount()] }), + host, + ); + expect(() => rt.setEnrichmentPrimaryId('a2')).toThrow(MusicNetworkError); + }); + + it('routes love/similar to the enrichment wire when the primary is eligible', async () => { + const rt = new MusicNetworkRuntime( + memStore({ scrobblingMasterEnabled: true, enrichmentPrimaryId: 'a1', accounts: [lastfmAccount(), lbAccount()] }), + host, + ); + expect(await rt.isTrackLoved({ title: 'T', artist: 'A' })).toBe(true); + expect(as.calls.loved).toBe(1); + expect(await rt.getSimilarArtists('A')).toEqual(['B']); + }); + + it('returns inert defaults when no primary is set', async () => { + const rt = new MusicNetworkRuntime( + memStore({ scrobblingMasterEnabled: true, enrichmentPrimaryId: null, accounts: [lastfmAccount()] }), + host, + ); + expect(await rt.isTrackLoved({ title: 'T', artist: 'A' })).toBe(false); + expect(await rt.getSimilarArtists('A')).toEqual([]); + expect(rt.profileUrl()).toBeNull(); + }); +}); diff --git a/src/music-network/runtime/MusicNetworkRuntime.ts b/src/music-network/runtime/MusicNetworkRuntime.ts new file mode 100644 index 00000000..a6681419 --- /dev/null +++ b/src/music-network/runtime/MusicNetworkRuntime.ts @@ -0,0 +1,304 @@ +// MusicNetworkRuntime — the one public API the rest of the app talks to. +// +// Accounts, roles, scrobble fan-out, and enrichment all flow through here. The +// app never imports a wire, preset, or the registry directly. State lives behind +// the MusicNetworkStore port; side effects (browser open, id generation) behind +// RuntimeHost. + +import type { + Account, + AccountPatch, + PersistedAccount, +} from '../core/accounts'; +import type { CapabilitySet } from '../core/capabilities'; +import { MusicNetworkError } from '../core/errors'; +import type { + ArtistStats, + PresetId, + RecentTrack, + ScrobbleEvent, + StatsPeriod, + TopItem, + TopKind, + TrackRef, + TrackStats, + UserProfile, +} from '../core/types'; +import type { ConnectContext } from '../contracts/ScrobbleWire'; +import { getPreset, requirePreset } from '../registry/presetRegistry'; +import { requireWire } from '../registry/wireRegistry'; +import { probeAccount } from './CapabilityProbe'; +import { resolveEnrichment } from './EnrichmentRouter'; +import { resolveWireContext } from './contextResolver'; +import { dispatchNowPlaying, dispatchScrobble } from './ScrobbleOrchestrator'; +import type { MusicNetworkStore, RuntimeHost } from './store'; + +export interface ConnectOptions { + /** Connect-form field values (token, baseUrl, apiKey, apiSecret, …). */ + fields?: Record; + signal?: AbortSignal; +} + +function deriveAuthBase(origin: string): string { + return origin ? `${origin.replace(/\/$/, '')}/api/auth/` : ''; +} + +export class MusicNetworkRuntime { + constructor( + private readonly store: MusicNetworkStore, + private readonly host: RuntimeHost, + ) {} + + // ── Accounts ────────────────────────────────────────────────────────────── + + private toAccount(p: PersistedAccount): Account { + const roles = getPreset(p.presetId)?.manifest.defaultRoles + ?? { scrobble: false, enrichmentEligible: false }; + return { ...p, roles }; + } + + listAccounts(): Account[] { + return this.store.getState().accounts.map(a => this.toAccount(a)); + } + + getAccount(id: string): Account | undefined { + const p = this.store.getState().accounts.find(a => a.id === id); + return p ? this.toAccount(p) : undefined; + } + + private persist(accounts: PersistedAccount[]): void { + this.store.setAccounts(accounts); + } + + async connect(presetId: PresetId, options: ConnectOptions = {}): Promise { + const preset = requirePreset(presetId); + const wire = requireWire(preset.manifest.wireId); + const fields = options.fields ?? {}; + + const origin = (fields.baseUrl ?? '').trim().replace(/\/$/, ''); + const apiBase = preset.manifest.endpoints?.apiBase + ?? `${origin}${preset.manifest.selfHostedApiSuffix ?? ''}`; + const authBase = preset.manifest.endpoints?.authBase ?? deriveAuthBase(origin); + const apiKey = preset.bundled?.apiKey ?? (fields.apiKey ?? '').trim(); + const apiSecret = preset.bundled?.apiSecret ?? (fields.apiSecret ?? '').trim(); + + const ctx: ConnectContext = { + presetId, + wireId: preset.manifest.wireId, + authStrategy: preset.manifest.authStrategy, + baseUrl: apiBase, + authBase, + apiKey, + apiSecret, + fields, + openExternal: this.host.openExternal, + signal: options.signal, + }; + + const result = await wire.connect(ctx); + + const account: PersistedAccount = { + id: this.host.newId(), + presetId, + wireId: preset.manifest.wireId, + label: preset.manifest.displayName, + // Fixed-host presets resolve their base from the manifest; store '' so the + // preset stays the single source of truth. Self-hosted presets persist the + // resolved base (origin + suffix). + baseUrl: preset.manifest.endpoints?.apiBase ? '' : (result.baseUrl ?? apiBase), + scrobbleEnabled: preset.manifest.defaultRoles.scrobble, + sessionKey: result.sessionKey, + username: result.username, + apiKey, + apiSecret, + sessionError: false, + capabilities: result.capabilities ?? {}, + }; + + account.capabilities = await probeAccount(account).catch(() => account.capabilities); + + const state = this.store.getState(); + this.persist([...state.accounts, account]); + + // First enrichment-eligible account with no primary becomes the primary. + if (preset.manifest.defaultRoles.enrichmentEligible && !state.enrichmentPrimaryId) { + this.store.setEnrichmentPrimaryId(account.id); + } + + return this.toAccount(account); + } + + disconnect(accountId: string): void { + const state = this.store.getState(); + const wire = (() => { + const acc = state.accounts.find(a => a.id === accountId); + return acc ? requireWire(acc.wireId) : undefined; + })(); + const acc = state.accounts.find(a => a.id === accountId); + if (acc && wire) wire.disconnect(resolveWireContext(acc)); + + this.persist(state.accounts.filter(a => a.id !== accountId)); + if (state.enrichmentPrimaryId === accountId) { + this.store.setEnrichmentPrimaryId(this.firstEligibleId()); + } + } + + updateAccount(accountId: string, patch: AccountPatch): void { + this.persist( + this.store.getState().accounts.map(a => + a.id === accountId ? { ...a, ...patch } : a, + ), + ); + } + + // ── Roles ───────────────────────────────────────────────────────────────── + + private firstEligibleId(): string | null { + const acc = this.store.getState().accounts.find( + a => getPreset(a.presetId)?.manifest.defaultRoles.enrichmentEligible, + ); + return acc?.id ?? null; + } + + getEnrichmentPrimaryId(): string | null { + return this.store.getState().enrichmentPrimaryId; + } + + setEnrichmentPrimaryId(accountId: string | null): void { + if (accountId === null) { + this.store.setEnrichmentPrimaryId(null); + return; + } + const acc = this.store.getState().accounts.find(a => a.id === accountId); + if (!acc) { + throw new MusicNetworkError('CAPABILITY_UNSUPPORTED', `Unknown account ${accountId}`); + } + if (!getPreset(acc.presetId)?.manifest.defaultRoles.enrichmentEligible) { + throw new MusicNetworkError('CAPABILITY_UNSUPPORTED', `${acc.label} cannot be an enrichment primary`, { + providerId: acc.presetId, + }); + } + this.store.setEnrichmentPrimaryId(accountId); + } + + listEnrichmentCandidates(): Account[] { + return this.listAccounts().filter(a => a.roles.enrichmentEligible); + } + + // ── Scrobble fan-out ──────────────────────────────────────────────────────── + + private scrobbleTargets(): PersistedAccount[] { + const state = this.store.getState(); + if (!state.scrobblingMasterEnabled) return []; + return state.accounts.filter(a => a.scrobbleEnabled && a.sessionKey); + } + + private orchestratorDeps() { + return { + setSessionError: (id: string, invalid: boolean) => + this.updateAccount(id, { sessionError: invalid }), + }; + } + + async dispatchScrobble(event: ScrobbleEvent): Promise { + await dispatchScrobble(this.scrobbleTargets(), event, this.orchestratorDeps()); + } + + async dispatchNowPlaying(event: ScrobbleEvent): Promise { + const targets = this.scrobbleTargets().filter( + a => a.capabilities.nowPlaying?.status === 'yes', + ); + await dispatchNowPlaying(targets, event, this.orchestratorDeps()); + } + + // ── Enrichment (single primary) ───────────────────────────────────────────── + + private primaryAccount(): PersistedAccount | undefined { + const { accounts, enrichmentPrimaryId } = this.store.getState(); + return accounts.find(a => a.id === enrichmentPrimaryId); + } + + private enrichment() { + return resolveEnrichment(this.primaryAccount()); + } + + async isTrackLoved(ref: TrackRef): Promise { + const e = this.enrichment(); + return e ? e.wire.getTrackLoved(e.ctx, ref) : false; + } + + async setTrackLoved(ref: TrackRef, loved: boolean): Promise { + const e = this.enrichment(); + if (e) await e.wire.loveTrack(e.ctx, ref, loved); + } + + async syncLovedTracks(): Promise> { + const e = this.enrichment(); + if (!e) return {}; + const tracks = await e.wire.getAllLovedTracks(e.ctx); + const map: Record = {}; + for (const t of tracks) map[`${t.title}::${t.artist}`] = true; + return map; + } + + async getSimilarArtists(artistName: string): Promise { + const e = this.enrichment(); + return e ? e.wire.getSimilarArtists(e.ctx, artistName) : []; + } + + async getTrackStats(ref: TrackRef): Promise { + const e = this.enrichment(); + return e ? e.wire.getTrackStats(e.ctx, ref) : null; + } + + async getArtistStats(artistName: string): Promise { + const e = this.enrichment(); + return e ? e.wire.getArtistStats(e.ctx, artistName) : null; + } + + async getUserProfile(): Promise { + const e = this.enrichment(); + return e ? e.wire.getUserProfile(e.ctx) : null; + } + + async getTopItems(period: StatsPeriod, kind: TopKind, limit: number): Promise { + const e = this.enrichment(); + return e ? e.wire.getTopItems(e.ctx, period, kind, limit) : []; + } + + async getRecentTracks(limit: number): Promise { + const e = this.enrichment(); + return e ? e.wire.getRecentTracks(e.ctx, limit) : []; + } + + // ── URLs (enrichment primary) ─────────────────────────────────────────────── + + profileUrl(): string | null { + const e = this.enrichment(); + return e ? e.wire.buildProfileUrl(e.ctx) || null : null; + } + + artistUrl(artistName: string): string | null { + const e = this.enrichment(); + return e ? e.wire.buildArtistUrl(e.ctx, artistName) || null : null; + } + + trackUrl(ref: TrackRef): string | null { + const e = this.enrichment(); + return e ? e.wire.buildTrackUrl(e.ctx, ref) || null : null; + } + + // ── Health ────────────────────────────────────────────────────────────────── + + async probeCapabilities(accountId: string): Promise { + const acc = this.store.getState().accounts.find(a => a.id === accountId); + if (!acc) throw new MusicNetworkError('PROBE_FAILED', `Unknown account ${accountId}`); + const caps = await probeAccount(acc); + this.updateAccount(accountId, { capabilities: caps }); + return caps; + } + + clearSessionError(accountId: string): void { + this.updateAccount(accountId, { sessionError: false }); + } +} diff --git a/src/music-network/runtime/ScrobbleOrchestrator.ts b/src/music-network/runtime/ScrobbleOrchestrator.ts new file mode 100644 index 00000000..4eda654c --- /dev/null +++ b/src/music-network/runtime/ScrobbleOrchestrator.ts @@ -0,0 +1,55 @@ +// Fans a playback event out to every enabled scrobble destination. +// +// Best-effort: one destination failing never blocks the others. A wire that +// throws AUTH_SESSION_INVALID flips that account's session-error flag (cleared +// on the next success); other errors are swallowed (the wires already log). +// Filtering (master toggle, scrobbleEnabled, capability) happens in the facade +// so this stays a pure fan-out over the list it is given. + +import { MusicNetworkError } from '../core/errors'; +import type { PersistedAccount } from '../core/accounts'; +import type { ScrobbleEvent } from '../core/types'; +import { getWire } from '../registry/wireRegistry'; +import { resolveWireContext } from './contextResolver'; + +export interface OrchestratorDeps { + /** Flip the persisted session-error flag for an account. */ + setSessionError(accountId: string, invalid: boolean): void; +} + +type WireOp = 'scrobble' | 'updateNowPlaying'; + +async function dispatchOne( + account: PersistedAccount, + op: WireOp, + event: ScrobbleEvent, + deps: OrchestratorDeps, +): Promise { + const wire = getWire(account.wireId); + if (!wire) return; + try { + await wire[op](resolveWireContext(account), event); + if (account.sessionError) deps.setSessionError(account.id, false); + } catch (e) { + if (e instanceof MusicNetworkError && e.code === 'AUTH_SESSION_INVALID') { + deps.setSessionError(account.id, true); + } + // best-effort: swallow everything else + } +} + +export async function dispatchScrobble( + accounts: PersistedAccount[], + event: ScrobbleEvent, + deps: OrchestratorDeps, +): Promise { + await Promise.all(accounts.map(a => dispatchOne(a, 'scrobble', event, deps))); +} + +export async function dispatchNowPlaying( + accounts: PersistedAccount[], + event: ScrobbleEvent, + deps: OrchestratorDeps, +): Promise { + await Promise.all(accounts.map(a => dispatchOne(a, 'updateNowPlaying', event, deps))); +} diff --git a/src/music-network/runtime/accountPersistence.test.ts b/src/music-network/runtime/accountPersistence.test.ts new file mode 100644 index 00000000..a7e347a0 --- /dev/null +++ b/src/music-network/runtime/accountPersistence.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { migrateLegacyLastfm, sanitizeAccounts } from './accountPersistence'; +import type { PersistedAccount } from '../core/accounts'; + +const newId = () => 'fixed-id'; + +describe('migrateLegacyLastfm', () => { + it('creates a Last.fm account and primary from a legacy session, without data loss', () => { + const result = migrateLegacyLastfm( + { lastfmSessionKey: 'sk-abc', lastfmUsername: 'frank', scrobblingEnabled: true }, + newId, + ); + expect(result.accounts).toHaveLength(1); + const acc = result.accounts[0]; + expect(acc.presetId).toBe('lastfm'); + expect(acc.wireId).toBe('audioscrobbler_v2'); + expect(acc.sessionKey).toBe('sk-abc'); + expect(acc.username).toBe('frank'); + // bundled Last.fm credentials are filled from the preset + expect(acc.apiKey).not.toBe(''); + expect(acc.apiSecret).not.toBe(''); + expect(result.enrichmentPrimaryId).toBe(acc.id); + expect(result.scrobblingMasterEnabled).toBe(true); + expect(acc.scrobbleEnabled).toBe(true); + }); + + it('carries the scrobbling preference into both master and account flags', () => { + const off = migrateLegacyLastfm( + { lastfmSessionKey: 'sk', lastfmUsername: 'u', scrobblingEnabled: false }, + newId, + ); + expect(off.scrobblingMasterEnabled).toBe(false); + expect(off.accounts[0].scrobbleEnabled).toBe(false); + }); + + it('produces an empty state (no account) when there is no legacy session', () => { + const result = migrateLegacyLastfm({ scrobblingEnabled: true }, newId); + expect(result.accounts).toEqual([]); + expect(result.enrichmentPrimaryId).toBeNull(); + expect(result.scrobblingMasterEnabled).toBe(true); + }); + + it('defaults the master toggle to true when scrobblingEnabled is absent', () => { + expect(migrateLegacyLastfm({}, newId).scrobblingMasterEnabled).toBe(true); + }); + + it('ignores a blank/whitespace session key', () => { + expect(migrateLegacyLastfm({ lastfmSessionKey: ' ' }, newId).accounts).toEqual([]); + }); +}); + +describe('sanitizeAccounts', () => { + const valid: PersistedAccount = { + id: 'a1', presetId: 'lastfm', wireId: 'audioscrobbler_v2', label: 'Last.fm', + baseUrl: '', scrobbleEnabled: true, sessionKey: 'sk', username: 'u', + apiKey: 'k', apiSecret: 's', sessionError: false, capabilities: {}, + }; + + it('keeps well-formed accounts with a known preset', () => { + expect(sanitizeAccounts([valid])).toEqual([valid]); + }); + + it('drops non-arrays, malformed entries, and unknown presets', () => { + expect(sanitizeAccounts(null)).toEqual([]); + expect(sanitizeAccounts([{ id: 'x' }])).toEqual([]); + expect(sanitizeAccounts([{ ...valid, presetId: 'bogus' }])).toEqual([]); + expect(sanitizeAccounts([valid, { foo: 1 }])).toEqual([valid]); + }); +}); diff --git a/src/music-network/runtime/accountPersistence.ts b/src/music-network/runtime/accountPersistence.ts new file mode 100644 index 00000000..d3c7e094 --- /dev/null +++ b/src/music-network/runtime/accountPersistence.ts @@ -0,0 +1,73 @@ +// Migration + validation for persisted Music Network state. +// +// Migrates the legacy flat lastfm* auth-store fields into a Last.fm account on +// first rehydrate, and defensively sanitizes any persisted account list. The +// legacy nowPlayingEnabled toggle is intentionally NOT touched here — it stays a +// global setting and gates dispatchNowPlaying at the playback call-site, so +// now-playing behaviour is preserved exactly. + +import type { MusicNetworkState, PersistedAccount } from '../core/accounts'; +import { getPreset } from '../registry/presetRegistry'; + +export interface LegacyLastfmState { + lastfmSessionKey?: string; + lastfmUsername?: string; + scrobblingEnabled?: boolean; +} + +/** + * Builds the initial MusicNetworkState from legacy fields. Returns a populated + * Last.fm account + primary when a legacy session key exists; otherwise an empty + * state (master toggle still carried over). No data loss: the session key, + * username and scrobbling preference are all preserved. + */ +export function migrateLegacyLastfm( + legacy: LegacyLastfmState, + newId: () => string, +): MusicNetworkState { + const scrobblingMasterEnabled = legacy.scrobblingEnabled ?? true; + const sessionKey = (legacy.lastfmSessionKey ?? '').trim(); + if (!sessionKey) { + return { scrobblingMasterEnabled, enrichmentPrimaryId: null, accounts: [] }; + } + + const preset = getPreset('lastfm'); + const id = newId(); + const account: PersistedAccount = { + id, + presetId: 'lastfm', + wireId: 'audioscrobbler_v2', + label: preset?.manifest.displayName ?? 'Last.fm', + baseUrl: '', + scrobbleEnabled: scrobblingMasterEnabled, + sessionKey, + username: legacy.lastfmUsername ?? '', + apiKey: preset?.bundled?.apiKey ?? '', + apiSecret: preset?.bundled?.apiSecret ?? '', + sessionError: false, + capabilities: { + scrobble: { status: 'yes' }, + nowPlaying: { status: 'yes' }, + }, + }; + return { scrobblingMasterEnabled, enrichmentPrimaryId: id, accounts: [account] }; +} + +const REQUIRED_STRING_FIELDS: (keyof PersistedAccount)[] = [ + 'id', 'presetId', 'wireId', 'label', 'sessionKey', +]; + +/** + * Drops malformed entries from a persisted account list (defensive against + * tampered/old blobs). Keeps only objects with the required string fields and a + * known preset. + */ +export function sanitizeAccounts(raw: unknown): PersistedAccount[] { + if (!Array.isArray(raw)) return []; + return raw.filter((a): a is PersistedAccount => { + if (!a || typeof a !== 'object') return false; + const acc = a as Record; + if (REQUIRED_STRING_FIELDS.some(f => typeof acc[f] !== 'string')) return false; + return getPreset(acc.presetId as PersistedAccount['presetId']) !== undefined; + }); +} diff --git a/src/music-network/runtime/contextResolver.ts b/src/music-network/runtime/contextResolver.ts new file mode 100644 index 00000000..5ec22cdd --- /dev/null +++ b/src/music-network/runtime/contextResolver.ts @@ -0,0 +1,22 @@ +// Resolves a persisted account (+ its preset) into the WireContext a wire needs. +// Endpoints fall back to the preset manifest for fixed-host presets; bundled +// credentials are already copied onto the account at connect time. + +import type { PersistedAccount } from '../core/accounts'; +import type { WireContext } from '../contracts/ScrobbleWire'; +import { getPreset } from '../registry/presetRegistry'; + +export function resolveWireContext(account: PersistedAccount): WireContext { + const manifest = getPreset(account.presetId)?.manifest; + const endpoints = manifest?.endpoints; + return { + account, + baseUrl: account.baseUrl || endpoints?.apiBase || '', + profileBase: endpoints?.profileBase ?? '', + apiKey: account.apiKey, + apiSecret: account.apiSecret, + sessionKey: account.sessionKey, + username: account.username, + authStrategy: manifest?.authStrategy, + }; +} diff --git a/src/music-network/runtime/getMusicNetworkRuntime.ts b/src/music-network/runtime/getMusicNetworkRuntime.ts new file mode 100644 index 00000000..0f5c61a7 --- /dev/null +++ b/src/music-network/runtime/getMusicNetworkRuntime.ts @@ -0,0 +1,39 @@ +// Runtime singleton. Initialized once at app start (Phase 5 wires the store to +// the auth store and the host to the Tauri shell). App code calls +// getMusicNetworkRuntime() everywhere else. + +import { registerBuiltinWires } from '../registry/registerBuiltinWires'; +import { MusicNetworkRuntime } from './MusicNetworkRuntime'; +import type { MusicNetworkStore, RuntimeHost } from './store'; + +let instance: MusicNetworkRuntime | null = null; + +export function initMusicNetworkRuntime( + store: MusicNetworkStore, + host: RuntimeHost, +): MusicNetworkRuntime { + registerBuiltinWires(); + instance = new MusicNetworkRuntime(store, host); + return instance; +} + +export function getMusicNetworkRuntime(): MusicNetworkRuntime { + if (!instance) { + throw new Error('Music Network runtime not initialized — call initMusicNetworkRuntime() at startup'); + } + return instance; +} + +/** + * Non-throwing accessor for best-effort callers (fire-and-forget scrobble, + * now-playing, loved sync). Returns null before init (e.g. in unit tests that + * don't bootstrap) so those paths no-op instead of throwing. + */ +export function getMusicNetworkRuntimeOrNull(): MusicNetworkRuntime | null { + return instance; +} + +/** Test seam. */ +export function __setMusicNetworkRuntime(rt: MusicNetworkRuntime | null): void { + instance = rt; +} diff --git a/src/music-network/runtime/store.ts b/src/music-network/runtime/store.ts new file mode 100644 index 00000000..e692b55b --- /dev/null +++ b/src/music-network/runtime/store.ts @@ -0,0 +1,22 @@ +// State port for the runtime. +// +// The runtime never imports the auth store directly — it reads and writes the +// persisted MusicNetworkState through this port, which Phase 5 backs with the +// auth store. Keeping it an interface lets the engine be unit-tested with an +// in-memory implementation. + +import type { MusicNetworkState, PersistedAccount } from '../core/accounts'; + +export interface MusicNetworkStore { + getState(): MusicNetworkState; + setAccounts(accounts: PersistedAccount[]): void; + setEnrichmentPrimaryId(id: string | null): void; +} + +/** Side effects the runtime needs from the host (Tauri shell / app). */ +export interface RuntimeHost { + /** Opens an external URL (Tauri shell) for browser auth flows. */ + openExternal(url: string): Promise; + /** Generates a unique account id (uuid). */ + newId(): string; +} diff --git a/src/music-network/ui/useEnrichmentPrimary.ts b/src/music-network/ui/useEnrichmentPrimary.ts new file mode 100644 index 00000000..8fa42141 --- /dev/null +++ b/src/music-network/ui/useEnrichmentPrimary.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '../../store/authStore'; +import { getPreset } from '../registry/presetRegistry'; +import type { PersistedAccount } from '../core/accounts'; +import type { PresetIcon } from '../contracts/PresetManifest'; + +export interface EnrichmentPrimary { + account: PersistedAccount; + /** Display label (e.g. "Last.fm", "Libre.fm"). */ + label: string; + /** Manifest icon id of the provider, for `renderPresetIcon`. */ + icon: PresetIcon; +} + +/** + * The single enrichment-primary account (drives love / similar / stats) plus its + * display label and provider icon, or null when no primary is set. + * + * One lookup for the whole app — indicator, player bar, hero, stats, and the + * context menus all go through here so nothing re-derives the account or + * hardcodes a provider name/icon (provider-agnostic, spec §7.3). The icon falls + * back to the neutral 'custom' glyph, never to a specific provider. + */ +export function useEnrichmentPrimary(): EnrichmentPrimary | null { + const account = useAuthStore( + s => s.musicNetworkAccounts.find(a => a.id === s.enrichmentPrimaryId), + ); + return useMemo(() => { + if (!account) return null; + return { + account, + label: account.label, + icon: getPreset(account.presetId)?.manifest.icon ?? 'custom', + }; + }, [account]); +} diff --git a/src/music-network/wires/audioscrobbler/AudioscrobblerWire.probe.test.ts b/src/music-network/wires/audioscrobbler/AudioscrobblerWire.probe.test.ts new file mode 100644 index 00000000..c81c49b8 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/AudioscrobblerWire.probe.test.ts @@ -0,0 +1,72 @@ +// Probe / connect-validation behaviour for the Audioscrobbler wire. +// +// Paste-auth presets (Rocksky, Maloja Audioscrobbler) have no browser flow, so +// the probe is the only place a pasted session key gets validated. It must flag +// an invalid key as scrobble:'error' (drives a connect toast) WITHOUT +// false-positiving a valid key on a scrobble-only service that rejects +// user.getInfo ("Unsupported method" is a NETWORK error, not an auth failure). +// Token-poll presets keep the unsigned enrichment probe untouched. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { audioscrobblerWire } from './AudioscrobblerWire'; +import type { WireContext } from '../../contracts/ScrobbleWire'; +import type { PersistedAccount } from '../../core/accounts'; + +const invokeMock = vi.mocked(invoke); + +function ctx(authStrategy: WireContext['authStrategy']): WireContext { + return { + account: { id: 'a1', presetId: 'maloja_compat', wireId: 'audioscrobbler_v2' } as PersistedAccount, + baseUrl: 'https://maloja.example/apis/audioscrobbler', + profileBase: '', + apiKey: '', + apiSecret: '', + sessionKey: 'SK', + username: '', + authStrategy, + }; +} + +beforeEach(() => { + invokeMock.mockReset(); +}); + +describe('probe — paste-auth (api_key_only) validation', () => { + it('flags scrobble:error when the signed validation hits an auth failure', async () => { + invokeMock.mockRejectedValue('Audioscrobbler 9 — Invalid session key'); + const caps = await audioscrobblerWire.probe(ctx('api_key_only')); + expect(caps.scrobble?.status).toBe('error'); + }); + + it('leaves scrobble optimistic when the service rejects user.getInfo (Rocksky valid key)', async () => { + // "Unsupported method" is a NETWORK error, not auth — a valid scrobble-only + // key must NOT be reported as broken. + invokeMock.mockRejectedValue('Audioscrobbler 6 — Unsupported method'); + const caps = await audioscrobblerWire.probe(ctx('api_key_only')); + expect(caps.scrobble?.status).toBe('yes'); + }); + + it('reports scrobble:yes + no enrichment when the validation succeeds', async () => { + invokeMock.mockResolvedValue({ user: { name: 'me' } }); + const caps = await audioscrobblerWire.probe(ctx('api_key_only')); + expect(caps.scrobble?.status).toBe('yes'); + expect(caps.similarArtists?.status).toBe('no'); + }); +}); + +describe('probe — token-poll (enrichment) path is unchanged', () => { + it('derives the enrichment set from an unsigned user.getInfo', async () => { + invokeMock.mockResolvedValue({ user: { name: 'me' } }); + const caps = await audioscrobblerWire.probe(ctx('token_poll')); + expect(caps.scrobble?.status).toBe('yes'); + expect(caps.similarArtists?.status).toBe('yes'); + }); + + it('degrades to enrichment-only error when user.getInfo fails, keeping scrobble', async () => { + invokeMock.mockRejectedValue(new Error('no user.getInfo here')); + const caps = await audioscrobblerWire.probe(ctx('token_poll')); + expect(caps.scrobble?.status).toBe('yes'); + expect(caps.similarArtists?.status).toBe('error'); + }); +}); diff --git a/src/music-network/wires/audioscrobbler/AudioscrobblerWire.scrobble.test.ts b/src/music-network/wires/audioscrobbler/AudioscrobblerWire.scrobble.test.ts new file mode 100644 index 00000000..977d1791 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/AudioscrobblerWire.scrobble.test.ts @@ -0,0 +1,87 @@ +// Characterizes the on-the-wire request shape for scrobble / now-playing. +// +// `track.scrobble` must use the indexed batch/array form (`artist[0]`, `track[0]`, +// …) — Last.fm/Libre.fm accept the bare single form too, but Rocksky rejects it +// (server 500). Durations are rounded to whole seconds; the millisecond timestamp +// is floored to Unix seconds. now-playing keeps the single (non-indexed) form. +// Both calls must be signed (api_sig) and POSTed (get=false). This pins the body +// so a future refactor can't silently regress Rocksky. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { audioscrobblerWire } from './AudioscrobblerWire'; +import type { WireContext } from '../../contracts/ScrobbleWire'; +import type { PersistedAccount } from '../../core/accounts'; + +const invokeMock = vi.mocked(invoke); + +function ctx(): WireContext { + return { + account: { id: 'a1', presetId: 'lastfm', wireId: 'audioscrobbler_v2' } as PersistedAccount, + baseUrl: 'https://ws.audioscrobbler.com/2.0', + profileBase: 'https://www.last.fm', + apiKey: 'k', + apiSecret: 's', + sessionKey: 'SK', + username: 'u', + }; +} + +function lastCall() { + const calls = invokeMock.mock.calls; + return calls[calls.length - 1]; +} + +/** Pull the params entries out of the most recent invoke call as a plain map. */ +function lastParams(): Record { + const arg = lastCall()?.[1] as { params: [string, string][] }; + return Object.fromEntries(arg.params); +} + +beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockResolvedValue({}); +}); + +describe('scrobble — Audioscrobbler batch/array form', () => { + it('emits indexed array keys with rounded duration and second-precision timestamp', async () => { + await audioscrobblerWire.scrobble(ctx(), { + title: 'Roygbiv', + artist: 'Boards of Canada', + album: 'Music Has the Right to Children', + duration: 137.6, + timestamp: 1_700_000_000_500, + }); + + expect(lastParams()).toEqual({ + method: 'track.scrobble', + 'track[0]': 'Roygbiv', + 'artist[0]': 'Boards of Canada', + 'album[0]': 'Music Has the Right to Children', + 'duration[0]': '138', + 'timestamp[0]': '1700000000', + sk: 'SK', + }); + }); + + it('signs the call and POSTs it (sign=true, get=false)', async () => { + await audioscrobblerWire.scrobble(ctx(), { title: 'T', artist: 'A', album: '', duration: 10, timestamp: 0 }); + const arg = lastCall()?.[1] as { sign: boolean; get: boolean }; + expect(arg.sign).toBe(true); + expect(arg.get).toBe(false); + }); +}); + +describe('updateNowPlaying — single (non-indexed) form', () => { + it('emits bare keys without array indices', async () => { + await audioscrobblerWire.updateNowPlaying(ctx(), { title: 'T', artist: 'A', album: 'Al', duration: 200.4, timestamp: 0 }); + expect(lastParams()).toEqual({ + method: 'track.updateNowPlaying', + track: 'T', + artist: 'A', + album: 'Al', + duration: '200', + sk: 'SK', + }); + }); +}); diff --git a/src/music-network/wires/audioscrobbler/AudioscrobblerWire.ts b/src/music-network/wires/audioscrobbler/AudioscrobblerWire.ts new file mode 100644 index 00000000..6251c535 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/AudioscrobblerWire.ts @@ -0,0 +1,307 @@ +// Audioscrobbler v2 wire — implements the full enrichment surface. +// +// Backs Last.fm, Libre.fm, Rocksky, custom GNU FM, and the Maloja Audioscrobbler +// compat preset. This is the only wire that implements EnrichmentWire; it is the +// behavioural successor to the legacy src/api/lastfm.ts and preserves every +// Last.fm feature (scrobble, now playing, love/unlove, loved sync, similar +// artists, track/artist stats, top lists, recent tracks, user profile, urls). + +import { + type CapabilitySet, + ENRICHMENT_CAPABILITIES, + markNoEnrichment, +} from '../../core/capabilities'; +import type { + ArtistStats, + RecentTrack, + StatsPeriod, + TopItem, + TopKind, + TrackRef, + TrackStats, + UserProfile, + WireId, +} from '../../core/types'; +import type { EnrichmentWire } from '../../contracts/EnrichmentWire'; +import type { + ConnectContext, + ConnectResult, + WireContext, +} from '../../contracts/ScrobbleWire'; +import { audioscrobblerCall } from './client'; +import { tokenPollStrategy } from './auth/tokenPoll'; +import { apiKeyOnlyStrategy } from '../shared/apiKeyOnly'; +import { MusicNetworkError } from '../../core/errors'; + +function toArray(v: T | T[] | undefined | null): T[] { + if (v == null) return []; + return Array.isArray(v) ? v : [v]; +} + +function topTags(raw: any, max = 5): string[] { + return toArray(raw).map((tg: any) => String(tg.name)).slice(0, max); +} + +class AudioscrobblerWireImpl implements EnrichmentWire { + readonly wireId: WireId = 'audioscrobbler_v2'; + readonly supportsEnrichment = true as const; + + connect(ctx: ConnectContext): Promise { + // Last.fm / Libre.fm use the browser token-poll flow; Rocksky has no + // auth.getToken and the user pastes a session key from `rocksky login`. + if (ctx.authStrategy === 'api_key_only') return apiKeyOnlyStrategy.connect(ctx); + return tokenPollStrategy.connect(ctx); + } + + disconnect(): void { + // Session teardown is store-side; nothing to revoke on the wire. + } + + async scrobble(ctx: WireContext, event: { title: string; artist: string; album: string; duration: number; timestamp: number }): Promise { + // Batch/array form (`artist[0]`, `track[0]`, …) is the documented + // Audioscrobbler track.scrobble shape. Last.fm/Libre.fm also accept the bare + // single form, but Rocksky requires the indexed array form — so we use the + // standard everywhere. + await audioscrobblerCall(ctx, { + method: 'track.scrobble', + 'track[0]': event.title, + 'artist[0]': event.artist, + 'album[0]': event.album, + 'duration[0]': String(Math.round(event.duration)), + 'timestamp[0]': String(Math.floor(event.timestamp / 1000)), + sk: ctx.sessionKey, + }, true, false); + } + + async updateNowPlaying(ctx: WireContext, event: { title: string; artist: string; album: string; duration: number }): Promise { + await audioscrobblerCall(ctx, { + method: 'track.updateNowPlaying', + track: event.title, + artist: event.artist, + album: event.album, + duration: String(Math.round(event.duration)), + sk: ctx.sessionKey, + }, true, false); + } + + /** + * Validates the session with a single user.getInfo call and derives the whole + * enrichment set from it. One call instead of probing each capability keeps + * connect cheap (performance-first); individual methods degrade gracefully at + * call time if a self-hosted GNU FM lacks a specific endpoint. + */ + async probe(ctx: WireContext): Promise { + const caps: CapabilitySet = { + scrobble: { status: 'yes' }, + nowPlaying: { status: 'yes' }, + }; + + // Paste-auth presets (Rocksky, Maloja Audioscrobbler) only had the credential + // checked for non-emptiness at connect, and they don't do enrichment — so + // validate the pasted session key here with a SIGNED call. We flip scrobble to + // 'error' ONLY on a genuine auth failure: a scrobble-only service that rejects + // user.getInfo ("Unsupported method" → NETWORK) or a transient blip is NOT + // proof of a bad key, so it leaves scrobble optimistic (no false reconnect). + if (ctx.authStrategy === 'api_key_only') { + try { + await audioscrobblerCall(ctx, { method: 'user.getInfo', user: ctx.username, sk: ctx.sessionKey }, true, false); + } catch (e) { + if (e instanceof MusicNetworkError && e.code === 'AUTH_SESSION_INVALID') { + caps.scrobble = { status: 'error', message: e.message }; + caps.nowPlaying = { status: 'error', message: e.message }; + } + } + return markNoEnrichment(caps); + } + + // Token-poll presets: the session was already validated by the browser flow. + // One unsigned user.getInfo derives the whole enrichment set; a self-hosted + // GNU FM lacking it degrades gracefully (enrichment-only error). + try { + const data = await audioscrobblerCall(ctx, { method: 'user.getInfo', user: ctx.username, sk: ctx.sessionKey }, false, true); + const ok = Boolean(data?.user); + for (const id of ENRICHMENT_CAPABILITIES) { + caps[id] = { status: ok ? 'yes' : 'error' }; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + for (const id of ENRICHMENT_CAPABILITIES) { + caps[id] = { status: 'error', message }; + } + } + return caps; + } + + async getTrackLoved(ctx: WireContext, ref: TrackRef): Promise { + try { + const data = await audioscrobblerCall(ctx, { method: 'track.getInfo', track: ref.title, artist: ref.artist, sk: ctx.sessionKey }, false, true); + return data?.track?.userloved === '1' || data?.track?.userloved === 1; + } catch { + return false; + } + } + + async loveTrack(ctx: WireContext, ref: TrackRef, loved: boolean): Promise { + await audioscrobblerCall(ctx, { + method: loved ? 'track.love' : 'track.unlove', + track: ref.title, + artist: ref.artist, + sk: ctx.sessionKey, + }, true, false); + } + + async getAllLovedTracks(ctx: WireContext): Promise { + const results: TrackRef[] = []; + let page = 1; + const limit = 200; + while (true) { + try { + const data = await audioscrobblerCall(ctx, { + method: 'user.getLovedTracks', + user: ctx.username, + sk: ctx.sessionKey, + limit: String(limit), + page: String(page), + }, false, true); + const tracks = toArray(data?.lovedtracks?.track); + if (tracks.length === 0) break; + for (const t of tracks) { + results.push({ title: t.name, artist: t.artist?.name ?? '' }); + } + const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1); + if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks + page++; + } catch { + break; + } + } + return results; + } + + async getSimilarArtists(ctx: WireContext, name: string): Promise { + try { + const data = await audioscrobblerCall(ctx, { method: 'artist.getSimilar', artist: name, limit: '50' }, false, true); + return toArray(data?.similarartists?.artist).map((a: any) => a.name as string); + } catch { + return []; + } + } + + async getTrackStats(ctx: WireContext, ref: TrackRef): Promise { + try { + const params: Record = { method: 'track.getInfo', artist: ref.artist, track: ref.title }; + if (ctx.username) params.username = ctx.username; + const data = await audioscrobblerCall(ctx, params, false, true); + const t = data?.track; + if (!t) return null; + const userPc = t.userplaycount != null ? Number(t.userplaycount) : null; + return { + listeners: Number(t.listeners) || 0, + playcount: Number(t.playcount) || 0, + userPlaycount: Number.isFinite(userPc as number) ? userPc : null, + userLoved: t.userloved === '1' || t.userloved === 1, + tags: topTags(t.toptags?.tag), + url: t.url ?? null, + }; + } catch { + return null; + } + } + + async getArtistStats(ctx: WireContext, name: string): Promise { + try { + const params: Record = { method: 'artist.getInfo', artist: name }; + if (ctx.username) params.username = ctx.username; + const data = await audioscrobblerCall(ctx, params, false, true); + const a = data?.artist; + if (!a) return null; + const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null; + const bioRaw = (a.bio?.content || a.bio?.summary || '').trim(); + return { + listeners: Number(a.stats?.listeners) || 0, + playcount: Number(a.stats?.playcount) || 0, + userPlaycount: Number.isFinite(userPc as number) ? userPc : null, + tags: topTags(a.tags?.tag), + url: a.url ?? null, + bio: bioRaw || null, + }; + } catch { + return null; + } + } + + async getUserProfile(ctx: WireContext): Promise { + try { + const data = await audioscrobblerCall(ctx, { method: 'user.getInfo', user: ctx.username, sk: ctx.sessionKey }, false, true); + const u = data?.user; + if (!u) return null; + return { + username: ctx.username, + playcount: Number(u.playcount) || 0, + registeredAt: Number(u.registered?.unixtime ?? 0), + }; + } catch { + return null; + } + } + + async getTopItems(ctx: WireContext, period: StatsPeriod, kind: TopKind, limit: number): Promise { + const method = kind === 'artists' ? 'user.getTopArtists' : kind === 'albums' ? 'user.getTopAlbums' : 'user.getTopTracks'; + const collection = kind === 'artists' ? 'topartists' : kind === 'albums' ? 'topalbums' : 'toptracks'; + const node = kind === 'artists' ? 'artist' : kind === 'albums' ? 'album' : 'track'; + try { + const data = await audioscrobblerCall(ctx, { + method, + user: ctx.username, + sk: ctx.sessionKey, + period, + limit: String(limit), + }, false, true); + return toArray(data?.[collection]?.[node]).map((it: any) => ({ + name: it.name, + playcount: it.playcount, + ...(kind === 'artists' ? {} : { artist: it.artist?.name ?? '' }), + })); + } catch { + return []; + } + } + + async getRecentTracks(ctx: WireContext, limit: number): Promise { + try { + const data = await audioscrobblerCall(ctx, { + method: 'user.getRecentTracks', + user: ctx.username, + sk: ctx.sessionKey, + limit: String(limit), + }, false, true); + return toArray(data?.recenttracks?.track).map((t: any) => ({ + name: t.name, + artist: t.artist?.['#text'] ?? t.artist?.name ?? '', + album: t.album?.['#text'] ?? '', + timestamp: t.date?.uts ? Number(t.date.uts) : null, + nowPlaying: t['@attr']?.nowplaying === 'true', + })); + } catch { + return []; + } + } + + buildProfileUrl(ctx: WireContext): string { + if (!ctx.profileBase || !ctx.username) return ''; + return `${ctx.profileBase}/user/${encodeURIComponent(ctx.username)}`; + } + + buildArtistUrl(ctx: WireContext, name: string): string { + if (!ctx.profileBase) return ''; + return `${ctx.profileBase}/music/${encodeURIComponent(name)}`; + } + + buildTrackUrl(ctx: WireContext, ref: TrackRef): string { + if (!ctx.profileBase) return ''; + return `${ctx.profileBase}/music/${encodeURIComponent(ref.artist)}/_/${encodeURIComponent(ref.title)}`; + } +} + +/** Singleton wire instance (wires are stateless; context carries all state). */ +export const audioscrobblerWire: EnrichmentWire = new AudioscrobblerWireImpl(); diff --git a/src/music-network/wires/audioscrobbler/auth/tokenPoll.ts b/src/music-network/wires/audioscrobbler/auth/tokenPoll.ts new file mode 100644 index 00000000..c9e46366 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/auth/tokenPoll.ts @@ -0,0 +1,76 @@ +// Audioscrobbler v2 — token-poll connect flow (Last.fm / GNU FM family). +// +// 1. Request a token (auth.getToken). +// 2. Open the provider's auth page so the user authorizes the token. +// 3. Poll auth.getSession until a session key is granted, or time out. +// +// Mirrors the legacy IntegrationsTab connect flow, but as a reusable strategy. + +import { MusicNetworkError } from '../../../core/errors'; +import type { AuthStrategy } from '../../../contracts/AuthStrategy'; +import type { ConnectContext, ConnectResult } from '../../../contracts/ScrobbleWire'; +import { audioscrobblerCall } from '../client'; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 120_000; + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(t); + reject(new MusicNetworkError('AUTH_TIMEOUT', 'Connect cancelled')); + }, { once: true }); + }); +} + +async function getToken(ctx: ConnectContext): Promise { + const data = await audioscrobblerCall(ctx, { method: 'auth.getToken' }, false, true); + const token = data?.token as string | undefined; + if (!token) throw new MusicNetworkError('NETWORK', 'No token returned'); + return token; +} + +function authUrl(authBase: string, apiKey: string, token: string): string { + // authBase example: https://www.last.fm/api/auth/ + const sep = authBase.includes('?') ? '&' : '?'; + return `${authBase}${sep}api_key=${apiKey}&token=${token}`; +} + +async function getSession( + ctx: ConnectContext, + token: string, +): Promise<{ key: string; name: string } | null> { + try { + const data = await audioscrobblerCall(ctx, { method: 'auth.getSession', token }, true, false); + const key = data?.session?.key as string | undefined; + const name = data?.session?.name as string | undefined; + if (key && name) return { key, name }; + return null; + } catch (e) { + // Pre-authorization the provider returns auth errors; keep polling until timeout. + if (e instanceof MusicNetworkError && e.code === 'AUTH_SESSION_INVALID') return null; + return null; + } +} + +export const tokenPollStrategy: AuthStrategy = { + id: 'token_poll', + + async connect(ctx: ConnectContext): Promise { + const token = await getToken(ctx); + await ctx.openExternal(authUrl(ctx.authBase, ctx.apiKey, token)); + + const deadline = POLL_TIMEOUT_MS; + let waited = 0; + while (waited < deadline) { + await delay(POLL_INTERVAL_MS, ctx.signal); + waited += POLL_INTERVAL_MS; + const session = await getSession(ctx, token); + if (session) { + return { sessionKey: session.key, username: session.name }; + } + } + throw new MusicNetworkError('AUTH_TIMEOUT', 'Authorization timed out'); + }, +}; diff --git a/src/music-network/wires/audioscrobbler/client.test.ts b/src/music-network/wires/audioscrobbler/client.test.ts new file mode 100644 index 00000000..25a8c26d --- /dev/null +++ b/src/music-network/wires/audioscrobbler/client.test.ts @@ -0,0 +1,81 @@ +// Error-classification characterization for the Audioscrobbler transport client. +// +// The numeric error codes collide across providers (Last.fm code 4 = auth +// failure, but Rocksky code 4 = a server-side 500). The classifier must therefore +// flip to AUTH_SESSION_INVALID only on the unambiguous Last.fm session codes +// (9/14) or an auth-shaped message — and treat everything else, including a +// Rocksky 500, as a plain NETWORK error so the account is not bounced to a +// reconnect state. These tests pin that boundary. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { audioscrobblerCall, type AudioscrobblerEndpoint } from './client'; + +const invokeMock = vi.mocked(invoke); + +const EP: AudioscrobblerEndpoint = { + baseUrl: 'https://ws.audioscrobbler.com/2.0', + apiKey: 'k', + apiSecret: 's', +}; + +beforeEach(() => { + invokeMock.mockReset(); +}); + +describe('audioscrobblerCall — success path', () => { + it('returns the invoke result and forwards params as entries + signing flags', async () => { + invokeMock.mockResolvedValue({ ok: true }); + const out = await audioscrobblerCall(EP, { method: 'track.scrobble', sk: 'x' }, true, false); + + expect(out).toEqual({ ok: true }); + expect(invokeMock).toHaveBeenCalledWith('audioscrobbler_request', { + baseUrl: EP.baseUrl, + params: [['method', 'track.scrobble'], ['sk', 'x']], + sign: true, + get: false, + apiKey: 'k', + apiSecret: 's', + }); + }); +}); + +describe('audioscrobblerCall — session-invalid classification (signed calls)', () => { + it.each([ + ['Audioscrobbler 9 — Invalid session key', 'numeric code 9'], + ['Audioscrobbler 14 — token has not been authorized', 'numeric code 14'], + ['Authentication Failed: nope', 'auth-shaped message'], + ['invalid session supplied', 'invalid-session message'], + ['invalid token', 'invalid-token message'], + ])('flips AUTH_SESSION_INVALID on %s (%s)', async (msg) => { + invokeMock.mockRejectedValue(msg); + await expect(audioscrobblerCall(EP, { method: 'track.scrobble', sk: 'x' }, true)).rejects.toMatchObject({ + code: 'AUTH_SESSION_INVALID', + }); + }); +}); + +describe('audioscrobblerCall — NETWORK classification (collision guards)', () => { + it('treats a code-4 (Rocksky 500 collision) as NETWORK, not session-invalid', async () => { + // Rocksky returns code 4 ("Failed to parse scrobbles") as a 500 — must NOT + // bounce the account to reconnect. + invokeMock.mockRejectedValue('Audioscrobbler 4 — Failed to parse scrobbles'); + await expect(audioscrobblerCall(EP, { method: 'track.scrobble', sk: 'x' }, true)).rejects.toMatchObject({ + code: 'NETWORK', + }); + }); + + it('treats a generic server/network failure as NETWORK', async () => { + invokeMock.mockRejectedValue(new Error('500 Internal Server Error')); + await expect(audioscrobblerCall(EP, { method: 'user.getInfo' }, false, true)).rejects.toMatchObject({ + code: 'NETWORK', + }); + }); + + it('never classifies an unsigned call as session-invalid, even on an auth-shaped message', async () => { + invokeMock.mockRejectedValue('Authentication Failed'); + await expect(audioscrobblerCall(EP, { method: 'track.getInfo' }, false)).rejects.toMatchObject({ + code: 'NETWORK', + }); + }); +}); diff --git a/src/music-network/wires/audioscrobbler/client.ts b/src/music-network/wires/audioscrobbler/client.ts new file mode 100644 index 00000000..fb5823d8 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/client.ts @@ -0,0 +1,54 @@ +// Audioscrobbler v2 — transport client. +// +// Thin wrapper over the Rust `audioscrobbler_request` command. Classifies +// failures into MusicNetworkError (auth-session-invalid vs network) but does NOT +// touch any store — session-error state is owned by the runtime, which clears it +// on a successful signed call and sets it on AUTH_SESSION_INVALID. + +import { invokeTransport } from '../shared/invokeTransport'; + +export interface AudioscrobblerEndpoint { + baseUrl: string; + apiKey: string; + apiSecret: string; +} + +// Auth/session detection. The generic transport prefixes Audioscrobbler errors +// with "Audioscrobbler ". Real auth failures are matched by +// MESSAGE, not by numeric code: the codes collide across providers (Last.fm +// code 4 = "Authentication Failed", but Rocksky code 4 = a server-side "Failed +// to parse scrobbles" / 500 that must NOT flip the account to a reconnect +// state). Codes 9/14 are Last.fm/GNU FM session-key/token failures with no +// ambiguous message. +const SESSION_INVALID_CODE = /^Audioscrobbler (9|14)\b/; +const SESSION_INVALID_MESSAGE = /authentication failed|invalid (session|token)/i; + +/** + * Calls the Audioscrobbler endpoint. `sign` adds an api_sig; `get` uses GET + * instead of a form POST. Throws MusicNetworkError on failure. + */ +export async function audioscrobblerCall( + ep: AudioscrobblerEndpoint, + params: Record, + sign = false, + get = false, +): Promise { + const entries = Object.entries(params) as [string, string][]; + return invokeTransport( + 'audioscrobbler_request', + { + baseUrl: ep.baseUrl, + params: entries, + sign, + get, + apiKey: ep.apiKey, + apiSecret: ep.apiSecret, + }, + { + // Only signed calls carry a session key, so an unsigned failure is never + // an auth-session problem. + match: msg => sign && (SESSION_INVALID_CODE.test(msg) || SESSION_INVALID_MESSAGE.test(msg)), + code: 'AUTH_SESSION_INVALID', + }, + ); +} diff --git a/src/music-network/wires/audioscrobbler/presets/customGnufm.ts b/src/music-network/wires/audioscrobbler/presets/customGnufm.ts new file mode 100644 index 00000000..5e34577e --- /dev/null +++ b/src/music-network/wires/audioscrobbler/presets/customGnufm.ts @@ -0,0 +1,53 @@ +// Custom GNU FM — user-hosted Audioscrobbler v2 / GNU FM instance. +// +// The user supplies their own origin plus API key and secret. GNU FM implements +// the Last.fm 2.0 API, so it reuses the Audioscrobbler wire with the token-poll +// flow (authBase derived from the origin by the runtime). Enrichment eligibility +// is left on but the connect probe decides what the instance actually supports; +// the UI degrades gracefully if enrichment is unavailable. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const manifest: PresetManifest = { + presetId: 'custom_gnufm', + wireId: 'audioscrobbler_v2', + displayName: 'Custom GNU FM', + descriptionKey: 'musicNetwork.presets.customGnufm.desc', + icon: 'custom', + category: 'custom', + selfHostedApiSuffix: '/2.0/', + credentials: 'user_full', + defaultRoles: { + scrobble: true, + enrichmentEligible: true, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: true, + }, + authStrategy: 'token_poll', + fields: [ + { + name: 'baseUrl', + labelKey: 'musicNetwork.fields.gnufmUrl', + type: 'url', + required: true, + placeholder: 'https://gnufm.example.com', + }, + { + name: 'apiKey', + labelKey: 'musicNetwork.fields.apiKey', + type: 'text', + required: true, + }, + { + name: 'apiSecret', + labelKey: 'musicNetwork.fields.apiSecret', + type: 'password', + required: true, + }, + ], +}; + +export const customGnufmPreset: BuiltinPreset = { manifest }; diff --git a/src/music-network/wires/audioscrobbler/presets/lastfm.ts b/src/music-network/wires/audioscrobbler/presets/lastfm.ts new file mode 100644 index 00000000..f615c53c --- /dev/null +++ b/src/music-network/wires/audioscrobbler/presets/lastfm.ts @@ -0,0 +1,41 @@ +// Last.fm — default Audioscrobbler v2 preset (full enrichment, bundled keys). +// +// These are the same application credentials the legacy src/api/lastfm.ts used; +// they move here so the bundled key is owned by the preset, not a loose module +// constant. Last.fm is the parity baseline and the default enrichment primary. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const LASTFM_API_KEY = '9917fb39049225a13bec225ad6d49054'; +const LASTFM_API_SECRET = '03817dda02bee87a178aab7581abae3b'; + +const manifest: PresetManifest = { + presetId: 'lastfm', + wireId: 'audioscrobbler_v2', + displayName: 'Last.fm', + descriptionKey: 'musicNetwork.presets.lastfm.desc', + icon: 'lastfm', + category: 'public_audioscrobbler', + endpoints: { + apiBase: 'https://ws.audioscrobbler.com/2.0/', + authBase: 'https://www.last.fm/api/auth/', + profileBase: 'https://www.last.fm', + }, + credentials: 'bundled', + defaultRoles: { + scrobble: true, + enrichmentEligible: true, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: true, + }, + authStrategy: 'token_poll', + fields: [], +}; + +export const lastfmPreset: BuiltinPreset = { + manifest, + bundled: { apiKey: LASTFM_API_KEY, apiSecret: LASTFM_API_SECRET }, +}; diff --git a/src/music-network/wires/audioscrobbler/presets/librefm.ts b/src/music-network/wires/audioscrobbler/presets/librefm.ts new file mode 100644 index 00000000..4c024521 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/presets/librefm.ts @@ -0,0 +1,42 @@ +// Libre.fm — Audioscrobbler v2 preset (full enrichment, bundled keys). +// +// Libre.fm implements the Last.fm 2.0 web API ("we implement the Last.fm API"), +// so it reuses the Audioscrobbler wire unchanged — only endpoints differ. The +// GNU FM family requires the trailing slash on apiBase. Bundled key/secret are +// the app's registered Libre.fm credentials; auth.getToken is verified live. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const LIBREFM_API_KEY = 'cbec96011b3ff276f45111d74121e690'; +const LIBREFM_API_SECRET = 'ef60e4ba036e22d912526c488854bd6a'; + +const manifest: PresetManifest = { + presetId: 'librefm', + wireId: 'audioscrobbler_v2', + displayName: 'Libre.fm', + descriptionKey: 'musicNetwork.presets.librefm.desc', + icon: 'librefm', + category: 'public_audioscrobbler', + endpoints: { + apiBase: 'https://libre.fm/2.0/', + authBase: 'https://libre.fm/api/auth/', + profileBase: 'https://libre.fm', + }, + credentials: 'bundled', + defaultRoles: { + scrobble: true, + enrichmentEligible: true, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: true, + }, + authStrategy: 'token_poll', + fields: [], +}; + +export const librefmPreset: BuiltinPreset = { + manifest, + bundled: { apiKey: LIBREFM_API_KEY, apiSecret: LIBREFM_API_SECRET }, +}; diff --git a/src/music-network/wires/audioscrobbler/presets/malojaCompat.ts b/src/music-network/wires/audioscrobbler/presets/malojaCompat.ts new file mode 100644 index 00000000..7fee3d3c --- /dev/null +++ b/src/music-network/wires/audioscrobbler/presets/malojaCompat.ts @@ -0,0 +1,50 @@ +// Maloja (Audioscrobbler) — {url}/apis/audioscrobbler, scrobble destination only. +// +// Maloja's /apis/audioscrobbler is its GNU FM / Last.fm 2.0-compatible endpoint +// (the older Audioscrobbler 1.2 submission protocol lives separately at +// /apis/audioscrobbler_legacy). So this preset reuses the Audioscrobbler v2 wire +// pointed at the user's Maloja origin + the compat suffix, with the Maloja API +// key pasted as the session key ("any API key as the password"). Maloja does not +// expose now-playing here, so nowPlaying is statically false. The two other +// Maloja modes (native JSON, ListenBrainz) are usually the simpler choice — this +// covers setups that only enable the Audioscrobbler surface. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const manifest: PresetManifest = { + presetId: 'maloja_compat', + wireId: 'audioscrobbler_v2', + displayName: 'Maloja (Audioscrobbler API)', + descriptionKey: 'musicNetwork.presets.malojaCompat.desc', + icon: 'maloja', + category: 'self_hosted', + selfHostedApiSuffix: '/apis/audioscrobbler', + credentials: 'user_api_key', + defaultRoles: { + scrobble: true, + enrichmentEligible: false, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: false, + }, + authStrategy: 'api_key_only', + fields: [ + { + name: 'baseUrl', + labelKey: 'musicNetwork.fields.malojaUrl', + type: 'url', + required: true, + placeholder: 'https://maloja.example.com', + }, + { + name: 'token', + labelKey: 'musicNetwork.fields.malojaKey', + type: 'password', + required: true, + }, + ], +}; + +export const malojaCompatPreset: BuiltinPreset = { manifest }; diff --git a/src/music-network/wires/audioscrobbler/presets/rocksky.ts b/src/music-network/wires/audioscrobbler/presets/rocksky.ts new file mode 100644 index 00000000..21c5c291 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/presets/rocksky.ts @@ -0,0 +1,51 @@ +// Rocksky — Audioscrobbler v2 endpoint, scrobble destination only. +// +// Verified against the live API: /2.0 (no trailing slash) accepts POST +// track.scrobble with api_key + sk + MD5 api_sig, but rejects every other method +// ("Unsupported method") and has no auth.getToken. So Rocksky reuses the +// Audioscrobbler wire for scrobbling only, with a pasted session key (obtained +// via `rocksky login`) instead of the browser token-poll flow. No now-playing, +// no enrichment. Bundled app key/secret sign the requests. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const ROCKSKY_API_KEY = 'aa590b1f5e656bfbc658708206181b33'; +const ROCKSKY_API_SECRET = '2299bf8cf402c0180381eca5a20d47dc'; + +const manifest: PresetManifest = { + presetId: 'rocksky', + wireId: 'audioscrobbler_v2', + displayName: 'Rocksky', + descriptionKey: 'musicNetwork.presets.rocksky.desc', + icon: 'rocksky', + category: 'public_audioscrobbler', + endpoints: { + apiBase: 'https://audioscrobbler.rocksky.app/2.0', + profileBase: 'https://rocksky.app', + }, + credentials: 'bundled', + defaultRoles: { + scrobble: true, + enrichmentEligible: false, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: false, + }, + authStrategy: 'api_key_only', + fields: [ + { + name: 'token', + labelKey: 'musicNetwork.fields.rockskySessionKey', + helpKey: 'musicNetwork.fields.rockskySessionKeyHelp', + type: 'password', + required: true, + }, + ], +}; + +export const rockskyPreset: BuiltinPreset = { + manifest, + bundled: { apiKey: ROCKSKY_API_KEY, apiSecret: ROCKSKY_API_SECRET }, +}; diff --git a/src/music-network/wires/audioscrobbler/sign.test.ts b/src/music-network/wires/audioscrobbler/sign.test.ts new file mode 100644 index 00000000..aad2b723 --- /dev/null +++ b/src/music-network/wires/audioscrobbler/sign.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { buildSignatureBaseString } from './sign'; + +describe('buildSignatureBaseString', () => { + it('sorts params alphabetically and concatenates key+value with api_key', () => { + const sig = buildSignatureBaseString( + { method: 'track.scrobble', track: 'Y', artist: 'X' }, + 'KEY', + ); + // sorted: api_key, artist, method, track + expect(sig).toBe('api_keyKEYartistXmethodtrack.scrobbletrackY'); + }); + + it('excludes format and callback from the signature', () => { + const withMeta = buildSignatureBaseString( + { method: 'auth.getSession', token: 'T', format: 'json', callback: 'cb' }, + 'KEY', + ); + const withoutMeta = buildSignatureBaseString( + { method: 'auth.getSession', token: 'T' }, + 'KEY', + ); + expect(withMeta).toBe(withoutMeta); + expect(withMeta).not.toContain('json'); + }); + + it('is deterministic regardless of input key order', () => { + const a = buildSignatureBaseString({ b: '2', a: '1', c: '3' }, 'K'); + const b = buildSignatureBaseString({ c: '3', a: '1', b: '2' }, 'K'); + expect(a).toBe(b); + }); +}); diff --git a/src/music-network/wires/audioscrobbler/sign.ts b/src/music-network/wires/audioscrobbler/sign.ts new file mode 100644 index 00000000..1c5e7cff --- /dev/null +++ b/src/music-network/wires/audioscrobbler/sign.ts @@ -0,0 +1,22 @@ +// Audioscrobbler v2 — signature base-string construction. +// +// The actual api_sig (MD5 of base-string + secret) is computed in Rust. This is +// a TS mirror of the *ordering rule* — sorted params, `format`/`callback` +// excluded, key+value concatenated — so the fragile part stays unit-testable +// without pulling an MD5 dependency into the frontend. + +/** + * Builds the Audioscrobbler signature base string from request params plus the + * api_key, exactly as the Rust transport does before appending the secret. + */ +export function buildSignatureBaseString( + params: Record, + apiKey: string, +): string { + const all: Record = { ...params, api_key: apiKey }; + return Object.keys(all) + .filter(k => k !== 'format' && k !== 'callback') + .sort() + .map(k => `${k}${all[k]}`) + .join(''); +} diff --git a/src/music-network/wires/listenbrainz/ListenBrainzWire.ts b/src/music-network/wires/listenbrainz/ListenBrainzWire.ts new file mode 100644 index 00000000..461b53bb --- /dev/null +++ b/src/music-network/wires/listenbrainz/ListenBrainzWire.ts @@ -0,0 +1,80 @@ +// ListenBrainz wire — scrobble + now playing only (no enrichment in v1). +// +// One implementation backs two presets: the direct api.listenbrainz.org host +// and the Maloja /apis/listenbrainz compat surface; they differ only by +// baseUrl. Auth is a pasted user token (api_key_only). Read/stats APIs are out +// of scope for v1 (spec §2.3), so this is a plain ScrobbleWire. + +import { type CapabilitySet, markNoEnrichment } from '../../core/capabilities'; +import type { ScrobbleEvent, WireId } from '../../core/types'; +import type { + ConnectContext, + ConnectResult, + ScrobbleWire, + WireContext, +} from '../../contracts/ScrobbleWire'; +import { apiKeyOnlyStrategy } from '../shared/apiKeyOnly'; +import { listenBrainzCall } from './client'; + +function trackMetadata(event: ScrobbleEvent) { + const md: Record = { + artist_name: event.artist, + track_name: event.title, + }; + if (event.album) md.release_name = event.album; + if (event.duration) { + md.additional_info = { duration_ms: Math.round(event.duration * 1000) }; + } + return md; +} + +function endpoint(ctx: WireContext) { + return { baseUrl: ctx.baseUrl, authToken: ctx.sessionKey }; +} + +class ListenBrainzWireImpl implements ScrobbleWire { + readonly wireId: WireId = 'listenbrainz'; + readonly supportsEnrichment = false; + + connect(ctx: ConnectContext): Promise { + return apiKeyOnlyStrategy.connect(ctx); + } + + disconnect(): void { + // Token is store-side; nothing to revoke remotely. + } + + async scrobble(ctx: WireContext, event: ScrobbleEvent): Promise { + await listenBrainzCall(endpoint(ctx), '/1/submit-listens', { + listen_type: 'single', + payload: [{ + listened_at: Math.floor(event.timestamp / 1000), + track_metadata: trackMetadata(event), + }], + }); + } + + async updateNowPlaying(ctx: WireContext, event: ScrobbleEvent): Promise { + await listenBrainzCall(endpoint(ctx), '/1/submit-listens', { + listen_type: 'playing_now', + payload: [{ track_metadata: trackMetadata(event) }], + }); + } + + async probe(ctx: WireContext): Promise { + const caps: CapabilitySet = {}; + try { + const data = await listenBrainzCall(endpoint(ctx), '/1/validate-token'); + const valid = data?.valid === true; + caps.scrobble = { status: valid ? 'yes' : 'error', message: valid ? undefined : 'Token invalid' }; + caps.nowPlaying = { status: valid ? 'yes' : 'error' }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + caps.scrobble = { status: 'error', message }; + caps.nowPlaying = { status: 'error', message }; + } + return markNoEnrichment(caps); + } +} + +export const listenBrainzWire: ScrobbleWire = new ListenBrainzWireImpl(); diff --git a/src/music-network/wires/listenbrainz/client.ts b/src/music-network/wires/listenbrainz/client.ts new file mode 100644 index 00000000..3647e37a --- /dev/null +++ b/src/music-network/wires/listenbrainz/client.ts @@ -0,0 +1,33 @@ +// ListenBrainz — transport client. +// +// Thin wrapper over the Rust `listenbrainz_request` command. Same wire serves +// the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz +// compat surface — only baseUrl differs. Classifies failures into +// MusicNetworkError; no store access (runtime owns session-error state). + +import { invokeTransport } from '../shared/invokeTransport'; + +export interface ListenBrainzEndpoint { + baseUrl: string; + authToken: string; +} + +// listenbrainz_request returns "ListenBrainz " on non-2xx. +const INVALID_TOKEN = /^ListenBrainz 401\b/; + +export async function listenBrainzCall( + ep: ListenBrainzEndpoint, + path: string, + jsonBody?: unknown, +): Promise { + return invokeTransport( + 'listenbrainz_request', + { + baseUrl: ep.baseUrl, + path, + authToken: ep.authToken, + jsonBody: jsonBody ?? null, + }, + { match: msg => INVALID_TOKEN.test(msg), code: 'AUTH_SESSION_INVALID' }, + ); +} diff --git a/src/music-network/wires/listenbrainz/presets/koito.ts b/src/music-network/wires/listenbrainz/presets/koito.ts new file mode 100644 index 00000000..f3892541 --- /dev/null +++ b/src/music-network/wires/listenbrainz/presets/koito.ts @@ -0,0 +1,48 @@ +// Koito — {url}/apis/listenbrainz, scrobble destination only. +// +// Koito is a self-hosted listening-history tracker that exposes a +// ListenBrainz-compatible submit API at {origin}/apis/listenbrainz, with an +// API key (generated in Koito's UI) used as the listen token. So it reuses the +// ListenBrainz wire — same protocol as the direct ListenBrainz and Maloja +// ListenBrainz presets, differing only by base URL and the pasted token. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const manifest: PresetManifest = { + presetId: 'koito', + wireId: 'listenbrainz', + displayName: 'Koito', + descriptionKey: 'musicNetwork.presets.koito.desc', + icon: 'koito', + category: 'self_hosted', + selfHostedApiSuffix: '/apis/listenbrainz', + credentials: 'user_api_key', + defaultRoles: { + scrobble: true, + enrichmentEligible: false, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: true, + }, + authStrategy: 'api_key_only', + fields: [ + { + name: 'baseUrl', + labelKey: 'musicNetwork.fields.koitoUrl', + type: 'url', + required: true, + placeholder: 'https://koito.example.com', + }, + { + name: 'token', + labelKey: 'musicNetwork.fields.koitoToken', + helpKey: 'musicNetwork.fields.koitoTokenHelp', + type: 'password', + required: true, + }, + ], +}; + +export const koitoPreset: BuiltinPreset = { manifest }; diff --git a/src/music-network/wires/listenbrainz/presets/listenbrainz.ts b/src/music-network/wires/listenbrainz/presets/listenbrainz.ts new file mode 100644 index 00000000..c471a931 --- /dev/null +++ b/src/music-network/wires/listenbrainz/presets/listenbrainz.ts @@ -0,0 +1,40 @@ +// ListenBrainz (direct) — api.listenbrainz.org, scrobble destination only. +// +// User pastes a token from listenbrainz.org/profile. Scrobble + now playing; +// no enrichment in v1 (spec §2.3). + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const manifest: PresetManifest = { + presetId: 'listenbrainz', + wireId: 'listenbrainz', + displayName: 'ListenBrainz', + descriptionKey: 'musicNetwork.presets.listenbrainz.desc', + icon: 'listenbrainz', + category: 'public_listenbrainz', + endpoints: { + apiBase: 'https://api.listenbrainz.org', + profileBase: 'https://listenbrainz.org', + }, + credentials: 'user_api_key', + defaultRoles: { + scrobble: true, + enrichmentEligible: false, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: true, + }, + authStrategy: 'api_key_only', + fields: [ + { + name: 'token', + labelKey: 'musicNetwork.fields.lbToken', + type: 'password', + required: true, + }, + ], +}; + +export const listenbrainzPreset: BuiltinPreset = { manifest }; diff --git a/src/music-network/wires/listenbrainz/presets/malojaListenbrainz.ts b/src/music-network/wires/listenbrainz/presets/malojaListenbrainz.ts new file mode 100644 index 00000000..6f291f98 --- /dev/null +++ b/src/music-network/wires/listenbrainz/presets/malojaListenbrainz.ts @@ -0,0 +1,44 @@ +// Maloja ListenBrainz compat — {url}/apis/listenbrainz, scrobble destination only. +// +// Same ListenBrainz wire as the direct preset; only the base URL differs (the +// user's Maloja origin plus the compat suffix). Auth token is the Maloja API key. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const manifest: PresetManifest = { + presetId: 'maloja_listenbrainz', + wireId: 'listenbrainz', + displayName: 'Maloja (ListenBrainz API)', + descriptionKey: 'musicNetwork.presets.malojaListenbrainz.desc', + icon: 'maloja', + category: 'self_hosted', + selfHostedApiSuffix: '/apis/listenbrainz', + credentials: 'user_api_key', + defaultRoles: { + scrobble: true, + enrichmentEligible: false, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: true, + }, + authStrategy: 'api_key_only', + fields: [ + { + name: 'baseUrl', + labelKey: 'musicNetwork.fields.malojaUrl', + type: 'url', + required: true, + placeholder: 'https://maloja.example.com', + }, + { + name: 'token', + labelKey: 'musicNetwork.fields.malojaKey', + type: 'password', + required: true, + }, + ], +}; + +export const malojaListenbrainzPreset: BuiltinPreset = { manifest }; diff --git a/src/music-network/wires/maloja/MalojaNativeWire.ts b/src/music-network/wires/maloja/MalojaNativeWire.ts new file mode 100644 index 00000000..8ac10b9f --- /dev/null +++ b/src/music-network/wires/maloja/MalojaNativeWire.ts @@ -0,0 +1,58 @@ +// Maloja native wire — scrobble only. +// +// Posts to /apis/mlj_1/newscrobble with the flat JSON body documented by Maloja +// (artists[], title, album, length, time; key carried in the body). Maloja has +// no now-playing endpoint, so updateNowPlaying is a safe no-op and the nowPlaying +// capability is reported as `no`. Auth is the pasted Maloja API key. + +import { type CapabilitySet, markNoEnrichment } from '../../core/capabilities'; +import type { ScrobbleEvent, WireId } from '../../core/types'; +import type { + ConnectContext, + ConnectResult, + ScrobbleWire, + WireContext, +} from '../../contracts/ScrobbleWire'; +import { apiKeyOnlyStrategy } from '../shared/apiKeyOnly'; +import { malojaCall } from './client'; + +class MalojaNativeWireImpl implements ScrobbleWire { + readonly wireId: WireId = 'maloja_native'; + readonly supportsEnrichment = false; + + connect(ctx: ConnectContext): Promise { + return apiKeyOnlyStrategy.connect(ctx); + } + + disconnect(): void { + // API key is store-side; nothing to revoke remotely. + } + + async scrobble(ctx: WireContext, event: ScrobbleEvent): Promise { + const body: Record = { + key: ctx.sessionKey, + artists: [event.artist], + title: event.title, + time: Math.floor(event.timestamp / 1000), + }; + if (event.album) body.album = event.album; + if (event.duration) body.length = Math.round(event.duration); + await malojaCall({ baseUrl: ctx.baseUrl }, '/apis/mlj_1/newscrobble', body); + } + + async updateNowPlaying(): Promise { + // Maloja has no now-playing endpoint — intentional no-op. + } + + async probe(): Promise { + // Maloja exposes no token-validation endpoint; trust the key and surface + // errors on the first scrobble. Scrobble is static-yes, everything else no. + const caps: CapabilitySet = { + scrobble: { status: 'yes' }, + nowPlaying: { status: 'no' }, + }; + return markNoEnrichment(caps); + } +} + +export const malojaNativeWire: ScrobbleWire = new MalojaNativeWireImpl(); diff --git a/src/music-network/wires/maloja/client.ts b/src/music-network/wires/maloja/client.ts new file mode 100644 index 00000000..b12b42fb --- /dev/null +++ b/src/music-network/wires/maloja/client.ts @@ -0,0 +1,31 @@ +// Maloja native — transport client. +// +// Thin wrapper over the Rust `maloja_request` command (native /apis/mlj_1 JSON). +// Classifies failures into MusicNetworkError; no store access. + +import { invokeTransport } from '../shared/invokeTransport'; + +export interface MalojaEndpoint { + baseUrl: string; +} + +// maloja_request returns "Maloja " on non-2xx. +const BAD_KEY = /^Maloja (401|403)\b/; + +export async function malojaCall( + ep: MalojaEndpoint, + path: string, + jsonBody?: unknown, + query: [string, string][] = [], +): Promise { + return invokeTransport( + 'maloja_request', + { + baseUrl: ep.baseUrl, + path, + query, + jsonBody: jsonBody ?? null, + }, + { match: msg => BAD_KEY.test(msg), code: 'MALOJA_BAD_KEY' }, + ); +} diff --git a/src/music-network/wires/maloja/presets/malojaNative.ts b/src/music-network/wires/maloja/presets/malojaNative.ts new file mode 100644 index 00000000..250aa80e --- /dev/null +++ b/src/music-network/wires/maloja/presets/malojaNative.ts @@ -0,0 +1,44 @@ +// Maloja native — {url}/apis/mlj_1, scrobble destination only. +// +// Posts the flat newscrobble JSON. No now-playing endpoint exists, so nowPlaying +// is statically false. baseUrl is the user's Maloja origin (the wire appends the +// /apis/mlj_1 method path). Auth key is the pasted Maloja API key. + +import type { PresetManifest } from '../../../contracts/PresetManifest'; +import type { BuiltinPreset } from '../../../registry/presetTypes'; + +const manifest: PresetManifest = { + presetId: 'maloja_native', + wireId: 'maloja_native', + displayName: 'Maloja', + descriptionKey: 'musicNetwork.presets.malojaNative.desc', + icon: 'maloja', + category: 'self_hosted', + credentials: 'user_api_key', + defaultRoles: { + scrobble: true, + enrichmentEligible: false, + }, + staticCapabilities: { + scrobble: true, + nowPlaying: false, + }, + authStrategy: 'api_key_only', + fields: [ + { + name: 'baseUrl', + labelKey: 'musicNetwork.fields.malojaUrl', + type: 'url', + required: true, + placeholder: 'https://maloja.example.com', + }, + { + name: 'token', + labelKey: 'musicNetwork.fields.malojaKey', + type: 'password', + required: true, + }, + ], +}; + +export const malojaNativePreset: BuiltinPreset = { manifest }; diff --git a/src/music-network/wires/shared/apiKeyOnly.test.ts b/src/music-network/wires/shared/apiKeyOnly.test.ts new file mode 100644 index 00000000..6fcf2418 --- /dev/null +++ b/src/music-network/wires/shared/apiKeyOnly.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { apiKeyOnlyStrategy } from './apiKeyOnly'; +import { MusicNetworkError } from '../../core/errors'; +import type { ConnectContext } from '../../contracts/ScrobbleWire'; + +function ctx(fields: Record, baseUrl = ''): ConnectContext { + return { + presetId: 'listenbrainz', + wireId: 'listenbrainz', + authStrategy: 'api_key_only', + baseUrl, + authBase: '', + apiKey: '', + apiSecret: '', + fields, + openExternal: async () => {}, + }; +} + +describe('apiKeyOnlyStrategy', () => { + it('maps the pasted token to the session key', async () => { + const res = await apiKeyOnlyStrategy.connect(ctx({ token: ' abc-123 ', username: ' me ' })); + expect(res.sessionKey).toBe('abc-123'); + expect(res.username).toBe('me'); + }); + + it('throws AUTH_SESSION_INVALID when no token is given', async () => { + await expect(apiKeyOnlyStrategy.connect(ctx({ token: ' ' }))).rejects.toMatchObject({ + code: 'AUTH_SESSION_INVALID', + }); + await expect(apiKeyOnlyStrategy.connect(ctx({}))).rejects.toBeInstanceOf(MusicNetworkError); + }); + + it('prefers a field baseUrl over the context baseUrl', async () => { + const res = await apiKeyOnlyStrategy.connect( + ctx({ token: 't', baseUrl: 'https://maloja.example' }, 'https://fallback'), + ); + expect(res.baseUrl).toBe('https://maloja.example'); + }); + + it('falls back to the context baseUrl when no field given', async () => { + const res = await apiKeyOnlyStrategy.connect(ctx({ token: 't' }, 'https://api.listenbrainz.org')); + expect(res.baseUrl).toBe('https://api.listenbrainz.org'); + }); +}); diff --git a/src/music-network/wires/shared/apiKeyOnly.ts b/src/music-network/wires/shared/apiKeyOnly.ts new file mode 100644 index 00000000..2cbec801 --- /dev/null +++ b/src/music-network/wires/shared/apiKeyOnly.ts @@ -0,0 +1,34 @@ +// Shared api_key_only connect strategy. +// +// Used by every paste-auth provider: ListenBrainz (user token), Maloja (API +// key), Rocksky (session key from `rocksky login`). There is no browser flow — +// the user supplies the credential directly. Validation happens in the wire's +// probe() right after connect, so this strategy only normalizes the inputs. +// +// Field convention (preset declares these in PresetManifest.fields): +// token — the credential (LB token / Maloja key / Rocksky session key) +// username — optional display/account name +// baseUrl — optional, for self-hosted instances + +import { MusicNetworkError } from '../../core/errors'; +import type { AuthStrategy } from '../../contracts/AuthStrategy'; +import type { ConnectContext, ConnectResult } from '../../contracts/ScrobbleWire'; + +export const apiKeyOnlyStrategy: AuthStrategy = { + id: 'api_key_only', + + async connect(ctx: ConnectContext): Promise { + const token = (ctx.fields.token ?? '').trim(); + if (!token) { + throw new MusicNetworkError('AUTH_SESSION_INVALID', 'A token or API key is required', { + providerId: ctx.presetId, + }); + } + const baseUrl = (ctx.fields.baseUrl ?? '').trim() || ctx.baseUrl; + return { + sessionKey: token, + username: (ctx.fields.username ?? '').trim(), + baseUrl: baseUrl || undefined, + }; + }, +}; diff --git a/src/music-network/wires/shared/invokeTransport.ts b/src/music-network/wires/shared/invokeTransport.ts new file mode 100644 index 00000000..d94a331f --- /dev/null +++ b/src/music-network/wires/shared/invokeTransport.ts @@ -0,0 +1,44 @@ +// Music Network — shared wire transport helper. +// +// Every provider client (audioscrobbler / listenbrainz / maloja) wraps a Rust +// `*_request` command with the same boilerplate: invoke, and on failure classify +// the error message into an auth-class MusicNetworkError (per the wire's own +// heuristic) or a generic NETWORK one. That boilerplate lives here; each wire +// keeps its own arg shape and auth rule. No store access — the runtime owns +// session-error state. + +import { invoke } from '@tauri-apps/api/core'; +import { MusicNetworkError, type MusicNetworkErrorCode } from '../../core/errors'; + +function errMsg(e: unknown): string { + if (typeof e === 'string') return e; + if (e instanceof Error) return e.message; + return String(e); +} + +export interface TransportAuthRule { + /** True when the error message indicates an auth/key failure for this wire. */ + match: (msg: string) => boolean; + /** Code thrown when `match` hits (e.g. AUTH_SESSION_INVALID, MALOJA_BAD_KEY). */ + code: MusicNetworkErrorCode; +} + +/** + * Invoke a provider transport command. On failure, throws the auth-class + * MusicNetworkError when `auth.match` recognises the message, otherwise NETWORK. + */ +export async function invokeTransport( + command: string, + args: Record, + auth?: TransportAuthRule, +): Promise { + try { + return await invoke(command, args); + } catch (e) { + const msg = errMsg(e); + if (auth?.match(msg)) { + throw new MusicNetworkError(auth.code, msg, { cause: e }); + } + throw new MusicNetworkError('NETWORK', msg, { cause: e }); + } +} diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 6065746c..7563c769 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -14,7 +14,6 @@ import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; -import { lastfmIsConfigured } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import { invalidateCoverArt } from '../utils/imageCache'; import { showToast } from '../utils/ui/toast'; @@ -91,6 +90,7 @@ export default function ArtistDetail() { s => !!(activeServerId && s.audiomuseNavidromeByServer[activeServerId]), ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null); const albumYearOrder = useArtistAlbumYearSortStore( s => s.orderByServer[activeServerId] ?? DEFAULT_ARTIST_ALBUM_YEAR_ORDER, ); @@ -248,11 +248,11 @@ export default function ArtistDetail() { albumCount: sa.albumCount, })); const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0; - const showLastfmSimilar = - lastfmIsConfigured() && + const showNetworkSimilar = + enrichmentConfigured && (!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) && (similarLoading || similarArtists.length > 0); - const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar; + const showSimilarSection = showAudiomuseSimilar || showNetworkSimilar; // ── User-customisable section order + visibility ──────────────────────────── // (`sectionConfig` is read at the top of the component — see comment there) @@ -339,7 +339,7 @@ export default function ArtistDetail() { key="similar" marginTop={sectionMt('similar')} showAudiomuseSimilar={showAudiomuseSimilar} - showLastfmSimilar={showLastfmSimilar} + showNetworkSimilar={showNetworkSimilar} similarLoading={similarLoading} similarArtists={similarArtists} serverSimilarArtists={serverSimilarArtists} diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index 0bdc8940..27b4c3e5 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -64,8 +64,7 @@ export default function NowPlaying() { const toggleQueue = usePlayerStore(s => s.toggleQueue); const enableBandsintown = useAuthStore(s => s.enableBandsintown); const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown); - const lastfmUsername = useAuthStore(s => s.lastfmUsername); - const lastfmSessionKey = useAuthStore(s => s.lastfmSessionKey); + const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId); const playTrackFn = usePlayerStore(s => s.playTrack); const radioMeta = useRadioMetadata(currentRadio ?? null); @@ -87,11 +86,11 @@ export default function NowPlaying() { const { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, - lfmTrack, lfmArtist, + networkTrack, networkArtist, } = useNowPlayingFetchers({ songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, - lastfmUsername, currentTrack, + enrichmentKey: enrichmentPrimaryId ?? '', currentTrack, subsonicServerId: playbackServerId, // `fetchEnabled` = "we have a playback server id". The reachability decision // (online / server reachable, no trackId so local-cache playback still loads @@ -99,10 +98,10 @@ export default function NowPlaying() { fetchEnabled: Boolean(playbackServerId), }); - // Star + Last.fm love + their toggle callbacks - const lfmLoveEnabled = Boolean(lastfmUsername && lastfmSessionKey); - const { starred, lfmLoved, toggleStar, toggleLfmLove } = useNowPlayingStarLove({ - currentTrack, songMeta, lfmTrack, lfmLoveEnabled, lastfmSessionKey, + // Star + enrichment love + their toggle callbacks + const networkLoveEnabled = enrichmentPrimaryId !== null; + const { starred, networkLoved, toggleStar, toggleNetworkLove } = useNowPlayingStarLove({ + currentTrack, songMeta, networkTrack, networkLoveEnabled, }); const openLyrics = useCallback(() => { @@ -125,17 +124,17 @@ export default function NowPlaying() { [songMeta, currentTrack?.artist], ); - // Merge Subsonic artistInfo with Last.fm fallback: if Subsonic has no bio, - // use Last.fm's artist bio so the card doesn't show up empty. + // Merge Subsonic artistInfo with the enrichment fallback: if Subsonic has no + // bio, use the enrichment primary's artist bio so the card isn't empty. const effectiveArtistInfo = useMemo(() => { - if (!artistInfo && !lfmArtist?.bio) return null; + if (!artistInfo && !networkArtist?.bio) return null; if (artistInfo?.biography) return artistInfo; - if (!lfmArtist?.bio) return artistInfo; + if (!networkArtist?.bio) return artistInfo; return { ...(artistInfo ?? {}), - biography: lfmArtist.bio, + biography: networkArtist.bio, }; - }, [artistInfo, lfmArtist]); + }, [artistInfo, networkArtist]); const artistInfoById = useArtistInfoBatch( playbackServerId, @@ -277,16 +276,16 @@ export default function NowPlaying() { genre={songMeta?.genre ?? undefined} playCount={(songMeta as (SubsonicSong & { playCount?: number }) | null)?.playCount} userRatingOverride={userRatingOverrides[currentTrack.id]} - lfmTrack={lfmTrack} - lfmArtist={lfmArtist} + networkTrack={networkTrack} + networkArtist={networkArtist} starred={starred} - lfmLoved={lfmLoved} - lfmLoveEnabled={lfmLoveEnabled} + networkLoved={networkLoved} + networkLoveEnabled={networkLoveEnabled} activeLyricsTab={activeTab === 'lyrics' && isQueueVisible} coverRef={playbackCoverRef} onNavigate={stableNavigate} onToggleStar={toggleStar} - onToggleLfmLove={toggleLfmLove} + onToggleNetworkLove={toggleNetworkLove} onOpenLyrics={openLyrics} /> diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 68b1a5fd..d561f971 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -12,9 +12,10 @@ import StatisticsTabBar from '../components/statistics/StatisticsTabBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useLocation } from 'react-router-dom'; -import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm'; +import { getMusicNetworkRuntime, type RecentTrack, type StatsPeriod, type TopItem } from '../music-network'; import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; import { usePlayerStatsRecordingEnabled } from '../hooks/usePlayerStatsRecordingEnabled'; +import { useEnrichmentPrimaryLabel } from '../hooks/useEnrichmentPrimaryLabel'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string { @@ -25,7 +26,7 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string) return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) }); } -const PERIODS: { key: LastfmPeriod; label: string }[] = [ +const PERIODS: { key: StatsPeriod; label: string }[] = [ { key: '7day', label: 'lfmPeriod7day' }, { key: '1month', label: 'lfmPeriod1month' }, { key: '3month', label: 'lfmPeriod3month' }, @@ -41,7 +42,8 @@ export default function Statistics() { const isPlayerStats = location.pathname === '/player-stats'; const offlineBrowseActive = useOfflineBrowseContext().active; const playerStatsEnabled = usePlayerStatsRecordingEnabled(); - const { lastfmSessionKey, lastfmUsername } = useAuthStore(); + const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId); + const enrichmentLabel = useEnrichmentPrimaryLabel() ?? ''; const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [recent, setRecent] = useState([]); const [frequent, setFrequent] = useState([]); @@ -59,12 +61,17 @@ export default function Statistics() { const [exportOpen, setExportOpen] = useState(false); - const [lfmPeriod, setLfmPeriod] = useState('1month'); - const [lfmTopArtists, setLfmTopArtists] = useState([]); - const [lfmTopAlbums, setLfmTopAlbums] = useState([]); - const [lfmTopTracks, setLfmTopTracks] = useState([]); + // Enrichment-primary listening stats. The `lfm*` local names and the + // `statistics.lfm*` i18n keys are the original (pre-framework) identifiers, + // kept as-is: the user-facing copy is provider-neutral ({{provider}}), and the + // keys share the `lfmPeriod`/`lfmPeriod7day` prefix so a blanket rename is + // unsafe. Internal-only; not a framework-boundary concern. + const [lfmPeriod, setLfmPeriod] = useState('1month'); + const [lfmTopArtists, setLfmTopArtists] = useState([]); + const [lfmTopAlbums, setLfmTopAlbums] = useState([]); + const [lfmTopTracks, setLfmTopTracks] = useState([]); const [lfmLoading, setLfmLoading] = useState(false); - const [lfmRecentTracks, setLfmRecentTracks] = useState([]); + const [lfmRecentTracks, setLfmRecentTracks] = useState([]); const [lfmRecentLoading, setLfmRecentLoading] = useState(false); useEffect(() => { @@ -138,28 +145,28 @@ export default function Statistics() { useEffect(() => { if (offlineBrowseActive || isPlayerStats) return; - if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; + if (enrichmentPrimaryId === null) return; setLfmRecentLoading(true); - lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20) + getMusicNetworkRuntime().getRecentTracks(20) .then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); }) .catch(() => setLfmRecentLoading(false)); - }, [lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]); + }, [enrichmentPrimaryId, offlineBrowseActive, isPlayerStats]); useEffect(() => { if (offlineBrowseActive || isPlayerStats) return; - if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; + if (enrichmentPrimaryId === null) return; setLfmLoading(true); Promise.all([ - lastfmGetTopArtists(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), - lastfmGetTopAlbums(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), - lastfmGetTopTracks(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), + getMusicNetworkRuntime().getTopItems(lfmPeriod, 'artists', 10), + getMusicNetworkRuntime().getTopItems(lfmPeriod, 'albums', 10), + getMusicNetworkRuntime().getTopItems(lfmPeriod, 'tracks', 10), ]).then(([artists, albums, tracks]) => { setLfmTopArtists(artists); setLfmTopAlbums(albums); setLfmTopTracks(tracks); setLfmLoading(false); }).catch(() => setLfmLoading(false)); - }, [lfmPeriod, lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]); + }, [lfmPeriod, enrichmentPrimaryId, offlineBrowseActive, isPlayerStats]); const loadMore = async ( type: 'frequent' | 'highest', @@ -316,30 +323,26 @@ export default function Statistics() { showRating /> - {/* Last.fm Stats */} - {lastfmIsConfigured() && ( + {/* Music Network Stats */} + {enrichmentPrimaryId !== null && (
-

{t('statistics.lfmTitle')}

- {lastfmSessionKey && ( -
- {PERIODS.map(p => ( - - ))} -
- )} +

{t('statistics.lfmTitle', { provider: enrichmentLabel })}

+
+ {PERIODS.map(p => ( + + ))} +
- {!lastfmSessionKey ? ( -

{t('statistics.lfmNotConnected')}

- ) : lfmLoading ? ( + {lfmLoading ? (
@@ -347,8 +350,8 @@ export default function Statistics() {
{([ { label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) }, - { label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist, playcount: a.playcount })) }, - { label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist, playcount: tr.playcount })) }, + { label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist ?? null, playcount: a.playcount })) }, + { label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist ?? null, playcount: tr.playcount })) }, ] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => { const max = Math.max(...col.items.map(it => Number(it.playcount)), 1); return ( @@ -386,7 +389,7 @@ export default function Statistics() { )} {/* Recent Scrobbles */} - {lastfmIsConfigured() && lastfmSessionKey && ( + {enrichmentPrimaryId !== null && (

{t('statistics.lfmRecentTracks')}

{lfmRecentLoading ? ( diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index 8ed6bebb..fd9d5244 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -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); diff --git a/src/store/audioListenerSetup/initialAudioSync.ts b/src/store/audioListenerSetup/initialAudioSync.ts index 4b8cb77d..497187ec 100644 --- a/src/store/audioListenerSetup/initialAudioSync.ts +++ b/src/store/audioListenerSetup/initialAudioSync.ts @@ -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(); diff --git a/src/store/authLastfmActions.ts b/src/store/authLastfmActions.ts deleted file mode 100644 index 1ad7cfd7..00000000 --- a/src/store/authLastfmActions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { AuthState } from './authStoreTypes'; - -type SetState = ( - partial: Partial | ((state: AuthState) => Partial), -) => 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 }), - }; -} diff --git a/src/store/authMusicNetworkActions.ts b/src/store/authMusicNetworkActions.ts new file mode 100644 index 00000000..4a76bd57 --- /dev/null +++ b/src/store/authMusicNetworkActions.ts @@ -0,0 +1,25 @@ +import type { PersistedAccount } from '../music-network'; +import type { AuthState } from './authStoreTypes'; + +type SetState = ( + partial: Partial | ((state: AuthState) => Partial), +) => 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 }), + }; +} diff --git a/src/store/authStore.login.test.ts b/src/store/authStore.login.test.ts index 7eb4a948..0ab28332 100644 --- a/src/store/authStore.login.test.ts +++ b/src/store/authStore.login.test.ts @@ -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'); - }); -}); diff --git a/src/store/authStore.persistence.test.ts b/src/store/authStore.persistence.test.ts index 9d26d1f5..8faa5666 100644 --- a/src/store/authStore.persistence.test.ts +++ b/src/store/authStore.persistence.test.ts @@ -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(); diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index d8c3e8ff..7f6b5e9d 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -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], diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 724a685a..c36cf8ff 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( (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()( isLoggedIn: false, isConnecting: false, connectionError: null, - lastfmSessionError: false, ...createServerProfileActions(set), - ...createAuthLastfmActions(set), + ...createMusicNetworkActions(set), ...createAudioSettingsActions(set), ...createCacheStorageActions(set), ...createDiscordSettingsActions(set), diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index 4fa0c8c2..a62bf44d 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -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 = { + 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)[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) => 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; diff --git a/src/store/lastfmActions.ts b/src/store/lastfmActions.ts deleted file mode 100644 index d0ad1e14..00000000 --- a/src/store/lastfmActions.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - lastfmGetAllLovedTracks, - lastfmLoveTrack, - lastfmUnloveTrack, -} from '../api/lastfm'; -import { useAuthStore } from './authStore'; -import type { PlayerState } from './playerStoreTypes'; - -type SetState = ( - partial: Partial | ((state: PlayerState) => Partial), -) => 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 = {}; - 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 } : {}), - })); - }, - }; -} diff --git a/src/store/lastfmLovedCacheStorage.test.ts b/src/store/lastfmLovedCacheStorage.test.ts deleted file mode 100644 index 5de0e1c1..00000000 --- a/src/store/lastfmLovedCacheStorage.test.ts +++ /dev/null @@ -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 }); - }); -}); diff --git a/src/store/lastfmLovedCacheStorage.ts b/src/store/lastfmLovedCacheStorage.ts deleted file mode 100644 index 8a454a8e..00000000 --- a/src/store/lastfmLovedCacheStorage.ts +++ /dev/null @@ -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 { - if (!raw || typeof raw !== 'object') return {}; - const out: Record = {}; - for (const [key, value] of Object.entries(raw as Record)) { - if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') { - out[key] = value; - } - } - return out; -} - -function readLegacyCacheFromPlayerBlob(): Record | 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 { - 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): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache))); - } catch { - // best-effort - } -} diff --git a/src/store/networkLoveActions.ts b/src/store/networkLoveActions.ts new file mode 100644 index 00000000..02af7f14 --- /dev/null +++ b/src/store/networkLoveActions.ts @@ -0,0 +1,65 @@ +import { getMusicNetworkRuntimeOrNull } from '../music-network'; +import type { PlayerState } from './playerStoreTypes'; + +type SetState = ( + partial: Partial | ((state: PlayerState) => Partial), +) => 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 } : {}), + })); + }, + }; +} diff --git a/src/store/networkLovedCacheStorage.ts b/src/store/networkLovedCacheStorage.ts new file mode 100644 index 00000000..db54412b --- /dev/null +++ b/src/store/networkLovedCacheStorage.ts @@ -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 { + if (!raw || typeof raw !== 'object') return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (typeof key === 'string' && key.length > 0 && typeof value === 'boolean') { + out[key] = value; + } + } + return out; +} + +function readKey(key: string): Record | 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 | 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 { + if (typeof window === 'undefined') return {}; + return ( + readKey(CACHE_STORAGE_KEY) + ?? readKey(LEGACY_CACHE_STORAGE_KEY) + ?? readLegacyCacheFromPlayerBlob() + ?? {} + ); +} + +export function persistNetworkLovedCache(cache: Record): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(sanitizeCache(cache))); + } catch { + // best-effort + } +} diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 81ae68a7..83dfad62 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -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); diff --git a/src/store/playerBarLayoutStore.ts b/src/store/playerBarLayoutStore.ts index 57582c94..e08775fb 100644 --- a/src/store/playerBarLayoutStore.ts +++ b/src/store/playerBarLayoutStore.ts @@ -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' diff --git a/src/store/playerStore.events.test.ts b/src/store/playerStore.events.test.ts index 0bfa29bf..62e04700 100644 --- a/src/store/playerStore.events.test.ts +++ b/src/store/playerStore.events.test.ts @@ -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); }); }); diff --git a/src/store/playerStore.misc.test.ts b/src/store/playerStore.misc.test.ts index 3076efc2..ac3ddc5a 100644 --- a/src/store/playerStore.misc.test.ts +++ b/src/store/playerStore.misc.test.ts @@ -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); }); }); diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 0f766323..06bc2d0a 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -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'; diff --git a/src/store/playerStore.playbackActions.test.ts b/src/store/playerStore.playbackActions.test.ts index b383070e..af46cca0 100644 --- a/src/store/playerStore.playbackActions.test.ts +++ b/src/store/playerStore.playbackActions.test.ts @@ -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), diff --git a/src/store/playerStore.progress.test.ts b/src/store/playerStore.progress.test.ts index 9e8f7cea..7494e832 100644 --- a/src/store/playerStore.progress.test.ts +++ b/src/store/playerStore.progress.test.ts @@ -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'; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index e676266c..6ea716a6 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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()( @@ -56,8 +56,8 @@ export const usePlayerStore = create()( 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()( 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()( ), 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()( 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); } }); diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index 001d6d37..13c08cbd 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -100,8 +100,8 @@ export interface PlayerState { currentTime: number; volume: number; scrobbled: boolean; - lastfmLoved: boolean; - lastfmLovedCache: Record; + networkLoved: boolean; + networkLovedCache: Record; starredOverrides: Record; 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; + toggleNetworkLove: () => void; + setNetworkLoved: (v: boolean) => void; + setNetworkLovedForSong: (title: string, artist: string, v: boolean) => void; + syncNetworkLovedTracks: () => Promise; resetAudioPause: () => void; initializeFromServerQueue: () => Promise; diff --git a/src/styles/components/np-dash.css b/src/styles/components/np-dash.css index 445e88c9..60bc5eda 100644 --- a/src/styles/components/np-dash.css +++ b/src/styles/components/np-dash.css @@ -102,7 +102,7 @@ color: var(--accent); background: var(--bg-hover); } -.np-dash-lfm-btn.is-loved { +.np-dash-network-btn.is-loved { color: var(--accent); } @@ -123,7 +123,7 @@ } /* Last.fm stats pulled up into the hero */ -.np-dash-hero-lfm { +.np-dash-hero-network { display: flex; flex-direction: column; gap: 5px; @@ -133,20 +133,20 @@ border-radius: var(--radius-md); border-left: 3px solid var(--accent); } -.np-dash-hero-lfm-heading { +.np-dash-hero-network-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; } -.np-dash-hero-lfm-badge { +.np-dash-hero-network-badge { font-size: 10px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); } -.np-dash-hero-lfm-row { +.np-dash-hero-network-row { display: flex; flex-wrap: wrap; align-items: baseline; @@ -155,19 +155,19 @@ color: var(--text-secondary); font-variant-numeric: tabular-nums; } -.np-dash-hero-lfm-scope { +.np-dash-hero-network-scope { font-weight: 600; color: var(--text-primary); } -.np-dash-hero-lfm-sep { +.np-dash-hero-network-sep { color: var(--text-muted); padding: 0 2px; } -.np-dash-hero-lfm-dot { +.np-dash-hero-network-dot { color: var(--text-muted); padding: 0 3px; } -.np-dash-hero-lfm-you { +.np-dash-hero-network-you { color: var(--accent); font-weight: 600; } @@ -676,7 +676,7 @@ } .np-dash-hero-sub, - .np-dash-hero-lfm-row { + .np-dash-hero-network-row { font-size: 13px; } @@ -714,7 +714,7 @@ gap: 5px; } - .np-dash-hero-lfm { + .np-dash-hero-network { padding: 10px 11px; }