mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts
Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.
* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports
Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:
- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
(Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON
lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.
* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets
Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).
- client.ts: transport wrapper over audioscrobbler_request, classifying
failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
token-poll presets (both endpoints verified live)
Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.
* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets
Add the scrobble-destination wires and presets:
- ListenBrainz wire (scrobble + now playing via playing_now), backing both
the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
(scrobble-only, session-key paste, bundled keys — verified live), and
custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix
maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.
* feat(music-network): registry, orchestrator, enrichment router + runtime facade
Wire the framework together behind a single facade:
- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
final authority (lets two presets on one wire diverge, e.g. Rocksky's
nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel
Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.
* feat(music-network): auth-store state + lossless legacy migration + runtime bridge
Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.
- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
username and scrobbling preference; fills bundled Last.fm key from the preset;
sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap
nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.
* feat(music-network): route playback, enrichment and love through the runtime
Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:
- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
the ArtistDetail similar-artists gate now use the runtime, gated on an
enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
lastfmActions -> networkLoveActions; loved cache storage renamed with a
lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
throw) before the runtime is initialized
src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.
* feat(music-network): manifest-driven Integrations UI + scrobble batch format
Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.
- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey
Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).
* fix(music-network): clearer Integrations layout + scrobble batch fix
Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
liked tracks/similar artists/stats come from it while scrobbling still goes
to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)
Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.
* feat(music-network): indicator + remove legacy lastfm path
Phase 7b/7c — finish the cutover and delete the old Last.fm path.
- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
tests; update settingsTabs + src/CLAUDE.md
No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.
* test(music-network): cover scrobble shape + error classification, drop dead i18n keys
Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.
Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.
* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets
- de-hardcode the single provider name across every enrichment surface
(love labels, now-playing badge, stats title); derive it from the
enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry
* fix(music-network): validate paste-auth keys on connect + UI polish
- AudioscrobblerWire.probe now validates an api_key_only session with a
signed call and reports scrobble:'error' only on a genuine auth failure
(a scrobble-only service that rejects user.getInfo is not a bad key), so
an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour
* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments
Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.
* docs(changelog): Music Network entry (PR #1066)
Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
* fix(music-network): bound request timeout on the provider transports
audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.
* refactor(music-network): dedupe wire transport + no-enrichment helpers
The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.
* refactor(music-network): drop write-only malojaWireMode dead state
malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.
* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback
The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.
---------
Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Added
|
## 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
|
### 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)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1000](https://github.com/Psychotoxical/psysonic/pull/1000), suggested by [@PHLAK](https://github.com/PHLAK)**
|
||||||
|
|||||||
+13
-7
@@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
|
|||||||
### Your Subsonic / Navidrome server
|
### 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.
|
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
|
### Music Network (scrobble & enrichment services)
|
||||||
If you connect a Last.fm account in Settings, Psysonic sends:
|
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:
|
||||||
- **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
|
|
||||||
|
|
||||||
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)
|
### 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.
|
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:
|
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)
|
- Server profiles (URL, username, password)
|
||||||
- Last.fm session key
|
- Scrobble service credentials (session keys / API tokens)
|
||||||
- Playback preferences, themes, keybindings, and all other settings
|
- Playback preferences, themes, keybindings, and all other settings
|
||||||
- Synced device manifests
|
- Synced device manifests
|
||||||
|
|
||||||
|
|||||||
@@ -287,11 +287,31 @@ pub async fn resolve_stream_url(url: String) -> String {
|
|||||||
resolve_playlist_url(&client, &url).await.unwrap_or(url)
|
resolve_playlist_url(&client, &url).await.unwrap_or(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
/// Default Audioscrobbler v2 endpoint (Last.fm). Other presets (Libre.fm,
|
||||||
/// `params` is a list of [key, value] pairs (method must be included).
|
/// Rocksky, GNU FM, Maloja compat) pass their own `base_url`.
|
||||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
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, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub async fn lastfm_request(
|
pub async fn audioscrobbler_request(
|
||||||
|
base_url: String,
|
||||||
params: Vec<[String; 2]>,
|
params: Vec<[String; 2]>,
|
||||||
sign: bool,
|
sign: bool,
|
||||||
get: bool,
|
get: bool,
|
||||||
@@ -300,6 +320,8 @@ pub async fn lastfm_request(
|
|||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url };
|
||||||
|
|
||||||
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
|
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
|
||||||
map.insert("api_key".into(), api_key.clone());
|
map.insert("api_key".into(), api_key.clone());
|
||||||
|
|
||||||
@@ -317,17 +339,17 @@ pub async fn lastfm_request(
|
|||||||
|
|
||||||
map.insert("format".into(), "json".into());
|
map.insert("format".into(), "json".into());
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = provider_http_client()?;
|
||||||
let resp = if get {
|
let resp = if get {
|
||||||
client
|
client
|
||||||
.get("https://ws.audioscrobbler.com/2.0/")
|
.get(&base)
|
||||||
.query(&map)
|
.query(&map)
|
||||||
.header("User-Agent", subsonic_wire_user_agent())
|
.header("User-Agent", subsonic_wire_user_agent())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
client
|
client
|
||||||
.post("https://ws.audioscrobbler.com/2.0/")
|
.post(&base)
|
||||||
.form(&map)
|
.form(&map)
|
||||||
.header("User-Agent", subsonic_wire_user_agent())
|
.header("User-Agent", subsonic_wire_user_agent())
|
||||||
.send()
|
.send()
|
||||||
@@ -337,7 +359,88 @@ pub async fn lastfm_request(
|
|||||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
if let Some(err) = json.get("error") {
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
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)
|
Ok(json)
|
||||||
@@ -495,4 +598,66 @@ mod tests {
|
|||||||
Some("https://pls.example/audio".to_string())
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -718,7 +718,9 @@ pub fn run() {
|
|||||||
audio::commands::audio_chain_preload,
|
audio::commands::audio_chain_preload,
|
||||||
psysonic_integration::discord::discord_update_presence,
|
psysonic_integration::discord::discord_update_presence,
|
||||||
psysonic_integration::discord::discord_clear_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_playlist_cover,
|
||||||
psysonic_integration::navidrome::covers::upload_radio_cover,
|
psysonic_integration::navidrome::covers::upload_radio_cover,
|
||||||
psysonic_integration::navidrome::covers::upload_artist_image,
|
psysonic_integration::navidrome::covers::upload_artist_image,
|
||||||
|
|||||||
@@ -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<string, string>, sign = false, get = false): Promise<any> {
|
|
||||||
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<string> {
|
|
||||||
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<string[]> {
|
|
||||||
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<Array<{ title: string; artist: string }>> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<LastfmUserInfo | null> {
|
|
||||||
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<LastfmRecentTrack[]> {
|
|
||||||
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<LastfmTopArtist[]> {
|
|
||||||
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<LastfmTopAlbum[]> {
|
|
||||||
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<LastfmTopTrack[]> {
|
|
||||||
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<void> {
|
|
||||||
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<LastfmTrackInfo | null> {
|
|
||||||
try {
|
|
||||||
const params: Record<string, string> = { 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<LastfmArtistStats | null> {
|
|
||||||
try {
|
|
||||||
const params: Record<string, string> = { 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
mainRouteInpageScrollViewportId,
|
mainRouteInpageScrollViewportId,
|
||||||
} from '../constants/appScroll';
|
} from '../constants/appScroll';
|
||||||
import ConnectionIndicator from '../components/ConnectionIndicator';
|
import ConnectionIndicator from '../components/ConnectionIndicator';
|
||||||
import LastfmIndicator from '../components/LastfmIndicator';
|
import MusicNetworkIndicator from '../components/MusicNetworkIndicator';
|
||||||
import OfflineBanner from '../components/OfflineBanner';
|
import OfflineBanner from '../components/OfflineBanner';
|
||||||
import AppUpdater from '../components/AppUpdater';
|
import AppUpdater from '../components/AppUpdater';
|
||||||
import TitleBar from '../components/TitleBar';
|
import TitleBar from '../components/TitleBar';
|
||||||
@@ -259,7 +259,7 @@ export function AppShell() {
|
|||||||
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||||
<LastfmIndicator />
|
<MusicNetworkIndicator />
|
||||||
<NowPlayingDropdown />
|
<NowPlayingDropdown />
|
||||||
<OrbitStartTrigger />
|
<OrbitStartTrigger />
|
||||||
{!isMobile && !isQueueVisible && (
|
{!isMobile && !isQueueVisible && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
|
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
|
||||||
import { configureStartupSplash } from './startupSplash';
|
import { configureStartupSplash } from './startupSplash';
|
||||||
|
import { setupMusicNetworkRuntime } from './musicNetworkBridge';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getWindowKind } from './windowKind';
|
import { getWindowKind } from './windowKind';
|
||||||
import { migrateThemeSelection } from '../utils/themes/themeMigration';
|
import { migrateThemeSelection } from '../utils/themes/themeMigration';
|
||||||
@@ -119,4 +120,5 @@ export function runPreReactBootstrap(): void {
|
|||||||
pushUserAgentToBackend();
|
pushUserAgentToBackend();
|
||||||
pushLoggingModeToBackend();
|
pushLoggingModeToBackend();
|
||||||
installQueueUndoHotkey();
|
installQueueUndoHotkey();
|
||||||
|
setupMusicNetworkRuntime();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
unstar: vi.fn(async () => undefined),
|
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', () => ({
|
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||||
orbitBulkGuard: vi.fn(async () => true),
|
orbitBulkGuard: vi.fn(async () => true),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ContextMenu() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
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 => ({
|
useShallow(s => ({
|
||||||
contextMenu: s.contextMenu,
|
contextMenu: s.contextMenu,
|
||||||
closeContextMenu: s.closeContextMenu,
|
closeContextMenu: s.closeContextMenu,
|
||||||
@@ -58,8 +58,8 @@ export default function ContextMenu() {
|
|||||||
queueItems: s.queueItems,
|
queueItems: s.queueItems,
|
||||||
currentTrack: s.currentTrack,
|
currentTrack: s.currentTrack,
|
||||||
removeTrack: s.removeTrack,
|
removeTrack: s.removeTrack,
|
||||||
lastfmLovedCache: s.lastfmLovedCache,
|
networkLovedCache: s.networkLovedCache,
|
||||||
setLastfmLovedForSong: s.setLastfmLovedForSong,
|
setNetworkLovedForSong: s.setNetworkLovedForSong,
|
||||||
starredOverrides: s.starredOverrides,
|
starredOverrides: s.starredOverrides,
|
||||||
setStarredOverride: s.setStarredOverride,
|
setStarredOverride: s.setStarredOverride,
|
||||||
openSongInfo: s.openSongInfo,
|
openSongInfo: s.openSongInfo,
|
||||||
@@ -234,8 +234,8 @@ export default function ContextMenu() {
|
|||||||
closeContextMenu={closeContextMenu}
|
closeContextMenu={closeContextMenu}
|
||||||
starredOverrides={starredOverrides}
|
starredOverrides={starredOverrides}
|
||||||
setStarredOverride={setStarredOverride}
|
setStarredOverride={setStarredOverride}
|
||||||
lastfmLovedCache={lastfmLovedCache}
|
networkLovedCache={networkLovedCache}
|
||||||
setLastfmLovedForSong={setLastfmLovedForSong}
|
setNetworkLovedForSong={setNetworkLovedForSong}
|
||||||
openSongInfo={openSongInfo}
|
openSongInfo={openSongInfo}
|
||||||
userRatingOverrides={userRatingOverrides}
|
userRatingOverrides={userRatingOverrides}
|
||||||
setKeyboardRating={setKeyboardRating}
|
setKeyboardRating={setKeyboardRating}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
className="connection-indicator"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
|
|
||||||
data-tooltip={tooltip}
|
|
||||||
data-tooltip-pos="bottom"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`connection-led connection-led--${lastfmSessionError ? 'disconnected' : 'connected'}`}
|
|
||||||
/>
|
|
||||||
<div className="connection-meta">
|
|
||||||
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<LastfmIcon size={11} />
|
|
||||||
Last.fm
|
|
||||||
</span>
|
|
||||||
<span className="connection-server">
|
|
||||||
{lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
scrobbleSong: vi.fn(async () => undefined),
|
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 MiniPlayer from './MiniPlayer';
|
||||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="connection-indicator"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
|
||||||
|
data-tooltip={tooltip}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
>
|
||||||
|
<div className={`connection-led connection-led--${primary.sessionError ? 'disconnected' : 'connected'}`} />
|
||||||
|
<div className="connection-meta">
|
||||||
|
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
{renderPresetIcon(icon, 11)}
|
||||||
|
{primary.label}
|
||||||
|
</span>
|
||||||
|
<span className="connection-server">{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
scrobbleSong: vi.fn(async () => undefined),
|
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 PlayerBar from './PlayerBar';
|
||||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import MarqueeText from './MarqueeText';
|
import MarqueeText from './MarqueeText';
|
||||||
import LastfmIcon from './LastfmIcon';
|
|
||||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||||
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
|
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
|
||||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||||
@@ -61,7 +60,7 @@ export default function PlayerBar() {
|
|||||||
currentTrack, currentRadio, isPlaying, volume,
|
currentTrack, currentRadio, isPlaying, volume,
|
||||||
togglePlay, next, previous, setVolume,
|
togglePlay, next, previous, setVolume,
|
||||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||||
lastfmLoved, toggleLastfmLove,
|
networkLoved, toggleNetworkLove,
|
||||||
isQueueVisible, toggleQueue,
|
isQueueVisible, toggleQueue,
|
||||||
starredOverrides,
|
starredOverrides,
|
||||||
userRatingOverrides,
|
userRatingOverrides,
|
||||||
@@ -79,15 +78,15 @@ export default function PlayerBar() {
|
|||||||
toggleRepeat: s.toggleRepeat,
|
toggleRepeat: s.toggleRepeat,
|
||||||
repeatMode: s.repeatMode,
|
repeatMode: s.repeatMode,
|
||||||
toggleFullscreen: s.toggleFullscreen,
|
toggleFullscreen: s.toggleFullscreen,
|
||||||
lastfmLoved: s.lastfmLoved,
|
networkLoved: s.networkLoved,
|
||||||
toggleLastfmLove: s.toggleLastfmLove,
|
toggleNetworkLove: s.toggleNetworkLove,
|
||||||
isQueueVisible: s.isQueueVisible,
|
isQueueVisible: s.isQueueVisible,
|
||||||
toggleQueue: s.toggleQueue,
|
toggleQueue: s.toggleQueue,
|
||||||
starredOverrides: s.starredOverrides,
|
starredOverrides: s.starredOverrides,
|
||||||
userRatingOverrides: s.userRatingOverrides,
|
userRatingOverrides: s.userRatingOverrides,
|
||||||
openContextMenu: s.openContextMenu,
|
openContextMenu: s.openContextMenu,
|
||||||
})));
|
})));
|
||||||
const { lastfmSessionKey } = useAuthStore();
|
const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId);
|
||||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||||
const playerBarRef = useRef<HTMLElement>(null);
|
const playerBarRef = useRef<HTMLElement>(null);
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
@@ -208,9 +207,9 @@ export default function PlayerBar() {
|
|||||||
previewingTrack={previewingTrack}
|
previewingTrack={previewingTrack}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
toggleStar={toggleStar}
|
toggleStar={toggleStar}
|
||||||
lastfmSessionKey={lastfmSessionKey}
|
enrichmentPrimaryId={enrichmentPrimaryId}
|
||||||
lastfmLoved={lastfmLoved}
|
networkLoved={networkLoved}
|
||||||
toggleLastfmLove={toggleLastfmLove}
|
toggleNetworkLove={toggleNetworkLove}
|
||||||
userRatingOverrides={userRatingOverrides}
|
userRatingOverrides={userRatingOverrides}
|
||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
navigate={navigatePlaybackLibrary}
|
navigate={navigatePlaybackLibrary}
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
scrobbleSong: vi.fn(async () => undefined),
|
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', () => ({
|
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||||
orbitBulkGuard: vi.fn(async () => true),
|
orbitBulkGuard: vi.fn(async () => true),
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ vi.mock('@/api/subsonic', () => ({
|
|||||||
scrobbleSong: vi.fn(async () => undefined),
|
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 WaveformSeek from './WaveformSeek';
|
||||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useIsMobile } from '../../hooks/useIsMobile';
|
|||||||
interface Props {
|
interface Props {
|
||||||
marginTop: string;
|
marginTop: string;
|
||||||
showAudiomuseSimilar: boolean;
|
showAudiomuseSimilar: boolean;
|
||||||
showLastfmSimilar: boolean;
|
showNetworkSimilar: boolean;
|
||||||
similarLoading: boolean;
|
similarLoading: boolean;
|
||||||
similarArtists: SubsonicArtist[];
|
similarArtists: SubsonicArtist[];
|
||||||
serverSimilarArtists: SubsonicArtist[];
|
serverSimilarArtists: SubsonicArtist[];
|
||||||
@@ -17,7 +17,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistDetailSimilarArtists({
|
export default function ArtistDetailSimilarArtists({
|
||||||
marginTop, showAudiomuseSimilar, showLastfmSimilar,
|
marginTop, showAudiomuseSimilar, showNetworkSimilar,
|
||||||
similarLoading, similarArtists, serverSimilarArtists,
|
similarLoading, similarArtists, serverSimilarArtists,
|
||||||
similarCollapsed, setSimilarCollapsed,
|
similarCollapsed, setSimilarCollapsed,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -41,7 +41,7 @@ export default function ArtistDetailSimilarArtists({
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{showLastfmSimilar && similarLoading ? (
|
{showNetworkSimilar && similarLoading ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||||
{t('artistDetail.loading')}
|
{t('artistDetail.loading')}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
const {
|
const {
|
||||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||||
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
|
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
|
||||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||||
playlistSongIds, setPlaylistSongIds,
|
playlistSongIds, setPlaylistSongIds,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
|||||||
const {
|
const {
|
||||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||||
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
|
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
|
||||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||||
playlistSongIds, setPlaylistSongIds,
|
playlistSongIds, setPlaylistSongIds,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
|||||||
const {
|
const {
|
||||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||||
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
|
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
|
||||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||||
playlistSongIds, setPlaylistSongIds,
|
playlistSongIds, setPlaylistSongIds,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react';
|
import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react';
|
||||||
import { queueSongStar } from '../../store/pendingStarSync';
|
import { queueSongStar } from '../../store/pendingStarSync';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '../../music-network';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||||
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
||||||
@@ -13,7 +13,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
const {
|
const {
|
||||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||||
starredOverrides, lastfmLovedCache, setLastfmLovedForSong,
|
starredOverrides, networkLovedCache, setNetworkLovedForSong,
|
||||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||||
playlistSongIds, setPlaylistSongIds,
|
playlistSongIds, setPlaylistSongIds,
|
||||||
@@ -24,6 +24,9 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const networkPrimary = useEnrichmentPrimary();
|
||||||
|
const networkLabel = networkPrimary?.label ?? '';
|
||||||
|
const networkIcon = networkPrimary?.icon ?? 'custom';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -76,18 +79,17 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
</div>
|
</div>
|
||||||
{auth.lastfmSessionKey && (() => {
|
{auth.enrichmentPrimaryId !== null && (() => {
|
||||||
const loveKey = `${song.title}::${song.artist}`;
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
const loved = networkLovedCache[loveKey] ?? false;
|
||||||
return (
|
return (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
const newLoved = !loved;
|
const newLoved = !loved;
|
||||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
setNetworkLovedForSong(song.title, song.artist, newLoved);
|
||||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
|
||||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
|
||||||
})}>
|
})}>
|
||||||
<LastfmIcon size={14} />
|
{renderPresetIcon(networkIcon, 14)}
|
||||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
|||||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||||
import { queueSongStar } from '../../store/pendingStarSync';
|
import { queueSongStar } from '../../store/pendingStarSync';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '../../music-network';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { usePlaylistStore } from '../../store/playlistStore';
|
import { usePlaylistStore } from '../../store/playlistStore';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { showToast } from '../../utils/ui/toast';
|
import { showToast } from '../../utils/ui/toast';
|
||||||
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '../../utils/orbit';
|
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '../../utils/orbit';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||||
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
||||||
@@ -20,7 +20,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
const {
|
const {
|
||||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||||
starredOverrides, lastfmLovedCache, setLastfmLovedForSong,
|
starredOverrides, networkLovedCache, setNetworkLovedForSong,
|
||||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||||
playlistSongIds, setPlaylistSongIds,
|
playlistSongIds, setPlaylistSongIds,
|
||||||
@@ -31,6 +31,9 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const networkPrimary = useEnrichmentPrimary();
|
||||||
|
const networkLabel = networkPrimary?.label ?? '';
|
||||||
|
const networkIcon = networkPrimary?.icon ?? 'custom';
|
||||||
const navigateToAlbum = useNavigateToAlbum();
|
const navigateToAlbum = useNavigateToAlbum();
|
||||||
const navigateToArtist = useNavigateToArtist();
|
const navigateToArtist = useNavigateToArtist();
|
||||||
|
|
||||||
@@ -134,18 +137,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{auth.lastfmSessionKey && (() => {
|
{auth.enrichmentPrimaryId !== null && (() => {
|
||||||
const loveKey = `${song.title}::${song.artist}`;
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
const loved = networkLovedCache[loveKey] ?? false;
|
||||||
return (
|
return (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
const newLoved = !loved;
|
const newLoved = !loved;
|
||||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
setNetworkLovedForSong(song.title, song.artist, newLoved);
|
||||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
|
||||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
|
||||||
})}>
|
})}>
|
||||||
<LastfmIcon size={14} />
|
{renderPresetIcon(networkIcon, 14)}
|
||||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -279,18 +281,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{auth.lastfmSessionKey && (() => {
|
{auth.enrichmentPrimaryId !== null && (() => {
|
||||||
const loveKey = `${song.title}::${song.artist}`;
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
const loved = networkLovedCache[loveKey] ?? false;
|
||||||
return (
|
return (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
const newLoved = !loved;
|
const newLoved = !loved;
|
||||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
setNetworkLovedForSong(song.title, song.artist, newLoved);
|
||||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
|
||||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
|
||||||
})}>
|
})}>
|
||||||
<LastfmIcon size={14} />
|
{renderPresetIcon(networkIcon, 14)}
|
||||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export interface ContextMenuItemsProps {
|
|||||||
closeContextMenu: () => void;
|
closeContextMenu: () => void;
|
||||||
starredOverrides: Record<string, boolean>;
|
starredOverrides: Record<string, boolean>;
|
||||||
setStarredOverride: (id: string, starred: boolean) => void;
|
setStarredOverride: (id: string, starred: boolean) => void;
|
||||||
lastfmLovedCache: Record<string, boolean>;
|
networkLovedCache: Record<string, boolean>;
|
||||||
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
setNetworkLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
||||||
openSongInfo: (id: string) => void;
|
openSongInfo: (id: string) => void;
|
||||||
userRatingOverrides: Record<string, number>;
|
userRatingOverrides: Record<string, number>;
|
||||||
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
|
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
import type { CoverArtRef } from '../../cover/types';
|
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 type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
|
||||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
|
import { useEnrichmentPrimaryLabel } from '../../hooks/useEnrichmentPrimaryLabel';
|
||||||
|
import { useEnrichmentPrimaryIcon } from '../../hooks/useEnrichmentPrimaryIcon';
|
||||||
|
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
track: { title: string; artist: string; album: string; year?: number;
|
track: { title: string; artist: string; album: string; year?: number;
|
||||||
@@ -19,16 +21,16 @@ interface HeroProps {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
playCount?: number;
|
playCount?: number;
|
||||||
userRatingOverride?: number;
|
userRatingOverride?: number;
|
||||||
lfmTrack: LastfmTrackInfo | null;
|
networkTrack: TrackStats | null;
|
||||||
lfmArtist: LastfmArtistStats | null;
|
networkArtist: ArtistStats | null;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
lfmLoved: boolean;
|
networkLoved: boolean;
|
||||||
lfmLoveEnabled: boolean;
|
networkLoveEnabled: boolean;
|
||||||
activeLyricsTab: boolean;
|
activeLyricsTab: boolean;
|
||||||
coverRef?: CoverArtRef;
|
coverRef?: CoverArtRef;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onToggleStar: () => void;
|
onToggleStar: () => void;
|
||||||
onToggleLfmLove: () => void;
|
onToggleNetworkLove: () => void;
|
||||||
onOpenLyrics: () => 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 { t } = useTranslation();
|
||||||
|
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
||||||
|
const networkIcon = useEnrichmentPrimaryIcon();
|
||||||
const rating = userRatingOverride ?? track.userRating;
|
const rating = userRatingOverride ?? track.userRating;
|
||||||
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
|
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
|
||||||
const releaseAge = track.year ? new Date().getFullYear() - track.year : 0;
|
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')}>
|
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}>
|
||||||
<Heart size={18} fill={starred ? 'var(--highlight)' : 'none'} color={starred ? 'var(--highlight)' : 'currentColor'} />
|
<Heart size={18} fill={starred ? 'var(--highlight)' : 'none'} color={starred ? 'var(--highlight)' : 'currentColor'} />
|
||||||
</button>
|
</button>
|
||||||
{lfmLoveEnabled && (
|
{networkLoveEnabled && (
|
||||||
<button onClick={onToggleLfmLove}
|
<button onClick={onToggleNetworkLove}
|
||||||
className={`np-dash-icon-btn np-dash-lfm-btn${lfmLoved ? ' is-loved' : ''}`}
|
className={`np-dash-icon-btn np-dash-network-btn${networkLoved ? ' is-loved' : ''}`}
|
||||||
data-tooltip={lfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}>
|
data-tooltip={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}>
|
||||||
<LastfmIcon size={18} />
|
{renderPresetIcon(networkIcon ?? 'lastfm', 18)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="np-dash-icon-btn"
|
<button className="np-dash-icon-btn"
|
||||||
@@ -140,40 +144,40 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(lfmTrack || lfmArtist) && (
|
{(networkTrack || networkArtist) && (
|
||||||
<div className="np-dash-hero-lfm">
|
<div className="np-dash-hero-network">
|
||||||
<div className="np-dash-hero-lfm-heading">
|
<div className="np-dash-hero-network-heading">
|
||||||
<span className="np-dash-hero-lfm-badge">Last.fm</span>
|
<span className="np-dash-hero-network-badge">{networkLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{lfmTrack && (
|
{networkTrack && (
|
||||||
<div className="np-dash-hero-lfm-row">
|
<div className="np-dash-hero-network-row">
|
||||||
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
|
<span className="np-dash-hero-network-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
|
||||||
<span className="np-dash-hero-lfm-sep">—</span>
|
<span className="np-dash-hero-network-sep">—</span>
|
||||||
<span>{t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
<span>{t('nowPlaying.listenersN', { n: networkTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||||||
<span className="np-dash-hero-lfm-dot">·</span>
|
<span className="np-dash-hero-network-dot">·</span>
|
||||||
<span>{t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
<span>{t('nowPlaying.scrobblesN', { n: networkTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||||||
{lfmTrack.userPlaycount != null && (
|
{networkTrack.userPlaycount != null && (
|
||||||
<>
|
<>
|
||||||
<span className="np-dash-hero-lfm-dot">·</span>
|
<span className="np-dash-hero-network-dot">·</span>
|
||||||
<span className="np-dash-hero-lfm-you">
|
<span className="np-dash-hero-network-you">
|
||||||
{t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
{t('nowPlaying.playsByYouN', { n: networkTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{lfmArtist && (
|
{networkArtist && (
|
||||||
<div className="np-dash-hero-lfm-row">
|
<div className="np-dash-hero-network-row">
|
||||||
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
|
<span className="np-dash-hero-network-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
|
||||||
<span className="np-dash-hero-lfm-sep">—</span>
|
<span className="np-dash-hero-network-sep">—</span>
|
||||||
<span>{t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
<span>{t('nowPlaying.listenersN', { n: networkArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||||||
<span className="np-dash-hero-lfm-dot">·</span>
|
<span className="np-dash-hero-network-dot">·</span>
|
||||||
<span>{t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
<span>{t('nowPlaying.scrobblesN', { n: networkArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||||||
{lfmArtist.userPlaycount != null && (
|
{networkArtist.userPlaycount != null && (
|
||||||
<>
|
<>
|
||||||
<span className="np-dash-hero-lfm-dot">·</span>
|
<span className="np-dash-hero-network-dot">·</span>
|
||||||
<span className="np-dash-hero-lfm-you">
|
<span className="np-dash-hero-network-you">
|
||||||
{t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
{t('nowPlaying.playsByYouN', { n: networkArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { CoverArtImage } from '../../cover/CoverArtImage';
|
|||||||
import { albumCoverRef } from '../../cover/ref';
|
import { albumCoverRef } from '../../cover/ref';
|
||||||
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
||||||
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
|
||||||
import MarqueeText from '../MarqueeText';
|
import MarqueeText from '../MarqueeText';
|
||||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
import { useEnrichmentPrimaryLabel } from '../../hooks/useEnrichmentPrimaryLabel';
|
||||||
|
import { useEnrichmentPrimaryIcon } from '../../hooks/useEnrichmentPrimaryIcon';
|
||||||
|
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||||
import {
|
import {
|
||||||
usePlayerBarLayoutStore,
|
usePlayerBarLayoutStore,
|
||||||
type PlayerBarLayoutItemId,
|
type PlayerBarLayoutItemId,
|
||||||
@@ -37,9 +39,9 @@ interface Props {
|
|||||||
previewingTrack: PreviewingTrack | null;
|
previewingTrack: PreviewingTrack | null;
|
||||||
isStarred: boolean;
|
isStarred: boolean;
|
||||||
toggleStar: () => void;
|
toggleStar: () => void;
|
||||||
lastfmSessionKey: string | null;
|
enrichmentPrimaryId: string | null;
|
||||||
lastfmLoved: boolean;
|
networkLoved: boolean;
|
||||||
toggleLastfmLove: () => void;
|
toggleNetworkLove: () => void;
|
||||||
userRatingOverrides: Record<string, number>;
|
userRatingOverrides: Record<string, number>;
|
||||||
toggleFullscreen: () => void;
|
toggleFullscreen: () => void;
|
||||||
navigate: (to: string) => void | Promise<void>;
|
navigate: (to: string) => void | Promise<void>;
|
||||||
@@ -51,11 +53,13 @@ export function PlayerTrackInfo({
|
|||||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
||||||
coverArtId, displayTitle, displayArtist, displayArtistRefs,
|
coverArtId, displayTitle, displayArtist, displayArtistRefs,
|
||||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
enrichmentPrimaryId, networkLoved, toggleNetworkLove,
|
||||||
userRatingOverrides, toggleFullscreen,
|
userRatingOverrides, toggleFullscreen,
|
||||||
navigate, openContextMenu, t,
|
navigate, openContextMenu, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||||
|
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
||||||
|
const networkIcon = useEnrichmentPrimaryIcon();
|
||||||
const playbackCoverRef = usePlaybackTrackCoverRef(
|
const playbackCoverRef = usePlaybackTrackCoverRef(
|
||||||
showPreviewMeta ? null : currentTrack ?? undefined,
|
showPreviewMeta ? null : currentTrack ?? undefined,
|
||||||
);
|
);
|
||||||
@@ -197,15 +201,15 @@ export function PlayerTrackInfo({
|
|||||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentTrack && !isRadio && lastfmSessionKey && isLayoutVisible('lastfmLove') && (
|
{currentTrack && !isRadio && enrichmentPrimaryId !== null && isLayoutVisible('lastfmLove') && (
|
||||||
<button
|
<button
|
||||||
className="player-btn player-btn-sm player-love-btn"
|
className="player-btn player-btn-sm player-love-btn"
|
||||||
onClick={toggleLastfmLove}
|
onClick={toggleNetworkLove}
|
||||||
aria-label={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
aria-label={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||||
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
data-tooltip={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||||
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
style={{ color: networkLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<LastfmIcon size={15} />
|
{renderPresetIcon(networkIcon ?? 'lastfm', 15)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,67 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
|
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 { useAuthStore } from '../../store/authStore';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
|
||||||
import SettingsSubSection from '../SettingsSubSection';
|
import SettingsSubSection from '../SettingsSubSection';
|
||||||
|
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
|
||||||
|
|
||||||
export function IntegrationsTab() {
|
export function IntegrationsTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
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<string | null>(null);
|
|
||||||
const [lfmError, setLfmError] = useState<string | null>(null);
|
|
||||||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -82,70 +27,8 @@ export function IntegrationsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last.fm */}
|
{/* Music Network — scrobbling + enrichment across multiple services */}
|
||||||
<SettingsSubSection
|
<MusicNetworkSection />
|
||||||
title={t('settings.lfmTitle')}
|
|
||||||
icon={<LastfmIcon size={16} />}
|
|
||||||
>
|
|
||||||
<div className="settings-card">
|
|
||||||
{auth.lastfmSessionKey ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
|
||||||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
|
||||||
{lfmUserInfo && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
|
||||||
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
|
|
||||||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
|
||||||
onClick={() => auth.disconnectLastfm()}
|
|
||||||
>
|
|
||||||
{t('settings.lfmDisconnect')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
|
||||||
</div>
|
|
||||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
|
||||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : lfmState === 'waiting' ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
||||||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
|
||||||
{t('settings.lfmConnecting')}
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
|
||||||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
|
||||||
{t('settings.lfmConnectDesc')}
|
|
||||||
</p>
|
|
||||||
{lfmState === 'error' && (
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
|
||||||
{t('settings.lfmConnect')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsSubSection>
|
|
||||||
|
|
||||||
{/* Discord Rich Presence */}
|
{/* Discord Rich Presence */}
|
||||||
<SettingsSubSection
|
<SettingsSubSection
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
errorI18nKey,
|
||||||
|
isMusicNetworkError,
|
||||||
|
listPresets,
|
||||||
|
type BuiltinPreset,
|
||||||
|
type PresetId,
|
||||||
|
} from '../../../music-network';
|
||||||
|
import { renderPresetIcon } from './presetIcon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Add a service" list, driven entirely by the preset registry. Token-poll
|
||||||
|
* presets connect immediately (browser flow); paste presets expand an inline
|
||||||
|
* form built from the manifest's `fields`. Adding a provider needs no edit here.
|
||||||
|
*/
|
||||||
|
export function ConnectProviderForm({
|
||||||
|
connectedPresetIds,
|
||||||
|
onConnect,
|
||||||
|
}: {
|
||||||
|
connectedPresetIds: PresetId[];
|
||||||
|
onConnect: (presetId: PresetId, fields: Record<string, string>) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState<PresetId | null>(null);
|
||||||
|
const [fields, setFields] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState<PresetId | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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<string, string>) => {
|
||||||
|
// 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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13 }}>{t('musicNetwork.addService')}</div>
|
||||||
|
{available.map(preset => {
|
||||||
|
const id = preset.manifest.presetId;
|
||||||
|
const isExpanded = expanded === id;
|
||||||
|
const isBusy = busy === id;
|
||||||
|
return (
|
||||||
|
<div key={id} className="settings-card" style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{preset.manifest.displayName}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(preset.manifest.descriptionKey)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 12px', flexShrink: 0 }}
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => onPrimaryAction(preset)}
|
||||||
|
>
|
||||||
|
{isBusy ? t('musicNetwork.connecting') : t('musicNetwork.connect')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||||
|
{preset.manifest.fields.map(field => (
|
||||||
|
<div className="form-group" key={field.name}>
|
||||||
|
<label style={{ fontSize: 12 }}>{t(field.labelKey)}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={field.type === 'password' ? 'password' : field.type === 'url' ? 'url' : 'text'}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
value={fields[field.name] ?? ''}
|
||||||
|
onChange={e => setFields(f => ({ ...f, [field.name]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{field.helpKey && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, whiteSpace: 'pre-line', lineHeight: 1.5 }}>
|
||||||
|
{t(field.helpKey)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void run(id, fields)}
|
||||||
|
>
|
||||||
|
{isBusy ? t('musicNetwork.connecting') : t('musicNetwork.connect')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{error && <p style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('musicNetwork.primaryLabel')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
value={primaryId ?? ''}
|
||||||
|
options={options}
|
||||||
|
onChange={v => onChange(v || null)}
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="settings-privacy-notice" role="note" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<AlertTriangle size={16} className="settings-privacy-notice-icon" aria-hidden="true" />
|
||||||
|
<div className="settings-privacy-notice-body">{t('musicNetwork.malojaProxyWarning')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<UserProfile | null>(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<string, string>) => {
|
||||||
|
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 (
|
||||||
|
<SettingsSubSection title={t('musicNetwork.title')} icon={<Share2 size={16} />}>
|
||||||
|
<div className="settings-card">
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
|
||||||
|
{t('musicNetwork.desc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{t('musicNetwork.masterToggle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
|
||||||
|
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<EnrichmentPrimarySelect
|
||||||
|
accounts={accounts}
|
||||||
|
primaryId={enrichmentPrimaryId}
|
||||||
|
onChange={setPrimary}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{accounts.map(account => (
|
||||||
|
<ScrobbleDestinationCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
profile={account.id === enrichmentPrimaryId ? primaryProfile : null}
|
||||||
|
onToggleScrobble={v => toggleScrobble(account.id, v)}
|
||||||
|
onDisconnect={() => disconnect(account.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalojaProxyWarning accounts={accounts} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
|
||||||
|
</div>
|
||||||
|
</SettingsSubSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 8%, transparent)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header: identity + status + profile stats */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem' }}>
|
||||||
|
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(icon, 20)}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{account.label}
|
||||||
|
<span
|
||||||
|
className={`connection-led connection-led--${account.sessionError ? 'disconnected' : 'connected'}`}
|
||||||
|
data-tooltip={account.sessionError ? t('musicNetwork.statusError') : t('musicNetwork.statusConnected')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{account.username && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>@{account.username}</div>
|
||||||
|
)}
|
||||||
|
{profile && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<span>{t('musicNetwork.scrobbles', { n: profile.playcount.toLocaleString() })}</span>
|
||||||
|
{profile.registeredAt > 0 && (
|
||||||
|
<span>{t('musicNetwork.memberSince', { year: new Date(profile.registeredAt * 1000).getFullYear() })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: the scrobble toggle (clearly inside this account's block) + disconnect */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.6rem 1rem',
|
||||||
|
borderTop: '1px solid color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>
|
||||||
|
<span className="toggle-switch" style={{ flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={account.scrobbleEnabled}
|
||||||
|
onChange={e => onToggleScrobble(e.target.checked)}
|
||||||
|
aria-label={t('musicNetwork.scrobbleHere')}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</span>
|
||||||
|
{t('musicNetwork.scrobbleHere')}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||||
|
onClick={onDisconnect}
|
||||||
|
>
|
||||||
|
{t('musicNetwork.disconnect')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <LastfmIcon size={size} />;
|
||||||
|
case 'rocksky':
|
||||||
|
return <Music2 size={size} />;
|
||||||
|
case 'listenbrainz':
|
||||||
|
return <Radio size={size} />;
|
||||||
|
case 'koito':
|
||||||
|
return <Headphones size={size} />;
|
||||||
|
case 'maloja':
|
||||||
|
return <Server size={size} />;
|
||||||
|
case 'custom':
|
||||||
|
default:
|
||||||
|
return <Globe size={size} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Account[]>(
|
||||||
|
() =>
|
||||||
|
accounts.map(a => ({
|
||||||
|
...a,
|
||||||
|
roles:
|
||||||
|
getPreset(a.presetId)?.manifest.defaultRoles
|
||||||
|
?? { scrobble: false, enrichmentEligible: false },
|
||||||
|
})),
|
||||||
|
[accounts],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accounts: richAccounts, enrichmentPrimaryId, scrobblingMasterEnabled };
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
|
{ 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.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: '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.discordRichPresence', keywords: 'discord rich presence rpc' },
|
||||||
{ tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' },
|
{ tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' },
|
||||||
{ tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
|
{ tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
|
import { getMusicNetworkRuntime } from '../music-network';
|
||||||
import { search } from '../api/subsonicSearch';
|
import { search } from '../api/subsonicSearch';
|
||||||
import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -24,15 +24,16 @@ export function useArtistSimilarArtists(
|
|||||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||||
);
|
);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null);
|
||||||
|
|
||||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||||
const [similarLoading, setSimilarLoading] = useState(false);
|
const [similarLoading, setSimilarLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
if (!artist || audiomuseNavidromeEnabled || !enrichmentConfigured) return;
|
||||||
setSimilarArtists([]);
|
setSimilarArtists([]);
|
||||||
setSimilarLoading(true);
|
setSimilarLoading(true);
|
||||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
|
||||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
names.slice(0, 30).map(name =>
|
names.slice(0, 30).map(name =>
|
||||||
@@ -53,17 +54,17 @@ export function useArtistSimilarArtists(
|
|||||||
setSimilarLoading(false);
|
setSimilarLoading(false);
|
||||||
}).catch(() => setSimilarLoading(false));
|
}).catch(() => setSimilarLoading(false));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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). */
|
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
|
if (!artist || !audiomuseNavidromeEnabled || !enrichmentConfigured) return;
|
||||||
if (artistInfoLoading) return;
|
if (artistInfoLoading) return;
|
||||||
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
||||||
|
|
||||||
setSimilarArtists([]);
|
setSimilarArtists([]);
|
||||||
setSimilarLoading(true);
|
setSimilarLoading(true);
|
||||||
lastfmGetSimilarArtists(artist.name).then(async names => {
|
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
|
||||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
names.slice(0, 30).map(name =>
|
names.slice(0, 30).map(name =>
|
||||||
@@ -90,6 +91,7 @@ export function useArtistSimilarArtists(
|
|||||||
audiomuseNavidromeEnabled,
|
audiomuseNavidromeEnabled,
|
||||||
artistInfoLoading,
|
artistInfoLoading,
|
||||||
info?.similarArtist?.length,
|
info?.similarArtist?.length,
|
||||||
|
enrichmentConfigured,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 <provider>" 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;
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum } from '../api/sub
|
|||||||
vi.mock('../api/subsonicArtists');
|
vi.mock('../api/subsonicArtists');
|
||||||
vi.mock('../api/subsonicLibrary');
|
vi.mock('../api/subsonicLibrary');
|
||||||
vi.mock('../api/bandsintown');
|
vi.mock('../api/bandsintown');
|
||||||
vi.mock('../api/lastfm');
|
|
||||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||||
shouldAttemptSubsonicForServer: vi.fn(() => true),
|
shouldAttemptSubsonicForServer: vi.fn(() => true),
|
||||||
}));
|
}));
|
||||||
@@ -25,7 +24,6 @@ import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetwork
|
|||||||
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
||||||
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
||||||
import { fetchBandsintownEvents } from '../api/bandsintown';
|
import { fetchBandsintownEvents } from '../api/bandsintown';
|
||||||
import { lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured } from '../api/lastfm';
|
|
||||||
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers';
|
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers';
|
||||||
|
|
||||||
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
|
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
|
||||||
@@ -44,7 +42,7 @@ const baseDeps: NowPlayingFetchersDeps = {
|
|||||||
artistName: '',
|
artistName: '',
|
||||||
enableBandsintown: false,
|
enableBandsintown: false,
|
||||||
audiomuseNavidromeEnabled: false,
|
audiomuseNavidromeEnabled: false,
|
||||||
lastfmUsername: '',
|
enrichmentKey: '',
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
subsonicServerId: 'srv1',
|
subsonicServerId: 'srv1',
|
||||||
fetchEnabled: true,
|
fetchEnabled: true,
|
||||||
@@ -54,9 +52,6 @@ beforeEach(() => {
|
|||||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as any);
|
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as any);
|
||||||
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
|
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
|
||||||
vi.mocked(lastfmIsConfigured).mockReturnValue(false);
|
|
||||||
vi.mocked(lastfmGetTrackInfo).mockResolvedValue(null);
|
|
||||||
vi.mocked(lastfmGetArtistStats).mockResolvedValue(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { getArtistInfoForServer } from '../api/subsonicArtists';
|
|||||||
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '../utils/library/nowPlayingMetadataResolve';
|
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '../utils/library/nowPlayingMetadataResolve';
|
||||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||||
import {
|
import type { ArtistStats, TrackStats } from '../music-network';
|
||||||
lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured,
|
import { getMusicNetworkRuntimeOrNull } from '../music-network';
|
||||||
type LastfmArtistStats, type LastfmTrackInfo,
|
|
||||||
} from '../api/lastfm';
|
|
||||||
import { makeCache } from '../utils/cache/nowPlayingCache';
|
import { makeCache } from '../utils/cache/nowPlayingCache';
|
||||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
import { useConnectionStatus } from './useConnectionStatus';
|
||||||
@@ -18,8 +16,8 @@ const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[]
|
|||||||
const topSongsCache = makeCache<SubsonicSong[]>();
|
const topSongsCache = makeCache<SubsonicSong[]>();
|
||||||
const tourCache = makeCache<BandsintownEvent[]>();
|
const tourCache = makeCache<BandsintownEvent[]>();
|
||||||
const discographyCache = makeCache<SubsonicAlbum[]>();
|
const discographyCache = makeCache<SubsonicAlbum[]>();
|
||||||
const lfmTrackCache = makeCache<LastfmTrackInfo | null>();
|
const networkTrackCache = makeCache<TrackStats | null>();
|
||||||
const lfmArtistCache = makeCache<LastfmArtistStats | null>();
|
const networkArtistCache = makeCache<ArtistStats | null>();
|
||||||
|
|
||||||
export interface NowPlayingFetchersDeps {
|
export interface NowPlayingFetchersDeps {
|
||||||
songId: string | undefined;
|
songId: string | undefined;
|
||||||
@@ -28,7 +26,7 @@ export interface NowPlayingFetchersDeps {
|
|||||||
artistName: string;
|
artistName: string;
|
||||||
enableBandsintown: boolean;
|
enableBandsintown: boolean;
|
||||||
audiomuseNavidromeEnabled: boolean;
|
audiomuseNavidromeEnabled: boolean;
|
||||||
lastfmUsername: string;
|
enrichmentKey: string;
|
||||||
currentTrack: { artist: string; title: string } | null;
|
currentTrack: { artist: string; title: string } | null;
|
||||||
/** Subsonic server for API calls — must match the playing queue server. */
|
/** Subsonic server for API calls — must match the playing queue server. */
|
||||||
subsonicServerId: string;
|
subsonicServerId: string;
|
||||||
@@ -49,8 +47,8 @@ export interface NowPlayingFetchersResult {
|
|||||||
tourEvents: BandsintownEvent[];
|
tourEvents: BandsintownEvent[];
|
||||||
tourLoading: boolean;
|
tourLoading: boolean;
|
||||||
discography: SubsonicAlbum[];
|
discography: SubsonicAlbum[];
|
||||||
lfmTrack: LastfmTrackInfo | null;
|
networkTrack: TrackStats | null;
|
||||||
lfmArtist: LastfmArtistStats | null;
|
networkArtist: ArtistStats | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subsonicCacheKey(serverId: string, id: string): string {
|
function subsonicCacheKey(serverId: string, id: string): string {
|
||||||
@@ -82,7 +80,7 @@ export async function prewarmNowPlayingFetchers(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const {
|
||||||
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
||||||
lastfmUsername, currentTrack, subsonicServerId, fetchEnabled = true,
|
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
if (!fetchEnabled || !subsonicServerId) return;
|
if (!fetchEnabled || !subsonicServerId) return;
|
||||||
@@ -153,21 +151,22 @@ export async function prewarmNowPlayingFetchers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastfmIsConfigured() && currentTrack) {
|
const prewarmRuntime = getMusicNetworkRuntimeOrNull();
|
||||||
const trackKey = `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}`;
|
if (prewarmRuntime?.getEnrichmentPrimaryId() && currentTrack) {
|
||||||
if (lfmTrackCache.get(trackKey) === undefined) {
|
const trackKey = `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}`;
|
||||||
|
if (networkTrackCache.get(trackKey) === undefined) {
|
||||||
jobs.push(
|
jobs.push(
|
||||||
lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined)
|
prewarmRuntime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
|
||||||
.then(v => lfmTrackCache.set(trackKey, v))
|
.then(v => networkTrackCache.set(trackKey, v))
|
||||||
.catch(() => lfmTrackCache.set(trackKey, null)),
|
.catch(() => networkTrackCache.set(trackKey, null)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const artistKey = `${currentTrack.artist} ${lastfmUsername}`;
|
const artistKey = `${currentTrack.artist} ${enrichmentKey}`;
|
||||||
if (lfmArtistCache.get(artistKey) === undefined) {
|
if (networkArtistCache.get(artistKey) === undefined) {
|
||||||
jobs.push(
|
jobs.push(
|
||||||
lastfmGetArtistStats(currentTrack.artist, lastfmUsername || undefined)
|
prewarmRuntime.getArtistStats(currentTrack.artist)
|
||||||
.then(v => lfmArtistCache.set(artistKey, v))
|
.then(v => networkArtistCache.set(artistKey, v))
|
||||||
.catch(() => lfmArtistCache.set(artistKey, null)),
|
.catch(() => networkArtistCache.set(artistKey, null)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +177,7 @@ export async function prewarmNowPlayingFetchers(
|
|||||||
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
|
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
|
||||||
const {
|
const {
|
||||||
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
||||||
lastfmUsername, currentTrack, subsonicServerId, fetchEnabled = true,
|
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
// id-keyed entity state — seeded from TTL cache so same-artist song switches
|
// 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<KeySlot<BandsintownEvent[]>>(() =>
|
const [tourEventsEntry, setTourEventsEntry] = useState<KeySlot<BandsintownEvent[]>>(() =>
|
||||||
seedKeySlot(tourKey, k => tourCache.get(k)));
|
seedKeySlot(tourKey, k => tourCache.get(k)));
|
||||||
const [tourLoading, setTourLoading] = useState(false);
|
const [tourLoading, setTourLoading] = useState(false);
|
||||||
const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : '';
|
const networkTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}` : '';
|
||||||
const lfmArtistKey = artistName ? `${artistName} ${lastfmUsername}` : '';
|
const networkArtistKey = artistName ? `${artistName} ${enrichmentKey}` : '';
|
||||||
const [lfmTrackEntry, setLfmTrackEntry] = useState<KeySlot<LastfmTrackInfo | null>>(() =>
|
const [networkTrackEntry, setNetworkTrackEntry] = useState<KeySlot<TrackStats | null>>(() =>
|
||||||
seedKeySlot(lfmTrackKey, k => lfmTrackCache.get(k)));
|
seedKeySlot(networkTrackKey, k => networkTrackCache.get(k)));
|
||||||
const [lfmArtistEntry, setLfmArtistEntry] = useState<KeySlot<LastfmArtistStats | null>>(() =>
|
const [networkArtistEntry, setNetworkArtistEntry] = useState<KeySlot<ArtistStats | null>>(() =>
|
||||||
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
|
seedKeySlot(networkArtistKey, k => networkArtistCache.get(k)));
|
||||||
|
|
||||||
const { status: connStatus } = useConnectionStatus();
|
const { status: connStatus } = useConnectionStatus();
|
||||||
// Gate split (PR #1049): index-first resolvers run whenever there's a server id
|
// 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; };
|
return () => { cancelled = true; };
|
||||||
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
|
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
|
||||||
|
|
||||||
// Last.fm track info (per-track)
|
// Enrichment track stats (per-track, from the enrichment primary)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastfmIsConfigured() || !currentTrack || !lfmTrackKey) { setLfmTrackEntry(null); return; }
|
const runtime = getMusicNetworkRuntimeOrNull();
|
||||||
const cached = lfmTrackCache.get(lfmTrackKey);
|
if (!runtime?.getEnrichmentPrimaryId() || !currentTrack || !networkTrackKey) { setNetworkTrackEntry(null); return; }
|
||||||
if (cached !== undefined) { setLfmTrackEntry({ key: lfmTrackKey, value: cached }); return; }
|
const cached = networkTrackCache.get(networkTrackKey);
|
||||||
setLfmTrackEntry(null);
|
if (cached !== undefined) { setNetworkTrackEntry({ key: networkTrackKey, value: cached }); return; }
|
||||||
|
setNetworkTrackEntry(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined)
|
runtime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
|
||||||
.then(v => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, v); setLfmTrackEntry({ key: lfmTrackKey, value: v }); } })
|
.then(v => { if (!cancelled) { networkTrackCache.set(networkTrackKey, v); setNetworkTrackEntry({ key: networkTrackKey, value: v }); } })
|
||||||
.catch(() => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, null); setLfmTrackEntry({ key: lfmTrackKey, value: null }); } });
|
.catch(() => { if (!cancelled) { networkTrackCache.set(networkTrackKey, null); setNetworkTrackEntry({ key: networkTrackKey, value: null }); } });
|
||||||
return () => { cancelled = true; };
|
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(() => {
|
useEffect(() => {
|
||||||
if (!lastfmIsConfigured() || !artistName || !lfmArtistKey) { setLfmArtistEntry(null); return; }
|
const runtime = getMusicNetworkRuntimeOrNull();
|
||||||
const cached = lfmArtistCache.get(lfmArtistKey);
|
if (!runtime?.getEnrichmentPrimaryId() || !artistName || !networkArtistKey) { setNetworkArtistEntry(null); return; }
|
||||||
if (cached !== undefined) { setLfmArtistEntry({ key: lfmArtistKey, value: cached }); return; }
|
const cached = networkArtistCache.get(networkArtistKey);
|
||||||
setLfmArtistEntry(null);
|
if (cached !== undefined) { setNetworkArtistEntry({ key: networkArtistKey, value: cached }); return; }
|
||||||
|
setNetworkArtistEntry(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
lastfmGetArtistStats(artistName, lastfmUsername || undefined)
|
runtime.getArtistStats(artistName)
|
||||||
.then(v => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, v); setLfmArtistEntry({ key: lfmArtistKey, value: v }); } })
|
.then(v => { if (!cancelled) { networkArtistCache.set(networkArtistKey, v); setNetworkArtistEntry({ key: networkArtistKey, value: v }); } })
|
||||||
.catch(() => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, null); setLfmArtistEntry({ key: lfmArtistKey, value: null }); } });
|
.catch(() => { if (!cancelled) { networkArtistCache.set(networkArtistKey, null); setNetworkArtistEntry({ key: networkArtistKey, value: null }); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [lfmArtistKey, artistName, lastfmUsername]);
|
}, [networkArtistKey, artistName, enrichmentKey]);
|
||||||
|
|
||||||
// Gate id-keyed slots on id-match so consumers never see a value paired
|
// 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
|
// 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 discography = discographyEntry && discographyEntry.id === artistId ? discographyEntry.value : [];
|
||||||
const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : [];
|
const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : [];
|
||||||
const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : [];
|
const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : [];
|
||||||
const lfmTrack = lfmTrackEntry && lfmTrackEntry.key === lfmTrackKey ? lfmTrackEntry.value : null;
|
const networkTrack = networkTrackEntry && networkTrackEntry.key === networkTrackKey ? networkTrackEntry.value : null;
|
||||||
const lfmArtist = lfmArtistEntry && lfmArtistEntry.key === lfmArtistKey ? lfmArtistEntry.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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function useNowPlayingPrewarm(): void {
|
|||||||
const audiomuseNavidromeEnabled = useAuthStore(
|
const audiomuseNavidromeEnabled = useAuthStore(
|
||||||
s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false),
|
s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false),
|
||||||
);
|
);
|
||||||
const lastfmUsername = useAuthStore(s => s.lastfmUsername);
|
const enrichmentKey = useAuthStore(s => s.enrichmentPrimaryId ?? '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTrack || !playbackServerId) return;
|
if (!currentTrack || !playbackServerId) return;
|
||||||
@@ -61,7 +61,7 @@ export function useNowPlayingPrewarm(): void {
|
|||||||
artistName: primary.name ?? currentTrack.artist,
|
artistName: primary.name ?? currentTrack.artist,
|
||||||
enableBandsintown,
|
enableBandsintown,
|
||||||
audiomuseNavidromeEnabled,
|
audiomuseNavidromeEnabled,
|
||||||
lastfmUsername,
|
enrichmentKey,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
subsonicServerId: playbackServerId,
|
subsonicServerId: playbackServerId,
|
||||||
// No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single
|
// No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single
|
||||||
@@ -92,7 +92,7 @@ export function useNowPlayingPrewarm(): void {
|
|||||||
playbackServerId,
|
playbackServerId,
|
||||||
enableBandsintown,
|
enableBandsintown,
|
||||||
audiomuseNavidromeEnabled,
|
audiomuseNavidromeEnabled,
|
||||||
lastfmUsername,
|
enrichmentKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,28 +2,25 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { queueSongStar } from '../store/pendingStarSync';
|
import { queueSongStar } from '../store/pendingStarSync';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import {
|
import type { TrackStats } from '../music-network';
|
||||||
lastfmLoveTrack, lastfmUnloveTrack,
|
import { getMusicNetworkRuntime } from '../music-network';
|
||||||
type LastfmTrackInfo,
|
|
||||||
} from '../api/lastfm';
|
|
||||||
|
|
||||||
export interface NowPlayingStarLoveDeps {
|
export interface NowPlayingStarLoveDeps {
|
||||||
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
|
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
|
||||||
songMeta: SubsonicSong | null;
|
songMeta: SubsonicSong | null;
|
||||||
lfmTrack: LastfmTrackInfo | null;
|
networkTrack: TrackStats | null;
|
||||||
lfmLoveEnabled: boolean;
|
networkLoveEnabled: boolean;
|
||||||
lastfmSessionKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NowPlayingStarLoveResult {
|
export interface NowPlayingStarLoveResult {
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
lfmLoved: boolean;
|
networkLoved: boolean;
|
||||||
toggleStar: () => Promise<void>;
|
toggleStar: () => Promise<void>;
|
||||||
toggleLfmLove: () => Promise<void>;
|
toggleNetworkLove: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult {
|
export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult {
|
||||||
const { currentTrack, songMeta, lfmTrack, lfmLoveEnabled, lastfmSessionKey } = deps;
|
const { currentTrack, songMeta, networkTrack, networkLoveEnabled } = deps;
|
||||||
|
|
||||||
// Star
|
// Star
|
||||||
const [starred, setStarred] = useState(false);
|
const [starred, setStarred] = useState(false);
|
||||||
@@ -35,15 +32,15 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
|
|||||||
queueSongStar(currentTrack.id, next, currentTrack.serverId);
|
queueSongStar(currentTrack.id, next, currentTrack.serverId);
|
||||||
}, [currentTrack, starred]);
|
}, [currentTrack, starred]);
|
||||||
|
|
||||||
// Last.fm love (seeded from track.getInfo, toggle via love/unlove)
|
// Love (enrichment primary; seeded from track.getInfo, toggle via love/unlove)
|
||||||
const [lfmLoved, setLfmLoved] = useState(false);
|
const [networkLoved, setNetworkLoved] = useState(false);
|
||||||
useEffect(() => { setLfmLoved(!!lfmTrack?.userLoved); }, [lfmTrack]);
|
useEffect(() => { setNetworkLoved(!!networkTrack?.userLoved); }, [networkTrack]);
|
||||||
const toggleLfmLove = useCallback(async () => {
|
const toggleNetworkLove = useCallback(async () => {
|
||||||
if (!currentTrack || !lfmLoveEnabled) return;
|
if (!currentTrack || !networkLoveEnabled) return;
|
||||||
const track = { title: currentTrack.title, artist: currentTrack.artist };
|
const track = { title: currentTrack.title, artist: currentTrack.artist };
|
||||||
if (lfmLoved) { await lastfmUnloveTrack(track, lastfmSessionKey); setLfmLoved(false); }
|
if (networkLoved) { await getMusicNetworkRuntime().setTrackLoved(track, false); setNetworkLoved(false); }
|
||||||
else { await lastfmLoveTrack (track, lastfmSessionKey); setLfmLoved(true); }
|
else { await getMusicNetworkRuntime().setTrackLoved(track, true); setNetworkLoved(true); }
|
||||||
}, [currentTrack, lfmLoved, lfmLoveEnabled, lastfmSessionKey]);
|
}, [currentTrack, networkLoved, networkLoveEnabled]);
|
||||||
|
|
||||||
return { starred, lfmLoved, toggleStar, toggleLfmLove };
|
return { starred, networkLoved, toggleStar, toggleNetworkLove };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Klicken, um einen anderen gespeicherten Server zu wählen.',
|
switchServerHint: 'Klicken, um einen anderen gespeicherten Server zu wählen.',
|
||||||
manageServers: 'Server verwalten…',
|
manageServers: 'Server verwalten…',
|
||||||
switchFailed: 'Wechsel fehlgeschlagen — Server nicht erreichbar.',
|
switchFailed: 'Wechsel fehlgeschlagen — Server nicht erreichbar.',
|
||||||
lastfmConnected: 'Last.fm verbunden als @{{user}}',
|
|
||||||
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
|
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.',
|
cliMixNeedsTrack: 'Es läuft nichts — starte zuerst die Wiedergabe und führe den Mix-Befehl erneut aus.',
|
||||||
lfmLove: 'Auf Last.fm liken',
|
networkLove: 'Auf {{provider}} liken',
|
||||||
lfmUnlove: 'Last.fm-Like entfernen',
|
networkUnlove: '{{provider}}-Like entfernen',
|
||||||
favorite: 'Favorisieren',
|
favorite: 'Favorisieren',
|
||||||
favoriteArtist: 'Künstler favorisieren',
|
favoriteArtist: 'Künstler favorisieren',
|
||||||
favoriteAlbum: 'Album favorisieren',
|
favoriteAlbum: 'Album favorisieren',
|
||||||
|
|||||||
@@ -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.',
|
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 ─────────────────────────
|
// ── Section 10: Integrationen & Fehlerbehebung ─────────────────────────
|
||||||
s10: 'Integrationen & Fehlerbehebung',
|
s10: 'Integrationen & Fehlerbehebung',
|
||||||
q39: 'Last.fm-Scrobbling?',
|
q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?',
|
||||||
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.',
|
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?',
|
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.',
|
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?',
|
q41: 'Bandsintown-Tour-Daten?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const deTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: 'vor {{count}} Jahr',
|
releasedYearsAgo_one: 'vor {{count}} Jahr',
|
||||||
releasedYearsAgo_other: 'vor {{count}} Jahren',
|
releasedYearsAgo_other: 'vor {{count}} Jahren',
|
||||||
discography: 'Diskografie',
|
discography: 'Diskografie',
|
||||||
lastfmStats: 'Last.fm-Statistik',
|
|
||||||
thisTrack: 'Dieser Titel',
|
thisTrack: 'Dieser Titel',
|
||||||
thisArtist: 'Dieser Künstler',
|
thisArtist: 'Dieser Künstler',
|
||||||
listeners: 'Hörer',
|
listeners: 'Hörer',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} Hörer',
|
listenersN: '{{n}} Hörer',
|
||||||
scrobblesN: '{{n}} Scrobbles',
|
scrobblesN: '{{n}} Scrobbles',
|
||||||
playsByYouN: '{{n}}× von dir gespielt',
|
playsByYouN: '{{n}}× von dir gespielt',
|
||||||
openTrackOnLastfm: 'Track auf Last.fm',
|
|
||||||
openArtistOnLastfm: 'Künstler auf Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (Track)',
|
rgTrackTooltip: 'ReplayGain (Track)',
|
||||||
rgAlbumTooltip: 'ReplayGain (Album)',
|
rgAlbumTooltip: 'ReplayGain (Album)',
|
||||||
rgAutoTooltip: 'ReplayGain (Auto)',
|
rgAutoTooltip: 'ReplayGain (Auto)',
|
||||||
|
|||||||
@@ -137,18 +137,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Suche fehlgeschlagen',
|
eqAutoEqError: 'Suche fehlgeschlagen',
|
||||||
eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen',
|
eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen',
|
||||||
eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden',
|
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',
|
behavior: 'App-Verhalten',
|
||||||
cacheTitle: 'Max. Speichergröße',
|
cacheTitle: 'Max. Speichergröße',
|
||||||
cacheDesc: 'Browser-Cache für Cover und Künstlerbilder (IndexedDB). Wenn voll, werden die ältesten Einträge automatisch entfernt.',
|
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',
|
playerBarReset: 'Zurücksetzen',
|
||||||
playerBarStarRating: 'Sterne-Bewertung',
|
playerBarStarRating: 'Sterne-Bewertung',
|
||||||
playerBarFavorite: 'Favorit (Herz)',
|
playerBarFavorite: 'Favorit (Herz)',
|
||||||
playerBarLastfmLove: 'Last.fm Love',
|
playerBarLastfmLove: 'Love-Button',
|
||||||
playerBarPlaybackRate: 'Wiedergabegeschwindigkeit',
|
playerBarPlaybackRate: 'Wiedergabegeschwindigkeit',
|
||||||
playerBarEqualizer: 'Equalizer',
|
playerBarEqualizer: 'Equalizer',
|
||||||
playerBarMiniPlayer: 'Mini-Player',
|
playerBarMiniPlayer: 'Mini-Player',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} Album',
|
decadeAlbums_one: '{{count}} Album',
|
||||||
decadeAlbums_other: '{{count}} Alben',
|
decadeAlbums_other: '{{count}} Alben',
|
||||||
decadeUnknown: 'Unbekannt',
|
decadeUnknown: 'Unbekannt',
|
||||||
lfmTitle: 'Last.fm Statistiken',
|
lfmTitle: '{{provider}} Statistiken',
|
||||||
lfmTopArtists: 'Top-Künstler',
|
lfmTopArtists: 'Top-Künstler',
|
||||||
lfmTopAlbums: 'Top-Alben',
|
lfmTopAlbums: 'Top-Alben',
|
||||||
lfmTopTracks: 'Top-Titel',
|
lfmTopTracks: 'Top-Titel',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 Monate',
|
lfmPeriod3month: '3 Monate',
|
||||||
lfmPeriod6month: '6 Monate',
|
lfmPeriod6month: '6 Monate',
|
||||||
lfmPeriod12month: '12 Monate',
|
lfmPeriod12month: '12 Monate',
|
||||||
lfmNotConnected: 'Verbinde Last.fm in den Einstellungen, um deine Statistiken zu sehen.',
|
|
||||||
lfmRecentTracks: 'Zuletzt gescrobbelt',
|
lfmRecentTracks: 'Zuletzt gescrobbelt',
|
||||||
lfmNowPlaying: 'Läuft gerade',
|
lfmNowPlaying: 'Läuft gerade',
|
||||||
lfmJustNow: 'gerade eben',
|
lfmJustNow: 'gerade eben',
|
||||||
|
|||||||
@@ -35,6 +35,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Click to choose another saved server.',
|
switchServerHint: 'Click to choose another saved server.',
|
||||||
manageServers: 'Manage servers…',
|
manageServers: 'Manage servers…',
|
||||||
switchFailed: 'Could not switch — server unreachable.',
|
switchFailed: 'Could not switch — server unreachable.',
|
||||||
lastfmConnected: 'Last.fm connected as @{{user}}',
|
|
||||||
lastfmSessionInvalid: 'Session invalid — click to re-connect',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
|
||||||
cliMixNeedsTrack: 'Nothing is playing — start playback first, then run the mix command again.',
|
cliMixNeedsTrack: 'Nothing is playing — start playback first, then run the mix command again.',
|
||||||
lfmLove: 'Love on Last.fm',
|
networkLove: 'Love on {{provider}}',
|
||||||
lfmUnlove: 'Unlove on Last.fm',
|
networkUnlove: 'Unlove on {{provider}}',
|
||||||
favorite: 'Favorite',
|
favorite: 'Favorite',
|
||||||
favoriteArtist: 'Favorite Artist',
|
favoriteArtist: 'Favorite Artist',
|
||||||
favoriteAlbum: 'Favorite Album',
|
favoriteAlbum: 'Favorite Album',
|
||||||
|
|||||||
@@ -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.',
|
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 ─────────────────────────
|
// ── Section 10: Integrations & Troubleshooting ─────────────────────────
|
||||||
s10: 'Integrations & Troubleshooting',
|
s10: 'Integrations & Troubleshooting',
|
||||||
q39: 'Last.fm scrobbling?',
|
q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?',
|
||||||
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.',
|
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?',
|
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.',
|
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?',
|
q41: 'Bandsintown tour dates?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const enTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: '{{count}} year ago',
|
releasedYearsAgo_one: '{{count}} year ago',
|
||||||
releasedYearsAgo_other: '{{count}} years ago',
|
releasedYearsAgo_other: '{{count}} years ago',
|
||||||
discography: 'Discography',
|
discography: 'Discography',
|
||||||
lastfmStats: 'Last.fm stats',
|
|
||||||
thisTrack: 'This track',
|
thisTrack: 'This track',
|
||||||
thisArtist: 'This artist',
|
thisArtist: 'This artist',
|
||||||
listeners: 'listeners',
|
listeners: 'listeners',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} listeners',
|
listenersN: '{{n}} listeners',
|
||||||
scrobblesN: '{{n}} scrobbles',
|
scrobblesN: '{{n}} scrobbles',
|
||||||
playsByYouN: 'played {{n}}× by you',
|
playsByYouN: 'played {{n}}× by you',
|
||||||
openTrackOnLastfm: 'Track on Last.fm',
|
|
||||||
openArtistOnLastfm: 'Artist on Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (track)',
|
rgTrackTooltip: 'ReplayGain (track)',
|
||||||
rgAlbumTooltip: 'ReplayGain (album)',
|
rgAlbumTooltip: 'ReplayGain (album)',
|
||||||
rgAutoTooltip: 'ReplayGain (auto)',
|
rgAutoTooltip: 'ReplayGain (auto)',
|
||||||
|
|||||||
@@ -137,18 +137,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Search failed',
|
eqAutoEqError: 'Search failed',
|
||||||
eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute',
|
eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute',
|
||||||
eqAutoEqFetchError: 'Failed to fetch EQ profile',
|
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',
|
behavior: 'App Behavior',
|
||||||
cacheTitle: 'Max. Storage Size',
|
cacheTitle: 'Max. Storage Size',
|
||||||
cacheDesc: 'In-browser cache for cover art and artist images (IndexedDB). Oldest entries are removed automatically when space runs low.',
|
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',
|
playerBarReset: 'Reset to default',
|
||||||
playerBarStarRating: 'Star rating',
|
playerBarStarRating: 'Star rating',
|
||||||
playerBarFavorite: 'Favorite (heart)',
|
playerBarFavorite: 'Favorite (heart)',
|
||||||
playerBarLastfmLove: 'Last.fm love',
|
playerBarLastfmLove: 'Love button',
|
||||||
playerBarPlaybackRate: 'Playback speed',
|
playerBarPlaybackRate: 'Playback speed',
|
||||||
playerBarEqualizer: 'Equalizer',
|
playerBarEqualizer: 'Equalizer',
|
||||||
playerBarMiniPlayer: 'Mini player',
|
playerBarMiniPlayer: 'Mini player',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} Album',
|
decadeAlbums_one: '{{count}} Album',
|
||||||
decadeAlbums_other: '{{count}} Albums',
|
decadeAlbums_other: '{{count}} Albums',
|
||||||
decadeUnknown: 'Unknown',
|
decadeUnknown: 'Unknown',
|
||||||
lfmTitle: 'Last.fm Stats',
|
lfmTitle: '{{provider}} Stats',
|
||||||
lfmTopArtists: 'Top Artists',
|
lfmTopArtists: 'Top Artists',
|
||||||
lfmTopAlbums: 'Top Albums',
|
lfmTopAlbums: 'Top Albums',
|
||||||
lfmTopTracks: 'Top Tracks',
|
lfmTopTracks: 'Top Tracks',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 Months',
|
lfmPeriod3month: '3 Months',
|
||||||
lfmPeriod6month: '6 Months',
|
lfmPeriod6month: '6 Months',
|
||||||
lfmPeriod12month: '12 Months',
|
lfmPeriod12month: '12 Months',
|
||||||
lfmNotConnected: 'Connect Last.fm in Settings to see your stats.',
|
|
||||||
lfmRecentTracks: 'Recent Scrobbles',
|
lfmRecentTracks: 'Recent Scrobbles',
|
||||||
lfmNowPlaying: 'Now Playing',
|
lfmNowPlaying: 'Now Playing',
|
||||||
lfmJustNow: 'just now',
|
lfmJustNow: 'just now',
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Clic para elegir otro servidor guardado.',
|
switchServerHint: 'Clic para elegir otro servidor guardado.',
|
||||||
manageServers: 'Gestionar servidores…',
|
manageServers: 'Gestionar servidores…',
|
||||||
switchFailed: 'No se pudo cambiar — servidor inalcanzable.',
|
switchFailed: 'No se pudo cambiar — servidor inalcanzable.',
|
||||||
lastfmConnected: 'Last.fm conectado como @{{user}}',
|
|
||||||
lastfmSessionInvalid: 'Sesión inválida — click para reconectar',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Mezcla Instantánea',
|
instantMix: 'Mezcla Instantánea',
|
||||||
instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.',
|
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.',
|
cliMixNeedsTrack: 'No hay nada en reproducción — inicia la reproducción y vuelve a ejecutar el comando de mezcla.',
|
||||||
lfmLove: 'Favorito en Last.fm',
|
networkLove: 'Favorito en {{provider}}',
|
||||||
lfmUnlove: 'Quitar favorito de Last.fm',
|
networkUnlove: 'Quitar favorito de {{provider}}',
|
||||||
favorite: 'Favorito',
|
favorite: 'Favorito',
|
||||||
favoriteArtist: 'Artista Favorito',
|
favoriteArtist: 'Artista Favorito',
|
||||||
favoriteAlbum: 'Álbum Favorito',
|
favoriteAlbum: 'Álbum Favorito',
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export const help = {
|
|||||||
q38: 'Device Sync — ¿copiar música a USB / SD?',
|
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.',
|
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',
|
s10: 'Integraciones y Solución de Problemas',
|
||||||
q39: '¿Scrobbling de Last.fm?',
|
q39: '¿Scrobbling — Last.fm, ListenBrainz, Maloja…?',
|
||||||
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.',
|
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?',
|
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.',
|
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?',
|
q41: '¿Fechas de gira de Bandsintown?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const esTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: 'hace {{count}} año',
|
releasedYearsAgo_one: 'hace {{count}} año',
|
||||||
releasedYearsAgo_other: 'hace {{count}} años',
|
releasedYearsAgo_other: 'hace {{count}} años',
|
||||||
discography: 'Discografía',
|
discography: 'Discografía',
|
||||||
lastfmStats: 'Estadísticas de Last.fm',
|
|
||||||
thisTrack: 'Este tema',
|
thisTrack: 'Este tema',
|
||||||
thisArtist: 'Este artista',
|
thisArtist: 'Este artista',
|
||||||
listeners: 'oyentes',
|
listeners: 'oyentes',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} oyentes',
|
listenersN: '{{n}} oyentes',
|
||||||
scrobblesN: '{{n}} scrobbles',
|
scrobblesN: '{{n}} scrobbles',
|
||||||
playsByYouN: 'reproducido {{n}}× por ti',
|
playsByYouN: 'reproducido {{n}}× por ti',
|
||||||
openTrackOnLastfm: 'Tema en Last.fm',
|
|
||||||
openArtistOnLastfm: 'Artista en Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (pista)',
|
rgTrackTooltip: 'ReplayGain (pista)',
|
||||||
rgAlbumTooltip: 'ReplayGain (álbum)',
|
rgAlbumTooltip: 'ReplayGain (álbum)',
|
||||||
rgAutoTooltip: 'ReplayGain (auto)',
|
rgAutoTooltip: 'ReplayGain (auto)',
|
||||||
|
|||||||
@@ -136,18 +136,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Error de búsqueda',
|
eqAutoEqError: 'Error de búsqueda',
|
||||||
eqAutoEqRateLimit: 'Límite de GitHub alcanzado — intenta de nuevo en un minuto',
|
eqAutoEqRateLimit: 'Límite de GitHub alcanzado — intenta de nuevo en un minuto',
|
||||||
eqAutoEqFetchError: 'Error al obtener perfil EQ',
|
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',
|
behavior: 'Comportamiento de la App',
|
||||||
cacheTitle: 'Tamaño Máx. de Almacenamiento',
|
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.',
|
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',
|
playerBarReset: 'Restablecer a predeterminado',
|
||||||
playerBarStarRating: 'Calificación con estrellas',
|
playerBarStarRating: 'Calificación con estrellas',
|
||||||
playerBarFavorite: 'Favorito (corazón)',
|
playerBarFavorite: 'Favorito (corazón)',
|
||||||
playerBarLastfmLove: 'Last.fm love',
|
playerBarLastfmLove: 'Botón Me gusta',
|
||||||
playerBarPlaybackRate: 'Velocidad de reproducción',
|
playerBarPlaybackRate: 'Velocidad de reproducción',
|
||||||
playerBarEqualizer: 'Ecualizador',
|
playerBarEqualizer: 'Ecualizador',
|
||||||
playerBarMiniPlayer: 'Mini reproductor',
|
playerBarMiniPlayer: 'Mini reproductor',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} Álbum',
|
decadeAlbums_one: '{{count}} Álbum',
|
||||||
decadeAlbums_other: '{{count}} Álbumes',
|
decadeAlbums_other: '{{count}} Álbumes',
|
||||||
decadeUnknown: 'Desconocido',
|
decadeUnknown: 'Desconocido',
|
||||||
lfmTitle: 'Estadísticas de Last.fm',
|
lfmTitle: 'Estadísticas de {{provider}}',
|
||||||
lfmTopArtists: 'Artistas Principales',
|
lfmTopArtists: 'Artistas Principales',
|
||||||
lfmTopAlbums: 'Álbumes Principales',
|
lfmTopAlbums: 'Álbumes Principales',
|
||||||
lfmTopTracks: 'Pistas Principales',
|
lfmTopTracks: 'Pistas Principales',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 Meses',
|
lfmPeriod3month: '3 Meses',
|
||||||
lfmPeriod6month: '6 Meses',
|
lfmPeriod6month: '6 Meses',
|
||||||
lfmPeriod12month: '12 Meses',
|
lfmPeriod12month: '12 Meses',
|
||||||
lfmNotConnected: 'Conecta Last.fm en Configuración para ver tus estadísticas.',
|
|
||||||
lfmRecentTracks: 'Scrobbles Recientes',
|
lfmRecentTracks: 'Scrobbles Recientes',
|
||||||
lfmNowPlaying: 'Reproduciendo Ahora',
|
lfmNowPlaying: 'Reproduciendo Ahora',
|
||||||
lfmJustNow: 'ahora mismo',
|
lfmJustNow: 'ahora mismo',
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Cliquez pour choisir un autre serveur enregistré.',
|
switchServerHint: 'Cliquez pour choisir un autre serveur enregistré.',
|
||||||
manageServers: 'Gérer les serveurs…',
|
manageServers: 'Gérer les serveurs…',
|
||||||
switchFailed: 'Impossible de changer — serveur injoignable.',
|
switchFailed: 'Impossible de changer — serveur injoignable.',
|
||||||
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
|
|
||||||
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Mix instantané',
|
instantMix: 'Mix instantané',
|
||||||
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
|
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.',
|
cliMixNeedsTrack: 'Aucune lecture en cours — lancez d’abord la lecture, puis relancez la commande de mix.',
|
||||||
lfmLove: 'Aimer sur Last.fm',
|
networkLove: 'Aimer sur {{provider}}',
|
||||||
lfmUnlove: 'Ne plus aimer sur Last.fm',
|
networkUnlove: 'Ne plus aimer sur {{provider}}',
|
||||||
favorite: 'Favori',
|
favorite: 'Favori',
|
||||||
favoriteArtist: 'Artiste favori',
|
favoriteArtist: 'Artiste favori',
|
||||||
favoriteAlbum: 'Album favori',
|
favoriteAlbum: 'Album favori',
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export const help = {
|
|||||||
q38: 'Device Sync — copier la musique sur USB / SD ?',
|
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.',
|
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',
|
s10: 'Intégrations & Dépannage',
|
||||||
q39: 'Scrobbling Last.fm ?',
|
q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja… ?',
|
||||||
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.',
|
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 ?',
|
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.',
|
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 ?',
|
q41: 'Dates de tournée Bandsintown ?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const frTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: 'il y a {{count}} an',
|
releasedYearsAgo_one: 'il y a {{count}} an',
|
||||||
releasedYearsAgo_other: 'il y a {{count}} ans',
|
releasedYearsAgo_other: 'il y a {{count}} ans',
|
||||||
discography: 'Discographie',
|
discography: 'Discographie',
|
||||||
lastfmStats: 'Statistiques Last.fm',
|
|
||||||
thisTrack: 'Ce titre',
|
thisTrack: 'Ce titre',
|
||||||
thisArtist: 'Cet artiste',
|
thisArtist: 'Cet artiste',
|
||||||
listeners: 'auditeurs',
|
listeners: 'auditeurs',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} auditeurs',
|
listenersN: '{{n}} auditeurs',
|
||||||
scrobblesN: '{{n}} scrobbles',
|
scrobblesN: '{{n}} scrobbles',
|
||||||
playsByYouN: 'écouté {{n}}× par vous',
|
playsByYouN: 'écouté {{n}}× par vous',
|
||||||
openTrackOnLastfm: 'Titre sur Last.fm',
|
|
||||||
openArtistOnLastfm: 'Artiste sur Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (piste)',
|
rgTrackTooltip: 'ReplayGain (piste)',
|
||||||
rgAlbumTooltip: 'ReplayGain (album)',
|
rgAlbumTooltip: 'ReplayGain (album)',
|
||||||
rgAutoTooltip: 'ReplayGain (auto)',
|
rgAutoTooltip: 'ReplayGain (auto)',
|
||||||
|
|||||||
@@ -136,18 +136,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Échec de la recherche',
|
eqAutoEqError: 'Échec de la recherche',
|
||||||
eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute',
|
eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute',
|
||||||
eqAutoEqFetchError: 'Impossible de charger le profil EQ',
|
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',
|
behavior: 'Comportement de l\'application',
|
||||||
cacheTitle: 'Taille max. du stockage',
|
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.',
|
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',
|
playerBarReset: 'Réinitialiser',
|
||||||
playerBarStarRating: 'Note par étoiles',
|
playerBarStarRating: 'Note par étoiles',
|
||||||
playerBarFavorite: 'Favori (cœur)',
|
playerBarFavorite: 'Favori (cœur)',
|
||||||
playerBarLastfmLove: 'Last.fm love',
|
playerBarLastfmLove: 'Bouton J’aime',
|
||||||
playerBarPlaybackRate: 'Vitesse de lecture',
|
playerBarPlaybackRate: 'Vitesse de lecture',
|
||||||
playerBarEqualizer: 'Égaliseur',
|
playerBarEqualizer: 'Égaliseur',
|
||||||
playerBarMiniPlayer: 'Mini-lecteur',
|
playerBarMiniPlayer: 'Mini-lecteur',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} album',
|
decadeAlbums_one: '{{count}} album',
|
||||||
decadeAlbums_other: '{{count}} albums',
|
decadeAlbums_other: '{{count}} albums',
|
||||||
decadeUnknown: 'Inconnu',
|
decadeUnknown: 'Inconnu',
|
||||||
lfmTitle: 'Stats Last.fm',
|
lfmTitle: 'Stats {{provider}}',
|
||||||
lfmTopArtists: 'Artistes favoris',
|
lfmTopArtists: 'Artistes favoris',
|
||||||
lfmTopAlbums: 'Albums favoris',
|
lfmTopAlbums: 'Albums favoris',
|
||||||
lfmTopTracks: 'Morceaux favoris',
|
lfmTopTracks: 'Morceaux favoris',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 mois',
|
lfmPeriod3month: '3 mois',
|
||||||
lfmPeriod6month: '6 mois',
|
lfmPeriod6month: '6 mois',
|
||||||
lfmPeriod12month: '12 mois',
|
lfmPeriod12month: '12 mois',
|
||||||
lfmNotConnected: 'Connectez Last.fm dans les Paramètres pour voir vos stats.',
|
|
||||||
lfmRecentTracks: 'Scrobbles récents',
|
lfmRecentTracks: 'Scrobbles récents',
|
||||||
lfmNowPlaying: 'En cours de lecture',
|
lfmNowPlaying: 'En cours de lecture',
|
||||||
lfmJustNow: 'à l\'instant',
|
lfmJustNow: 'à l\'instant',
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Klikk for å velge en annen lagret server.',
|
switchServerHint: 'Klikk for å velge en annen lagret server.',
|
||||||
manageServers: 'Administrer servere…',
|
manageServers: 'Administrer servere…',
|
||||||
switchFailed: 'Klarte ikke å bytte — serveren er utilgjengelig.',
|
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',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
|
||||||
cliMixNeedsTrack: 'Ingenting spilles — start avspilling først, og kjør mix-kommandoen på nytt.',
|
cliMixNeedsTrack: 'Ingenting spilles — start avspilling først, og kjør mix-kommandoen på nytt.',
|
||||||
lfmLove: 'Lik på Last.fm',
|
networkLove: 'Lik på {{provider}}',
|
||||||
lfmUnlove: 'Fjern fra likte på Last.fm',
|
networkUnlove: 'Fjern fra likte på {{provider}}',
|
||||||
favorite: 'Favoritt',
|
favorite: 'Favoritt',
|
||||||
favoriteArtist: 'Favorittartist',
|
favoriteArtist: 'Favorittartist',
|
||||||
favoriteAlbum: 'Favorittalbum',
|
favoriteAlbum: 'Favorittalbum',
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export const help = {
|
|||||||
q38: 'Device Sync — kopiere musikk til USB / SD?',
|
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.',
|
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',
|
s10: 'Integrasjoner & Feilsøking',
|
||||||
q39: 'Last.fm-scrobbling?',
|
q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?',
|
||||||
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.',
|
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?',
|
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.',
|
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?',
|
q41: 'Bandsintown-turnedato?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const nbTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: 'for {{count}} år siden',
|
releasedYearsAgo_one: 'for {{count}} år siden',
|
||||||
releasedYearsAgo_other: 'for {{count}} år siden',
|
releasedYearsAgo_other: 'for {{count}} år siden',
|
||||||
discography: 'Diskografi',
|
discography: 'Diskografi',
|
||||||
lastfmStats: 'Last.fm-statistikk',
|
|
||||||
thisTrack: 'Dette sporet',
|
thisTrack: 'Dette sporet',
|
||||||
thisArtist: 'Denne artisten',
|
thisArtist: 'Denne artisten',
|
||||||
listeners: 'lyttere',
|
listeners: 'lyttere',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} lyttere',
|
listenersN: '{{n}} lyttere',
|
||||||
scrobblesN: '{{n}} scrobbles',
|
scrobblesN: '{{n}} scrobbles',
|
||||||
playsByYouN: 'spilt {{n}}× av deg',
|
playsByYouN: 'spilt {{n}}× av deg',
|
||||||
openTrackOnLastfm: 'Spor på Last.fm',
|
|
||||||
openArtistOnLastfm: 'Artist på Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (spor)',
|
rgTrackTooltip: 'ReplayGain (spor)',
|
||||||
rgAlbumTooltip: 'ReplayGain (album)',
|
rgAlbumTooltip: 'ReplayGain (album)',
|
||||||
rgAutoTooltip: 'ReplayGain (auto)',
|
rgAutoTooltip: 'ReplayGain (auto)',
|
||||||
|
|||||||
@@ -136,18 +136,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Søk mislyktes',
|
eqAutoEqError: 'Søk mislyktes',
|
||||||
eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt',
|
eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt',
|
||||||
eqAutoEqFetchError: 'Kunne ikke hente EQ-profil',
|
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',
|
behavior: 'App-oppførsel',
|
||||||
cacheTitle: 'Maks. lagringsstørrelse',
|
cacheTitle: 'Maks. lagringsstørrelse',
|
||||||
cacheDesc: 'Nettlesercache for plateomslag og artistbilder (IndexedDB). Når den er full, fjernes de eldste oppføringene automatisk.',
|
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',
|
playerBarReset: 'Tilbakestill til standard',
|
||||||
playerBarStarRating: 'Stjernevurdering',
|
playerBarStarRating: 'Stjernevurdering',
|
||||||
playerBarFavorite: 'Favoritt (hjerte)',
|
playerBarFavorite: 'Favoritt (hjerte)',
|
||||||
playerBarLastfmLove: 'Last.fm love',
|
playerBarLastfmLove: 'Lik-knapp',
|
||||||
playerBarPlaybackRate: 'Avspillingshastighet',
|
playerBarPlaybackRate: 'Avspillingshastighet',
|
||||||
playerBarEqualizer: 'Equalizer',
|
playerBarEqualizer: 'Equalizer',
|
||||||
playerBarMiniPlayer: 'Miniavspiller',
|
playerBarMiniPlayer: 'Miniavspiller',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} Album',
|
decadeAlbums_one: '{{count}} Album',
|
||||||
decadeAlbums_other: '{{count}} Album',
|
decadeAlbums_other: '{{count}} Album',
|
||||||
decadeUnknown: 'Ukjent',
|
decadeUnknown: 'Ukjent',
|
||||||
lfmTitle: 'Last.fm Statistikk',
|
lfmTitle: '{{provider}} Statistikk',
|
||||||
lfmTopArtists: 'Toppartister',
|
lfmTopArtists: 'Toppartister',
|
||||||
lfmTopAlbums: 'Toppalbum',
|
lfmTopAlbums: 'Toppalbum',
|
||||||
lfmTopTracks: 'Toppspor',
|
lfmTopTracks: 'Toppspor',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 måneder',
|
lfmPeriod3month: '3 måneder',
|
||||||
lfmPeriod6month: '6 måneder',
|
lfmPeriod6month: '6 måneder',
|
||||||
lfmPeriod12month: '12 måneder',
|
lfmPeriod12month: '12 måneder',
|
||||||
lfmNotConnected: 'Koble til Last.fm i Innstillinger for å se statistikken din.',
|
|
||||||
lfmRecentTracks: 'Siste Scrobble-innslag',
|
lfmRecentTracks: 'Siste Scrobble-innslag',
|
||||||
lfmNowPlaying: 'Spilles nå',
|
lfmNowPlaying: 'Spilles nå',
|
||||||
lfmJustNow: 'akkurat nå',
|
lfmJustNow: 'akkurat nå',
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Klik om een andere opgeslagen server te kiezen.',
|
switchServerHint: 'Klik om een andere opgeslagen server te kiezen.',
|
||||||
manageServers: 'Servers beheren…',
|
manageServers: 'Servers beheren…',
|
||||||
switchFailed: 'Wisselen mislukt — server niet bereikbaar.',
|
switchFailed: 'Wisselen mislukt — server niet bereikbaar.',
|
||||||
lastfmConnected: 'Last.fm verbonden als @{{user}}',
|
|
||||||
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
|
||||||
cliMixNeedsTrack: 'Er speelt niets — start eerst afspelen en voer het mix-commando opnieuw uit.',
|
cliMixNeedsTrack: 'Er speelt niets — start eerst afspelen en voer het mix-commando opnieuw uit.',
|
||||||
lfmLove: 'Liken op Last.fm',
|
networkLove: 'Liken op {{provider}}',
|
||||||
lfmUnlove: 'Niet meer liken op Last.fm',
|
networkUnlove: 'Niet meer liken op {{provider}}',
|
||||||
favorite: 'Favoriet',
|
favorite: 'Favoriet',
|
||||||
favoriteArtist: 'Favoriete artiest',
|
favoriteArtist: 'Favoriete artiest',
|
||||||
favoriteAlbum: 'Favoriet album',
|
favoriteAlbum: 'Favoriet album',
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ export const help = {
|
|||||||
q38: 'Device Sync — muziek naar USB / SD?',
|
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.',
|
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',
|
s10: 'Integraties & Probleemoplossing',
|
||||||
q39: 'Last.fm scrobbling?',
|
q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?',
|
||||||
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.',
|
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?',
|
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.',
|
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?',
|
q41: 'Bandsintown tour data?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const nlTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: '{{count}} jaar geleden',
|
releasedYearsAgo_one: '{{count}} jaar geleden',
|
||||||
releasedYearsAgo_other: '{{count}} jaar geleden',
|
releasedYearsAgo_other: '{{count}} jaar geleden',
|
||||||
discography: 'Discografie',
|
discography: 'Discografie',
|
||||||
lastfmStats: 'Last.fm-statistieken',
|
|
||||||
thisTrack: 'Dit nummer',
|
thisTrack: 'Dit nummer',
|
||||||
thisArtist: 'Deze artiest',
|
thisArtist: 'Deze artiest',
|
||||||
listeners: 'luisteraars',
|
listeners: 'luisteraars',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} luisteraars',
|
listenersN: '{{n}} luisteraars',
|
||||||
scrobblesN: '{{n}} scrobbles',
|
scrobblesN: '{{n}} scrobbles',
|
||||||
playsByYouN: '{{n}}× door jou afgespeeld',
|
playsByYouN: '{{n}}× door jou afgespeeld',
|
||||||
openTrackOnLastfm: 'Nummer op Last.fm',
|
|
||||||
openArtistOnLastfm: 'Artiest op Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (track)',
|
rgTrackTooltip: 'ReplayGain (track)',
|
||||||
rgAlbumTooltip: 'ReplayGain (album)',
|
rgAlbumTooltip: 'ReplayGain (album)',
|
||||||
rgAutoTooltip: 'ReplayGain (auto)',
|
rgAutoTooltip: 'ReplayGain (auto)',
|
||||||
|
|||||||
@@ -136,18 +136,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Zoeken mislukt',
|
eqAutoEqError: 'Zoeken mislukt',
|
||||||
eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw',
|
eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw',
|
||||||
eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen',
|
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',
|
behavior: 'App-gedrag',
|
||||||
cacheTitle: 'Max. opslaggrootte',
|
cacheTitle: 'Max. opslaggrootte',
|
||||||
cacheDesc: 'Browsercache voor albumhoezen en artiestafbeeldingen (IndexedDB). Als de cache vol is, worden de oudste items automatisch verwijderd.',
|
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',
|
playerBarReset: 'Standaard herstellen',
|
||||||
playerBarStarRating: 'Sterbeoordeling',
|
playerBarStarRating: 'Sterbeoordeling',
|
||||||
playerBarFavorite: 'Favoriet (hart)',
|
playerBarFavorite: 'Favoriet (hart)',
|
||||||
playerBarLastfmLove: 'Last.fm love',
|
playerBarLastfmLove: 'Like-knop',
|
||||||
playerBarPlaybackRate: 'Afspeelsnelheid',
|
playerBarPlaybackRate: 'Afspeelsnelheid',
|
||||||
playerBarEqualizer: 'Equalizer',
|
playerBarEqualizer: 'Equalizer',
|
||||||
playerBarMiniPlayer: 'Mini-speler',
|
playerBarMiniPlayer: 'Mini-speler',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} album',
|
decadeAlbums_one: '{{count}} album',
|
||||||
decadeAlbums_other: '{{count}} albums',
|
decadeAlbums_other: '{{count}} albums',
|
||||||
decadeUnknown: 'Onbekend',
|
decadeUnknown: 'Onbekend',
|
||||||
lfmTitle: 'Last.fm-statistieken',
|
lfmTitle: '{{provider}}-statistieken',
|
||||||
lfmTopArtists: 'Topartiesten',
|
lfmTopArtists: 'Topartiesten',
|
||||||
lfmTopAlbums: 'Topalbums',
|
lfmTopAlbums: 'Topalbums',
|
||||||
lfmTopTracks: 'Topnummers',
|
lfmTopTracks: 'Topnummers',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 maanden',
|
lfmPeriod3month: '3 maanden',
|
||||||
lfmPeriod6month: '6 maanden',
|
lfmPeriod6month: '6 maanden',
|
||||||
lfmPeriod12month: '12 maanden',
|
lfmPeriod12month: '12 maanden',
|
||||||
lfmNotConnected: 'Verbind Last.fm in Instellingen om je statistieken te zien.',
|
|
||||||
lfmRecentTracks: 'Recente scrobbles',
|
lfmRecentTracks: 'Recente scrobbles',
|
||||||
lfmNowPlaying: 'Nu bezig',
|
lfmNowPlaying: 'Nu bezig',
|
||||||
lfmJustNow: 'zojuist',
|
lfmJustNow: 'zojuist',
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Apasă pentru a alege alt server salvat.',
|
switchServerHint: 'Apasă pentru a alege alt server salvat.',
|
||||||
manageServers: 'Gestionează serverele…',
|
manageServers: 'Gestionează serverele…',
|
||||||
switchFailed: 'Nu s-a putut schimba — nu s-a putut ajunge la server.',
|
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',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Mix Instant',
|
instantMix: 'Mix Instant',
|
||||||
instantMixFailed: 'Nu s-a putut crea Mixul Instant — eroare de server sau plugin.',
|
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.',
|
cliMixNeedsTrack: 'Nimic nu este redat — pornește redarea mai întâi, apoi rulează comanda mix din nou.',
|
||||||
lfmLove: 'Apreciază în Last.fm',
|
networkLove: 'Apreciază în {{provider}}',
|
||||||
lfmUnlove: 'Elimină aprecierea în Last.fm',
|
networkUnlove: 'Elimină aprecierea în {{provider}}',
|
||||||
favorite: 'Favorit',
|
favorite: 'Favorit',
|
||||||
favoriteArtist: 'Artist Favorit',
|
favoriteArtist: 'Artist Favorit',
|
||||||
favoriteAlbum: 'Album Favorit',
|
favoriteAlbum: 'Album Favorit',
|
||||||
|
|||||||
@@ -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.',
|
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 ─────────────────────────
|
// ── Section 10: Integrations & Troubleshooting ─────────────────────────
|
||||||
s10: 'Integrări & Depanare',
|
s10: 'Integrări & Depanare',
|
||||||
q39: 'Scrobbling Last.fm?',
|
q39: 'Scrobbling — Last.fm, ListenBrainz, Maloja…?',
|
||||||
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.',
|
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?',
|
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.',
|
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?',
|
q41: 'Date turneu Bandsintown?',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { common } from './common';
|
|||||||
import { settings } from './settings';
|
import { settings } from './settings';
|
||||||
import { changelog } from './changelog';
|
import { changelog } from './changelog';
|
||||||
import { whatsNew } from './whatsNew';
|
import { whatsNew } from './whatsNew';
|
||||||
|
import { musicNetwork } from './musicNetwork';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
import { queue } from './queue';
|
import { queue } from './queue';
|
||||||
import { miniPlayer } from './miniPlayer';
|
import { miniPlayer } from './miniPlayer';
|
||||||
@@ -72,6 +73,7 @@ export const roTranslation = {
|
|||||||
settings,
|
settings,
|
||||||
changelog,
|
changelog,
|
||||||
whatsNew,
|
whatsNew,
|
||||||
|
musicNetwork,
|
||||||
help,
|
help,
|
||||||
queue,
|
queue,
|
||||||
miniPlayer,
|
miniPlayer,
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -21,7 +21,6 @@ export const nowPlaying = {
|
|||||||
releasedYearsAgo_one: 'acum {{count}} an',
|
releasedYearsAgo_one: 'acum {{count}} an',
|
||||||
releasedYearsAgo_other: 'acum {{count}} ani',
|
releasedYearsAgo_other: 'acum {{count}} ani',
|
||||||
discography: 'Discografie',
|
discography: 'Discografie',
|
||||||
lastfmStats: 'Statistici Last.fm',
|
|
||||||
thisTrack: 'Această piesă',
|
thisTrack: 'Această piesă',
|
||||||
thisArtist: 'Acest artist',
|
thisArtist: 'Acest artist',
|
||||||
listeners: 'ascultători',
|
listeners: 'ascultători',
|
||||||
@@ -30,8 +29,6 @@ export const nowPlaying = {
|
|||||||
listenersN: '{{n}} ascultători',
|
listenersN: '{{n}} ascultători',
|
||||||
scrobblesN: '{{n}} scrobble-uri',
|
scrobblesN: '{{n}} scrobble-uri',
|
||||||
playsByYouN: 'redate {{n}}× de tine',
|
playsByYouN: 'redate {{n}}× de tine',
|
||||||
openTrackOnLastfm: 'Piesă în Last.fm',
|
|
||||||
openArtistOnLastfm: 'Artist în Last.fm',
|
|
||||||
rgTrackTooltip: 'ReplayGain (piesă)',
|
rgTrackTooltip: 'ReplayGain (piesă)',
|
||||||
rgAlbumTooltip: 'ReplayGain (album)',
|
rgAlbumTooltip: 'ReplayGain (album)',
|
||||||
rgAutoTooltip: 'ReplayGain (auto)',
|
rgAutoTooltip: 'ReplayGain (auto)',
|
||||||
|
|||||||
@@ -136,18 +136,6 @@ export const settings = {
|
|||||||
eqAutoEqError: 'Căutarea a eșuat',
|
eqAutoEqError: 'Căutarea a eșuat',
|
||||||
eqAutoEqRateLimit: 'Limita ratei GitHub a fost atinsă — încearcă din nou într-un minut',
|
eqAutoEqRateLimit: 'Limita ratei GitHub a fost atinsă — încearcă din nou într-un minut',
|
||||||
eqAutoEqFetchError: 'Nu s-a putut lua profilul EQ',
|
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',
|
behavior: 'Comportamentul Aplicației',
|
||||||
cacheTitle: 'Spațiu de stocare maxim',
|
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.',
|
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',
|
playerBarReset: 'Resetare la implicit',
|
||||||
playerBarStarRating: 'Evaluare cu stele',
|
playerBarStarRating: 'Evaluare cu stele',
|
||||||
playerBarFavorite: 'Favorit (inimă)',
|
playerBarFavorite: 'Favorit (inimă)',
|
||||||
playerBarLastfmLove: 'Last.fm love',
|
playerBarLastfmLove: 'Buton de apreciere',
|
||||||
playerBarPlaybackRate: 'Viteză redare',
|
playerBarPlaybackRate: 'Viteză redare',
|
||||||
playerBarEqualizer: 'Egalizator',
|
playerBarEqualizer: 'Egalizator',
|
||||||
playerBarMiniPlayer: 'Mini player',
|
playerBarMiniPlayer: 'Mini player',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const statistics = {
|
|||||||
decadeAlbums_one: '{{count}} Album',
|
decadeAlbums_one: '{{count}} Album',
|
||||||
decadeAlbums_other: '{{count}} Albume',
|
decadeAlbums_other: '{{count}} Albume',
|
||||||
decadeUnknown: 'Necunoscut',
|
decadeUnknown: 'Necunoscut',
|
||||||
lfmTitle: 'Statistici Last.fm',
|
lfmTitle: 'Statistici {{provider}}',
|
||||||
lfmTopArtists: 'Artiști de top',
|
lfmTopArtists: 'Artiști de top',
|
||||||
lfmTopAlbums: 'Albume de top',
|
lfmTopAlbums: 'Albume de top',
|
||||||
lfmTopTracks: 'Piese de top',
|
lfmTopTracks: 'Piese de top',
|
||||||
@@ -33,7 +33,6 @@ export const statistics = {
|
|||||||
lfmPeriod3month: '3 Luni',
|
lfmPeriod3month: '3 Luni',
|
||||||
lfmPeriod6month: '6 Luni',
|
lfmPeriod6month: '6 Luni',
|
||||||
lfmPeriod12month: '12 Luni',
|
lfmPeriod12month: '12 Luni',
|
||||||
lfmNotConnected: 'Conectează Last.fm în Setări pentru a îți vedea statisticile.',
|
|
||||||
lfmRecentTracks: 'Scrobble-uri Recente',
|
lfmRecentTracks: 'Scrobble-uri Recente',
|
||||||
lfmNowPlaying: 'Now Playing',
|
lfmNowPlaying: 'Now Playing',
|
||||||
lfmJustNow: 'tocmai acum',
|
lfmJustNow: 'tocmai acum',
|
||||||
|
|||||||
@@ -38,6 +38,4 @@ export const connection = {
|
|||||||
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
switchServerHint: 'Нажмите, чтобы выбрать другой сохранённый сервер.',
|
||||||
manageServers: 'Управление серверами…',
|
manageServers: 'Управление серверами…',
|
||||||
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
switchFailed: 'Не удалось переключиться — сервер недоступен.',
|
||||||
lastfmConnected: 'Last.fm: @{{user}}',
|
|
||||||
lastfmSessionInvalid: 'Сессия недействительна — подключите снова',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export const contextMenu = {
|
|||||||
instantMix: 'Instant Mix',
|
instantMix: 'Instant Mix',
|
||||||
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
|
||||||
cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.',
|
cliMixNeedsTrack: 'Ничего не играет — сначала начните воспроизведение, затем снова выполните команду микса.',
|
||||||
lfmLove: 'Любимое на Last.fm',
|
networkLove: 'Любимое на {{provider}}',
|
||||||
lfmUnlove: 'Убрать с Last.fm',
|
networkUnlove: 'Убрать с {{provider}}',
|
||||||
favorite: 'В избранное',
|
favorite: 'В избранное',
|
||||||
favoriteArtist: 'Исполнитель в избранное',
|
favoriteArtist: 'Исполнитель в избранное',
|
||||||
favoriteAlbum: 'Альбом в избранное',
|
favoriteAlbum: 'Альбом в избранное',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user