mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(server): dual server address (LAN + public) per profile (#880)
* feat(server): ServerProfile.alternateUrl + shareUsesLocalUrl
Additive optional fields on ServerProfile to back the dual-address work.
'alternateUrl' is the optional second endpoint (e.g. LAN counterpart of a
public 'url' or vice versa); 'shareUsesLocalUrl' chooses which of the two
goes into Orbit / entity / magic-string shares when both are set.
Pure schema change — no runtime consumers yet. Single-address profiles
keep the same persisted shape; the optional fields are simply absent.
* feat(server): serverEndpoint module with LAN classification + IPv6
Introduces a single home for connect-/share-layer URL utilities that
the dual-address work will build on:
* normalizeServerBaseUrl(raw) — aligned with serverProfileBaseUrl
* isLanUrl(url) — IPv4 (unchanged) + IPv6: ::1 loopback, fe80::/10
link-local, fc00::/7 ULA, and IPv4-mapped (both dot-decimal and the
URL-API-normalized hex form, since new URL() rewrites ::ffff:1.2.3.4
to ::ffff:HHHH:HHHH)
* allNormalizedAddresses(profile) — deduped [url, alternateUrl?]
* serverAddressEndpoints(profile) — LAN-first ServerEndpoint[]
isLanUrl moves out of useConnectionStatus.ts (the hook now imports +
re-exports it for backward compatibility); OrbitStartModal points at
the new path directly. 38 unit tests cover the IPv4/IPv6 matrix,
dedupe, ordering, and edge cases.
* feat(server): pickReachableBaseUrl with in-memory connect cache
Adds the runtime connect layer on top of serverEndpoint:
* pickReachableBaseUrl(profile) — sequential LAN-first ping via the
existing pingWithCredentials; first OK wins.
* ensureConnectUrlResolved(profile) — boot / switch / online-event
entry point (same mechanism, intent-named).
* In-memory cache keyed by profileId; sticky behaviour tries the
cached endpoint first, falls back to the natural order on miss.
* invalidateReachableEndpointCache(profileId?) — single-profile and
whole-cache flushes for profile-edit / credentials-change / online.
* getCachedConnectBaseUrl(profileId) — sync getter for getBaseUrl
fallback path (next commit).
The cache is **session-only**, never persisted. Single-address profiles
keep the same shape (one endpoint, one ping). 9 new unit tests cover
single + dual address, LAN-first preference, fallthrough, unreachable +
stale-cache clear, sticky hit, and the two invalidate flavours.
* feat(server): getBaseUrl reads connect cache, falls back to primary url
Dual-address profiles need 'getBaseUrl' to return the runtime-probed
connect URL (LAN at home, public elsewhere), not the literal primary
'url'. To keep the sync getter shape that ~60 call sites depend on,
the store now reads 'getCachedConnectBaseUrl(server.id)' from the
serverEndpoint cache.
If no probe has run yet (very early boot, before switchActiveServer
populates the cache), it falls back to the normalized primary URL —
identical to today's behaviour. Single-address profiles see no
difference; their cache entry equals serverProfileBaseUrl(url).
* feat(server): switch + connection-status probe via ensureConnectUrlResolved
Both surfaces that previously called pingWithCredentials(server.url, ...)
directly now route through the dual-address connect layer:
* switchActiveServer awaits ensureConnectUrlResolved(server), reads the
identity (type/serverVersion/openSubsonic) off the returned ping, and
hands probe.baseUrl (not server.url) to scheduleInstantMixProbeForServer
so the AudioMuse probe also hits the reachable endpoint.
* useConnectionStatus.check() does the same on every 120 s tick — sticky
cache fast-paths the steady state, and a network change naturally
flips the active endpoint without a manual retry.
* useConnectionStatus.retry() and the 'online' event flush the cached
entry for the active profile first, so the next probe starts from the
natural LAN-first order instead of revalidating a stale URL.
Single-address profiles behave identically: one endpoint in the list,
one ping per check. No behaviour change for them.
* feat(server): PickReachableResult.ping + connectBaseUrlForServer helper
Two additive extensions to the serverEndpoint surface that the upcoming
CONNECT migrations all need:
* 'ping: PingWithCredentialsResult' on the OK branch of
PickReachableResult so callers (switchActiveServer,
useConnectionStatus) can read type/serverVersion/openSubsonic from
the same probe instead of issuing a second pingWithCredentials.
* connectBaseUrlForServer(server) — sync getter for the connect URL
of *any* saved profile (active or not). Reads the cache, falls back
to normalized primary url on miss. Becomes the canonical helper
for non-active-server HTTP traffic (apiForServer, stream URLs,
cover fetches, library bind session).
Tests: pingOk() fixture now returns identity fields; the single
toEqual-asserting test in pickReachableBaseUrl became a guarded read
to keep equality concise while still asserting on the new ping field.
* feat(connect): apiForServer + stream URLs route via connectBaseUrlForServer
Non-active-server HTTP traffic (the apiForServer entry point, used by
QueuePanel cross-server cover fetches and share-paste resolution) and
the stream-URL builders previously read 'server.url' directly,
bypassing the dual-address connect cache. Both now go through
connectBaseUrlForServer, which serves the cached LAN/public endpoint
when one exists and falls back to the normalized primary url
otherwise.
buildStreamUrl(id) now uses the baseUrl it already pulled from
getBaseUrl() (which is connect-cache aware as of 2a6d8283) instead of
re-normalizing server.url — same value for single-address profiles,
correctly dual-address for the rest. Single-address callers see no
behaviour change.
* feat(connect): cover fetch + cover-cache ensure use connect URL
Both cover-fetch surfaces previously read 'scope.url' / 'server.url'
straight into HTTP requests, which would freeze the cover pipeline on
the primary URL even when a LAN endpoint was reachable:
* buildCoverArtFetchUrl(ref, tier) — for the 'server' scope (queue
rows cached against a non-active profile) and the 'playback' scope
(cross-server playback) now resolves the connect URL via
connectBaseUrlForServer before handing off to
buildCoverArtUrlForServer.
* ensureArgsFromRef in coverCache.ts — restBaseUrl for the Tauri
'cover_cache_ensure' invoke now uses the cached connect URL for
both 'server' and 'active'/'playback' scopes.
Scope.url itself remains the index-stable primary URL — that's what
storageKeys (INDEX) consume. Only the HTTP base shifts to the connect
endpoint, exactly the split the spec calls out.
* feat(connect): library bind + server-test probe via ensureConnectUrlResolved
Two more 'rebind / re-test an existing saved server' paths still pinged
the literal primary URL — both go through the dual-address connect
layer now:
* bindIndexedServer (librarySession.ts): used to ping server.url then
pass serverProfileBaseUrl(server) as the bind 'baseUrl' to Rust.
Now: one ensureConnectUrlResolved call covers both — probe.baseUrl
feeds librarySyncBindSession directly, so Rust's per-server library
sync uses whichever endpoint actually answered. Drops the
pingWithCredentials and serverProfileBaseUrl imports.
* testConnection (ServersTab.tsx): same shape — probe via the connect
layer, read identity from probe.ping, hand probe.baseUrl to
scheduleInstantMixProbeForServer so the AudioMuse probe also hits
the connect endpoint. Single-address profiles still ping once.
Add/edit save handlers (handleAddServer / handleEditServer) keep the
direct pingWithCredentials against the user-entered data.url — that
flow is the dual-address verify hook, which PR 2 extends.
* feat(server): serverShareBaseUrl — public-by-default share URL picker
Adds the share-layer companion to connectBaseUrlForServer. Different
intent: connect picks the reachable endpoint for HTTP, share picks the
URL that goes into Orbit invites / entity share payloads / queue share
links / magic strings — where a guest opening the link is not on the
host's LAN.
Logic per spec §5.1:
* Single-address profile → that one address (normalized).
* Both set, default flag → public.
* Both set, shareUsesLocalUrl flag → local.
* Edge cases (both LAN, both public, missing one of the two): fall
back to the first endpoint in the list so the function is total.
Empty profile returns the normalized url (possibly empty) — defensive,
never throws. 7 unit tests pin all six branches plus the empty case.
Call-site migration (Orbit, copyEntityShareLink, QueuePanel, magic
string srv field, findServerIdForShareUrl, Orbit LAN warning) lands
in the next commits.
* feat(share): Orbit + entity + queue shares use serverShareBaseUrl
Four share-encoding surfaces previously read 'getBaseUrl()' (connect)
or the raw 'server.url' (primary) when embedding the host into outgoing
share links — both wrong for a dual-address profile:
* copyEntityShareLink (track / album / artist / composer) — was
getBaseUrl(); now reads serverShareBaseUrl(active). Guests opening
the link are off-LAN, so public is the right default.
* OrbitStartModal — was raw server?.url for both 'buildOrbitShareLink'
and the LAN warning. Now goes through serverShareBaseUrl; the LAN
warning correspondingly reflects the address the guest will see,
not the host's primary.
* OrbitSharePopover — same fix on the host-only share popover.
* QueuePanel.handleCopyQueueShare — was getBaseUrl(); same migration.
Single-address profiles return exactly the same string from
serverShareBaseUrl as serverProfileBaseUrl(server.url) did before, so
no behaviour change for the common case. shareUsesLocalUrl flips the
default to LAN for the rare 'share into a LAN-only group' use; that
checkbox lands with the form UI in the next sub-phase.
findServerIdForShareUrl + paste-side share matchers are migrated in
the next commit (read-side of the same contract).
* feat(share): paste-side resolves dual-address profiles + connect URL
Read-side counterpart to the encode-side migration:
* findServerIdForShareUrl(servers, shareSrv) now matches a profile
when shareSrv normalizes to either profile.url OR profile.alternateUrl.
Without this, a paste of a link generated against the host's LAN
address (shareUsesLocalUrl=true) would fail to find the local saved
profile even though it's the same server.
* resolveSharedSong / resolveShareSearchAlbum / resolveShareSearchArtist
in enqueueShareSearchPayload now hand connectBaseUrlForServer(lookup.server)
to the *WithCredentials HTTP calls instead of the raw lookup.server.url.
The looked-up profile may be dual-address; this routes the song / album /
artist fetch through whichever endpoint is currently reachable.
Indirect consumers (shareServerOriginLabel, shareQueueServerContext) read
the same findServerIdForShareUrl, so no code change needed there — they
now match both addresses transparently.
* feat(verify): serverFingerprint + same-server verification
Pure-logic core of dual-address verify. Three exports:
* fetchServerFingerprint(baseUrl, user, pass) — one ping (envelope
'version' extracted alongside type/serverVersion/openSubsonic) plus
four soft-fail optional calls in parallel (getMusicFolders, getUser,
getLicense, getIndexes). Optional failures collapse to null fields,
not whole-fingerprint failure. Subsonic-generic — never branches on
type === 'navidrome'. indexesDigest is a hash of letter-count plus
sorted first 20 artist ids, so two probes against the same library
see the same digest without comparing full payloads.
* compareFingerprints(a, b) — strict on the ping triple
(type case-insensitive, serverVersion exact, openSubsonic boolean);
envelope apiVersion informational only. Body signals counted only
when both sides have a non-null value; empty musicFolders [] on both
sides still counts as a matching signal. Result: 'match' (>=1 common
signal all agreeing) / 'mismatch' (any common differs) /
'insufficient' (0 common). No 'save anyway' for insufficient in v1.
* verifySameServerEndpoints(profile, user, pass) — single-address
short-circuits to ok:true (nothing to verify). Otherwise parallel
fingerprint probes, then pairwise compare. Ping-fail on any
endpoint reports the offending host for the UI.
20 unit tests cover the compare matrix (every body signal in every
direction), Navidrome- vs minimal-Subsonic-shape probes, ping-fail,
and all four verify outcomes (ok / unreachable / mismatch /
insufficient). HTTP mocked via vi.stubGlobal('fetch') + plain-object
Response shape (avoiding any Response-polyfill dependency).
* feat(boundary): resolve_host_addresses Tauri command + TS wrapper
Adds one additive invoke for dual-address form hints. Spec §9.1
+ contracts.md §6.
Rust side (src-tauri/src/lib_commands/app_api/network.rs):
* #[tauri::command] resolve_host_addresses(hostname: String) ->
Result<Vec<String>, String>
* tokio::net::lookup_host with a port suffix (':0'); discards the
port from each SocketAddr, dedupes addresses via HashSet.
* strip_port helper handles 'host:port', 'ipv4:port',
'[ipv6]:port', '[ipv6]' (no port), and bare 'ipv6' (left as-is
for lookup_host to wrap). 7 unit tests cover each shape.
* Lookup failure → Ok(vec![]) so a DNS hiccup doesn't surface as
an error toast or block the save flow — form-hint only.
Frontend wrapper (src/api/network.ts):
* resolveHostAddresses(hostname) — trims, invokes, swallows errors
to an empty array so consumers get a clean total function.
§04 boundary note: additive command, no breaking change. Added to
the invoke_handler! generate_handler list in lib.rs at the natural
alphabetical-ish slot near migration_run.
* feat(form): AddServerForm — second optional address + share flag + DNS hint
UI side of dual-address. AddServerForm now carries the four new
moving parts spec §6 calls out:
* Second address field ('Second address (optional)') under the
primary URL. Placeholder flips to suggest the opposite kind
of address based on the primary's LAN classification.
* Contextual hint under the second field when it's empty —
'add a public address for outside-home use' (primary is LAN)
or 'add a local address for faster home access' (primary is
public). Hint disappears once the field has content.
* Two-LAN client-side check on submit: when both addresses
classify as LAN, save is blocked with a toast. Reverse case
(both public) intentionally allowed per spec §6.3.
* shareUsesLocalUrl checkbox — visibility rule per spec §5.3:
hidden until the second address has content; shows with the
persisted value on edit; cleared when the user empties the
second address before save.
DNS hint: on primary-URL blur we call the Tauri
resolve_host_addresses command and classify the response by
isLanUrl. Literal IPs skip DNS (already classified locally). DNS
miss → no hint surfaces (never blocks save). Used only for the
hint text — connect still goes through pingWithCredentials.
i18n: 9 new keys in en + ru (serverAlternateUrl*, shareUsesLocalUrl*,
serverBothLanError). Other locales fall back to English.
onSave signature widened to 'void | Promise<void>' — ServersTab /
Login both accept that already. The save flow itself still calls
the legacy pingWithCredentials path; verify wiring lands in 2e.
* feat(verify): wire verifySameServerEndpoints into add + dual-edit flows
Save flow now blocks persisting a dual-address profile that fails the
same-server check. Spec §6.4 + §7.4.
handleAddServer:
* When data.alternateUrl is non-empty, runs verifySameServerEndpoints
BEFORE the existing ping. mismatch / insufficient / unreachable each
surface a localized toast and abort the save (no addServer call).
* Single-address adds keep the legacy single-ping path — no extra
network round-trip when there's nothing to verify.
handleEditServer:
* Unconditional save remains the default — but if the edit either
introduces / changes alternateUrl OR changes url / username / password
while alternateUrl is set, verify runs first and may block.
* After persist, invalidates the reachable-endpoint cache for this
profile id so any sticky cached connect URL from before the edit is
re-probed on the next access (credentials may have changed, alternate
may have appeared).
i18n: 4 new toast keys in en + ru (dualAddressVerifying placeholder for
future progress UI, plus mismatch / insufficient / unreachable). Other
locales fall back to English.
announceVerifyResult helper keeps the toast routing in one place so
handleAddServer and handleEditServer share the same surface.
* i18n(settings): dual-address keys across all 9 locales
Adds the 13 new dual-address keys to the remaining 7 locales:
de · fr · es · nl · nb · ro · zh.
* serverAlternateUrl + placeholderPublic / placeholderLocal
* serverAlternateUrlHintAddPublic / HintAddLocal (contextual hints
under the second-address field)
* serverBothLanError (client-side two-LAN validation)
* dualAddressVerifying / Mismatch / Insufficient / Unreachable
(toast strings; Unreachable carries the {{host}} interpolation)
* shareUsesLocalUrl + Desc (checkbox label + short description)
Placeholders (example.com URL / 192.168.1.100:4533) kept identical
across all locales — same shape the existing serverUrlPlaceholder
already uses.
Single coordinated sweep so every supported language ships dual-
address fully translated rather than falling back to English.
* feat(cover): cover_cache_rename_server_bucket Tauri command
Adds the disk-side companion to the upcoming URL-change remigration
flow (dual-server-address spec §8.3). When a user edits the primary
url so the derived index key changes, the SQLite migration already
re-tags rows via the existing migration_run command — this command
moves the cover-cache bucket on disk so cached WebP tiles stay
reachable under the new key.
Behaviour:
* old_key == new_key → no-op.
* Old bucket missing → no-op success (nothing to migrate).
* New bucket missing → simple fs::rename (fastest path).
* Both exist → recursive merge with 'prefer existing' on file
collision (the newer bucket wins; data is never lost).
* Emits 'cover:bucket-renamed' with {oldKey, newKey} on success
so the frontend disk-src cache can invalidate stale URLs.
Sanitization: rejects empty, backslash, and '..' path segments at
the FS boundary. Forward slashes are legitimate (a Navidrome
mounted at a subpath like 'music.example.com/navidrome' produces
an index key with one); they're handled by Path::join.
4 unit tests cover key sanitization (accepts real keys, rejects
traversal/backslash) and the merge semantics (unique-file move +
prefer-existing on collision). Registered in lib.rs invoke handler.
* feat(remap): rewriteFrontendStoreKeysForRemap — explicit oldKey→newKey path
Adds the URL-change remigration entry point next to the existing
UUID→indexKey migration. Same plumbing (offline store, hot cache,
analysis-strategy maps) but driven by explicit { oldKey, newKey }[]
mappings instead of being derived from the current servers list.
Also folds in the player-side cleanup the existing path didn't have:
* queueServerId — if currently bound to a remapped index key,
repoint to the new one so playback continues through the rename
instead of looking unbound.
* analysisStrategyStore — runs the same map-key swap inline (the
'migrateServerOverrides' helper handles UUID→indexKey, not
indexKey→indexKey).
Same prefer-existing-on-collision semantics as the disk-side
cover_cache_rename_server_bucket — if a destination key already
carries data, we keep it. 8 unit tests cover no-ops, the four
store rewrites, the queue repoint, the 'leave other servers
untouched' guarantee, and the collision case.
* feat(remap): serverUrlRemigration orchestrator — 4-stage pipeline
Single-file orchestrator that runs the full URL-change remigration when
a profile edit shifts the primary url's derived index key. Spec §8 +
contracts.md §4.
Two exports:
* indexKeyRemapForUrlChange(prev, next): IndexKeyRemap | null
— short-circuits scheme-only edits ('http://x' ↔ 'https://x'),
trailing-slash differences, and alternateUrl-only changes by
normalizing both sides through serverIndexKeyFromUrl and
returning null when they match. Empty urls also return null.
* runIndexKeyRemigration(remap): Promise<IndexKeyRemigrationResult>
— runs inspect → run → frontend-rewrite → cover-rename in order,
aborts on the first failure and reports the offending stage.
Failure ordering rationale:
* inspect / run failures stop the destructive step — DB is
untouched, the caller can retry safely.
* frontend rewrite swallows errors (best-effort; zustand persist
catches up on rehydrate next session).
* cover-rename failure is reported but does NOT roll back the DB
rows (that would be even more destructive); covers under the
old key recover via the existing cover backfill on next access.
10 tests cover the detect logic (5 cases including the path-suffix
case) plus all four pipeline stages with mocked Tauri invoke —
including verifying the legacyId/indexKey mapping shape lands on
both inspect and run calls.
* feat(remap): wire URL-change remigration into ServersTab edit flow
handleEditServer now detects a primary-url index-key change BEFORE
any other save logic (verify, persist, etc.) and orchestrates the
full remigration when one is needed.
Flow:
* indexKeyRemapForUrlChange(prev, next) — null for scheme-only
edits / alternateUrl-only edits / no-op edits, so the common case
falls straight through without prompting the user.
* On a real remap → confirm modal via useConfirmModalStore.request
(danger style, plain-language oldKey → newKey copy, explicit
'cannot be undone'). User cancel → abort save entirely.
* On confirm → runIndexKeyRemigration pipeline. Failure surfaces a
stage-specific toast (inspect / run / cover-rename — wording per
spec §8.4 + the recoverability matrix in serverUrlRemigration.ts).
* On success → fall through to the existing dual-address verify,
then auth.updateServer + invalidate cache + ping (unchanged).
i18n: 7 new keys (urlRemigrationTitle / Message with oldKey + newKey
interpolation / Confirm / Progress / FailureInspect / FailureRun /
FailureCoverRename) — full sweep across all 9 locales (en, de, ru,
fr, es, nl, nb, ro, zh).
Test fixup: the offline-store collision test in rewriteFrontendStoreKeys
needed an 'as unknown as' cast — the existing OfflineTrackMeta type
doesn't carry the test-only marker property, and a one-step cast
TS rejected as not overlapping enough.
* feat(magic): magic-string v2 encode/decode with dual-address fields
Adds the v2 wire format for server invites. Spec §10 + contracts.md §5.2.
Encode is opportunistic: payloads with alternateUrl set OR
shareUsesLocalUrl=true emit v2; everything else stays v1 byte-identical.
This means single-address profiles keep producing exactly the same
invite they did before — older receivers can't tell the difference.
v2 shape: { v: 2, url, alt?, shareLocal?, u, w, n? }
* url — the host's share URL (public by default; LAN if shareLocal),
NOT necessarily the host's primary URL. Receiver treats it as the
primary of the new profile (their own index key).
* alt — the host's alternate address (the other half of the pair).
* shareLocal — mirrors the host's shareUsesLocalUrl preference so
onward shares from the receiver behave the same way.
Decode accepts both v1 and v2. v2-only fields are left undefined when
decoding a v1 payload (no '|| undefined' shim — kept absent so zustand
persist diffs stay clean). Defensive: an empty alt on a v2 invite
decodes as if the field were absent, never as ''.
Test updates: 'rejects a payload with the wrong version' now targets
v: 3 (v: 2 became valid). 7 new tests cover the v1/v2 wire-format
choice, both v2 round-trip shapes, the v1-decode-into-v2-fields-
undefined backward-compat case, and the empty-alt edge.
* feat(magic): wire magic-string v2 through invite-encode + paste consumers
Five surfaces now produce / accept v2 invites with the dual-address
fields end-to-end:
Encode side (admin → user invite):
* MagicStringModal — looks up the saved profile that matches the
serverUrl prop (primary OR alternateUrl normalize-match) and
encodes through serverShareBaseUrl + alternateUrl + the share
flag. Falls back to plain v1 for single-address profiles.
* UserForm — same lookup + encode plumbing on the in-form
'Save & get magic string' flow.
Decode side (paste invite into form):
* AddServerForm useEffect (initialInvite from outer state) +
handleMagicStringChange (live paste) both pull alternateUrl
and shareUsesLocalUrl off the decoded payload into form state,
so the second-address field + checkbox surface immediately on a
v2 paste.
* Login form state gains hidden alternateUrl + shareUsesLocalUrl
fields (not user-editable — Login stays single-address by
design); v2 paste / initial-invite both populate them so they
persist with the new profile via addServer. handleQuickConnect
likewise forwards the existing dual-address shape for
already-saved profiles.
* attemptConnect signature widened with the two optional fields;
addServer / updateServer call sites conditionally include them
only when alternateUrl is non-empty so single-address profiles
keep their lean persisted shape.
Both encode helpers share the same magicPayloadAddressFields lookup
(MagicStringModal + UserForm) — the duplication is acceptable for
two co-located small files but a single shared helper would be the
natural place to consolidate if a third encoder appears.
* fix(form): magic-string submit forwards decoded alternateUrl + share flag
AddServerForm.submit's magic-string branch was forwarding only
name/url/username/password from the decoded payload, silently dropping
alternateUrl + shareUsesLocalUrl. A v2 invite pasted into the
magic-string field and saved would land in the store as a single-
address profile even though handleMagicStringChange had already
populated the dual-address fields into form state for display.
Now the magic branch picks the dual-address fields off the decoded
payload (same conditional spread as the non-magic branch a few lines
down) so v2 invites round-trip end-to-end through the form.
* fix(verify): extractUserId drops username fallback to avoid false mismatches
Spec §7.2 footnote: 'fallback (normalized username) only if no id on
either side'. The old extract unconditionally fell back to the
authenticated username whenever user.id was empty — but the
comparator can't tell username-fallback apart from a server-supplied
id. So a server pair where endpoint A returns user.id='42' and
endpoint B only returns user.username='frank' would land in compare
with userId values '42' vs 'frank' and report mismatch on what is
actually the same user on the same server.
Now extractUserId returns null when user.id is absent. Both sides
returning null means compareFingerprints skips userId as a common
signal (correct behaviour per the §7.3 'both sides have a value'
rule). One new test pins it; the call site drops the redundant
fallbackUsername argument.
* fix(remap): rewriteFrontendStoreKeysForRemap migrates coverStrategyStore
Spec §8.2 lists 'analysis/cover strategy maps' as targets of the
URL-change remigration. The For-Remap path covered analysis but
missed cover — a user-set cover strategy override (e.g. 'aggressive'
for one server) would silently drop when the user edited that
profile's primary URL, because the key tagged under the old index
key never made it to the new one.
Same shape as the analysis remap inline: prefer-existing on
collision (newer key wins), delete the legacy entry afterwards.
One new test pins the move; setup adds a fresh useCoverStrategyStore
reset between cases.
* fix(cover): is_safe_index_key rejects absolute paths + drive letters
Defense-in-depth gap in cover_cache_rename_server_bucket. The old
sanitizer only rejected backslashes and '..' segments — '/etc/passwd'
on Unix and 'C:\Windows' on Windows both passed. Path::join with an
absolute argument REPLACES the base path, so root.join('/etc/passwd')
on Unix would walk out of cover-cache entirely.
Real index keys never reach this state — they come out of
serverIndexKeyFromUrl which strips schemes and trailing slashes, so
no leading separator and no drive-letter prefix is ever produced.
But the comment promises 'defense in depth' and that defense is
worth completing.
Now rejects:
* empty keys (would join to the cover-cache root itself)
* leading '/' or '\'
* Windows drive-letter prefix 'X:' (case-insensitive ASCII letter)
* backslashes anywhere (separators are forward-slash only)
* '..' segments after split('/')
One new Rust test covers all four cases.
* fix(connection): isLan badge reflects active endpoint, not primary url
useConnectionStatus.isLan was reading isLanUrl(server.url) — the
*primary* address — so a dual-address profile that had fallen over to
its public alternate kept advertising 'LAN' in the badge until the
user looked at the server URL itself. Spec call-site-checklist line
25: 'active endpoint kind for badge (LAN vs extern)'.
Now: ensureConnectUrlResolved returns probe.endpoint.kind on success;
the hook tracks the latest kind in local state and surfaces that.
Pre-first-probe fall-through keeps the old primary-URL classification
so the badge has something to render at mount time.
Three tests pin the three states (LAN-active after probe, public-
active after fallback, primary-fallback before first probe completes);
plus one for the online-event handler that invalidates the cache and
re-probes.
* fix(server): dedupe concurrent pickReachableBaseUrl calls for same profile
Multiple call sites probe on roughly the same beat — useConnectionStatus
120-s tick + online handler + initial mount, switchActiveServer,
bindIndexedServer, and ServersTab.testConnection. Two near-simultaneous
probes for the same profile both observed an empty cache, both pinged
every endpoint, and raced to write the sticky URL with last-write-wins.
On a dual-address profile the slower probe could stomp the correct
LAN sticky a millisecond after it was set.
Now: an in-flight Map<profileId, Promise<PickReachableResult>>; a
second call for the same id during an active probe returns the same
promise instead of starting a fresh one. The finally-clear keeps the
dedup window narrow (one settled probe → next call probes fresh).
invalidateReachableEndpointCache deliberately doesn't touch the
in-flight map — the racing probe's own finally will clean up.
Three tests pin it: two concurrent calls share one ping; a fresh
call after the previous settled does ping again; plus the existing
shareBaseUrl two-LAN-with-flag-on test + the two-public-default test
for completeness.
* refactor(magic): extract magicPayloadAddressFields to single shared helper
The lookup that turns a serverUrl into the v2-encode-ready
{ url, alternateUrl?, shareUsesLocalUrl? } shape was duplicated
byte-identically between MagicStringModal.tsx and UserForm.tsx —
both Navidrome admin user-mgmt encode sites. Workdocs §03 / §1a
'one source of truth for behavior'.
Now lives in src/utils/server/serverMagicString.ts as a named
export. Both call sites import + pass the current servers list
(via useAuthStore.getState().servers) — keeps the helper pure so
it's straight to unit-test if a third encoder ever appears.
* fix(cover): wire cover:bucket-renamed listener for URL-change remigration
cover_cache_rename_server_bucket emits cover:bucket-renamed on
successful rename / merge, but no frontend listener was wiring it in.
After a URL-change remigration the in-memory disk-src cache still
held entries pointing at `{root}/{oldKey}/…/.webp` paths the disk
no longer carries — the next read would serve a stale URL until
the entry naturally aged out.
New: forgetDiskSrcForServer(serverIndexKey) in diskSrcCache drops
every cover entry under one server key in one pass (the existing
forgetDiskSrcPrefix needs a non-empty coverArtId and can't blanket-
clear a server). useCoverArtBridge subscribes to cover:bucket-
renamed and calls it with oldKey.
Tests: forgetDiskSrcForServer happy path, empty-key defensive
no-op, no-match no-op; plus a regression-pin on the existing
forgetDiskSrcPrefix so the two helpers don't drift in semantics.
* test(dual-server): fill the high-impact coverage gaps from the branch review
Adds the tests the review identified as missing on the hot-path UI
flows + the partial-fail / alt-only edges:
* AddServerForm.test.tsx (new) — five component tests:
single-address save / dual-address save / two-LAN block-with-
toast / v2 magic-string paste forwarding alt + share flag /
edit-flow stripping alt+flag when the field is emptied.
* Login.test.tsx (new) — v2 invite paste persists alternateUrl +
shareUsesLocalUrl onto the saved profile; v1 invite leaves
those fields undefined.
* shareLink.test.ts — alternateUrl-only match for
findServerIdForShareUrl (the dual-address paste case where the
host shared the LAN URL via shareUsesLocalUrl=true); 'first hit
wins' across two profiles where both match.
* serverFingerprint.test.ts — partial-fail mix (folders+user ok,
license+indexes rejected) so a Promise.allSettled regression
wouldn't go silent.
* cover_cache/mod.rs — rename_bucket_inner extracted as a
testable FS-only helper (the Tauri command wrapper now just
locks state + calls it + emits the event), plus six tests
covering empty/unsafe keys, no-op-when-missing, no-op-when-
equal, simple-rename, merge-with-prefer-existing.
Also: every test fixture that I authored on this branch using
'frank' / 'frank@example.com' as a stand-in username/email
swapped to 'tester' / 'tester@example.com' — keeping personal
names out of test data is the codebase convention (see
factories.ts) and is now a memory rule.
* fix(test): typed mock access for pingWithCredentials in useConnectionStatus
vi.mocked(...) returns the mock typed properly — bare .mock fails
tsc because the imported function signature isn't a vi.Mock at
import time.
* docs(changelog): dual server address (PR #880)
* fix(test): align diskSrcCache tests with main cover storage key API
After rebase onto main, forgetDiskSrcPrefix takes a CoverArtRef-shaped
argument and storage keys include cacheKind; merge coverDiskUrl tests
from main with dual-address forgetDiskSrcForServer coverage.
This commit is contained in:
committed by
GitHub
parent
091e61f7a5
commit
9fa086c428
@@ -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<BindServerResult> {
|
||||
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();
|
||||
|
||||
@@ -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<string, { tag: string }>;
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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<typeof pingOk>) => 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');
|
||||
});
|
||||
});
|
||||
@@ -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<ServerProfile, 'url' | 'alternateUrl'>,
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<ServerProfile, 'url' | 'alternateUrl'>,
|
||||
): 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<ServerProfile, 'url' | 'alternateUrl' | 'shareUsesLocalUrl'>,
|
||||
): 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<string, string>();
|
||||
|
||||
/**
|
||||
* 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<string, Promise<PickReachableResult>>();
|
||||
|
||||
/**
|
||||
* 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<ServerProfile, 'id' | 'url'>,
|
||||
): 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<PickReachableResult> {
|
||||
// Dedupe concurrent calls for the same profile — see `inFlightProbes`.
|
||||
const existing = inFlightProbes.get(profile.id);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = (async (): Promise<PickReachableResult> => {
|
||||
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<PickReachableResult> {
|
||||
return pickReachableBaseUrl(profile);
|
||||
}
|
||||
@@ -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<typeof import('../../api/subsonicClient')>();
|
||||
return {
|
||||
...original,
|
||||
apiWithCredentials: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { apiWithCredentials } from '../../api/subsonicClient';
|
||||
import {
|
||||
compareFingerprints,
|
||||
fetchServerFingerprint,
|
||||
verifySameServerEndpoints,
|
||||
type ServerFingerprint,
|
||||
} from './serverFingerprint';
|
||||
|
||||
function makeFingerprint(overrides: Partial<ServerFingerprint> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<PingFingerprint> {
|
||||
// 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<string, unknown> | 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<string, unknown>;
|
||||
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? 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<string, unknown> | 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<string, unknown> | null)?.['user'] as
|
||||
| Record<string, unknown>
|
||||
| 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<string, unknown> | null)?.['license'] as
|
||||
| Record<string, unknown>
|
||||
| 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<string, unknown> | null)?.['indexes'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!indexes) return null;
|
||||
const letters = Array.isArray(indexes.index) ? (indexes.index as Array<Record<string, unknown>>) : [];
|
||||
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<Record<string, unknown>>) : [];
|
||||
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<ServerFingerprint> {
|
||||
// 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<Record<string, unknown>>(baseUrl, username, password, 'getMusicFolders.view'),
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getUser.view', { username }),
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getLicense.view'),
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getIndexes.view'),
|
||||
]);
|
||||
|
||||
const [foldersResult, userResult, licenseResult, indexesResult] = settled;
|
||||
|
||||
const value = <T>(r: PromiseSettledResult<T>): 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<ServerProfile, 'url' | 'alternateUrl'>,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<VerifySameServerResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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<ServerProfile, 'url'>,
|
||||
next: Pick<ServerProfile, 'url'>,
|
||||
): 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<IndexKeyRemigrationResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<boolean
|
||||
}
|
||||
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
serverVersion: ping.serverVersion,
|
||||
openSubsonic: ping.openSubsonic,
|
||||
type: probe.ping.type,
|
||||
serverVersion: probe.ping.serverVersion,
|
||||
openSubsonic: probe.ping.openSubsonic,
|
||||
};
|
||||
const auth = useAuthStore.getState();
|
||||
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);
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
return true;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { serverShareBaseUrl } from '../server/serverEndpoint';
|
||||
import { encodeSharePayload, type EntityShareKind } from './shareLink';
|
||||
import { copyTextToClipboard } from '../server/serverMagicString';
|
||||
|
||||
/** Copies a track / album / artist / composer share link (`psysonic2-`) to the clipboard. */
|
||||
export async function copyEntityShareLink(kind: EntityShareKind, id: string): Promise<boolean> {
|
||||
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() }));
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user