From 9fa086c42899eff0f0d82cc9cda49d3922e59b93 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 28 May 2026 13:36:25 +0200 Subject: [PATCH] feat(server): dual server address (LAN + public) per profile (#880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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, 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' — 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 — 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>; 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. --- CHANGELOG.md | 11 + src-tauri/src/cover_cache/mod.rs | 283 +++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/lib_commands/app_api/mod.rs | 2 + src-tauri/src/lib_commands/app_api/network.rs | 124 +++++ src/api/coverCache.ts | 9 +- src/api/network.ts | 24 + src/api/subsonicClient.ts | 14 +- src/api/subsonicStreamUrl.ts | 9 +- src/components/OrbitSharePopover.tsx | 6 +- src/components/OrbitStartModal.tsx | 7 +- src/components/QueuePanel.tsx | 8 +- .../settings/AddServerForm.test.tsx | 145 ++++++ src/components/settings/AddServerForm.tsx | 229 ++++++++- src/components/settings/ServersTab.tsx | 134 ++++- src/components/settings/UserForm.tsx | 8 +- .../settings/userMgmt/MagicStringModal.tsx | 8 +- src/config/settingsCredits.ts | 1 + src/cover/diskSrcCache.test.ts | 75 ++- src/cover/diskSrcCache.ts | 19 + src/cover/fetchUrl.ts | 9 +- src/hooks/tauriBridge/useCoverArtBridge.ts | 17 + src/hooks/useConnectionStatus.test.ts | 135 +++++ src/hooks/useConnectionStatus.ts | 76 ++- src/locales/de/settings.ts | 19 + src/locales/en/settings.ts | 19 + src/locales/es/settings.ts | 19 + src/locales/fr/settings.ts | 19 + src/locales/nb/settings.ts | 19 + src/locales/nl/settings.ts | 19 + src/locales/ro/settings.ts | 19 + src/locales/ru/settings.ts | 19 + src/locales/zh/settings.ts | 19 + src/pages/Login.test.tsx | 97 ++++ src/pages/Login.tsx | 71 ++- src/store/authStore.ts | 11 +- src/store/authStoreTypes.ts | 18 + src/utils/library/librarySession.ts | 18 +- .../server/rewriteFrontendStoreKeys.test.ts | 112 +++++ src/utils/server/rewriteFrontendStoreKeys.ts | 78 +++ src/utils/server/serverEndpoint.test.ts | 462 ++++++++++++++++++ src/utils/server/serverEndpoint.ts | 282 +++++++++++ src/utils/server/serverFingerprint.test.ts | 352 +++++++++++++ src/utils/server/serverFingerprint.ts | 332 +++++++++++++ src/utils/server/serverMagicString.test.ts | 109 ++++- src/utils/server/serverMagicString.ts | 100 +++- src/utils/server/serverUrlRemigration.test.ts | 155 ++++++ src/utils/server/serverUrlRemigration.ts | 124 +++++ src/utils/server/switchActiveServer.ts | 19 +- src/utils/share/copyEntityShareLink.ts | 10 +- src/utils/share/enqueueShareSearchPayload.ts | 22 +- src/utils/share/shareLink.test.ts | 27 + src/utils/share/shareLink.ts | 9 +- 53 files changed, 3830 insertions(+), 104 deletions(-) create mode 100644 src-tauri/src/lib_commands/app_api/network.rs create mode 100644 src/api/network.ts create mode 100644 src/components/settings/AddServerForm.test.tsx create mode 100644 src/hooks/useConnectionStatus.test.ts create mode 100644 src/pages/Login.test.tsx create mode 100644 src/utils/server/rewriteFrontendStoreKeys.test.ts create mode 100644 src/utils/server/serverEndpoint.test.ts create mode 100644 src/utils/server/serverEndpoint.ts create mode 100644 src/utils/server/serverFingerprint.test.ts create mode 100644 src/utils/server/serverFingerprint.ts create mode 100644 src/utils/server/serverUrlRemigration.test.ts create mode 100644 src/utils/server/serverUrlRemigration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f6f8ae..dc4e3a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Servers — second optional address per profile (LAN + public) + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#880](https://github.com/Psychotoxical/psysonic/pull/880)** + +* **Settings → Servers:** a profile can carry a second address — typically a LAN counterpart of a public URL or vice versa. The app probes LAN-first and uses whichever endpoint actually answers, so the same profile is fast at home and reachable away from home without manual switching. Single-address profiles behave exactly as before. +* **Same-server verify on save** when both addresses are filled — mismatched or unreachable pairs are blocked with a clear message; two-LAN combinations are caught client-side before any network call. +* **Share links** (Orbit invites, library / queue shares, magic invite v2) embed the **public** address by default so off-LAN guests can reach the host; a *Use local address in share links* checkbox flips that for LAN-only groups. Pasted invites match either address. +* **Editing the primary URL** to a new host triggers a confirm-modal-gated data move: library + analysis databases, cover-cache files on disk, and player queue all re-tag to the new identifier in one go. Changing only the second address or `http`↔`https` skips the migration entirely. + + + ## Changed ### Linux — session GDK, WebKitGTK mitigations, and Wayland text diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 1c3074cc..931f26c9 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -705,6 +705,128 @@ pub async fn cover_cache_clear_server( Ok(()) } +/// Rename a server's cover-cache bucket on disk after the user edits the +/// primary URL (and the derived index key changes). Used by the URL-change +/// remigration pipeline (dual-server-address spec §8.3) so cached covers +/// stay reachable under the new key. +/// +/// Sanitization: rejects path-separator characters and `..` components — keys +/// flow from `serverIndexKeyFromUrl(url)` which strips schemes and trailing +/// slashes, but defense in depth at the FS boundary is cheap. +/// +/// Behaviour: +/// - `old_key == new_key` → no-op success. +/// - Old bucket missing → no-op success (nothing to migrate). +/// - New bucket missing → simple `rename` (fastest path). +/// - Both exist → recursive merge, **prefer existing** in destination (the +/// newer bucket wins on collision; the surviving file count goes up, never +/// loses data). +/// +/// Always emits `cover:bucket-renamed` with `{oldKey, newKey}` on success so +/// the frontend in-memory disk-src cache can invalidate stale entries. +#[tauri::command] +pub async fn cover_cache_rename_server_bucket( + app: AppHandle, + old_key: String, + new_key: String, +) -> Result<(), String> { + let st = state(&app)?; + let guard = st.lock().await; + rename_bucket_inner(&guard.root, &old_key, &new_key)?; + drop(guard); + let _ = app.emit( + "cover:bucket-renamed", + serde_json::json!({ "oldKey": old_key, "newKey": new_key }), + ); + Ok(()) +} + +/// FS-only worker for `cover_cache_rename_server_bucket`, lifted out so the +/// command-level behaviour (sanitization + every short-circuit + the merge +/// branch) is testable against a real `tempdir` without spinning up Tauri +/// State. The command wrapper above adds nothing the tests need to cover +/// except the event emit. +fn rename_bucket_inner(root: &std::path::Path, old_key: &str, new_key: &str) -> Result<(), String> { + if old_key.is_empty() || new_key.is_empty() { + return Err("cover_cache_rename_server_bucket: empty key".into()); + } + if !is_safe_index_key(old_key) || !is_safe_index_key(new_key) { + return Err("cover_cache_rename_server_bucket: key contains path separator".into()); + } + if old_key == new_key { + return Ok(()); + } + + let old_dir = root.join(old_key); + let new_dir = root.join(new_key); + + if !old_dir.is_dir() { + return Ok(()); + } + + if !new_dir.exists() { + std::fs::rename(&old_dir, &new_dir).map_err(|e| e.to_string())?; + } else { + merge_cover_bucket(&old_dir, &new_dir)?; + let _ = std::fs::remove_dir_all(&old_dir); + } + Ok(()) +} + +fn is_safe_index_key(key: &str) -> bool { + // Real index keys are `host[:port][/sub/path]` shape — forward slashes + // are legitimate path components (Navidrome behind a reverse-proxy + // subpath, etc.). Everything below is defense-in-depth at the FS + // boundary; real keys come out of `serverIndexKeyFromUrl` and never + // start with a separator or carry the patterns we reject here. + if key.is_empty() { + return false; + } + // Absolute-path leaders — `root.join("/etc/...")` and `root.join("\\foo\\")` + // on Unix / Windows respectively REPLACE the base path with the absolute + // argument. Reject before that ever happens. + if key.starts_with('/') || key.starts_with('\\') { + return false; + } + // Windows drive-letter root (`C:`, `c:`). `Path::join("C:")` is also + // treated as absolute on Windows. + let bytes = key.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() { + return false; + } + // Backslash anywhere — separators are forward-slash only. + if key.contains('\\') { + return false; + } + // No `..` segments anywhere — would escape the cover-cache root. + for segment in key.split('/') { + if segment == ".." { + return false; + } + } + true +} + +fn merge_cover_bucket(old_dir: &std::path::Path, new_dir: &std::path::Path) -> Result<(), String> { + let entries = std::fs::read_dir(old_dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let from = entry.path(); + let to = new_dir.join(entry.file_name()); + if to.exists() { + // Prefer existing in destination — newer bucket wins. + continue; + } + if from.is_dir() { + std::fs::create_dir_all(&to).map_err(|e| e.to_string())?; + merge_cover_bucket(&from, &to)?; + } else { + std::fs::rename(&from, &to).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + #[tauri::command] pub async fn cover_cache_configure( app: AppHandle, @@ -856,6 +978,9 @@ mod tests { use super::decode_image_bytes; use super::disk::{cover_dir, tier_path}; + use super::{is_safe_index_key, merge_cover_bucket, rename_bucket_inner}; + use std::fs; + use std::path::PathBuf; #[test] fn disk_layout_paths() { @@ -874,4 +999,162 @@ mod tests { assert_eq!(decoded.width(), 2); assert_eq!(decoded.height(), 2); } + + #[test] + fn safe_index_key_accepts_real_keys() { + assert!(is_safe_index_key("music.example.com")); + assert!(is_safe_index_key("192.168.0.10:4533")); + assert!(is_safe_index_key("music.example.com/navidrome")); + assert!(is_safe_index_key("[fe80::1]:4533")); + } + + #[test] + fn safe_index_key_rejects_path_traversal_and_backslashes() { + assert!(!is_safe_index_key("../etc")); + assert!(!is_safe_index_key("a/../b")); + assert!(!is_safe_index_key("a\\b")); + assert!(!is_safe_index_key("..\\evil")); + } + + #[test] + fn safe_index_key_rejects_absolute_paths_and_drive_letters() { + // Path::join with an absolute argument replaces the base — must + // never accept keys that lead with a separator. + assert!(!is_safe_index_key("/etc/passwd")); + assert!(!is_safe_index_key("/")); + assert!(!is_safe_index_key("\\windows")); + // Windows drive-letter roots are also treated as absolute. + assert!(!is_safe_index_key("C:")); + assert!(!is_safe_index_key("C:/Windows")); + assert!(!is_safe_index_key("c:foo")); + // Empty key is meaningless and would join to the root itself. + assert!(!is_safe_index_key("")); + } + + /// Build a unique tmpdir for the merge tests so parallel runs don't trip + /// on each other. + fn fresh_tmpdir(label: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!("psysonic-cover-merge-{}-{}", label, nanos)); + fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn merge_bucket_moves_unique_files() { + let root = fresh_tmpdir("unique"); + let old = root.join("old"); + let new_ = root.join("new"); + fs::create_dir_all(old.join("al-1")).unwrap(); + fs::write(old.join("al-1").join("128.webp"), b"old-bytes").unwrap(); + fs::create_dir_all(&new_).unwrap(); + + merge_cover_bucket(&old, &new_).unwrap(); + + assert!(new_.join("al-1").join("128.webp").exists()); + assert_eq!(fs::read(new_.join("al-1").join("128.webp")).unwrap(), b"old-bytes"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn merge_bucket_prefers_existing_on_collision() { + let root = fresh_tmpdir("collision"); + let old = root.join("old"); + let new_ = root.join("new"); + fs::create_dir_all(old.join("al-1")).unwrap(); + fs::create_dir_all(new_.join("al-1")).unwrap(); + fs::write(old.join("al-1").join("128.webp"), b"OLD").unwrap(); + fs::write(new_.join("al-1").join("128.webp"), b"NEW").unwrap(); + + merge_cover_bucket(&old, &new_).unwrap(); + + // Existing destination wins; nothing was overwritten. + assert_eq!(fs::read(new_.join("al-1").join("128.webp")).unwrap(), b"NEW"); + let _ = fs::remove_dir_all(&root); + } + + // ── rename_bucket_inner — command-level behaviour ───────────────────────── + + #[test] + fn rename_bucket_inner_rejects_empty_keys() { + let root = fresh_tmpdir("rename-empty"); + assert!(rename_bucket_inner(&root, "", "new").is_err()); + assert!(rename_bucket_inner(&root, "old", "").is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_rejects_unsafe_keys() { + let root = fresh_tmpdir("rename-unsafe"); + assert!(rename_bucket_inner(&root, "../escape", "new").is_err()); + assert!(rename_bucket_inner(&root, "old", "/abs/path").is_err()); + assert!(rename_bucket_inner(&root, "old", "C:/Windows").is_err()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_noop_when_old_missing() { + let root = fresh_tmpdir("rename-missing"); + // No old dir exists at all — must succeed without creating new. + rename_bucket_inner(&root, "old", "new").unwrap(); + assert!(!root.join("new").exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_noop_when_keys_equal() { + let root = fresh_tmpdir("rename-equal"); + fs::create_dir_all(root.join("same").join("al-1")).unwrap(); + fs::write(root.join("same").join("al-1").join("128.webp"), b"x").unwrap(); + rename_bucket_inner(&root, "same", "same").unwrap(); + // Still exactly where it was; nothing renamed. + assert!(root.join("same").join("al-1").join("128.webp").exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_simple_rename_when_new_missing() { + let root = fresh_tmpdir("rename-simple"); + fs::create_dir_all(root.join("old").join("al-1")).unwrap(); + fs::write(root.join("old").join("al-1").join("128.webp"), b"payload").unwrap(); + rename_bucket_inner(&root, "old", "new").unwrap(); + assert!(!root.join("old").exists()); + assert_eq!( + fs::read(root.join("new").join("al-1").join("128.webp")).unwrap(), + b"payload", + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn rename_bucket_inner_merges_when_new_exists() { + let root = fresh_tmpdir("rename-merge"); + fs::create_dir_all(root.join("old").join("al-1")).unwrap(); + fs::create_dir_all(root.join("new").join("al-2")).unwrap(); + fs::write(root.join("old").join("al-1").join("128.webp"), b"from-old").unwrap(); + fs::write(root.join("new").join("al-2").join("128.webp"), b"from-new").unwrap(); + // Collision on al-2 — destination wins. + fs::create_dir_all(root.join("old").join("al-2")).unwrap(); + fs::write(root.join("old").join("al-2").join("128.webp"), b"overwrite-attempt").unwrap(); + + rename_bucket_inner(&root, "old", "new").unwrap(); + + // Old bucket gone. + assert!(!root.join("old").exists()); + // al-1 moved in. + assert_eq!( + fs::read(root.join("new").join("al-1").join("128.webp")).unwrap(), + b"from-old", + ); + // al-2 destination preserved (prefer-existing). + assert_eq!( + fs::read(root.join("new").join("al-2").join("128.webp")).unwrap(), + b"from-new", + ); + let _ = fs::remove_dir_all(&root); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae1c0637..07f7cd19 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -599,6 +599,7 @@ pub fn run() { backup_import_full, migration_inspect, migration_run, + resolve_host_addresses, psysonic_syncfs::sync::batch::calculate_sync_payload, exit_app, cli_publish_player_snapshot, @@ -748,6 +749,7 @@ pub fn run() { cover_cache::cover_cache_configure, cover_cache::cover_cache_clear, cover_cache::cover_cache_clear_server, + cover_cache::cover_cache_rename_server_bucket, cover_cache::cover_cache_stats_server, cover_cache::library_cover_backfill_batch, cover_cache::library_cover_progress, diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs index 79017237..59246d46 100644 --- a/src-tauri/src/lib_commands/app_api/mod.rs +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -3,6 +3,7 @@ mod cli_bridge; mod core; mod integration; mod migration; +mod network; mod perf; pub(crate) mod platform; @@ -33,6 +34,7 @@ pub(crate) use integration::{ unregister_global_shortcut, }; pub(crate) use migration::{migration_inspect, migration_run}; +pub(crate) use network::resolve_host_addresses; // Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown, // and analysis admin commands now live in their domain crates. invoke_handler! diff --git a/src-tauri/src/lib_commands/app_api/network.rs b/src-tauri/src/lib_commands/app_api/network.rs new file mode 100644 index 00000000..c19a04e2 --- /dev/null +++ b/src-tauri/src/lib_commands/app_api/network.rs @@ -0,0 +1,124 @@ +//! Network helpers exposed as Tauri commands. Currently just DNS lookup for the +//! dual-server-address add/edit form (UI hint only — not for connect). + +use std::collections::HashSet; +use tokio::net::lookup_host; + +/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6). +/// +/// Strips a `host:port` suffix before lookup — the form only knows the host. +/// Used by the add/edit-server form to hint whether the entered address +/// classifies as LAN or public (a hostname that resolves to a private range +/// IP suggests the user might want to add a public second address, and +/// vice versa). **Never used for connect** — connect always goes through the +/// existing `pingWithCredentials` path, which carries credentials. +/// +/// Returns an empty vec on lookup failure (the UI then shows no hint, by +/// design: a transient DNS hiccup shouldn't block save). +#[tauri::command] +pub(crate) async fn resolve_host_addresses(hostname: String) -> Result, String> { + let trimmed = hostname.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + // Strip port if present. IPv6 literals use [host]:port; bare IPv4/host + // use host:port. We only resolve the host portion. + let host_only = strip_port(trimmed); + if host_only.is_empty() { + return Ok(Vec::new()); + } + + // tokio's lookup_host requires a port. Append :0 — we discard the port + // from each returned SocketAddr. + let lookup_target = if host_only.contains(':') { + // IPv6 literal — wrap in brackets if not already. + if host_only.starts_with('[') { + format!("{}:0", host_only) + } else { + format!("[{}]:0", host_only) + } + } else { + format!("{}:0", host_only) + }; + + let addrs = match lookup_host(&lookup_target).await { + Ok(iter) => iter, + Err(_) => return Ok(Vec::new()), + }; + + let mut seen: HashSet = HashSet::new(); + let mut result = Vec::new(); + for sock in addrs { + let ip = sock.ip().to_string(); + if seen.insert(ip.clone()) { + result.push(ip); + } + } + Ok(result) +} + +/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves +/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone. +fn strip_port(input: &str) -> String { + let s = input.trim(); + // Bracketed IPv6 — `[host]:port` → `host`; `[host]` (no port) → `host`. + if let Some(rest) = s.strip_prefix('[') { + if let Some(close) = rest.find(']') { + return rest[..close].to_string(); + } + // Malformed bracket — fall through. + } + // Hostnames and IPv4 only contain one `:`. IPv6 without brackets cannot + // be unambiguously split from a port, so leave as-is (lookup_host wraps + // it for us). + let colon_count = s.bytes().filter(|&b| b == b':').count(); + if colon_count == 1 { + if let Some((host, _port)) = s.rsplit_once(':') { + return host.to_string(); + } + } + s.to_string() +} + +#[cfg(test)] +mod tests { + use super::strip_port; + + #[test] + fn strips_host_port_pair() { + assert_eq!(strip_port("music.example.com:4533"), "music.example.com"); + } + + #[test] + fn strips_ipv4_port_pair() { + assert_eq!(strip_port("192.168.0.10:4533"), "192.168.0.10"); + } + + #[test] + fn leaves_bare_host_alone() { + assert_eq!(strip_port("music.example.com"), "music.example.com"); + } + + #[test] + fn unwraps_bracketed_ipv6_with_port() { + assert_eq!(strip_port("[::1]:4533"), "::1"); + } + + #[test] + fn unwraps_bracketed_ipv6_without_port() { + assert_eq!(strip_port("[fe80::1]"), "fe80::1"); + } + + #[test] + fn leaves_unbracketed_ipv6_alone() { + // Multiple colons + no brackets — can't tell host from port; safe to + // hand the raw string to lookup_host, which handles it. + assert_eq!(strip_port("fe80::1"), "fe80::1"); + } + + #[test] + fn handles_empty_input() { + assert_eq!(strip_port(""), ""); + } +} diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index 92090fcd..b86f22b5 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { restBaseFromUrl } from './subsonicClient'; @@ -42,13 +43,17 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const scope = ref.serverScope; if (scope.kind === 'server') { + // scope.url is the index-stable primary; the Rust cover fetcher needs + // the runtime connect URL (LAN or public, whichever currently answers). return { serverIndexKey: coverIndexKeyFromRef(ref), cacheKind: ref.cacheKind, cacheEntityId: ref.cacheEntityId, coverArtId: ref.fetchCoverArtId, tier, - restBaseUrl: coverCacheRestHost(scope.url), + restBaseUrl: coverCacheRestHost( + connectBaseUrlForServer({ id: scope.serverId, url: scope.url }), + ), username: scope.username, password: scope.password, }; @@ -66,7 +71,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { return getActiveServer(); })() : getActiveServer(); - const baseUrl = server?.url || getBaseUrl(); + const baseUrl = server ? connectBaseUrlForServer(server) : getBaseUrl(); return { serverIndexKey: coverIndexKeyFromRef(ref), cacheKind: ref.cacheKind, diff --git a/src/api/network.ts b/src/api/network.ts new file mode 100644 index 00000000..deeb971e --- /dev/null +++ b/src/api/network.ts @@ -0,0 +1,24 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * Resolve a hostname (or `host:port`) to a deduped list of IP-address strings + * via the Rust `resolve_host_addresses` command. IPv4 + IPv6 returned in one + * list; order is whatever the OS resolver hands back. + * + * **Form-hint only.** Used by the add/edit-server form to suggest whether + * the entered address is LAN-only (→ hint to add a public second address) + * or public-only (→ hint to add a local one). The actual connect path runs + * `pingWithCredentials` against the URL — not against the resolved IPs. + * + * Lookup failure / DNS hiccup → empty array (UI shows no hint, by design; + * a transient DNS error must not block save). + */ +export async function resolveHostAddresses(hostname: string): Promise { + const trimmed = hostname.trim(); + if (!trimmed) return []; + try { + return await invoke('resolve_host_addresses', { hostname: trimmed }); + } catch { + return []; + } +} diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index e69ff744..fdfa0c65 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -3,6 +3,7 @@ import md5 from 'md5'; import { version } from '../../package.json'; import { useAuthStore } from '../store/authStore'; import type { ServerProfile } from '../store/authStoreTypes'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup'; export const SUBSONIC_CLIENT = `psysonic/${version}`; @@ -66,7 +67,18 @@ export async function apiForServer( ): Promise { const server = getServerById(serverId); if (!server) throw new Error(`Unknown server: ${serverId}`); - return apiWithCredentials(server.url, server.username, server.password, endpoint, extra, timeout); + // Dual-address: route through the cached connect URL when one has been + // probed for this profile; otherwise the normalized primary url is the + // same string the legacy code path used, so single-address profiles are + // byte-identical to before. + return apiWithCredentials( + connectBaseUrlForServer(server), + server.username, + server.password, + endpoint, + extra, + timeout, + ); } export async function api( diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index 1b1833bd..63125876 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -3,6 +3,7 @@ import { coverStorageKey, coverStorageKeyFromRef } from '../cover/storageKeys'; import { coverEntryToRef, resolveAlbumCoverEntry } from '../cover/resolveEntry'; import type { CoverArtTier } from '../cover/types'; import { useAuthStore } from '../store/authStore'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; @@ -45,7 +46,8 @@ function streamUrlFromProfile( export function buildStreamUrlForServer(serverId: string, id: string): string { const server = findServerByIdOrIndexKey(serverId); if (!server) return buildStreamUrl(id); - return streamUrlFromProfile(server.url, server.username, server.password, id); + // Dual-address: route the stream through the cached connect endpoint. + return streamUrlFromProfile(connectBaseUrlForServer(server), server.username, server.password, id); } export function buildStreamUrl(id: string): string { @@ -53,7 +55,10 @@ export function buildStreamUrl(id: string): string { const server = getActiveServer(); const baseUrl = getBaseUrl(); if (!server || !baseUrl) return streamUrlFromProfile('', '', '', id); - return streamUrlFromProfile(server.url, server.username, server.password, id); + // `getBaseUrl()` already returns the cached connect URL; use it directly + // instead of re-normalizing `server.url`, which would bypass the dual- + // address connect cache. + return streamUrlFromProfile(baseUrl, server.username, server.password, id); } /** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */ diff --git a/src/components/OrbitSharePopover.tsx b/src/components/OrbitSharePopover.tsx index e0b88c9f..18604039 100644 --- a/src/components/OrbitSharePopover.tsx +++ b/src/components/OrbitSharePopover.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { useAuthStore } from '../store/authStore'; import { buildOrbitShareLink } from '../utils/orbit'; +import { serverShareBaseUrl } from '../utils/server/serverEndpoint'; interface Props { anchorRef: React.RefObject; @@ -23,7 +24,10 @@ export default function OrbitSharePopover({ anchorRef, onClose }: Props) { const [copied, setCopied] = useState(false); const shareLink = sessionId - ? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId) + ? (() => { + const active = useAuthStore.getState().getActiveServer(); + return buildOrbitShareLink(active ? serverShareBaseUrl(active) : '', sessionId); + })() : null; useEffect(() => { diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx index 95c13b9a..5a865537 100644 --- a/src/components/OrbitStartModal.tsx +++ b/src/components/OrbitStartModal.tsx @@ -13,7 +13,7 @@ import { import { randomOrbitSessionName } from '../utils/orbitNames'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; -import { isLanUrl } from '../hooks/useConnectionStatus'; +import { isLanUrl, serverShareBaseUrl } from '../utils/server/serverEndpoint'; import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit'; interface Props { onClose: () => void; } @@ -38,7 +38,10 @@ export default function OrbitStartModal({ onClose }: Props) { const [clearQueue, setClearQueue] = useState(false); const server = useAuthStore.getState().getActiveServer(); - const serverBase = server?.url ?? ''; + // Orbit links go to remote guests — use the share URL (public by default + // when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning + // then correctly reads the address the guest will actually see. + const serverBase = server ? serverShareBaseUrl(server) : ''; const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer'); const onLan = isLanUrl(serverBase); diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 9d8caf68..ddc8e4ff 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useAuthStore } from '../store/authStore'; import { encodeSharePayload } from '../utils/share/shareLink'; +import { serverShareBaseUrl } from '../utils/server/serverEndpoint'; import { copyTextToClipboard } from '../utils/server/serverMagicString'; import { showToast } from '../utils/ui/toast'; import { useThemeStore } from '../store/themeStore'; @@ -196,7 +197,12 @@ function QueuePanelHostOrSolo() { showToast(t('queue.shareQueueEmpty'), 3000, 'info'); return; } - const srv = useAuthStore.getState().getBaseUrl(); + // Queue share goes to remote recipients — use the share URL, not the + // connect URL the active app is currently bound to (would leak the LAN + // host on a dual-address profile). + const active = useAuthStore.getState().getActiveServer(); + if (!active) return; + const srv = serverShareBaseUrl(active); if (!srv) return; const ids = queueItems.map(r => r.trackId); const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids })); diff --git a/src/components/settings/AddServerForm.test.tsx b/src/components/settings/AddServerForm.test.tsx new file mode 100644 index 00000000..1f3476cf --- /dev/null +++ b/src/components/settings/AddServerForm.test.tsx @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { AddServerForm } from './AddServerForm'; +import { encodeServerMagicString } from '@/utils/server/serverMagicString'; + +// resolve_host_addresses Tauri command — hint-only, must not block save. +vi.mock('@/api/network', () => ({ + resolveHostAddresses: vi.fn(async () => [] as string[]), +})); + +// showToast mocked so we can assert two-LAN validation surfaced the error. +vi.mock('@/utils/ui/toast', () => ({ + showToast: vi.fn(), +})); + +import { showToast } from '@/utils/ui/toast'; + +describe('AddServerForm — dual-address behaviour', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('saves a single-address profile without alternateUrl / shareUsesLocalUrl', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + // [0] name, [1] primary url, [2] alternate url, [3] username, [4] magic string + await user.type(inputs[1]!, 'https://music.example.com'); + await user.type(inputs[3]!, 'tester'); + await user.type(screen.getByPlaceholderText('••••••••'), 'pw'); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg.username).toBe('tester'); + expect(arg.password).toBe('pw'); + expect(arg).not.toHaveProperty('alternateUrl'); + expect(arg).not.toHaveProperty('shareUsesLocalUrl'); + }); + + it('saves both addresses when the user fills the second field', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + // [0] name, [1] primary url, [2] alternate url, [3] username + await user.type(inputs[1]!, 'https://music.example.com'); + await user.type(inputs[2]!, 'http://192.168.0.10:4533'); + await user.type(inputs[3]!, 'tester'); + await user.type(screen.getByPlaceholderText('••••••••'), 'pw'); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg.alternateUrl).toBe('http://192.168.0.10:4533'); + expect(arg.shareUsesLocalUrl).toBe(false); + }); + + it('blocks save with a toast when both addresses classify as LAN', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const inputs = screen.getAllByRole('textbox'); + await user.type(inputs[1]!, 'http://10.0.0.5'); + await user.type(inputs[2]!, 'http://192.168.0.10'); + await user.type(inputs[3]!, 'tester'); + await user.type(screen.getByPlaceholderText('••••••••'), 'pw'); + await user.click(screen.getByRole('button', { name: /add/i })); + + // Save is blocked, error toast surfaced with the two-LAN string. + expect(onSave).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith( + expect.stringMatching(/both addresses are local/i), + expect.any(Number), + 'error', + ); + }); + + it('decodes a v2 magic string and forwards alternateUrl + shareUsesLocalUrl on save', async () => { + const onSave = vi.fn(); + renderWithProviders(); + const user = userEvent.setup(); + + const magicString = encodeServerMagicString({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + shareUsesLocalUrl: true, + username: 'tester', + password: 'pw', + }); + + // The magic-string input is the last textbox shown for new-profile mode. + const inputs = screen.getAllByRole('textbox'); + const magicInput = inputs[inputs.length - 1]!; + await user.type(magicInput, magicString); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg.alternateUrl).toBe('http://192.168.0.10:4533'); + expect(arg.shareUsesLocalUrl).toBe(true); + expect(arg.username).toBe('tester'); + expect(arg.password).toBe('pw'); + }); + + it('strips alternateUrl + share flag when the user empties the second field', async () => { + const onSave = vi.fn(); + renderWithProviders( + , + ); + const user = userEvent.setup(); + + // Locate the alternate-url field (second URL-shaped input, prefilled). + const altInput = screen.getByDisplayValue('http://192.168.0.10'); + await user.clear(altInput); + await user.click(screen.getByRole('button', { name: /save/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const arg = onSave.mock.calls[0]![0]; + expect(arg.url).toBe('https://music.example.com'); + expect(arg).not.toHaveProperty('alternateUrl'); + expect(arg).not.toHaveProperty('shareUsesLocalUrl'); + }); +}); diff --git a/src/components/settings/AddServerForm.tsx b/src/components/settings/AddServerForm.tsx index 9e66f071..41ee6b7b 100644 --- a/src/components/settings/AddServerForm.tsx +++ b/src/components/settings/AddServerForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { ServerProfile } from '../../store/authStoreTypes'; import { showToast } from '../../utils/ui/toast'; @@ -9,6 +9,34 @@ import { type ServerMagicPayload, } from '../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../utils/server/serverDisplayName'; +import { isLanUrl } from '../../utils/server/serverEndpoint'; +import { resolveHostAddresses } from '../../api/network'; + +type FormState = { + name: string; + url: string; + alternateUrl: string; + shareUsesLocalUrl: boolean; + username: string; + password: string; +}; + +/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */ +function hostnameForDnsHint(rawUrl: string): string | null { + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + try { + const u = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`); + const host = u.hostname.replace(/^\[|\]$/g, ''); + // Literal IPv4 / IPv6 → no DNS lookup; isLanUrl alone classifies it. + if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return null; + if (host.includes(':')) return null; + if (host === 'localhost' || host.endsWith('.local')) return null; + return host; + } catch { + return null; + } +} export function AddServerForm({ onSave, @@ -16,35 +44,64 @@ export function AddServerForm({ initialInvite = null, editingServer = null, }: { - onSave: (data: Omit) => void; + onSave: (data: Omit) => void | Promise; onCancel: () => void; initialInvite?: ServerMagicPayload | null; editingServer?: ServerProfile | null; }) { const { t } = useTranslation(); const isEdit = editingServer != null; - const [form, setForm] = useState( + const [form, setForm] = useState( editingServer - ? { name: editingServer.name, url: editingServer.url, username: editingServer.username, password: editingServer.password } - : { name: '', url: '', username: '', password: '' }, + ? { + name: editingServer.name, + url: editingServer.url, + alternateUrl: editingServer.alternateUrl ?? '', + shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false, + username: editingServer.username, + password: editingServer.password, + } + : { + name: '', + url: '', + alternateUrl: '', + shareUsesLocalUrl: false, + username: '', + password: '', + }, ); const [magicString, setMagicString] = useState(''); const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); + // DNS-classified hint: 'lan' / 'public' / null (no hint, no lookup yet, or + // literal IP — isLanUrl already classifies those without DNS). + const [primaryDnsClass, setPrimaryDnsClass] = useState<'lan' | 'public' | null>(null); useEffect(() => { if (!initialInvite) return; setBlockPasswordReveal(true); - setForm({ + setForm(f => ({ + ...f, name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url), url: initialInvite.url, + // v2 invites carry the host's dual-address fields. Pre-populate so the + // receiver sees both addresses + the share preference rather than + // re-typing them. + alternateUrl: initialInvite.alternateUrl ?? '', + shareUsesLocalUrl: initialInvite.shareUsesLocalUrl ?? false, username: initialInvite.username, password: initialInvite.password, - }); + })); setMagicString(encodeServerMagicString(initialInvite)); }, [initialInvite]); - const update = (k: keyof typeof form) => (e: React.ChangeEvent) => - setForm(f => ({ ...f, [k]: e.target.value })); + const update = (k: K) => + (e: React.ChangeEvent) => { + const value = + e.target.type === 'checkbox' + ? (e.target as HTMLInputElement).checked + : e.target.value; + setForm(f => ({ ...f, [k]: value }) as FormState); + }; const handleMagicStringChange = (e: React.ChangeEvent) => { const v = e.target.value; @@ -53,16 +110,70 @@ export function AddServerForm({ const decoded = decodeServerMagicString(trimmed); if (decoded) { setBlockPasswordReveal(true); - setForm({ + setForm(f => ({ + ...f, name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), url: decoded.url, + alternateUrl: decoded.alternateUrl ?? '', + shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false, username: decoded.username, password: decoded.password, - }); + })); } }; - const submit = () => { + // Literal-IP classification — instant, no DNS needed. + const primaryUrlIsLanLiteral = useMemo(() => { + const trimmed = form.url.trim(); + if (!trimmed) return null as boolean | null; + if (hostnameForDnsHint(trimmed) !== null) return null; // hostname — defer to DNS + return isLanUrl(trimmed); + }, [form.url]); + + // Effective LAN classification: literal IP shortcut OR DNS result. + const primaryUrlIsLan = useMemo(() => { + if (primaryUrlIsLanLiteral !== null) return primaryUrlIsLanLiteral; + if (primaryDnsClass === null) return null; + return primaryDnsClass === 'lan'; + }, [primaryUrlIsLanLiteral, primaryDnsClass]); + + const runDnsHint = async () => { + const hostname = hostnameForDnsHint(form.url); + if (!hostname) { + setPrimaryDnsClass(null); + return; + } + const addresses = await resolveHostAddresses(hostname); + if (addresses.length === 0) { + // DNS failed (no network, NXDOMAIN, …) → no hint, don't block. + setPrimaryDnsClass(null); + return; + } + const anyPublic = addresses.some(ip => !isLanUrl(`http://${ip}`)); + setPrimaryDnsClass(anyPublic ? 'public' : 'lan'); + }; + + // Two-LAN client-side check before submit. Returns true on validation pass. + const validateAddresses = (): boolean => { + const url = form.url.trim(); + const alt = form.alternateUrl.trim(); + if (!url) return false; + if (!alt) return true; // single-address — always fine. + // For the LAN-LAN check we accept both the synchronous isLanUrl + // classification (literal IPs + .local + localhost) and the DNS-resolved + // class for the primary. The alternate goes through isLanUrl directly — + // we don't run a second DNS lookup for it here; the verify step on save + // will catch any deeper inconsistency. + const primaryLan = primaryUrlIsLan ?? isLanUrl(url); + const altLan = isLanUrl(alt); + if (primaryLan && altLan) { + showToast(t('settings.serverBothLanError'), 4500, 'error'); + return false; + } + return true; + }; + + const submit = async () => { const ms = magicString.trim(); if (ms) { const decoded = decodeServerMagicString(ms); @@ -70,28 +181,66 @@ export function AddServerForm({ showToast(t('login.magicStringInvalid'), 4000, 'error'); return; } - onSave({ + // v2 invites carry alternateUrl + shareUsesLocalUrl — must survive the + // magic-string submit path (handleMagicStringChange already prefills + // them into form state, but the magic-string branch forwards the + // decoded payload directly so we have to pick them off here too). + const altDecoded = decoded.alternateUrl?.trim() ?? ''; + await onSave({ name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), url: decoded.url, username: decoded.username, password: decoded.password, + ...(altDecoded + ? { + alternateUrl: altDecoded, + shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false, + } + : {}), }); return; } if (!form.url.trim()) return; - onSave({ + if (!validateAddresses()) return; + + const altTrimmed = form.alternateUrl.trim(); + // If the user clears the second address, strip the share-flag as well so + // we don't leave a dangling preference (spec §5.3 last row). + const data: Omit = { name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password, - }); + ...(altTrimmed + ? { + alternateUrl: altTrimmed, + shareUsesLocalUrl: form.shareUsesLocalUrl, + } + : {}), + }; + await onSave(data); }; + // Hint to show under the second-address field: only when the primary is + // classified one way or the other and the second is still empty. + const alternateUrlHint = + !form.alternateUrl.trim() && primaryUrlIsLan !== null + ? primaryUrlIsLan + ? t('settings.serverAlternateUrlHintAddPublic') + : t('settings.serverAlternateUrlHintAddLocal') + : null; + + // Share checkbox visibility (spec §5.3): + // - hidden when there is no second address + // - shown the moment a user fills the second field (or both already exist + // on an edit). Default off; persists across save/load. + const showShareCheckbox = form.alternateUrl.trim().length > 0; + return (
{ e.preventDefault(); submit(); }} + onSubmit={e => { e.preventDefault(); void submit(); }} >

