mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
9fa086c428
* feat(server): ServerProfile.alternateUrl + shareUsesLocalUrl
Additive optional fields on ServerProfile to back the dual-address work.
'alternateUrl' is the optional second endpoint (e.g. LAN counterpart of a
public 'url' or vice versa); 'shareUsesLocalUrl' chooses which of the two
goes into Orbit / entity / magic-string shares when both are set.
Pure schema change — no runtime consumers yet. Single-address profiles
keep the same persisted shape; the optional fields are simply absent.
* feat(server): serverEndpoint module with LAN classification + IPv6
Introduces a single home for connect-/share-layer URL utilities that
the dual-address work will build on:
* normalizeServerBaseUrl(raw) — aligned with serverProfileBaseUrl
* isLanUrl(url) — IPv4 (unchanged) + IPv6: ::1 loopback, fe80::/10
link-local, fc00::/7 ULA, and IPv4-mapped (both dot-decimal and the
URL-API-normalized hex form, since new URL() rewrites ::ffff:1.2.3.4
to ::ffff:HHHH:HHHH)
* allNormalizedAddresses(profile) — deduped [url, alternateUrl?]
* serverAddressEndpoints(profile) — LAN-first ServerEndpoint[]
isLanUrl moves out of useConnectionStatus.ts (the hook now imports +
re-exports it for backward compatibility); OrbitStartModal points at
the new path directly. 38 unit tests cover the IPv4/IPv6 matrix,
dedupe, ordering, and edge cases.
* feat(server): pickReachableBaseUrl with in-memory connect cache
Adds the runtime connect layer on top of serverEndpoint:
* pickReachableBaseUrl(profile) — sequential LAN-first ping via the
existing pingWithCredentials; first OK wins.
* ensureConnectUrlResolved(profile) — boot / switch / online-event
entry point (same mechanism, intent-named).
* In-memory cache keyed by profileId; sticky behaviour tries the
cached endpoint first, falls back to the natural order on miss.
* invalidateReachableEndpointCache(profileId?) — single-profile and
whole-cache flushes for profile-edit / credentials-change / online.
* getCachedConnectBaseUrl(profileId) — sync getter for getBaseUrl
fallback path (next commit).
The cache is **session-only**, never persisted. Single-address profiles
keep the same shape (one endpoint, one ping). 9 new unit tests cover
single + dual address, LAN-first preference, fallthrough, unreachable +
stale-cache clear, sticky hit, and the two invalidate flavours.
* feat(server): getBaseUrl reads connect cache, falls back to primary url
Dual-address profiles need 'getBaseUrl' to return the runtime-probed
connect URL (LAN at home, public elsewhere), not the literal primary
'url'. To keep the sync getter shape that ~60 call sites depend on,
the store now reads 'getCachedConnectBaseUrl(server.id)' from the
serverEndpoint cache.
If no probe has run yet (very early boot, before switchActiveServer
populates the cache), it falls back to the normalized primary URL —
identical to today's behaviour. Single-address profiles see no
difference; their cache entry equals serverProfileBaseUrl(url).
* feat(server): switch + connection-status probe via ensureConnectUrlResolved
Both surfaces that previously called pingWithCredentials(server.url, ...)
directly now route through the dual-address connect layer:
* switchActiveServer awaits ensureConnectUrlResolved(server), reads the
identity (type/serverVersion/openSubsonic) off the returned ping, and
hands probe.baseUrl (not server.url) to scheduleInstantMixProbeForServer
so the AudioMuse probe also hits the reachable endpoint.
* useConnectionStatus.check() does the same on every 120 s tick — sticky
cache fast-paths the steady state, and a network change naturally
flips the active endpoint without a manual retry.
* useConnectionStatus.retry() and the 'online' event flush the cached
entry for the active profile first, so the next probe starts from the
natural LAN-first order instead of revalidating a stale URL.
Single-address profiles behave identically: one endpoint in the list,
one ping per check. No behaviour change for them.
* feat(server): PickReachableResult.ping + connectBaseUrlForServer helper
Two additive extensions to the serverEndpoint surface that the upcoming
CONNECT migrations all need:
* 'ping: PingWithCredentialsResult' on the OK branch of
PickReachableResult so callers (switchActiveServer,
useConnectionStatus) can read type/serverVersion/openSubsonic from
the same probe instead of issuing a second pingWithCredentials.
* connectBaseUrlForServer(server) — sync getter for the connect URL
of *any* saved profile (active or not). Reads the cache, falls back
to normalized primary url on miss. Becomes the canonical helper
for non-active-server HTTP traffic (apiForServer, stream URLs,
cover fetches, library bind session).
Tests: pingOk() fixture now returns identity fields; the single
toEqual-asserting test in pickReachableBaseUrl became a guarded read
to keep equality concise while still asserting on the new ping field.
* feat(connect): apiForServer + stream URLs route via connectBaseUrlForServer
Non-active-server HTTP traffic (the apiForServer entry point, used by
QueuePanel cross-server cover fetches and share-paste resolution) and
the stream-URL builders previously read 'server.url' directly,
bypassing the dual-address connect cache. Both now go through
connectBaseUrlForServer, which serves the cached LAN/public endpoint
when one exists and falls back to the normalized primary url
otherwise.
buildStreamUrl(id) now uses the baseUrl it already pulled from
getBaseUrl() (which is connect-cache aware as of 2a6d8283) instead of
re-normalizing server.url — same value for single-address profiles,
correctly dual-address for the rest. Single-address callers see no
behaviour change.
* feat(connect): cover fetch + cover-cache ensure use connect URL
Both cover-fetch surfaces previously read 'scope.url' / 'server.url'
straight into HTTP requests, which would freeze the cover pipeline on
the primary URL even when a LAN endpoint was reachable:
* buildCoverArtFetchUrl(ref, tier) — for the 'server' scope (queue
rows cached against a non-active profile) and the 'playback' scope
(cross-server playback) now resolves the connect URL via
connectBaseUrlForServer before handing off to
buildCoverArtUrlForServer.
* ensureArgsFromRef in coverCache.ts — restBaseUrl for the Tauri
'cover_cache_ensure' invoke now uses the cached connect URL for
both 'server' and 'active'/'playback' scopes.
Scope.url itself remains the index-stable primary URL — that's what
storageKeys (INDEX) consume. Only the HTTP base shifts to the connect
endpoint, exactly the split the spec calls out.
* feat(connect): library bind + server-test probe via ensureConnectUrlResolved
Two more 'rebind / re-test an existing saved server' paths still pinged
the literal primary URL — both go through the dual-address connect
layer now:
* bindIndexedServer (librarySession.ts): used to ping server.url then
pass serverProfileBaseUrl(server) as the bind 'baseUrl' to Rust.
Now: one ensureConnectUrlResolved call covers both — probe.baseUrl
feeds librarySyncBindSession directly, so Rust's per-server library
sync uses whichever endpoint actually answered. Drops the
pingWithCredentials and serverProfileBaseUrl imports.
* testConnection (ServersTab.tsx): same shape — probe via the connect
layer, read identity from probe.ping, hand probe.baseUrl to
scheduleInstantMixProbeForServer so the AudioMuse probe also hits
the connect endpoint. Single-address profiles still ping once.
Add/edit save handlers (handleAddServer / handleEditServer) keep the
direct pingWithCredentials against the user-entered data.url — that
flow is the dual-address verify hook, which PR 2 extends.
* feat(server): serverShareBaseUrl — public-by-default share URL picker
Adds the share-layer companion to connectBaseUrlForServer. Different
intent: connect picks the reachable endpoint for HTTP, share picks the
URL that goes into Orbit invites / entity share payloads / queue share
links / magic strings — where a guest opening the link is not on the
host's LAN.
Logic per spec §5.1:
* Single-address profile → that one address (normalized).
* Both set, default flag → public.
* Both set, shareUsesLocalUrl flag → local.
* Edge cases (both LAN, both public, missing one of the two): fall
back to the first endpoint in the list so the function is total.
Empty profile returns the normalized url (possibly empty) — defensive,
never throws. 7 unit tests pin all six branches plus the empty case.
Call-site migration (Orbit, copyEntityShareLink, QueuePanel, magic
string srv field, findServerIdForShareUrl, Orbit LAN warning) lands
in the next commits.
* feat(share): Orbit + entity + queue shares use serverShareBaseUrl
Four share-encoding surfaces previously read 'getBaseUrl()' (connect)
or the raw 'server.url' (primary) when embedding the host into outgoing
share links — both wrong for a dual-address profile:
* copyEntityShareLink (track / album / artist / composer) — was
getBaseUrl(); now reads serverShareBaseUrl(active). Guests opening
the link are off-LAN, so public is the right default.
* OrbitStartModal — was raw server?.url for both 'buildOrbitShareLink'
and the LAN warning. Now goes through serverShareBaseUrl; the LAN
warning correspondingly reflects the address the guest will see,
not the host's primary.
* OrbitSharePopover — same fix on the host-only share popover.
* QueuePanel.handleCopyQueueShare — was getBaseUrl(); same migration.
Single-address profiles return exactly the same string from
serverShareBaseUrl as serverProfileBaseUrl(server.url) did before, so
no behaviour change for the common case. shareUsesLocalUrl flips the
default to LAN for the rare 'share into a LAN-only group' use; that
checkbox lands with the form UI in the next sub-phase.
findServerIdForShareUrl + paste-side share matchers are migrated in
the next commit (read-side of the same contract).
* feat(share): paste-side resolves dual-address profiles + connect URL
Read-side counterpart to the encode-side migration:
* findServerIdForShareUrl(servers, shareSrv) now matches a profile
when shareSrv normalizes to either profile.url OR profile.alternateUrl.
Without this, a paste of a link generated against the host's LAN
address (shareUsesLocalUrl=true) would fail to find the local saved
profile even though it's the same server.
* resolveSharedSong / resolveShareSearchAlbum / resolveShareSearchArtist
in enqueueShareSearchPayload now hand connectBaseUrlForServer(lookup.server)
to the *WithCredentials HTTP calls instead of the raw lookup.server.url.
The looked-up profile may be dual-address; this routes the song / album /
artist fetch through whichever endpoint is currently reachable.
Indirect consumers (shareServerOriginLabel, shareQueueServerContext) read
the same findServerIdForShareUrl, so no code change needed there — they
now match both addresses transparently.
* feat(verify): serverFingerprint + same-server verification
Pure-logic core of dual-address verify. Three exports:
* fetchServerFingerprint(baseUrl, user, pass) — one ping (envelope
'version' extracted alongside type/serverVersion/openSubsonic) plus
four soft-fail optional calls in parallel (getMusicFolders, getUser,
getLicense, getIndexes). Optional failures collapse to null fields,
not whole-fingerprint failure. Subsonic-generic — never branches on
type === 'navidrome'. indexesDigest is a hash of letter-count plus
sorted first 20 artist ids, so two probes against the same library
see the same digest without comparing full payloads.
* compareFingerprints(a, b) — strict on the ping triple
(type case-insensitive, serverVersion exact, openSubsonic boolean);
envelope apiVersion informational only. Body signals counted only
when both sides have a non-null value; empty musicFolders [] on both
sides still counts as a matching signal. Result: 'match' (>=1 common
signal all agreeing) / 'mismatch' (any common differs) /
'insufficient' (0 common). No 'save anyway' for insufficient in v1.
* verifySameServerEndpoints(profile, user, pass) — single-address
short-circuits to ok:true (nothing to verify). Otherwise parallel
fingerprint probes, then pairwise compare. Ping-fail on any
endpoint reports the offending host for the UI.
20 unit tests cover the compare matrix (every body signal in every
direction), Navidrome- vs minimal-Subsonic-shape probes, ping-fail,
and all four verify outcomes (ok / unreachable / mismatch /
insufficient). HTTP mocked via vi.stubGlobal('fetch') + plain-object
Response shape (avoiding any Response-polyfill dependency).
* feat(boundary): resolve_host_addresses Tauri command + TS wrapper
Adds one additive invoke for dual-address form hints. Spec §9.1
+ contracts.md §6.
Rust side (src-tauri/src/lib_commands/app_api/network.rs):
* #[tauri::command] resolve_host_addresses(hostname: String) ->
Result<Vec<String>, String>
* tokio::net::lookup_host with a port suffix (':0'); discards the
port from each SocketAddr, dedupes addresses via HashSet.
* strip_port helper handles 'host:port', 'ipv4:port',
'[ipv6]:port', '[ipv6]' (no port), and bare 'ipv6' (left as-is
for lookup_host to wrap). 7 unit tests cover each shape.
* Lookup failure → Ok(vec![]) so a DNS hiccup doesn't surface as
an error toast or block the save flow — form-hint only.
Frontend wrapper (src/api/network.ts):
* resolveHostAddresses(hostname) — trims, invokes, swallows errors
to an empty array so consumers get a clean total function.
§04 boundary note: additive command, no breaking change. Added to
the invoke_handler! generate_handler list in lib.rs at the natural
alphabetical-ish slot near migration_run.
* feat(form): AddServerForm — second optional address + share flag + DNS hint
UI side of dual-address. AddServerForm now carries the four new
moving parts spec §6 calls out:
* Second address field ('Second address (optional)') under the
primary URL. Placeholder flips to suggest the opposite kind
of address based on the primary's LAN classification.
* Contextual hint under the second field when it's empty —
'add a public address for outside-home use' (primary is LAN)
or 'add a local address for faster home access' (primary is
public). Hint disappears once the field has content.
* Two-LAN client-side check on submit: when both addresses
classify as LAN, save is blocked with a toast. Reverse case
(both public) intentionally allowed per spec §6.3.
* shareUsesLocalUrl checkbox — visibility rule per spec §5.3:
hidden until the second address has content; shows with the
persisted value on edit; cleared when the user empties the
second address before save.
DNS hint: on primary-URL blur we call the Tauri
resolve_host_addresses command and classify the response by
isLanUrl. Literal IPs skip DNS (already classified locally). DNS
miss → no hint surfaces (never blocks save). Used only for the
hint text — connect still goes through pingWithCredentials.
i18n: 9 new keys in en + ru (serverAlternateUrl*, shareUsesLocalUrl*,
serverBothLanError). Other locales fall back to English.
onSave signature widened to 'void | Promise<void>' — ServersTab /
Login both accept that already. The save flow itself still calls
the legacy pingWithCredentials path; verify wiring lands in 2e.
* feat(verify): wire verifySameServerEndpoints into add + dual-edit flows
Save flow now blocks persisting a dual-address profile that fails the
same-server check. Spec §6.4 + §7.4.
handleAddServer:
* When data.alternateUrl is non-empty, runs verifySameServerEndpoints
BEFORE the existing ping. mismatch / insufficient / unreachable each
surface a localized toast and abort the save (no addServer call).
* Single-address adds keep the legacy single-ping path — no extra
network round-trip when there's nothing to verify.
handleEditServer:
* Unconditional save remains the default — but if the edit either
introduces / changes alternateUrl OR changes url / username / password
while alternateUrl is set, verify runs first and may block.
* After persist, invalidates the reachable-endpoint cache for this
profile id so any sticky cached connect URL from before the edit is
re-probed on the next access (credentials may have changed, alternate
may have appeared).
i18n: 4 new toast keys in en + ru (dualAddressVerifying placeholder for
future progress UI, plus mismatch / insufficient / unreachable). Other
locales fall back to English.
announceVerifyResult helper keeps the toast routing in one place so
handleAddServer and handleEditServer share the same surface.
* i18n(settings): dual-address keys across all 9 locales
Adds the 13 new dual-address keys to the remaining 7 locales:
de · fr · es · nl · nb · ro · zh.
* serverAlternateUrl + placeholderPublic / placeholderLocal
* serverAlternateUrlHintAddPublic / HintAddLocal (contextual hints
under the second-address field)
* serverBothLanError (client-side two-LAN validation)
* dualAddressVerifying / Mismatch / Insufficient / Unreachable
(toast strings; Unreachable carries the {{host}} interpolation)
* shareUsesLocalUrl + Desc (checkbox label + short description)
Placeholders (example.com URL / 192.168.1.100:4533) kept identical
across all locales — same shape the existing serverUrlPlaceholder
already uses.
Single coordinated sweep so every supported language ships dual-
address fully translated rather than falling back to English.
* feat(cover): cover_cache_rename_server_bucket Tauri command
Adds the disk-side companion to the upcoming URL-change remigration
flow (dual-server-address spec §8.3). When a user edits the primary
url so the derived index key changes, the SQLite migration already
re-tags rows via the existing migration_run command — this command
moves the cover-cache bucket on disk so cached WebP tiles stay
reachable under the new key.
Behaviour:
* old_key == new_key → no-op.
* Old bucket missing → no-op success (nothing to migrate).
* New bucket missing → simple fs::rename (fastest path).
* Both exist → recursive merge with 'prefer existing' on file
collision (the newer bucket wins; data is never lost).
* Emits 'cover:bucket-renamed' with {oldKey, newKey} on success
so the frontend disk-src cache can invalidate stale URLs.
Sanitization: rejects empty, backslash, and '..' path segments at
the FS boundary. Forward slashes are legitimate (a Navidrome
mounted at a subpath like 'music.example.com/navidrome' produces
an index key with one); they're handled by Path::join.
4 unit tests cover key sanitization (accepts real keys, rejects
traversal/backslash) and the merge semantics (unique-file move +
prefer-existing on collision). Registered in lib.rs invoke handler.
* feat(remap): rewriteFrontendStoreKeysForRemap — explicit oldKey→newKey path
Adds the URL-change remigration entry point next to the existing
UUID→indexKey migration. Same plumbing (offline store, hot cache,
analysis-strategy maps) but driven by explicit { oldKey, newKey }[]
mappings instead of being derived from the current servers list.
Also folds in the player-side cleanup the existing path didn't have:
* queueServerId — if currently bound to a remapped index key,
repoint to the new one so playback continues through the rename
instead of looking unbound.
* analysisStrategyStore — runs the same map-key swap inline (the
'migrateServerOverrides' helper handles UUID→indexKey, not
indexKey→indexKey).
Same prefer-existing-on-collision semantics as the disk-side
cover_cache_rename_server_bucket — if a destination key already
carries data, we keep it. 8 unit tests cover no-ops, the four
store rewrites, the queue repoint, the 'leave other servers
untouched' guarantee, and the collision case.
* feat(remap): serverUrlRemigration orchestrator — 4-stage pipeline
Single-file orchestrator that runs the full URL-change remigration when
a profile edit shifts the primary url's derived index key. Spec §8 +
contracts.md §4.
Two exports:
* indexKeyRemapForUrlChange(prev, next): IndexKeyRemap | null
— short-circuits scheme-only edits ('http://x' ↔ 'https://x'),
trailing-slash differences, and alternateUrl-only changes by
normalizing both sides through serverIndexKeyFromUrl and
returning null when they match. Empty urls also return null.
* runIndexKeyRemigration(remap): Promise<IndexKeyRemigrationResult>
— runs inspect → run → frontend-rewrite → cover-rename in order,
aborts on the first failure and reports the offending stage.
Failure ordering rationale:
* inspect / run failures stop the destructive step — DB is
untouched, the caller can retry safely.
* frontend rewrite swallows errors (best-effort; zustand persist
catches up on rehydrate next session).
* cover-rename failure is reported but does NOT roll back the DB
rows (that would be even more destructive); covers under the
old key recover via the existing cover backfill on next access.
10 tests cover the detect logic (5 cases including the path-suffix
case) plus all four pipeline stages with mocked Tauri invoke —
including verifying the legacyId/indexKey mapping shape lands on
both inspect and run calls.
* feat(remap): wire URL-change remigration into ServersTab edit flow
handleEditServer now detects a primary-url index-key change BEFORE
any other save logic (verify, persist, etc.) and orchestrates the
full remigration when one is needed.
Flow:
* indexKeyRemapForUrlChange(prev, next) — null for scheme-only
edits / alternateUrl-only edits / no-op edits, so the common case
falls straight through without prompting the user.
* On a real remap → confirm modal via useConfirmModalStore.request
(danger style, plain-language oldKey → newKey copy, explicit
'cannot be undone'). User cancel → abort save entirely.
* On confirm → runIndexKeyRemigration pipeline. Failure surfaces a
stage-specific toast (inspect / run / cover-rename — wording per
spec §8.4 + the recoverability matrix in serverUrlRemigration.ts).
* On success → fall through to the existing dual-address verify,
then auth.updateServer + invalidate cache + ping (unchanged).
i18n: 7 new keys (urlRemigrationTitle / Message with oldKey + newKey
interpolation / Confirm / Progress / FailureInspect / FailureRun /
FailureCoverRename) — full sweep across all 9 locales (en, de, ru,
fr, es, nl, nb, ro, zh).
Test fixup: the offline-store collision test in rewriteFrontendStoreKeys
needed an 'as unknown as' cast — the existing OfflineTrackMeta type
doesn't carry the test-only marker property, and a one-step cast
TS rejected as not overlapping enough.
* feat(magic): magic-string v2 encode/decode with dual-address fields
Adds the v2 wire format for server invites. Spec §10 + contracts.md §5.2.
Encode is opportunistic: payloads with alternateUrl set OR
shareUsesLocalUrl=true emit v2; everything else stays v1 byte-identical.
This means single-address profiles keep producing exactly the same
invite they did before — older receivers can't tell the difference.
v2 shape: { v: 2, url, alt?, shareLocal?, u, w, n? }
* url — the host's share URL (public by default; LAN if shareLocal),
NOT necessarily the host's primary URL. Receiver treats it as the
primary of the new profile (their own index key).
* alt — the host's alternate address (the other half of the pair).
* shareLocal — mirrors the host's shareUsesLocalUrl preference so
onward shares from the receiver behave the same way.
Decode accepts both v1 and v2. v2-only fields are left undefined when
decoding a v1 payload (no '|| undefined' shim — kept absent so zustand
persist diffs stay clean). Defensive: an empty alt on a v2 invite
decodes as if the field were absent, never as ''.
Test updates: 'rejects a payload with the wrong version' now targets
v: 3 (v: 2 became valid). 7 new tests cover the v1/v2 wire-format
choice, both v2 round-trip shapes, the v1-decode-into-v2-fields-
undefined backward-compat case, and the empty-alt edge.
* feat(magic): wire magic-string v2 through invite-encode + paste consumers
Five surfaces now produce / accept v2 invites with the dual-address
fields end-to-end:
Encode side (admin → user invite):
* MagicStringModal — looks up the saved profile that matches the
serverUrl prop (primary OR alternateUrl normalize-match) and
encodes through serverShareBaseUrl + alternateUrl + the share
flag. Falls back to plain v1 for single-address profiles.
* UserForm — same lookup + encode plumbing on the in-form
'Save & get magic string' flow.
Decode side (paste invite into form):
* AddServerForm useEffect (initialInvite from outer state) +
handleMagicStringChange (live paste) both pull alternateUrl
and shareUsesLocalUrl off the decoded payload into form state,
so the second-address field + checkbox surface immediately on a
v2 paste.
* Login form state gains hidden alternateUrl + shareUsesLocalUrl
fields (not user-editable — Login stays single-address by
design); v2 paste / initial-invite both populate them so they
persist with the new profile via addServer. handleQuickConnect
likewise forwards the existing dual-address shape for
already-saved profiles.
* attemptConnect signature widened with the two optional fields;
addServer / updateServer call sites conditionally include them
only when alternateUrl is non-empty so single-address profiles
keep their lean persisted shape.
Both encode helpers share the same magicPayloadAddressFields lookup
(MagicStringModal + UserForm) — the duplication is acceptable for
two co-located small files but a single shared helper would be the
natural place to consolidate if a third encoder appears.
* fix(form): magic-string submit forwards decoded alternateUrl + share flag
AddServerForm.submit's magic-string branch was forwarding only
name/url/username/password from the decoded payload, silently dropping
alternateUrl + shareUsesLocalUrl. A v2 invite pasted into the
magic-string field and saved would land in the store as a single-
address profile even though handleMagicStringChange had already
populated the dual-address fields into form state for display.
Now the magic branch picks the dual-address fields off the decoded
payload (same conditional spread as the non-magic branch a few lines
down) so v2 invites round-trip end-to-end through the form.
* fix(verify): extractUserId drops username fallback to avoid false mismatches
Spec §7.2 footnote: 'fallback (normalized username) only if no id on
either side'. The old extract unconditionally fell back to the
authenticated username whenever user.id was empty — but the
comparator can't tell username-fallback apart from a server-supplied
id. So a server pair where endpoint A returns user.id='42' and
endpoint B only returns user.username='frank' would land in compare
with userId values '42' vs 'frank' and report mismatch on what is
actually the same user on the same server.
Now extractUserId returns null when user.id is absent. Both sides
returning null means compareFingerprints skips userId as a common
signal (correct behaviour per the §7.3 'both sides have a value'
rule). One new test pins it; the call site drops the redundant
fallbackUsername argument.
* fix(remap): rewriteFrontendStoreKeysForRemap migrates coverStrategyStore
Spec §8.2 lists 'analysis/cover strategy maps' as targets of the
URL-change remigration. The For-Remap path covered analysis but
missed cover — a user-set cover strategy override (e.g. 'aggressive'
for one server) would silently drop when the user edited that
profile's primary URL, because the key tagged under the old index
key never made it to the new one.
Same shape as the analysis remap inline: prefer-existing on
collision (newer key wins), delete the legacy entry afterwards.
One new test pins the move; setup adds a fresh useCoverStrategyStore
reset between cases.
* fix(cover): is_safe_index_key rejects absolute paths + drive letters
Defense-in-depth gap in cover_cache_rename_server_bucket. The old
sanitizer only rejected backslashes and '..' segments — '/etc/passwd'
on Unix and 'C:\Windows' on Windows both passed. Path::join with an
absolute argument REPLACES the base path, so root.join('/etc/passwd')
on Unix would walk out of cover-cache entirely.
Real index keys never reach this state — they come out of
serverIndexKeyFromUrl which strips schemes and trailing slashes, so
no leading separator and no drive-letter prefix is ever produced.
But the comment promises 'defense in depth' and that defense is
worth completing.
Now rejects:
* empty keys (would join to the cover-cache root itself)
* leading '/' or '\'
* Windows drive-letter prefix 'X:' (case-insensitive ASCII letter)
* backslashes anywhere (separators are forward-slash only)
* '..' segments after split('/')
One new Rust test covers all four cases.
* fix(connection): isLan badge reflects active endpoint, not primary url
useConnectionStatus.isLan was reading isLanUrl(server.url) — the
*primary* address — so a dual-address profile that had fallen over to
its public alternate kept advertising 'LAN' in the badge until the
user looked at the server URL itself. Spec call-site-checklist line
25: 'active endpoint kind for badge (LAN vs extern)'.
Now: ensureConnectUrlResolved returns probe.endpoint.kind on success;
the hook tracks the latest kind in local state and surfaces that.
Pre-first-probe fall-through keeps the old primary-URL classification
so the badge has something to render at mount time.
Three tests pin the three states (LAN-active after probe, public-
active after fallback, primary-fallback before first probe completes);
plus one for the online-event handler that invalidates the cache and
re-probes.
* fix(server): dedupe concurrent pickReachableBaseUrl calls for same profile
Multiple call sites probe on roughly the same beat — useConnectionStatus
120-s tick + online handler + initial mount, switchActiveServer,
bindIndexedServer, and ServersTab.testConnection. Two near-simultaneous
probes for the same profile both observed an empty cache, both pinged
every endpoint, and raced to write the sticky URL with last-write-wins.
On a dual-address profile the slower probe could stomp the correct
LAN sticky a millisecond after it was set.
Now: an in-flight Map<profileId, Promise<PickReachableResult>>; a
second call for the same id during an active probe returns the same
promise instead of starting a fresh one. The finally-clear keeps the
dedup window narrow (one settled probe → next call probes fresh).
invalidateReachableEndpointCache deliberately doesn't touch the
in-flight map — the racing probe's own finally will clean up.
Three tests pin it: two concurrent calls share one ping; a fresh
call after the previous settled does ping again; plus the existing
shareBaseUrl two-LAN-with-flag-on test + the two-public-default test
for completeness.
* refactor(magic): extract magicPayloadAddressFields to single shared helper
The lookup that turns a serverUrl into the v2-encode-ready
{ url, alternateUrl?, shareUsesLocalUrl? } shape was duplicated
byte-identically between MagicStringModal.tsx and UserForm.tsx —
both Navidrome admin user-mgmt encode sites. Workdocs §03 / §1a
'one source of truth for behavior'.
Now lives in src/utils/server/serverMagicString.ts as a named
export. Both call sites import + pass the current servers list
(via useAuthStore.getState().servers) — keeps the helper pure so
it's straight to unit-test if a third encoder ever appears.
* fix(cover): wire cover:bucket-renamed listener for URL-change remigration
cover_cache_rename_server_bucket emits cover:bucket-renamed on
successful rename / merge, but no frontend listener was wiring it in.
After a URL-change remigration the in-memory disk-src cache still
held entries pointing at `{root}/{oldKey}/…/.webp` paths the disk
no longer carries — the next read would serve a stale URL until
the entry naturally aged out.
New: forgetDiskSrcForServer(serverIndexKey) in diskSrcCache drops
every cover entry under one server key in one pass (the existing
forgetDiskSrcPrefix needs a non-empty coverArtId and can't blanket-
clear a server). useCoverArtBridge subscribes to cover:bucket-
renamed and calls it with oldKey.
Tests: forgetDiskSrcForServer happy path, empty-key defensive
no-op, no-match no-op; plus a regression-pin on the existing
forgetDiskSrcPrefix so the two helpers don't drift in semantics.
* test(dual-server): fill the high-impact coverage gaps from the branch review
Adds the tests the review identified as missing on the hot-path UI
flows + the partial-fail / alt-only edges:
* AddServerForm.test.tsx (new) — five component tests:
single-address save / dual-address save / two-LAN block-with-
toast / v2 magic-string paste forwarding alt + share flag /
edit-flow stripping alt+flag when the field is emptied.
* Login.test.tsx (new) — v2 invite paste persists alternateUrl +
shareUsesLocalUrl onto the saved profile; v1 invite leaves
those fields undefined.
* shareLink.test.ts — alternateUrl-only match for
findServerIdForShareUrl (the dual-address paste case where the
host shared the LAN URL via shareUsesLocalUrl=true); 'first hit
wins' across two profiles where both match.
* serverFingerprint.test.ts — partial-fail mix (folders+user ok,
license+indexes rejected) so a Promise.allSettled regression
wouldn't go silent.
* cover_cache/mod.rs — rename_bucket_inner extracted as a
testable FS-only helper (the Tauri command wrapper now just
locks state + calls it + emits the event), plus six tests
covering empty/unsafe keys, no-op-when-missing, no-op-when-
equal, simple-rename, merge-with-prefer-existing.
Also: every test fixture that I authored on this branch using
'frank' / 'frank@example.com' as a stand-in username/email
swapped to 'tester' / 'tester@example.com' — keeping personal
names out of test data is the codebase convention (see
factories.ts) and is now a memory rule.
* fix(test): typed mock access for pingWithCredentials in useConnectionStatus
vi.mocked(...) returns the mock typed properly — bare .mock fails
tsc because the imported function signature isn't a vi.Mock at
import time.
* docs(changelog): dual server address (PR #880)
* fix(test): align diskSrcCache tests with main cover storage key API
After rebase onto main, forgetDiskSrcPrefix takes a CoverArtRef-shaped
argument and storage keys include cacheKind; merge coverDiskUrl tests
from main with dual-address forgetDiskSrcForServer coverage.
610 lines
41 KiB
TypeScript
610 lines
41 KiB
TypeScript
export const settings = {
|
||
title: 'Configuración',
|
||
language: 'Idioma',
|
||
languageEn: 'English',
|
||
languageDe: 'Deutsch',
|
||
languageEs: 'Español',
|
||
languageFr: 'Français',
|
||
languageNl: 'Nederlands',
|
||
languageNb: 'Norsk',
|
||
languageRu: 'Русский',
|
||
languageZh: '中文',
|
||
languageRo: 'Română',
|
||
font: 'Fuente',
|
||
fontHintOpenDyslexic: 'Compatible con dislexia · sin soporte para chino',
|
||
theme: 'Tema',
|
||
appearance: 'Apariencia',
|
||
servers: 'Servidores',
|
||
serverName: 'Nombre del Servidor',
|
||
serverUrl: 'URL del Servidor',
|
||
serverUrlPlaceholder: '192.168.1.100:4533 o https://music.example.com',
|
||
serverAlternateUrl: 'Segunda dirección (opcional)',
|
||
serverAlternateUrlPlaceholderPublic: 'https://music.example.com',
|
||
serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533',
|
||
serverAlternateUrlHintAddPublic: 'Puedes añadir una dirección pública para acceder fuera de tu red doméstica.',
|
||
serverAlternateUrlHintAddLocal: 'Puedes añadir una dirección local para una conexión más rápida en casa.',
|
||
serverBothLanError: 'Ambas direcciones son locales: conserva una o añade una pública.',
|
||
dualAddressVerifying: 'Comprobando que ambas direcciones apuntan al mismo servidor…',
|
||
dualAddressMismatch: 'Las dos direcciones apuntan a servidores distintos. Comprueba la ortografía y vuelve a intentarlo.',
|
||
dualAddressInsufficient: 'No se pudo confirmar que ambas direcciones sean el mismo servidor. Elimina una dirección para continuar.',
|
||
dualAddressUnreachable: 'No se pudo contactar con {{host}}. Comprueba la dirección y tu red, y vuelve a intentarlo.',
|
||
urlRemigrationTitle: '¿Mover los datos de la biblioteca?',
|
||
urlRemigrationMessage: 'Cambiar la dirección moverá todos los datos de biblioteca, análisis y caché de carátulas de {{oldKey}} a {{newKey}}. Esto no se puede deshacer.',
|
||
urlRemigrationConfirm: 'Mover datos',
|
||
urlRemigrationProgress: 'Moviendo los datos de la biblioteca…',
|
||
urlRemigrationFailureInspect: 'No se pudo comprobar qué se movería. Vuelve a intentarlo o abre el registro de depuración.',
|
||
urlRemigrationFailureRun: 'Fallo al mover los datos de la biblioteca. Tus datos guardados están intactos — vuelve a intentarlo.',
|
||
urlRemigrationFailureCoverRename: 'Los datos de la biblioteca se movieron, pero los archivos de carátulas no se pudieron renombrar. Las carátulas se reconstruirán en el próximo acceso.',
|
||
shareUsesLocalUrl: 'Usar la dirección local en los enlaces compartidos',
|
||
shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.',
|
||
serverUsername: 'Usuario',
|
||
serverPassword: 'Contraseña',
|
||
addServer: 'Agregar Servidor',
|
||
addServerTitle: 'Agregar Nuevo Servidor',
|
||
editServer: 'Editar',
|
||
editServerTitle: 'Editar Servidor',
|
||
useServer: 'Usar',
|
||
deleteServer: 'Eliminar',
|
||
noServers: 'No hay servidores guardados.',
|
||
serverActive: 'Activo',
|
||
confirmDeleteServer: '¿Eliminar servidor "{{name}}"?',
|
||
serverConnecting: 'Conectando…',
|
||
serverConnected: '¡Conectado!',
|
||
serverFailed: 'Conexión fallida.',
|
||
testBtn: 'Probar Conexión',
|
||
testingBtn: 'Probando…',
|
||
serverCompatible: 'Diseñado para Navidrome. Otros servidores compatibles con Subsonic (Gonic, Airsonic, …) pueden funcionar con funcionalidad reducida, ya que Psysonic utiliza muchos endpoints específicos de la API de Navidrome.',
|
||
userMgmtTitle: 'Gestión de usuarios',
|
||
userMgmtDesc: 'Administra los usuarios de este servidor. Requiere privilegios de administrador.',
|
||
userMgmtNoAdmin: 'Necesitas privilegios de administrador para gestionar usuarios en este servidor.',
|
||
userMgmtLoadError: 'No se pudieron cargar los usuarios.',
|
||
userMgmtLoadFriendly: 'El servidor no respondió — suele ser algo puntual.',
|
||
userMgmtRetry: 'Reintentar',
|
||
userMgmtEmpty: 'No se encontraron usuarios.',
|
||
userMgmtYouBadge: 'Tú',
|
||
userMgmtAdminBadge: 'Admin',
|
||
userMgmtAddUser: 'Añadir usuario',
|
||
userMgmtAddUserTitle: 'Nuevo usuario',
|
||
userMgmtEditUserTitle: 'Editar usuario',
|
||
userMgmtUsername: 'Nombre de usuario',
|
||
userMgmtName: 'Nombre visible',
|
||
userMgmtEmail: 'Correo electrónico',
|
||
userMgmtPassword: 'Contraseña',
|
||
userMgmtPasswordEditHint: 'Introduce una nueva contraseña para actualizarla.',
|
||
userMgmtRoleAdmin: 'Admin',
|
||
userMgmtLibraries: 'Bibliotecas',
|
||
userMgmtLibrariesAdminHint: 'Los usuarios admin tienen acceso automáticamente a todas las bibliotecas.',
|
||
userMgmtLibrariesEmpty: 'No hay bibliotecas disponibles en este servidor.',
|
||
userMgmtLibrariesValidation: 'Selecciona al menos una biblioteca.',
|
||
userMgmtLibrariesUpdateError: 'Usuario guardado, pero la asignación de bibliotecas falló',
|
||
userMgmtNoLibraries: 'Sin bibliotecas asignadas',
|
||
userMgmtNeverSeen: 'Nunca',
|
||
userMgmtSave: 'Guardar',
|
||
userMgmtCancel: 'Cancelar',
|
||
userMgmtDelete: 'Eliminar',
|
||
userMgmtEdit: 'Editar',
|
||
userMgmtConfirmDelete: '¿Eliminar el usuario «{{username}}»? Esta acción no se puede deshacer.',
|
||
userMgmtCreateError: 'No se pudo crear el usuario.',
|
||
userMgmtUpdateError: 'No se pudo actualizar el usuario.',
|
||
userMgmtDeleteError: 'No se pudo eliminar el usuario.',
|
||
userMgmtCreated: 'Usuario creado.',
|
||
userMgmtUpdated: 'Usuario actualizado.',
|
||
userMgmtDeleted: 'Usuario eliminado.',
|
||
userMgmtValidationMissing: 'Se requieren nombre de usuario, nombre visible y contraseña.',
|
||
userMgmtValidationMissingIdentity: 'Se requieren nombre de usuario y nombre visible.',
|
||
userMgmtMagicStringGenerate: 'Generar cadena mágica',
|
||
userMgmtSaveAndMagicString: 'Guardar y obtener cadena mágica',
|
||
userMgmtMagicStringPasswordNavHint:
|
||
'Navidrome guardará esta contraseña para el usuario. Si difiere de la actual, se actualizará la contraseña de acceso en el servidor.',
|
||
userMgmtMagicStringPlaintextWarning:
|
||
'Comparte la cadena mágica con cuidado: contiene la contraseña sin cifrar (la codificación no es cifrado). Quien tenga la cadena completa puede iniciar sesión como este usuario.',
|
||
userMgmtMagicStringCopied: 'Cadena mágica copiada al portapapeles.',
|
||
userMgmtMagicStringCopyFailed: 'No se pudo copiar al portapapeles.',
|
||
userMgmtMagicStringLoginFailed: 'Falló la comprobación de la contraseña; no se pudieron verificar las credenciales.',
|
||
userMgmtMagicStringModalTitle: 'Generar cadena mágica',
|
||
userMgmtMagicStringModalDesc: 'Introduce la contraseña Subsonic de «{{username}}». Se incluirá en la cadena mágica copiada.',
|
||
userMgmtMagicStringModalConfirm: 'Copiar cadena',
|
||
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
|
||
audiomuseDesc:
|
||
'Activa si este servidor tiene el plugin <pluginLink>AudioMuse-AI Navidrome</pluginLink> configurado. Habilita Mezcla Instantánea desde pistas y usa artistas similares del servidor en lugar de Last.fm en páginas de artistas.',
|
||
audiomuseIssueHint:
|
||
'La Mezcla Instantánea falló recientemente — verifica el plugin de Navidrome y la API de AudioMuse. Artistas similares usan Last.fm como respaldo cuando el servidor no devuelve ninguno.',
|
||
connected: 'Conectado',
|
||
failed: 'Fallido',
|
||
eqTitle: 'Ecualizador',
|
||
eqEnabled: 'Habilitar Ecualizador',
|
||
eqPreset: 'Preajuste',
|
||
eqPresetCustom: 'Personalizado',
|
||
eqPresetBuiltin: 'Preajustes Integrados',
|
||
eqPresetCustomGroup: 'Mis Preajustes',
|
||
eqSavePreset: 'Guardar como Preajuste',
|
||
eqPresetName: 'Nombre del preajuste…',
|
||
eqDeletePreset: 'Eliminar preajuste',
|
||
eqResetBands: 'Restablecer a Plano',
|
||
eqPreGain: 'Pre-ganancia',
|
||
eqResetPreGain: 'Restablecer pre-ganancia',
|
||
eqAutoEqTitle: 'Búsqueda AutoEQ de Auriculares',
|
||
eqAutoEqPlaceholder: 'Buscar modelo de auricular / IEM…',
|
||
eqAutoEqSearching: 'Buscando…',
|
||
eqAutoEqNoResults: 'No se encontraron resultados',
|
||
eqAutoEqError: 'Error de búsqueda',
|
||
eqAutoEqRateLimit: 'Límite de GitHub alcanzado — intenta de nuevo en un minuto',
|
||
eqAutoEqFetchError: 'Error al obtener perfil EQ',
|
||
lfmTitle: 'Last.fm',
|
||
lfmConnect: 'Conectar con Last.fm',
|
||
lfmConnecting: 'Esperando autorización…',
|
||
lfmConfirm: 'He autorizado la aplicación',
|
||
lfmConnected: 'Conectado como',
|
||
lfmDisconnect: 'Desconectar',
|
||
lfmConnectDesc: 'Conecta tu cuenta de Last.fm para habilitar scrobbling y actualizaciones de "Reproduciendo Ahora" directamente desde Psysonic — no requiere configuración de Navidrome.',
|
||
lfmOpenBrowser: 'Se abrirá una ventana del navegador. Autoriza Psysonic en Last.fm, luego click en el botón de abajo.',
|
||
lfmScrobbles: '{{n}} scrobbles',
|
||
lfmMemberSince: 'Miembro desde {{year}}',
|
||
scrobbleEnabled: 'Scrobbling habilitado',
|
||
scrobbleDesc: 'Enviar canciones a Last.fm después del 50% de reproducción',
|
||
behavior: 'Comportamiento de la App',
|
||
cacheTitle: 'Tamaño Máx. de Almacenamiento',
|
||
cacheDesc: 'Portadas e imágenes de artistas. Cuando está lleno, las entradas más antiguas se eliminan automáticamente. Los álbumes offline no se eliminan automáticamente, pero se borrarán al limpiar la caché manualmente.',
|
||
cacheUsedImages: 'Imágenes:',
|
||
cacheUsedOffline: 'Pistas offline:',
|
||
cacheUsedHot: 'Tamaño en disco:',
|
||
hotCacheTrackCount: 'Pistas en caché:',
|
||
cacheMaxLabel: 'Tamaño máx.',
|
||
cacheClearBtn: 'Limpiar Caché',
|
||
cacheClearWarning: 'Esto también eliminará todos los álbumes offline de la biblioteca.',
|
||
cacheClearConfirm: 'Limpiar Todo',
|
||
cacheClearCancel: 'Cancelar',
|
||
offlineDirTitle: 'Biblioteca Offline (En App)',
|
||
offlineDirDesc: 'Ubicación de almacenamiento para pistas que haces disponibles offline dentro de Psysonic.',
|
||
offlineDirDefault: 'Predeterminado (Datos de App)',
|
||
offlineDirChange: 'Cambiar Directorio',
|
||
offlineDirClear: 'Restablecer a Predeterminado',
|
||
offlineDirHint: 'Las nuevas descargas usarán esta ubicación. Las descargas existentes permanecen en su ruta original.',
|
||
hotCacheTitle: 'Caché de reproducción activa',
|
||
hotCacheDisclaimer: 'Precarga las siguientes pistas en cola y mantiene las anteriores. Cuando está activado, usa espacio en disco y red.',
|
||
hotCacheDirDefault: 'Predeterminado (Datos de App)',
|
||
hotCacheDirChange: 'Cambiar carpeta',
|
||
hotCacheDirClear: 'Restablecer a predeterminado',
|
||
hotCacheDirHint: 'Cambiar la carpeta restablece el índice en-app; los archivos ya en disco permanecen donde están hasta que los elimines.',
|
||
hotCacheEnabled: 'Habilitar caché de reproducción activa',
|
||
hotCacheMaxMb: 'Tamaño máximo de caché',
|
||
hotCacheDebounce: 'Espera de cambio de cola',
|
||
hotCacheDebounceImmediate: 'Inmediato',
|
||
hotCacheDebounceSeconds: '{{n}} s',
|
||
hotCacheClearBtn: 'Limpiar caché activa',
|
||
audioOutputDevice: 'Dispositivo de salida de audio',
|
||
audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.',
|
||
audioOutputDeviceDefault: 'Predeterminado del sistema',
|
||
audioOutputDeviceRefresh: 'Actualizar lista de dispositivos',
|
||
audioOutputDeviceOsDefaultNow: 'salida actual del sistema',
|
||
audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.',
|
||
audioOutputDeviceNotInCurrentList: 'no está en la lista actual',
|
||
audioOutputDeviceMacNotice: 'En macOS, la reproducción actualmente sigue siempre al dispositivo de salida del sistema por razones técnicas. Cambia el destino mediante Ajustes del Sistema → Sonido o el icono del altavoz en la barra de menús. Motivo: CoreAudio activa una solicitud de permiso de micrófono al abrir un stream no-predeterminado — lo evitamos usando siempre la salida por defecto del sistema.',
|
||
hiResTitle: 'Reproducción Nativa Hi-Res',
|
||
hiResEnabled: 'Habilitar reproducción nativa hi-res',
|
||
hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).",
|
||
showArtistImages: 'Mostrar Imágenes de Artistas',
|
||
showArtistImagesDesc: 'Carga y muestra imágenes de artistas en el resumen de Artistas. Desactivado por defecto para reducir I/O de disco del servidor y carga de red en bibliotecas grandes.',
|
||
showOrbitTrigger: 'Mostrar "Orbit" en la cabecera',
|
||
showOrbitTriggerDesc: 'El botón en la cabecera para iniciar o unirse a una sesión de escucha compartida. Ocúltalo si no usas Orbit — puedes volver a activarlo aquí.',
|
||
showTrayIcon: 'Mostrar Icono en Bandeja',
|
||
showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.',
|
||
minimizeToTray: 'Minimizar a Bandeja',
|
||
minimizeToTrayDesc: 'Al cerrar la ventana, mantener Psysonic ejecutándose en la bandeja del sistema en lugar de salir.',
|
||
clockFormat: 'Formato de hora',
|
||
clockFormatDesc: 'Formato del reloj utilizado por la ETA de la cola y la vista previa del temporizador de suspensión.',
|
||
clockFormatAuto: 'Automático (idioma del sistema)',
|
||
clockFormatTwentyFour: '24 horas',
|
||
clockFormatTwelve: '12 horas (AM/PM)',
|
||
preloadMiniPlayer: 'Precargar mini reproductor',
|
||
preloadMiniPlayerDesc: 'Crea la ventana del mini reproductor en segundo plano al iniciar la aplicación para que muestre contenido al instante la primera vez que se abre. Consume un poco más de memoria.',
|
||
discordRichPresence: 'Discord Rich Presence',
|
||
discordRichPresenceDesc: 'Muestra la pista actual en tu perfil de Discord. Requiere que Discord esté ejecutándose.',
|
||
useCustomTitlebar: 'Barra de título personalizada',
|
||
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
|
||
linuxWebkitSmoothScroll: 'Rueda suave (Linux)',
|
||
linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).',
|
||
linuxWaylandTextRender: 'Renderizado de texto Wayland (Linux)',
|
||
linuxWaylandTextRenderDesc:
|
||
'El suavizado en la interfaz se aplica al instante. La aceleración de hardware de WebKit (nítido/GPU) se guarda y aplica al reiniciar la app; cambiarla en vivo puede congelar WebKitGTK.',
|
||
linuxWaylandTextRenderBalanced: 'Equilibrado',
|
||
linuxWaylandTextRenderSharp: 'Nítido (más CPU)',
|
||
linuxWaylandTextRenderGpu: 'Prioridad GPU',
|
||
linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)',
|
||
discordCoverSource: 'Fuente de portada',
|
||
discordCoverSourceDesc: 'De dónde obtener la portada del álbum para tu perfil de Discord.',
|
||
discordCoverNone: 'Ninguna (solo icono de la app)',
|
||
discordCoverServer: 'Servidor (vía info del álbum)',
|
||
discordCoverApple: 'Apple Music',
|
||
discordOptions: 'Opciones avanzadas de Discord',
|
||
discordTemplates: 'Plantillas de texto personalizadas',
|
||
discordTemplatesDesc: 'Personaliza qué información se muestra en tu perfil de Discord. Variables: {title}, {artist}, {album}',
|
||
discordTemplateDetails: 'Línea principal (details)',
|
||
discordTemplateState: 'Línea secundaria (state)',
|
||
discordTemplateLargeText: 'Tooltip del álbum (largeText)',
|
||
nowPlayingEnabled: 'Mostrar en Reproduciendo Ahora',
|
||
nowPlayingEnabledDesc: 'Transmite tu pista actual a la vista de oyentes en vivo del servidor. Desactiva para dejar de enviar datos de reproducción.',
|
||
enableBandsintown: 'Fechas de gira de Bandsintown',
|
||
enableBandsintownDesc: 'Muestra próximos conciertos del artista actual en la pestaña Info. Los datos se obtienen de la API pública de Bandsintown.',
|
||
lyricsServerFirst: 'Preferir letras del servidor',
|
||
lyricsServerFirstDesc: 'Verifica primero letras provistas por el servidor (etiquetas embebidas, archivos sidecar) antes de consultar LRCLIB. Desactiva para usar LRCLIB primero.',
|
||
enableNeteaselyrics: 'Letras de Netease Cloud Music',
|
||
enableNeteaselyricsDesc: 'Usa Netease Cloud Music como fuente de letras de último recurso cuando el servidor y LRCLIB no devuelvan nada. Mejor cobertura para música asiática e internacional.',
|
||
lyricsSourcesTitle: 'Fuentes de Letras',
|
||
lyricsSourcesDesc: 'Elige qué fuentes consultar para letras y en qué orden. Arrastra para reordenar. Las fuentes desactivadas se omiten completamente.',
|
||
lyricsSourceServer: 'Servidor',
|
||
lyricsSourceLrclib: 'LRCLIB',
|
||
lyricsSourceNetease: 'Netease Cloud Music',
|
||
lyricsYouLyPlus: 'YouLyPlus (Karaoke)',
|
||
lyricsYouLyPlusDesc: 'Sincronización palabra por palabra desde un backend comunitario (Apple Music, Spotify, Musixmatch, QQ). Se prueba primero; las fuentes activadas abajo actúan como respaldo.',
|
||
lyricsSourcesFallbackHint: 'Se usan como respaldo cuando YouLyPlus no encuentra nada. Arrastra para reordenar.',
|
||
lyricsSourcesPrimaryHint: 'Se prueban en orden. Arrastra para reordenar. Todo desactivado = letras desactivadas.',
|
||
lyricsStaticOnly: 'Mostrar letras como texto estático',
|
||
lyricsStaticOnlyDesc: 'Muestra las letras sincronizadas sin desplazamiento automático ni resaltado por palabra.',
|
||
downloadsTitle: 'Exportación ZIP y Archivado',
|
||
downloadsFolderDesc: 'Carpeta de destino para álbumes que descargas como ZIP a tu computadora.',
|
||
downloadsDefault: 'Carpeta de Descargas Predeterminada',
|
||
pickFolder: 'Seleccionar',
|
||
pickFolderTitle: 'Seleccionar Carpeta de Descarga',
|
||
clearFolder: 'Limpiar carpeta de descarga',
|
||
logout: 'Cerrar Sesión',
|
||
aboutTitle: 'Acerca de Psysonic',
|
||
aboutDesc: 'Un reproductor de música de escritorio moderno creado para Navidrome. Utiliza la API de Subsonic más extensiones específicas de Navidrome. Construido en Tauri v2 con motor de audio nativo en Rust — ligero y rápido, pero lleno de características: barra de progreso tipo onda, letras sincronizadas, integración Last.fm, ecualizador de 10 bandas, crossfade, reproducción gapless, Replay Gain, navegación por géneros, y una gran biblioteca de temas.',
|
||
aboutLicense: 'Licencia',
|
||
aboutLicenseText: 'GNU GPL v3 — libre para usar, modificar y distribuir bajo la misma licencia.',
|
||
aboutRepo: 'Código Fuente en GitHub',
|
||
aboutVersion: 'Versión',
|
||
aboutBuiltWith: 'Construido con Tauri · React · TypeScript · Rust/rodio',
|
||
aboutReleaseNotesLabel: 'Notas de la versión',
|
||
aboutReleaseNotesLink: 'Abrir las novedades de esta versión',
|
||
aboutContributorsLabel: 'Contribuidores',
|
||
showChangelogOnUpdate: "Mostrar 'Novedades' al actualizar",
|
||
showChangelogOnUpdateDesc: 'Muestra un discreto banner de changelog encima de Now Playing tras una actualización. Clic abre las notas de la versión; X lo oculta.',
|
||
libraryGridMaxColumnsTitle: 'Rejillas de tarjetas en la biblioteca',
|
||
libraryGridMaxColumnsPerfHint: 'Más columnas caben más mosaicos por fila y pueden aumentar el trabajo de diseño y pintado; se nota más en bibliotecas muy grandes o equipos lentos.',
|
||
libraryGridMaxColumnsRangeLabel: 'Máximo de columnas ({{min}}–{{max}})',
|
||
libraryGridMaxColumnsDesc: 'Se aplica a vistas de álbum, artista, lista de reproducción, radio, sin conexión y otras con tarjetas. Menos columnas = mosaicos más grandes y suele aligerar la CPU.',
|
||
libraryIndexTitle: 'Índice local de biblioteca (vista previa)',
|
||
libraryIndexDesc: 'Mantiene una copia local de la base de datos de pistas de cada servidor para que la navegación y la búsqueda sean rápidas y funcionen sin conexión. La sincronización inicial se ejecuta en todos los servidores configurados; los servidores sin conexión se reintentan automáticamente.',
|
||
libraryIndexDeltaHint: 'La sincronización delta en segundo plano comprueba los servidores vinculados cada 30 segundos y se ejecuta cuando corresponde — normalmente cada 5–45 minutos según el tamaño de la biblioteca y la carga.',
|
||
libraryIndexEnable: 'Activar índice local de biblioteca',
|
||
libraryIndexEnableAllDesc: 'Indexar y sincronizar todos los servidores configurados en segundo plano.',
|
||
libraryIndexNoServer: 'Añade un servidor primero.',
|
||
libraryIndexServerListTitle: 'Servidores indexados',
|
||
libraryIndexAllExcluded: 'Todos los servidores están excluidos de la sincronización. Vuelve a activar el índice o añade un servidor.',
|
||
libraryIndexServerOffline: 'Servidor sin conexión — sincronización aplazada',
|
||
libraryIndexServerDeferred: 'Aplazada',
|
||
libraryIndexServerSyncing: 'Sincronizando…',
|
||
libraryIndexFullResync: 'Resincronización completa',
|
||
libraryIndexDeltaSync: 'Sincronización delta',
|
||
libraryIndexExcludeServer: 'Excluir de la sincronización',
|
||
libraryIndexExcludingServer: 'Excluyendo…',
|
||
libraryIndexExcludedTitle: 'Excluidos de la sincronización',
|
||
libraryIndexIncludeServer: 'Incluir de nuevo',
|
||
libraryIndexIncludingServer: 'Incluyendo…',
|
||
libraryIndexStatus: 'Estado',
|
||
libraryIndexStatusIdle: 'Inactivo',
|
||
libraryIndexStatusProbing: 'Comprobando servidor…',
|
||
libraryIndexStatusInitial: 'Sincronización inicial…',
|
||
libraryIndexStatusReady: 'Listo ({{count}} pistas)',
|
||
libraryIndexStatusError: 'Error — consulta los registros',
|
||
libraryIndexProgressIngest: '{{count}} pistas indexadas…',
|
||
libraryIndexProgressVerify: '{{checked}} verificadas ({{deleted}} eliminadas)',
|
||
libraryIndexSyncNow: 'Sincronizar ahora',
|
||
libraryIndexVerify: 'Verificar integridad de la biblioteca',
|
||
libraryIndexCancel: 'Cancelar',
|
||
libraryIndexAutoReconcile: 'Reconciliación automática al bajar el recuento',
|
||
libraryIndexAutoReconcileDesc: 'Comprueba automáticamente pistas eliminadas cuando el servidor informa de menos de las esperadas.',
|
||
libraryIndexSyncError: 'Error de sincronización de biblioteca: {{error}}',
|
||
libraryIndexBindError: 'No se pudo activar el índice: {{error}}',
|
||
randomMixTitle: 'Lista negra de Mezcla Aleatoria',
|
||
luckyMixMenuTitle: 'Mostrar Mezcla Suerte en el menú',
|
||
luckyMixMenuDesc: 'Activa Mezcla Suerte en "Crear Mezcla" y como elemento de menú separado cuando la navegación dividida está activa. Solo visible cuando AudioMuse está activo en el servidor actual.',
|
||
randomMixBlacklistTitle: 'Palabras Clave de Filtro Personalizadas',
|
||
randomMixBlacklistDesc: 'Las canciones se excluyen cuando cualquier palabra clave coincide con su género, título, álbum o artista (activo cuando el checkbox de arriba está activado).',
|
||
randomMixBlacklistPlaceholder: 'Agregar palabra clave…',
|
||
randomMixBlacklistAdd: 'Agregar',
|
||
randomMixBlacklistEmpty: 'Aún no hay palabras clave personalizadas.',
|
||
randomMixHardcodedTitle: 'Palabras clave integradas (activas cuando el checkbox está activado)',
|
||
tabAudio: 'Audio',
|
||
tabStorage: 'Sin conexión y caché',
|
||
tabAppearance: 'Apariencia',
|
||
tabLibrary: 'Biblioteca',
|
||
tabServers: 'Servidores',
|
||
tabLyrics: 'Letras',
|
||
tabPersonalisation: 'Personalización',
|
||
tabIntegrations: 'Integraciones',
|
||
inputKeybindingsTitle: 'Atajos de teclado',
|
||
aboutContributorsCount_one: '{{count}} contribución',
|
||
aboutContributorsCount_other: '{{count}} contribuciones',
|
||
searchPlaceholder: 'Buscar en ajustes…',
|
||
searchNoResults: 'Ningún ajuste coincide con tu búsqueda.',
|
||
aboutMaintainersLabel: 'Mantenedores',
|
||
integrationsPrivacyTitle: 'Aviso de privacidad',
|
||
integrationsPrivacyBody: 'Todas las integraciones de esta pestaña son <strong>opt-in</strong> y, una vez activadas, envían datos a servicios externos o a tu servidor Navidrome. Last.fm recibe tu historial de escucha, Discord muestra la pista actual en tu perfil, Bandsintown se consulta por artista para obtener fechas de gira, y la compartición «En reproducción» publica tu pista actual para otros usuarios de tu servidor Navidrome. Si no quieres nada de esto, simplemente deja desactivada la sección correspondiente.',
|
||
homeCustomizerTitle: 'Página de Inicio',
|
||
queueToolbarTitle: 'Barra de herramientas de cola',
|
||
queueToolbarReset: 'Restablecer a predeterminado',
|
||
queueToolbarSeparator: 'Separador',
|
||
sidebarTitle: 'Barra Lateral',
|
||
sidebarReset: 'Restablecer a predeterminado',
|
||
artistLayoutTitle: 'Secciones de la página del artista',
|
||
artistLayoutDesc: 'Arrastra para reordenar, alterna para ocultar secciones individuales de la página del artista. Las secciones sin datos se omiten automáticamente.',
|
||
artistLayoutReset: 'Restablecer a predeterminado',
|
||
artistLayoutBio: 'Biografía del artista',
|
||
artistLayoutTopTracks: 'Mejores pistas',
|
||
artistLayoutSimilar: 'Artistas similares',
|
||
artistLayoutAlbums: 'Álbumes',
|
||
artistLayoutFeatured: 'También presente en',
|
||
playlistLayoutTitle: 'Diseño de la página de playlists',
|
||
playlistLayoutDesc: 'Mostrar u ocultar elementos individuales en la página de playlists.',
|
||
playlistLayoutReset: 'Restablecer a predeterminado',
|
||
playerBarTitle: 'Barra de reproducción',
|
||
playerBarReset: 'Restablecer a predeterminado',
|
||
playerBarStarRating: 'Calificación con estrellas',
|
||
playerBarFavorite: 'Favorito (corazón)',
|
||
playerBarLastfmLove: 'Last.fm love',
|
||
playerBarPlaybackRate: 'Velocidad de reproducción',
|
||
playerBarEqualizer: 'Ecualizador',
|
||
playerBarMiniPlayer: 'Mini reproductor',
|
||
playbackRateTitle: 'Velocidad de reproducción',
|
||
playbackRateEnabled: 'Activar velocidad de reproducción',
|
||
playbackRateEnabledDesc: 'Velocidad global para todas las pistas. No se aplica a la radio ni a las vistas previas.',
|
||
playbackRateStrategy: 'Estrategia',
|
||
playbackRateStrategySpeed: 'Velocidad',
|
||
playbackRateStrategyVarispeed: 'Con tono',
|
||
playbackRateStrategyPreserve: 'Tono',
|
||
playbackRateSpeed: 'Velocidad',
|
||
playbackRatePitch: 'Tono',
|
||
playbackRateDerivedPitch: 'Cambio de tono por velocidad: {{value}}',
|
||
playbackRateAutoPitch: 'El tono se corrige automáticamente.',
|
||
playbackRateHint: '« Velocidad » mantiene el tono natural. « Con tono » cambia el tono con la velocidad. « Tono » añade un desplazamiento manual. Usa más CPU que « Con tono ». No para radio, vistas previas u Orbit.',
|
||
playbackRateNeutral: 'A 1,0× y sin desplazamiento de tono, la reproducción es normal.',
|
||
playbackRateOrbitPaused: 'No se aplica durante sesiones Orbit — la reproducción permanece a 1,0× para sincronizar.',
|
||
playbackRateOrbitPausedShort: 'Orbit: 1,0× para sincronizar.',
|
||
advancedMode: 'Avanzado',
|
||
advancedModeTooltip: 'Mostrar opciones avanzadas en todas las pestañas de ajustes. Aportes de la comunidad que no reflejan necesariamente la filosofía de diseño de los mantenedores de Psysonic.',
|
||
advancedBadge: 'Avanzado',
|
||
sidebarDrag: 'Arrastra para reordenar',
|
||
sidebarFixed: 'Siempre visible',
|
||
randomNavSplitTitle: 'Dividir navegación Mix',
|
||
randomNavSplitDesc: 'Mostrar "Mezcla Aleatoria", "Álbumes Aleatorios" y "Mezcla Suerte" como entradas separadas en la barra lateral en lugar del hub "Crear Mezcla".',
|
||
tabInput: 'Entrada',
|
||
tabUsers: 'Usuarios',
|
||
tabSystem: 'Sistema',
|
||
loggingTitle: 'Registro',
|
||
loggingModeDesc: 'Controla la verbosidad de los logs del backend en la terminal.',
|
||
loggingModeOff: 'Apagado',
|
||
loggingModeNormal: 'Normal',
|
||
loggingModeDebug: 'Depuración',
|
||
loggingExport: 'Exportar logs',
|
||
loggingExportSuccess: 'Logs exportados ({{count}} líneas).',
|
||
loggingExportError: 'No se pudieron exportar los logs.',
|
||
ratingsSectionTitle: 'Calificaciones',
|
||
ratingsSkipStarTitle: 'Saltar para 1 estrella',
|
||
ratingsSkipStarDesc:
|
||
'Después de varios saltos seguidos, asigna 1 estrella a la pista. Solo para pistas aún no calificadas.',
|
||
ratingsSkipStarThresholdLabel: 'Saltos',
|
||
ratingsMixFilterTitle: 'Filtrar por calificación',
|
||
ratingsMixFilterDesc:
|
||
'Filtra elementos de baja calificación en {{mix}} y {{albums}}. Click en la estrella seleccionada nuevamente para desactivar el umbral.',
|
||
ratingsMixMinSong: 'Canciones',
|
||
ratingsMixMinAlbum: 'Álbumes',
|
||
ratingsMixMinArtist: 'Artistas',
|
||
ratingsMixMinThresholdAria: 'Estrellas mínimas: {{label}}',
|
||
backupTitle: 'Respaldo y Restauración',
|
||
backupExport: 'Exportar configuración',
|
||
backupExportDesc: 'Guarda toda la configuración, perfiles de servidor, configuración Last.fm, tema, EQ y atajos de teclado a un archivo .psybkp. Las contraseñas se almacenan en texto plano — mantén el archivo seguro.',
|
||
backupImport: 'Importar configuración',
|
||
backupImportDesc: 'Restaura configuración desde un archivo .psybkp. La app se recargará después de importar.',
|
||
backupImportConfirm: 'Esto sobrescribirá toda la configuración actual. ¿Continuar?',
|
||
backupSuccess: 'Respaldo guardado',
|
||
backupImportSuccess: 'Configuración restaurada — recargando…',
|
||
backupImportError: 'Archivo de respaldo inválido o corrupto.',
|
||
shortcutsReset: 'Restablecer a predeterminados',
|
||
shortcutListening: 'Presiona una tecla…',
|
||
shortcutUnbound: '—',
|
||
globalShortcutsTitle: 'Atajos Globales',
|
||
globalShortcutsNote: 'Funcionan en todo el sistema incluso cuando Psysonic está en segundo plano. Requieren Ctrl, Alt o Super como modificador.',
|
||
shortcutClear: 'Limpiar',
|
||
shortcutPlayPause: 'Reproducir / Pausa',
|
||
shortcutNext: 'Siguiente pista',
|
||
shortcutPrev: 'Pista anterior',
|
||
shortcutVolumeUp: 'Subir volumen',
|
||
shortcutVolumeDown: 'Bajar volumen',
|
||
shortcutSeekForward: 'Avanzar 10s',
|
||
shortcutSeekBackward: 'Retroceder 10s',
|
||
shortcutToggleQueue: 'Alternar cola',
|
||
shortcutOpenFolderBrowser: 'Abrir {{folderBrowser}}',
|
||
shortcutFullscreenPlayer: 'Reproductor pantalla completa',
|
||
shortcutNativeFullscreen: 'Pantalla completa nativa',
|
||
shortcutOpenMiniPlayer: 'Abrir mini reproductor',
|
||
shortcutStartSearch: 'Iniciar una búsqueda',
|
||
shortcutStartAdvancedSearch: 'Iniciar una búsqueda avanzada',
|
||
shortcutToggleSidebar: 'Alternar barra lateral',
|
||
shortcutMuteSound: 'Silenciar',
|
||
shortcutToggleEqualizer: 'Abrir / alternar ecualizador',
|
||
shortcutToggleRepeat: 'Alternar repetición',
|
||
shortcutOpenNowPlaying: 'Abrir "Reproduciendo ahora"',
|
||
shortcutShowLyrics: 'Mostrar letra',
|
||
shortcutFavoriteCurrentTrack: 'Añadir pista actual a favoritos',
|
||
shortcutOpenHelp: 'Ayuda',
|
||
playbackTitle: 'Reproducción',
|
||
replayGain: 'Replay Gain',
|
||
replayGainDesc: 'Normalizar volumen de pistas usando metadatos ReplayGain',
|
||
replayGainMode: 'Modo',
|
||
replayGainAuto: 'Auto',
|
||
replayGainAutoDesc: 'Usa la ganancia del álbum cuando las pistas adyacentes en la cola son del mismo álbum, si no la ganancia de pista.',
|
||
replayGainTrack: 'Pista',
|
||
replayGainAlbum: 'Álbum',
|
||
replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)',
|
||
replayGainPreGainDesc: 'Aumento universal añadido a cada cálculo de ReplayGain. Útil si tu biblioteca te parece globalmente demasiado baja.',
|
||
replayGainFallback: 'Respaldo (sin etiquetar / radio)',
|
||
replayGainFallbackDesc: 'Se aplica a pistas (y emisoras de radio) sin etiquetas ReplayGain, para que el contenido sin etiquetar no salte más fuerte que el resto.',
|
||
normalization: 'Normalización',
|
||
normalizationDesc: 'Iguala el volumen percibido entre pistas, álbumes y radio.',
|
||
normalizationOff: 'Apagado',
|
||
normalizationReplayGain: 'ReplayGain',
|
||
normalizationLufs: 'LUFS',
|
||
loudnessTargetLufs: 'LUFS objetivo',
|
||
loudnessTargetLufsDesc: 'Volumen de referencia al que se ajustan todas las pistas. Valor más bajo = salida más fuerte. Los servicios de streaming suelen rondar los -14 LUFS.',
|
||
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir',
|
||
loudnessPreAnalysisAttenuationDesc:
|
||
'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo. A la derecha se muestra el dB efectivo para el destino actual.',
|
||
loudnessPreAnalysisAttenuationRef: 'Efectivo {{eff}} dB con destino {{tgt}} LUFS.',
|
||
loudnessPreAnalysisAttenuationReset: 'Predeterminado',
|
||
loudnessFirstPlayNote:
|
||
'En la primera reproducción de un tema nuevo el volumen puede oscilar brevemente mientras se mide. La siguiente vez se usa la medición guardada y todo permanece estable. Las pistas próximas en la cola suelen analizarse durante la canción anterior, por lo que en la práctica esto rara vez ocurre.',
|
||
crossfade: 'Crossfade',
|
||
crossfadeDesc: 'Transición entre pistas',
|
||
crossfadeSecs: '{{n}} s',
|
||
notWithGapless: 'No disponible mientras Gapless está activo',
|
||
notWithCrossfade: 'No disponible mientras Crossfade está activo',
|
||
gapless: 'Reproducción Gapless',
|
||
gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones',
|
||
preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"',
|
||
preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.',
|
||
trackPreviewsTitle: 'Previsualizaciones de pistas',
|
||
trackPreviewsToggle: 'Activar previsualizaciones',
|
||
trackPreviewsDesc: 'Mostrar botones de Reproducir y Previsualización en las listas de pistas para una muestra rápida a mitad de canción.',
|
||
trackPreviewStart: 'Posición inicial',
|
||
trackPreviewStartDesc: 'Cuánto avanza la pista antes de iniciar la previsualización (% de la duración).',
|
||
trackPreviewDuration: 'Duración',
|
||
trackPreviewDurationDesc: 'Cuánto dura cada previsualización antes de detenerse.',
|
||
trackPreviewDurationSecs: '{{n}} s',
|
||
trackPreviewLocationsTitle: 'Dónde aparecen las previsualizaciones',
|
||
trackPreviewLocationsDesc: 'Elige en qué listas se muestran los botones de previsualización.',
|
||
trackPreviewLocation_suggestions: 'En sugerencias de listas',
|
||
trackPreviewLocation_albums: 'En listas de pistas de álbum',
|
||
trackPreviewLocation_playlists: 'En listas de reproducción',
|
||
trackPreviewLocation_favorites: 'En favoritos',
|
||
trackPreviewLocation_artist: 'En las mejores pistas del artista',
|
||
trackPreviewLocation_randomMix: 'En Random Mix',
|
||
preloadMode: 'Precargar Siguiente Pista',
|
||
preloadModeDesc: 'Cuándo comenzar a cargar la siguiente pista en cola',
|
||
nextTrackBufferingTitle: 'Buffer',
|
||
preloadHotCacheMutualExclusive: 'Precarga y Caché Activa sirven para lo mismo — solo uno puede estar activo a la vez. Habilitar uno desactivará automáticamente el otro.',
|
||
preloadOff: 'Apagado',
|
||
preloadBalanced: 'Balanceado (30 s antes del final)',
|
||
preloadEarly: 'Temprano (después de 5 s de reproducción)',
|
||
preloadCustom: 'Personalizado',
|
||
preloadCustomSeconds: 'Segundos antes del final: {{n}}',
|
||
infiniteQueue: 'Cola Infinita',
|
||
infiniteQueueDesc: 'Agregar automáticamente pistas aleatorias cuando la cola termina',
|
||
experimental: 'Experimental',
|
||
fsPlayerSection: 'Reproductor Pantalla Completa',
|
||
fsShowArtistPortrait: 'Mostrar foto del artista',
|
||
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
|
||
fsPortraitDim: 'Oscurecimiento de foto',
|
||
fsLyricsStyle: 'Estilo de letra',
|
||
fsLyricsStyleRail: 'Carril',
|
||
fsLyricsStyleRailDesc: 'Carril deslizante clásico de 5 líneas.',
|
||
fsLyricsStyleApple: 'Desplazamiento',
|
||
fsLyricsStyleAppleDesc: 'Lista desplazable a pantalla completa.',
|
||
sidebarLyricsStyle: 'Estilo de desplazamiento de letra',
|
||
sidebarLyricsStyleClassic: 'Clásico',
|
||
sidebarLyricsStyleClassicDesc: 'La línea activa se centra.',
|
||
sidebarLyricsStyleApple: 'Apple Music-like',
|
||
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
|
||
seekbarStyle: 'Estilo de Barra de Progreso',
|
||
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
|
||
seekbarTruewave: 'Forma de Onda Real',
|
||
seekbarPseudowave: 'Forma de Onda Pseudo',
|
||
seekbarLinedot: 'Línea y Punto',
|
||
seekbarBar: 'Barra',
|
||
seekbarThick: 'Barra Gruesa',
|
||
seekbarSegmented: 'Segmentada',
|
||
seekbarNeon: 'Brillo Neón',
|
||
seekbarPulsewave: 'Onda Pulsante',
|
||
seekbarParticletrail: 'Estela de Partículas',
|
||
seekbarLiquidfill: 'Llenado Líquido',
|
||
seekbarRetrotape: 'Cinta Retro',
|
||
themeSchedulerTitle: 'Cambio Automático de Tema',
|
||
themeSchedulerEnable: 'Habilitar Programador de Temas',
|
||
themeSchedulerEnableSub: 'Cambia automáticamente entre dos temas según la hora del día',
|
||
themeSchedulerDayTheme: 'Tema de Día',
|
||
themeSchedulerDayStart: 'Comienza de Día A las',
|
||
themeSchedulerNightTheme: 'Tema de Noche',
|
||
themeSchedulerNightStart: 'Comienza de Noche A las',
|
||
themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.',
|
||
visualOptionsTitle: 'Opciones Visuales',
|
||
coverArtBackground: 'Fondo de Portada',
|
||
coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción',
|
||
playlistCoverPhoto: 'Foto de Portada de Playlist',
|
||
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
|
||
showBitrate: 'Mostrar Bitrate',
|
||
showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas',
|
||
floatingPlayerBar: 'Barra del Reproductor Flotante',
|
||
floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido',
|
||
uiScaleTitle: 'Escala de Interfaz',
|
||
uiScaleLabel: 'Zoom',
|
||
confirmDeleteServerLibrary: '¿También quieres eliminar el índice local de biblioteca de este servidor? Pulsa Cancelar para conservar la copia en caché para uso sin conexión.',
|
||
waveformCacheClearBtn: 'Borrar caché de forma de onda',
|
||
waveformCacheCleared: 'Caché de forma de onda borrada ({{count}} filas).',
|
||
waveformCacheClearFailed: 'No se pudo borrar la caché de forma de onda.',
|
||
analyticsStrategyTitle: 'Estrategias de análisis',
|
||
analyticsStrategyDesc: 'Elige el estilo de análisis para la biblioteca.',
|
||
analyticsStrategyLabel: 'Estrategia',
|
||
analyticsStrategyLazy: 'Perezosa',
|
||
analyticsStrategyLazyDesc: 'Analiza solo cuando se reproduce una pista o entra en la caché caliente/sin conexión; uso mínimo de CPU y red.',
|
||
analyticsStrategyAdvanced: 'Agresiva',
|
||
analyticsStrategyAdvancedDesc: 'Escaneo en segundo plano más agresivo. Al terminar, obtienes BPM para todas las pistas y carga inmediata de loudness y forma de onda.',
|
||
analyticsStrategyPriorityTitle: 'Prioridad de análisis (siempre activa)',
|
||
analyticsStrategyPriorityHigh: '1 — Reproducción actual: descargar y analizar primero (bytes de reproducción, backfill de loudness o HTTP cuando haga falta).',
|
||
analyticsStrategyPriorityMiddle: '2 — Siguientes: ~5 pistas de cola, precarga de la siguiente y vecinas de caché caliente; analiza desde caché/descarga cuando sea posible, con backfill HTTP solo en fallo.',
|
||
analyticsStrategyPriorityLow: '3 — Lote de biblioteca (solo Agresiva): cola automática en segundo plano al final; se rellena por watermark (~3× workers) sin esperar a que quede vacía.',
|
||
analyticsStrategyServerLabel: 'Servidor',
|
||
analyticsStrategyProgressLabel: 'Progreso del análisis:',
|
||
analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})',
|
||
analyticsStrategyActionsLabel: 'Acciones',
|
||
analyticsStrategyParallelismLabel: 'Workers en paralelo',
|
||
analyticsStrategyParallelismValue: '{{n}} hilos',
|
||
analyticsStrategyParallelismDesc: 'Cuántas pistas descargar y analizar al mismo tiempo (HTTP + CPU). Valores más altos aceleran el progreso, pero consumen más red y CPU.',
|
||
analyticsStrategyAdvancedWarning: 'La estrategia agresiva usa más CPU y ancho de banda mientras se ejecuta el lote de biblioteca en segundo plano.',
|
||
analyticsStrategyServerRemoved: 'Servidor eliminado',
|
||
analyticsStrategyClearAction: 'Borrar análisis',
|
||
analyticsStrategyClearTitle: '¿Borrar análisis?',
|
||
analyticsStrategyClearDesc: 'Borrar el análisis de {{server}} elimina las formas de onda y loudness en caché. Si vuelves a añadir este servidor, puede requerir una reindexación larga.',
|
||
analyticsStrategyClearCancel: 'Cancelar',
|
||
analyticsStrategyClearConfirm: 'Borrar análisis',
|
||
analyticsStrategyClearSuccess: 'Análisis borrado para el servidor eliminado.',
|
||
analyticsStrategyClearError: 'No se pudo borrar el análisis del servidor eliminado.',
|
||
analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, bloqueado por pistas fallidas)',
|
||
analyticsFailedTracksOpenTitle: 'Pistas fallidas: {{count}}',
|
||
analyticsFailedTracksTitle: 'Pistas de análisis fallidas — {{server}}',
|
||
analyticsFailedTracksDesc: 'Estas pistas fallaron repetidamente en decode/enrichment y quedan excluidas del análisis automático en segundo plano.',
|
||
analyticsFailedTracksLoading: 'Cargando lista de pistas fallidas…',
|
||
analyticsFailedTracksEmpty: 'No hay pistas fallidas para este servidor.',
|
||
analyticsFailedTracksClose: 'Cerrar',
|
||
analyticsFailedTracksExport: 'Exportar lista',
|
||
analyticsFailedTracksExportSuccess: 'Lista de pistas fallidas exportada ({{count}}).',
|
||
analyticsFailedTracksExportError: 'No se pudo exportar la lista de pistas fallidas.',
|
||
analyticsFailedTracksRescan: 'Reanalizar pistas fallidas',
|
||
analyticsFailedTracksRescanSuccess: 'Reanálisis programado para {{count}} pistas fallidas.',
|
||
analyticsFailedTracksRescanError: 'No se pudo reanalizar las pistas fallidas.',
|
||
analyticsFailedTracksLoadError: 'No se pudo cargar las pistas fallidas.',
|
||
backupImportAny: 'Importar copia',
|
||
backupImportAnyConfirm: 'Importar el archivo de copia seleccionado y aplicar su contenido (ajustes, bases de datos de biblioteca o copia completa). ¿Continuar?',
|
||
backupModeFull: 'Completa',
|
||
backupModeLibrary: 'Bases de datos de biblioteca',
|
||
backupModeConfig: 'Configuración',
|
||
backupFullDesc: 'Archiva los ajustes y las bases de datos de la biblioteca en un único archivo de copia.',
|
||
backupFullExport: 'Exportar copia completa',
|
||
backupFullImport: 'Importar copia completa',
|
||
backupFullImportConfirm: 'Esto sobrescribirá los ajustes, reemplazará las bases de datos actuales y las moverá a .bak. ¿Continuar?',
|
||
backupFullExportSuccess: 'Copia completa exportada.',
|
||
backupFullImportSuccess: 'Copia completa importada — recargando…',
|
||
backupFullImportError: 'No se pudo importar la copia completa.',
|
||
backupLibraryExport: 'Exportar bases de datos',
|
||
backupLibraryExportDesc: 'Crea un archivo comprimido con instantáneas SQLite limpias de las bases de datos de biblioteca.',
|
||
backupLibraryImport: 'Importar bases de datos',
|
||
backupLibraryImportDesc: 'Restaura las bases de datos de biblioteca desde un archivo. Las bases actuales se mueven a .bak antes del cambio.',
|
||
backupLibraryImportConfirm: 'Esto reemplazará las bases de datos actuales y las moverá a .bak. ¿Continuar?',
|
||
backupLibraryExportSuccess: 'Copia de bases de datos exportada.',
|
||
backupLibraryImportSuccess: 'Copia de bases de datos importada.',
|
||
backupLibraryImportError: 'No se pudo importar la copia de bases de datos.',
|
||
backupOverlayExportTitle: 'Creando copia…',
|
||
backupOverlayImportTitle: 'Restaurando copia…',
|
||
backupOverlayHint: 'Puedes mantener la ventana abierta mientras procesamos archivos. La entrada está bloqueada temporalmente.',
|
||
};
|