Files
psysonic/src/locales/nb/settings.ts
T
Frank Stellmacher 9fa086c428 feat(server): dual server address (LAN + public) per profile (#880)
* 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.
2026-05-28 14:36:25 +03:00

613 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const settings = {
title: 'Innstillinger',
language: 'Språk',
languageEn: 'English',
languageDe: 'Deutsch',
languageEs: 'Español',
languageFr: 'Français',
languageNl: 'Nederlands',
languageNb: 'Norsk',
languageRu: 'Русский',
languageZh: '中文',
languageRo: 'Română',
font: 'Skrifttype',
fontHintOpenDyslexic: 'Dyslexivennlig · ingen kinesisk støtte',
theme: 'Tema',
appearance: 'Utseende',
servers: 'Tjenere',
serverName: 'Tjenernavn',
serverUrl: 'Tjener-URL',
serverUrlPlaceholder: '192.168.1.100:4533 eller https://musikk.eksempel.com',
serverAlternateUrl: 'Andre adresse (valgfritt)',
serverAlternateUrlPlaceholderPublic: 'https://music.example.com',
serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533',
serverAlternateUrlHintAddPublic: 'Du kan legge til en offentlig adresse for tilgang utenfor hjemmenettverket.',
serverAlternateUrlHintAddLocal: 'Du kan legge til en lokal adresse for raskere tilkobling hjemme.',
serverBothLanError: 'Begge adressene er lokale — behold én eller legg til en offentlig adresse.',
dualAddressVerifying: 'Sjekker at begge adressene peker til samme tjener…',
dualAddressMismatch: 'De to adressene peker til ulike tjenere. Sjekk skrivemåten og prøv igjen.',
dualAddressInsufficient: 'Kunne ikke bekrefte at begge adressene er samme tjener. Fjern én adresse for å fortsette.',
dualAddressUnreachable: 'Kunne ikke nå {{host}}. Sjekk adressen og nettverket, og prøv igjen.',
urlRemigrationTitle: 'Flytte biblioteksdataene?',
urlRemigrationMessage: 'Å endre adressen vil flytte alle biblioteks-, analyse- og omslags-buffer-data fra {{oldKey}} til {{newKey}}. Dette kan ikke angres.',
urlRemigrationConfirm: 'Flytt data',
urlRemigrationProgress: 'Flytter biblioteksdata…',
urlRemigrationFailureInspect: 'Kunne ikke sjekke hva som ville bli flyttet. Prøv igjen eller åpne feilsøkingsloggen.',
urlRemigrationFailureRun: 'Flyttingen av biblioteksdataene mislyktes. Lagrede data er uendret — prøv igjen.',
urlRemigrationFailureCoverRename: 'Biblioteksdataene ble flyttet, men omslagsfilene kunne ikke endre navn. Omslag bygges opp igjen ved neste tilgang.',
shareUsesLocalUrl: 'Bruk lokal adresse i delingslenker',
shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.',
serverUsername: 'Brukernavn',
serverPassword: 'Passord',
addServer: 'Legg til tjener',
addServerTitle: 'Legg til ny tjener',
editServer: 'Rediger',
editServerTitle: 'Rediger tjener',
useServer: 'Bruk',
deleteServer: 'Slett',
noServers: 'Ingen tjenere lagret.',
serverActive: 'Aktiv',
confirmDeleteServer: 'Slett tjener "{{name}}"?',
serverConnecting: 'Kobler til…',
serverConnected: 'Tilkoblet!',
serverFailed: 'Tilkobling mislyktes.',
testBtn: 'Test tilkobling',
testingBtn: 'Tester…',
serverCompatible: 'Laget for Navidrome. Andre Subsonic-kompatible servere (Gonic, Airsonic, …) kan fungere med begrenset funksjonalitet, fordi Psysonic bruker mange Navidrome-spesifikke API-endepunkter.',
userMgmtTitle: 'Brukeradministrasjon',
userMgmtDesc: 'Administrer brukere på denne serveren. Krever admin-rettigheter.',
userMgmtNoAdmin: 'Du trenger admin-rettigheter for å administrere brukere på denne serveren.',
userMgmtLoadError: 'Kunne ikke laste brukere.',
userMgmtLoadFriendly: 'Serveren svarte ikke — som regel en engangsfeil.',
userMgmtRetry: 'Prøv igjen',
userMgmtEmpty: 'Ingen brukere funnet.',
userMgmtYouBadge: 'Deg',
userMgmtAdminBadge: 'Admin',
userMgmtAddUser: 'Legg til bruker',
userMgmtAddUserTitle: 'Ny bruker',
userMgmtEditUserTitle: 'Rediger bruker',
userMgmtUsername: 'Brukernavn',
userMgmtName: 'Visningsnavn',
userMgmtEmail: 'E-post',
userMgmtPassword: 'Passord',
userMgmtPasswordEditHint: 'Skriv inn et nytt passord for å oppdatere det.',
userMgmtRoleAdmin: 'Admin',
userMgmtLibraries: 'Biblioteker',
userMgmtLibrariesAdminHint: 'Administratorbrukere har automatisk tilgang til alle biblioteker.',
userMgmtLibrariesEmpty: 'Ingen biblioteker tilgjengelig på denne serveren.',
userMgmtLibrariesValidation: 'Velg minst ett bibliotek.',
userMgmtLibrariesUpdateError: 'Brukeren ble lagret, men bibliotekstilordning mislyktes',
userMgmtNoLibraries: 'Ingen biblioteker tilordnet',
userMgmtNeverSeen: 'Aldri',
userMgmtSave: 'Lagre',
userMgmtCancel: 'Avbryt',
userMgmtDelete: 'Slett',
userMgmtEdit: 'Rediger',
userMgmtConfirmDelete: 'Slett brukeren «{{username}}»? Dette kan ikke angres.',
userMgmtCreateError: 'Kunne ikke opprette bruker.',
userMgmtUpdateError: 'Kunne ikke oppdatere bruker.',
userMgmtDeleteError: 'Kunne ikke slette bruker.',
userMgmtCreated: 'Bruker opprettet.',
userMgmtUpdated: 'Bruker oppdatert.',
userMgmtDeleted: 'Bruker slettet.',
userMgmtValidationMissing: 'Brukernavn, visningsnavn og passord er påkrevd.',
userMgmtValidationMissingIdentity: 'Brukernavn og visningsnavn er påkrevd.',
userMgmtMagicStringGenerate: 'Generer magic string',
userMgmtSaveAndMagicString: 'Lagre og hent magic string',
userMgmtMagicStringPasswordNavHint:
'Navidrome lagrer dette passordet for brukeren. Hvis det avviker fra det nåværende, oppdateres innloggingspassordet på serveren.',
userMgmtMagicStringPlaintextWarning:
'Del magic string med varsomhet: den inneholder passordet i klartekst (koding er ikke kryptering). Den som har hele strengen, kan logge inn som denne brukeren.',
userMgmtMagicStringCopied: 'Magic string er kopiert til utklippstavlen.',
userMgmtMagicStringCopyFailed: 'Klarte ikke å kopiere til utklippstavlen.',
userMgmtMagicStringLoginFailed: 'Passordsjekk mislyktes — påloggingsdetaljene kunne ikke verifiseres.',
userMgmtMagicStringModalTitle: 'Generer magic string',
userMgmtMagicStringModalDesc: 'Skriv inn Subsonic-passordet for «{{username}}». Det tas med i den kopierte magic string-en.',
userMgmtMagicStringModalConfirm: 'Kopier streng',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Slå på hvis denne serveren bruker <pluginLink>AudioMuse-AI Navidrome-plugin</pluginLink>. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
audiomuseIssueHint:
'Instant Mix feilet nylig — sjekk Navidrome-plugin og AudioMuse API. Lignende artister hentes fra Last.fm hvis serveren ikke returnerer noe.',
connected: 'Tilkoblet',
failed: 'Mislyktes',
eqTitle: 'Jevnstiller',
eqEnabled: 'Aktiver jevnstiller',
eqPreset: 'Forhåndsinnstilling',
eqPresetCustom: 'Egendefinert',
eqPresetBuiltin: 'Innebygde forhåndsinnstillinger',
eqPresetCustomGroup: 'Mine forhåndsinnstillinger',
eqSavePreset: 'Lagre som forhåndsinnstilling',
eqPresetName: 'Navn på forhåndsinnstilling…',
eqDeletePreset: 'Slett forhåndsinnstilling',
eqResetBands: 'Tilbakestill til standard',
eqPreGain: 'Forforsterkning',
eqResetPreGain: 'Tilbakestill forforsterkning',
eqAutoEqTitle: 'AutoEQ hodetelefonoppslag',
eqAutoEqPlaceholder: 'Søk etter hodetelefon- / IEM-modell…',
eqAutoEqSearching: 'Søker…',
eqAutoEqNoResults: 'Ingen resultater funnet',
eqAutoEqError: 'Søk mislyktes',
eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt',
eqAutoEqFetchError: 'Kunne ikke hente EQ-profil',
lfmTitle: 'Last.fm',
lfmConnect: 'Koble til med Last.fm',
lfmConnecting: 'Venter på autorisasjon…',
lfmConfirm: 'Jeg har autorisert appen',
lfmConnected: 'Tilkoblet som',
lfmDisconnect: 'Koble fra',
lfmConnectDesc: 'Koble til din Last.fm-konto for å aktivere scrobbling og få "Nå spiller"-oppdateringer direkte fra Psysonic - ingen Navidrome-konfigurasjon kreves.',
lfmOpenBrowser: 'Et nettleservindu vil åpne seg. Autoriser Psysonic via Last.fm, og klikk deretter på knappen nedenfor.',
lfmScrobbles: '{{n}} scrobbles',
lfmMemberSince: 'Medlem siden {{year}}',
scrobbleEnabled: 'Scrobbling er aktivert',
scrobbleDesc: 'Send sanger til Last.fm etter 50 % avspilling',
behavior: 'App-oppførsel',
cacheTitle: 'Maks. lagringsstørrelse',
cacheDesc: 'Plateomslag og artistbilder. Når den er full, fjernes de eldste oppføringene automatisk. Frakoblede album fjernes ikke automatisk, men slettes når hurtigbufferen tømmes manuelt.',
cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor',
cacheUsedImages: 'Bilder:',
cacheUsedOffline: 'Frakoblede spor:',
cacheUsedHot: 'Plass brukt:',
hotCacheTrackCount: 'Spor i buffer:',
cacheMaxLabel: 'Maks størrelse',
cacheClearBtn: 'Tøm hurtigbuffer',
cacheClearWarning: 'Dette vil også fjerne alle frakoblede album fra biblioteket.',
cacheClearConfirm: 'Tøm alt',
cacheClearCancel: 'Avbryt',
offlineDirTitle: 'Frakoblet bibliotek (In-App)',
offlineDirDesc: 'Lagringsplassering for spor som du gjør tilgjengelige som frakoblet i Psysonic.',
offlineDirDefault: 'Standard (App-data)',
offlineDirChange: 'Endre katalog',
offlineDirClear: 'Tilbakestill til standard',
offlineDirHint: 'Nye nedlastinger vil bruke denne plasseringen. Eksisterende nedlastinger forblir på sin opprinnelige lokasjon.',
hotCacheTitle: 'Varm avspillingsbuffer',
hotCacheDisclaimer: 'Forhåndshenter neste spor i køen og beholder tidligere. Når aktivert: diskplass og nettverk.',
hotCacheDirDefault: 'Standard (App-data)',
hotCacheDirChange: 'Endre mappe',
hotCacheDirClear: 'Tilbakestill til standard',
hotCacheDirHint: 'Bytte mappe nullstiller indeksen i appen; gamle filer blir liggende til du sletter dem.',
hotCacheEnabled: 'Aktiver varm avspillingsbuffer',
hotCacheMaxMb: 'Maks bufferstørrelse',
hotCacheDebounce: 'Utsettelse ved køendring',
hotCacheDebounceImmediate: 'Umiddelbart',
hotCacheDebounceSeconds: '{{n}} sek',
hotCacheClearBtn: 'Tøm varm buffer',
audioOutputDevice: 'Lydutgangsenhet',
audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.',
audioOutputDeviceDefault: 'Systemstandard',
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
audioOutputDeviceMacNotice: 'På macOS følger avspillingen av tekniske årsaker alltid systemets lydutgang. Endre målet via Systeminnstillinger → Lyd eller høyttalerikonet i menylinjen. Bakgrunn: CoreAudio utløser en mikrofontillatelsesdialog når en ikke-standard strøm åpnes — vi unngår det ved alltid å bruke systemets standardutgang.',
hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.",
showArtistImages: 'Vis artistbilder',
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
showOrbitTrigger: 'Vis «Orbit» i toppen',
showOrbitTriggerDesc: 'Knappen i toppen for å starte eller bli med i en delt lytteøkt. Skjul den hvis du ikke bruker Orbit — du kan slå den på igjen her.',
minimizeToTray: 'Minimer til oppgavelinjen',
minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.',
clockFormat: 'Klokkeformat',
clockFormatDesc: 'Klokkevisning brukt av kø-ETA og forhåndsvisningen av hviletimeren.',
clockFormatAuto: 'Automatisk (systemspråk)',
clockFormatTwentyFour: '24-timers',
clockFormatTwelve: '12-timers (AM/PM)',
preloadMiniPlayer: 'Forhåndslast miniavspiller',
preloadMiniPlayerDesc: 'Bygger miniavspiller-vinduet i bakgrunnen ved appstart slik at det viser innhold umiddelbart ved første åpning. Bruker litt mer minne.',
linuxWebkitSmoothScroll: 'Mykt musehjul (Linux)',
linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.',
linuxWaylandTextRender: 'Wayland-tekstgjengivelse (Linux)',
linuxWaylandTextRenderDesc:
'Utjevning i grensesnittet skjer med én gang. WebKit-maskinvareakselerasjon (skarp/GPU) lagres og brukes ved neste appstart — live bytte kan fryse WebKitGTK.',
linuxWaylandTextRenderBalanced: 'Balansert',
linuxWaylandTextRenderSharp: 'Skarpt (CPU-vennlig)',
linuxWaylandTextRenderGpu: 'GPU først',
linuxWaylandTextRenderMinimal: 'Minimum (standard CSS-utjevning)',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.',
discordCoverSource: 'Coverkilde',
discordCoverSourceDesc: 'Hvor albumcoveret for Discord-profilen din hentes fra.',
discordCoverNone: 'Ingen (kun app-ikon)',
discordCoverServer: 'Server (via albuminfo)',
discordCoverApple: 'Apple Music',
discordOptions: 'Avanserte Discord-alternativer',
discordTemplates: 'Egendefinerte tekstmaler',
discordTemplatesDesc: 'Tilpass hvilken informasjon som vises på Discord-profilen din. Variabler: {title}, {artist}, {album}',
discordTemplateDetails: 'Primær linje (details)',
discordTemplateState: 'Sekundær linje (state)',
discordTemplateLargeText: 'Album-verktøytips (largeText)',
nowPlayingEnabled: 'Vis i "Nå spiller"',
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
enableBandsintown: 'Bandsintown-turnédatoer',
enableBandsintownDesc: 'Vis kommende konserter for gjeldende artist i Info-fanen. Data hentes fra det offentlige Bandsintown-API-et.',
lyricsServerFirst: 'Foretrekk server-sangtekst',
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.',
lyricsSourcesTitle: 'Sangtekstkilder',
lyricsSourcesDesc: 'Velg hvilke kilder som skal brukes og i hvilken rekkefølge. Dra for å sortere. Deaktiverte kilder hoppes over.',
lyricsSourceServer: 'Tjener',
lyricsSourceLrclib: 'LRCLIB',
lyricsSourceNetease: 'Netease Cloud Music',
lyricsYouLyPlus: 'YouLyPlus (Karaoke)',
lyricsYouLyPlusDesc: 'Ord-for-ord-synkronisering fra en fellesskapsbackend (Apple Music, Spotify, Musixmatch, QQ). Prøves først; de aktiverte kildene under fungerer som reserve.',
lyricsSourcesFallbackHint: 'Brukes som reserve når YouLyPlus ikke finner noe. Dra for å endre rekkefølge.',
lyricsSourcesPrimaryHint: 'Prøves i rekkefølge. Dra for å endre rekkefølge. Alt av = tekster deaktivert.',
lyricsStaticOnly: 'Vis sangtekst som statisk tekst',
lyricsStaticOnlyDesc: 'Viser synkroniserte tekster uten auto-scroll og uten ord-utheving.',
downloadsTitle: 'ZIP Eksport & Arkivering',
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
downloadsDefault: 'Standard nedlastingsmappe',
pickFolder: 'Velg',
pickFolderTitle: 'Velg nedlastingsmappe',
clearFolder: 'Tøm nedlastingsmappe',
logout: 'Logg ut',
aboutTitle: 'Om Psysonic',
aboutDesc: 'En moderne musikkspiller laget for Navidrome. Bruker Subsonic-API-en pluss Navidrome-spesifikke utvidelser. Bygget på Tauri v2 med en innebygd Rust-lydmotor — lett og rask, men fullpakket med funksjoner: bølgeform-søkelinje, synkroniserte sangtekster, Last.fm-integrasjon, 10-bånds jevnstiller, crossfade, gapless-avspilling, Replay Gain, sjangerlesing og et stort bibliotek med temaer.',
aboutLicense: 'Lisens',
aboutLicenseText: 'GNU GPL v3 - gratis å bruke, endre og distribuere under samme lisens.',
aboutRepo: 'Kildekode på GitHub',
aboutVersion: 'Versjon',
aboutBuiltWith: 'Bygget med Tauri · React · TypeScript · Rust/rodio',
aboutReleaseNotesLabel: 'Versjonsnotater',
aboutReleaseNotesLink: 'Åpne nyhetene for denne versjonen',
aboutContributorsLabel: 'Bidragsytere',
showChangelogOnUpdate: "Vis 'Hva er nytt' ved oppdatering til ny versjon",
showChangelogOnUpdateDesc: "Viser et diskret changelog-banner over Now Playing etter en oppdatering. Klikk åpner versjonsnotatene; X skjuler det.",
libraryGridMaxColumnsTitle: 'Kortrutenett i biblioteket',
libraryGridMaxColumnsPerfHint: 'Flere kolonner gir flere fliser per rad og kan øke layout- og tegnearbeid — merkbart på veldig store biblioteker eller tregere maskiner.',
libraryGridMaxColumnsRangeLabel: 'Maksimalt antall kolonner ({{min}}{{max}})',
libraryGridMaxColumnsDesc: 'Gjelder album-, artist-, spilleliste-, radio-, offline- og andre kortbaserte bibliotekvisninger. Færre kolonner = større fliser og vanligvis mindre CPU-belastning.',
libraryIndexTitle: 'Lokalt biblioteksindeks (forhåndsvisning)',
libraryIndexDesc: 'Holder en lokal kopi av hver servers spordatabase slik at blaing og søk forblir raskt og fungerer offline. Første synkronisering kjører for alle konfigurerte servere; offline servere prøves automatisk på nytt.',
libraryIndexDeltaHint: 'Delta-synkronisering i bakgrunnen sjekker tilknyttede servere hvert 30. sekund og kjører når det er på tide — vanligvis hvert 5.45. minutt avhengig av biblioteksstørrelse og belastning.',
libraryIndexEnable: 'Aktiver lokalt biblioteksindeks',
libraryIndexEnableAllDesc: 'Indekser og synkroniser alle konfigurerte servere i bakgrunnen.',
libraryIndexNoServer: 'Legg til en server først.',
libraryIndexServerListTitle: 'Indekserte servere',
libraryIndexAllExcluded: 'Alle servere er ekskludert fra synkronisering. Aktiver indeksen igjen eller legg til en server.',
libraryIndexServerOffline: 'Server offline — synkronisering utsatt',
libraryIndexServerDeferred: 'Utsatt',
libraryIndexServerSyncing: 'Synkroniserer…',
libraryIndexFullResync: 'Full resynkronisering',
libraryIndexDeltaSync: 'Delta-synkronisering',
libraryIndexExcludeServer: 'Ekskluder fra synkronisering',
libraryIndexExcludingServer: 'Ekskluderer…',
libraryIndexExcludedTitle: 'Ekskludert fra synkronisering',
libraryIndexIncludeServer: 'Inkluder igjen',
libraryIndexIncludingServer: 'Inkluderer…',
libraryIndexStatus: 'Status',
libraryIndexStatusIdle: 'Inaktiv',
libraryIndexStatusProbing: 'Sjekker server…',
libraryIndexStatusInitial: 'Første synkronisering…',
libraryIndexStatusReady: 'Klar ({{count}} spor)',
libraryIndexStatusError: 'Feil — se logger',
libraryIndexProgressIngest: '{{count}} spor indeksert…',
libraryIndexProgressVerify: '{{checked}} verifisert ({{deleted}} fjernet)',
libraryIndexSyncNow: 'Synkroniser nå',
libraryIndexVerify: 'Verifiser bibliotekets integritet',
libraryIndexCancel: 'Avbryt',
libraryIndexAutoReconcile: 'Auto-avstemming ved fall i antall',
libraryIndexAutoReconcileDesc: 'Sjekker automatisk etter fjernede spor når serveren rapporterer færre enn forventet.',
libraryIndexSyncError: 'Biblioteksynkronisering mislyktes: {{error}}',
libraryIndexBindError: 'Kunne ikke aktivere indeks: {{error}}',
randomMixTitle: 'Svarteliste for tilfeldig miks',
luckyMixMenuTitle: 'Vis Lykkemiks i menyen',
luckyMixMenuDesc: 'Aktiverer Lykkemiks i "Lag en miks" og som eget menypunkt når delt navigasjon er aktiv. Vises bare når AudioMuse er aktiv på gjeldende server.',
randomMixBlacklistTitle: 'Egendefinerte filternøkkelord',
randomMixBlacklistDesc: 'Sanger ekskluderes når et nøkkelord samsvarer med sjanger, tittel, album eller artist (aktiv når avkrysningsboksen ovenfor er på).',
randomMixBlacklistPlaceholder: 'Legg til nøkkelord…',
randomMixBlacklistAdd: 'Legg til',
randomMixBlacklistEmpty: 'Ingen egendefinerte nøkkelord lagt til ennå.',
randomMixHardcodedTitle: 'Innebygde nøkkelord (aktiv når avkrysningsboksen er på)',
tabPlayback: 'Avspilling',
tabLibrary: 'Bibliotek',
tabServers: 'Servere',
tabLyrics: 'Sangtekster',
tabPersonalisation: 'Personalisering',
tabIntegrations: 'Integrasjoner',
tabAppearance: 'Utseende',
tabStorage: 'Frakoblet & Cache',
inputKeybindingsTitle: 'Tastatursnarveier',
aboutContributorsCount_one: '{{count}} bidrag',
aboutContributorsCount_other: '{{count}} bidrag',
searchPlaceholder: 'Søk i innstillinger…',
searchNoResults: 'Ingen innstillinger samsvarer med søket ditt.',
aboutMaintainersLabel: 'Ansvarlige',
integrationsPrivacyTitle: 'Personvern-merknad',
integrationsPrivacyBody: 'Alle integrasjoner på denne fanen er <strong>frivillige</strong> og sender, når aktivert, data til eksterne tjenester eller til Navidrome-serveren din. Last.fm mottar lyttehistorikken din, Discord viser gjeldende spor i profilen din, Bandsintown spørres per artist for turnédatoer, og "Spilles nå"-delingen publiserer gjeldende spor til andre brukere av Navidrome-serveren din. Hvis du ikke ønsker noe av dette, la den aktuelle seksjonen stå deaktivert.',
homeCustomizerTitle: 'Hjemmeside',
queueToolbarTitle: 'Kø-verktøylinje',
queueToolbarReset: 'Tilbakestill til standard',
queueToolbarSeparator: 'Skilje',
sidebarTitle: 'Sidefelt',
sidebarReset: 'Tilbakestill til standard',
artistLayoutTitle: 'Artistsidens seksjoner',
artistLayoutDesc: 'Dra for å omorganisere, veksle for å skjule individuelle seksjoner av artistsiden. Seksjoner uten data hoppes over automatisk.',
artistLayoutReset: 'Tilbakestill til standard',
artistLayoutBio: 'Artistbiografi',
artistLayoutTopTracks: 'Toppspor',
artistLayoutSimilar: 'Lignende artister',
artistLayoutAlbums: 'Album',
artistLayoutFeatured: 'Også med på',
playlistLayoutTitle: 'Sideoppsett for spilleliste',
playlistLayoutDesc: 'Vis eller skjul individuelle elementer på spillelistesiden.',
playlistLayoutReset: 'Tilbakestill til standard',
playerBarTitle: 'Spillerlinje',
playerBarReset: 'Tilbakestill til standard',
playerBarStarRating: 'Stjernevurdering',
playerBarFavorite: 'Favoritt (hjerte)',
playerBarLastfmLove: 'Last.fm love',
playerBarPlaybackRate: 'Avspillingshastighet',
playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Miniavspiller',
playbackRateTitle: 'Avspillingshastighet',
playbackRateEnabled: 'Aktiver avspillingshastighet',
playbackRateEnabledDesc: 'Global hastighet for alle spor. Gjelder ikke radio og forhåndsvisninger.',
playbackRateStrategy: 'Strategi',
playbackRateStrategySpeed: 'Hastighet',
playbackRateStrategyVarispeed: 'Med tone',
playbackRateStrategyPreserve: 'Tonehøyde',
playbackRateSpeed: 'Hastighet',
playbackRatePitch: 'Tonehøyde',
playbackRateDerivedPitch: 'Tonehøydeforskyvning fra hastighet: {{value}}',
playbackRateAutoPitch: 'Tonehøyden korrigeres automatisk.',
playbackRateHint: '« Hastighet » beholder naturlig tonehøyde. « Med tone » endrer tonehøyden med hastigheten. « Tonehøyde » legger til manuell forskyvning. Mer CPU enn « Med tone ». Ikke for radio, forhåndsvisninger eller Orbit.',
playbackRateNeutral: 'Ved 1,0× og uten toneforskyvning er avspilling normal.',
playbackRateOrbitPaused: 'Ikke aktiv under Orbit-økter — avspilling forblir 1,0× for synk.',
playbackRateOrbitPausedShort: 'Orbit: 1,0× for synk.',
advancedMode: 'Avansert',
advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.',
advancedBadge: 'Avansert',
sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon',
randomNavSplitDesc: 'Vis "Tilfeldig miks", "Tilfeldige album" og "Lykkemiks" som separate sidefeltsoppføringer i stedet for "Lag en miks"-huben.',
tabShortcuts: 'Snarveier',
tabUsers: 'Brukere',
tabSystem: 'System',
loggingTitle: 'Loggføring',
loggingModeDesc: 'Styrer hvor detaljert backend-loggene i terminalen er.',
loggingModeOff: 'Av',
loggingModeNormal: 'Normal',
loggingModeDebug: 'Debug',
loggingExport: 'Eksporter logger',
loggingExportSuccess: 'Logger eksportert ({{count}} linjer).',
loggingExportError: 'Kunne ikke eksportere logger.',
ratingsSectionTitle: 'Vurderinger',
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
ratingsSkipStarDesc:
'Etter flere hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
ratingsSkipStarThresholdLabel: 'Hopp',
ratingsMixFilterTitle: 'Filtrering etter vurdering',
ratingsMixFilterDesc:
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
ratingsMixMinSong: 'Spor',
ratingsMixMinAlbum: 'Album',
ratingsMixMinArtist: 'Artister',
ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
backupTitle: 'Sikkerhetskopiering og gjenoppretting',
backupExport: 'Eksporter innstillinger',
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst hold filen sikker.',
backupImport: 'Importer innstillinger',
backupImportDesc: 'Gjenoppretter innstillinger fra en .psybkp-fil. Applikasjonen starter på nytt etter import.',
backupImportConfirm: 'Dette vil overskrive alle gjeldende innstillinger. Vil du fortsette?',
backupSuccess: 'Sikkerhetskopiering lagret',
backupImportSuccess: 'Innstillinger gjenopprettet laster inn data på nytt…',
backupImportError: 'Ugyldig eller ødelagt fil for sikkerhetskopi.',
shortcutsReset: 'Tilbakestill til standardinnstillinger',
shortcutListening: 'Trykk på en tast…',
shortcutUnbound: '-',
globalShortcutsTitle: 'Globale snarveier',
globalShortcutsNote: 'Arbeid systemomfattende selv når Psysonic er i bakgrunnen. Krever Ctrl, Alt eller Super som modifikator.',
shortcutClear: 'Fjern',
shortcutPlayPause: 'Spill av / Pause',
shortcutNext: 'Neste spor',
shortcutPrev: 'Forrige spor',
shortcutVolumeUp: 'Volum opp',
shortcutVolumeDown: 'Volum ned',
shortcutSeekForward: 'Søk fremover 10 sekunder',
shortcutSeekBackward: 'Søk bakover 10 sekunder',
shortcutToggleQueue: 'Veksle kø',
shortcutOpenFolderBrowser: 'Åpne {{folderBrowser}}',
shortcutFullscreenPlayer: 'Fullskjermsspiller',
shortcutNativeFullscreen: 'Naturlig fullskjerm',
shortcutOpenMiniPlayer: 'Åpne minispiller',
shortcutStartSearch: 'Start et søk',
shortcutStartAdvancedSearch: 'Start et avansert søk',
shortcutToggleSidebar: 'Veksle sidefelt',
shortcutMuteSound: 'Demp lyd',
shortcutToggleEqualizer: 'Åpne / veksle Equalizer',
shortcutToggleRepeat: 'Veksle gjenta',
shortcutOpenNowPlaying: 'Åpne "Spilles nå"',
shortcutShowLyrics: 'Vis sangtekst',
shortcutFavoriteCurrentTrack: 'Legg gjeldende spor til favoritter',
shortcutOpenHelp: 'Hjelp',
playbackTitle: 'Avspilling',
replayGain: 'Replay Gain',
replayGainDesc: 'Normaliser sporvolumet ved hjelp av ReplayGain-metadata',
replayGainMode: 'Modus',
replayGainAuto: 'Auto',
replayGainAutoDesc: 'Bruker albumforsterkning når nabospor i køen er fra samme album, ellers sporforsterkning.',
replayGainTrack: 'Spor',
replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (taggede filer)',
replayGainPreGainDesc: 'Universelt løft som legges på hver ReplayGain-beregning. Nyttig hvis biblioteket ditt totalt sett virker for stille.',
replayGainFallback: 'Reserveverdi (uten tagger / radio)',
replayGainFallbackDesc: 'Brukes for spor (og radiostrømmer) uten ReplayGain-tagger, slik at utagget innhold ikke spretter høyere enn resten.',
normalization: 'Normalisering',
normalizationDesc: 'Jevn ut opplevd loudness mellom spor, album og radio.',
normalizationOff: 'Av',
normalizationReplayGain: 'ReplayGain',
normalizationLufs: 'LUFS',
loudnessTargetLufs: 'Mål-LUFS',
loudnessTargetLufsDesc: 'Referanse-loudness alle spor matches mot. Lavere tall = høyere utgang. Strømmetjenester ligger vanligvis rundt -14 LUFS.',
loudnessPreAnalysisAttenuation: 'Demping før måling',
loudnessPreAnalysisAttenuationDesc:
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere. Til høyre vises effektiv dB for valgt mål.',
loudnessPreAnalysisAttenuationRef: 'Effektivt {{eff}} dB med mål {{tgt}} LUFS.',
loudnessPreAnalysisAttenuationReset: 'Standard',
loudnessFirstPlayNote:
'Første avspilling av et helt nytt spor kan drive litt i volum mens målingen skjer. Neste gang brukes den lagrede målingen, og alt blir stabilt. Kommende spor i køen blir vanligvis pre-analysert mens det forrige spilles, så dette skjer sjelden i praksis.',
crossfade: 'Crossfade',
crossfadeDesc: 'Tone mellom spor',
crossfadeSecs: '{{n}}s',
notWithGapless: 'Ikke tilgjengelig mens Gapless er aktiv',
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
gapless: 'Gapless avspilling',
gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger',
preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge',
preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.',
trackPreviewsTitle: 'Sporforhåndsvisning',
trackPreviewsToggle: 'Aktiver sporforhåndsvisning',
trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.',
trackPreviewStart: 'Startposisjon',
trackPreviewStartDesc: 'Hvor langt inn i sporet forhåndsvisningen starter (% av lengden).',
trackPreviewDuration: 'Varighet',
trackPreviewDurationDesc: 'Hvor lenge hver forhåndsvisning spiller før den stopper.',
trackPreviewDurationSecs: '{{n}} s',
trackPreviewLocationsTitle: 'Hvor forhåndsvisninger vises',
trackPreviewLocationsDesc: 'Velg hvilke lister som viser forhåndsvisningsknapper.',
trackPreviewLocation_suggestions: 'I spillelisteforslag',
trackPreviewLocation_albums: 'I albumsporlister',
trackPreviewLocation_playlists: 'I spillelister',
trackPreviewLocation_favorites: 'I favoritter',
trackPreviewLocation_artist: 'I artistens toppspor',
trackPreviewLocation_randomMix: 'I Random Mix',
infiniteQueue: 'Uendelig kø',
infiniteQueueDesc: 'Legg automatisk til tilfeldige spor når køen går tom',
experimental: 'Eksperimentell',
preloadMode: 'Forhåndslast neste spor',
preloadModeDesc: 'Når buffering av neste spor i køen skal starte',
nextTrackBufferingTitle: 'Bufring',
preloadHotCacheMutualExclusive: 'Forhåndslasting og Hot Cache tjener samme formål bare én kan være aktiv om gangen. Å aktivere den ene deaktiverer automatisk den andre.',
preloadOff: 'Av',
preloadBalanced: 'Balansert (30 s før slutt)',
preloadEarly: 'Tidlig (etter 5 s avspilling)',
preloadCustom: 'Egendefinert',
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
fsPlayerSection: 'Fullskjermspiller',
fsShowArtistPortrait: 'Vis artistbilde',
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
fsPortraitDim: 'Mørklegging av bilde',
fsLyricsStyle: 'Tekststil',
fsLyricsStyleRail: 'Skinner',
fsLyricsStyleRailDesc: 'Klassisk 5-linjes glidende skinner.',
fsLyricsStyleApple: 'Rulling',
fsLyricsStyleAppleDesc: 'Fullskjerm rulleliste.',
sidebarLyricsStyle: 'Rullestil for tekst',
sidebarLyricsStyleClassic: 'Klassisk',
sidebarLyricsStyleClassicDesc: 'Aktiv linje sentreres.',
sidebarLyricsStyleApple: 'Apple Music-like',
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
seekbarTruewave: 'Ekte bølgeform',
seekbarPseudowave: 'Pseudo-bølgeform',
seekbarLinedot: 'Linje & punkt',
seekbarBar: 'Linje',
seekbarThick: 'Tykk linje',
seekbarSegmented: 'Segmentert',
seekbarNeon: 'Neon',
seekbarPulsewave: 'Pulsbølge',
seekbarParticletrail: 'Partikkelspor',
seekbarLiquidfill: 'Væskerør',
seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Tidsplanlagt tema',
themeSchedulerEnable: 'Aktiver temaplanlegger',
themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt',
themeSchedulerDayTheme: 'Dagtema',
themeSchedulerDayStart: 'Dag starter kl.',
themeSchedulerNightTheme: 'Natttema',
themeSchedulerNightStart: 'Natt starter kl.',
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
visualOptionsTitle: 'Visuelle Alternativer',
coverArtBackground: 'Cover-bakgrunn',
coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter',
playlistCoverPhoto: 'Playlist-coverfoto',
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
showBitrate: 'Vis Bitrate',
showBitrateSub: 'Vis audio-bitrate i sporlister',
floatingPlayerBar: 'Flytende Spillerlinje',
floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet',
uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom',
confirmDeleteServerLibrary: 'Vil du også slette denne serverens lokale bibliotekindeks? Klikk Avbryt for å beholde den bufrede kopien for offline bruk.',
waveformCacheClearBtn: 'Tøm waveform-cache',
waveformCacheCleared: 'Waveform-cache tømt ({{count}} rader).',
waveformCacheClearFailed: 'Kunne ikke tømme waveform-cache.',
showTrayIcon: 'Vis systemstatusikon',
showTrayIconDesc: 'Vis Psysonic-ikonet i systemstatusfeltet / menylinjen.',
useCustomTitlebar: 'Tilpasset tittellinje',
useCustomTitlebarDesc: 'Erstatt systemets tittellinje med en innebygd som matcher app-temaet. Slå av for å bruke den native GNOME/GTK-tittellinjen.',
analyticsStrategyTitle: 'Analysestrategier',
analyticsStrategyDesc: 'Velg analysestil for biblioteket.',
analyticsStrategyLabel: 'Strategi',
analyticsStrategyLazy: 'Lat',
analyticsStrategyLazyDesc: 'Analyserer bare når spor spiller eller går inn i hot/offline-cache — minimalt CPU- og nettverksbruk.',
analyticsStrategyAdvanced: 'Aggressiv',
analyticsStrategyAdvancedDesc: 'Mer aggressiv bakgrunnsskanning. Når den er ferdig får du BPM for alle spor samt umiddelbar lasting av loudness og waveform.',
analyticsStrategyPriorityTitle: 'Analyseprioritet (alltid aktiv)',
analyticsStrategyPriorityHigh: '1 — Spiller nå: last ned og analyser først (avspillingsbytes, loudness-backfill eller HTTP ved behov).',
analyticsStrategyPriorityMiddle: '2 — Neste opp: de neste ~5 køsporene, preload av neste og hot-cache-naboer — analyser fra cache/nedlastede bytes når mulig; HTTP-backfill kun ved bom.',
analyticsStrategyPriorityLow: '3 — Bibliotekbatch (kun Aggressiv): automatisk bakgrunnskø bakerst; fylles på ved watermark (~3× workers) uten å vente på tom kø.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Analysefremdrift:',
analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})',
analyticsStrategyActionsLabel: 'Handlinger',
analyticsStrategyParallelismLabel: 'Parallelle workers',
analyticsStrategyParallelismValue: '{{n}} tråder',
analyticsStrategyParallelismDesc: 'Hvor mange spor som lastes ned og analyseres samtidig (HTTP + CPU). Høyere verdi gjør bibliotekinnhentingen raskere, men bruker mer nettverk og CPU.',
analyticsStrategyAdvancedWarning: 'Aggressiv bruker mer CPU og nettverksbåndbredde mens bibliotekbatchen kjører i bakgrunnen.',
analyticsStrategyServerRemoved: 'Fjernet server',
analyticsStrategyClearAction: 'Tøm analyse',
analyticsStrategyClearTitle: 'Tøm analyse?',
analyticsStrategyClearDesc: 'Å tømme analyse for {{server}} fjerner bufrede waveforms og loudness. Å legge til serveren igjen senere kan kreve lang reindeksering.',
analyticsStrategyClearCancel: 'Avbryt',
analyticsStrategyClearConfirm: 'Tøm analyse',
analyticsStrategyClearSuccess: 'Analyse tømt for den fjernede serveren.',
analyticsStrategyClearError: 'Kunne ikke tømme analyse for den fjernede serveren.',
analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, blokkert av feilede spor)',
analyticsFailedTracksOpenTitle: 'Feilede spor: {{count}}',
analyticsFailedTracksTitle: 'Feilede analysespor — {{server}}',
analyticsFailedTracksDesc: 'Disse sporene feilet gjentatte ganger i decode/enrichment og er ekskludert fra automatisk bakgrunnsanalyse.',
analyticsFailedTracksLoading: 'Laster liste over feilede spor…',
analyticsFailedTracksEmpty: 'Ingen feilede spor for denne serveren.',
analyticsFailedTracksClose: 'Lukk',
analyticsFailedTracksExport: 'Eksporter liste',
analyticsFailedTracksExportSuccess: 'Liste over feilede spor eksportert ({{count}}).',
analyticsFailedTracksExportError: 'Kunne ikke eksportere liste over feilede spor.',
analyticsFailedTracksRescan: 'Skann feilede spor på nytt',
analyticsFailedTracksRescanSuccess: 'Ny skanning planlagt for {{count}} feilede spor.',
analyticsFailedTracksRescanError: 'Kunne ikke skanne feilede spor på nytt.',
analyticsFailedTracksLoadError: 'Kunne ikke laste feilede spor.',
tabAudio: 'Lyd',
tabInput: 'Inndata',
backupImportAny: 'Importer sikkerhetskopi',
backupImportAnyConfirm: 'Importer valgt sikkerhetskopifil og bruk innholdet (innstillinger, bibliotekdatabaser eller full). Fortsette?',
backupModeFull: 'Full',
backupModeLibrary: 'Bibliotekdatabaser',
backupModeConfig: 'Konfig',
backupFullDesc: 'Arkiverer innstillinger og bibliotekdatabaser i én sikkerhetskopifil.',
backupFullExport: 'Eksporter full sikkerhetskopi',
backupFullImport: 'Importer full sikkerhetskopi',
backupFullImportConfirm: 'Dette vil overskrive innstillinger, erstatte nåværende bibliotekdatabaser og flytte dem til .bak. Fortsette?',
backupFullExportSuccess: 'Full sikkerhetskopi eksportert.',
backupFullImportSuccess: 'Full sikkerhetskopi importert — laster inn på nytt…',
backupFullImportError: 'Kunne ikke importere full sikkerhetskopi.',
backupLibraryExport: 'Eksporter databaser',
backupLibraryExportDesc: 'Lager et komprimert arkiv med rene SQLite-øyeblikksbilder av bibliotekdatabaser.',
backupLibraryImport: 'Importer databaser',
backupLibraryImportDesc: 'Gjenoppretter bibliotekdatabaser fra en arkivfil. Nåværende databaser flyttes til .bak før bytte.',
backupLibraryImportConfirm: 'Dette vil erstatte nåværende bibliotekdatabaser og flytte dem til .bak. Fortsette?',
backupLibraryExportSuccess: 'Databasesikkerhetskopi eksportert.',
backupLibraryImportSuccess: 'Databasesikkerhetskopi importert.',
backupLibraryImportError: 'Kunne ikke importere databasesikkerhetskopi.',
backupOverlayExportTitle: 'Oppretter sikkerhetskopi…',
backupOverlayImportTitle: 'Gjenoppretter sikkerhetskopi…',
backupOverlayHint: 'Du kan holde vinduet åpent mens vi behandler filer. Inndata er midlertidig låst.',
};