{isEdit ? t('settings.editServerTitle') : t('settings.addServerTitle')} @@ -102,8 +251,54 @@ export function AddServerForm({
- + { void runDnsHint(); }} + placeholder={t('settings.serverUrlPlaceholder')} + autoComplete="off" + />
+
+ + + {alternateUrlHint && ( +
+ {alternateUrlHint} +
+ )} +
+ {showShareCheckbox && ( +
+ +
+ )}
diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index 4598c03e..042f0943 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -13,6 +13,17 @@ import type { ServerProfile } from '../../store/authStoreTypes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { useDragDrop } from '../../contexts/DragDropContext'; import { type ServerMagicPayload } from '../../utils/server/serverMagicString'; +import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint'; +import { + verifySameServerEndpoints, + type VerifySameServerResult, +} from '../../utils/server/serverFingerprint'; +import { + indexKeyRemapForUrlChange, + runIndexKeyRemigration, +} from '../../utils/server/serverUrlRemigration'; +import { useConfirmModalStore } from '../../store/confirmModalStore'; +import { showToast } from '../../utils/ui/toast'; import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; @@ -112,17 +123,21 @@ export function ServersTab({ const testConnection = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (ping.ok) { + // Dual-address: probe through the connect layer so the test reflects + // whichever endpoint the app would actually use right now (LAN at home, + // public elsewhere). probe.baseUrl also feeds the AudioMuse probe so + // that one hits the same endpoint. + const probe = await ensureConnectUrlResolved(server); + if (probe.ok) { const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, + type: probe.ping.type, + serverVersion: probe.ping.serverVersion, + openSubsonic: probe.ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); - scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); + scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity); } - setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' })); + setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } @@ -166,12 +181,48 @@ export function ServersTab({ setPastedServerInvite(null); }; + /** + * Surface a dual-address verify failure as a toast (mismatch / + * insufficient / unreachable). Returns true when the result is `ok` and + * the caller should proceed; false when the user must fix something + * before save. + */ + const announceVerifyResult = (result: VerifySameServerResult): boolean => { + if (result.ok) return true; + if (result.reason === 'unreachable') { + showToast( + t('settings.dualAddressUnreachable', { host: result.unreachableHost ?? '' }), + 6000, + 'error', + ); + } else if (result.reason === 'mismatch') { + showToast(t('settings.dualAddressMismatch'), 6000, 'error'); + } else { + showToast(t('settings.dualAddressInsufficient'), 6000, 'error'); + } + return false; + }; + const handleAddServer = async (data: Omit) => { setShowAddForm(false); setPastedServerInvite(null); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { + // Dual-address: confirm both addresses point at the same server + // before persisting anything. Single-address adds skip verify and go + // straight to the legacy ping (which is also the connect-test). + if (data.alternateUrl) { + const verify = await verifySameServerEndpoints( + { url: data.url, alternateUrl: data.alternateUrl }, + data.username, + data.password, + ); + if (!announceVerifyResult(verify)) { + setConnStatus(s => ({ ...s, [tempId]: 'error' })); + return; + } + } const ping = await pingWithCredentials(data.url, data.username, data.password); if (ping.ok) { const id = auth.addServer(data); @@ -193,13 +244,76 @@ export function ServersTab({ } }; - // Edit saves unconditionally — ping result becomes a post-save status - // indicator (analog zum existing Test-Button) rather than blocking the - // save. Lets users update a profile even when the server is currently + // Edit normally saves unconditionally — ping result becomes a post-save + // status indicator (analog zum existing Test-Button) rather than blocking + // the save. Lets users update a profile even when the server is currently // unreachable. + // + // **Dual-address exception:** when the edit introduces or changes the + // second address (or changes the primary url while a second address is + // already saved), verify both addresses are the same server *before* + // persisting. A mismatch here would silently bind library / cover / queue + // data to two unrelated boxes — the spec blocks save in v1. const handleEditServer = async (id: string, data: Omit) => { + const previous = auth.servers.find(s => s.id === id); + + // URL-change remigration — runs BEFORE everything else when the edit + // changes the derived index key. User confirms first; on failure the + // edit is aborted with a stage-specific toast. Spec §8. + const remap = previous ? indexKeyRemapForUrlChange(previous, data) : null; + if (remap) { + const confirmed = await useConfirmModalStore.getState().request({ + title: t('settings.urlRemigrationTitle'), + message: t('settings.urlRemigrationMessage', { + oldKey: remap.oldKey, + newKey: remap.newKey, + }), + confirmLabel: t('settings.urlRemigrationConfirm'), + cancelLabel: t('common.cancel'), + danger: true, + }); + if (!confirmed) return; + setConnStatus(s => ({ ...s, [id]: 'testing' })); + const result = await runIndexKeyRemigration(remap); + if (!result.ok) { + const failureKey = + result.failure.stage === 'inspect' + ? 'settings.urlRemigrationFailureInspect' + : result.failure.stage === 'run' + ? 'settings.urlRemigrationFailureRun' + : 'settings.urlRemigrationFailureCoverRename'; + showToast(t(failureKey), 8000, 'error'); + setConnStatus(s => ({ ...s, [id]: 'error' })); + return; + } + } + + const dualAddressChanged = + data.alternateUrl != null && + data.alternateUrl !== '' && + (data.alternateUrl !== previous?.alternateUrl || + data.url !== previous?.url || + data.username !== previous?.username || + data.password !== previous?.password); + + if (dualAddressChanged) { + setConnStatus(s => ({ ...s, [id]: 'testing' })); + const verify = await verifySameServerEndpoints( + { url: data.url, alternateUrl: data.alternateUrl }, + data.username, + data.password, + ); + if (!announceVerifyResult(verify)) { + setConnStatus(s => ({ ...s, [id]: 'error' })); + return; + } + } + setEditingServerId(null); auth.updateServer(id, data); + // Profile edited → any cached sticky connect URL for this id may now be + // stale (credentials may have changed, alternate may have been added). + invalidateReachableEndpointCache(id); setConnStatus(s => ({ ...s, [id]: 'testing' })); try { const ping = await pingWithCredentials(data.url, data.username, data.password); diff --git a/src/components/settings/UserForm.tsx b/src/components/settings/UserForm.tsx index 8dfb4463..6253bbab 100644 --- a/src/components/settings/UserForm.tsx +++ b/src/components/settings/UserForm.tsx @@ -6,8 +6,10 @@ import { showToast } from '../../utils/ui/toast'; import { copyTextToClipboard, encodeServerMagicString, + magicPayloadAddressFields, } from '../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../utils/server/serverDisplayName'; +import { useAuthStore } from '../../store/authStore'; export interface UserFormState { userName: string; @@ -104,8 +106,12 @@ export function UserForm({ } finally { setMagicGenBusy(false); } + const addressFields = magicPayloadAddressFields( + shareServerUrl.trim(), + useAuthStore.getState().servers, + ); const str = encodeServerMagicString({ - url: shareServerUrl.trim(), + ...addressFields, username: form.userName.trim(), password: form.password, name: shortHostFromServerUrl(shareServerUrl), diff --git a/src/components/settings/userMgmt/MagicStringModal.tsx b/src/components/settings/userMgmt/MagicStringModal.tsx index 3b7cbfb3..1ed6af6a 100644 --- a/src/components/settings/userMgmt/MagicStringModal.tsx +++ b/src/components/settings/userMgmt/MagicStringModal.tsx @@ -7,8 +7,10 @@ import { showToast } from '../../../utils/ui/toast'; import { copyTextToClipboard, encodeServerMagicString, + magicPayloadAddressFields, } from '../../../utils/server/serverMagicString'; import { shortHostFromServerUrl } from '../../../utils/server/serverDisplayName'; +import { useAuthStore } from '../../../store/authStore'; interface Props { user: NdUser; @@ -63,8 +65,12 @@ export function MagicStringModal({ return; } setSubmitting(false); + const addressFields = magicPayloadAddressFields( + serverUrl, + useAuthStore.getState().servers, + ); const str = encodeServerMagicString({ - url: serverUrl, + ...addressFields, username: user.userName, password: password.trim(), name: shortHostFromServerUrl(serverUrl), diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 2fbd7253..b8fd66ee 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -134,6 +134,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)', 'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)', 'Artist detail: sort albums by year (newest/oldest) in the Albums section (PR #877)', + 'Servers: optional second address per profile (LAN + public), LAN-first connect with sticky fallback, same-server verify on save, share-link picker, URL-change library remigration, magic invite v2 (PR #880)', 'Cover art: Windows thumbnails, tier fallback, PNG decode, Subsonic coverArt id resolution (PR #878)', 'Analytics: native advanced library backfill coordinator — UI stays responsive on large libraries (PR #881)', 'Analytics: library backfill scan phase/cursor persistence so advanced indexing can finish large libraries (PR #882)', diff --git a/src/cover/diskSrcCache.test.ts b/src/cover/diskSrcCache.test.ts index 2d122c39..6303138d 100644 --- a/src/cover/diskSrcCache.test.ts +++ b/src/cover/diskSrcCache.test.ts @@ -6,7 +6,14 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { convertFileSrc } from '@tauri-apps/api/core'; -import { clearAllDiskSrcCache, coverDiskUrl, getDiskSrc, rememberDiskSrc } from './diskSrcCache'; +import { + clearAllDiskSrcCache, + coverDiskUrl, + forgetDiskSrcForServer, + forgetDiskSrcPrefix, + getDiskSrc, + rememberDiskSrc, +} from './diskSrcCache'; describe('coverDiskUrl', () => { beforeEach(() => { @@ -56,3 +63,69 @@ describe('rememberDiskSrc', () => { expect(getDiskSrc('srv:cover:al-1:128')).toBe(''); }); }); + +const serverScopeA = { + kind: 'server' as const, + serverId: 'profile-a', + url: 'http://srv-a', + username: 'u', + password: 'p', +}; + +describe('forgetDiskSrcForServer', () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockImplementation((p: string) => + `asset://localhost/${encodeURIComponent(p)}`, + ); + clearAllDiskSrcCache(); + }); + + it('drops every cached entry under the given server index key', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + rememberDiskSrc('srv-a:cover:album:al-1:512', '/disk/a/al-1/512.webp'); + rememberDiskSrc('srv-a:cover:album:al-2:128', '/disk/a/al-2/128.webp'); + rememberDiskSrc('srv-b:cover:album:al-1:128', '/disk/b/al-1/128.webp'); + + forgetDiskSrcForServer('srv-a'); + + expect(getDiskSrc('srv-a:cover:album:al-1:128')).toBe(''); + expect(getDiskSrc('srv-a:cover:album:al-1:512')).toBe(''); + expect(getDiskSrc('srv-a:cover:album:al-2:128')).toBe(''); + // Other servers untouched — this is the URL-change remigration path, + // not a global purge. + expect(getDiskSrc('srv-b:cover:album:al-1:128')).not.toBe(''); + }); + + it('is a no-op on an empty key (defensive)', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + forgetDiskSrcForServer(''); + expect(getDiskSrc('srv-a:cover:album:al-1:128')).not.toBe(''); + }); + + it('is a no-op when nothing matches the prefix', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + forgetDiskSrcForServer('srv-missing'); + expect(getDiskSrc('srv-a:cover:album:al-1:128')).not.toBe(''); + }); +}); + +describe('forgetDiskSrcPrefix (regression — must not be confused with forgetDiskSrcForServer)', () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockImplementation((p: string) => + `asset://localhost/${encodeURIComponent(p)}`, + ); + clearAllDiskSrcCache(); + }); + + it('only clears the (server, cache entity) tuple', () => { + rememberDiskSrc('srv-a:cover:album:al-1:128', '/disk/a/al-1/128.webp'); + rememberDiskSrc('srv-a:cover:album:al-2:128', '/disk/a/al-2/128.webp'); + forgetDiskSrcPrefix({ + serverScope: serverScopeA, + cacheKind: 'album', + cacheEntityId: 'al-1', + }); + expect(getDiskSrc('srv-a:cover:album:al-1:128')).toBe(''); + expect(getDiskSrc('srv-a:cover:album:al-2:128')).not.toBe(''); + }); +}); diff --git a/src/cover/diskSrcCache.ts b/src/cover/diskSrcCache.ts index 1795f96a..87706a50 100644 --- a/src/cover/diskSrcCache.ts +++ b/src/cover/diskSrcCache.ts @@ -123,6 +123,25 @@ export function forgetDiskSrcPrefix(ref: { if (changed) bumpDiskSrcCache(); } +/** + * Drop every cached disk-src under a server index key (all cover ids, all + * tiers). Used by the URL-change remigration `cover:bucket-renamed` listener + * so entries pointing at the now-renamed `{root}/{oldKey}/…` path stop + * serving stale URLs. + */ +export function forgetDiskSrcForServer(serverIndexKey: string): void { + if (!serverIndexKey) return; + const prefix = `${serverIndexKey}:cover:`; + let changed = false; + for (const key of diskSrcByStorageKey.keys()) { + if (key.startsWith(prefix)) { + diskSrcByStorageKey.delete(key); + changed = true; + } + } + if (changed) bumpDiskSrcCache(); +} + export function clearAllDiskSrcCache(): void { if (diskSrcByStorageKey.size === 0) return; diskSrcByStorageKey.clear(); diff --git a/src/cover/fetchUrl.ts b/src/cover/fetchUrl.ts index 550a8ae9..5ab587dd 100644 --- a/src/cover/fetchUrl.ts +++ b/src/cover/fetchUrl.ts @@ -4,14 +4,19 @@ import { } from '../api/subsonicStreamUrl'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { useAuthStore } from '../store/authStore'; +import { connectBaseUrlForServer } from '../utils/server/serverEndpoint'; import type { CoverArtRef, CoverArtTier } from './types'; /** Builds ephemeral getCoverArt URL — NOT a cache key */ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string { const { fetchCoverArtId, serverScope } = ref; if (serverScope.kind === 'server') { + // Scope.url is the index-stable primary URL (so storage keys keep working + // across LAN ↔ public). For the actual cover fetch we want the connect + // endpoint — use connectBaseUrlForServer to pick the cached LAN/public + // URL, falling back to the primary url when no probe has run yet. return buildCoverArtUrlForServer( - serverScope.url, + connectBaseUrlForServer({ id: serverScope.serverId, url: serverScope.url }), serverScope.username, serverScope.password, fetchCoverArtId, @@ -25,7 +30,7 @@ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): str const server = useAuthStore.getState().servers.find(s => s.id === playbackSid); if (server) { return buildCoverArtUrlForServer( - server.url, + connectBaseUrlForServer(server), server.username, server.password, fetchCoverArtId, diff --git a/src/hooks/tauriBridge/useCoverArtBridge.ts b/src/hooks/tauriBridge/useCoverArtBridge.ts index 8bd47980..116c83a4 100644 --- a/src/hooks/tauriBridge/useCoverArtBridge.ts +++ b/src/hooks/tauriBridge/useCoverArtBridge.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { listen } from '@tauri-apps/api/event'; import { clearAllDiskSrcCache, + forgetDiskSrcForServer, forgetDiskSrcPrefix, } from '../../cover/diskSrcCache'; import { rememberDiskSrcLadder } from '../../cover/diskSrcLookup'; @@ -24,6 +25,11 @@ type CoverEvictedPayload = { cacheEntityId: string; }; +type CoverBucketRenamedPayload = { + oldKey: string; + newKey: string; +}; + /** Rust → UI: disk `.webp` ready — do not invalidate IDB (that caused webview refetch storms). */ export function useCoverArtBridge(): void { useEffect(() => { @@ -57,6 +63,17 @@ export function useCoverArtBridge(): void { } }), ); + unsubs.push( + await listen('cover:bucket-renamed', ev => { + // URL-change remigration moved the disk bucket from oldKey to newKey + // (cover_cache_rename_server_bucket). Every in-memory disk-src cache + // entry tagged under oldKey now points at a path that no longer + // exists — drop them so the next read re-resolves under newKey via + // the normal getDiskSrcForGrid path. + if (!ev.payload?.oldKey) return; + forgetDiskSrcForServer(ev.payload.oldKey); + }), + ); })(); return () => { for (const u of unsubs) u(); diff --git a/src/hooks/useConnectionStatus.test.ts b/src/hooks/useConnectionStatus.test.ts new file mode 100644 index 00000000..abebee3c --- /dev/null +++ b/src/hooks/useConnectionStatus.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { resetAuthStore } from '@/test/helpers/storeReset'; +import { useAuthStore } from '@/store/authStore'; +import { + invalidateReachableEndpointCache, + type PickReachableResult, +} from '@/utils/server/serverEndpoint'; + +vi.mock('@/api/subsonic', () => ({ + pingWithCredentials: vi.fn(), + scheduleInstantMixProbeForServer: vi.fn(), +})); + +vi.mock('@/utils/perf/perfFlags', () => ({ + usePerfProbeFlags: () => ({ disableBackgroundPolling: false }), +})); + +import { pingWithCredentials } from '@/api/subsonic'; +import { useConnectionStatus } from './useConnectionStatus'; + +beforeEach(() => { + resetAuthStore(); + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); +}); + +function seedDualAddressServer(): string { + const id = useAuthStore.getState().addServer({ + name: 'Home', + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + username: 'tester', + password: 'pw', + }); + useAuthStore.getState().setActiveServer(id); + return id; +} + +describe('useConnectionStatus.isLan', () => { + it('reports the active endpoint kind after a probe, not the primary URL kind', async () => { + seedDualAddressServer(); + // LAN endpoint answers — alternateUrl is the LAN side here, so a + // primary-url-only check would say "public". We assert it says "local" + // (active endpoint kind). + vi.mocked(pingWithCredentials).mockImplementation(async url => + url === 'http://192.168.0.10' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + expect(result.current.isLan).toBe(true); + }); + + it('falls back to public when only the public address answers', async () => { + seedDualAddressServer(); + vi.mocked(pingWithCredentials).mockImplementation(async url => + url === 'https://music.example.com' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + // primary url is `https://music.example.com` — public. isLanUrl alone + // would have said `false` for the wrong reason (because the primary + // happens to be public); the test is meaningful because the LAN side + // was probed first and refused, so `activeEndpointKind` actively + // reflects "public". + expect(result.current.isLan).toBe(false); + }); + + it('falls back to primary URL classification before the first probe completes', () => { + seedDualAddressServer(); + // Don't resolve the ping — the hook is still in the `checking` state. + let _resolve: ((v: PickReachableResult) => void) | null = null; + vi.mocked(pingWithCredentials).mockReturnValue( + new Promise(r => { + _resolve = ((res: PickReachableResult) => { + if (res.ok) { + r({ + ok: true, + type: res.ping.type, + serverVersion: res.ping.serverVersion, + openSubsonic: res.ping.openSubsonic, + }); + } else { + r({ ok: false }); + } + }) as never; + }), + ); + + const { result } = renderHook(() => useConnectionStatus()); + // Before the probe completes, isLan reflects the primary URL — public + // here, so false. + expect(result.current.isLan).toBe(false); + }); +}); + +describe('useConnectionStatus online event', () => { + it('flushes the reachable-endpoint cache when the browser fires online', async () => { + seedDualAddressServer(); + // Initial probe: LAN answers. + vi.mocked(pingWithCredentials).mockImplementation(async url => + url === 'http://192.168.0.10' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + + // Now flip: LAN goes dark, only public answers. The 120 s tick won't + // fire in this test; we trigger the online event instead. The handler + // invalidates the sticky cache so the next probe goes LAN-first and + // flips over to public when LAN refuses. + vi.mocked(pingWithCredentials).mockClear(); + vi.mocked(pingWithCredentials).mockImplementation(async url => + url === 'https://music.example.com' + ? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true } + : { ok: false }, + ); + + await act(async () => { + window.dispatchEvent(new Event('online')); + }); + + await waitFor(() => expect(result.current.isLan).toBe(false)); + // Both endpoints were probed (LAN refused, public answered). + expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index 4c9f99ff..5e1b07fd 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -1,31 +1,29 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useAuthStore } from '../store/authStore'; -import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; +import { scheduleInstantMixProbeForServer } from '../api/subsonic'; import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; +import { + ensureConnectUrlResolved, + invalidateReachableEndpointCache, + isLanUrl, + type ServerEndpointKind, +} from '../utils/server/serverEndpoint'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; -export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; +// Backward-compatible re-export for call sites that still import from the hook. +export { isLanUrl }; -export function isLanUrl(url: string): boolean { - try { - const hostname = new URL(url.startsWith('http') ? url : `http://${url}`).hostname; - return ( - hostname === 'localhost' || - hostname.endsWith('.local') || - /^127\./.test(hostname) || - /^10\./.test(hostname) || - /^192\.168\./.test(hostname) || - /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) - ); - } catch { - return false; - } -} +export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; export function useConnectionStatus() { const perfFlags = usePerfProbeFlags(); const [status, setStatus] = useState('checking'); const [isRetrying, setIsRetrying] = useState(false); + // Tracks the kind of endpoint the last successful probe answered on so the + // badge reflects the *active* connection, not just whatever the user typed + // as the primary URL. A LAN-tagged primary that has fallen over to its + // public alternate must read as 'public', not 'local'. + const [activeEndpointKind, setActiveEndpointKind] = useState(null); const intervalRef = useRef | null>(null); const check = useCallback(async () => { @@ -40,24 +38,35 @@ export function useConnectionStatus() { return; } - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (ping.ok) { + // Dual-address: probe LAN-first via the shared cache. On every poll the + // sticky entry is tried first; on failure the full sequence runs and the + // cache flips to whichever endpoint actually answers — so a laptop moving + // off WiFi smoothly transitions from LAN to public without a manual retry. + const probe = await ensureConnectUrlResolved(server); + if (probe.ok) { const sid = useAuthStore.getState().activeServerId; if (sid) { const identity = { - type: ping.type, - serverVersion: ping.serverVersion, - openSubsonic: ping.openSubsonic, + type: probe.ping.type, + serverVersion: probe.ping.serverVersion, + openSubsonic: probe.ping.openSubsonic, }; useAuthStore.getState().setSubsonicServerIdentity(sid, identity); - scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity); + scheduleInstantMixProbeForServer(sid, probe.baseUrl, server.username, server.password, identity); } + setActiveEndpointKind(probe.endpoint.kind); + } else { + setActiveEndpointKind(null); } - setStatus(ping.ok ? 'connected' : 'disconnected'); + setStatus(probe.ok ? 'connected' : 'disconnected'); }, []); const retry = useCallback(async () => { setIsRetrying(true); + // Manual retry: drop the sticky cache so the next probe starts in the + // natural LAN-first order instead of revalidating whatever last worked. + const sid = useAuthStore.getState().activeServerId; + if (sid) invalidateReachableEndpointCache(sid); await check(); setIsRetrying(false); }, [check]); @@ -74,7 +83,13 @@ export function useConnectionStatus() { check(); intervalRef.current = setInterval(check, 120_000); - const handleOnline = () => check(); + const handleOnline = () => { + // Network just came back — the sticky entry is from a different network + // moment and may be wrong. Flush, then re-probe LAN-first. + const sid = useAuthStore.getState().activeServerId; + if (sid) invalidateReachableEndpointCache(sid); + check(); + }; const handleOffline = () => setStatus('disconnected'); window.addEventListener('online', handleOnline); @@ -98,7 +113,16 @@ export function useConnectionStatus() { status, isRetrying, retry, - isLan: server ? isLanUrl(server.url) : false, + // Active endpoint kind preferred; until the first probe completes we + // fall back to the primary url's classification so the badge has + // *something* to render at mount time. Once a probe has resolved, + // `activeEndpointKind` is the source of truth. + isLan: + activeEndpointKind !== null + ? activeEndpointKind === 'local' + : server + ? isLanUrl(server.url) + : false, serverName, }; } diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 444c33f0..b67db267 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Server-Name', serverUrl: 'Server-URL', serverUrlPlaceholder: '192.168.1.100:4533 oder https://music.example.com', + serverAlternateUrl: 'Zweite Adresse (optional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Du kannst eine öffentliche Adresse hinzufügen, um außerhalb deines Heimnetzwerks zuzugreifen.', + serverAlternateUrlHintAddLocal: 'Du kannst eine lokale Adresse hinzufügen, um zu Hause schneller zu verbinden.', + serverBothLanError: 'Beide Adressen sind lokal — eine entfernen oder eine öffentliche hinzufügen.', + dualAddressVerifying: 'Prüfe, ob beide Adressen denselben Server meinen…', + dualAddressMismatch: 'Die beiden Adressen zeigen auf unterschiedliche Server. Schreibweise prüfen und erneut versuchen.', + dualAddressInsufficient: 'Konnte nicht bestätigen, dass beide Adressen derselbe Server sind. Entferne eine Adresse, um fortzufahren.', + dualAddressUnreachable: 'Konnte {{host}} nicht erreichen. Adresse und Netzwerk prüfen, dann erneut versuchen.', + urlRemigrationTitle: 'Bibliotheksdaten verschieben?', + urlRemigrationMessage: 'Beim Ändern der Adresse werden alle Bibliotheks-, Analyse- und Cover-Cache-Daten von {{oldKey}} nach {{newKey}} verschoben. Das kann nicht rückgängig gemacht werden.', + urlRemigrationConfirm: 'Daten verschieben', + urlRemigrationProgress: 'Bibliotheksdaten werden verschoben…', + urlRemigrationFailureInspect: 'Konnte nicht prüfen, was verschoben würde. Erneut versuchen oder das Debug-Log öffnen.', + urlRemigrationFailureRun: 'Verschieben der Bibliotheksdaten ist fehlgeschlagen. Deine gespeicherten Daten sind unverändert — erneut versuchen.', + urlRemigrationFailureCoverRename: 'Bibliotheksdaten wurden verschoben, aber Cover-Dateien konnten nicht umbenannt werden. Cover werden beim nächsten Zugriff neu aufgebaut.', + shareUsesLocalUrl: 'Lokale Adresse in Freigabe-Links verwenden', + shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.', serverUsername: 'Benutzername', serverPassword: 'Passwort', addServer: 'Server hinzufügen', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index eb4e42eb..440efee8 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Server Name', serverUrl: 'Server URL', serverUrlPlaceholder: '192.168.1.100:4533 or https://music.example.com', + serverAlternateUrl: 'Second address (optional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'You can add a public address for access outside your home network.', + serverAlternateUrlHintAddLocal: 'You can add a local address for faster connection at home.', + serverBothLanError: 'Both addresses are local — keep one or add a public address.', + dualAddressVerifying: 'Checking that both addresses point at the same server…', + dualAddressMismatch: 'The two addresses point at different servers. Check spelling and try again.', + dualAddressInsufficient: 'Could not confirm both addresses are the same server. Remove one address to continue.', + dualAddressUnreachable: 'Could not reach {{host}}. Check the address and your network, then try again.', + urlRemigrationTitle: 'Move your library data?', + urlRemigrationMessage: 'Changing the address will move all library, analysis, and cover-cache data from {{oldKey}} to {{newKey}}. This cannot be undone.', + urlRemigrationConfirm: 'Move data', + urlRemigrationProgress: 'Moving library data…', + urlRemigrationFailureInspect: 'Could not check what would be moved. Try again, or open the debug log.', + urlRemigrationFailureRun: 'Moving the library data failed. Your saved data is unchanged — try again.', + urlRemigrationFailureCoverRename: 'Library data was moved, but cover files could not be renamed. Covers will rebuild on next access.', + shareUsesLocalUrl: 'Use local address in share links', + shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.', serverUsername: 'Username', serverPassword: 'Password', addServer: 'Add Server', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 4e1d3e25..87371d2a 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -18,6 +18,25 @@ export const settings = { 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', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index d7679552..eafaf63b 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Nom du serveur', serverUrl: 'URL du serveur', serverUrlPlaceholder: '192.168.1.100:4533 ou https://music.exemple.com', + serverAlternateUrl: 'Seconde adresse (optionnelle)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Vous pouvez ajouter une adresse publique pour y accéder en dehors de votre réseau domestique.', + serverAlternateUrlHintAddLocal: 'Vous pouvez ajouter une adresse locale pour une connexion plus rapide à domicile.', + serverBothLanError: 'Les deux adresses sont locales — conservez-en une ou ajoutez une adresse publique.', + dualAddressVerifying: 'Vérification que les deux adresses pointent vers le même serveur…', + dualAddressMismatch: 'Les deux adresses pointent vers des serveurs différents. Vérifiez l’orthographe et réessayez.', + dualAddressInsufficient: 'Impossible de confirmer que les deux adresses correspondent au même serveur. Supprimez une adresse pour continuer.', + dualAddressUnreachable: 'Impossible de joindre {{host}}. Vérifiez l’adresse et votre réseau, puis réessayez.', + urlRemigrationTitle: 'Déplacer les données de la bibliothèque ?', + urlRemigrationMessage: 'Changer l’adresse déplacera toutes les données de la bibliothèque, de l’analyse et du cache de couvertures de {{oldKey}} vers {{newKey}}. Cette action est irréversible.', + urlRemigrationConfirm: 'Déplacer les données', + urlRemigrationProgress: 'Déplacement des données de la bibliothèque…', + urlRemigrationFailureInspect: 'Impossible de vérifier ce qui serait déplacé. Réessayez ou ouvrez le journal de débogage.', + urlRemigrationFailureRun: 'Le déplacement des données de la bibliothèque a échoué. Vos données enregistrées sont intactes — réessayez.', + urlRemigrationFailureCoverRename: 'Les données de la bibliothèque ont été déplacées, mais les fichiers de couverture n’ont pas pu être renommés. Les couvertures seront reconstruites au prochain accès.', + shareUsesLocalUrl: 'Utiliser l’adresse locale dans les liens de partage', + shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.', serverUsername: 'Nom d\'utilisateur', serverPassword: 'Mot de passe', addServer: 'Ajouter un serveur', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 28ebdb5a..aa6ffa82 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -18,6 +18,25 @@ export const settings = { 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', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 485cc25e..1de9b202 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Servernaam', serverUrl: 'Server-URL', serverUrlPlaceholder: '192.168.1.100:4533 of https://music.voorbeeld.nl', + serverAlternateUrl: 'Tweede adres (optioneel)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Voeg een openbaar adres toe om buiten je thuisnetwerk te verbinden.', + serverAlternateUrlHintAddLocal: 'Voeg een lokaal adres toe voor een snellere verbinding thuis.', + serverBothLanError: 'Beide adressen zijn lokaal — houd er één of voeg een openbaar adres toe.', + dualAddressVerifying: 'Controleren of beide adressen naar dezelfde server wijzen…', + dualAddressMismatch: 'De twee adressen wijzen naar verschillende servers. Controleer de spelling en probeer opnieuw.', + dualAddressInsufficient: 'Kon niet bevestigen dat beide adressen dezelfde server zijn. Verwijder één adres om door te gaan.', + dualAddressUnreachable: 'Kon {{host}} niet bereiken. Controleer het adres en je netwerk en probeer opnieuw.', + urlRemigrationTitle: 'Bibliotheekgegevens verplaatsen?', + urlRemigrationMessage: 'Bij het wijzigen van het adres worden alle bibliotheek-, analyse- en cover-cachegegevens van {{oldKey}} naar {{newKey}} verplaatst. Dit kan niet ongedaan gemaakt worden.', + urlRemigrationConfirm: 'Gegevens verplaatsen', + urlRemigrationProgress: 'Bibliotheekgegevens worden verplaatst…', + urlRemigrationFailureInspect: 'Kon niet controleren wat verplaatst zou worden. Probeer opnieuw of open het foutopsporingslogboek.', + urlRemigrationFailureRun: 'Verplaatsen van bibliotheekgegevens mislukt. Je opgeslagen gegevens zijn ongewijzigd — probeer opnieuw.', + urlRemigrationFailureCoverRename: 'Bibliotheekgegevens zijn verplaatst, maar coverbestanden konden niet worden hernoemd. Covers worden bij het volgende gebruik opnieuw opgebouwd.', + shareUsesLocalUrl: 'Lokaal adres gebruiken in deellinks', + shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.', serverUsername: 'Gebruikersnaam', serverPassword: 'Wachtwoord', addServer: 'Server toevoegen', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 154b8be5..7706d887 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Nume Server', serverUrl: 'URL Server', serverUrlPlaceholder: '192.168.1.100:4533 sau https://music.example.com', + serverAlternateUrl: 'A doua adresă (opțional)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Poți adăuga o adresă publică pentru a te conecta din afara rețelei tale de acasă.', + serverAlternateUrlHintAddLocal: 'Poți adăuga o adresă locală pentru o conexiune mai rapidă acasă.', + serverBothLanError: 'Ambele adrese sunt locale — păstrează una sau adaugă o adresă publică.', + dualAddressVerifying: 'Verificăm dacă ambele adrese duc la același server…', + dualAddressMismatch: 'Cele două adrese duc la servere diferite. Verifică ortografia și încearcă din nou.', + dualAddressInsufficient: 'Nu am putut confirma că ambele adrese sunt același server. Elimină o adresă pentru a continua.', + dualAddressUnreachable: 'Nu am putut contacta {{host}}. Verifică adresa și rețeaua și încearcă din nou.', + urlRemigrationTitle: 'Muți datele bibliotecii?', + urlRemigrationMessage: 'Schimbarea adresei va muta toate datele bibliotecii, analizei și ale cache-ului de coperți de la {{oldKey}} la {{newKey}}. Această acțiune nu poate fi anulată.', + urlRemigrationConfirm: 'Mută datele', + urlRemigrationProgress: 'Mutăm datele bibliotecii…', + urlRemigrationFailureInspect: 'Nu am putut verifica ce ar fi mutat. Încearcă din nou sau deschide jurnalul de depanare.', + urlRemigrationFailureRun: 'Mutarea datelor bibliotecii a eșuat. Datele salvate sunt neschimbate — încearcă din nou.', + urlRemigrationFailureCoverRename: 'Datele bibliotecii au fost mutate, dar fișierele de copertă nu au putut fi redenumite. Coperțile vor fi reconstruite la următorul acces.', + shareUsesLocalUrl: 'Folosește adresa locală în linkurile de partajare', + shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.', serverUsername: 'Nume utilizator', serverPassword: 'Parolă', addServer: 'Adaugă Server', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 689e19be..91494eb9 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: 'Название', serverUrl: 'Адрес', serverUrlPlaceholder: '192.168.1.100:4533 или https://music.example.com', + serverAlternateUrl: 'Второй адрес (необязательно)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: 'Добавьте публичный адрес, чтобы подключаться вне домашней сети.', + serverAlternateUrlHintAddLocal: 'Добавьте локальный адрес для более быстрой связи дома.', + serverBothLanError: 'Оба адреса локальные — оставьте один или добавьте публичный.', + dualAddressVerifying: 'Проверяем, что оба адреса указывают на один и тот же сервер…', + dualAddressMismatch: 'Адреса ведут к разным серверам. Проверьте написание и попробуйте снова.', + dualAddressInsufficient: 'Не удалось убедиться, что это один сервер. Удалите один адрес, чтобы продолжить.', + dualAddressUnreachable: 'Не удалось достучаться до {{host}}. Проверьте адрес и сеть, затем попробуйте снова.', + urlRemigrationTitle: 'Переместить данные библиотеки?', + urlRemigrationMessage: 'Смена адреса перенесёт все данные библиотеки, анализа и кэша обложек с {{oldKey}} на {{newKey}}. Это нельзя отменить.', + urlRemigrationConfirm: 'Переместить данные', + urlRemigrationProgress: 'Перемещаем данные библиотеки…', + urlRemigrationFailureInspect: 'Не удалось проверить, что нужно перенести. Попробуйте снова или откройте журнал отладки.', + urlRemigrationFailureRun: 'Не удалось перенести данные библиотеки. Сохранённые данные не изменены — попробуйте снова.', + urlRemigrationFailureCoverRename: 'Данные библиотеки перенесены, но файлы обложек не удалось переименовать. Обложки восстановятся при следующем обращении.', + shareUsesLocalUrl: 'Использовать локальный адрес в ссылках общего доступа', + shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.', serverUsername: 'Логин', serverPassword: 'Пароль', addServer: 'Добавить сервер', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index bc1f6261..704b73ee 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -18,6 +18,25 @@ export const settings = { serverName: '服务器名称', serverUrl: '服务器地址', serverUrlPlaceholder: '192.168.1.100:4533 或 https://music.example.com', + serverAlternateUrl: '备用地址(可选)', + serverAlternateUrlPlaceholderPublic: 'https://music.example.com', + serverAlternateUrlPlaceholderLocal: '192.168.1.100:4533', + serverAlternateUrlHintAddPublic: '可以添加一个公共地址,便于在家庭网络之外访问。', + serverAlternateUrlHintAddLocal: '可以添加一个本地地址,在家中连接更快。', + serverBothLanError: '两个地址都是本地地址——保留一个或添加一个公共地址。', + dualAddressVerifying: '正在确认两个地址指向同一台服务器…', + dualAddressMismatch: '两个地址指向不同的服务器。请检查拼写后重试。', + dualAddressInsufficient: '无法确认两个地址是同一台服务器。请删除一个地址以继续。', + dualAddressUnreachable: '无法连接到 {{host}}。请检查地址和网络后重试。', + urlRemigrationTitle: '移动你的资料库数据?', + urlRemigrationMessage: '更改地址将把所有资料库、分析和封面缓存数据从 {{oldKey}} 移动到 {{newKey}}。此操作无法撤销。', + urlRemigrationConfirm: '移动数据', + urlRemigrationProgress: '正在移动资料库数据…', + urlRemigrationFailureInspect: '无法检查将要移动的内容。请重试或打开调试日志。', + urlRemigrationFailureRun: '资料库数据移动失败。已保存的数据保持不变——请重试。', + urlRemigrationFailureCoverRename: '资料库数据已移动,但封面文件无法重命名。封面将在下次访问时重新生成。', + shareUsesLocalUrl: '在分享链接中使用本地地址', + shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。', serverUsername: '用户名', serverPassword: '密码', addServer: '添加服务器', diff --git a/src/pages/Login.test.tsx b/src/pages/Login.test.tsx new file mode 100644 index 00000000..109e5f85 --- /dev/null +++ b/src/pages/Login.test.tsx @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import { resetAuthStore } from '@/test/helpers/storeReset'; +import { useAuthStore } from '@/store/authStore'; +import { encodeServerMagicString } from '@/utils/server/serverMagicString'; + +vi.mock('@/api/subsonic', () => ({ + pingWithCredentials: vi.fn(async () => ({ + ok: true, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + })), + scheduleInstantMixProbeForServer: vi.fn(), +})); + +import { pingWithCredentials } from '@/api/subsonic'; + +beforeEach(() => { + resetAuthStore(); + vi.mocked(pingWithCredentials).mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Login — v2 magic string paste persistence', () => { + it('persists alternateUrl + shareUsesLocalUrl from a pasted v2 invite', async () => { + const Login = (await import('./Login')).default; + renderWithProviders(); + const user = userEvent.setup(); + + const magicString = encodeServerMagicString({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + shareUsesLocalUrl: true, + username: 'tester', + password: 'pw', + }); + + // Find the magic-string input by its known label/placeholder. The login + // form has multiple inputs; we use the displayed-value-after-paste path + // to keep the test stable across UI label tweaks. + const allTextboxes = screen.getAllByRole('textbox'); + // The magic-string input is the one whose change handler decodes and + // prefills the rest — paste into it and the form auto-populates. + const magicInput = allTextboxes[allTextboxes.length - 1]!; + await user.type(magicInput, magicString); + + // Submit the form via the Connect button (label "Connect" in en locale). + const submit = screen.getByRole('button', { name: /connect/i }); + await user.click(submit); + + await waitFor(() => { + expect(useAuthStore.getState().servers.length).toBeGreaterThan(0); + }); + + const saved = useAuthStore.getState().servers[0]!; + expect(saved.url).toBe('https://music.example.com'); + expect(saved.alternateUrl).toBe('http://192.168.0.10:4533'); + expect(saved.shareUsesLocalUrl).toBe(true); + expect(saved.username).toBe('tester'); + }); + + it('persists a v1 invite as a single-address profile (no alternateUrl)', async () => { + const Login = (await import('./Login')).default; + renderWithProviders(); + const user = userEvent.setup(); + + const v1MagicString = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'tester', + password: 'pw', + }); + + const allTextboxes = screen.getAllByRole('textbox'); + const magicInput = allTextboxes[allTextboxes.length - 1]!; + await user.type(magicInput, v1MagicString); + + const submit = screen.getByRole('button', { name: /connect/i }); + await user.click(submit); + + await waitFor(() => { + expect(useAuthStore.getState().servers.length).toBeGreaterThan(0); + }); + + const saved = useAuthStore.getState().servers[0]!; + expect(saved.url).toBe('https://music.example.com'); + // v1 invite — neither dual-address field should appear on the saved + // profile so localStorage doesn't carry dangling defaults. + expect(saved.alternateUrl).toBeUndefined(); + expect(saved.shareUsesLocalUrl).toBeUndefined(); + }); +}); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 173a4c42..dcdce10c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -24,7 +24,18 @@ export default function Login() { const { t } = useTranslation(); const { addServer, updateServer, setActiveServer, setLoggedIn, setConnecting, setConnectionError, servers } = useAuthStore(); - const [form, setForm] = useState({ serverName: '', url: '', username: '', password: '' }); + // alternateUrl / shareUsesLocalUrl are not user-editable on this page (Login + // stays single-address by design); they're populated only when a v2 magic + // string is decoded so the dual-address shape persists straight from the + // invite onto the saved profile. + const [form, setForm] = useState({ + serverName: '', + url: '', + username: '', + password: '', + alternateUrl: '' as string, + shareUsesLocalUrl: false, + }); const [magicString, setMagicString] = useState(''); const [showPass, setShowPass] = useState(false); /** After a valid magic string decode, do not allow revealing the password in the UI. */ @@ -42,6 +53,8 @@ export default function Login() { url: inv.url, username: inv.username, password: inv.password, + alternateUrl: inv.alternateUrl ?? '', + shareUsesLocalUrl: inv.shareUsesLocalUrl ?? false, }); setMagicString(encodeServerMagicString(inv)); navigate('/login', { replace: true, state: {} }); @@ -63,6 +76,8 @@ export default function Login() { url: decoded.url, username: decoded.username, password: decoded.password, + alternateUrl: decoded.alternateUrl ?? '', + shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false, }); if (status === 'error') { setStatus('idle'); @@ -71,7 +86,14 @@ export default function Login() { } }; - const attemptConnect = async (profile: { name: string; url: string; username: string; password: string }) => { + const attemptConnect = async (profile: { + name: string; + url: string; + username: string; + password: string; + alternateUrl?: string; + shareUsesLocalUrl?: boolean; + }) => { if (!profile.url.trim()) { setTestMessage(t('login.urlRequired')); setStatus('error'); @@ -97,11 +119,21 @@ export default function Login() { if (ping.ok) { // Connection succeeded — now persist to store const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim()); + // Dual-address fields persist straight off a v2 magic invite even + // though Login itself never shows the second-address field. The + // user can edit/remove them later via Settings → Servers. + const altTrimmed = profile.alternateUrl?.trim() ?? ''; let serverId: string; if (existing) { updateServer(existing.id, { name: profile.name.trim() || profile.url.trim(), password: profile.password, + ...(altTrimmed + ? { + alternateUrl: altTrimmed, + shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false, + } + : {}), }); serverId = existing.id; } else { @@ -110,6 +142,12 @@ export default function Login() { url: profile.url.trim(), username: profile.username.trim(), password: profile.password, + ...(altTrimmed + ? { + alternateUrl: altTrimmed, + shareUsesLocalUrl: profile.shareUsesLocalUrl ?? false, + } + : {}), }); } const identity = { @@ -152,18 +190,41 @@ export default function Login() { url: decoded.url, username: decoded.username, password: decoded.password, + alternateUrl: decoded.alternateUrl, + shareUsesLocalUrl: decoded.shareUsesLocalUrl, }); return; } - await attemptConnect({ name: form.serverName, url: form.url, username: form.username, password: form.password }); + await attemptConnect({ + name: form.serverName, + url: form.url, + username: form.username, + password: form.password, + alternateUrl: form.alternateUrl, + shareUsesLocalUrl: form.shareUsesLocalUrl, + }); }; const handleQuickConnect = async (srv: typeof servers[0]) => { setMagicString(''); setBlockPasswordReveal(false); setShowPass(false); - setForm({ serverName: srv.name, url: srv.url, username: srv.username, password: srv.password }); - await attemptConnect({ name: srv.name, url: srv.url, username: srv.username, password: srv.password }); + setForm({ + serverName: srv.name, + url: srv.url, + username: srv.username, + password: srv.password, + alternateUrl: srv.alternateUrl ?? '', + shareUsesLocalUrl: srv.shareUsesLocalUrl ?? false, + }); + await attemptConnect({ + name: srv.name, + url: srv.url, + username: srv.username, + password: srv.password, + alternateUrl: srv.alternateUrl, + shareUsesLocalUrl: srv.shareUsesLocalUrl, + }); }; return ( diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 4e0dbcfb..16d52e2a 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -21,6 +21,8 @@ import { } from './authStoreDefaults'; import { computeAuthStoreRehydration } from './authStoreRehydrate'; import type { AuthState } from './authStoreTypes'; +import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint'; +import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl'; @@ -142,8 +144,13 @@ export const useAuthStore = create()( const s = get(); const server = s.servers.find(srv => srv.id === s.activeServerId); if (!server?.url) return ''; - const base = server.url.startsWith('http') ? server.url : `http://${server.url}`; - return base.replace(/\/$/, ''); + // Dual-address: read the runtime-probed connect URL from the + // serverEndpoint cache. `null` (no probe yet — first boot, switch + // happening right now) falls back to the normalized primary URL so + // callers running before the first probe still get a usable base. + const cached = getCachedConnectBaseUrl(server.id); + if (cached) return cached; + return serverProfileBaseUrl({ url: server.url }); }, getActiveServer: () => { diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 3701c7a3..6d840a26 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -7,7 +7,25 @@ import type { export interface ServerProfile { id: string; name: string; + /** + * Primary address. **Canonical source of the index key** — adding or changing + * `alternateUrl` never touches library/cover/analysis storage. Only editing + * `url` (host/port/path) triggers an index-key remigration. + */ url: string; + /** + * Optional second address (typically a LAN counterpart of a public `url`, or + * vice versa). Used by the connect layer as a sequential fallback and by the + * share layer when `shareUsesLocalUrl` flips. Never participates in the index + * key. + */ + alternateUrl?: string; + /** + * When both `url` and `alternateUrl` are set, controls which one is embedded + * in Orbit / entity / magic-string shares. Default behaviour (absent / false) + * is to prefer the **public** address. + */ + shareUsesLocalUrl?: boolean; username: string; password: string; } diff --git a/src/utils/library/librarySession.ts b/src/utils/library/librarySession.ts index 71d4a424..e0560f6e 100644 --- a/src/utils/library/librarySession.ts +++ b/src/utils/library/librarySession.ts @@ -3,11 +3,10 @@ import { librarySyncBindSession, } from '../../api/library'; import { enqueueLibrarySync, queueInitialSyncIfNeeded } from './librarySyncQueue'; -import { pingWithCredentials } from '../../api/subsonic'; import type { ServerProfile } from '../../store/authStoreTypes'; import { useAuthStore } from '../../store/authStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; -import { serverProfileBaseUrl } from '../server/serverBaseUrl'; +import { ensureConnectUrlResolved } from '../server/serverEndpoint'; import { serverIndexKeyForProfile } from '../server/serverIndexKey'; import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog'; @@ -18,15 +17,14 @@ export type BindServerResult = 'bound' | 'offline' | 'error'; */ export async function bindIndexedServer(server: ServerProfile): Promise { if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return 'error'; - const baseUrl = serverProfileBaseUrl(server); - if (!baseUrl) return 'error'; - try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (!ping.ok) return 'offline'; - } catch { - return 'offline'; - } + // Dual-address: resolve the connect URL once (LAN-first, sticky cached) and + // hand that to the Rust bind-session command — Rust then sees the reachable + // endpoint instead of the literal primary URL. Single-address profiles fall + // through to one ping, identical to the legacy path. + const probe = await ensureConnectUrlResolved(server); + if (!probe.ok) return 'offline'; + const baseUrl = probe.baseUrl; try { const t0 = performance.now(); diff --git a/src/utils/server/rewriteFrontendStoreKeys.test.ts b/src/utils/server/rewriteFrontendStoreKeys.test.ts new file mode 100644 index 00000000..65f967f5 --- /dev/null +++ b/src/utils/server/rewriteFrontendStoreKeys.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; +import { useCoverStrategyStore } from '../../store/coverStrategyStore'; +import { useHotCacheStore } from '../../store/hotCacheStore'; +import { useOfflineStore } from '../../store/offlineStore'; +import { usePlayerStore } from '../../store/playerStore'; +import { rewriteFrontendStoreKeysForRemap } from './rewriteFrontendStoreKeys'; + +describe('rewriteFrontendStoreKeysForRemap', () => { + beforeEach(() => { + useOfflineStore.setState({ tracks: {}, albums: {} }); + useHotCacheStore.setState({ entries: {} }); + useAnalysisStrategyStore.setState({ + strategyByServer: {}, + advancedParallelismByServer: {}, + }); + useCoverStrategyStore.setState({ strategyByServer: {} }); + usePlayerStore.setState({ queueServerId: null }); + }); + + it('no-ops on empty remap list', async () => { + useOfflineStore.setState({ + tracks: { 'old:t1': { serverId: 'old' } as never }, + albums: {}, + }); + await rewriteFrontendStoreKeysForRemap([]); + expect(useOfflineStore.getState().tracks).toHaveProperty('old:t1'); + }); + + it('no-ops when oldKey === newKey', async () => { + useOfflineStore.setState({ + tracks: { 'same:t1': { serverId: 'same' } as never }, + albums: {}, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'same', newKey: 'same' }]); + expect(useOfflineStore.getState().tracks).toHaveProperty('same:t1'); + }); + + it('rewrites offline tracks + albums under the new key', async () => { + useOfflineStore.setState({ + tracks: { 'old:t1': { serverId: 'old' } as never }, + albums: { 'old:al-1': { serverId: 'old' } as never }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const state = useOfflineStore.getState(); + expect(state.tracks).toHaveProperty('new:t1'); + expect(state.tracks).not.toHaveProperty('old:t1'); + expect(state.albums).toHaveProperty('new:al-1'); + expect(state.albums).not.toHaveProperty('old:al-1'); + }); + + it('rewrites hot-cache entries under the new key', async () => { + useHotCacheStore.setState({ + entries: { 'old:t1': { trackId: 't1' } as never }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const entries = useHotCacheStore.getState().entries; + expect(entries).toHaveProperty('new:t1'); + expect(entries).not.toHaveProperty('old:t1'); + }); + + it('moves analysis strategy + advanced-parallelism entries to the new key', async () => { + useAnalysisStrategyStore.setState({ + strategyByServer: { old: 'lazy' as never }, + advancedParallelismByServer: { old: 3 }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const s = useAnalysisStrategyStore.getState(); + expect(s.strategyByServer).toHaveProperty('new'); + expect(s.strategyByServer).not.toHaveProperty('old'); + expect(s.advancedParallelismByServer.new).toBe(3); + expect(s.advancedParallelismByServer.old).toBeUndefined(); + }); + + it('moves cover strategy entries to the new key', async () => { + useCoverStrategyStore.setState({ + strategyByServer: { old: 'aggressive' as never }, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const s = useCoverStrategyStore.getState(); + expect(s.strategyByServer).toHaveProperty('new'); + expect(s.strategyByServer).not.toHaveProperty('old'); + }); + + it('repoints player queueServerId when it matches the old key', async () => { + usePlayerStore.setState({ queueServerId: 'old' }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + expect(usePlayerStore.getState().queueServerId).toBe('new'); + }); + + it('leaves queueServerId untouched when it is bound to a different server', async () => { + usePlayerStore.setState({ queueServerId: 'other' }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + expect(usePlayerStore.getState().queueServerId).toBe('other'); + }); + + it('does not clobber an existing entry under the new key', async () => { + useOfflineStore.setState({ + tracks: { + 'old:t1': { serverId: 'old', tag: 'from-old' } as never, + 'new:t1': { serverId: 'new', tag: 'from-new' } as never, + }, + albums: {}, + }); + await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]); + const tracks = useOfflineStore.getState().tracks as unknown as Record; + // Existing destination preserved — same prefer-existing semantics as + // the disk-side cover bucket merge. + expect(tracks['new:t1']?.tag).toBe('from-new'); + expect(tracks).not.toHaveProperty('old:t1'); + }); +}); diff --git a/src/utils/server/rewriteFrontendStoreKeys.ts b/src/utils/server/rewriteFrontendStoreKeys.ts index db8ab45f..9c3b5e7f 100644 --- a/src/utils/server/rewriteFrontendStoreKeys.ts +++ b/src/utils/server/rewriteFrontendStoreKeys.ts @@ -4,8 +4,15 @@ import { useCoverStrategyStore } from '../../store/coverStrategyStore'; import { useHotCacheStore } from '../../store/hotCacheStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { useOfflineStore } from '../../store/offlineStore'; +import { usePlayerStore } from '../../store/playerStore'; import { serverIndexKeyFromUrl } from './serverIndexKey'; +/** + * One `legacyId → indexKey` rewrite step. `legacyId` is whatever the keys + * used to be tagged with — the historical name reflects the very first + * migration (UUID → index key), but the same plumbing now also covers the + * URL-change remigration (oldKey → newKey). + */ type Mapping = { legacyId: string; indexKey: string }; function buildMappings(servers: ServerProfile[]): Mapping[] { @@ -111,3 +118,74 @@ export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promis useCoverStrategyStore.getState().migrateServerOverrides(servers); useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled })); } + +/** + * URL-change remigration entry point: rewrites every front-end keyed store + * for one or more explicit `oldKey → newKey` index-key remaps. Used after + * `migration_run` has re-tagged the SQLite tables (library + analysis) and + * `cover_cache_rename_server_bucket` has moved the disk bucket — without + * this step the in-memory zustand state would still point at the old keys. + * + * Player queue `queueServerId` is included here: if the queue is currently + * bound to a remapped index key, it gets re-pointed at the new one so the + * queue keeps playing through the rename instead of looking unbound. + */ +export async function rewriteFrontendStoreKeysForRemap( + remaps: ReadonlyArray<{ oldKey: string; newKey: string }>, +): Promise { + const mappings: Mapping[] = remaps + .map(r => ({ legacyId: r.oldKey.trim(), indexKey: r.newKey.trim() })) + .filter(m => m.legacyId.length > 0 && m.indexKey.length > 0 && m.legacyId !== m.indexKey); + if (mappings.length === 0) return; + + rewriteOfflineStoreKeys(mappings); + rewriteHotCacheStoreKeys(mappings); + rewriteAnalysisStrategyStoreKeys(mappings); + + // Player queue: queueServerId is a single string, not a keyed map. + const queueRemap = new Map(mappings.map(m => [m.legacyId, m.indexKey])); + usePlayerStore.setState(state => { + if (!state.queueServerId) return state; + const next = queueRemap.get(state.queueServerId); + if (!next) return state; + return { ...state, queueServerId: next }; + }); + + // The analysis/cover strategy stores carry per-server-id maps that the + // `migrateServerOverrides` helpers already handle for the UUID→indexKey + // case; for index-key→index-key we run the same map-remap path inline. + useAnalysisStrategyStore.setState(state => { + const strategyByServer = { ...state.strategyByServer }; + const advancedParallelismByServer = { ...state.advancedParallelismByServer }; + for (const { legacyId, indexKey } of mappings) { + if (strategyByServer[legacyId] !== undefined && strategyByServer[indexKey] === undefined) { + strategyByServer[indexKey] = strategyByServer[legacyId]; + } + delete strategyByServer[legacyId]; + if ( + advancedParallelismByServer[legacyId] !== undefined && + advancedParallelismByServer[indexKey] === undefined + ) { + advancedParallelismByServer[indexKey] = advancedParallelismByServer[legacyId]; + } + delete advancedParallelismByServer[legacyId]; + } + return { strategyByServer, advancedParallelismByServer }; + }); + + // Cover strategy overrides are keyed by the same index key — spec §8.2 + // lists "analysis/cover strategy maps", so remap both. Without this a + // user-set cover strategy on the old key drops silently on URL edit. + useCoverStrategyStore.setState(state => { + const strategyByServer = { ...state.strategyByServer }; + for (const { legacyId, indexKey } of mappings) { + if (strategyByServer[legacyId] !== undefined && strategyByServer[indexKey] === undefined) { + strategyByServer[indexKey] = strategyByServer[legacyId]; + } + delete strategyByServer[legacyId]; + } + return { strategyByServer }; + }); + + useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled })); +} diff --git a/src/utils/server/serverEndpoint.test.ts b/src/utils/server/serverEndpoint.test.ts new file mode 100644 index 00000000..6bdc7385 --- /dev/null +++ b/src/utils/server/serverEndpoint.test.ts @@ -0,0 +1,462 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../api/subsonic', () => ({ + pingWithCredentials: vi.fn(), +})); + +import { pingWithCredentials } from '../../api/subsonic'; +import { + allNormalizedAddresses, + ensureConnectUrlResolved, + getCachedConnectBaseUrl, + invalidateReachableEndpointCache, + isLanUrl, + normalizeServerBaseUrl, + pickReachableBaseUrl, + serverAddressEndpoints, + serverShareBaseUrl, +} from './serverEndpoint'; +import type { ServerProfile } from '../../store/authStoreTypes'; + +function makeProfile(overrides: Partial = {}): ServerProfile { + return { + id: 'profile-1', + name: 'Test', + url: 'https://music.example.com', + username: 'u', + password: 'p', + ...overrides, + }; +} + +function pingOk(overrides: Partial<{ type: string; serverVersion: string; openSubsonic: boolean }> = {}) { + return { + ok: true as const, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + ...overrides, + }; +} +function pingFail() { + return { ok: false as const }; +} + +describe('normalizeServerBaseUrl', () => { + it('strips a single trailing slash', () => { + expect(normalizeServerBaseUrl('https://music.example.com/')).toBe( + 'https://music.example.com', + ); + }); + + it('prefixes http:// for a bare host', () => { + expect(normalizeServerBaseUrl('music.example.com')).toBe('http://music.example.com'); + }); + + it('returns empty for empty input', () => { + expect(normalizeServerBaseUrl('')).toBe(''); + }); +}); + +describe('isLanUrl — IPv4', () => { + it.each([ + 'http://localhost', + 'http://localhost:4533', + 'http://musicbox.local', + 'http://127.0.0.1', + 'http://127.5.6.7', + 'http://10.0.0.5', + 'http://192.168.1.10', + 'http://172.16.0.1', + 'http://172.31.255.255', + ])('classifies %s as LAN', url => { + expect(isLanUrl(url)).toBe(true); + }); + + it.each([ + 'http://172.15.0.1', + 'http://172.32.0.1', + 'https://example.com', + 'https://music.example.com', + 'http://8.8.8.8', + ])('classifies %s as public', url => { + expect(isLanUrl(url)).toBe(false); + }); +}); + +describe('isLanUrl — IPv6', () => { + it.each([ + 'http://[::1]', + 'http://[::1]:4533', + 'http://[fe80::1]', + 'http://[fe80::abcd:1]', + 'http://[fc00::1]', + 'http://[fd12:3456:789a::1]', + 'http://[::ffff:127.0.0.1]', + 'http://[::ffff:192.168.0.1]', + ])('classifies %s as LAN', url => { + expect(isLanUrl(url)).toBe(true); + }); + + it.each([ + 'http://[2001:db8::1]', + 'http://[::ffff:8.8.8.8]', + 'http://[2606:4700:4700::1111]', + ])('classifies %s as public', url => { + expect(isLanUrl(url)).toBe(false); + }); +}); + +describe('isLanUrl — edge cases', () => { + it('handles bare hosts without scheme', () => { + expect(isLanUrl('192.168.0.1')).toBe(true); + expect(isLanUrl('example.com')).toBe(false); + }); + + it('returns false on empty / malformed', () => { + expect(isLanUrl('')).toBe(false); + expect(isLanUrl('not a url at all ')).toBe(false); + }); +}); + +describe('allNormalizedAddresses', () => { + it('returns single entry for profile with only url', () => { + expect( + allNormalizedAddresses({ url: 'https://music.example.com' }), + ).toEqual(['https://music.example.com']); + }); + + it('returns both addresses preserving order', () => { + expect( + allNormalizedAddresses({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + }), + ).toEqual(['https://music.example.com', 'http://192.168.0.10:4533']); + }); + + it('dedupes identical normalized addresses', () => { + expect( + allNormalizedAddresses({ + url: 'https://music.example.com/', + alternateUrl: 'https://music.example.com', + }), + ).toEqual(['https://music.example.com']); + }); + + it('drops empty alternateUrl', () => { + expect( + allNormalizedAddresses({ + url: 'https://music.example.com', + alternateUrl: '', + }), + ).toEqual(['https://music.example.com']); + }); +}); + +describe('serverAddressEndpoints', () => { + it('returns a single local endpoint for a LAN-only profile', () => { + expect( + serverAddressEndpoints({ url: 'http://192.168.0.10' }), + ).toEqual([{ url: 'http://192.168.0.10', kind: 'local' }]); + }); + + it('puts LAN before public when public is primary', () => { + expect( + serverAddressEndpoints({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ).toEqual([ + { url: 'http://192.168.0.10', kind: 'local' }, + { url: 'https://music.example.com', kind: 'public' }, + ]); + }); + + it('keeps LAN-first when LAN is already primary', () => { + expect( + serverAddressEndpoints({ + url: 'http://192.168.0.10', + alternateUrl: 'https://music.example.com', + }), + ).toEqual([ + { url: 'http://192.168.0.10', kind: 'local' }, + { url: 'https://music.example.com', kind: 'public' }, + ]); + }); + + it('preserves original order among endpoints of the same kind', () => { + expect( + serverAddressEndpoints({ + url: 'http://10.0.0.5', + alternateUrl: 'http://192.168.0.10', + }), + ).toEqual([ + { url: 'http://10.0.0.5', kind: 'local' }, + { url: 'http://192.168.0.10', kind: 'local' }, + ]); + }); +}); + +describe('pickReachableBaseUrl', () => { + beforeEach(() => { + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); + }); + + it('returns the single endpoint when it pings ok and caches it', async () => { + vi.mocked(pingWithCredentials).mockResolvedValue(pingOk()); + const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.baseUrl).toBe('https://music.example.com'); + expect(result.endpoint).toEqual({ url: 'https://music.example.com', kind: 'public' }); + expect(result.ping.ok).toBe(true); + expect(result.ping.type).toBe('navidrome'); + } + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + }); + + it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => { + vi.mocked(pingWithCredentials).mockResolvedValue(pingOk()); + const result = await pickReachableBaseUrl( + makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10'); + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10'); + }); + + it('falls through to the public endpoint when LAN ping fails', async () => { + vi.mocked(pingWithCredentials) + .mockResolvedValueOnce(pingFail()) + .mockResolvedValueOnce(pingOk()); + const result = await pickReachableBaseUrl( + makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); + expect(pingWithCredentials).toHaveBeenCalledTimes(2); + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + }); + + it('returns unreachable and clears cache when every endpoint fails', async () => { + vi.mocked(pingWithCredentials).mockResolvedValue(pingFail()); + // Seed a stale cache entry first. + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + + vi.mocked(pingWithCredentials).mockReset(); + vi.mocked(pingWithCredentials).mockResolvedValue(pingFail()); + const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' })); + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + expect(getCachedConnectBaseUrl('profile-1')).toBeNull(); + }); + + it('returns unreachable when the profile has no usable url', async () => { + const result = await pickReachableBaseUrl(makeProfile({ url: '' })); + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + expect(pingWithCredentials).not.toHaveBeenCalled(); + }); + + it('tries the cached endpoint first on subsequent calls (sticky)', async () => { + const profile = makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + // First call: LAN responds ok, becomes cached. + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(profile); + expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); + + // Second call: cached URL is tried first; sole ping happens against it. + vi.mocked(pingWithCredentials).mockClear(); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + const result = await pickReachableBaseUrl(profile); + expect(result.ok).toBe(true); + if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10'); + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10'); + }); + + it('dedupes concurrent calls for the same profile (single shared probe)', async () => { + // Two callers in the same tick must observe the same promise — without + // the in-flight map both would ping every endpoint and race on the + // cache write, with last-write-wins potentially clobbering the correct + // LAN sticky a millisecond after it was set. + let resolvePing: ((v: ReturnType) => void) | null = null; + vi.mocked(pingWithCredentials).mockReturnValueOnce( + new Promise(r => { + resolvePing = r; + }), + ); + const profile = makeProfile({ url: 'http://192.168.0.10' }); + const p1 = pickReachableBaseUrl(profile); + const p2 = pickReachableBaseUrl(profile); + + // Both calls saw a pending probe — only one ping should have been fired. + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + + resolvePing!(pingOk()); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.ok).toBe(true); + expect(r2.ok).toBe(true); + if (r1.ok && r2.ok) { + expect(r1.baseUrl).toBe(r2.baseUrl); + } + expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); + }); + + it('starts a fresh probe after the in-flight one settles', async () => { + // Once the previous probe resolves, the in-flight slot is freed and + // the next call hits the network again (subject to the sticky cache). + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); + + vi.mocked(pingWithCredentials).mockClear(); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' })); + expect(pingWithCredentials).toHaveBeenCalledTimes(1); + }); + + it('falls back to the natural order if the cached endpoint stops answering', async () => { + const profile = makeProfile({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await pickReachableBaseUrl(profile); + expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10'); + + // LAN now fails; public answers. + vi.mocked(pingWithCredentials).mockClear(); + vi.mocked(pingWithCredentials) + .mockResolvedValueOnce(pingFail()) + .mockResolvedValueOnce(pingOk()); + const result = await pickReachableBaseUrl(profile); + expect(result.ok).toBe(true); + if (result.ok) expect(result.baseUrl).toBe('https://music.example.com'); + expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com'); + }); +}); + +describe('invalidateReachableEndpointCache', () => { + beforeEach(() => { + invalidateReachableEndpointCache(); + vi.mocked(pingWithCredentials).mockReset(); + }); + + it('clears a specific profile', async () => { + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await ensureConnectUrlResolved(makeProfile({ id: 'a' })); + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await ensureConnectUrlResolved(makeProfile({ id: 'b' })); + expect(getCachedConnectBaseUrl('a')).not.toBeNull(); + expect(getCachedConnectBaseUrl('b')).not.toBeNull(); + + invalidateReachableEndpointCache('a'); + expect(getCachedConnectBaseUrl('a')).toBeNull(); + expect(getCachedConnectBaseUrl('b')).not.toBeNull(); + }); + + it('clears everything when called with no argument', async () => { + vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk()); + await ensureConnectUrlResolved(makeProfile({ id: 'a' })); + invalidateReachableEndpointCache(); + expect(getCachedConnectBaseUrl('a')).toBeNull(); + }); +}); + +describe('serverShareBaseUrl', () => { + it('returns the single address for a single-URL profile', () => { + expect(serverShareBaseUrl({ url: 'https://music.example.com' })).toBe( + 'https://music.example.com', + ); + }); + + it('falls back to a normalized url even when empty', () => { + // Defensive — never throws; downstream consumers tolerate the empty string. + expect(serverShareBaseUrl({ url: '' })).toBe(''); + }); + + it('prefers the public address by default when both are set', () => { + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }), + ).toBe('https://music.example.com'); + }); + + it('still prefers public when the LAN address is the primary', () => { + expect( + serverShareBaseUrl({ + url: 'http://192.168.0.10', + alternateUrl: 'https://music.example.com', + }), + ).toBe('https://music.example.com'); + }); + + it('returns the LAN address when shareUsesLocalUrl is true', () => { + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + shareUsesLocalUrl: true, + }), + ).toBe('http://192.168.0.10'); + }); + + it('falls back to the first endpoint when no LAN exists and flag is set', () => { + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'https://music-alt.example.com', + shareUsesLocalUrl: true, + }), + ).toBe('https://music.example.com'); + }); + + it('falls back to the first endpoint when both are LAN and flag is off', () => { + expect( + serverShareBaseUrl({ + url: 'http://10.0.0.5', + alternateUrl: 'http://192.168.0.10', + }), + ).toBe('http://10.0.0.5'); + }); + + it('returns the first LAN endpoint when both are LAN and flag is on', () => { + // Two LAN addresses + flag set: spec §5.1 says "local ?? endpoints[0]". + // `find(isLanUrl)` returns the first LAN, which is endpoints[0] either + // way — pin the test so future refactors don't accidentally drift. + expect( + serverShareBaseUrl({ + url: 'http://10.0.0.5', + alternateUrl: 'http://192.168.0.10', + shareUsesLocalUrl: true, + }), + ).toBe('http://10.0.0.5'); + }); + + it('returns the first endpoint when both are public and flag is off', () => { + // Reverse case: two publics, no LAN exists, flag default → publicEndpoint + // matches the first one. Identity, but locks the rule down. + expect( + serverShareBaseUrl({ + url: 'https://music.example.com', + alternateUrl: 'https://backup.example.com', + }), + ).toBe('https://music.example.com'); + }); +}); diff --git a/src/utils/server/serverEndpoint.ts b/src/utils/server/serverEndpoint.ts new file mode 100644 index 00000000..bc541474 --- /dev/null +++ b/src/utils/server/serverEndpoint.ts @@ -0,0 +1,282 @@ +import { pingWithCredentials } from '../../api/subsonic'; +import type { PingWithCredentialsResult } from '../../api/subsonicTypes'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { serverProfileBaseUrl } from './serverBaseUrl'; + +export type ServerEndpointKind = 'local' | 'public'; + +export type ServerEndpoint = { + /** Normalized base URL, no trailing slash. */ + url: string; + kind: ServerEndpointKind; +}; + +export type PickReachableResult = + | { + ok: true; + baseUrl: string; + endpoint: ServerEndpoint; + /** + * The successful ping response — exposed so callers like + * `switchActiveServer` don't need to issue a second `pingWithCredentials` + * just to read `type` / `serverVersion` / `openSubsonic`. + */ + ping: PingWithCredentialsResult; + } + | { ok: false; reason: 'unreachable' }; + +/** + * Aligned with `serverProfileBaseUrl` so connect / share / index helpers all + * agree on the canonical form of an address (`http://` default, no trailing + * slash). Exposed separately so non-profile-shaped callers can normalize a + * raw string. + */ +export function normalizeServerBaseUrl(raw: string): string { + return serverProfileBaseUrl({ url: raw }); +} + +function isIpv4LanLiteral(ip: string): boolean { + return ( + /^127\./.test(ip) || + /^10\./.test(ip) || + /^192\.168\./.test(ip) || + /^172\.(1[6-9]|2\d|3[01])\./.test(ip) + ); +} + +function isIpv6LanHostname(hostname: string): boolean { + if (hostname === '::1') return true; + // fe80::/10 — link-local (first 10 bits 1111 1110 10..) + if (/^fe[89ab][0-9a-f]:/.test(hostname)) return true; + // fc00::/7 — ULA (includes fd00::/8) + if (/^f[cd][0-9a-f]{2}:/.test(hostname)) return true; + // IPv4-mapped IPv6 — accept dot-decimal (`::ffff:1.2.3.4`, raw user input) + // and the URL-API-normalized hex form (`::ffff:HHHH:HHHH`, which `new URL` + // produces from any dot-decimal input). + const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(hostname); + if (dotted) return isIpv4LanLiteral(dotted[1]!); + const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(hostname); + if (hexMapped) { + const v1 = parseInt(hexMapped[1]!, 16); + const v2 = parseInt(hexMapped[2]!, 16); + const ipv4 = `${(v1 >> 8) & 0xff}.${v1 & 0xff}.${(v2 >> 8) & 0xff}.${v2 & 0xff}`; + return isIpv4LanLiteral(ipv4); + } + return false; +} + +/** + * True when `url`'s hostname falls in a private / link-local range, or is a + * loopback / `.local` / `localhost`. IPv4 + IPv6 (incl. IPv4-mapped). Empty / + * malformed inputs return `false`. + * + * Mirrors the prior `isLanUrl` in `useConnectionStatus.ts` for IPv4 — the + * additions are the IPv6 cases. UI hints, endpoint ordering, and the + * share-link LAN warning all read this. + */ +export function isLanUrl(url: string): boolean { + if (!url) return false; + try { + const parsed = new URL(url.startsWith('http') ? url : `http://${url}`); + const raw = parsed.hostname; + // `URL().hostname` keeps IPv6 brackets — strip before pattern matches. + const hostname = raw.replace(/^\[|\]$/g, '').toLowerCase(); + if (!hostname) return false; + if (hostname === 'localhost' || hostname.endsWith('.local')) return true; + if (hostname.includes(':')) return isIpv6LanHostname(hostname); + return isIpv4LanLiteral(hostname); + } catch { + return false; + } +} + +/** + * Deduped normalized addresses for a profile (`url` plus optional + * `alternateUrl`). Both fields are passed through `normalizeServerBaseUrl` + * before dedupe so `https://x.example/` and `https://x.example` collapse. + * Order is preserved (`url` first); empty entries are dropped. + */ +export function allNormalizedAddresses( + profile: Pick, +): string[] { + const result: string[] = []; + const seen = new Set(); + for (const raw of [profile.url, profile.alternateUrl]) { + if (!raw) continue; + const normalized = normalizeServerBaseUrl(raw); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +/** + * Endpoint list for connect probing — LAN-first, stable within each class. + * Single-address profiles return one entry; dual-address returns up to two. + */ +export function serverAddressEndpoints( + profile: Pick, +): ServerEndpoint[] { + const endpoints: ServerEndpoint[] = allNormalizedAddresses(profile).map(url => ({ + url, + kind: isLanUrl(url) ? 'local' : 'public', + })); + return [ + ...endpoints.filter(e => e.kind === 'local'), + ...endpoints.filter(e => e.kind === 'public'), + ]; +} + +/** + * URL to embed in **shares** (Orbit invites, entity / queue share payloads, + * magic strings). Different from the connect URL: a guest opening the share + * link is not on the host's LAN, so the public address is the right default + * when both are configured. `shareUsesLocalUrl` flips that for the rare + * "share into a LAN-only group" case (spec §5). + * + * Single-address profiles return their one normalized address; empty + * profiles still return a normalized form of `url` (possibly empty). + */ +export function serverShareBaseUrl( + profile: Pick, +): string { + const endpoints = allNormalizedAddresses(profile); + if (endpoints.length === 0) return normalizeServerBaseUrl(profile.url); + if (endpoints.length === 1) return endpoints[0]!; + + const local = endpoints.find(isLanUrl); + const publicEndpoint = endpoints.find(u => !isLanUrl(u)); + + if (profile.shareUsesLocalUrl) return local ?? endpoints[0]!; + return publicEndpoint ?? endpoints[0]!; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connect cache (in-memory, per-session) +// +// `pickReachableBaseUrl` probes the LAN-first endpoint list with the existing +// `pingWithCredentials`, sequentially (not parallel) so LAN wins over public +// without racing. The first OK URL is cached against the profile id so the +// next sync `getBaseUrl()` lookup is instant. Cache is **session only** — +// never persisted; cleared on profile edit / credentials change / online +// event / manual retry via `invalidateReachableEndpointCache`. +// ───────────────────────────────────────────────────────────────────────────── + +const connectCache = new Map(); + +/** + * In-flight probes keyed by `profile.id`. Three call sites (useConnectionStatus + * 120-s tick, switchActiveServer, bindIndexedServer, plus retry / online + * handlers) can fire near-simultaneously; without this map two probes would + * each see an empty cache, both ping every endpoint, and race to set the + * sticky URL — the loser's `connectCache.set` would stomp the winner. + * Returning the existing promise dedupes them so every caller gets the + * same result. + */ +const inFlightProbes = new Map>(); + +/** + * Last resolved connect URL for the profile, if a probe has succeeded in this + * session. `null` means "no probe yet" — sync `getBaseUrl()` callers should + * fall back to the normalized primary `url`. + */ +export function getCachedConnectBaseUrl(profileId: string): string | null { + return connectCache.get(profileId) ?? null; +} + +/** + * Synchronous connect URL for any saved profile (active or not). Reads the + * cached probe result; falls back to the normalized primary `url` when no + * probe has run yet for that profile. **Use this** everywhere HTTP traffic + * is built against an explicit `server.url` — never read the raw `url` + * straight for HTTP. + */ +export function connectBaseUrlForServer( + server: Pick, +): string { + const cached = connectCache.get(server.id); + if (cached) return cached; + return serverProfileBaseUrl({ url: server.url }); +} + +/** + * Drop one or all cached connect URLs. Call when: + * - profile was edited (url / alternateUrl / credentials changed) + * - network went online (re-check sticky) + * - user explicitly retried the connection + */ +export function invalidateReachableEndpointCache(profileId?: string): void { + if (profileId === undefined) { + connectCache.clear(); + // Don't clear in-flight slots — they're already racing against the + // network, letting their own `finally` clean up keeps the dedup + // invariant. Their results will still write to the (now empty) cache, + // which is the right behaviour: the freshest probe wins. + return; + } + connectCache.delete(profileId); +} + +/** + * Sequentially ping the profile's endpoints (LAN-first), return the first one + * that answers OK. Sticky: if a cached endpoint exists and is still in the + * list, it's tried first; on failure, the cache entry is cleared and the full + * sequence runs. + * + * Single-address profiles: one ping, identical to the legacy behavior. + */ +export async function pickReachableBaseUrl( + profile: ServerProfile, +): Promise { + // Dedupe concurrent calls for the same profile — see `inFlightProbes`. + const existing = inFlightProbes.get(profile.id); + if (existing) return existing; + + const promise = (async (): Promise => { + const ordered = serverAddressEndpoints(profile); + if (ordered.length === 0) return { ok: false, reason: 'unreachable' }; + + // Apply sticky: move the cached endpoint (if still in the list) to the front. + const cached = connectCache.get(profile.id); + const endpoints = + cached && ordered.some(e => e.url === cached) + ? [ + ordered.find(e => e.url === cached)!, + ...ordered.filter(e => e.url !== cached), + ] + : ordered; + + for (const endpoint of endpoints) { + const ping = await pingWithCredentials(endpoint.url, profile.username, profile.password); + if (ping.ok) { + connectCache.set(profile.id, endpoint.url); + return { ok: true, baseUrl: endpoint.url, endpoint, ping }; + } + } + + // Every endpoint failed — drop any stale cache entry so the next probe + // starts from the natural LAN-first order. + connectCache.delete(profile.id); + return { ok: false, reason: 'unreachable' }; + })(); + + inFlightProbes.set(profile.id, promise); + try { + return await promise; + } finally { + // Always clear the in-flight slot when this promise settles — the next + // call (after a real boundary in time) starts a fresh probe. + inFlightProbes.delete(profile.id); + } +} + +/** + * Boot / switch / online-event entry point: same mechanism as + * `pickReachableBaseUrl` but named for intent at the call site. + */ +export async function ensureConnectUrlResolved( + profile: ServerProfile, +): Promise { + return pickReachableBaseUrl(profile); +} diff --git a/src/utils/server/serverFingerprint.test.ts b/src/utils/server/serverFingerprint.test.ts new file mode 100644 index 00000000..765cb517 --- /dev/null +++ b/src/utils/server/serverFingerprint.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the apiWithCredentials surface — fetchServerFingerprint pulls +// folders / user / license / indexes through it. +vi.mock('../../api/subsonicClient', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + apiWithCredentials: vi.fn(), + }; +}); + +import { apiWithCredentials } from '../../api/subsonicClient'; +import { + compareFingerprints, + fetchServerFingerprint, + verifySameServerEndpoints, + type ServerFingerprint, +} from './serverFingerprint'; + +function makeFingerprint(overrides: Partial = {}): ServerFingerprint { + return { + ping: { + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + apiVersion: '1.16.1', + }, + musicFolders: [{ id: '1', name: 'Music' }], + userId: 'tester', + licenseKey: 'tester@example.com', + indexesDigest: 'letters:26|a1,a2', + ...overrides, + }; +} + +function navidromePingResponse() { + return { + 'subsonic-response': { + status: 'ok', + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + version: '1.16.1', + }, + }; +} + +function ampachePingResponse() { + // Minimal Subsonic-shape — ampache doesn't always advertise openSubsonic etc. + return { + 'subsonic-response': { + status: 'ok', + type: 'ampache', + serverVersion: '6.0.0', + version: '1.13.0', + }, + }; +} + +/** Build a fetch-API-shaped mock response without depending on the global `Response` polyfill. */ +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: async () => body, + } as unknown as Response; +} + +describe('compareFingerprints', () => { + it('matches two identical fingerprints', () => { + const a = makeFingerprint(); + const b = makeFingerprint(); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('mismatches when type differs (case-insensitive)', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, type: 'navidrome' } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, type: 'subsonic' } }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when serverVersion differs', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, serverVersion: '0.55.0' } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, serverVersion: '0.54.9' } }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when openSubsonic differs', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, openSubsonic: true } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, openSubsonic: false } }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('treats envelope apiVersion as informational (no mismatch on its own)', () => { + const a = makeFingerprint({ ping: { ...makeFingerprint().ping, apiVersion: '1.16.1' } }); + const b = makeFingerprint({ ping: { ...makeFingerprint().ping, apiVersion: '1.13.0' } }); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('mismatches when musicFolders differ', () => { + const a = makeFingerprint({ musicFolders: [{ id: '1', name: 'Music' }] }); + const b = makeFingerprint({ musicFolders: [{ id: '1', name: 'Music' }, { id: '2', name: 'Audiobooks' }] }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('treats empty musicFolders on both sides as a matching signal', () => { + const a = makeFingerprint({ musicFolders: [], userId: null, licenseKey: null, indexesDigest: null }); + const b = makeFingerprint({ musicFolders: [], userId: null, licenseKey: null, indexesDigest: null }); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('mismatches when userId differs', () => { + const a = makeFingerprint({ userId: 'tester' }); + const b = makeFingerprint({ userId: 'maria' }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when licenseKey differs', () => { + const a = makeFingerprint({ licenseKey: 'a@example.com' }); + const b = makeFingerprint({ licenseKey: 'b@example.com' }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('mismatches when indexesDigest differs', () => { + const a = makeFingerprint({ indexesDigest: 'letters:5|x,y' }); + const b = makeFingerprint({ indexesDigest: 'letters:5|x,z' }); + expect(compareFingerprints(a, b)).toBe('mismatch'); + }); + + it('ignores a body signal that is null on one side', () => { + // userId null on a — only license + folders + indexes contribute; they match. + const a = makeFingerprint({ userId: null }); + const b = makeFingerprint({ userId: 'tester' }); + expect(compareFingerprints(a, b)).toBe('match'); + }); + + it('returns insufficient when ping matches but every body signal is null on at least one side', () => { + const a = makeFingerprint({ + musicFolders: null, + userId: null, + licenseKey: null, + indexesDigest: null, + }); + const b = makeFingerprint(); + expect(compareFingerprints(a, b)).toBe('insufficient'); + }); +}); + +describe('fetchServerFingerprint', () => { + beforeEach(() => { + vi.mocked(apiWithCredentials).mockReset(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('returns a populated fingerprint for a Navidrome-shaped server', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(navidromePingResponse()), + ); + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester', username: 'tester' } }) + .mockResolvedValueOnce({ license: { email: 'tester@example.com' } }) + .mockResolvedValueOnce({ + indexes: { + index: [ + { name: 'A', artist: [{ id: 'a1' }, { id: 'a2' }] }, + { name: 'B', artist: [{ id: 'b1' }] }, + ], + }, + }); + + const fp = await fetchServerFingerprint('https://music.example.com', 'tester', 'pw'); + expect(fp.ping.type).toBe('navidrome'); + expect(fp.ping.serverVersion).toBe('0.55.0'); + expect(fp.ping.openSubsonic).toBe(true); + expect(fp.ping.apiVersion).toBe('1.16.1'); + expect(fp.musicFolders).toEqual([{ id: '1', name: 'Music' }]); + expect(fp.userId).toBe('tester'); + expect(fp.licenseKey).toBe('tester@example.com'); + expect(fp.indexesDigest).toMatch(/^letters:2\|/); + }); + + it('extracts userId only when the server supplies an explicit id (no username fallback)', async () => { + // Two servers return the same authenticated user but only one of them + // surfaces a `user.id` — the username-only side must extract null so the + // comparator skips the signal instead of comparing two unrelated strings. + vi.mocked(fetch).mockResolvedValue(jsonResponse(navidromePingResponse())); + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [] } }) + .mockResolvedValueOnce({ user: { username: 'tester' } }) // no `id` field + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + const fp = await fetchServerFingerprint('https://x.example.com', 'tester', 'pw'); + expect(fp.userId).toBeNull(); + }); + + it('handles a partial-fail mix — some optional calls ok, others rejected', async () => { + // Real-world: a server answers getMusicFolders + getUser but rejects + // getLicense / getIndexes (subset of OpenSubsonic supported). The + // fingerprint must surface what succeeded and leave the rest null — + // guards against a Promise.all-vs-allSettled regression that would + // collapse the whole fingerprint when one call throws. + vi.mocked(fetch).mockResolvedValue(jsonResponse(navidromePingResponse())); + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester' } }) + .mockRejectedValueOnce(new Error('license not implemented')) + .mockRejectedValueOnce(new Error('indexes not implemented')); + const fp = await fetchServerFingerprint('https://music.example.com', 'tester', 'pw'); + expect(fp.musicFolders).toEqual([{ id: '1', name: 'Music' }]); + expect(fp.userId).toBe('tester'); + expect(fp.licenseKey).toBeNull(); + expect(fp.indexesDigest).toBeNull(); + }); + + it('soft-fails optional calls — minimal Subsonic-shape', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(ampachePingResponse()), + ); + // All four optional calls fail (server doesn't support them). + vi.mocked(apiWithCredentials).mockRejectedValue(new Error('Not implemented')); + + const fp = await fetchServerFingerprint('https://ampache.example.com', 'u', 'p'); + expect(fp.ping.type).toBe('ampache'); + expect(fp.ping.serverVersion).toBe('6.0.0'); + expect(fp.ping.openSubsonic).toBe(false); + expect(fp.musicFolders).toBeNull(); + expect(fp.userId).toBeNull(); + expect(fp.licenseKey).toBeNull(); + expect(fp.indexesDigest).toBeNull(); + }); + + it('returns a null-bodied fingerprint when ping itself fails', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(null, false, 500)); + const fp = await fetchServerFingerprint('https://broken.example.com', 'u', 'p'); + expect(fp.ping.type).toBeNull(); + expect(fp.ping.serverVersion).toBeNull(); + expect(fp.musicFolders).toBeNull(); + expect(fp.userId).toBeNull(); + // Optional calls are skipped entirely once ping fails. + expect(apiWithCredentials).not.toHaveBeenCalled(); + }); +}); + +describe('verifySameServerEndpoints', () => { + beforeEach(() => { + vi.mocked(apiWithCredentials).mockReset(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('short-circuits to ok:true for a single-address profile', async () => { + const result = await verifySameServerEndpoints( + { url: 'https://music.example.com' }, + 'u', + 'p', + ); + expect(result).toEqual({ ok: true }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns ok:true when both endpoints fingerprint the same server', async () => { + // Both pings succeed; both return identical Navidrome payloads. + vi.mocked(fetch).mockResolvedValue( + jsonResponse(navidromePingResponse()), + ); + vi.mocked(apiWithCredentials).mockResolvedValue({ + musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] }, + }); + + const result = await verifySameServerEndpoints( + { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }, + 'u', + 'p', + ); + expect(result).toEqual({ ok: true }); + }); + + it('returns unreachable when one endpoint ping fails', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(navidromePingResponse())) + .mockResolvedValueOnce(jsonResponse(null, false, 500)); + vi.mocked(apiWithCredentials).mockResolvedValue({}); + + const result = await verifySameServerEndpoints( + { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }, + 'u', + 'p', + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('unreachable'); + expect(result.unreachableHost).toBe('http://192.168.0.10'); + } + }); + + it('returns mismatch when fingerprints disagree on a body signal', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(navidromePingResponse()), + ); + // Two different folder lists. + vi.mocked(apiWithCredentials) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '1', name: 'Music' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester' } }) + .mockResolvedValueOnce({ license: { email: 'a@e.com' } }) + .mockResolvedValueOnce({ + indexes: { index: [{ name: 'A', artist: [{ id: 'a1' }] }] }, + }) + .mockResolvedValueOnce({ musicFolders: { musicFolder: [{ id: '99', name: 'Other' }] } }) + .mockResolvedValueOnce({ user: { id: 'tester' } }) + .mockResolvedValueOnce({ license: { email: 'a@e.com' } }) + .mockResolvedValueOnce({ + indexes: { index: [{ name: 'A', artist: [{ id: 'a1' }] }] }, + }); + + const result = await verifySameServerEndpoints( + { + url: 'https://a.example.com', + alternateUrl: 'https://b.example.com', + }, + 'u', + 'p', + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('mismatch'); + }); + + it('returns insufficient when pings agree but no body signal is comparable', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse(ampachePingResponse()), + ); + // Both servers don't respond to any optional call. + vi.mocked(apiWithCredentials).mockRejectedValue(new Error('Not implemented')); + + const result = await verifySameServerEndpoints( + { + url: 'https://ampache-1.example.com', + alternateUrl: 'https://ampache-2.example.com', + }, + 'u', + 'p', + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('insufficient'); + }); +}); diff --git a/src/utils/server/serverFingerprint.ts b/src/utils/server/serverFingerprint.ts new file mode 100644 index 00000000..40866886 --- /dev/null +++ b/src/utils/server/serverFingerprint.ts @@ -0,0 +1,332 @@ +/** + * Same-server verification for dual-address profiles. + * + * When a user enters both a LAN and a public address for "their" server, the + * UI must refuse to save the pair if the two addresses are actually different + * boxes (e.g. typo, copy-paste mishap, two different Navidromes). We probe + * each address for an **idempotent fingerprint** — a small read-only snapshot + * of server identity — and compare them. + * + * Subsonic-generic by design: no branch on `type === 'navidrome'`. Whatever + * the server reports for `ping.view` plus the optional folders / user / + * license / indexes calls defines the fingerprint. Servers that only respond + * to `ping.view` produce an "insufficient" result, which blocks save in v1 + * (no "save anyway" escape hatch — see spec §7.3). + */ + +import md5 from 'md5'; +import { apiWithCredentials, restBaseFromUrl, secureRandomSalt, SUBSONIC_CLIENT } from '../../api/subsonicClient'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { allNormalizedAddresses } from './serverEndpoint'; + +export type ServerFingerprint = { + ping: { + type: string | null; + serverVersion: string | null; + openSubsonic: boolean; + apiVersion: string | null; + }; + musicFolders: Array<{ id: string; name: string }> | null; + userId: string | null; + /** Normalized lowercased email if present, else null. */ + licenseKey: string | null; + indexesDigest: string | null; +}; + +export type FingerprintCompareResult = 'match' | 'mismatch' | 'insufficient'; + +export type VerifySameServerResult = + | { ok: true } + | { + ok: false; + reason: 'mismatch' | 'insufficient' | 'unreachable'; + unreachableHost?: string; + }; + +// ─── ping (with envelope `version`) ────────────────────────────────────────── +// +// `pingWithCredentials` in api/subsonic.ts drops the envelope `version`. We +// need it for the fingerprint (informational, per spec §7.3) so we do the +// ping call here against the same Subsonic shape. Failure → throws. + +type PingFingerprint = ServerFingerprint['ping'] & { ok: boolean }; + +async function fetchPingFingerprint( + baseUrl: string, + username: string, + password: string, +): Promise { + // Mirrors pingWithCredentials but also extracts envelope `version`. + // Using fetch (the bundled axios pulls in extra noise here; one call is fine). + const salt = secureRandomSalt(); + const token = md5(password + salt); + const params = new URLSearchParams({ + u: username, + t: token, + s: salt, + v: '1.16.1', + c: SUBSONIC_CLIENT, + f: 'json', + }); + const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`; + let body: Record | null = null; + try { + const resp = await fetch(url, { method: 'GET' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const json = (await resp.json()) as Record; + body = (json?.['subsonic-response'] as Record) ?? null; + } catch { + return { + ok: false, + type: null, + serverVersion: null, + openSubsonic: false, + apiVersion: null, + }; + } + if (!body) { + return { + ok: false, + type: null, + serverVersion: null, + openSubsonic: false, + apiVersion: null, + }; + } + const ok = body.status === 'ok'; + return { + ok, + type: typeof body.type === 'string' ? body.type : null, + serverVersion: typeof body.serverVersion === 'string' ? body.serverVersion : null, + openSubsonic: body.openSubsonic === true, + apiVersion: typeof body.version === 'string' ? body.version : null, + }; +} + +// ─── optional fingerprint calls (failures soft-fail to null) ───────────────── + +function extractMusicFolders(data: unknown): Array<{ id: string; name: string }> | null { + const folders = (data as Record | null)?.['musicFolders'] as + | { musicFolder?: Array<{ id: unknown; name: unknown }> } + | undefined; + const list = folders?.musicFolder; + if (!Array.isArray(list)) return null; + const normalized = list + .map(f => ({ id: String(f.id ?? ''), name: typeof f.name === 'string' ? f.name : '' })) + .filter(f => f.id !== ''); + // Sort by id so order differences from the server don't trip the compare. + normalized.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return normalized; +} + +function extractUserId(data: unknown): string | null { + const user = (data as Record | null)?.['user'] as + | Record + | undefined; + if (!user) return null; + // Only count the server-supplied id as a signal. A username-only fallback + // (spec §7.2 footnote) would create false `mismatch` results when one + // endpoint surfaces an explicit id and the other only returns the + // username we already authenticated with — both sides would carry a + // value and the comparator would compare unrelated strings. + const explicit = typeof user.id === 'string' ? user.id.trim() : ''; + return explicit || null; +} + +function extractLicenseEmail(data: unknown): string | null { + const license = (data as Record | null)?.['license'] as + | Record + | undefined; + if (!license) return null; + const email = typeof license.email === 'string' ? license.email.trim().toLowerCase() : ''; + return email || null; +} + +function extractIndexesDigest(data: unknown): string | null { + const indexes = (data as Record | null)?.['indexes'] as + | Record + | undefined; + if (!indexes) return null; + const letters = Array.isArray(indexes.index) ? (indexes.index as Array>) : []; + if (letters.length === 0) return null; + const letterCount = letters.length; + const artistIds: string[] = []; + for (const letter of letters) { + const artists = Array.isArray(letter.artist) ? (letter.artist as Array>) : []; + for (const artist of artists) { + const id = typeof artist.id === 'string' ? artist.id : null; + if (id) artistIds.push(id); + if (artistIds.length >= 20) break; + } + if (artistIds.length >= 20) break; + } + if (artistIds.length === 0) return `letters:${letterCount}|`; + artistIds.sort(); + return `letters:${letterCount}|${artistIds.slice(0, 20).join(',')}`; +} + +// ─── fetchServerFingerprint ────────────────────────────────────────────────── + +export async function fetchServerFingerprint( + baseUrl: string, + username: string, + password: string, +): Promise { + // Ping is required — but we still build a (mostly-null) fingerprint when + // it fails so callers can tell the difference between "server unreachable" + // (verify reports `unreachable`) and "server reachable but disagrees" + // (verify reports `mismatch` / `insufficient`). + const ping = await fetchPingFingerprint(baseUrl, username, password); + + // The optional calls only make sense once ping succeeded — without that, + // any subsequent call against the same URL is just wasted bandwidth. + if (!ping.ok) { + return { + ping: { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + apiVersion: ping.apiVersion, + }, + musicFolders: null, + userId: null, + licenseKey: null, + indexesDigest: null, + }; + } + + const settled = await Promise.allSettled([ + apiWithCredentials>(baseUrl, username, password, 'getMusicFolders.view'), + apiWithCredentials>(baseUrl, username, password, 'getUser.view', { username }), + apiWithCredentials>(baseUrl, username, password, 'getLicense.view'), + apiWithCredentials>(baseUrl, username, password, 'getIndexes.view'), + ]); + + const [foldersResult, userResult, licenseResult, indexesResult] = settled; + + const value = (r: PromiseSettledResult): T | null => + r.status === 'fulfilled' ? r.value : null; + + return { + ping: { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + apiVersion: ping.apiVersion, + }, + musicFolders: extractMusicFolders(value(foldersResult)), + userId: extractUserId(value(userResult)), + licenseKey: extractLicenseEmail(value(licenseResult)), + indexesDigest: extractIndexesDigest(value(indexesResult)), + }; +} + +// ─── compareFingerprints ───────────────────────────────────────────────────── + +/** + * Strict comparison rule for the ping triple, plus a "common-signals" rule + * for the body. Envelope `apiVersion` is informational only and never causes + * mismatch on its own (spec §7.3). + * + * `match` — every common signal agrees + at least one common body signal + * `mismatch` — any common signal differs (ping or body) + * `insufficient` — pings ok but no body signal is present on both sides + */ +export function compareFingerprints( + a: ServerFingerprint, + b: ServerFingerprint, +): FingerprintCompareResult { + // Ping strictness — these MUST agree once both pings succeeded. (Callers + // upstream of compareFingerprints handle the "ping failed" case as + // `unreachable`.) + const aType = a.ping.type?.trim().toLowerCase() ?? null; + const bType = b.ping.type?.trim().toLowerCase() ?? null; + if (aType !== bType) return 'mismatch'; + + const aVersion = a.ping.serverVersion ?? null; + const bVersion = b.ping.serverVersion ?? null; + if (aVersion !== bVersion) return 'mismatch'; + + if (a.ping.openSubsonic !== b.ping.openSubsonic) return 'mismatch'; + + // Body signals — only count when both sides have a value. Empty array on + // both sides for musicFolders is itself a matching signal (spec §7.3). + let common = 0; + + // musicFolders — array equality after sort (extract already sorts). + if (a.musicFolders !== null && b.musicFolders !== null) { + common += 1; + if (!musicFoldersEqual(a.musicFolders, b.musicFolders)) return 'mismatch'; + } + + if (a.userId !== null && b.userId !== null) { + common += 1; + if (a.userId !== b.userId) return 'mismatch'; + } + + if (a.licenseKey !== null && b.licenseKey !== null) { + common += 1; + if (a.licenseKey !== b.licenseKey) return 'mismatch'; + } + + if (a.indexesDigest !== null && b.indexesDigest !== null) { + common += 1; + if (a.indexesDigest !== b.indexesDigest) return 'mismatch'; + } + + if (common === 0) return 'insufficient'; + return 'match'; +} + +function musicFoldersEqual( + a: Array<{ id: string; name: string }>, + b: Array<{ id: string; name: string }>, +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const ai = a[i]!; + const bi = b[i]!; + if (ai.id !== bi.id || ai.name !== bi.name) return false; + } + return true; +} + +// ─── verifySameServerEndpoints ─────────────────────────────────────────────── + +/** + * Top-level orchestrator: probe each configured address in parallel, then + * compare the fingerprints pairwise. Single-address profiles short-circuit to + * `ok: true` — nothing to verify. + */ +export async function verifySameServerEndpoints( + profile: Pick, + username: string, + password: string, +): Promise { + const endpoints = allNormalizedAddresses(profile); + if (endpoints.length <= 1) return { ok: true }; + + const fingerprints = await Promise.all( + endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password)), + ); + + // If any ping failed → unreachable (with the offending host for the UI). + for (let i = 0; i < endpoints.length; i++) { + if (!fingerprints[i]!.ping.type && !fingerprints[i]!.ping.serverVersion) { + // ping.type/serverVersion are null only when the ping itself failed + // (fetchPingFingerprint zeroes everything on failure). + return { ok: false, reason: 'unreachable', unreachableHost: endpoints[i]! }; + } + } + + // Compare every pair. N=2 in v1 (spec §6), but the loop generalises. + for (let i = 0; i < fingerprints.length; i++) { + for (let j = i + 1; j < fingerprints.length; j++) { + const result = compareFingerprints(fingerprints[i]!, fingerprints[j]!); + if (result === 'mismatch') return { ok: false, reason: 'mismatch' }; + if (result === 'insufficient') return { ok: false, reason: 'insufficient' }; + } + } + + return { ok: true }; +} diff --git a/src/utils/server/serverMagicString.test.ts b/src/utils/server/serverMagicString.test.ts index 02f16355..52d34045 100644 --- a/src/utils/server/serverMagicString.test.ts +++ b/src/utils/server/serverMagicString.test.ts @@ -65,8 +65,9 @@ describe('serverMagicString', () => { expect(decodeServerMagicString(garbage)).toBeNull(); }); - it('rejects a payload with the wrong version', () => { - const wrongVersion = btoa(JSON.stringify({ v: 2, url: 'https://x', u: 'u', w: 'p' })) + it('rejects a payload with an unknown version', () => { + // v: 3 is out of range; v1 + v2 are both accepted. + const wrongVersion = btoa(JSON.stringify({ v: 3, url: 'https://x', u: 'u', w: 'p' })) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + wrongVersion)).toBeNull(); }); @@ -100,6 +101,110 @@ describe('serverMagicString', () => { it('rejects text that contains only the bare prefix', () => { expect(decodeServerMagicStringFromText(`prefix only: ${SERVER_MAGIC_STRING_PREFIX} done`)).toBeNull(); }); + + // ─── v2 (dual-address) ────────────────────────────────────────────────── + + it('emits v1 for a single-address invite (backward-compatible)', () => { + // No alternateUrl, no shareUsesLocalUrl → v1 wire format. Verified by + // round-tripping through a v1-decode of the inner JSON. + const encoded = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'alice', + password: 'pw', + }); + const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length); + const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4); + const inner = JSON.parse(atob(padded)); + expect(inner.v).toBe(1); + expect(inner.alt).toBeUndefined(); + expect(inner.shareLocal).toBeUndefined(); + }); + + it('emits v2 when alternateUrl is set', () => { + const encoded = encodeServerMagicString({ + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + username: 'alice', + password: 'pw', + }); + const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length); + const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4); + const inner = JSON.parse(atob(padded)); + expect(inner.v).toBe(2); + expect(inner.url).toBe('https://music.example.com'); + expect(inner.alt).toBe('http://192.168.0.10:4533'); + expect(inner.shareLocal).toBeUndefined(); + }); + + it('emits v2 when only shareUsesLocalUrl is set (no alt)', () => { + // Edge: the host has dropped the second address but kept the share flag + // on. We still emit v2 so the receiver picks up the preference. + const encoded = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'alice', + password: 'pw', + shareUsesLocalUrl: true, + }); + const b64 = encoded.slice(SERVER_MAGIC_STRING_PREFIX.length); + const b64Std = b64.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64Std + '='.repeat((4 - (b64Std.length % 4)) % 4); + const inner = JSON.parse(atob(padded)); + expect(inner.v).toBe(2); + expect(inner.alt).toBeUndefined(); + expect(inner.shareLocal).toBe(true); + }); + + it('round-trips v2 with alternateUrl + shareUsesLocalUrl + name', () => { + const original = { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + shareUsesLocalUrl: true, + username: 'alice', + password: 'pw', + name: 'Home', + }; + const encoded = encodeServerMagicString(original); + expect(decodeServerMagicString(encoded)).toEqual(original); + }); + + it('round-trips v2 with alternateUrl only (default share flag absent)', () => { + const original = { + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + username: 'alice', + password: 'pw', + }; + const decoded = decodeServerMagicString(encodeServerMagicString(original)); + expect(decoded).toEqual(original); + // shareUsesLocalUrl must NOT be set on the decoded payload when the + // host didn't flip the flag — kept absent rather than `false` so + // existing zustand persist diffs stay clean. + expect(decoded?.shareUsesLocalUrl).toBeUndefined(); + }); + + it('decodes a v1 invite without alternateUrl / shareUsesLocalUrl', () => { + // Hand-built v1 payload → verify backward-compat decode path leaves the + // v2-only fields undefined. + const v1 = btoa(JSON.stringify({ v: 1, url: 'https://x.example', u: 'u', w: 'p' })) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const decoded = decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + v1); + expect(decoded?.url).toBe('https://x.example'); + expect(decoded?.alternateUrl).toBeUndefined(); + expect(decoded?.shareUsesLocalUrl).toBeUndefined(); + }); + + it('treats an empty alt field on v2 as absent (no alternateUrl)', () => { + // Defensive — a misformed v2 invite with `alt: ''` should decode as if + // no alternate were set, not as an empty-string alternateUrl that + // would later fail isLanUrl / normalize checks. + const v2 = btoa(JSON.stringify({ + v: 2, url: 'https://x.example', alt: ' ', u: 'u', w: 'p', + })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const decoded = decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + v2); + expect(decoded?.alternateUrl).toBeUndefined(); + }); }); describe('copyTextToClipboard', () => { diff --git a/src/utils/server/serverMagicString.ts b/src/utils/server/serverMagicString.ts index 15ff40df..ca5018f3 100644 --- a/src/utils/server/serverMagicString.ts +++ b/src/utils/server/serverMagicString.ts @@ -1,3 +1,6 @@ +import type { ServerProfile } from '../../store/authStoreTypes'; +import { normalizeServerBaseUrl, serverShareBaseUrl } from './serverEndpoint'; + /** * Prefix for server invite strings (Subsonic credentials). Same family as library * shares in `shareLink.ts` (`psysonic2-` + payload). @@ -8,7 +11,26 @@ export const SERVER_MAGIC_STRING_PREFIX = 'psysonic1-'; export const DECODED_PASSWORD_VISUAL_MASK = '••••••••••'; export interface ServerMagicPayload { + /** + * **v1:** the host's primary URL (also the connect URL for single-address + * profiles). + * **v2:** the host's **share URL** — public by default when both + * addresses are set, or the local address if `shareUsesLocalUrl` flips it. + * The receiver takes whichever they got and treats it as the primary URL + * of the new profile (their own index key). + */ url: string; + /** + * v2 only — the host's alternate address. Empty / absent when the host + * has a single-address profile or chose to share a v1 invite. + */ + alternateUrl?: string; + /** + * v2 only — the host's preference for which address goes into future + * share links from the receiver's side. Mirrors the source profile's + * checkbox so a guest's onward shares behave the same way. + */ + shareUsesLocalUrl?: boolean; username: string; password: string; /** Optional display name for the saved server entry */ @@ -32,16 +54,34 @@ function base64UrlToUtf8(b64url: string): string { return new TextDecoder().decode(bytes); } -/** Encode server URL + Subsonic credentials into a single pasteable string. */ +/** + * Encode server URL + Subsonic credentials into a single pasteable string. + * + * Emits **v2** when the payload carries an `alternateUrl` or + * `shareUsesLocalUrl=true` (the dual-address shape — spec §10). Falls back + * to **v1** otherwise, byte-identical with the pre-dual-address format so + * older receivers keep working. + */ export function encodeServerMagicString(p: ServerMagicPayload): string { - const payload = { - v: 1 as const, + const alt = p.alternateUrl?.trim() ?? ''; + const useV2 = alt.length > 0 || p.shareUsesLocalUrl === true; + const base = { url: p.url.trim(), u: p.username, w: p.password, ...(p.name?.trim() ? { n: p.name.trim() } : {}), }; - return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(payload)); + if (useV2) { + const v2 = { + v: 2 as const, + ...base, + ...(alt ? { alt } : {}), + ...(p.shareUsesLocalUrl ? { shareLocal: true as const } : {}), + }; + return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(v2)); + } + const v1 = { v: 1 as const, ...base }; + return SERVER_MAGIC_STRING_PREFIX + utf8ToBase64Url(JSON.stringify(v1)); } /** @@ -61,6 +101,11 @@ export function decodeServerMagicStringFromText(text: string): ServerMagicPayloa return decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + token); } +/** + * Decode either a v1 or v2 invite. v2 surfaces `alternateUrl` + the share + * preference; v1 leaves those fields undefined so single-address profiles + * keep the same persisted shape. + */ export function decodeServerMagicString(raw: string): ServerMagicPayload | null { const s = raw.trim(); if (!s.startsWith(SERVER_MAGIC_STRING_PREFIX)) return null; @@ -74,15 +119,60 @@ export function decodeServerMagicString(raw: string): ServerMagicPayload | null } if (!obj || typeof obj !== 'object') return null; const o = obj as Record; - if (o.v !== 1) return null; + if (o.v !== 1 && o.v !== 2) return null; const url = typeof o.url === 'string' ? o.url.trim() : ''; const username = typeof o.u === 'string' ? o.u : ''; const password = typeof o.w === 'string' ? o.w : ''; const name = typeof o.n === 'string' && o.n.trim() ? o.n.trim() : undefined; if (!url || !username) return null; + + if (o.v === 2) { + const alt = typeof o.alt === 'string' ? o.alt.trim() : ''; + return { + url, + ...(alt ? { alternateUrl: alt } : {}), + ...(o.shareLocal === true ? { shareUsesLocalUrl: true } : {}), + username, + password, + name, + }; + } return { url, username, password, name }; } +/** + * Pick out the dual-address fields for the saved profile whose primary URL + * or `alternateUrl` matches the given `serverUrl`. Returns the share URL + + * the alternate + the share flag when those exist; falls back to just the + * raw `serverUrl` for legacy / single-address profiles (v1 wire shape). + * + * Shared by `MagicStringModal` and `UserForm` (the two places we encode + * server invites from). The lookup is normalize-aware so it tolerates + * trailing-slash / scheme differences between the input URL and the saved + * profile's URL. + */ +export function magicPayloadAddressFields( + serverUrl: string, + servers: ServerProfile[], +): { + url: string; + alternateUrl?: string; + shareUsesLocalUrl?: boolean; +} { + const normalized = normalizeServerBaseUrl(serverUrl); + const match = servers.find( + s => + normalizeServerBaseUrl(s.url) === normalized || + (s.alternateUrl != null && normalizeServerBaseUrl(s.alternateUrl) === normalized), + ); + if (!match) return { url: serverUrl }; + return { + url: serverShareBaseUrl(match), + ...(match.alternateUrl ? { alternateUrl: match.alternateUrl } : {}), + ...(match.shareUsesLocalUrl ? { shareUsesLocalUrl: true } : {}), + }; +} + export async function copyTextToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); diff --git a/src/utils/server/serverUrlRemigration.test.ts b/src/utils/server/serverUrlRemigration.test.ts new file mode 100644 index 00000000..7b55f43e --- /dev/null +++ b/src/utils/server/serverUrlRemigration.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the Tauri core invoke surface — the orchestrator calls +// `migration_inspect`, `migration_run`, and `cover_cache_rename_server_bucket` +// through this single entry point. +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +import { invoke } from '@tauri-apps/api/core'; +import { + indexKeyRemapForUrlChange, + runIndexKeyRemigration, +} from './serverUrlRemigration'; + +function inspectStub(needs = true) { + return { + needsMigration: needs, + hasSkippedUnknownServerRows: false, + canRun: true, + warnings: [], + unmappedEmptyBucket: false, + library: { totalLegacyRows: 100, skippedUnknownServerRows: 0, tables: {} }, + analysis: { totalLegacyRows: 50, skippedUnknownServerRows: 0, tables: {} }, + mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }], + }; +} + +function runStub() { + return { + library: { importedRows: 100, sourceRows: 100, skippedUnknownServerRows: 0 }, + analysis: { importedRows: 50, sourceRows: 50, skippedUnknownServerRows: 0 }, + hasSkippedUnknownServerRows: false, + switched: true, + backupRemoved: true, + }; +} + +describe('indexKeyRemapForUrlChange', () => { + it('returns null when both urls collapse to the same index key', () => { + expect( + indexKeyRemapForUrlChange({ url: 'https://music.example.com' }, { url: 'http://music.example.com/' }), + ).toBeNull(); + }); + + it('returns null when only the scheme changes', () => { + expect( + indexKeyRemapForUrlChange({ url: 'http://music.example.com' }, { url: 'https://music.example.com' }), + ).toBeNull(); + }); + + it('returns null for missing urls', () => { + expect(indexKeyRemapForUrlChange({ url: '' }, { url: 'https://x.example' })).toBeNull(); + expect(indexKeyRemapForUrlChange({ url: 'https://x.example' }, { url: '' })).toBeNull(); + }); + + it('returns the remap when the host changes', () => { + expect( + indexKeyRemapForUrlChange( + { url: 'https://old.example.com' }, + { url: 'https://new.example.com' }, + ), + ).toEqual({ oldKey: 'old.example.com', newKey: 'new.example.com' }); + }); + + it('returns the remap when the path part changes', () => { + expect( + indexKeyRemapForUrlChange( + { url: 'https://music.example.com' }, + { url: 'https://music.example.com/navidrome' }, + ), + ).toEqual({ oldKey: 'music.example.com', newKey: 'music.example.com/navidrome' }); + }); +}); + +describe('runIndexKeyRemigration', () => { + beforeEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it('runs the full pipeline on the happy path', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) // migration_inspect + .mockResolvedValueOnce(runStub()) // migration_run + .mockResolvedValueOnce(undefined); // cover_cache_rename_server_bucket + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(true); + expect(invoke).toHaveBeenCalledTimes(3); + expect(vi.mocked(invoke).mock.calls[0]![0]).toBe('migration_inspect'); + expect(vi.mocked(invoke).mock.calls[1]![0]).toBe('migration_run'); + expect(vi.mocked(invoke).mock.calls[2]![0]).toBe('cover_cache_rename_server_bucket'); + expect(vi.mocked(invoke).mock.calls[2]![1]).toEqual({ oldKey: 'old', newKey: 'new' }); + }); + + it('hands the same { legacyId, indexKey } mapping to inspect + run', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) + .mockResolvedValueOnce(runStub()) + .mockResolvedValueOnce(undefined); + + await runIndexKeyRemigration({ oldKey: 'old.example.com', newKey: 'new.example.com' }); + + const inspectCall = vi.mocked(invoke).mock.calls[0]!; + const runCall = vi.mocked(invoke).mock.calls[1]!; + expect(inspectCall[1]).toEqual({ + mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }], + }); + expect(runCall[1]).toEqual({ + mappings: [{ legacyId: 'old.example.com', indexKey: 'new.example.com' }], + }); + }); + + it('reports inspect failure and stops without running the destructive step', async () => { + vi.mocked(invoke).mockRejectedValueOnce(new Error('locked')); // inspect throws + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.stage).toBe('inspect'); + expect(result.failure.error).toContain('locked'); + } + expect(invoke).toHaveBeenCalledTimes(1); // only inspect ran + }); + + it('reports run failure and does not attempt the cover rename', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) + .mockRejectedValueOnce(new Error('disk full')); + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure.stage).toBe('run'); + expect(invoke).toHaveBeenCalledTimes(2); // inspect + failed run + }); + + it('reports cover-rename failure (DB rows already moved — recoverable)', async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(inspectStub()) + .mockResolvedValueOnce(runStub()) + .mockRejectedValueOnce(new Error('rename: permission denied')); + + const result = await runIndexKeyRemigration({ oldKey: 'old', newKey: 'new' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.stage).toBe('cover-rename'); + expect(result.failure.error).toContain('permission denied'); + } + expect(invoke).toHaveBeenCalledTimes(3); // all three were attempted + }); +}); diff --git a/src/utils/server/serverUrlRemigration.ts b/src/utils/server/serverUrlRemigration.ts new file mode 100644 index 00000000..f28ad18d --- /dev/null +++ b/src/utils/server/serverUrlRemigration.ts @@ -0,0 +1,124 @@ +/** + * Primary-URL change remigration — moves library / analysis SQLite rows and + * the cover-cache disk bucket from one index key to another when the user + * edits a server profile's primary `url` in a way that changes the derived + * `serverIndexKeyFromUrl(url)`. + * + * Spec §8 ("URL change remigration"). The pipeline: + * + * 1. detect — `indexKeyRemapForUrlChange(prev, next)` returns null when the + * index key is unchanged (scheme-only flip, alternateUrl-only edit, + * trailing-slash edit, etc.) so callers can short-circuit without doing + * any work. + * 2. inspect — `migration_inspect` confirms how many rows would move (also + * surfaces warnings before the destructive step runs). + * 3. run — `migration_run` re-tags the SQLite rows (library + analysis). + * 4. front-end rewrite — `rewriteFrontendStoreKeysForRemap` repoints + * offlineStore / hotCacheStore / analysisStrategyStore / + * playerStore.queueServerId. + * 5. cover bucket — `cover_cache_rename_server_bucket` moves + * `{cover_root}/{oldKey}/` to `{newKey}/`. + * + * Failures abort early; the caller blocks the profile save and shows the + * user a retry. We never partially commit: the SQLite step is the latest + * destructive step before the front-end rewrite and disk rename, so the + * worst case (DB ok, cover rename fails) leaves the index key consistent + * but covers under the new key may need to be re-downloaded — handled by + * the existing cover backfill on next access. + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { + migrationInspect, + migrationRun, + type MigrationInspectReport, + type MigrationRunResult, +} from '../../api/migration'; +import { rewriteFrontendStoreKeysForRemap } from './rewriteFrontendStoreKeys'; +import { serverIndexKeyFromUrl } from './serverIndexKey'; + +export type IndexKeyRemap = { oldKey: string; newKey: string }; + +export type IndexKeyRemigrationFailure = + | { stage: 'inspect'; error: string } + | { stage: 'run'; error: string } + | { stage: 'cover-rename'; error: string }; + +export type IndexKeyRemigrationResult = + | { ok: true; inspect: MigrationInspectReport; run: MigrationRunResult } + | { ok: false; failure: IndexKeyRemigrationFailure }; + +/** + * Detect whether a profile edit changes the **index key** derived from the + * primary url. Returns `null` when both sides normalize to the same key — + * scheme-only edits (`http://x` → `https://x`), trailing-slash differences, + * and any change that only touches `alternateUrl` / credentials / name all + * fall through. + * + * Empty / missing urls return null (no remigration to do). + */ +export function indexKeyRemapForUrlChange( + previous: Pick, + next: Pick, +): IndexKeyRemap | null { + const oldKey = serverIndexKeyFromUrl(previous.url ?? '').trim(); + const newKey = serverIndexKeyFromUrl(next.url ?? '').trim(); + if (!oldKey || !newKey) return null; + if (oldKey === newKey) return null; + return { oldKey, newKey }; +} + +/** + * Run the four-stage pipeline for a single oldKey → newKey remap. + * + * Returns a structured result the caller can branch on — UI surfaces the + * failure stage so the user knows whether to retry the DB step, fix + * network, or report a cover-rename bug. + */ +export async function runIndexKeyRemigration( + remap: IndexKeyRemap, +): Promise { + const mappings = [{ legacyId: remap.oldKey, indexKey: remap.newKey }]; + + let inspect: MigrationInspectReport; + try { + inspect = await migrationInspect(mappings); + } catch (e) { + return { ok: false, failure: { stage: 'inspect', error: String(e) } }; + } + + let run: MigrationRunResult; + try { + run = await migrationRun(mappings); + } catch (e) { + return { ok: false, failure: { stage: 'run', error: String(e) } }; + } + + // Frontend stores — this is in-memory only; if zustand throws (it + // shouldn't), the user is left with a DB tagged on newKey and stores on + // oldKey. That recovers on next app start via the existing rehydration + // path, so we don't treat it as a failure here. + try { + await rewriteFrontendStoreKeysForRemap([remap]); + } catch { + /* in-memory rewrite is best-effort; the persisted state catches up at + the next zustand persist tick. */ + } + + try { + await invoke('cover_cache_rename_server_bucket', { + oldKey: remap.oldKey, + newKey: remap.newKey, + }); + } catch (e) { + // Cover rename is the latest step and the most recoverable failure: + // the disk bucket is still under oldKey, library + analysis already + // point at newKey, so covers will look "missing" until the user re- + // triggers a sync or the backfill catches them. Surface the failure + // but don't undo the DB step — that would be far more destructive. + return { ok: false, failure: { stage: 'cover-rename', error: String(e) } }; + } + + return { ok: true, inspect, run }; +} diff --git a/src/utils/server/switchActiveServer.ts b/src/utils/server/switchActiveServer.ts index 84f17d62..d9b311c0 100644 --- a/src/utils/server/switchActiveServer.ts +++ b/src/utils/server/switchActiveServer.ts @@ -1,5 +1,5 @@ import type { ServerProfile } from '../../store/authStoreTypes'; -import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; +import { scheduleInstantMixProbeForServer } from '../../api/subsonic'; import { coverTrafficBeginServerSwitch, coverTrafficEndServerSwitch, @@ -7,12 +7,17 @@ import { import { useAuthStore } from '../../store/authStore'; import { useOrbitStore } from '../../store/orbitStore'; import { endOrbitSession, leaveOrbitSession } from '../orbit'; +import { ensureConnectUrlResolved } from './serverEndpoint'; export async function switchActiveServer(server: ServerProfile): Promise { coverTrafficBeginServerSwitch(); try { - const ping = await pingWithCredentials(server.url, server.username, server.password); - if (!ping.ok) return false; + // Resolve the reachable endpoint (LAN-first, sticky cached); this also + // populates the connect cache so the sync `getBaseUrl()` lookup serves the + // probed URL on the very next read. Single-address profiles fall through + // to one ping, identical to the legacy behaviour. + const probe = await ensureConnectUrlResolved(server); + if (!probe.ok) return false; // Tear down any active Orbit session before we actually switch. The // session's playlists live on the *old* server — once we flip the @@ -32,13 +37,13 @@ export async function switchActiveServer(server: ServerProfile): Promise { - const srv = useAuthStore.getState().getBaseUrl(); - if (!srv || !id.trim()) return false; + const active = useAuthStore.getState().getActiveServer(); + if (!active || !id.trim()) return false; + // Share URL ≠ connect URL — a guest opening this link is not on our LAN, so + // a dual-address profile defaults to the public address (overridable via + // shareUsesLocalUrl when the user explicitly shares into a LAN group). + const srv = serverShareBaseUrl(active); + if (!srv) return false; return copyTextToClipboard(encodeSharePayload({ srv, k: kind, id: id.trim() })); } diff --git a/src/utils/share/enqueueShareSearchPayload.ts b/src/utils/share/enqueueShareSearchPayload.ts index ec91111e..2cce0104 100644 --- a/src/utils/share/enqueueShareSearchPayload.ts +++ b/src/utils/share/enqueueShareSearchPayload.ts @@ -14,6 +14,7 @@ import { songToTrack } from '../playback/songToTrack'; import type { Track } from '../../store/playerStoreTypes'; import { orbitBulkGuard } from '../orbitBulkGuard'; import { findServerIdForShareUrl } from './shareLink'; +import { connectBaseUrlForServer } from '../server/serverEndpoint'; import { serverIndexKeyFromUrl } from '../server/serverIndexKey'; import type { AlbumShareSearchPayload, @@ -104,7 +105,12 @@ async function resolveSharedSong( activateShareServer(lookup.serverId); return getSong(id); } - return getSongWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, id); + return getSongWithCredentials( + connectBaseUrlForServer(lookup.server), + lookup.server.username, + lookup.server.password, + id, + ); } async function getAlbumAfterActivation( @@ -172,7 +178,12 @@ export async function resolveShareSearchAlbum( try { const { album } = options.activateServer ? await getAlbumAfterActivation(payload.id, lookup.serverId) - : await getAlbumWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id); + : await getAlbumWithCredentials( + connectBaseUrlForServer(lookup.server), + lookup.server.username, + lookup.server.password, + payload.id, + ); return { type: 'ok', album }; } catch { return { type: 'unavailable' }; @@ -194,7 +205,12 @@ export async function resolveShareSearchArtist( try { const { artist } = options.activateServer ? await getArtistAfterActivation(payload.id, lookup.serverId) - : await getArtistWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id); + : await getArtistWithCredentials( + connectBaseUrlForServer(lookup.server), + lookup.server.username, + lookup.server.password, + payload.id, + ); return { type: 'ok', artist }; } catch { return { type: 'unavailable' }; diff --git a/src/utils/share/shareLink.test.ts b/src/utils/share/shareLink.test.ts index a5c3c2cd..a6f6aa10 100644 --- a/src/utils/share/shareLink.test.ts +++ b/src/utils/share/shareLink.test.ts @@ -267,4 +267,31 @@ describe('findServerIdForShareUrl', () => { it('returns null on an empty server list', () => { expect(findServerIdForShareUrl([], 'https://x.example')).toBeNull(); }); + + it('matches a dual-address profile by its alternateUrl', () => { + // Host generated a v2 invite with shareUsesLocalUrl=true → the share URL + // in the payload is the LAN side. The receiver pastes that URL; their + // saved profile has the public URL as primary and the LAN as alternate, + // so the lookup must match on alternateUrl, not just url. + const a = makeServer({ + id: 'a', + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10:4533', + }); + expect(findServerIdForShareUrl([a], 'http://192.168.0.10:4533')).toBe('a'); + expect(findServerIdForShareUrl([a], 'http://192.168.0.10:4533/')).toBe('a'); + }); + + it('matches either primary or alternate, prefers primary when both exist on different profiles', () => { + const a = makeServer({ + id: 'a', + url: 'https://music.example.com', + alternateUrl: 'http://192.168.0.10', + }); + const b = makeServer({ id: 'b', url: 'http://192.168.0.10:4533' }); + // 'a' matches via alternateUrl, 'b' matches via url — both are valid + // hits; the function returns the first one (insertion order). + expect(findServerIdForShareUrl([a, b], 'http://192.168.0.10')).toBe('a'); + expect(findServerIdForShareUrl([b, a], 'http://192.168.0.10:4533')).toBe('b'); + }); }); diff --git a/src/utils/share/shareLink.ts b/src/utils/share/shareLink.ts index 942cb699..993f728b 100644 --- a/src/utils/share/shareLink.ts +++ b/src/utils/share/shareLink.ts @@ -105,7 +105,14 @@ export function decodeSharePayloadFromText(text: string): EntitySharePayloadV1 | export function findServerIdForShareUrl(servers: ServerProfile[], shareSrv: string): string | null { const norm = normalizeShareServerUrl(shareSrv); - const hit = servers.find(s => normalizeShareServerUrl(s.url) === norm); + // Dual-address: a paste may carry either the host's primary url or the + // alternateUrl (depending on which the host had as their share URL at + // encode time, modulated by shareUsesLocalUrl). Match either. + const hit = servers.find( + s => + normalizeShareServerUrl(s.url) === norm || + (!!s.alternateUrl && normalizeShareServerUrl(s.alternateUrl) === norm), + ); return hit?.id ?? null; }