mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2a5b8853 | |||
| 0286b9b161 | |||
| 61947ee5a7 | |||
| f66ad3b578 | |||
| 8dfe5f9094 | |||
| 42a84932e6 | |||
| 0d83a6e957 | |||
| d6a03532b2 | |||
| 2fd99c7097 | |||
| 8ec32d7057 | |||
| 38843103b5 | |||
| 688aa5dff6 | |||
| 90b7851785 | |||
| 78a177b1bc | |||
| 9b03949f34 | |||
| a4fbd45d5d | |||
| 15cecb5d7d | |||
| 2c9b2eeb46 | |||
| bd43ea5240 | |||
| 9a31fe8295 | |||
| 9323f5fee6 | |||
| d162c2557b | |||
| 93166c774a | |||
| e69b89b28a | |||
| ec8cfbc1c8 | |||
| 887c940e1b | |||
| d9969ed76f | |||
| 986550c854 | |||
| 30ccaf51a8 | |||
| 538dbe3db1 | |||
| a5313d5cb1 | |||
| b950d4704b | |||
| 23f8008248 | |||
| b9b4f76c11 | |||
| d8e5d4eed4 | |||
| 2d1a078186 | |||
| c037ab459a | |||
| 955a9fcbd6 | |||
| c428d37e0e | |||
| 4225146a16 | |||
| d50c9c444d | |||
| 99c0b6cdac | |||
| ee044ece1a | |||
| fde7ab432f | |||
| f28e82c022 | |||
| 0f580f58c8 | |||
| a6ee0668c8 | |||
| ed52a9991f | |||
| ad74578ef6 | |||
| ccb2d11fc4 | |||
| 44d373d7bb | |||
| 116196f0d4 | |||
| 68b21643f8 | |||
| 8498d5a566 | |||
| 3ec65a6407 | |||
| 47b09d6f25 | |||
| 1e956d6043 | |||
| 82967caa9c | |||
| a6122f9db4 | |||
| 6d63365c2a | |||
| 6168e81195 | |||
| 067ed00ae2 | |||
| 16e562b42d | |||
| 961dba996c | |||
| 4fd558fa28 | |||
| 42dcbb9323 | |||
| 997e697a53 | |||
| 4c0dfaaada | |||
| e563749ace | |||
| 15fb0f6c56 | |||
| 6d404fdc2d | |||
| c453f01b94 |
+281
@@ -9,6 +9,287 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Added
|
||||
|
||||
### Theme store — version numbers and an animated/static filter
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1104](https://github.com/Psychotoxical/psysonic/pull/1104)**
|
||||
|
||||
* Theme versions now show in the store (next to the author) and under each installed community theme; when an update is available, the store shows the installed → available version.
|
||||
* New store filter to show only animated themes or only static ones, next to the existing mode and sort controls.
|
||||
|
||||
### Playlist folders
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1119](https://github.com/Psychotoxical/psysonic/pull/1119)**, suggested by [@SilverWolf24](https://github.com/SilverWolf24)
|
||||
|
||||
* Organise your playlists into folders on the Playlists page and in the sidebar — create folders, drag playlists into them (or use the right-click "Move to folder" menu), rename, collapse and switch between the folder view and a single flat list. Folders are saved locally on this device only, since the Subsonic API has no folder support.
|
||||
|
||||
### AutoDJ — content-aware crossfade
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1122](https://github.com/Psychotoxical/psysonic/pull/1122) and [@Psychotoxical](https://github.com/Psychotoxical), PR [#1124](https://github.com/Psychotoxical/psysonic/pull/1124)**
|
||||
|
||||
* New **AutoDJ** crossfade mode. Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
|
||||
* AutoDJ is now its own mode rather than a sub-option of Crossfade — its own button in the queue toolbar and its own entry in the audio settings. Crossfade, AutoDJ and Gapless are mutually exclusive (only one active at a time) under a single Off / Gapless / Crossfade / AutoDJ picker, the playback settings are regrouped into clearer Normalization / Track transitions / Queue behaviour panels, and the queue toolbar's separate Save and Load playlist buttons are combined into one Playlist menu (existing toolbar layouts are preserved). Off by default; classic Crossfade is unchanged.
|
||||
|
||||
### AutoDJ — smooth skip and interrupt blend
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1128](https://github.com/Psychotoxical/psysonic/pull/1128)**
|
||||
|
||||
* New **Smooth skip** toggle under Settings → Audio → Track transitions (on by default when AutoDJ is active). Manual Next/Previous and picking a track from the library, an album, or the infinite queue crossfade from where you are listening instead of hard-cutting.
|
||||
* Loud→loud queue advances use a consistent ~2s musical blend; manual skips cap at the same length so quiet intros are not drowned out.
|
||||
* When the target track is not buffered yet, the player briefly ducks the outgoing track while preloading; the player bar keeps showing the current song until the handoff so titles and artwork do not flicker or pause spuriously.
|
||||
* During an active blend, the play/pause button shows a pulsing Blend icon.
|
||||
|
||||
### Play queue sync — cross-device handoff
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1131](https://github.com/Psychotoxical/psysonic/pull/1131)**, closes [#1129](https://github.com/Psychotoxical/psysonic/issues/1129)
|
||||
|
||||
* Manual **pull** from the header connection indicator (LED + sync ring): click to fetch the active server's play queue when it differs from the local player; no-op when already in sync. Yellow LED when browse server ≠ playback server (e.g. after switching servers).
|
||||
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
|
||||
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
|
||||
|
||||
### Japanese translation
|
||||
|
||||
**By [@Soli0222](https://github.com/Soli0222), PR [#1134](https://github.com/Psychotoxical/psysonic/pull/1134)**
|
||||
|
||||
* Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
### Artist artwork from fanart.tv
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137)**
|
||||
|
||||
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
|
||||
|
||||
### Remember the equalizer per audio output device
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1146](https://github.com/Psychotoxical/psysonic/pull/1146)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
|
||||
|
||||
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
|
||||
|
||||
### Hungarian translation
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
|
||||
|
||||
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
||||
|
||||
### Custom HTTP headers for gated servers
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
|
||||
|
||||
* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles.
|
||||
* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings.
|
||||
* Gate header values are redacted from application logs.
|
||||
|
||||
### Orbit — shared crossfade, gapless and AutoDJ
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1158](https://github.com/Psychotoxical/psysonic/pull/1158)**
|
||||
|
||||
* In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave.
|
||||
* While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Settings — consistent grouped layout
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1126](https://github.com/Psychotoxical/psysonic/pull/1126) and PR [#1130](https://github.com/Psychotoxical/psysonic/pull/1130)**
|
||||
|
||||
* The settings tabs now group related controls into clearly bordered, labelled panels for a more consistent, easier-to-scan layout — across Appearance, System, Audio, Storage, Library, Integrations, Music Network, Lyrics, Personalisation, Input and Themes. Standalone toggles are left as they were, and a few duplicated section titles are gone.
|
||||
* The **Lucky Mix menu** toggle moved from the Library tab to the sidebar customizer, alongside the other navigation toggles.
|
||||
* The **Native Hi-Res Playback** description now explains what turning it on actually does — play each track at its original sample rate, matching the audio device to the file, instead of resampling everything to 44.1 kHz. The old wording described the off state and read as if the option forced 44.1 kHz.
|
||||
* **Settings → Audio**: **Normalization** and **Track transitions** are now their own top-level categories (directly under Audio Output Device) instead of being grouped together inside one *Playback* section.
|
||||
* **Settings → Personalisation** gains a **Queue Settings** category that brings the queue display mode, the queue toolbar customizer, and the **Preserve "Play Next" order** toggle (moved here from Audio) together in one place.
|
||||
* On macOS, the **Audio Output Device** category is now hidden rather than showing a notice — playback there always follows the system output device.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playlists header buttons clipped at narrow widths
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
|
||||
|
||||
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
|
||||
|
||||
### Seeking in streamed Opus/Ogg tracks
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1110](https://github.com/Psychotoxical/psysonic/pull/1110)**
|
||||
|
||||
* Scrubbing an Opus/Ogg track that was still streaming did nothing — the seekbar snapped back, and seeking only worked once the track had fully downloaded. Seeking now works mid-stream: the player fetches just the part of the file it needs over HTTP instead of waiting for the whole track to download. Cached and local files are unchanged. (Follow-up to the 1.48.1 Opus/Ogg seek-crash fix, #1100, which made streamed seeking a safe no-op rather than a crash.)
|
||||
|
||||
### Media buttons missing from the Windows taskbar preview
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1112](https://github.com/Psychotoxical/psysonic/pull/1112)**
|
||||
|
||||
* The Previous / Play-Pause / Next buttons in the Windows taskbar thumbnail preview (the popup shown when hovering the taskbar icon) had stopped appearing. They are back, and the middle button's icon again reflects the current playback state.
|
||||
|
||||
### Album sorting within artists
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1115](https://github.com/Psychotoxical/psysonic/pull/1115), PR [#1120](https://github.com/Psychotoxical/psysonic/pull/1120)**, suggested by [@kingley82](https://github.com/kingley82)
|
||||
|
||||
* When browsing albums sorted by artist, each artist's albums appeared in an arbitrary order. They are now ordered A–Z by album title within each artist.
|
||||
* New **Artist → Year** sort option groups albums by artist and orders each artist's albums chronologically (oldest first).
|
||||
|
||||
### "Add to playlist" from the player bar added the whole album
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1117](https://github.com/Psychotoxical/psysonic/pull/1117)**
|
||||
|
||||
* Right-clicking the current track in the player bar opened an album menu, so "Add to playlist" added the entire album instead of the playing song. The player bar menu now acts on the current song.
|
||||
|
||||
### Security — transitive form-data CRLF injection (GHSA-hmw2-7cc7-3qxx)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1118](https://github.com/Psychotoxical/psysonic/pull/1118)**
|
||||
|
||||
* Bumped transitive `form-data` 4.0.5 → 4.0.6 (via axios) to close Dependabot alert [#18](https://github.com/Psychotoxical/psysonic/security/dependabot/18) for CRLF injection in multipart field names (CVE-2026-12143). Psysonic only uses axios for GET requests, so exploitability was low; the lockfile bump clears the advisory.
|
||||
|
||||
### Live listener badge stale when the popover was closed
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1125](https://github.com/Psychotoxical/psysonic/pull/1125)**
|
||||
|
||||
* The Live header badge only refreshed `getNowPlaying` while the "Who is listening?" popover was open, so the listener count could stay stale or hidden until opened. Poll every 30 s while the window is visible (10 s while the popover is open); background fetches are silent so the header does not flash a loading state.
|
||||
|
||||
### Niri compositor tiling WM detection
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1127](https://github.com/Psychotoxical/psysonic/pull/1127)**
|
||||
|
||||
* Niri is now recognized as a tiling window manager (`NIRI_SOCKET`, `XDG_CURRENT_DESKTOP=niri`), so it gets the same custom title bar, window decorations, and mini-player behavior as Hyprland and Sway instead of being treated like a floating desktop.
|
||||
|
||||
### Play queue idle pull overwrote local edits
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
|
||||
|
||||
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
|
||||
|
||||
### Paused client did not push edited queue on Play
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
|
||||
|
||||
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
|
||||
|
||||
### Connection indicator flapping on flaky links
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1135](https://github.com/Psychotoxical/psysonic/pull/1135)**
|
||||
|
||||
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
|
||||
|
||||
### Yellow sync LED during normal playback
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
|
||||
|
||||
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
|
||||
|
||||
### Update notification — clearer popup on Linux
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
|
||||
|
||||
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
||||
|
||||
### Favorites — bulk add to playlist and play/enqueue selected
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
|
||||
|
||||
* Bulk **Add to playlist** no longer cleared the selection on `mousedown` before the click ran, so chosen tracks were not actually added.
|
||||
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
|
||||
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
|
||||
|
||||
### Artists letter index — Navidrome ignored articles
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**, closes [#1144](https://github.com/Psychotoxical/psysonic/issues/1144)
|
||||
|
||||
* On the **Artists** page (and **Composers**), the A–Z filter now groups names like Navidrome: leading articles such as **The** are skipped before picking the letter — **The Beatles** lands under **B**, not **T**. The bucket follows the server's own `ignoredArticles` list when the local index knows it.
|
||||
* The local library index stores `name_sort` and the server's `ignoredArticles` from `getArtists`, sorts browse SQL by the sort key (now indexed), and repairs stale keys once on upgrade.
|
||||
|
||||
### Library index — safer open, swap and recovery
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**
|
||||
|
||||
* The local library database now opens, swaps and restores through one pipeline, so a swapped or restored file always picks up pending migrations and one-time repairs instead of serving a stale schema.
|
||||
* A panic or a poisoned lock in one query no longer wedges the whole library index — connections recover and report the error instead, and the new sort-key migration applies idempotently so a half-applied upgrade self-heals on the next launch.
|
||||
|
||||
### Equalizer — the active AutoEQ profile name stays visible
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1147](https://github.com/Psychotoxical/psysonic/pull/1147)**
|
||||
|
||||
* After applying an AutoEQ headphone profile, the preset picker now shows the profile name under an AutoEQ group instead of going blank, and the delete button no longer appears for AutoEQ profiles (where it did nothing).
|
||||
|
||||
### All Albums — compilation and favorites filters
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@bcorporaal](https://github.com/bcorporaal), PR [#1151](https://github.com/Psychotoxical/psysonic/pull/1151)**, closes [#1143](https://github.com/Psychotoxical/psysonic/issues/1143)
|
||||
|
||||
* **Only compilations** no longer shows a handful of albums after the local index already filtered them — slice mode skips the redundant client pass that dropped rows without `isCompilation` on the DTO.
|
||||
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
|
||||
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
|
||||
|
||||
### Orbit — session reliability fixes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
|
||||
|
||||
* Opening Psysonic on a second device no longer deletes a session that is still live on another device.
|
||||
* Long sessions keep updating for guests instead of silently stalling once the shared state grew too large.
|
||||
* Radio no longer adds unrelated tracks to a guest's queue mid-session.
|
||||
* Auto-shuffle and auto-approve are independent again — toggling one in an older session no longer flips the other.
|
||||
* A session is kept within its guest limit even when several people join at once.
|
||||
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
|
||||
* Pasted invites are rejected unless they point at a normal http/https server address.
|
||||
|
||||
## [1.48.1] - 2026-06-15
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback freeze on track changes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
|
||||
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
|
||||
|
||||
### Paused or stopped playback restarting on headphone disconnect (macOS)
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
|
||||
|
||||
### Crash when seeking Opus/Ogg files
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1100](https://github.com/Psychotoxical/psysonic/pull/1100)**
|
||||
|
||||
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
|
||||
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
|
||||
|
||||
### Discord Rich Presence cover art missing with two server addresses
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
|
||||
|
||||
### "Minimize to Tray" ignored on the macOS close button
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
|
||||
|
||||
### Library sync stalling for many seconds on large Navidrome collections
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1105](https://github.com/Psychotoxical/psysonic/pull/1105)**
|
||||
|
||||
* On large libraries (reported with ~200,000 tracks on Navidrome), background library sync could lock up database writes for minutes at a time — playback history, ratings and other saves piled up waiting behind it.
|
||||
* Root cause: the track id-remap step ran a database lookup that couldn't use its indexes and scanned the entire track table once per incoming track, so the cost grew with the square of the library size. The lookup now uses the proper indexes, bringing it back to a fast, near-instant operation.
|
||||
|
||||
### Album cover missing in Windows media controls
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) showed the track title and artist but no album cover. Windows could not decode the cached WebP cover art for its thumbnail, even with the Store WebP extension installed. The cover is now converted to PNG before it is handed to the media controls, so the artwork shows again. macOS and Linux are unaffected.
|
||||
|
||||
### Windows media controls showed "Unknown application"
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) labelled playback as "Unknown application" with no icon. The app now registers an explicit application identity at startup so Windows shows "Psysonic" and its icon as the playback source.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -14,11 +14,11 @@ Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **A
|
||||
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
|
||||
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic/1.47.0"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian and Chinese.
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese and Hungarian.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
@@ -134,7 +134,7 @@ Start a session, invite others with a link and listen together with host-control
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native installer |
|
||||
| Windows | Native installer / WinGet |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
@@ -154,7 +154,14 @@ Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
or,
|
||||
install via Windows Package Manager (WinGet):
|
||||
```powershell
|
||||
winget install Psysonic
|
||||
```
|
||||
|
||||
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
|
||||
|
||||
## macOS
|
||||
|
||||
@@ -194,6 +201,12 @@ See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVA
|
||||
|
||||
---
|
||||
|
||||
# Reviews
|
||||
|
||||
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
|
||||
|
||||
---
|
||||
|
||||
# Community & Support
|
||||
|
||||
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
|
||||
|
||||
@@ -7,6 +7,86 @@ current line before promoting to `next` / `release`. Technical details and PR cr
|
||||
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
|
||||
### Playlist folders — your playlists, organised
|
||||
|
||||
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
|
||||
|
||||
### Theme store — spot updates, pick your style
|
||||
|
||||
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
|
||||
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
|
||||
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
### Japanese — now in your language
|
||||
|
||||
- Psysonic is now available in **Japanese (日本語)** — pick it from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Artist artwork — richer artist and fullscreen views
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
|
||||
### Equalizer — a profile per output device
|
||||
|
||||
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
|
||||
### Windows
|
||||
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
|
||||
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
|
||||
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
|
||||
|
||||
### Other
|
||||
|
||||
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
|
||||
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-nwCrZ0enhyLNkS15sgSR9CuQX9cif4fLDNAL2yUPG8s="
|
||||
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
|
||||
}
|
||||
|
||||
Generated
+13
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -2466,16 +2466,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -2592,9 +2592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -3674,9 +3674,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz",
|
||||
"integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
@@ -69,5 +69,8 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.28.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.46.0
|
||||
pkgver=1.48.1
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+9
-7
@@ -4114,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4186,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4216,16 +4216,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"tauri",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4242,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4257,7 +4259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use psysonic_core::ports::PlaybackQueryHandle;
|
||||
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
|
||||
|
||||
@@ -679,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
|
||||
})
|
||||
}
|
||||
|
||||
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
|
||||
async fn analysis_backfill_download_bytes(
|
||||
app: &tauri::AppHandle,
|
||||
server_id: &str,
|
||||
url: &str,
|
||||
) -> Result<(Vec<u8>, u64), String> {
|
||||
let fetch_started = std::time::Instant::now();
|
||||
let response = analysis_http_client()
|
||||
.get(url)
|
||||
let registry = app
|
||||
.try_state::<Arc<ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
let request = apply_optional_registry_headers(
|
||||
registry.as_deref(),
|
||||
Some(server_id),
|
||||
url,
|
||||
analysis_http_client().get(url),
|
||||
);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -781,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
|
||||
let app = app.clone();
|
||||
let shared = shared.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let download_result = analysis_backfill_download_bytes(&url).await;
|
||||
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
|
||||
{
|
||||
let mut st = shared
|
||||
.state
|
||||
|
||||
@@ -40,6 +40,8 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Power",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -11,7 +11,7 @@ use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::build_source;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
@@ -20,7 +20,7 @@ use super::sink_swap::{
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
|
||||
SinkSwapInputs,
|
||||
};
|
||||
use super::playback_rate::preserve_pitch_will_run;
|
||||
use super::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
@@ -57,10 +57,29 @@ pub async fn audio_play(
|
||||
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
|
||||
// `false` so older/external `audio_play` callers that omit it still work.
|
||||
start_paused: Option<bool>,
|
||||
// Silence-aware crossfade (B-head): begin playback past the next track's
|
||||
// leading silence. Optional + defaults to `0` so existing callers are
|
||||
// unaffected; only applied when the freshly built source is seekable.
|
||||
start_secs: Option<f64>,
|
||||
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
|
||||
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
|
||||
// transition instead of the global `crossfade_secs`. `None` → use the global
|
||||
// setting (today's behaviour); always still clamped to the measured remaining.
|
||||
crossfade_secs_override: Option<f32>,
|
||||
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
|
||||
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
|
||||
// already fades out in the recording, so let it ride at full engine gain
|
||||
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
|
||||
// B's fade (today's behaviour). Always clamped to A's measured remaining.
|
||||
outgoing_fade_secs_override: Option<f32>,
|
||||
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
|
||||
// while a track is playing. Optional; only honoured when `manual` is true.
|
||||
manual_autodj_blend: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let start_paused = start_paused.unwrap_or(false);
|
||||
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// ── Ghost-command guard ───────────────────────────────────────────────────
|
||||
@@ -220,9 +239,15 @@ pub async fn audio_play(
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
|
||||
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
|
||||
let crossfade_enabled =
|
||||
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
|
||||
// Per-transition override (dynamic crossfade) caps the fade for this swap;
|
||||
// otherwise fall back to the global crossfade length. Both clamped the same.
|
||||
let crossfade_secs_val = crossfade_secs_override
|
||||
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
|
||||
.clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
@@ -247,6 +272,19 @@ pub async fn audio_play(
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
|
||||
// override means A already fades out in the recording, so we leave it at
|
||||
// full engine gain (scenario A). Never longer than A's remaining audio.
|
||||
let outgoing_fade_secs: f32 = if crossfade_enabled {
|
||||
match outgoing_fade_secs_override {
|
||||
Some(v) => v.max(0.0).min(actual_fade_secs),
|
||||
None => actual_fade_secs,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
@@ -286,8 +324,9 @@ pub async fn audio_play(
|
||||
e
|
||||
})?;
|
||||
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
|
||||
let source_seekable = playback_source.is_seekable;
|
||||
let built = playback_source.built;
|
||||
let source = built.source;
|
||||
let mut source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
let output_rate = built.output_rate;
|
||||
let output_channels = built.output_channels;
|
||||
@@ -397,6 +436,17 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
// Silence-aware crossfade (B-head): skip the next track's leading silence by
|
||||
// seeking the freshly built source before it is appended. The outermost
|
||||
// `CountingSource` stores the sample counter on a successful seek; we still
|
||||
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
|
||||
// so the seekbar and the crossfade-remaining math are content-relative.
|
||||
let did_start_seek = if start_secs > 0.05 && source_seekable {
|
||||
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
@@ -423,9 +473,29 @@ pub async fn audio_play(
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
|
||||
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
|
||||
// sample counter to the content offset so position reporting is correct.
|
||||
if did_start_seek {
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.seek_offset = start_secs;
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
start_secs,
|
||||
output_rate,
|
||||
output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
if defer_playback_start {
|
||||
if !start_paused {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
@@ -455,6 +525,7 @@ pub async fn audio_play(
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
Some(analysis_app),
|
||||
@@ -526,7 +597,14 @@ pub async fn audio_chain_preload(
|
||||
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let resp = audio_http_client(&state).get(&url).send().await
|
||||
let resp = crate::engine::playback_scoped_get(
|
||||
&state,
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(()); // silently fail — audio_play will retry
|
||||
|
||||
@@ -169,8 +169,14 @@ impl SizedDecoder {
|
||||
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||
// seekability during probe (same as `new_streaming`) so preview does not
|
||||
// read the entire in-memory file before the first sample.
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
//
|
||||
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
|
||||
// otherwise its demuxer never records `phys_byte_range_end` and the first
|
||||
// seek panics (see `container_hint_is_ogg`). This source is fully
|
||||
// in-memory, so the trailing-metadata scan it re-enables is free.
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !crate::stream::container_hint_is_ogg(format_hint);
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate {
|
||||
inner: Box::new(source),
|
||||
@@ -315,19 +321,33 @@ impl SizedDecoder {
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
/// `source_random_access`: the underlying source can cheaply seek to EOF
|
||||
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
|
||||
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
|
||||
pub(crate) fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
source_random_access: bool,
|
||||
) -> Result<Self, String> {
|
||||
// For non-MP4 progressive streams, hide seekability during the probe so
|
||||
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
|
||||
// and block until the whole file is downloaded). Re-enabled right after.
|
||||
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
|
||||
// prefetched separately).
|
||||
//
|
||||
// Ogg also keeps seekability through the probe, but only on random-access
|
||||
// sources: its demuxer records `phys_byte_range_end` during the probe and
|
||||
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
|
||||
// local file the stream-end scan is cheap; on a progressive ranged stream
|
||||
// it would force a full download, so there we keep the gate and accept
|
||||
// that seeking is a no-op (the panic itself is contained in `try_seek`).
|
||||
let stream_len = media.byte_len();
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let ogg_needs_seekable_probe =
|
||||
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !ogg_needs_seekable_probe;
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
|
||||
None => media,
|
||||
@@ -588,20 +608,36 @@ impl Source for SizedDecoder {
|
||||
|
||||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||||
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||||
))?;
|
||||
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
|
||||
// `None` in `OggReader::do_seek`) on some streams instead of returning
|
||||
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
|
||||
// panic poisons the engine mutexes and then aborts the whole process at
|
||||
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
|
||||
// symptom of that poison). Contain the unwind here — including the packet
|
||||
// reads in `refine_position`, which can hit the same broken demuxer state —
|
||||
// and surface it as a recoverable `SeekError` so the engine stays alive
|
||||
// (the seek becomes a no-op rather than killing playback).
|
||||
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.refine_position(seek_res)?;
|
||||
Ok::<(), String>(())
|
||||
}));
|
||||
|
||||
self.refine_position(seek_res)
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e))
|
||||
))?;
|
||||
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
match seek_outcome {
|
||||
Ok(Ok(())) => {
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other(e),
|
||||
))),
|
||||
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other("seek panicked inside the demuxer (contained)"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,8 +1055,9 @@ mod tests {
|
||||
#[test]
|
||||
fn new_streaming_constructs_from_synthetic_wav() {
|
||||
let wav = synthetic_wav_bytes(0.5);
|
||||
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
|
||||
.expect("streaming WAV decode setup");
|
||||
let decoder =
|
||||
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
|
||||
.expect("streaming WAV decode setup");
|
||||
assert_eq!(decoder.spec.rate(), 44_100);
|
||||
assert_eq!(decoder.spec.channels().count(), 1);
|
||||
// Live streams report no total duration.
|
||||
@@ -1033,6 +1070,7 @@ mod tests {
|
||||
seekable_source(vec![0x00u8; 64]),
|
||||
None,
|
||||
"test-stream",
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
@@ -211,6 +212,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
outgoing_fade_secs: 0.0,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
@@ -260,6 +262,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
engine.chained_info.clone(),
|
||||
engine.crossfade_enabled.clone(),
|
||||
engine.crossfade_secs.clone(),
|
||||
engine.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
Some(analysis_app),
|
||||
|
||||
@@ -82,6 +82,15 @@ pub(crate) async fn reopen_output_stream(
|
||||
if !opened {
|
||||
return false;
|
||||
}
|
||||
// When we're not actively playing (paused/stopped), bump the generation
|
||||
// before stopping the old sink so the still-running progress task sees the
|
||||
// mismatch and bails out instead of emitting a spurious `audio:ended` —
|
||||
// which would otherwise trigger a frontend restart of paused playback
|
||||
// (#1094). The active-playback path bumps inside
|
||||
// `try_resume_after_device_change`, so only guard the non-playing case here.
|
||||
if !snapshot.is_playing {
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
||||
|
||||
@@ -60,6 +61,15 @@ pub struct AudioEngine {
|
||||
pub(crate) stream_playback_armed: Arc<AtomicBool>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>,
|
||||
/// AutoDJ: when true, the progress task does NOT fire its autonomous
|
||||
/// `crossfade_secs`-before-end `audio:ended` timer — the JS A-tail logic
|
||||
/// drives every advance (gated on the next track being playable). Prevents
|
||||
/// the engine from starting a still-buffering next track and fading over it
|
||||
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
|
||||
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
|
||||
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
|
||||
/// outgoing sink; block normalization/volume ramps until the handoff swap.
|
||||
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||
/// When true, audio_play chains sources to the existing Sink instead of
|
||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||
@@ -475,6 +485,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
@@ -559,3 +571,83 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
|
||||
*slot = client;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_playback_request_headers(
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: Option<&str>,
|
||||
url: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if let Some(reg) = registry {
|
||||
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
|
||||
return reg.apply_for_http_url(sid, url, req);
|
||||
}
|
||||
if let Some(ctx) = reg.get_for_server_url(url) {
|
||||
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
|
||||
}
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PlaybackHttpHeaders {
|
||||
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
|
||||
server_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PlaybackHttpHeaders {
|
||||
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
|
||||
Self {
|
||||
registry: app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s)),
|
||||
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
apply_playback_request_headers(
|
||||
self.registry.as_deref(),
|
||||
self.server_id.as_deref(),
|
||||
url,
|
||||
req,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scoped_http_get(
|
||||
state: &AudioEngine,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: Option<&str>,
|
||||
url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
apply_playback_request_headers(
|
||||
registry,
|
||||
server_id,
|
||||
url,
|
||||
audio_http_client(state).get(url),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve registry + server id for playback/preload HTTP GETs.
|
||||
pub(crate) fn playback_scoped_get(
|
||||
state: &AudioEngine,
|
||||
app: &tauri::AppHandle,
|
||||
url: &str,
|
||||
server_id: Option<&str>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
let sid = server_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
|
||||
scoped_http_get(
|
||||
state,
|
||||
registry.as_deref(),
|
||||
sid.as_deref(),
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -708,7 +708,10 @@ pub(crate) async fn fetch_data(
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let response = crate::engine::playback_scoped_get(state, app, url, None)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let ct = response.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
@@ -805,6 +808,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
|
||||
gain_linear_to_db(gain_linear)
|
||||
}
|
||||
|
||||
static SINK_VOLUME_RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Cancel any in-flight sink-volume ramp (new ramp wins).
|
||||
pub(crate) fn cancel_sink_volume_ramp() {
|
||||
SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Audible sink multiplier — may differ from `base_volume * replay_gain` after
|
||||
/// interrupt prep or a mid-ramp correction.
|
||||
pub(crate) fn sink_volume_now(sink: &Player) -> f32 {
|
||||
sink.volume().clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
@@ -812,8 +828,7 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
let delta = (to - from).abs();
|
||||
// Stretch large corrections to avoid audible "step down" moments.
|
||||
@@ -826,18 +841,46 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
} else {
|
||||
(8, 16)
|
||||
};
|
||||
for i in 1..=steps {
|
||||
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
/// Linear sink-volume ramp over an explicit wall-clock duration (interrupt prep).
|
||||
pub(crate) fn ramp_sink_volume_over_secs(sink: Arc<Player>, from: f32, to: f32, secs: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
if (to - from).abs() < 0.002 {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let secs = secs.clamp(0.1, 12.0);
|
||||
let step_ms: u64 = 20;
|
||||
let steps = ((secs * 1000.0) / step_ms as f32).round().max(1.0) as usize;
|
||||
std::thread::spawn(move || {
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
fn ramp_sink_volume_steps(
|
||||
sink: Arc<Player>,
|
||||
from: f32,
|
||||
to: f32,
|
||||
steps: usize,
|
||||
step_ms: u64,
|
||||
my_gen: u64,
|
||||
) {
|
||||
for i in 1..=steps {
|
||||
if SINK_VOLUME_RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,9 +13,9 @@ use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
@@ -105,11 +105,19 @@ pub fn audio_update_replay_gain(
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
|
||||
// ramp A back to full gain before the handoff swap.
|
||||
return;
|
||||
}
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
@@ -143,6 +151,34 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Duck the current sink over `fade_secs` without exhausting its source (which
|
||||
/// would spuriously emit `audio:ended` before the interrupt handoff).
|
||||
#[tauri::command]
|
||||
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
|
||||
let fade_secs = fade_secs.clamp(0.1, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
let Some(sink) = cur.sink.as_ref() else {
|
||||
return;
|
||||
};
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(true, Ordering::Relaxed);
|
||||
cancel_sink_volume_ramp();
|
||||
let from = sink_volume_now(sink);
|
||||
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
|
||||
}
|
||||
|
||||
/// AutoDJ: when `true`, the progress task stops firing its autonomous
|
||||
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
|
||||
/// (only when the next track is actually playable). When `false`, the engine's
|
||||
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
|
||||
#[tauri::command]
|
||||
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state
|
||||
.autodj_suppress_autocrossfade
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
@@ -152,14 +188,13 @@ pub fn audio_set_playback_rate(
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
use crate::playback_rate::{
|
||||
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
|
||||
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_VARISPEED,
|
||||
content_position_from_samples, is_effect_active, rate_change_needs_restamp,
|
||||
raw_counter_samples_for_content_position, STRATEGY_PRESERVE_PITCH,
|
||||
STRATEGY_SPEED_CORRECTED, STRATEGY_VARISPEED,
|
||||
};
|
||||
|
||||
let clamped_speed = speed.clamp(0.5, 2.0);
|
||||
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
|
||||
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
|
||||
let old_strat = state.playback_rate.load_strategy();
|
||||
let old_speed = state.playback_rate.load_speed();
|
||||
let was_active = is_effect_active(&state.playback_rate);
|
||||
@@ -170,24 +205,37 @@ pub fn audio_set_playback_rate(
|
||||
};
|
||||
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
|
||||
|
||||
let restamp_content = if was_active
|
||||
&& enabled == old_enabled
|
||||
&& uses_preserve_dsp(old_strat)
|
||||
&& new_strat == old_strat
|
||||
&& speed_changed
|
||||
// Will the *new* config leave the rate effect active?
|
||||
let new_active = enabled
|
||||
&& match new_strat {
|
||||
STRATEGY_PRESERVE_PITCH => {
|
||||
(clamped_speed - 1.0).abs() > 0.001 || clamped_pitch.abs() > 0.001
|
||||
}
|
||||
_ => (clamped_speed - 1.0).abs() > 0.001,
|
||||
};
|
||||
|
||||
// Preserve the content (song) position across any change to the
|
||||
// sample-counter ↔ position mapping: an active↔neutral toggle (enable /
|
||||
// disable, or speed crossing 1.0×) OR a speed change while active. Scoped to
|
||||
// the preserve-pitch DSP family and same strategy — varispeed has no
|
||||
// content/raw factor, and a strategy switch is out of scope here.
|
||||
//
|
||||
// The old condition only restamped active→active, so every enable/disable
|
||||
// toggle reinterpreted `samples_played` under the new factor and jumped the
|
||||
// position (≈ raw_secs × Δspeed — e.g. ±18 s at the 180 s mark on a ±10%
|
||||
// toggle; this is what broke Orbit drift correction).
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
let restamp_content = if sample_rate > 0
|
||||
&& channels > 0
|
||||
&& rate_change_needs_restamp(old_strat, new_strat, was_active, new_active, speed_changed)
|
||||
{
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
if sample_rate > 0 && channels > 0 {
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -210,19 +258,19 @@ pub fn audio_set_playback_rate(
|
||||
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
|
||||
|
||||
if let Some(content_secs) = restamp_content {
|
||||
if is_effect_active(&state.playback_rate) {
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
// Always re-derive the counter for the NEW config — including the
|
||||
// neutral case (raw_counter_… maps content == raw there), which is
|
||||
// exactly the active↔neutral transition the old is_effect_active gate
|
||||
// skipped.
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
||||
@@ -40,6 +40,9 @@ pub(crate) enum PlayInput {
|
||||
reader: Box<dyn MediaSource>,
|
||||
format_hint: Option<String>,
|
||||
tag: &'static str,
|
||||
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
|
||||
/// seekability through the probe so its seek path does not panic.
|
||||
random_access: bool,
|
||||
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
|
||||
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
|
||||
},
|
||||
@@ -201,6 +204,7 @@ fn open_local_file_input(
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
})
|
||||
}
|
||||
@@ -213,7 +217,12 @@ async fn open_ranged_or_streaming_input(
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
|
||||
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
|
||||
let response = http_headers
|
||||
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
||||
return Ok(None); // superseded
|
||||
@@ -252,8 +261,8 @@ async fn open_ranged_or_streaming_input(
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = audio_http_client(state)
|
||||
.get(ctx.url)
|
||||
if let Ok(pr) = http_headers
|
||||
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
@@ -324,12 +333,28 @@ async fn open_ranged_or_streaming_input(
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
http_headers.clone(),
|
||||
loudness_hold_for_defer,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
// On-demand random-access fetcher: lets seeks (Ogg bisection, end-of-
|
||||
// stream probe, forward scrubs) pull arbitrary byte ranges over HTTP
|
||||
// Range instead of blocking until the linear filler reaches the target.
|
||||
// This is what makes seeking work on a still-downloading Opus/Ogg stream
|
||||
// (previously a contained no-op) without forcing a full pre-download.
|
||||
let on_demand = Some(Arc::new(super::stream::OnDemand::new(
|
||||
audio_http_client(state),
|
||||
tokio::runtime::Handle::current(),
|
||||
ctx.url.to_string(),
|
||||
buf.clone(),
|
||||
total,
|
||||
state.generation.clone(),
|
||||
ctx.gen,
|
||||
http_headers.clone(),
|
||||
)));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
@@ -340,11 +365,16 @@ async fn open_ranged_or_streaming_input(
|
||||
done,
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
on_demand,
|
||||
};
|
||||
return Ok(Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
// The on-demand fetcher makes a seek-to-EOF during the probe cheap,
|
||||
// so Ogg can stay seekable through the probe (records its byte range
|
||||
// → real seeking) without forcing a full download.
|
||||
random_access: true,
|
||||
mp4_probe_gate,
|
||||
}));
|
||||
}
|
||||
@@ -378,6 +408,7 @@ async fn open_ranged_or_streaming_input(
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
http_headers,
|
||||
playback_armed,
|
||||
));
|
||||
|
||||
|
||||
@@ -82,6 +82,27 @@ pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a playback-rate config change must restamp the sample counter to keep
|
||||
/// the content (song) position stable.
|
||||
///
|
||||
/// The counter ↔ position factor is `speed` while the preserve-pitch effect is
|
||||
/// active and `1.0` while neutral (see [`effective_position_secs`]). So any
|
||||
/// transition that flips active↔neutral, or changes speed while staying active,
|
||||
/// changes that factor and needs a restamp. Scoped to the preserve-pitch DSP
|
||||
/// family with an unchanged strategy: varispeed has no content/raw factor, and a
|
||||
/// strategy switch is handled elsewhere.
|
||||
pub(crate) fn rate_change_needs_restamp(
|
||||
old_strategy: u32,
|
||||
new_strategy: u32,
|
||||
was_active: bool,
|
||||
now_active: bool,
|
||||
speed_changed: bool,
|
||||
) -> bool {
|
||||
uses_preserve_dsp(old_strategy)
|
||||
&& new_strategy == old_strategy
|
||||
&& (was_active != now_active || (was_active && now_active && speed_changed))
|
||||
}
|
||||
|
||||
/// True when preserve-pitch DSP (background worker) should run for this track.
|
||||
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
|
||||
atomics.enabled.load(Ordering::Relaxed)
|
||||
@@ -659,4 +680,55 @@ mod tests {
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
|
||||
assert!((after - 30.0).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_needs_restamp_covers_active_neutral_toggles() {
|
||||
let sc = STRATEGY_SPEED_CORRECTED;
|
||||
// Both directions of an active↔neutral toggle need a restamp.
|
||||
assert!(rate_change_needs_restamp(sc, sc, false, true, true));
|
||||
assert!(rate_change_needs_restamp(sc, sc, true, false, true));
|
||||
// Active→active with a speed change needs one too.
|
||||
assert!(rate_change_needs_restamp(sc, sc, true, true, true));
|
||||
// Active→active with no speed change, and neutral→neutral, do not.
|
||||
assert!(!rate_change_needs_restamp(sc, sc, true, true, false));
|
||||
assert!(!rate_change_needs_restamp(sc, sc, false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_change_needs_restamp_skips_varispeed_and_strategy_switch() {
|
||||
let sc = STRATEGY_SPEED_CORRECTED;
|
||||
let vs = STRATEGY_VARISPEED;
|
||||
// Varispeed has no content/raw factor → never restamp.
|
||||
assert!(!rate_change_needs_restamp(vs, vs, false, true, true));
|
||||
// A strategy switch is out of scope for the restamp path.
|
||||
assert!(!rate_change_needs_restamp(sc, vs, true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restamp_keeps_position_across_active_neutral_toggle() {
|
||||
// The bug: toggling the effect on/off must not move the song position.
|
||||
// Start active at 1.10×, sitting at 180 s of content.
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.strategy.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(1.10f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(180.0, 44_100, 2, &a);
|
||||
assert!((content_position_from_samples(samples, 44_100, 2, &a) - 180.0).abs() < 0.05);
|
||||
|
||||
// Toggle to neutral (disabled). Without a restamp the position would
|
||||
// jump ~18 s (180 × 0.10); with it, the position is preserved.
|
||||
let old_content = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
a.enabled.store(false, Ordering::Relaxed);
|
||||
let restamped = raw_counter_samples_for_content_position(old_content, 44_100, 2, &a);
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &a);
|
||||
assert!((after - 180.0).abs() < 0.05, "position jumped to {after}");
|
||||
|
||||
// Back to active at 0.90× — still stable.
|
||||
let old_content2 = content_position_from_samples(restamped, 44_100, 2, &a);
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(0.90f32.to_bits(), Ordering::Relaxed);
|
||||
let restamped2 = raw_counter_samples_for_content_position(old_content2, 44_100, 2, &a);
|
||||
let after2 = content_position_from_samples(restamped2, 44_100, 2, &a);
|
||||
assert!((after2 - 180.0).abs() < 0.05, "position jumped to {after2}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||
use super::state::PreloadedTrack;
|
||||
|
||||
@@ -119,6 +119,7 @@ pub async fn audio_preload(
|
||||
duration_hint: f64,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
eager: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -183,15 +184,28 @@ pub async fn audio_preload(
|
||||
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// If the user skips during the wait the generation counter changes and we abort.
|
||||
// Eager callers (crossfade/AutoDJ pre-buffer, fired ~30 s before the fade
|
||||
// when the current track is long-settled) skip the wait so the RAM slot
|
||||
// fills in time for the fade to fire. If the user skips during the wait the
|
||||
// generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
if !eager.unwrap_or(false) {
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let response = crate::engine::playback_scoped_get(
|
||||
&state,
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
|
||||
@@ -8,7 +8,7 @@ use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
|
||||
state: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<SizedDecoder>, String> {
|
||||
let http_headers = PlaybackHttpHeaders::from_app(app, None);
|
||||
let preview_http = preview_http_client(state);
|
||||
let response = preview_http
|
||||
.get(url)
|
||||
let response = http_headers
|
||||
.apply(url, preview_http.get(url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = preview_http
|
||||
.get(url)
|
||||
if let Ok(pr) = http_headers
|
||||
.apply(url, preview_http.get(url))
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
None,
|
||||
None,
|
||||
http_headers.clone(),
|
||||
None,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
@@ -279,10 +281,13 @@ async fn open_preview_decoder(
|
||||
done,
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
// Preview plays a fixed short segment; no user seeking → no need for
|
||||
// the on-demand random-access fetcher.
|
||||
on_demand: None,
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled_arc: Arc<AtomicBool>,
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
autodj_suppress_arc: Arc<AtomicBool>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
emitter: E,
|
||||
analysis_app: Option<AppHandle>,
|
||||
@@ -245,7 +246,12 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
|
||||
// every advance (gated on the next track being playable). Treat it
|
||||
// like crossfade-off here: only emit `audio:ended` on real source
|
||||
// exhaustion (above) or the watchdog — never the early timer.
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
|
||||
&& !autodj_suppress_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
@@ -335,6 +341,7 @@ mod tests {
|
||||
chained: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled: Arc<AtomicBool>,
|
||||
crossfade_secs: Arc<AtomicU32>,
|
||||
autodj_suppress: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate: Arc<AtomicU32>,
|
||||
@@ -365,6 +372,7 @@ mod tests {
|
||||
chained: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
autodj_suppress: Arc::new(AtomicBool::new(false)),
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
@@ -384,6 +392,7 @@ mod tests {
|
||||
self.chained.clone(),
|
||||
self.crossfade_enabled.clone(),
|
||||
self.crossfade_secs.clone(),
|
||||
self.autodj_suppress.clone(),
|
||||
self.done.clone(),
|
||||
emitter,
|
||||
None,
|
||||
@@ -639,4 +648,34 @@ mod tests {
|
||||
);
|
||||
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn autodj_suppress_does_not_fire_crossfade_timer() {
|
||||
// AutoDJ suppression on: even with crossfade enabled and the position
|
||||
// inside the crossfade window, the autonomous timer must NOT emit
|
||||
// audio:ended (JS drives the advance, gated on the next track being
|
||||
// ready). The real end is still reached via source exhaustion.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||||
h.autodj_suppress.store(true, Ordering::SeqCst);
|
||||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"suppressed AutoDJ must not fire the autonomous crossfade timer"
|
||||
);
|
||||
|
||||
// Source exhausts → audio:ended fires (clean sequential end).
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -184,6 +184,7 @@ pub async fn audio_play_radio(
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
None,
|
||||
|
||||
@@ -90,9 +90,49 @@ pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
|
||||
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
let Some(old) = old_sink else {
|
||||
return;
|
||||
};
|
||||
if fade_secs <= 0.0 {
|
||||
old.stop();
|
||||
return;
|
||||
}
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
@@ -107,6 +147,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
@@ -134,21 +175,26 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
|
||||
// Calculate total fade samples from the measured actual_fade_secs.
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
} else if let Some(old) = old_sink {
|
||||
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
|
||||
// so clamp the handoff sink or A blasts over B's fade-in.
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
old.set_volume(0.0);
|
||||
}
|
||||
|
||||
// Keep old sink alive until the fade completes + small margin,
|
||||
// then drop it. No volume stepping needed — the fade-out runs
|
||||
// at sample level inside the audio thread.
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||
@@ -162,4 +208,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ async fn build_source_from_play_input(
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
random_access,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
@@ -354,7 +355,7 @@ async fn build_source_from_play_input(
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -375,7 +376,12 @@ async fn build_source_from_play_input(
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
|
||||
SizedDecoder::new_streaming(
|
||||
Box::new(reader),
|
||||
stream_hint.as_deref(),
|
||||
"track-stream",
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
@@ -221,13 +221,18 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// For seeks to the very start (< 100 ms): keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
if pos.as_millis() < 100 {
|
||||
if self.sample_count == 0 {
|
||||
// Seek before any audio has played → this is the initial start-offset
|
||||
// seek (B-head: skip the incoming track's leading silence). Keep the
|
||||
// fade-in (`sample_count` stays 0) so a crossfaded track still rises
|
||||
// in from its trimmed start instead of popping in at full gain.
|
||||
} else if pos.as_millis() < 100 {
|
||||
// Mid-playback seek to the very start: keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
// Mid-playback seek elsewhere (user dragging the seekbar): skip
|
||||
// straight to unity gain so the new position is at full volume.
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
|
||||
@@ -21,9 +21,26 @@ pub(crate) use mp4::{
|
||||
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
|
||||
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
|
||||
};
|
||||
|
||||
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
|
||||
/// Opus, Speex, FLAC-in-Ogg).
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
|
||||
/// construction time, but only when the source reports `is_seekable()` *during
|
||||
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
|
||||
/// `phys_byte_range_end` stays `None` and the first real seek panics with
|
||||
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
|
||||
/// seek to EOF must therefore stay seekable through the probe for Ogg.
|
||||
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"ogg" | "oga" | "ogx" | "opus" | "spx"
|
||||
)
|
||||
}
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use ranged_http::{OnDemand, RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use reader::AudioStreamReader;
|
||||
pub(crate) use track_stream::track_download_task;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::super::engine::PlaybackHttpHeaders;
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
||||
@@ -50,6 +51,131 @@ impl Drop for RangedLoudnessSeedHoldClear {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum bytes fetched per on-demand Range request. A seek often triggers a
|
||||
/// short read; fetching a window amortizes the HTTP round-trip and lets the few
|
||||
/// pages a bisection lands on (and the playback that follows a forward seek) be
|
||||
/// served without a fresh request each time.
|
||||
const OD_FETCH_WINDOW: u64 = 1024 * 1024;
|
||||
/// Forward gap (cursor ahead of the contiguous linear download) above which a
|
||||
/// read is treated as a *seek* and served by an on-demand HTTP Range fetch
|
||||
/// instead of waiting for the linear filler to catch up. Below it we assume
|
||||
/// ordinary read-ahead that the linear download will satisfy shortly, so we do
|
||||
/// not issue redundant range requests during normal (slightly starved) play.
|
||||
const OD_SEEK_GAP: u64 = 512 * 1024;
|
||||
|
||||
/// Random-access companion for [`RangedHttpSource`]: fetches arbitrary byte
|
||||
/// ranges over HTTP `Range` on demand so seeks (which jump the read cursor far
|
||||
/// ahead of the linear download) resolve quickly instead of blocking until the
|
||||
/// linear filler reaches the target.
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer seeks by *bisection* — it reads pages at
|
||||
/// midpoints across the whole byte range, and its probe scans the last pages to
|
||||
/// find the stream-end timestamp. On a purely linear-fill source every such read
|
||||
/// would block until the download caught up (effectively forcing a full
|
||||
/// download before any seek). On-demand range fetches make those reads cheap.
|
||||
pub(crate) struct OnDemand {
|
||||
http: reqwest::Client,
|
||||
handle: tokio::runtime::Handle,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
/// Byte ranges already fetched on demand (sorted/merged not required — N is
|
||||
/// the handful of seek targets per track).
|
||||
filled: Mutex<Vec<(u64, u64)>>,
|
||||
/// Ranges with an in-flight fetch, so a polling read does not respawn them.
|
||||
inflight: Mutex<Vec<(u64, u64)>>,
|
||||
/// Bumped after every completed (success or failure) fetch so the read loop
|
||||
/// can reset its stall deadline while on-demand fetches make progress.
|
||||
progress: AtomicU64,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
}
|
||||
|
||||
impl OnDemand {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
http: reqwest::Client,
|
||||
handle: tokio::runtime::Handle,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
) -> Self {
|
||||
OnDemand {
|
||||
http,
|
||||
handle,
|
||||
url,
|
||||
buf,
|
||||
total_size,
|
||||
gen_arc,
|
||||
gen,
|
||||
filled: Mutex::new(Vec::new()),
|
||||
inflight: Mutex::new(Vec::new()),
|
||||
progress: AtomicU64::new(0),
|
||||
http_headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn covers(&self, start: u64, end: u64) -> bool {
|
||||
self.filled
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|&(s, e)| s <= start && end <= e)
|
||||
}
|
||||
|
||||
fn inflight_covers(&self, start: u64, end: u64) -> bool {
|
||||
self.inflight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|&(s, e)| s <= start && end <= e)
|
||||
}
|
||||
|
||||
/// Spawn a Range fetch covering at least `[start, end)` (rounded up to
|
||||
/// [`OD_FETCH_WINDOW`]) unless it is already filled or in flight. Returns
|
||||
/// immediately; the caller polls [`OnDemand::covers`] / `progress`.
|
||||
fn request(self: &Arc<Self>, start: u64, end: u64) {
|
||||
if start >= self.total_size {
|
||||
return;
|
||||
}
|
||||
let want_end = end.max(start + OD_FETCH_WINDOW).min(self.total_size);
|
||||
if self.covers(start, want_end) || self.inflight_covers(start, want_end) {
|
||||
return;
|
||||
}
|
||||
self.inflight.lock().unwrap().push((start, want_end));
|
||||
let me = Arc::clone(self);
|
||||
self.handle.spawn(async move {
|
||||
let end_inclusive = want_end.saturating_sub(1);
|
||||
let res = ranged_write_http_range(
|
||||
&me.http,
|
||||
&me.url,
|
||||
&me.buf,
|
||||
start,
|
||||
end_inclusive,
|
||||
me.gen,
|
||||
&me.gen_arc,
|
||||
&me.http_headers,
|
||||
)
|
||||
.await;
|
||||
if let Ok(written) = res {
|
||||
if written > 0 {
|
||||
me.filled.lock().unwrap().push((start, start + written as u64));
|
||||
}
|
||||
}
|
||||
// Drop the reservation either way so a failed fetch can be retried.
|
||||
me.inflight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|&(s, e)| !(s == start && e == want_end));
|
||||
me.progress.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RangedHttpSource {
|
||||
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
|
||||
pub(crate) buf: Arc<Mutex<Vec<u8>>>,
|
||||
@@ -64,6 +190,10 @@ pub(crate) struct RangedHttpSource {
|
||||
pub(crate) done: Arc<AtomicBool>,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
/// On-demand random-access fetcher. `None` keeps the legacy linear-only
|
||||
/// behaviour (used by unit tests); production ranged playback sets it so
|
||||
/// seeks resolve via HTTP `Range` instead of blocking on the linear filler.
|
||||
pub(crate) on_demand: Option<Arc<OnDemand>>,
|
||||
}
|
||||
|
||||
impl RangedHttpSource {
|
||||
@@ -78,6 +208,11 @@ impl RangedHttpSource {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(od) = &self.on_demand {
|
||||
if od.covers(start, end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -103,6 +238,11 @@ impl Read for RangedHttpSource {
|
||||
let stall_timeout = Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
let mut deadline = Instant::now() + stall_timeout;
|
||||
let mut last_dl_seen = self.downloaded_to.load(Ordering::Relaxed) as u64;
|
||||
let mut last_od_seen = self
|
||||
.on_demand
|
||||
.as_ref()
|
||||
.map(|od| od.progress.load(Ordering::Relaxed))
|
||||
.unwrap_or(0);
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
crate::app_deprintln!(
|
||||
@@ -120,6 +260,24 @@ impl Read for RangedHttpSource {
|
||||
last_dl_seen = dl;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
// A read whose cursor is far ahead of the contiguous linear download
|
||||
// is a seek (Ogg bisection midpoint, end-of-stream probe, or a
|
||||
// forward scrub). Serve it from an on-demand HTTP Range fetch rather
|
||||
// than blocking until the linear filler crawls there. While the
|
||||
// download is still running; an aborted download keeps the legacy
|
||||
// partial/EOF behaviour below.
|
||||
if let Some(od) = &self.on_demand {
|
||||
let od_progress = od.progress.load(Ordering::SeqCst);
|
||||
if od_progress != last_od_seen {
|
||||
last_od_seen = od_progress;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
if !self.done.load(Ordering::SeqCst)
|
||||
&& self.pos > dl.saturating_add(OD_SEEK_GAP)
|
||||
{
|
||||
od.request(self.pos, target_end);
|
||||
}
|
||||
}
|
||||
// Download finished but our cursor is past downloaded_to (e.g. seek
|
||||
// beyond a partial download that aborted). Return what we have.
|
||||
if self.done.load(Ordering::SeqCst) {
|
||||
@@ -214,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
|
||||
downloaded_to: &Arc<AtomicUsize>,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
http_headers: &PlaybackHttpHeaders,
|
||||
mut on_partial: F,
|
||||
playback_armed: Option<&AtomicBool>,
|
||||
) -> (usize, RangedHttpLoopOutcome)
|
||||
@@ -234,6 +393,7 @@ where
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
req = http_headers.apply(url, req);
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
@@ -334,6 +494,7 @@ where
|
||||
}
|
||||
|
||||
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ranged_write_http_range(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
@@ -342,22 +503,32 @@ async fn ranged_write_http_range(
|
||||
end_inclusive: u64,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
http_headers: &PlaybackHttpHeaders,
|
||||
) -> Result<usize, ()> {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
let response = http_client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
|
||||
let response = http_headers
|
||||
.apply(
|
||||
url,
|
||||
http_client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
if !(response.status() == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| response.status() == reqwest::StatusCode::OK)
|
||||
{
|
||||
// Require 206 for any non-zero offset. A server that ignored the `Range`
|
||||
// header and replied 200 returns the *whole* body from byte 0; writing that
|
||||
// at `start` would corrupt the buffer. A 200 is only safe when we asked from
|
||||
// offset 0 (the body genuinely starts there).
|
||||
let status = response.status();
|
||||
let ok = status == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| (status == reqwest::StatusCode::OK && start == 0);
|
||||
if !ok {
|
||||
return Err(());
|
||||
}
|
||||
let mut written = 0usize;
|
||||
@@ -397,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
) {
|
||||
const MIN_TAIL: u64 = 256 * 1024;
|
||||
const MAX_TAIL: u64 = 8 * 1024 * 1024;
|
||||
@@ -415,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
|
||||
end_inclusive,
|
||||
gen,
|
||||
&gen_arc,
|
||||
&http_headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -464,6 +637,7 @@ pub(crate) async fn ranged_download_task(
|
||||
cache_track_id: Option<String>,
|
||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||
server_id: Option<String>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
|
||||
// track; `None` for large files where ranged skips seed (needs backfill).
|
||||
loudness_seed_hold: Option<LoudnessSeedHold>,
|
||||
@@ -547,6 +721,7 @@ pub(crate) async fn ranged_download_task(
|
||||
let tail_from_bg = tail_filled_from.clone();
|
||||
let armed_bg = playback_armed.clone();
|
||||
let gen_bg = gen_arc.clone();
|
||||
let headers_bg = http_headers.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
ranged_prefetch_mp4_tail(
|
||||
client,
|
||||
@@ -558,6 +733,7 @@ pub(crate) async fn ranged_download_task(
|
||||
armed_bg,
|
||||
gen,
|
||||
gen_bg,
|
||||
headers_bg,
|
||||
)
|
||||
.await;
|
||||
}))
|
||||
@@ -578,6 +754,7 @@ pub(crate) async fn ranged_download_task(
|
||||
&downloaded_to,
|
||||
gen,
|
||||
&gen_arc,
|
||||
&http_headers,
|
||||
on_partial,
|
||||
linear_arm,
|
||||
)
|
||||
@@ -736,6 +913,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 7,
|
||||
on_demand: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +983,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
@@ -835,6 +1014,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 2];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
@@ -859,6 +1039,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
assert_eq!(src.read(&mut out).unwrap(), 0);
|
||||
@@ -965,6 +1146,7 @@ mod tests {
|
||||
&dl,
|
||||
1,
|
||||
&gen_arc,
|
||||
&PlaybackHttpHeaders::default(),
|
||||
|_, _| {},
|
||||
None,
|
||||
)
|
||||
@@ -1000,6 +1182,7 @@ mod tests {
|
||||
&dl,
|
||||
1,
|
||||
&gen_arc,
|
||||
&PlaybackHttpHeaders::default(),
|
||||
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
|
||||
None,
|
||||
)
|
||||
@@ -1028,7 +1211,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(1024);
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
||||
@@ -1059,7 +1242,7 @@ mod tests {
|
||||
gen_arc.store(99, Ordering::SeqCst);
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
|
||||
@@ -1118,7 +1301,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
// Stream finishes via a Range-resumed second request.
|
||||
@@ -1136,6 +1319,126 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves whatever inclusive byte range the request asks for out of `body`,
|
||||
/// as a 206 — models a server that honours arbitrary `Range` requests.
|
||||
struct RangeResponder {
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Respond for RangeResponder {
|
||||
fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
let range = req
|
||||
.headers
|
||||
.get(reqwest::header::RANGE.as_str())
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("bytes="))
|
||||
.map(|s| s.to_string());
|
||||
let Some(range) = range else {
|
||||
return ResponseTemplate::new(200).set_body_bytes(self.body.clone());
|
||||
};
|
||||
let mut parts = range.splitn(2, '-');
|
||||
let start: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let end_inclusive: usize = parts
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(self.body.len().saturating_sub(1));
|
||||
let end = (end_inclusive + 1).min(self.body.len());
|
||||
ResponseTemplate::new(206).set_body_bytes(self.body[start..end].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn read_far_ahead_is_served_by_on_demand_range_fetch() {
|
||||
// 4 MiB track; nothing downloaded linearly yet and the download is still
|
||||
// "in progress" (done = false). A read whose cursor sits well past the
|
||||
// linear front must be satisfied by an on-demand Range fetch.
|
||||
let total: usize = 4 * 1024 * 1024;
|
||||
let body: Vec<u8> = (0..total).map(|i| (i % 256) as u8).collect();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/track"))
|
||||
.respond_with(RangeResponder { body: body.clone() })
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let on_demand = Some(Arc::new(OnDemand::new(
|
||||
reqwest::Client::new(),
|
||||
tokio::runtime::Handle::current(),
|
||||
url,
|
||||
buf.clone(),
|
||||
total as u64,
|
||||
gen_arc.clone(),
|
||||
1,
|
||||
PlaybackHttpHeaders::default(),
|
||||
)));
|
||||
let mut src = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(false)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total as u64,
|
||||
pos: 2 * 1024 * 1024, // 2 MiB — far past the (empty) linear front
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand,
|
||||
};
|
||||
|
||||
// The blocking read polls until the on-demand fetch fills the region.
|
||||
let out = tokio::task::spawn_blocking(move || {
|
||||
let mut out = [0u8; 16];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
(n, out)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.0, 16, "read returns the requested bytes via on-demand fetch");
|
||||
let base = 2 * 1024 * 1024usize;
|
||||
let expected: Vec<u8> = (base..base + 16).map(|i| (i % 256) as u8).collect();
|
||||
assert_eq!(&out.1[..], &expected[..]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ranged_write_http_range_rejects_200_at_nonzero_offset() {
|
||||
// A server that ignores Range and answers 200 with the whole body must
|
||||
// NOT be written at a non-zero offset (would corrupt the buffer).
|
||||
let server = MockServer::start().await;
|
||||
let body = vec![0xCDu8; 4096];
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/track"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; 4096]));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let res = ranged_write_http_range(
|
||||
&reqwest::Client::new(),
|
||||
&url,
|
||||
&buf,
|
||||
1024, // non-zero offset
|
||||
2047,
|
||||
1,
|
||||
&gen_arc,
|
||||
&PlaybackHttpHeaders::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "200 at a non-zero offset must be rejected");
|
||||
assert!(
|
||||
buf.lock().unwrap().iter().all(|&b| b == 0),
|
||||
"buffer must be left untouched on a rejected 200"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn loop_aborts_when_reconnect_returns_non_206() {
|
||||
// Returns 200 first time (partial body), then 200 again (not 206) on the
|
||||
@@ -1160,7 +1463,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
|
||||
|
||||
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
|
||||
use ringbuf::traits::Producer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::super::engine::PlaybackHttpHeaders;
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
|
||||
cache_track_id: Option<String>,
|
||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||
server_id: Option<String>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
req = http_headers.apply(&url, req);
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
|
||||
@@ -204,6 +204,8 @@ mod tests {
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0)),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
|
||||
@@ -9,6 +9,8 @@ publish = false
|
||||
[dependencies]
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! macros) and the cross-crate port traits used to break dependency cycles
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod server_http;
|
||||
pub mod cover_cache_layout;
|
||||
pub mod log_sanitize;
|
||||
pub mod media_layout;
|
||||
|
||||
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
||||
|
||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||
"refresh_token", "authorization", "auth",
|
||||
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
|
||||
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
|
||||
];
|
||||
|
||||
/// Sanitize one runtime log line for display and export.
|
||||
pub fn sanitize_log_line(line: &str) -> String {
|
||||
let mut out = redact_bearer_tokens(line);
|
||||
out = redact_pangolin_headers(&out);
|
||||
out = redact_sensitive_key_values(&out);
|
||||
out = redact_urls_in_text(&out);
|
||||
out
|
||||
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn redact_pangolin_headers(line: &str) -> String {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let mut out = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
|
||||
let idx = search_from + rel;
|
||||
let after_prefix = &lower[idx..];
|
||||
let Some(sep_rel) = after_prefix.find([':', '=']) else {
|
||||
search_from = idx + 1;
|
||||
continue;
|
||||
};
|
||||
let sep_idx = idx + sep_rel;
|
||||
let val_start = sep_idx + 1;
|
||||
let slice = &out[val_start..];
|
||||
let trimmed = slice.trim_start();
|
||||
let ws = slice.len().saturating_sub(trimmed.len());
|
||||
let val_start = val_start + ws;
|
||||
let end = trimmed
|
||||
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||
.unwrap_or(trimmed.len());
|
||||
if end > 0 {
|
||||
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||
}
|
||||
search_from = val_start + "REDACTED".len();
|
||||
if search_from >= out.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_sensitive_key_values(line: &str) -> String {
|
||||
let mut out = line.to_string();
|
||||
for key in SENSITIVE_KV_KEYS {
|
||||
@@ -368,6 +401,17 @@ mod tests {
|
||||
assert!(!out.contains("user:pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_reverse_proxy_gate_headers() {
|
||||
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
|
||||
assert!(!out.contains("gate-secret"));
|
||||
assert!(!out.contains("tok123"));
|
||||
assert!(out.contains("x-pangolin-auth: REDACTED"));
|
||||
assert!(!out.contains("pangolin-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_log_with_em_dash_does_not_panic() {
|
||||
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
|
||||
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EndpointKind {
|
||||
Local,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CustomHeadersApplyTo {
|
||||
Local,
|
||||
#[default]
|
||||
Public,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ServerHttpEndpointWire {
|
||||
pub url: String,
|
||||
pub kind: EndpointKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CustomHeaderEntryWire {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ServerHttpContextSyncWire {
|
||||
#[serde(rename = "serverId")]
|
||||
pub server_id: String,
|
||||
#[serde(rename = "appServerId")]
|
||||
pub app_server_id: String,
|
||||
pub endpoints: Vec<ServerHttpEndpointWire>,
|
||||
#[serde(rename = "customHeaders", default)]
|
||||
pub custom_headers: Vec<CustomHeaderEntryWire>,
|
||||
#[serde(rename = "customHeadersApplyTo", default)]
|
||||
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerHttpContext {
|
||||
pub endpoints: Vec<(String, EndpointKind)>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub apply_to: CustomHeadersApplyTo,
|
||||
}
|
||||
|
||||
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
|
||||
fn from(w: ServerHttpContextSyncWire) -> Self {
|
||||
Self {
|
||||
endpoints: w
|
||||
.endpoints
|
||||
.into_iter()
|
||||
.map(|e| (normalize_server_base_url(&e.url), e.kind))
|
||||
.collect(),
|
||||
headers: w
|
||||
.custom_headers
|
||||
.into_iter()
|
||||
.map(|h| (h.name.trim().to_string(), h.value))
|
||||
.filter(|(n, _)| !n.is_empty())
|
||||
.collect(),
|
||||
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_server_base_url(raw: &str) -> String {
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
|
||||
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
|
||||
let trimmed = raw_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
|
||||
return normalize_server_base_url(trimmed);
|
||||
};
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
let mut path = parsed.path().to_string();
|
||||
if let Some(idx) = path.find("/rest/") {
|
||||
path.truncate(idx);
|
||||
} else if path.ends_with("/rest") {
|
||||
path.truncate(path.len().saturating_sub("/rest".len()));
|
||||
} else {
|
||||
for seg in ["/api/", "/auth/"] {
|
||||
if let Some(idx) = path.find(seg) {
|
||||
path.truncate(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while path.ends_with('/') && path.len() > 1 {
|
||||
path.pop();
|
||||
}
|
||||
parsed.set_path(if path.is_empty() { "/" } else { &path });
|
||||
let host = parsed.host_str().unwrap_or_default();
|
||||
if host.is_empty() {
|
||||
return normalize_server_base_url(trimmed);
|
||||
}
|
||||
let mut out = format!("{}://{}", parsed.scheme(), host);
|
||||
if let Some(port) = parsed.port() {
|
||||
out.push(':');
|
||||
out.push_str(&port.to_string());
|
||||
}
|
||||
if !path.is_empty() && path != "/" {
|
||||
out.push_str(&path);
|
||||
}
|
||||
normalize_server_base_url(&out)
|
||||
}
|
||||
|
||||
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
|
||||
let mut map = HeaderMap::new();
|
||||
if ctx.headers.is_empty() {
|
||||
return map;
|
||||
}
|
||||
let normalized = normalize_server_base_url(request_base_url);
|
||||
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
|
||||
return map;
|
||||
};
|
||||
let apply = match ctx.apply_to {
|
||||
CustomHeadersApplyTo::Both => true,
|
||||
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
|
||||
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
|
||||
};
|
||||
if !apply {
|
||||
return map;
|
||||
}
|
||||
for (name, value) in &ctx.headers {
|
||||
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(header_value) = HeaderValue::from_str(value) else {
|
||||
continue;
|
||||
};
|
||||
map.insert(header_name, header_value);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
pub fn apply_server_headers(
|
||||
builder: RequestBuilder,
|
||||
ctx: &ServerHttpContext,
|
||||
request_base_url: &str,
|
||||
) -> RequestBuilder {
|
||||
let map = headers_for_request_base_url(ctx, request_base_url);
|
||||
if map.is_empty() {
|
||||
return builder;
|
||||
}
|
||||
builder.headers(map)
|
||||
}
|
||||
|
||||
pub fn apply_server_headers_for_http_url(
|
||||
builder: RequestBuilder,
|
||||
ctx: &ServerHttpContext,
|
||||
full_http_url: &str,
|
||||
) -> RequestBuilder {
|
||||
let base = request_base_url_from_http_url(full_http_url);
|
||||
apply_server_headers(builder, ctx, &base)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ServerHttpRegistry {
|
||||
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
|
||||
ref_to_key: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl ServerHttpRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
|
||||
let index_key = wire.server_id.clone();
|
||||
let app_id = wire.app_server_id.clone();
|
||||
let ctx = Arc::new(ServerHttpContext::from(wire));
|
||||
if ctx.headers.is_empty() {
|
||||
self.remove(&index_key, &app_id);
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut contexts = self.contexts.lock().unwrap();
|
||||
contexts.insert(index_key.clone(), Arc::clone(&ctx));
|
||||
}
|
||||
let mut refs = self.ref_to_key.lock().unwrap();
|
||||
refs.insert(index_key.clone(), index_key.clone());
|
||||
refs.insert(app_id, index_key);
|
||||
}
|
||||
|
||||
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
|
||||
let mut new_contexts = HashMap::new();
|
||||
let mut new_refs = HashMap::new();
|
||||
for wire in entries {
|
||||
let index_key = wire.server_id.clone();
|
||||
let app_id = wire.app_server_id.clone();
|
||||
let ctx = Arc::new(ServerHttpContext::from(wire));
|
||||
if ctx.headers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
|
||||
new_refs.insert(index_key.clone(), index_key.clone());
|
||||
new_refs.insert(app_id, index_key);
|
||||
}
|
||||
*self.contexts.lock().unwrap() = new_contexts;
|
||||
*self.ref_to_key.lock().unwrap() = new_refs;
|
||||
}
|
||||
|
||||
pub fn remove(&self, index_key: &str, app_server_id: &str) {
|
||||
self.contexts.lock().unwrap().remove(index_key);
|
||||
let mut refs = self.ref_to_key.lock().unwrap();
|
||||
refs.remove(index_key);
|
||||
refs.remove(app_server_id);
|
||||
}
|
||||
|
||||
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
|
||||
self.contexts.lock().unwrap().get(index_key).cloned()
|
||||
}
|
||||
|
||||
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
|
||||
if server_ref.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let key = {
|
||||
let refs = self.ref_to_key.lock().unwrap();
|
||||
refs.get(server_ref).cloned()
|
||||
};
|
||||
if let Some(k) = key {
|
||||
return self.get(&k);
|
||||
}
|
||||
self.get(server_ref)
|
||||
}
|
||||
|
||||
/// Fallback when only a server base URL is known (Navidrome invoke paths).
|
||||
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
|
||||
let base = request_base_url_from_http_url(server_url);
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let contexts = self.contexts.lock().unwrap();
|
||||
for ctx in contexts.values() {
|
||||
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
|
||||
return Some(Arc::clone(ctx));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn apply_for_http_url(
|
||||
&self,
|
||||
server_ref: &str,
|
||||
full_http_url: &str,
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
let Some(ctx) = self.get_for_server_ref(server_ref) else {
|
||||
return builder;
|
||||
};
|
||||
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
|
||||
}
|
||||
|
||||
pub fn apply_for_base_url(
|
||||
&self,
|
||||
server_ref: &str,
|
||||
request_base_url: &str,
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
let Some(ctx) = self.get_for_server_ref(server_ref) else {
|
||||
return builder;
|
||||
};
|
||||
apply_server_headers(builder, &ctx, request_base_url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
|
||||
pub fn apply_optional_registry_headers(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
full_http_url: &str,
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
if let Some(reg) = registry {
|
||||
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||
return reg.apply_for_http_url(sid, full_http_url, builder);
|
||||
}
|
||||
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
|
||||
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_base_url_strips_rest_and_query() {
|
||||
let url = "https://music.example/rest/stream.view?id=1&u=x";
|
||||
assert_eq!(
|
||||
request_base_url_from_http_url(url),
|
||||
"https://music.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_apply_public_only_on_public_endpoint() {
|
||||
let ctx = ServerHttpContext {
|
||||
endpoints: vec![
|
||||
("http://192.168.0.10".into(), EndpointKind::Local),
|
||||
("https://music.example".into(), EndpointKind::Public),
|
||||
],
|
||||
headers: vec![("X-Gate".into(), "secret".into())],
|
||||
apply_to: CustomHeadersApplyTo::Public,
|
||||
};
|
||||
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
|
||||
assert!(lan.is_empty());
|
||||
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
|
||||
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_resolves_app_id_alias() {
|
||||
let reg = ServerHttpRegistry::new();
|
||||
reg.sync(ServerHttpContextSyncWire {
|
||||
server_id: "music.example".into(),
|
||||
app_server_id: "uuid-1".into(),
|
||||
endpoints: vec![ServerHttpEndpointWire {
|
||||
url: "https://music.example".into(),
|
||||
kind: EndpointKind::Public,
|
||||
}],
|
||||
custom_headers: vec![CustomHeaderEntryWire {
|
||||
name: "X-Gate".into(),
|
||||
value: "tok".into(),
|
||||
}],
|
||||
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
|
||||
});
|
||||
assert!(reg.get("music.example").is_some());
|
||||
assert!(reg.get_for_server_ref("uuid-1").is_some());
|
||||
assert!(reg.get("uuid-1").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
//! Auth + retry + HTTP client for Navidrome's native REST API.
|
||||
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
|
||||
|
||||
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
navidrome_token_with_registry(None, server_url, username, password).await
|
||||
}
|
||||
|
||||
pub async fn navidrome_token_with_registry(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/auth/login", server_url))
|
||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let base = server_url.trim_end_matches('/');
|
||||
let login_url = format!("{base}/auth/login");
|
||||
let mut req = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({ "username": username, "password": password }));
|
||||
if let Some(reg) = registry {
|
||||
if let Some(ctx) = reg.get_for_server_url(server_url) {
|
||||
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
|
||||
}
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| e.to_string())?;
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
data["token"]
|
||||
.as_str()
|
||||
@@ -17,6 +33,16 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
|
||||
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
||||
}
|
||||
|
||||
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
|
||||
pub fn nd_apply_request(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
full_url: &str,
|
||||
builder: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
apply_optional_registry_headers(registry, server_ref, full_url, builder)
|
||||
}
|
||||
|
||||
/// Payload returned by Navidrome's `/auth/login`.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct NdLoginResult {
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
//! login (via `navidrome_token`) and then a multipart POST to the relevant
|
||||
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
|
||||
|
||||
use super::client::navidrome_token;
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use tauri::State;
|
||||
|
||||
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_playlist_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
playlist_id: String,
|
||||
username: String,
|
||||
@@ -13,26 +19,34 @@ pub async fn upload_playlist_cover(
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
@@ -40,26 +54,34 @@ pub async fn upload_radio_cover(
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_artist_image(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
artist_id: String,
|
||||
username: String,
|
||||
@@ -67,40 +89,58 @@ pub async fn upload_artist_image(
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
|
||||
let resp = nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.delete(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 404/503 = no image existed — treat as success
|
||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
||||
if !resp.status().is_success()
|
||||
&& resp.status() != reqwest::StatusCode::NOT_FOUND
|
||||
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
|
||||
{
|
||||
resp.error_for_status().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -11,4 +11,4 @@ pub mod probe;
|
||||
pub mod queries;
|
||||
pub mod users;
|
||||
|
||||
pub use client::navidrome_token;
|
||||
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
|
||||
|
||||
@@ -2,24 +2,41 @@
|
||||
//! payload is forwarded as-is so the frontend can compose any rule the
|
||||
//! Navidrome version supports without backend changes.
|
||||
|
||||
use super::client::{nd_err, nd_http_client, nd_retry};
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use tauri::State;
|
||||
|
||||
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
|
||||
|
||||
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_playlists(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
smart: Option<bool>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let base = format!("{}/api/playlist", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
let client = nd_http_client();
|
||||
let mut req = client
|
||||
.get(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token));
|
||||
if let Some(s) = smart {
|
||||
req = req.query(&[("smart", s)]);
|
||||
let base = base.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
let mut req = nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&base,
|
||||
nd_http_client()
|
||||
.get(&base)
|
||||
.header("X-ND-Authorization", auth),
|
||||
);
|
||||
if let Some(s) = smart {
|
||||
req = req.query(&[("smart", s)]);
|
||||
}
|
||||
req.send().await
|
||||
}
|
||||
req.send()
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -31,16 +48,31 @@ pub async fn nd_list_playlists(
|
||||
/// POST `/api/playlist` — create playlist (supports smart rules payload).
|
||||
#[tauri::command]
|
||||
pub async fn nd_create_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.post(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
@@ -54,17 +86,32 @@ pub async fn nd_create_playlist(
|
||||
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
|
||||
#[tauri::command]
|
||||
pub async fn nd_update_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.put(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.put(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
@@ -78,15 +125,29 @@ pub async fn nd_update_playlist(
|
||||
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
|
||||
#[tauri::command]
|
||||
pub async fn nd_get_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
@@ -100,15 +161,29 @@ pub async fn nd_get_playlist(
|
||||
/// DELETE `/api/playlist/{id}` — delete playlist.
|
||||
#[tauri::command]
|
||||
pub async fn nd_delete_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.delete(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.delete(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! endpoint? — so this stays a free function rather than a client
|
||||
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
|
||||
|
||||
use super::client::{nd_err, nd_http_client};
|
||||
use super::client::{nd_apply_request, nd_err, nd_http_client};
|
||||
|
||||
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
|
||||
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
|
||||
@@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client};
|
||||
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
|
||||
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
|
||||
/// is PR-3b's job.
|
||||
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
|
||||
pub async fn native_bulk_available(
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
) -> Result<bool, String> {
|
||||
let client = nd_http_client();
|
||||
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("X-ND-Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(nd_err)?;
|
||||
let resp = nd_apply_request(
|
||||
registry,
|
||||
server_ref,
|
||||
&url,
|
||||
client
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {token}")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(nd_err)?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
@@ -56,7 +66,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
|
||||
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -71,7 +81,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
|
||||
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
@@ -84,7 +94,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
|
||||
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
|
||||
assert!(!ok, "401 reads as `endpoint not available for this caller`");
|
||||
}
|
||||
|
||||
@@ -97,7 +107,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
|
||||
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
|
||||
assert!(err.contains("503"));
|
||||
}
|
||||
|
||||
@@ -111,6 +121,6 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let with_slash = format!("{}/", server.uri());
|
||||
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
|
||||
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
//! incompletely: songs, role-filtered artist/album lists, libraries,
|
||||
//! per-user library assignment, and absolute song path resolution.
|
||||
|
||||
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use tauri::State;
|
||||
|
||||
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry};
|
||||
|
||||
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
|
||||
/// song list. Pure async helper used by the library-side N1 ingest
|
||||
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
|
||||
/// variant below for existing frontend callers.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_list_songs_internal(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
sort: &str,
|
||||
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
|
||||
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
|
||||
server_url, sort, order, start, end
|
||||
);
|
||||
let auth = format!("Bearer {token}");
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {token}"))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
registry,
|
||||
server_ref,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -36,6 +56,7 @@ pub async fn nd_list_songs_internal(
|
||||
/// surface unchanged for existing call sites in the WebView.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_songs(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
sort: String,
|
||||
@@ -43,7 +64,17 @@ pub async fn nd_list_songs(
|
||||
start: u32,
|
||||
end: u32,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
|
||||
nd_list_songs_internal(
|
||||
Some(http_registry.as_ref()),
|
||||
None,
|
||||
&server_url,
|
||||
&token,
|
||||
&sort,
|
||||
&order,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
|
||||
@@ -70,6 +101,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_list_artists_by_role(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
role: String,
|
||||
@@ -79,24 +111,42 @@ pub async fn nd_list_artists_by_role(
|
||||
end: u32,
|
||||
library_id: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let mut seed = serde_json::Map::new();
|
||||
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
|
||||
let filters = nd_build_filters(seed, library_id.as_deref());
|
||||
let start_s = start.to_string();
|
||||
let end_s = end.to_string();
|
||||
let base = format!("{}/api/artist", server_url);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/artist", server_url))
|
||||
.query(&[
|
||||
("_filters", filters.as_str()),
|
||||
("_sort", sort.as_str()),
|
||||
("_order", order.as_str()),
|
||||
("_start", start_s.as_str()),
|
||||
("_end", end_s.as_str()),
|
||||
])
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let base = base.clone();
|
||||
let filters = filters.clone();
|
||||
let sort = sort.clone();
|
||||
let order = order.clone();
|
||||
let start_s = start_s.clone();
|
||||
let end_s = end_s.clone();
|
||||
let auth = format!("Bearer {}", token);
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&base,
|
||||
nd_http_client()
|
||||
.get(&base)
|
||||
.query(&[
|
||||
("_filters", filters.as_str()),
|
||||
("_sort", sort.as_str()),
|
||||
("_order", order.as_str()),
|
||||
("_start", start_s.as_str()),
|
||||
("_end", end_s.as_str()),
|
||||
])
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role(
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_list_albums_by_artist_role(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
artist_id: String,
|
||||
@@ -121,25 +172,43 @@ pub async fn nd_list_albums_by_artist_role(
|
||||
end: u32,
|
||||
library_id: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let filter_key = format!("role_{}_id", role);
|
||||
let mut seed = serde_json::Map::new();
|
||||
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
|
||||
let filters = nd_build_filters(seed, library_id.as_deref());
|
||||
let start_s = start.to_string();
|
||||
let end_s = end.to_string();
|
||||
let base = format!("{}/api/album", server_url);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/album", server_url))
|
||||
.query(&[
|
||||
("_filters", filters.as_str()),
|
||||
("_sort", sort.as_str()),
|
||||
("_order", order.as_str()),
|
||||
("_start", start_s.as_str()),
|
||||
("_end", end_s.as_str()),
|
||||
])
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let base = base.clone();
|
||||
let filters = filters.clone();
|
||||
let sort = sort.clone();
|
||||
let order = order.clone();
|
||||
let start_s = start_s.clone();
|
||||
let end_s = end_s.clone();
|
||||
let auth = format!("Bearer {}", token);
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&base,
|
||||
nd_http_client()
|
||||
.get(&base)
|
||||
.query(&[
|
||||
("_filters", filters.as_str()),
|
||||
("_sort", sort.as_str()),
|
||||
("_order", order.as_str()),
|
||||
("_start", start_s.as_str()),
|
||||
("_end", end_s.as_str()),
|
||||
])
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -149,15 +218,28 @@ pub async fn nd_list_albums_by_artist_role(
|
||||
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_libraries(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/library", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/library", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client().get(&url).header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -168,19 +250,35 @@ pub async fn nd_list_libraries(
|
||||
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
|
||||
#[tauri::command]
|
||||
pub async fn nd_set_user_libraries(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
library_ids: Vec<i64>,
|
||||
) -> Result<(), String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let body = serde_json::json!({ "libraryIds": library_ids });
|
||||
let url = format!("{}/api/user/{}/library", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.put(format!("{}/api/user/{}/library", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.put(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
@@ -202,18 +300,31 @@ pub async fn nd_set_user_libraries(
|
||||
/// it for non-admin users on some configurations.
|
||||
#[tauri::command]
|
||||
pub async fn nd_get_song_path(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let url = format!("{}/api/song/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
|
||||
@@ -2,22 +2,39 @@
|
||||
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
|
||||
//! when the caller is not an admin.
|
||||
|
||||
use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use tauri::State;
|
||||
|
||||
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult};
|
||||
|
||||
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
|
||||
#[tauri::command]
|
||||
pub async fn navidrome_login(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<NdLoginResult, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let body = serde_json::json!({ "username": username, "password": password });
|
||||
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.post(format!("{}/auth/login", server_url))
|
||||
.json(&body)
|
||||
let login_url = login_url.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&login_url,
|
||||
nd_http_client().post(&login_url).json(&body),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -25,21 +42,40 @@ pub async fn navidrome_login(
|
||||
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
|
||||
let user_id = data["id"].as_str().unwrap_or("").to_string();
|
||||
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
|
||||
Ok(NdLoginResult { token, user_id, is_admin })
|
||||
Ok(NdLoginResult {
|
||||
token,
|
||||
user_id,
|
||||
is_admin,
|
||||
})
|
||||
}
|
||||
|
||||
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_users(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/user", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/user", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -48,7 +84,9 @@ pub async fn nd_list_users(
|
||||
|
||||
/// POST `/api/user` — create a user.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_create_user(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
user_name: String,
|
||||
@@ -57,6 +95,7 @@ pub async fn nd_create_user(
|
||||
password: String,
|
||||
is_admin: bool,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let body = serde_json::json!({
|
||||
"userName": user_name,
|
||||
"name": name,
|
||||
@@ -64,13 +103,27 @@ pub async fn nd_create_user(
|
||||
"password": password,
|
||||
"isAdmin": is_admin,
|
||||
});
|
||||
let url = format!("{}/api/user", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.post(format!("{}/api/user", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
@@ -83,6 +136,7 @@ pub async fn nd_create_user(
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_update_user(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
@@ -92,6 +146,7 @@ pub async fn nd_update_user(
|
||||
password: String,
|
||||
is_admin: bool,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let mut body = serde_json::json!({
|
||||
"id": id,
|
||||
"userName": user_name,
|
||||
@@ -102,13 +157,27 @@ pub async fn nd_update_user(
|
||||
if !password.is_empty() {
|
||||
body["password"] = serde_json::Value::String(password);
|
||||
}
|
||||
let url = format!("{}/api/user/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.put(format!("{}/api/user/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.put(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
@@ -120,16 +189,31 @@ pub async fn nd_update_user(
|
||||
/// DELETE `/api/user/{id}`.
|
||||
#[tauri::command]
|
||||
pub async fn nd_delete_user(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/user/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.delete(format!("{}/api/user/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.delete(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
}).await?;
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde::Deserialize;
|
||||
use super::auth::SubsonicCredentials;
|
||||
use super::error::{flatten_reqwest_error, SubsonicError};
|
||||
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
|
||||
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
|
||||
|
||||
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
|
||||
/// Navidrome and other servers in the wild support. OpenSubsonic
|
||||
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
|
||||
base_url: String,
|
||||
credentials: CredentialsMode,
|
||||
http: reqwest::Client,
|
||||
http_context: Option<ServerHttpContext>,
|
||||
}
|
||||
|
||||
impl SubsonicClient {
|
||||
@@ -75,9 +77,28 @@ impl SubsonicClient {
|
||||
password: password.into(),
|
||||
},
|
||||
http,
|
||||
http_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self {
|
||||
self.http_context = Some(ctx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Production helper — attach registry context when present for `server_ref`
|
||||
/// (app server id or index key).
|
||||
pub fn with_registry(
|
||||
self,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: &str,
|
||||
) -> Self {
|
||||
registry
|
||||
.and_then(|r| r.get_for_server_ref(server_ref))
|
||||
.map(|ctx| self.clone().with_http_context((*ctx).clone()))
|
||||
.unwrap_or(self)
|
||||
}
|
||||
|
||||
/// Test-/cache-friendly constructor — re-uses the same
|
||||
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
|
||||
/// this for deterministic `s=` and `t=` query params; production code
|
||||
@@ -95,6 +116,7 @@ impl SubsonicClient {
|
||||
base_url: url,
|
||||
credentials: CredentialsMode::Static(credentials),
|
||||
http,
|
||||
http_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,10 +323,15 @@ impl SubsonicClient {
|
||||
let mut query: Vec<(&str, &str)> = auth.to_vec();
|
||||
query.extend_from_slice(extra);
|
||||
|
||||
let resp = self
|
||||
let mut req = self
|
||||
.http
|
||||
.get(format!("{}/rest/{method}.view", self.base_url))
|
||||
.query(&query)
|
||||
.query(&query);
|
||||
if let Some(ctx) = &self.http_context {
|
||||
req = apply_server_headers(req, ctx, &self.base_url);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
|
||||
@@ -331,6 +358,16 @@ fn default_http_client() -> reqwest::Client {
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
pub fn subsonic_client_with_registry(
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: &str,
|
||||
base_url: impl Into<String>,
|
||||
username: impl Into<String>,
|
||||
password: impl Into<String>,
|
||||
) -> SubsonicClient {
|
||||
SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref)
|
||||
}
|
||||
|
||||
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
|
||||
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
|
||||
/// variant; surfaces every other failed status as `Api { code, message }`.
|
||||
|
||||
@@ -10,7 +10,8 @@ pub mod types;
|
||||
|
||||
pub use auth::SubsonicCredentials;
|
||||
pub use client::{
|
||||
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
|
||||
fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION,
|
||||
SUBSONIC_CLIENT_ID,
|
||||
};
|
||||
pub use stream_url::{build_stream_view_url, rest_base_from_url};
|
||||
pub use error::SubsonicError;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- External artist artwork lookup (fanart.tv etc.) — image-scraper design-review §12.
|
||||
-- Render NEVER reads this table; only the on-demand cover ensure path + the
|
||||
-- negative cache (`mbid_ambiguous` 24h backoff) use it. `server_id` is the
|
||||
-- serverIndexKey (same key as coverStorageKey / the on-disk cover path, §27),
|
||||
-- NOT the auth-profile UUID.
|
||||
CREATE TABLE IF NOT EXISTS artist_artwork_lookup (
|
||||
server_id TEXT NOT NULL,
|
||||
artist_id TEXT NOT NULL,
|
||||
surface_kind TEXT NOT NULL, -- 'fanart' (| 'thumb' later)
|
||||
mbid TEXT, -- nullable; from tag or MusicBrainz
|
||||
mbid_source TEXT, -- 'tag' | 'musicbrainz' | NULL
|
||||
status TEXT NOT NULL, -- pending|hit|miss|skipped|no_mbid|mbid_ambiguous|error
|
||||
provider TEXT, -- hit source (e.g. 'fanart'); NULL for miss/skipped
|
||||
updated_at INTEGER NOT NULL, -- unix ms
|
||||
PRIMARY KEY (server_id, artist_id, surface_kind)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Artist browse sort key (Navidrome OrderArtistName parity) + server ignoredArticles watermark.
|
||||
-- Applied idempotently from store.rs `apply_migration_14` (per-column guard) so a
|
||||
-- partial apply recovers; keep this file in sync as the canonical DDL.
|
||||
ALTER TABLE artist ADD COLUMN name_sort TEXT;
|
||||
ALTER TABLE sync_state ADD COLUMN ignored_articles TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_name_sort ON artist(server_id, name_sort);
|
||||
@@ -39,7 +39,7 @@ const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \
|
||||
WHERE t.server_id = a.server_id AND t.album_id = a.id AND t.deleted = 0)), \
|
||||
a.genre, a.cover_art_id, a.starred_at, a.synced_at, a.raw_json";
|
||||
|
||||
const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \
|
||||
const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.name_sort, ar.album_count, \
|
||||
ar.synced_at, ar.raw_json";
|
||||
|
||||
/// Flat track projection used when browsing albums in advanced search.
|
||||
@@ -527,7 +527,9 @@ fn build_artist_from_table(
|
||||
}
|
||||
}
|
||||
let order = order_clause(&req.sort, EntityKind::Artist)
|
||||
.unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string());
|
||||
.unwrap_or_else(|| {
|
||||
"ORDER BY COALESCE(ar.name_sort, ar.name) COLLATE NOCASE ASC, ar.id ASC".to_string()
|
||||
});
|
||||
query_rows(
|
||||
store,
|
||||
ARTIST_COLUMNS,
|
||||
@@ -794,10 +796,16 @@ fn build_artist_from_fts(
|
||||
if !seen.insert(artist_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let name = artist.unwrap_or_default();
|
||||
let name_sort = crate::artist_sort::sort_key_for_display_name(
|
||||
&name,
|
||||
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
|
||||
);
|
||||
deduped.push(LibraryArtistDto {
|
||||
server_id,
|
||||
id: artist_id,
|
||||
name: artist.unwrap_or_default(),
|
||||
name,
|
||||
name_sort: Some(name_sort),
|
||||
album_count: None,
|
||||
synced_at,
|
||||
raw_json: Value::Null,
|
||||
@@ -1179,13 +1187,14 @@ fn map_album(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
|
||||
}
|
||||
|
||||
fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
|
||||
let raw: Option<String> = r.get(5)?;
|
||||
let raw: Option<String> = r.get(6)?;
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
album_count: r.get(3)?,
|
||||
synced_at: r.get(4)?,
|
||||
name_sort: r.get(3)?,
|
||||
album_count: r.get(4)?,
|
||||
synced_at: r.get(5)?,
|
||||
raw_json: parse_raw_json(raw),
|
||||
})
|
||||
}
|
||||
@@ -1211,10 +1220,16 @@ fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbum
|
||||
}
|
||||
|
||||
fn map_artist_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
|
||||
let name: String = r.get(2)?;
|
||||
let name_sort = crate::artist_sort::sort_key_for_display_name(
|
||||
&name,
|
||||
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
|
||||
);
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
name,
|
||||
name_sort: Some(name_sort),
|
||||
album_count: Some(r.get(3)?),
|
||||
synced_at: r.get(4)?,
|
||||
raw_json: Value::Null,
|
||||
@@ -1289,7 +1304,7 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
|
||||
("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"),
|
||||
("year", EntityKind::Album) => Some("a.year"),
|
||||
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
|
||||
("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"),
|
||||
("name", EntityKind::Artist) => Some("COALESCE(ar.name_sort, ar.name) COLLATE NOCASE"),
|
||||
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
|
||||
// no index scan needed beyond the row-id range. Direction is ignored.
|
||||
("random", _) => Some("RANDOM()"),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! External artist artwork lookup table accessors (fanart.tv etc.) —
|
||||
//! image-scraper design-review §12. Render NEVER reads this; only the
|
||||
//! on-demand cover ensure path + the `mbid_ambiguous` 24h negative cache use
|
||||
//! it. `server_id` is the serverIndexKey (§27), not the auth-profile UUID.
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
/// One `artist_artwork_lookup` row. The `(server_id, artist_id, surface_kind)`
|
||||
/// primary key is implied by the lookup; this carries the resolution state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArtistArtworkRow {
|
||||
pub mbid: Option<String>,
|
||||
pub mbid_source: Option<String>,
|
||||
pub status: String,
|
||||
pub provider: Option<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Fetch the cached lookup row for `(server_id, artist_id, surface_kind)`, if any.
|
||||
pub fn get_artist_artwork(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
artist_id: &str,
|
||||
surface_kind: &str,
|
||||
) -> Result<Option<ArtistArtworkRow>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT mbid, mbid_source, status, provider, updated_at
|
||||
FROM artist_artwork_lookup
|
||||
WHERE server_id = ?1 AND artist_id = ?2 AND surface_kind = ?3",
|
||||
rusqlite::params![server_id, artist_id, surface_kind],
|
||||
|row| {
|
||||
Ok(ArtistArtworkRow {
|
||||
mbid: row.get(0)?,
|
||||
mbid_source: row.get(1)?,
|
||||
status: row.get(2)?,
|
||||
provider: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or replace the lookup row for `(server_id, artist_id, surface_kind)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn upsert_artist_artwork(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
artist_id: &str,
|
||||
surface_kind: &str,
|
||||
mbid: Option<&str>,
|
||||
mbid_source: Option<&str>,
|
||||
status: &str,
|
||||
provider: Option<&str>,
|
||||
updated_at: i64,
|
||||
) -> Result<(), String> {
|
||||
store.with_conn_mut("artist_artwork.upsert", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist_artwork_lookup
|
||||
(server_id, artist_id, surface_kind, mbid, mbid_source, status, provider, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(server_id, artist_id, surface_kind) DO UPDATE SET
|
||||
mbid = excluded.mbid,
|
||||
mbid_source = excluded.mbid_source,
|
||||
status = excluded.status,
|
||||
provider = excluded.provider,
|
||||
updated_at = excluded.updated_at",
|
||||
rusqlite::params![
|
||||
server_id,
|
||||
artist_id,
|
||||
surface_kind,
|
||||
mbid,
|
||||
mbid_source,
|
||||
status,
|
||||
provider,
|
||||
updated_at
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete all lookup rows for a server — part of clear-cover-cache-per-server
|
||||
/// (§12 / Appendix B.4).
|
||||
pub fn clear_artist_artwork_for_server(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
) -> Result<usize, String> {
|
||||
store.with_conn_mut("artist_artwork.clear_server", |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM artist_artwork_lookup WHERE server_id = ?1",
|
||||
rusqlite::params![server_id],
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[test]
|
||||
fn upsert_then_get_roundtrips_and_replaces() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let sk = "fanart";
|
||||
|
||||
assert!(get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().is_none());
|
||||
|
||||
upsert_artist_artwork(
|
||||
&store, "srv", "ar-1", sk, None, None, "no_mbid", None, 1000,
|
||||
)
|
||||
.unwrap();
|
||||
let row = get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().unwrap();
|
||||
assert_eq!(row.status, "no_mbid");
|
||||
assert_eq!(row.mbid, None);
|
||||
assert_eq!(row.updated_at, 1000);
|
||||
|
||||
// Replace (e.g. tag MBID appeared, fanart hit).
|
||||
upsert_artist_artwork(
|
||||
&store,
|
||||
"srv",
|
||||
"ar-1",
|
||||
sk,
|
||||
Some("mbid-123"),
|
||||
Some("tag"),
|
||||
"hit",
|
||||
Some("fanart"),
|
||||
2000,
|
||||
)
|
||||
.unwrap();
|
||||
let row = get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().unwrap();
|
||||
assert_eq!(row.status, "hit");
|
||||
assert_eq!(row.mbid.as_deref(), Some("mbid-123"));
|
||||
assert_eq!(row.mbid_source.as_deref(), Some("tag"));
|
||||
assert_eq!(row.provider.as_deref(), Some("fanart"));
|
||||
assert_eq!(row.updated_at, 2000);
|
||||
|
||||
// Clear-per-server removes it.
|
||||
assert_eq!(clear_artist_artwork_for_server(&store, "srv").unwrap(), 1);
|
||||
assert!(get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Display-name → sort/bucket key (Navidrome `SanitizeFieldForSortingNoArticle` subset).
|
||||
|
||||
/// Navidrome default (`utils/str/sanitize_strings_test.go`).
|
||||
pub const DEFAULT_IGNORED_ARTICLES: &str = "The El La Los Las Le Les Os As O A";
|
||||
|
||||
/// Strip leading articles from a display name (case-insensitive article match).
|
||||
pub fn strip_leading_articles(name: &str, ignored_articles: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
for article in ignored_articles.split(' ').filter(|s| !s.is_empty()) {
|
||||
let prefix = format!("{} ", article);
|
||||
// `prefix` is ASCII; use `get` so we never slice inside a multibyte rune
|
||||
// (e.g. "Elə…" must not panic when probing the "El " article).
|
||||
let head = trimmed.get(0..prefix.len());
|
||||
if head.is_some_and(|h| h.eq_ignore_ascii_case(&prefix)) {
|
||||
return trimmed[prefix.len()..].trim_start().to_string();
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Lowercase sort key used for SQL `ORDER BY` and UI letter buckets.
|
||||
pub fn sort_key_for_display_name(name: &str, ignored_articles: &str) -> String {
|
||||
strip_leading_articles(name, ignored_articles).to_lowercase()
|
||||
}
|
||||
|
||||
pub fn ignored_articles_or_default(ignored_articles: Option<&str>) -> &str {
|
||||
match ignored_articles.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(s) => s,
|
||||
None => DEFAULT_IGNORED_ARTICLES,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_the_from_beatles() {
|
||||
let key = sort_key_for_display_name("The Beatles", DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, "beatles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_the_from_kinks() {
|
||||
let key = sort_key_for_display_name("The Kinks", DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, "kinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_article_names() {
|
||||
assert_eq!(
|
||||
sort_key_for_display_name("Adele", DEFAULT_IGNORED_ARTICLES),
|
||||
"adele"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_ignored_articles() {
|
||||
assert_eq!(
|
||||
sort_key_for_display_name("The Beatles", "The"),
|
||||
"beatles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_panic_when_article_prefix_aligns_with_multibyte_rune() {
|
||||
// Regression: byte slice `trimmed[..prefix.len()]` panicked on "Elə…"
|
||||
// when probing the "El " ignored article (ə spans bytes 2..4).
|
||||
let key = sort_key_for_display_name("Eləmir", DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, "eləmir");
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use psysonic_integration::navidrome::navidrome_token;
|
||||
use psysonic_integration::subsonic::SubsonicClient;
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use psysonic_integration::navidrome::navidrome_token_with_registry;
|
||||
use psysonic_integration::subsonic::subsonic_client_with_registry;
|
||||
|
||||
use crate::advanced_search;
|
||||
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
||||
@@ -180,8 +181,8 @@ pub async fn library_get_status(
|
||||
conn.query_row(
|
||||
"SELECT sync_phase, capability_flags, library_tier, last_full_sync_at, \
|
||||
last_delta_sync_at, next_poll_at, server_last_scan_iso, \
|
||||
indexes_last_modified_ms, artists_last_modified_ms, local_track_count, \
|
||||
server_track_count, last_error \
|
||||
indexes_last_modified_ms, artists_last_modified_ms, ignored_articles, \
|
||||
local_track_count, server_track_count, last_error \
|
||||
FROM sync_state WHERE server_id = ?1 AND library_scope = ?2",
|
||||
params![server_id, scope],
|
||||
|r| {
|
||||
@@ -195,9 +196,10 @@ pub async fn library_get_status(
|
||||
server_last_scan_iso: r.get(6)?,
|
||||
indexes_last_modified_ms: r.get(7)?,
|
||||
artists_last_modified_ms: r.get(8)?,
|
||||
local_track_count: r.get(9)?,
|
||||
server_track_count: r.get(10)?,
|
||||
last_error: r.get(11)?,
|
||||
ignored_articles: r.get(9)?,
|
||||
local_track_count: r.get(10)?,
|
||||
server_track_count: r.get(11)?,
|
||||
last_error: r.get(12)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -246,6 +248,7 @@ pub async fn library_get_status(
|
||||
server_last_scan_iso: row.server_last_scan_iso,
|
||||
indexes_last_modified_ms: row.indexes_last_modified_ms,
|
||||
artists_last_modified_ms: row.artists_last_modified_ms,
|
||||
ignored_articles: row.ignored_articles,
|
||||
local_track_count,
|
||||
server_track_count: row.server_track_count,
|
||||
last_error: row.last_error,
|
||||
@@ -622,6 +625,7 @@ struct SyncStateRow {
|
||||
server_last_scan_iso: Option<String>,
|
||||
indexes_last_modified_ms: Option<i64>,
|
||||
artists_last_modified_ms: Option<i64>,
|
||||
ignored_articles: Option<String>,
|
||||
local_track_count: Option<i64>,
|
||||
server_track_count: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
@@ -654,13 +658,14 @@ fn normalize_base_url(raw: &str) -> String {
|
||||
/// caller falls back to a cached bearer / the Subsonic-only path. Never logs
|
||||
/// the token or credentials.
|
||||
async fn navidrome_token_with_retry(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
base_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Option<String> {
|
||||
const ATTEMPTS: u32 = 3;
|
||||
for attempt in 1..=ATTEMPTS {
|
||||
match navidrome_token(base_url, username, password).await {
|
||||
match navidrome_token_with_registry(registry, base_url, username, password).await {
|
||||
Ok(tok) => return Some(tok),
|
||||
Err(_) if attempt < ATTEMPTS => {
|
||||
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
|
||||
@@ -674,6 +679,7 @@ async fn navidrome_token_with_retry(
|
||||
#[tauri::command]
|
||||
pub async fn library_sync_bind_session(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_id: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
@@ -687,8 +693,13 @@ pub async fn library_sync_bind_session(
|
||||
// keep a bearer cached from a prior bind rather than dropping to
|
||||
// Subsonic-only — a transient miss must not strip an N1-capable server
|
||||
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic.
|
||||
let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password)
|
||||
.await
|
||||
let navidrome_token_cached = match navidrome_token_with_retry(
|
||||
Some(http_registry.as_ref()),
|
||||
&base_url,
|
||||
&username,
|
||||
&password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(tok) => Some(tok),
|
||||
None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token),
|
||||
@@ -706,7 +717,13 @@ pub async fn library_sync_bind_session(
|
||||
|
||||
// Run the probe + persist capability flags. Failure to probe is a
|
||||
// bind-time error — caller should fix credentials / URL.
|
||||
let subsonic = SubsonicClient::new(base_url, username, password);
|
||||
let subsonic = subsonic_client_with_registry(
|
||||
Some(http_registry.as_ref()),
|
||||
&server_id,
|
||||
base_url,
|
||||
username,
|
||||
password,
|
||||
);
|
||||
let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
|
||||
server_url: subsonic_base_url_from(&runtime, &server_id),
|
||||
bearer_token: tok,
|
||||
@@ -716,6 +733,7 @@ pub async fn library_sync_bind_session(
|
||||
&runtime.store,
|
||||
&subsonic,
|
||||
navidrome_creds.as_ref(),
|
||||
Some(http_registry.as_ref()),
|
||||
&server_id,
|
||||
scope,
|
||||
)
|
||||
@@ -869,8 +887,12 @@ async fn library_sync_start_inner(
|
||||
let job_id_for_task = job_id.clone();
|
||||
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint());
|
||||
|
||||
let app_for_runner = app.clone();
|
||||
let runner_handle: tokio::task::JoinHandle<Result<(), String>> = tokio::task::spawn(async move {
|
||||
let subsonic = SubsonicClient::new(
|
||||
let registry = app_for_runner.state::<Arc<ServerHttpRegistry>>();
|
||||
let subsonic = subsonic_client_with_registry(
|
||||
Some(registry.as_ref()),
|
||||
&session_clone.server_id,
|
||||
session_clone.base_url.clone(),
|
||||
session_clone.username.clone(),
|
||||
session_clone.password.clone(),
|
||||
@@ -892,7 +914,8 @@ async fn library_sync_start_inner(
|
||||
)
|
||||
.with_cancellation(Arc::clone(&cancel_for_task))
|
||||
.with_progress(Arc::clone(&progress))
|
||||
.with_parallelism_budget(parallelism);
|
||||
.with_parallelism_budget(parallelism)
|
||||
.with_http_registry(Some(Arc::clone(®istry)));
|
||||
if let Some(creds) = navidrome_creds.clone() {
|
||||
runner = runner.with_navidrome_credentials(creds);
|
||||
}
|
||||
@@ -916,7 +939,8 @@ async fn library_sync_start_inner(
|
||||
capability_flags,
|
||||
)
|
||||
.with_cancellation(Arc::clone(&cancel_for_task))
|
||||
.with_progress(Arc::clone(&progress));
|
||||
.with_progress(Arc::clone(&progress))
|
||||
.with_http_registry(Some(Arc::clone(®istry)));
|
||||
if tombstone_budget > 0 {
|
||||
runner = runner.with_tombstone_budget(tombstone_budget);
|
||||
}
|
||||
@@ -1644,7 +1668,7 @@ mod tests {
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
|
||||
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
|
||||
assert_eq!(tok.as_deref(), Some("nd-tok"));
|
||||
}
|
||||
|
||||
@@ -1661,7 +1685,7 @@ mod tests {
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
|
||||
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
|
||||
assert!(tok.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct SyncStateDto {
|
||||
pub server_last_scan_iso: Option<String>,
|
||||
pub indexes_last_modified_ms: Option<i64>,
|
||||
pub artists_last_modified_ms: Option<i64>,
|
||||
/// Space-separated leading articles from the server's `getArtists` response.
|
||||
pub ignored_articles: Option<String>,
|
||||
pub local_track_count: Option<i64>,
|
||||
pub server_track_count: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
@@ -476,6 +478,8 @@ pub struct LibraryArtistDto {
|
||||
pub server_id: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name_sort: Option<String>,
|
||||
pub album_count: Option<i64>,
|
||||
pub synced_at: i64,
|
||||
pub raw_json: Value,
|
||||
@@ -861,6 +865,7 @@ mod tests {
|
||||
server_last_scan_iso: None,
|
||||
indexes_last_modified_ms: None,
|
||||
artists_last_modified_ms: None,
|
||||
ignored_articles: None,
|
||||
local_track_count: None,
|
||||
server_track_count: None,
|
||||
last_error: None,
|
||||
|
||||
@@ -15,7 +15,9 @@ mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod analysis_backfill_policy;
|
||||
pub mod library_readiness;
|
||||
pub mod artist_artwork;
|
||||
pub mod artist_lossless_browse;
|
||||
pub mod artist_sort;
|
||||
pub mod cover_backfill;
|
||||
pub mod cover_resolve;
|
||||
pub mod canonical;
|
||||
|
||||
@@ -193,6 +193,7 @@ fn query_artists(
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||
name_sort: None,
|
||||
album_count: None,
|
||||
synced_at: r.get(3)?,
|
||||
raw_json: serde_json::Value::Null,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! `artist` table — browse index rows from `getArtists` and track-derived backfill.
|
||||
|
||||
use rusqlite::{params, Transaction};
|
||||
|
||||
use crate::artist_sort::{ignored_articles_or_default, sort_key_for_display_name};
|
||||
use crate::store::LibraryStore;
|
||||
use psysonic_integration::subsonic::ArtistIndex;
|
||||
|
||||
pub struct ArtistRepository<'a> {
|
||||
store: &'a LibraryStore,
|
||||
}
|
||||
|
||||
impl<'a> ArtistRepository<'a> {
|
||||
pub fn new(store: &'a LibraryStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Upsert artists from a Subsonic `getArtists` / `getIndexes` body.
|
||||
pub fn upsert_index(
|
||||
&self,
|
||||
server_id: &str,
|
||||
index: &ArtistIndex,
|
||||
synced_at: i64,
|
||||
) -> Result<u32, String> {
|
||||
let ignored = ignored_articles_or_default(index.ignored_articles.as_deref());
|
||||
let mut count = 0u32;
|
||||
self.store.with_conn_mut("artist.upsert_index", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for bucket in &index.index {
|
||||
for artist in &bucket.artist {
|
||||
let name_sort = sort_key_for_display_name(&artist.name, ignored);
|
||||
upsert_artist_row(
|
||||
&tx,
|
||||
server_id,
|
||||
&artist.id,
|
||||
&artist.name,
|
||||
&name_sort,
|
||||
artist.album_count,
|
||||
synced_at,
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Materialize missing `artist` rows from synced tracks (pre-pass backfill).
|
||||
pub fn backfill_from_tracks(
|
||||
&self,
|
||||
server_id: &str,
|
||||
ignored_articles: &str,
|
||||
synced_at: i64,
|
||||
) -> Result<u32, String> {
|
||||
let rows: Vec<(String, String)> = self
|
||||
.store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT artist_id, MAX(artist) \
|
||||
FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
AND artist_id IS NOT NULL AND artist_id != '' \
|
||||
AND artist IS NOT NULL AND artist != '' \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM artist ar \
|
||||
WHERE ar.server_id = track.server_id AND ar.id = track.artist_id \
|
||||
) \
|
||||
GROUP BY artist_id",
|
||||
)?;
|
||||
let collected = stmt
|
||||
.query_map(params![server_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(collected)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut count = 0u32;
|
||||
self.store.with_conn_mut("artist.backfill_from_tracks", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for (id, name) in &rows {
|
||||
let name_sort = sort_key_for_display_name(name, ignored_articles);
|
||||
upsert_artist_row(&tx, server_id, id, name, &name_sort, None, synced_at)?;
|
||||
count += 1;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// One-time repair: fill `name_sort` where null (upgrade path).
|
||||
pub fn backfill_null_name_sort(&self, ignored_articles: &str) -> Result<u32, String> {
|
||||
let rows: Vec<(String, String, String)> = self
|
||||
.store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT server_id, id, name FROM artist WHERE name_sort IS NULL")?;
|
||||
let collected = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(collected)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut count = 0u32;
|
||||
self.store.with_conn_mut("artist.backfill_null_name_sort", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for (server_id, id, name) in &rows {
|
||||
let name_sort = sort_key_for_display_name(name, ignored_articles);
|
||||
tx.execute(
|
||||
"UPDATE artist SET name_sort = ?1 WHERE server_id = ?2 AND id = ?3",
|
||||
params![name_sort, server_id, id],
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_artist_row(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
name_sort: &str,
|
||||
album_count: Option<i64>,
|
||||
synced_at: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO artist (server_id, id, name, name_sort, album_count, synced_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
|
||||
ON CONFLICT(server_id, id) DO UPDATE SET \
|
||||
name = excluded.name, \
|
||||
name_sort = excluded.name_sort, \
|
||||
album_count = COALESCE(excluded.album_count, artist.album_count), \
|
||||
synced_at = excluded.synced_at",
|
||||
params![server_id, id, name, name_sort, album_count, synced_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::LibraryStore;
|
||||
use psysonic_integration::subsonic::{ArtistIndex, ArtistRef, IndexBucket};
|
||||
|
||||
#[test]
|
||||
fn upsert_index_stores_name_sort_for_the_beatles() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = ArtistRepository::new(&store);
|
||||
let index = ArtistIndex {
|
||||
last_modified_ms: Some(1),
|
||||
ignored_articles: Some("The".into()),
|
||||
index: vec![IndexBucket {
|
||||
name: "B".into(),
|
||||
artist: vec![ArtistRef {
|
||||
id: "ar_1".into(),
|
||||
name: "The Beatles".into(),
|
||||
album_count: Some(3),
|
||||
cover_art: None,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
repo.upsert_index("s1", &index, 1000).unwrap();
|
||||
let name_sort: String = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar_1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(name_sort, "beatles");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod artist;
|
||||
pub mod artifact;
|
||||
pub mod fact;
|
||||
pub mod play_session;
|
||||
@@ -5,6 +6,7 @@ pub mod sync_state;
|
||||
pub mod track;
|
||||
pub mod track_id_history;
|
||||
|
||||
pub use artist::ArtistRepository;
|
||||
pub use artifact::ArtifactRepository;
|
||||
pub use fact::FactRepository;
|
||||
pub use play_session::PlaySessionRepository;
|
||||
|
||||
@@ -561,6 +561,44 @@ impl<'a> SyncStateRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `ignored_articles` from the last `getArtists` pass (Navidrome
|
||||
/// `IgnoredArticles` string — space-separated article tokens).
|
||||
pub fn get_ignored_articles(
|
||||
&self,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
self.read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT ignored_articles FROM sync_state \
|
||||
WHERE server_id = ?1 AND library_scope = ?2",
|
||||
params![server_id, library_scope],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.map(|opt| opt.flatten())
|
||||
}
|
||||
|
||||
/// Persist server `ignoredArticles` for local artist sort-key computation.
|
||||
pub fn set_ignored_articles(
|
||||
&self,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
ignored_articles: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("sync_state.set_ignored_articles", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, ignored_articles) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
|
||||
ignored_articles = excluded.ignored_articles",
|
||||
params![server_id, library_scope, ignored_articles],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `library_tier` (spec §6.2.2 — `small` / `medium` / `huge`
|
||||
/// / `unknown`). Drives the adaptive poll interval; PR-3d wires
|
||||
/// the EWMA loop that picks this.
|
||||
|
||||
@@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> {
|
||||
let mut remapped: Vec<RemapEntry> = Vec::new();
|
||||
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
|
||||
let mut remap_lookup = if unstable_track_ids {
|
||||
Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?)
|
||||
Some((
|
||||
tx.prepare_cached(REMAP_LOOKUP_BY_HASH_SQL)?,
|
||||
tx.prepare_cached(REMAP_LOOKUP_BY_PATH_SQL)?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -459,11 +462,12 @@ impl<'a> TrackRepository<'a> {
|
||||
// then do we retarget children to the new id, since
|
||||
// child tables FK→track(server_id, id) and would refuse
|
||||
// an UPDATE pointing at an id that doesn't exist yet.
|
||||
let detected_old: Option<String> = if let Some(ref mut lookup) = remap_lookup {
|
||||
detect_remap_target_cached(lookup, r)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let detected_old: Option<String> =
|
||||
if let Some((ref mut by_hash, ref mut by_path)) = remap_lookup {
|
||||
detect_remap_target_cached(by_hash, by_path, r)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
upsert.execute(params![
|
||||
r.server_id,
|
||||
@@ -543,38 +547,76 @@ impl<'a> TrackRepository<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
const REMAP_LOOKUP_SQL: &str = r#"
|
||||
// Two single-column lookups instead of one `OR` across `content_hash`
|
||||
// and `server_path`. The combined `OR` form could not use the partial
|
||||
// `idx_track_remap_hash` / `idx_track_remap_path` indexes — SQLite only
|
||||
// applies a partial index when the query's WHERE provably implies the
|
||||
// index predicate (`… != ''`), and an `OR` spanning two columns blocks
|
||||
// the per-branch index plan. The result was a full `track` scan per
|
||||
// incoming row → O(rows × catalog) on large libraries (observed:
|
||||
// `upsert_batch_remap exec_ms=162001` on a ~200k-track Navidrome sync).
|
||||
// Each statement below repeats the index predicate so the planner picks
|
||||
// the matching partial index (SEARCH, not SCAN); hash wins over path,
|
||||
// matching §6.9's strong-key priority.
|
||||
const REMAP_LOOKUP_BY_HASH_SQL: &str = r#"
|
||||
SELECT id FROM track
|
||||
WHERE server_id = ?1
|
||||
AND deleted = 0
|
||||
AND id != ?2
|
||||
AND (
|
||||
(?3 IS NOT NULL AND content_hash = ?3)
|
||||
OR (?4 IS NOT NULL AND server_path = ?4)
|
||||
)
|
||||
AND content_hash IS NOT NULL
|
||||
AND content_hash != ''
|
||||
AND content_hash = ?2
|
||||
AND id != ?3
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const REMAP_LOOKUP_BY_PATH_SQL: &str = r#"
|
||||
SELECT id FROM track
|
||||
WHERE server_id = ?1
|
||||
AND deleted = 0
|
||||
AND server_path IS NOT NULL
|
||||
AND server_path != ''
|
||||
AND server_path = ?2
|
||||
AND id != ?3
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a
|
||||
/// non-deleted row with a different id on this server matches the
|
||||
/// incoming row's `content_hash` or `server_path`.
|
||||
/// incoming row's `content_hash` or `server_path`. Hash is the stronger
|
||||
/// key, so it is checked first.
|
||||
fn detect_remap_target_cached(
|
||||
lookup: &mut rusqlite::Statement<'_>,
|
||||
by_hash: &mut rusqlite::Statement<'_>,
|
||||
by_path: &mut rusqlite::Statement<'_>,
|
||||
incoming: &TrackRow,
|
||||
) -> rusqlite::Result<Option<String>> {
|
||||
// Empty-string sentinels are *not* eligible — spec §6.9 explicitly
|
||||
// excludes them so the file-tree default never collides.
|
||||
let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty());
|
||||
let path = incoming.server_path.as_deref().filter(|s| !s.is_empty());
|
||||
if hash.is_none() && path.is_none() {
|
||||
return Ok(None);
|
||||
|
||||
if let Some(hash) = hash {
|
||||
let old = by_hash
|
||||
.query_row(params![incoming.server_id, hash, incoming.id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
if old.is_some() {
|
||||
return Ok(old);
|
||||
}
|
||||
}
|
||||
lookup
|
||||
.query_row(
|
||||
params![incoming.server_id, incoming.id, hash, path],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
|
||||
if let Some(path) = path {
|
||||
let old = by_path
|
||||
.query_row(params![incoming.server_id, path, incoming.id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
if old.is_some() {
|
||||
return Ok(old);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Run the §6.9 retarget half — UPDATE every FK-bound child to the
|
||||
@@ -1247,6 +1289,48 @@ mod tests {
|
||||
assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_lookup_uses_partial_indexes_not_full_scan() {
|
||||
// Regression: the §6.9 remap lookup must hit
|
||||
// idx_track_remap_hash / idx_track_remap_path. The prior
|
||||
// `OR`-based query fell back to a full `track` scan on every
|
||||
// incoming row → O(rows × catalog) stalls on large libraries
|
||||
// (`upsert_batch_remap exec_ms=162001` on a ~200k Navidrome sync).
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let plan = |sql: &str| -> String {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
|
||||
let rows: rusqlite::Result<Vec<String>> = stmt
|
||||
.query_map(params!["s1", "v", "id"], |r| r.get::<_, String>(3))?
|
||||
.collect();
|
||||
rows
|
||||
})
|
||||
.unwrap()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let hash_plan = plan(REMAP_LOOKUP_BY_HASH_SQL);
|
||||
assert!(
|
||||
hash_plan.contains("idx_track_remap_hash"),
|
||||
"hash lookup must use idx_track_remap_hash, got: {hash_plan}"
|
||||
);
|
||||
assert!(
|
||||
!hash_plan.contains("SCAN"),
|
||||
"hash lookup must not full-scan track, got: {hash_plan}"
|
||||
);
|
||||
|
||||
let path_plan = plan(REMAP_LOOKUP_BY_PATH_SQL);
|
||||
assert!(
|
||||
path_plan.contains("idx_track_remap_path"),
|
||||
"path lookup must use idx_track_remap_path, got: {path_plan}"
|
||||
);
|
||||
assert!(
|
||||
!path_plan.contains("SCAN"),
|
||||
"path lookup must not full-scan track, got: {path_plan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_is_noop_when_new_id_matches_existing_id() {
|
||||
// Standard delta-sync: same id, same hash. Must not trigger
|
||||
|
||||
@@ -4,12 +4,18 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
|
||||
use tauri::Manager;
|
||||
|
||||
/// Current head of the embedded migrations. Bump each time a new
|
||||
/// `migrations/NNN_*.sql` is added.
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 12;
|
||||
///
|
||||
/// Migration checklist (wiring, data backfill, open/swap path):
|
||||
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 14;
|
||||
|
||||
/// One-time data repair after migration 014 (`artist.name_sort`).
|
||||
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
|
||||
|
||||
/// Lowest applied schema version the current code can advance from purely
|
||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
||||
@@ -26,10 +32,21 @@ pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql
|
||||
/// still pick up `track_genre` + `library_data_migration`.
|
||||
pub(crate) const MIGRATION_012_TRACK_GENRE_LEGACY: &str =
|
||||
include_str!("../migrations/012_track_genre_legacy_repair.sql");
|
||||
/// Version 13: additive `artist_artwork_lookup` table for external artist
|
||||
/// artwork (fanart.tv) — image-scraper §12. Pure CREATE TABLE IF NOT EXISTS.
|
||||
pub(crate) const MIGRATION_013_ARTIST_ARTWORK_LOOKUP: &str =
|
||||
include_str!("../migrations/013_artist_artwork_lookup.sql");
|
||||
pub(crate) const MIGRATION_014_ARTIST_NAME_SORT: &str =
|
||||
include_str!("../migrations/014_artist_name_sort.sql");
|
||||
|
||||
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
|
||||
/// defensively before applying so the source order can stay readable.
|
||||
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL), (12, MIGRATION_012_TRACK_GENRE_LEGACY)];
|
||||
const MIGRATIONS: &[(i64, &str)] = &[
|
||||
(1, INITIAL_SQL),
|
||||
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
|
||||
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
|
||||
(14, MIGRATION_014_ARTIST_NAME_SORT),
|
||||
];
|
||||
|
||||
/// Idempotent repair — also runs after the migration runner on every open so
|
||||
/// DBs that recorded the wrong version numbers still get the tables.
|
||||
@@ -62,6 +79,9 @@ pub struct LibraryStore {
|
||||
read_conn: Mutex<Connection>,
|
||||
/// IS-3 bulk ingest in progress — read paths skip write-lock work.
|
||||
bulk_ingest_active: AtomicBool,
|
||||
/// `swap_database_file` / `restore_database_backup` — fail fast instead of
|
||||
/// touching in-memory placeholder connections while the file is offline.
|
||||
swap_in_progress: AtomicBool,
|
||||
}
|
||||
|
||||
impl LibraryStore {
|
||||
@@ -75,10 +95,7 @@ impl LibraryStore {
|
||||
|
||||
fn open_file(db_path: &Path) -> Result<Self, String> {
|
||||
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
|
||||
run_migrations(&write_conn).map_err(|e| e.to_string())?;
|
||||
ensure_genre_tags_schema(&write_conn).map_err(|e| e.to_string())?;
|
||||
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
|
||||
prepare_write_connection_for_open(&write_conn).map_err(|e| e.to_string())?;
|
||||
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
|
||||
@@ -86,6 +103,7 @@ impl LibraryStore {
|
||||
write_conn: Mutex::new(write_conn),
|
||||
read_conn: Mutex::new(read_conn),
|
||||
bulk_ingest_active: AtomicBool::new(false),
|
||||
swap_in_progress: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,14 +112,14 @@ impl LibraryStore {
|
||||
let uri = in_memory_uri();
|
||||
let write_conn = Connection::open(&uri).expect("in-memory write connection");
|
||||
configure_write_connection(&write_conn).expect("write pragmas");
|
||||
run_migrations(&write_conn).expect("schema migration");
|
||||
ensure_genre_tags_schema(&write_conn).expect("genre tags schema");
|
||||
prepare_write_connection_for_open(&write_conn).expect("schema migration");
|
||||
let read_conn = Connection::open(&uri).expect("in-memory read connection");
|
||||
configure_read_connection(&read_conn).expect("read pragmas");
|
||||
Self {
|
||||
write_conn: Mutex::new(write_conn),
|
||||
read_conn: Mutex::new(read_conn),
|
||||
bulk_ingest_active: AtomicBool::new(false),
|
||||
swap_in_progress: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +132,36 @@ impl LibraryStore {
|
||||
self.bulk_ingest_active.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn swap_in_progress(&self) -> bool {
|
||||
self.swap_in_progress.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn lock_write_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
|
||||
if self.swap_in_progress() {
|
||||
return Err("library database swap in progress".to_string());
|
||||
}
|
||||
match self.write_conn.lock() {
|
||||
Ok(guard) => Ok(guard),
|
||||
Err(poisoned) => {
|
||||
crate::app_eprintln!("[library-db] write lock was poisoned — recovering");
|
||||
Ok(poisoned.into_inner())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_read_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
|
||||
if self.swap_in_progress() {
|
||||
return Err("library database swap in progress".to_string());
|
||||
}
|
||||
match self.read_conn.lock() {
|
||||
Ok(guard) => Ok(guard),
|
||||
Err(poisoned) => {
|
||||
crate::app_eprintln!("[library-db] read lock was poisoned — recovering");
|
||||
Ok(poisoned.into_inner())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer connection — sync ingest, migrations, mutations.
|
||||
///
|
||||
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
|
||||
@@ -126,13 +174,10 @@ impl LibraryStore {
|
||||
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
|
||||
) -> Result<R, String> {
|
||||
let lock_start = std::time::Instant::now();
|
||||
let conn = self
|
||||
.write_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store write lock poisoned".to_string())?;
|
||||
let conn = self.lock_write_conn()?;
|
||||
let lock_wait_ms = lock_start.elapsed().as_millis();
|
||||
let exec_start = std::time::Instant::now();
|
||||
let out = f(&conn).map_err(|e| e.to_string());
|
||||
let out = run_conn_closure(&conn, f);
|
||||
let exec_ms = exec_start.elapsed().as_millis();
|
||||
log_write_op(op, lock_wait_ms, exec_ms);
|
||||
out
|
||||
@@ -143,11 +188,8 @@ impl LibraryStore {
|
||||
&self,
|
||||
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
|
||||
) -> Result<R, String> {
|
||||
let conn = self
|
||||
.read_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store read lock poisoned".to_string())?;
|
||||
f(&conn).map_err(|e| e.to_string())
|
||||
let conn = self.lock_read_conn()?;
|
||||
run_conn_closure(&conn, f)
|
||||
}
|
||||
|
||||
pub(crate) fn with_conn_mut<R>(
|
||||
@@ -164,13 +206,10 @@ impl LibraryStore {
|
||||
f: impl FnOnce(&mut Connection) -> rusqlite::Result<R>,
|
||||
) -> Result<(R, WriteOpTiming), String> {
|
||||
let lock_start = std::time::Instant::now();
|
||||
let mut conn = self
|
||||
.write_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store write lock poisoned".to_string())?;
|
||||
let mut conn = self.lock_write_conn()?;
|
||||
let lock_wait_ms = lock_start.elapsed().as_millis() as u64;
|
||||
let exec_start = std::time::Instant::now();
|
||||
let out = f(&mut conn).map_err(|e| e.to_string())?;
|
||||
let out = run_conn_mut_closure(&mut conn, f)?;
|
||||
let exec_ms = exec_start.elapsed().as_millis() as u64;
|
||||
log_write_op(op, lock_wait_ms as u128, exec_ms as u128);
|
||||
Ok((out, WriteOpTiming { lock_wait_ms, exec_ms }))
|
||||
@@ -184,8 +223,8 @@ impl LibraryStore {
|
||||
}
|
||||
|
||||
/// Atomically switch the active sqlite file while replacing long-lived
|
||||
/// write/read connections under the same locks so no command can keep
|
||||
/// writing to the old inode after the swap.
|
||||
/// write/read connections. Other threads see `library database swap in
|
||||
/// progress` while the file is offline instead of touching placeholder DBs.
|
||||
pub fn swap_database_file(
|
||||
&self,
|
||||
active_path: &Path,
|
||||
@@ -194,14 +233,14 @@ impl LibraryStore {
|
||||
if !destination_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut write_conn = self
|
||||
.write_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store write lock poisoned".to_string())?;
|
||||
let mut read_conn = self
|
||||
.read_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store read lock poisoned".to_string())?;
|
||||
|
||||
let mut swap_guard = SwapInProgressGuard::new(self);
|
||||
let mut write_conn = self.write_conn.lock().map_err(|_| {
|
||||
"library store write lock poisoned during database swap".to_string()
|
||||
})?;
|
||||
let mut read_conn = self.read_conn.lock().map_err(|_| {
|
||||
"library store read lock poisoned during database swap".to_string()
|
||||
})?;
|
||||
|
||||
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
@@ -229,28 +268,68 @@ impl LibraryStore {
|
||||
let _ = move_sidecar(&backup, active_path, "-wal");
|
||||
let _ = move_sidecar(&backup, active_path, "-shm");
|
||||
}
|
||||
drop(read_conn);
|
||||
drop(write_conn);
|
||||
let (reopened_write, reopened_read) = open_database_connections(active_path)
|
||||
.map_err(|e| format!("library swap reopen failed after rename error: {e}"))?;
|
||||
let mut write_conn = self.write_conn.lock().map_err(|_| {
|
||||
"library store write lock poisoned during database swap".to_string()
|
||||
})?;
|
||||
let mut read_conn = self.read_conn.lock().map_err(|_| {
|
||||
"library store read lock poisoned during database swap".to_string()
|
||||
})?;
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
swap_guard.release();
|
||||
return Err(err.to_string());
|
||||
}
|
||||
|
||||
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
|
||||
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
Ok(Some(backup))
|
||||
drop(read_conn);
|
||||
drop(write_conn);
|
||||
|
||||
let reopen = open_database_connections(active_path);
|
||||
|
||||
let mut write_conn = self.write_conn.lock().map_err(|_| {
|
||||
"library store write lock poisoned during database swap".to_string()
|
||||
})?;
|
||||
let mut read_conn = self.read_conn.lock().map_err(|_| {
|
||||
"library store read lock poisoned during database swap".to_string()
|
||||
})?;
|
||||
|
||||
match reopen {
|
||||
Ok((reopened_write, reopened_read)) => {
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
swap_guard.release();
|
||||
Ok(Some(backup))
|
||||
}
|
||||
Err(open_err) => {
|
||||
if backup.exists() {
|
||||
if active_path.exists() {
|
||||
remove_db_with_sidecars(active_path).ok();
|
||||
}
|
||||
let _ = fs::rename(&backup, active_path);
|
||||
let _ = move_sidecar(&backup, active_path, "-wal");
|
||||
let _ = move_sidecar(&backup, active_path, "-shm");
|
||||
}
|
||||
let (reopened_write, reopened_read) = open_database_connections(active_path)
|
||||
.map_err(|e| format!("library swap reopen failed after revert: {e}"))?;
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
swap_guard.release();
|
||||
Err(format!("library swap failed: {open_err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> {
|
||||
let mut write_conn = self
|
||||
.write_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store write lock poisoned".to_string())?;
|
||||
let mut read_conn = self
|
||||
.read_conn
|
||||
.lock()
|
||||
.map_err(|_| "library store read lock poisoned".to_string())?;
|
||||
let mut swap_guard = SwapInProgressGuard::new(self);
|
||||
let mut write_conn = self.write_conn.lock().map_err(|_| {
|
||||
"library store write lock poisoned during database restore".to_string()
|
||||
})?;
|
||||
let mut read_conn = self.read_conn.lock().map_err(|_| {
|
||||
"library store read lock poisoned during database restore".to_string()
|
||||
})?;
|
||||
|
||||
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
@@ -268,13 +347,21 @@ impl LibraryStore {
|
||||
move_sidecar(backup_path, active_path, "-shm")?;
|
||||
}
|
||||
|
||||
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
|
||||
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
|
||||
drop(read_conn);
|
||||
drop(write_conn);
|
||||
|
||||
let (reopened_write, reopened_read) =
|
||||
open_database_connections(active_path).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut write_conn = self.write_conn.lock().map_err(|_| {
|
||||
"library store write lock poisoned during database restore".to_string()
|
||||
})?;
|
||||
let mut read_conn = self.read_conn.lock().map_err(|_| {
|
||||
"library store read lock poisoned during database restore".to_string()
|
||||
})?;
|
||||
*write_conn = reopened_write;
|
||||
*read_conn = reopened_read;
|
||||
swap_guard.release();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -302,6 +389,74 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
|
||||
}
|
||||
}
|
||||
|
||||
struct SwapInProgressGuard<'a> {
|
||||
store: &'a LibraryStore,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
impl<'a> SwapInProgressGuard<'a> {
|
||||
fn new(store: &'a LibraryStore) -> Self {
|
||||
store.swap_in_progress.store(true, Ordering::Release);
|
||||
Self {
|
||||
store,
|
||||
released: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
if !self.released {
|
||||
self.store.swap_in_progress.store(false, Ordering::Release);
|
||||
self.released = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwapInProgressGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
fn run_conn_closure<R>(
|
||||
conn: &Connection,
|
||||
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
|
||||
) -> Result<R, String> {
|
||||
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(conn)));
|
||||
match out {
|
||||
Ok(result) => result.map_err(|e| e.to_string()),
|
||||
Err(payload) => {
|
||||
let detail = panic_payload_to_string(payload);
|
||||
crate::app_eprintln!("[library-db] connection query panicked: {detail}");
|
||||
Err(format!("library connection query panicked: {detail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_conn_mut_closure<R>(
|
||||
conn: &mut Connection,
|
||||
f: impl FnOnce(&mut Connection) -> rusqlite::Result<R>,
|
||||
) -> Result<R, String> {
|
||||
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(conn)));
|
||||
match out {
|
||||
Ok(result) => result.map_err(|e| e.to_string()),
|
||||
Err(payload) => {
|
||||
let detail = panic_payload_to_string(payload);
|
||||
crate::app_eprintln!("[library-db] connection mutation panicked: {detail}");
|
||||
Err(format!("library connection mutation panicked: {detail}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
if let Some(msg) = payload.downcast_ref::<&str>() {
|
||||
msg.to_string()
|
||||
} else if let Some(msg) = payload.downcast_ref::<String>() {
|
||||
msg.clone()
|
||||
} else {
|
||||
"unknown panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let db_dir = base.join("databases").join("library");
|
||||
@@ -421,6 +576,151 @@ fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open write + read handles after migrations, one-time repairs, and WAL checkpoint.
|
||||
fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Connection)> {
|
||||
let write_conn = Connection::open(db_path)?;
|
||||
configure_write_connection(&write_conn)?;
|
||||
prepare_write_connection_for_open(&write_conn)?;
|
||||
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
configure_read_connection(&read_conn)?;
|
||||
Ok((write_conn, read_conn))
|
||||
}
|
||||
|
||||
fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> {
|
||||
run_migrations(conn)?;
|
||||
maybe_reconcile_artist_name_sort(conn)?;
|
||||
ensure_genre_tags_schema(conn)?;
|
||||
checkpoint_wal_conn(conn, "open")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artist_name_sort_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let column_exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('artist') WHERE name = 'name_sort'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(column_exists > 0)
|
||||
}
|
||||
|
||||
fn sync_state_ignored_articles_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let column_exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('sync_state') WHERE name = 'ignored_articles'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(column_exists > 0)
|
||||
}
|
||||
|
||||
/// Apply schema 014 idempotently — mirrors `migrations/014_artist_name_sort.sql`
|
||||
/// but tolerates a partial prior apply (missing one column / re-run).
|
||||
fn apply_migration_14(conn: &Connection) -> rusqlite::Result<()> {
|
||||
if !artist_name_sort_column_exists(conn)? {
|
||||
conn.execute_batch("ALTER TABLE artist ADD COLUMN name_sort TEXT;")?;
|
||||
}
|
||||
if !sync_state_ignored_articles_column_exists(conn)? {
|
||||
conn.execute_batch("ALTER TABLE sync_state ADD COLUMN ignored_articles TEXT;")?;
|
||||
}
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_artist_name_sort ON artist(server_id, name_sort);",
|
||||
)?;
|
||||
finish_migration_14_reconcile(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_schema_migration(conn: &Connection, version: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))",
|
||||
params![version],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_migration_14_reconcile(conn: &Connection) -> rusqlite::Result<()> {
|
||||
if !artist_name_sort_reconcile_completed(conn)? {
|
||||
repair_artist_name_sort_keys(conn)?;
|
||||
mark_artist_name_sort_reconcile_completed(conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artist_name_sort_reconcile_completed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![ARTIST_NAME_SORT_RECONCILE_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
fn mark_artist_name_sort_reconcile_completed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
|
||||
VALUES (?1, 0, strftime('%s','now'), strftime('%s','now')) \
|
||||
ON CONFLICT(id) DO UPDATE SET completed_at = excluded.completed_at",
|
||||
params![ARTIST_NAME_SORT_RECONCILE_ID],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-time reconcile after schema 014 — not on every open (avoids long write locks at startup).
|
||||
fn maybe_reconcile_artist_name_sort(conn: &Connection) -> rusqlite::Result<()> {
|
||||
if !artist_name_sort_column_exists(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
if artist_name_sort_reconcile_completed(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
repair_artist_name_sort_keys(conn)?;
|
||||
mark_artist_name_sort_reconcile_completed(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconcile `artist.name_sort` with display `name` (upgrade / stale rows).
|
||||
fn repair_artist_name_sort_keys(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let table_exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'artist'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if table_exists == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if !artist_name_sort_column_exists(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
let ignored = crate::artist_sort::DEFAULT_IGNORED_ARTICLES;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare("SELECT server_id, id, name, name_sort FROM artist")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let server_id: String = row.get(0)?;
|
||||
let id: String = row.get(1)?;
|
||||
let name: String = row.get(2)?;
|
||||
let current: Option<String> = row.get(3)?;
|
||||
let expected = crate::artist_sort::sort_key_for_display_name(&name, ignored);
|
||||
if current.as_deref() == Some(&expected) {
|
||||
continue;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE artist SET name_sort = ?1 WHERE server_id = ?2 AND id = ?3",
|
||||
rusqlite::params![expected, server_id, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
|
||||
run_migrations_with(
|
||||
conn,
|
||||
@@ -469,11 +769,17 @@ pub(crate) fn run_migrations_with(
|
||||
if already > 0 {
|
||||
continue;
|
||||
}
|
||||
if version == 14 {
|
||||
// Applied idempotently (per-column ADD + IF NOT EXISTS index) so a
|
||||
// partial DDL apply — one ALTER landed before a crash, no
|
||||
// schema_migrations row — recovers instead of failing on a
|
||||
// duplicate-column re-run of the batch.
|
||||
apply_migration_14(conn)?;
|
||||
record_schema_migration(conn, version)?;
|
||||
continue;
|
||||
}
|
||||
conn.execute_batch(sql)?;
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))",
|
||||
params![version],
|
||||
)?;
|
||||
record_schema_migration(conn, version)?;
|
||||
}
|
||||
Ok(MigrationOutcome::Applied)
|
||||
}
|
||||
@@ -774,4 +1080,125 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(outcome, MigrationOutcome::Applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_name_sort_reconcile_runs_once_and_sets_name_sort() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn_mut("test.seed_artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, name_sort, synced_at) \
|
||||
VALUES ('s1', 'ar1', 'The Beatles', 'the beatles', 1)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM library_data_migration WHERE id = ?1",
|
||||
params![ARTIST_NAME_SORT_RECONCILE_ID],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed artist");
|
||||
|
||||
store
|
||||
.with_conn("test.reconcile", maybe_reconcile_artist_name_sort)
|
||||
.expect("reconcile");
|
||||
|
||||
let name_sort: String = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.expect("read name_sort");
|
||||
assert_eq!(name_sort, "beatles");
|
||||
|
||||
let completed_before: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![ARTIST_NAME_SORT_RECONCILE_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.expect("reconcile marker");
|
||||
assert!(completed_before > 0);
|
||||
|
||||
store
|
||||
.with_conn("test.reconcile_again", maybe_reconcile_artist_name_sort)
|
||||
.expect("reconcile again");
|
||||
|
||||
let name_sort_after: String = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.expect("read name_sort again");
|
||||
assert_eq!(name_sort_after, "beatles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_14_recovers_partial_schema_without_schema_migrations_row() {
|
||||
let uri = in_memory_uri();
|
||||
let conn = Connection::open(&uri).expect("connection");
|
||||
configure_write_connection(&conn).expect("pragmas");
|
||||
let migrations_through_13: &[(i64, &str)] = &[
|
||||
(1, INITIAL_SQL),
|
||||
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
|
||||
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
|
||||
];
|
||||
run_migrations_with(
|
||||
&conn,
|
||||
migrations_through_13,
|
||||
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||||
no_op_hook,
|
||||
)
|
||||
.expect("migrate through v13");
|
||||
conn.execute_batch(MIGRATION_014_ARTIST_NAME_SORT)
|
||||
.expect("apply ddl only");
|
||||
|
||||
let recorded: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = 14",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("count migration");
|
||||
assert_eq!(recorded, 0);
|
||||
|
||||
run_migrations_with(
|
||||
&conn,
|
||||
MIGRATIONS,
|
||||
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||||
no_op_hook,
|
||||
)
|
||||
.expect("recover partial migration");
|
||||
|
||||
let recorded_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = 14",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("count migration after");
|
||||
assert_eq!(recorded_after, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_conn_recovers_after_closure_panic() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let first: Result<i64, String> = store.with_read_conn(|_conn| {
|
||||
panic!("simulated read panic");
|
||||
});
|
||||
assert!(first.is_err());
|
||||
|
||||
let ok: i64 = store
|
||||
.with_read_conn(|conn| conn.query_row("SELECT 1", [], |r| r.get(0)))
|
||||
.expect("read after panic recovery");
|
||||
assert_eq!(ok, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Persist Subsonic `getArtists` / `getIndexes` bodies into the local `artist` table.
|
||||
|
||||
use crate::repos::{ArtistRepository, SyncStateRepository};
|
||||
use crate::store::LibraryStore;
|
||||
use psysonic_integration::subsonic::ArtistIndex;
|
||||
|
||||
use super::error::SyncError;
|
||||
|
||||
pub fn apply_artist_index(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
index: &ArtistIndex,
|
||||
) -> Result<(), SyncError> {
|
||||
let synced_at = super::now_unix_ms();
|
||||
let ignored = crate::artist_sort::ignored_articles_or_default(
|
||||
index.ignored_articles.as_deref(),
|
||||
);
|
||||
let sync_state = SyncStateRepository::new(store);
|
||||
sync_state
|
||||
.set_ignored_articles(server_id, library_scope, ignored)
|
||||
.map_err(SyncError::Storage)?;
|
||||
let repo = ArtistRepository::new(store);
|
||||
repo.upsert_index(server_id, index, synced_at).map_err(SyncError::Storage)?;
|
||||
repo.backfill_from_tracks(server_id, ignored, synced_at).map_err(SyncError::Storage)?;
|
||||
if let Some(ms) = index.last_modified_ms {
|
||||
sync_state
|
||||
.set_artists_last_modified_ms(server_id, library_scope, ms)
|
||||
.map_err(SyncError::Storage)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -91,6 +91,7 @@ pub async fn probe_and_persist(
|
||||
store: &crate::store::LibraryStore,
|
||||
subsonic: &psysonic_integration::subsonic::SubsonicClient,
|
||||
navidrome: Option<&NavidromeProbeCredentials>,
|
||||
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: &str,
|
||||
library_scope: &str,
|
||||
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
|
||||
@@ -110,7 +111,7 @@ pub async fn probe_and_persist(
|
||||
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut result = CapabilityProbe::run(subsonic, navidrome).await?;
|
||||
let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?;
|
||||
|
||||
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
|
||||
// must not drop a previously-learned NavidromeNativeBulk capability — the
|
||||
@@ -173,6 +174,8 @@ impl CapabilityProbe {
|
||||
pub async fn run(
|
||||
subsonic: &SubsonicClient,
|
||||
navidrome: Option<&NavidromeProbeCredentials>,
|
||||
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: Option<&str>,
|
||||
) -> Result<CapabilityProbeResult, SubsonicError> {
|
||||
let server_info = subsonic.server_info().await?;
|
||||
|
||||
@@ -207,7 +210,14 @@ impl CapabilityProbe {
|
||||
}
|
||||
|
||||
if let Some(creds) = navidrome {
|
||||
match native_bulk_available(&creds.server_url, &creds.bearer_token).await {
|
||||
match native_bulk_available(
|
||||
http_registry,
|
||||
server_id,
|
||||
&creds.server_url,
|
||||
&creds.bearer_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
|
||||
Ok(false) => {}
|
||||
Err(_) => {
|
||||
@@ -345,7 +355,7 @@ mod tests {
|
||||
let server = MockServer::start().await;
|
||||
mount_subsonic_full_navidrome(&server).await;
|
||||
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||
@@ -372,7 +382,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
|
||||
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
|
||||
@@ -415,7 +425,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
|
||||
@@ -440,7 +450,7 @@ mod tests {
|
||||
server_url: server.uri(),
|
||||
bearer_token: "nd-tok".into(),
|
||||
};
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
@@ -461,6 +471,7 @@ mod tests {
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"s1",
|
||||
"",
|
||||
)
|
||||
@@ -498,6 +509,7 @@ mod tests {
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"s1",
|
||||
"",
|
||||
)
|
||||
@@ -530,6 +542,7 @@ mod tests {
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"s1",
|
||||
"",
|
||||
)
|
||||
@@ -584,7 +597,7 @@ mod tests {
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let result =
|
||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
|
||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.server_track_count, Some(170_000));
|
||||
@@ -617,6 +630,7 @@ mod tests {
|
||||
&store,
|
||||
&test_subsonic_client(&server.uri()),
|
||||
None,
|
||||
None,
|
||||
"s1",
|
||||
"",
|
||||
)
|
||||
@@ -647,7 +661,7 @@ mod tests {
|
||||
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
|
||||
|
||||
let result =
|
||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
|
||||
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.server_track_count, None);
|
||||
@@ -672,7 +686,7 @@ mod tests {
|
||||
server_url: server.uri(),
|
||||
bearer_token: "nd-tok".into(),
|
||||
};
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
|
||||
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
||||
use psysonic_integration::subsonic::SubsonicClient;
|
||||
use serde_json::Value;
|
||||
@@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> {
|
||||
store: &'a LibraryStore,
|
||||
subsonic: &'a SubsonicClient,
|
||||
navidrome: Option<NavidromeProbeCredentials>,
|
||||
http_registry: Option<Arc<ServerHttpRegistry>>,
|
||||
server_id: String,
|
||||
library_scope: String,
|
||||
capability_flags: CapabilityFlags,
|
||||
@@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
store,
|
||||
subsonic,
|
||||
navidrome: None,
|
||||
http_registry: None,
|
||||
server_id: server_id.into(),
|
||||
library_scope: library_scope.into(),
|
||||
capability_flags,
|
||||
@@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
|
||||
self.http_registry = registry;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
|
||||
self.cancel = Some(flag);
|
||||
self
|
||||
@@ -204,8 +212,20 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// DS-9 — stamp watermarks.
|
||||
// DS-9 — stamp watermarks + refresh artist browse index when applicable.
|
||||
if let Some(ms) = probe.next_artists_watermark {
|
||||
let scope = self.library_scope_opt();
|
||||
if let Ok(index) = self.subsonic.get_artists(scope).await {
|
||||
super::artist_index::apply_artist_index(
|
||||
self.store,
|
||||
&self.server_id,
|
||||
&self.library_scope,
|
||||
&index,
|
||||
)?;
|
||||
}
|
||||
// Advance the watermark to the probed value regardless of the index
|
||||
// refresh result — a failed/empty `getArtists` must not force a full
|
||||
// refetch on every delta. Wins over the index's own last-modified.
|
||||
sync_state
|
||||
.set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms)
|
||||
.map_err(SyncError::Storage)?;
|
||||
@@ -384,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
self,
|
||||
|| {
|
||||
nd_list_songs_internal(
|
||||
self.http_registry.as_deref(),
|
||||
Some(&self.server_id),
|
||||
&creds.server_url,
|
||||
&creds.bearer_token,
|
||||
"updated_at",
|
||||
@@ -529,13 +551,7 @@ struct DeltaPollOutcome {
|
||||
next_artists_watermark: Option<i64>,
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
use super::now_unix_ms;
|
||||
|
||||
async fn retry_with_backoff<'a, F, FFut, T, E>(
|
||||
runner: &DeltaSyncRunner<'a>,
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
|
||||
use psysonic_integration::subsonic::SubsonicClient;
|
||||
use serde_json::Value;
|
||||
@@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> {
|
||||
store: &'a LibraryStore,
|
||||
subsonic: &'a SubsonicClient,
|
||||
navidrome: Option<NavidromeProbeCredentials>,
|
||||
http_registry: Option<Arc<ServerHttpRegistry>>,
|
||||
server_id: String,
|
||||
library_scope: String,
|
||||
capability_flags: CapabilityFlags,
|
||||
@@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
store,
|
||||
subsonic,
|
||||
navidrome: None,
|
||||
http_registry: None,
|
||||
server_id: server_id.into(),
|
||||
library_scope: library_scope.into(),
|
||||
capability_flags,
|
||||
@@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
|
||||
self.http_registry = registry;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
|
||||
self.cancel = Some(flag);
|
||||
self
|
||||
@@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
let cancel = self.cancel.clone();
|
||||
let sleep_enabled = self.sleep_enabled;
|
||||
let creds = creds.clone();
|
||||
let http_registry = self.http_registry.clone();
|
||||
let server_id = self.server_id.clone();
|
||||
let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
|
||||
|
||||
loop {
|
||||
@@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
queue.pump(|| self.check_cancellation(), |off| {
|
||||
let creds = creds.clone();
|
||||
let cancel = cancel.clone();
|
||||
let http_registry = http_registry.clone();
|
||||
let server_id = server_id.clone();
|
||||
tokio::spawn(async move {
|
||||
retry_fetch(
|
||||
sleep_enabled,
|
||||
@@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
|| async {
|
||||
let end = off.saturating_add(batch_size);
|
||||
let response = nd_list_songs_internal(
|
||||
http_registry.as_deref(),
|
||||
Some(&server_id),
|
||||
&creds.server_url,
|
||||
&creds.bearer_token,
|
||||
"id",
|
||||
@@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
self,
|
||||
|| {
|
||||
nd_list_songs_internal(
|
||||
self.http_registry.as_deref(),
|
||||
Some(&self.server_id),
|
||||
&creds.server_url,
|
||||
&creds.bearer_token,
|
||||
"id",
|
||||
@@ -1179,7 +1195,7 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
|
||||
async fn run_artist_pass(
|
||||
&self,
|
||||
sync_state: &SyncStateRepository<'_>,
|
||||
_sync_state: &SyncStateRepository<'_>,
|
||||
) -> Result<(), SyncError> {
|
||||
let scope = self.library_scope_opt();
|
||||
let artists = retry_with_backoff(
|
||||
@@ -1190,11 +1206,12 @@ impl<'a> InitialSyncRunner<'a> {
|
||||
.await
|
||||
.ok();
|
||||
if let Some(index) = artists {
|
||||
if let Some(ms) = index.last_modified_ms {
|
||||
sync_state
|
||||
.set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms)
|
||||
.map_err(SyncError::Storage)?;
|
||||
}
|
||||
super::artist_index::apply_artist_index(
|
||||
self.store,
|
||||
&self.server_id,
|
||||
&self.library_scope,
|
||||
&index,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1227,13 +1244,7 @@ fn is_empty_cursor(v: &Value) -> bool {
|
||||
matches!(v, Value::Object(o) if o.is_empty())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
use super::now_unix_ms;
|
||||
|
||||
/// Wrap an async closure in §6.8 backoff. Retries on `SyncError::Transport`
|
||||
/// up to `MAX_ATTEMPTS_PER_BATCH`, sleeping per the backoff schedule
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! `DeltaSyncRunner` / background scheduler / Tauri surface follow in
|
||||
//! PR-3c / PR-3d / PR-5.
|
||||
|
||||
pub mod artist_index;
|
||||
pub mod backoff;
|
||||
pub mod bandwidth;
|
||||
pub mod budget;
|
||||
@@ -38,3 +39,12 @@ pub use scheduler::{BackgroundScheduler, SchedulerTickReport, DEFAULT_TOMBSTONE_
|
||||
pub use strategy::IngestStrategy;
|
||||
pub use supervisor::SyncSupervisor;
|
||||
pub use tombstone::{should_auto_reconcile, TombstoneReconciler, TombstoneReport};
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch, saturating to `i64::MAX`.
|
||||
pub(crate) fn now_unix_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use psysonic_integration::subsonic::SubsonicClient;
|
||||
|
||||
use super::bandwidth::{ParallelismBudget, PlaybackHint};
|
||||
@@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> {
|
||||
store: &'a LibraryStore,
|
||||
subsonic: &'a SubsonicClient,
|
||||
navidrome: Option<NavidromeProbeCredentials>,
|
||||
http_registry: Option<Arc<ServerHttpRegistry>>,
|
||||
server_id: String,
|
||||
library_scope: String,
|
||||
capability_flags: CapabilityFlags,
|
||||
@@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
store,
|
||||
subsonic,
|
||||
navidrome: None,
|
||||
http_registry: None,
|
||||
server_id: server_id.into(),
|
||||
library_scope: library_scope.into(),
|
||||
capability_flags,
|
||||
@@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
|
||||
self.http_registry = registry;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
|
||||
self.playback_hint = hint;
|
||||
self
|
||||
@@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> {
|
||||
&self.library_scope,
|
||||
self.capability_flags,
|
||||
)
|
||||
.with_progress(Arc::clone(&self.progress));
|
||||
.with_progress(Arc::clone(&self.progress))
|
||||
.with_http_registry(self.http_registry.clone());
|
||||
if let Some(creds) = &self.navidrome {
|
||||
runner = runner.with_navidrome_credentials(creds.clone());
|
||||
}
|
||||
|
||||
+14
-1
@@ -2,6 +2,8 @@ use tauri::{Emitter, Manager};
|
||||
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
|
||||
use crate::file_transfer::apply_server_http_get;
|
||||
|
||||
pub fn resolve_hot_cache_root(
|
||||
custom_dir: Option<String>,
|
||||
app: &tauri::AppHandle,
|
||||
@@ -340,7 +342,18 @@ pub async fn download_zip(
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let http_registry = app
|
||||
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| std::sync::Arc::clone(&*s));
|
||||
let response = apply_server_http_get(
|
||||
&client,
|
||||
http_registry.as_deref(),
|
||||
None,
|
||||
&url,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
+12
-2
@@ -1,8 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
|
||||
use psysonic_audio as audio;
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::file_transfer::stream_to_file;
|
||||
use crate::file_transfer::{apply_server_http_get, stream_to_file};
|
||||
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
|
||||
use super::offline::enqueue_analysis_seed_from_file;
|
||||
|
||||
@@ -70,7 +73,14 @@ pub async fn download_track_hot_cache(
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
|
||||
let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
+13
-2
@@ -22,7 +22,7 @@ use psysonic_library::repos::TrackRow;
|
||||
use psysonic_library::{repos::TrackRepository, LibraryRuntime};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
||||
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||
use crate::{offline_cancel_flags, DownloadSemaphore};
|
||||
|
||||
use super::offline::enqueue_analysis_seed_from_file;
|
||||
@@ -359,7 +359,18 @@ pub async fn download_track_local(
|
||||
}
|
||||
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
let response = apply_server_http_get(
|
||||
&client,
|
||||
http_registry.as_deref(),
|
||||
Some(&server_index_key),
|
||||
&url,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
+18
-7
@@ -9,7 +9,7 @@ use psysonic_analysis::analysis_runtime::{
|
||||
};
|
||||
use crate::{offline_cancel_flags, DownloadSemaphore};
|
||||
|
||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
||||
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||
|
||||
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,12 +31,15 @@ pub async fn enqueue_analysis_seed_from_file(
|
||||
///
|
||||
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
|
||||
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn download_track_to_cache_dir(
|
||||
cache_dir: &std::path::Path,
|
||||
track_id: &str,
|
||||
suffix: &str,
|
||||
url: &str,
|
||||
client: &reqwest::Client,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
tokio::fs::create_dir_all(cache_dir)
|
||||
@@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir(
|
||||
return Ok(file_path);
|
||||
}
|
||||
|
||||
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let response = apply_server_http_get(client, registry, server_ref, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
@@ -129,12 +135,17 @@ pub async fn download_track_offline(
|
||||
}
|
||||
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
let final_path = download_track_to_cache_dir(
|
||||
&cache_dir,
|
||||
&track_id,
|
||||
&suffix,
|
||||
&url,
|
||||
&client,
|
||||
http_registry.as_deref(),
|
||||
Some(&server_id),
|
||||
cancel_flag.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -191,7 +202,7 @@ mod tests {
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/stream/track-1", server.uri());
|
||||
|
||||
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
|
||||
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(path.exists());
|
||||
@@ -211,7 +222,7 @@ mod tests {
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/should-not-be-hit", server.uri());
|
||||
|
||||
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
|
||||
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, pre_existing);
|
||||
@@ -232,7 +243,7 @@ mod tests {
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/stream/missing", server.uri());
|
||||
|
||||
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None)
|
||||
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None, None, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("HTTP 404"), "got {err}");
|
||||
@@ -256,7 +267,7 @@ mod tests {
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None)
|
||||
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cache_dir.join("t.mp3").exists());
|
||||
@@ -278,7 +289,7 @@ mod tests {
|
||||
|
||||
let cancel = AtomicBool::new(true);
|
||||
let err =
|
||||
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel))
|
||||
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, Some(&cancel))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, "CANCELLED");
|
||||
|
||||
@@ -13,6 +13,20 @@ pub fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn apply_server_http_get(
|
||||
client: &reqwest::Client,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
psysonic_core::server_http::apply_optional_registry_headers(
|
||||
registry,
|
||||
server_ref,
|
||||
url,
|
||||
client.get(url),
|
||||
)
|
||||
}
|
||||
|
||||
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
|
||||
/// file in memory — keeps RAM flat regardless of file size.
|
||||
///
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Emitter;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::sync_cancel_flags;
|
||||
|
||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
||||
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||
use super::device::{
|
||||
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
|
||||
TrackSyncInfo,
|
||||
@@ -107,6 +107,7 @@ pub struct SyncDeltaResult {
|
||||
|
||||
pub async fn fetch_subsonic_songs(
|
||||
client: &reqwest::Client,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
auth: &SubsonicAuthPayload,
|
||||
endpoint: &str,
|
||||
id: &str,
|
||||
@@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs(
|
||||
("f", auth.f.as_str()),
|
||||
("id", id),
|
||||
];
|
||||
let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?;
|
||||
let res = apply_server_http_get(client, registry, None, &url)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||
parse_subsonic_songs(&json, endpoint)
|
||||
}
|
||||
@@ -239,8 +244,12 @@ pub async fn calculate_sync_payload(
|
||||
deletion_ids: Vec<String>,
|
||||
auth: SubsonicAuthPayload,
|
||||
target_dir: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncDeltaResult, String> {
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
|
||||
let mut add_bytes = 0;
|
||||
let mut add_count = 0;
|
||||
@@ -264,17 +273,19 @@ pub async fn calculate_sync_payload(
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
let reg_for_task = http_registry.clone();
|
||||
let source_snapshot = source.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let registry = reg_for_task.as_deref();
|
||||
let mut res_tracks = Vec::new();
|
||||
if source.source_type == "album" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
} else if source.source_type == "playlist" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||
} else if source.source_type == "artist" {
|
||||
let url = format!("{}/getArtist.view", auth_clone.base_url);
|
||||
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
|
||||
if let Ok(re) = cli.get(&url).query(&query).send().await {
|
||||
if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await {
|
||||
if let Ok(js) = re.json::<serde_json::Value>().await {
|
||||
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
|
||||
let arr = root.as_array().cloned().unwrap_or_else(|| {
|
||||
@@ -282,7 +293,7 @@ pub async fn calculate_sync_payload(
|
||||
});
|
||||
for al in arr {
|
||||
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", aid).await {
|
||||
res_tracks.extend(ts);
|
||||
}
|
||||
}
|
||||
@@ -303,12 +314,14 @@ pub async fn calculate_sync_payload(
|
||||
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
|
||||
};
|
||||
let cli = client.clone();
|
||||
let reg_for_task = http_registry.clone();
|
||||
del_handles.push(tokio::spawn(async move {
|
||||
let registry = reg_for_task.as_deref();
|
||||
let mut res_tracks = Vec::new();
|
||||
if source.source_type == "album" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
|
||||
} else if source.source_type == "playlist" {
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
|
||||
}
|
||||
res_tracks
|
||||
}));
|
||||
@@ -437,6 +450,9 @@ pub async fn sync_batch_to_device(
|
||||
|
||||
// Shared reqwest client — reused across all downloads.
|
||||
let client = subsonic_http_client(Duration::from_secs(300))?;
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
|
||||
// Concurrency limiter: max 2 parallel USB writes.
|
||||
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
|
||||
@@ -455,6 +471,7 @@ pub async fn sync_batch_to_device(
|
||||
for track in tracks {
|
||||
let sem = semaphore.clone();
|
||||
let cli = client.clone();
|
||||
let reg_for_task = http_registry.clone();
|
||||
let app2 = app.clone();
|
||||
let job = job_id.clone();
|
||||
let dest = dest_dir.clone();
|
||||
@@ -466,6 +483,7 @@ pub async fn sync_batch_to_device(
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.expect("semaphore closed");
|
||||
let registry = reg_for_task.as_deref();
|
||||
|
||||
// Bail out if cancelled while waiting in the semaphore queue.
|
||||
if cancel.load(Ordering::Relaxed) { return; }
|
||||
@@ -492,7 +510,7 @@ pub async fn sync_batch_to_device(
|
||||
}
|
||||
}
|
||||
|
||||
let response = match cli.get(&track.url).send().await {
|
||||
let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
f.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -819,7 +837,7 @@ mod tests {
|
||||
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
||||
.unwrap();
|
||||
let auth = fake_auth(server.uri());
|
||||
let songs = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "album-42")
|
||||
let songs = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "album-42")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(songs.len(), 2);
|
||||
@@ -838,7 +856,7 @@ mod tests {
|
||||
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
||||
.unwrap();
|
||||
let auth = fake_auth(server.uri());
|
||||
let result = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "missing").await;
|
||||
let result = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "missing").await;
|
||||
// 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we
|
||||
// just assert the function does not panic and propagates an error string.
|
||||
assert!(result.is_err());
|
||||
@@ -989,7 +1007,7 @@ mod tests {
|
||||
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
|
||||
.unwrap();
|
||||
let auth = fake_auth(server.uri());
|
||||
let songs = fetch_subsonic_songs(&client, &auth, "getPlaylist.view", "p1")
|
||||
let songs = fetch_subsonic_songs(&client, None, &auth, "getPlaylist.view", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use tauri::Emitter;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
||||
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
|
||||
|
||||
// ─── Device Sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track(
|
||||
suffix: &str,
|
||||
url: &str,
|
||||
client: &reqwest::Client,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
) -> Result<bool, String> {
|
||||
if dest_path.exists() {
|
||||
return Ok(false);
|
||||
@@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let response = apply_server_http_get(client, registry, server_ref, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
@@ -366,7 +371,19 @@ pub async fn sync_track_to_device(
|
||||
let path_str = dest_path.to_string_lossy().to_string();
|
||||
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
|
||||
match sync_download_one_track(&dest_path, &track.suffix, &track.url, &client).await {
|
||||
let http_registry = app
|
||||
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| std::sync::Arc::clone(&*s));
|
||||
match sync_download_one_track(
|
||||
&dest_path,
|
||||
&track.suffix,
|
||||
&track.url,
|
||||
&client,
|
||||
http_registry.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(false) => {
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
|
||||
@@ -656,7 +673,7 @@ mod tests {
|
||||
let dest = dir.path().join("Album").join("01 - track.flac");
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/track", server.uri());
|
||||
let downloaded = sync_download_one_track(&dest, "flac", &url, &client)
|
||||
let downloaded = sync_download_one_track(&dest, "flac", &url, &client, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(downloaded, "fresh download must report Ok(true)");
|
||||
@@ -672,7 +689,7 @@ mod tests {
|
||||
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/should-not-be-hit", server.uri());
|
||||
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client)
|
||||
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!downloaded, "pre-existing file must be reported as skipped");
|
||||
@@ -692,7 +709,7 @@ mod tests {
|
||||
let dest = dir.path().join("track.opus");
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/missing", server.uri());
|
||||
let err = sync_download_one_track(&dest, "opus", &url, &client)
|
||||
let err = sync_download_one_track(&dest, "opus", &url, &client, None, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("HTTP 403"));
|
||||
@@ -714,7 +731,7 @@ mod tests {
|
||||
assert!(!dest.parent().unwrap().exists());
|
||||
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
|
||||
let url = format!("{}/t", server.uri());
|
||||
sync_download_one_track(&dest, "mp3", &url, &client)
|
||||
sync_download_one_track(&dest, "mp3", &url, &client, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(dest.exists());
|
||||
|
||||
@@ -309,6 +309,12 @@ async fn ensure_one(
|
||||
password: session.password,
|
||||
library_bulk: true,
|
||||
library_server_id: Some(session.library_server_id),
|
||||
// Library backfill never touches external providers (§15).
|
||||
external_artwork_enabled: false,
|
||||
surface_kind: None,
|
||||
artist_name: None,
|
||||
album_title: None,
|
||||
external_artwork_byok: None,
|
||||
};
|
||||
let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
|
||||
dir.join(format!("{tier}.webp"))
|
||||
}
|
||||
|
||||
/// External-provider tier file in the SAME entity dir, differentiated by a
|
||||
/// filename suffix only (image-scraper §14/§16): `{tier}-{provider}.webp`
|
||||
/// (e.g. `2000-fanart.webp`). The `coverStorageKey`/`cacheKind` is unchanged.
|
||||
pub fn provider_tier_path(dir: &Path, tier: u32, provider: &str) -> PathBuf {
|
||||
dir.join(format!("{tier}-{provider}.webp"))
|
||||
}
|
||||
|
||||
pub fn provider_tier_exists(dir: &Path, tier: u32, provider: &str) -> Option<PathBuf> {
|
||||
let p = provider_tier_path(dir, tier, provider);
|
||||
if p.is_file() {
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn meta_path(dir: &Path) -> PathBuf {
|
||||
dir.join("meta.json")
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! External artist-artwork providers (image-scraper P0 spike).
|
||||
//!
|
||||
//! - Subsonic `getArtistInfo2` → the artist's tag MusicBrainz id (§19 step 2;
|
||||
//! MBID resolution stays Rust-side per §23).
|
||||
//! - fanart.tv `v3/music/<mbid>` → the first `artistbackground` URL.
|
||||
//!
|
||||
//! Mirrors the token auth of `fetch.rs`. The chosen background image's bytes
|
||||
//! are downloaded by the ensure flow via `fetch::fetch_cover_bytes` (a generic
|
||||
//! retrying GET). All network use is gated by the caller (feature flag +
|
||||
//! reachability + the dedicated low-concurrency fanart semaphore).
|
||||
|
||||
use reqwest::Client;
|
||||
|
||||
use super::fetch::build_subsonic_url;
|
||||
|
||||
const FANART_API_BASE: &str = "https://webservice.fanart.tv/v3/music";
|
||||
const MUSICBRAINZ_BASE: &str = "https://musicbrainz.org/ws/2";
|
||||
/// fanart.tv project `api_key`, embedded in the binary like Last.fm's key and as
|
||||
/// fanart.tv's own terms expect ("sent in addition to your project key" — the app
|
||||
/// ships a project key, users add a personal one on top). Committed as a literal
|
||||
/// (not a build secret) so every build — CI, local, AUR, Nix, from-source — has
|
||||
/// it; desktop-app keys are extractable from any binary anyway. Users can still
|
||||
/// add their own personal key (BYOK, §22), sent in addition to this one.
|
||||
pub(super) const FANART_PROJECT_KEY: &str = "a32e00543d18deadb797bc0cc9826760";
|
||||
/// MusicBrainz requires a meaningful, contactable User-Agent (their ToS).
|
||||
const MUSICBRAINZ_USER_AGENT: &str = concat!(
|
||||
"Psysonic/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( https://github.com/Psychotoxical/psysonic )"
|
||||
);
|
||||
|
||||
/// Subsonic `getArtistInfo2.view` (JSON) URL for an artist id.
|
||||
pub fn build_artist_info2_url(
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
artist_id: &str,
|
||||
) -> String {
|
||||
build_subsonic_url(
|
||||
rest_base,
|
||||
"getArtistInfo2",
|
||||
username,
|
||||
password,
|
||||
&[("id", artist_id), ("f", "json")],
|
||||
)
|
||||
}
|
||||
|
||||
/// fanart.tv music endpoint URL for an MBID. The BYOK personal `client_key` is
|
||||
/// sent **in addition to** the project `api_key` when non-empty (fanart.tv ToS,
|
||||
/// §22) — never a replacement.
|
||||
pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) -> String {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("api_key", api_key);
|
||||
if let Some(ck) = client_key {
|
||||
if !ck.is_empty() {
|
||||
serializer.append_pair("client_key", ck);
|
||||
}
|
||||
}
|
||||
format!("{FANART_API_BASE}/{mbid}?{}", serializer.finish())
|
||||
}
|
||||
|
||||
/// GET `getArtistInfo2` and extract `artistInfo2.musicBrainzId` (tag MBID).
|
||||
/// `Ok(None)` when the artist carries no MBID tag.
|
||||
pub async fn fetch_artist_tag_mbid(
|
||||
client: &Client,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
artist_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let url = build_artist_info2_url(rest_base, username, password, artist_id);
|
||||
let body = http_get_text_scoped(client, registry, server_ref, &url).await?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
let mbid = v
|
||||
.get("subsonic-response")
|
||||
.and_then(|r| r.get("artistInfo2"))
|
||||
.and_then(|a| a.get("musicBrainzId"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(mbid)
|
||||
}
|
||||
|
||||
/// Map a render surface to its fanart.tv JSON array key. `fanart` (the 16:9
|
||||
/// fullscreen background) → `artistbackground`; `banner` (the wide artist-detail
|
||||
/// header strip) → `musicbanner`.
|
||||
pub fn fanart_json_key(surface: &str) -> &'static str {
|
||||
match surface {
|
||||
"banner" => "musicbanner",
|
||||
_ => "artistbackground",
|
||||
}
|
||||
}
|
||||
|
||||
/// GET the fanart.tv music JSON for an MBID and return the first image URL for
|
||||
/// the requested `surface` (the API returns each kind most-liked first).
|
||||
/// `Ok(None)` when the artist has no image of that kind (404 or empty array).
|
||||
pub async fn fetch_fanart_image_url(
|
||||
client: &Client,
|
||||
mbid: &str,
|
||||
api_key: &str,
|
||||
client_key: Option<&str>,
|
||||
surface: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let url = build_fanart_url(mbid, api_key, client_key);
|
||||
let Some(body) = http_get_text_opt(client, &url).await? else {
|
||||
return Ok(None); // 404 → artist has no fanart at all
|
||||
};
|
||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
let img = v
|
||||
.get(fanart_json_key(surface))
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|o| o.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Outcome of a name→MusicBrainz artist-MBID resolution (§19).
|
||||
pub enum MbResolution {
|
||||
/// A single, confident artist MBID (one artist across high-score releases).
|
||||
Found(String),
|
||||
/// Multiple candidate artists — never guess; the caller backs off 24h.
|
||||
Ambiguous,
|
||||
/// No matching release at all.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Escape the Lucene special characters that would break a MusicBrainz query.
|
||||
fn mb_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
/// Strip a single trailing parenthetical / bracketed qualifier (e.g.
|
||||
/// "(2004 Remastered)", "[Deluxe Edition]") so a decorated library album title
|
||||
/// still matches the canonical MusicBrainz release. Leaves leading qualifiers
|
||||
/// (e.g. "(What's the Story) Morning Glory?") untouched.
|
||||
fn normalize_album_for_mb(title: &str) -> String {
|
||||
let t = title.trim();
|
||||
let stripped = if t.ends_with(')') {
|
||||
t.rfind(" (").map(|i| &t[..i]).unwrap_or(t)
|
||||
} else if t.ends_with(']') {
|
||||
t.rfind(" [").map(|i| &t[..i]).unwrap_or(t)
|
||||
} else {
|
||||
t
|
||||
};
|
||||
stripped.trim().to_string()
|
||||
}
|
||||
|
||||
/// Resolve an artist MBID by name, confirmed by an album release (§19). One
|
||||
/// query to the MusicBrainz release search; the primary artist across the
|
||||
/// high-confidence releases wins, conflicting ids → `Ambiguous`. Sends the
|
||||
/// required User-Agent. The caller enforces the ≤1 req/s rate limit.
|
||||
pub async fn resolve_mbid_via_musicbrainz(
|
||||
client: &Client,
|
||||
artist_name: &str,
|
||||
album_title: &str,
|
||||
) -> Result<MbResolution, String> {
|
||||
let album = normalize_album_for_mb(album_title);
|
||||
let query = format!(
|
||||
"artist:\"{}\" AND release:\"{}\"",
|
||||
mb_escape(artist_name),
|
||||
mb_escape(&album)
|
||||
);
|
||||
// Scope the (non-Send) serializer so it is dropped before the await below.
|
||||
let url = {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("query", &query);
|
||||
serializer.append_pair("fmt", "json");
|
||||
serializer.append_pair("limit", "8");
|
||||
format!("{MUSICBRAINZ_BASE}/release/?{}", serializer.finish())
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(reqwest::header::USER_AGENT, MUSICBRAINZ_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let body = resp.text().await.map_err(|e| e.to_string())?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
|
||||
Ok(classify_mb_releases(&v))
|
||||
}
|
||||
|
||||
/// Pure classification of a MusicBrainz release-search response: the primary
|
||||
/// artist id of each release scoring ≥ 90. One distinct id → `Found`, several →
|
||||
/// `Ambiguous`, none → `None`.
|
||||
fn classify_mb_releases(v: &serde_json::Value) -> MbResolution {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if let Some(releases) = v.get("releases").and_then(|r| r.as_array()) {
|
||||
for rel in releases {
|
||||
let score = rel.get("score").and_then(|s| s.as_i64()).unwrap_or(0);
|
||||
if score < 90 {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = rel
|
||||
.get("artist-credit")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|c| c.get("artist"))
|
||||
.and_then(|a| a.get("id"))
|
||||
.and_then(|i| i.as_str())
|
||||
{
|
||||
ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
match ids.len() {
|
||||
0 => MbResolution::None,
|
||||
1 => MbResolution::Found(ids.into_iter().next().unwrap_or_default()),
|
||||
_ => MbResolution::Ambiguous,
|
||||
}
|
||||
}
|
||||
|
||||
/// Single GET → response text; any non-2xx is an error.
|
||||
async fn http_get_text_scoped(
|
||||
client: &Client,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
url: &str,
|
||||
) -> Result<String, String> {
|
||||
let resp = psysonic_core::server_http::apply_optional_registry_headers(
|
||||
registry,
|
||||
server_ref,
|
||||
url,
|
||||
client.get(url),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Single GET → `Some(text)` on success, `None` on 404, error otherwise.
|
||||
async fn http_get_text_opt(client: &Client, url: &str) -> Result<Option<String>, String> {
|
||||
let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
resp.text().await.map(Some).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artist_info2_url_is_json_and_token_authed() {
|
||||
let u = build_artist_info2_url("http://nav.local:4533", "u", "p", "ar-1");
|
||||
assert!(u.starts_with("http://nav.local:4533/rest/getArtistInfo2.view?"));
|
||||
assert!(u.contains("id=ar-1"));
|
||||
assert!(u.contains("f=json"));
|
||||
assert!(u.contains("&t=") && u.contains("&s="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanart_url_adds_client_key_only_when_present() {
|
||||
assert_eq!(
|
||||
build_fanart_url("mbid-123", "PROJ", None),
|
||||
"https://webservice.fanart.tv/v3/music/mbid-123?api_key=PROJ"
|
||||
);
|
||||
let byok = build_fanart_url("mbid-123", "PROJ", Some("PERS"));
|
||||
assert!(byok.contains("api_key=PROJ") && byok.contains("client_key=PERS"));
|
||||
// empty BYOK is ignored — project key only
|
||||
assert!(!build_fanart_url("mbid-123", "PROJ", Some("")).contains("client_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_first_artistbackground_url() {
|
||||
let json = r#"{"artistbackground":[{"id":"1","url":"https://a/bg1.jpg","likes":"9"},{"url":"https://a/bg2.jpg"}]}"#;
|
||||
let v: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let bg = v
|
||||
.get("artistbackground")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|o| o.get("url"))
|
||||
.and_then(|u| u.as_str());
|
||||
assert_eq!(bg, Some("https://a/bg1.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_key_maps_surface() {
|
||||
assert_eq!(fanart_json_key("fanart"), "artistbackground");
|
||||
assert_eq!(fanart_json_key("banner"), "musicbanner");
|
||||
assert_eq!(fanart_json_key("anything-else"), "artistbackground");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_album_strips_trailing_qualifier_only() {
|
||||
assert_eq!(normalize_album_for_mb("Show No Mercy (2004 Remastered)"), "Show No Mercy");
|
||||
assert_eq!(normalize_album_for_mb("Album [Deluxe Edition]"), "Album");
|
||||
assert_eq!(normalize_album_for_mb("Reign in Blood"), "Reign in Blood");
|
||||
// leading qualifier left intact (does not end with a close bracket)
|
||||
assert_eq!(
|
||||
normalize_album_for_mb("(What's the Story) Morning Glory?"),
|
||||
"(What's the Story) Morning Glory?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mb_escape_handles_quotes_and_backslashes() {
|
||||
assert_eq!(mb_escape("AC/DC"), "AC/DC");
|
||||
assert_eq!(mb_escape(r#"a"b"#), r#"a\"b"#);
|
||||
assert_eq!(mb_escape(r"a\b"), r"a\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mb_picks_single_high_score_artist() {
|
||||
let v: serde_json::Value = serde_json::from_str(
|
||||
r#"{"releases":[
|
||||
{"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
|
||||
{"score":95,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
|
||||
{"score":40,"artist-credit":[{"artist":{"id":"mbid-Z"}}]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(classify_mb_releases(&v), MbResolution::Found(id) if id == "mbid-A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_mb_ambiguous_and_none() {
|
||||
let two: serde_json::Value = serde_json::from_str(
|
||||
r#"{"releases":[
|
||||
{"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
|
||||
{"score":92,"artist-credit":[{"artist":{"id":"mbid-B"}}]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(classify_mb_releases(&two), MbResolution::Ambiguous));
|
||||
|
||||
let low: serde_json::Value =
|
||||
serde_json::from_str(r#"{"releases":[{"score":50,"artist-credit":[{"artist":{"id":"x"}}]}]}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(classify_mb_releases(&low), MbResolution::None));
|
||||
|
||||
let empty: serde_json::Value = serde_json::from_str(r#"{"releases":[]}"#).unwrap();
|
||||
assert!(matches!(classify_mb_releases(&empty), MbResolution::None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! External artist-artwork ensure path (image-scraper). Split out of `mod.rs`
|
||||
//! to keep the cover-cache orchestrator navigable: the on-demand fanart/banner
|
||||
//! fetch (fanart.tv via MBID resolution), the §11 quality gate, the
|
||||
//! surface-aware peek, and the §12 lookup-table cache. Everything here is gated
|
||||
//! by `ensure_inner` (feature flag, `!library_bulk`, artist kind) — see the call
|
||||
//! site in `mod.rs`. Pure code move; behaviour unchanged.
|
||||
|
||||
use super::encode::write_webp_tier;
|
||||
use super::{decode_image_bytes, disk, external, fetch, peek_fallback_tiers, peek_tier_path};
|
||||
use super::CoverCacheEnsureArgs;
|
||||
use psysonic_library::LibraryRuntime;
|
||||
use reqwest::Client;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Spike negative-cache marker — "this artist has no fanart", mirrors the
|
||||
/// §11 quality gate for the fanart (16:9) surface: an existing on-disk image
|
||||
/// pre-empts an external fetch only when it is wide enough AND roughly 16:9.
|
||||
/// Square Navidrome artist portraits never satisfy it.
|
||||
const FANART_MIN_WIDTH: u32 = 1280;
|
||||
const FANART_ASPECT_MIN: f32 = 1.6;
|
||||
const FANART_ASPECT_MAX: f32 = 2.0;
|
||||
|
||||
/// The external-artwork surfaces fanart.tv serves for an artist. Returns the
|
||||
/// surface name — also the on-disk file suffix (`{tier}-{surface}.webp`) and the
|
||||
/// lookup `surface_kind` — when the requested surface is external, else `None`.
|
||||
pub(super) fn external_surface(surface_kind: Option<&str>) -> Option<&str> {
|
||||
match surface_kind {
|
||||
Some("fanart") => Some("fanart"),
|
||||
Some("banner") => Some("banner"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`peek_tier_path`] but, for an external surface (`fanart`/`banner`),
|
||||
/// serves only the matching `{tier}-{surface}.webp` tiers. If none exist yet it
|
||||
/// returns None so ensure runs the external branch (fetch; Navidrome is the
|
||||
/// fallback inside that branch's miss path) instead of short-circuiting on a
|
||||
/// cached Navidrome tier (§18, "external prioritised").
|
||||
pub(super) fn peek_cover_path(dir: &Path, want: u32, args: &CoverCacheEnsureArgs) -> Option<PathBuf> {
|
||||
if let Some(surface) = external_surface(args.surface_kind.as_deref()) {
|
||||
if let Some(p) = disk::provider_tier_exists(dir, want, surface) {
|
||||
return Some(p);
|
||||
}
|
||||
for &tier in peek_fallback_tiers(want) {
|
||||
if let Some(p) = disk::provider_tier_exists(dir, tier, surface) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
peek_tier_path(dir, want)
|
||||
}
|
||||
|
||||
fn marker_recent(path: &Path, max_age: Duration) -> bool {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| t.elapsed().map(|e| e < max_age).unwrap_or(true))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn write_marker(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(path, b"1");
|
||||
}
|
||||
|
||||
/// §11: do these pixel dimensions satisfy the fanart (16:9) surface?
|
||||
fn dims_satisfy_fanart(w: u32, h: u32) -> bool {
|
||||
if w < FANART_MIN_WIDTH || h == 0 {
|
||||
return false;
|
||||
}
|
||||
let aspect = w as f32 / h as f32;
|
||||
(FANART_ASPECT_MIN..=FANART_ASPECT_MAX).contains(&aspect)
|
||||
}
|
||||
|
||||
/// §11 quality gate: true when a Navidrome tier already on disk is an HQ ~16:9
|
||||
/// image (so the external fetch can be skipped). Reads dimensions only — no
|
||||
/// full decode. Square artist portraits fail and external proceeds.
|
||||
fn navidrome_tier_is_hq_fanart(dir: &Path) -> bool {
|
||||
for &tier in &[2000u32, 800, 512, 256, 128] {
|
||||
let p = disk::tier_path(dir, tier);
|
||||
if p.is_file() {
|
||||
if let Ok((w, h)) = image::image_dimensions(&p) {
|
||||
if dims_satisfy_fanart(w, h) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read the cached `artist_artwork_lookup` row for a surface off the async
|
||||
/// executor (§12).
|
||||
async fn read_artist_lookup(
|
||||
store: &Option<Arc<psysonic_library::store::LibraryStore>>,
|
||||
server_id: &str,
|
||||
artist_id: &str,
|
||||
surface: &str,
|
||||
) -> Option<psysonic_library::artist_artwork::ArtistArtworkRow> {
|
||||
let store = store.clone()?;
|
||||
let (server_id, artist_id, surface) =
|
||||
(server_id.to_string(), artist_id.to_string(), surface.to_string());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
psysonic_library::artist_artwork::get_artist_artwork(&store, &server_id, &artist_id, &surface)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Upsert an `artist_artwork_lookup` row off the async executor (§12). No-op
|
||||
/// when the library store is absent (e.g. before login).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn persist_artist_lookup(
|
||||
store: &Option<Arc<psysonic_library::store::LibraryStore>>,
|
||||
server_id: &str,
|
||||
artist_id: &str,
|
||||
surface: &str,
|
||||
status: &str,
|
||||
mbid: Option<&str>,
|
||||
mbid_source: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
now: i64,
|
||||
) {
|
||||
let Some(store) = store.clone() else {
|
||||
return;
|
||||
};
|
||||
let (server_id, artist_id, surface, status) = (
|
||||
server_id.to_string(),
|
||||
artist_id.to_string(),
|
||||
surface.to_string(),
|
||||
status.to_string(),
|
||||
);
|
||||
let (mbid, mbid_source, provider) = (
|
||||
mbid.map(String::from),
|
||||
mbid_source.map(String::from),
|
||||
provider.map(String::from),
|
||||
);
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
psysonic_library::artist_artwork::upsert_artist_artwork(
|
||||
&store,
|
||||
&server_id,
|
||||
&artist_id,
|
||||
&surface,
|
||||
mbid.as_deref(),
|
||||
mbid_source.as_deref(),
|
||||
&status,
|
||||
provider.as_deref(),
|
||||
now,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Try to satisfy an external artist `surface` (`fanart` 16:9 background or
|
||||
/// `banner` strip) from fanart.tv. Writes `{2000,512}-{surface}.webp` into the
|
||||
/// entity dir and returns the requested-tier path on success. `None` = "no
|
||||
/// image, fall through to Navidrome" — never writes a `.fetch-failed` marker
|
||||
/// (§28).
|
||||
///
|
||||
/// MBID resolution stays Rust-side (§23): the tag MBID via `getArtistInfo2`,
|
||||
/// else a name→MusicBrainz album-confirmed lookup (§19), cached per surface in
|
||||
/// `artist_artwork_lookup` (§12). The §11 quality gate runs first for the
|
||||
/// `fanart` surface only.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn try_external_fanart(
|
||||
app: &AppHandle,
|
||||
args: &CoverCacheEnsureArgs,
|
||||
dir: &Path,
|
||||
client: &Client,
|
||||
fanart_sem: &Arc<Semaphore>,
|
||||
musicbrainz_sem: &Arc<Semaphore>,
|
||||
requested: u32,
|
||||
surface: &str,
|
||||
) -> Option<PathBuf> {
|
||||
// Project key: a runtime env var (dev convenience) wins, else the embedded
|
||||
// `FANART_PROJECT_KEY` committed in the source — so the feature works in every
|
||||
// build (CI, local, AUR, Nix, from-source), not just ones built with a secret.
|
||||
// The BYOK personal key is optional and sent in addition (§22).
|
||||
let api_key = std::env::var("PSYSONIC_FANART_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
.unwrap_or_else(|| external::FANART_PROJECT_KEY.to_string());
|
||||
// BYOK personal key (§22): the settings field wins, else the dev env var.
|
||||
let byok = args
|
||||
.external_artwork_byok
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|k| !k.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| std::env::var("PSYSONIC_FANART_CLIENT_KEY").ok())
|
||||
.filter(|k| !k.is_empty());
|
||||
|
||||
// §11 quality gate applies to the 16:9 `fanart` surface only — if Navidrome
|
||||
// already serves an HQ ~16:9 image, skip the external fetch. The `banner`
|
||||
// strip has its own aspect and is never pre-empted by a Navidrome tier.
|
||||
if surface == "fanart" && navidrome_tier_is_hq_fanart(dir) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// §12: the lookup table is both the MBID resolution cache and the negative
|
||||
// cache. Absent before login → all reads/writes become no-ops.
|
||||
let store: Option<Arc<psysonic_library::store::LibraryStore>> =
|
||||
app.try_state::<LibraryRuntime>().map(|rt| rt.store.clone());
|
||||
let server_id = &args.server_index_key;
|
||||
let artist_id = &args.cache_entity_id;
|
||||
let now = now_unix_ms();
|
||||
|
||||
let cached = read_artist_lookup(&store, server_id, artist_id, surface).await;
|
||||
if let Some(row) = &cached {
|
||||
// Back off: no/ambiguous MBID for 24h; a confirmed "no fanart" miss for
|
||||
// 30 min (also held by the `.miss-fanart` marker).
|
||||
let within = |window: Duration| now - row.updated_at < window.as_millis() as i64;
|
||||
match row.status.as_str() {
|
||||
"no_mbid" | "mbid_ambiguous" if within(Duration::from_secs(24 * 60 * 60)) => {
|
||||
return None;
|
||||
}
|
||||
"miss" if within(Duration::from_secs(30 * 60)) => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let miss_marker = dir.join(format!(".miss-{surface}"));
|
||||
if marker_recent(&miss_marker, Duration::from_secs(30 * 60)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let _permit = fanart_sem.clone().acquire_owned().await.ok()?;
|
||||
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
|
||||
// §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
|
||||
// already carries one (skip the Navidrome round-trip).
|
||||
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) {
|
||||
Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
|
||||
None => match external::fetch_artist_tag_mbid(
|
||||
client,
|
||||
http_registry.as_deref(),
|
||||
Some(server_id),
|
||||
&args.rest_base_url,
|
||||
&args.username,
|
||||
&args.password,
|
||||
&args.cache_entity_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(m)) => (m, Some("tag".to_string())),
|
||||
Ok(None) => {
|
||||
// No tag MBID. §19: try a name→MusicBrainz album-confirmed lookup
|
||||
// when both the artist name and an album are in context.
|
||||
match (args.artist_name.as_deref(), args.album_title.as_deref()) {
|
||||
(Some(name), Some(album))
|
||||
if !name.trim().is_empty() && !album.trim().is_empty() =>
|
||||
{
|
||||
// ≤1 req/s: hold the single MB permit across the request
|
||||
// plus a ≥1s spacing so concurrent ensures can't burst MB.
|
||||
let _mb = musicbrainz_sem.clone().acquire_owned().await.ok()?;
|
||||
let resolved =
|
||||
external::resolve_mbid_via_musicbrainz(client, name, album).await;
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
drop(_mb);
|
||||
match resolved {
|
||||
Ok(external::MbResolution::Found(m)) => {
|
||||
(m, Some("musicbrainz".to_string()))
|
||||
}
|
||||
Ok(external::MbResolution::Ambiguous) => {
|
||||
persist_artist_lookup(
|
||||
&store, server_id, artist_id, surface, "mbid_ambiguous", None,
|
||||
None, None, now,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
Ok(external::MbResolution::None) => {
|
||||
persist_artist_lookup(
|
||||
&store, server_id, artist_id, surface, "no_mbid", None, None,
|
||||
None, now,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[fanart] musicbrainz failed: {e}"); // transient
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// No album context → we could not even *attempt* name→MB.
|
||||
// Do NOT cache `no_mbid`: a later ensure that arrives with
|
||||
// album context (e.g. once the artist's album list loads)
|
||||
// would otherwise be blocked by the 24h backoff.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[fanart] getArtistInfo2 failed: {e}"); // transient — don't cache
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let img_url = match external::fetch_fanart_image_url(
|
||||
client,
|
||||
&mbid,
|
||||
&api_key,
|
||||
byok.as_deref(),
|
||||
surface,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
write_marker(&miss_marker); // artist has no image of this kind
|
||||
persist_artist_lookup(
|
||||
&store,
|
||||
server_id,
|
||||
artist_id,
|
||||
surface,
|
||||
"miss",
|
||||
Some(&mbid),
|
||||
mbid_source.as_deref(),
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[fanart] lookup failed: {e}"); // transient — don't cache
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = match fetch::fetch_cover_bytes(client, &img_url, None, None).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("[fanart] download failed: {e}"); // transient — don't cache
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Decode + write {2000,512}-{surface}.webp (matryoshka §17).
|
||||
let dir_owned = dir.to_path_buf();
|
||||
let surface_owned = surface.to_string();
|
||||
let encoded = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let img = decode_image_bytes(&bytes)?;
|
||||
std::fs::create_dir_all(&dir_owned).map_err(|e| e.to_string())?;
|
||||
for tier in [2000u32, 512u32] {
|
||||
write_webp_tier(&img, tier, &disk::provider_tier_path(&dir_owned, tier, &surface_owned))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if !matches!(encoded, Ok(Ok(()))) {
|
||||
eprintln!("[fanart] encode failed: {encoded:?}");
|
||||
return None;
|
||||
}
|
||||
|
||||
persist_artist_lookup(
|
||||
&store,
|
||||
server_id,
|
||||
artist_id,
|
||||
surface,
|
||||
"hit",
|
||||
Some(&mbid),
|
||||
mbid_source.as_deref(),
|
||||
Some("fanart"),
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
|
||||
// NOTE: do NOT emit `cover:tier-ready` here. That event is keyed by the
|
||||
// canonical cover key (cacheKind/cacheEntityId/tier, no surface), so emitting
|
||||
// it with the `{tier}-{surface}.webp` path would seed the frontend disk-src
|
||||
// cache for the *Navidrome* artist cover with the external image — leaking
|
||||
// fanart/banner into the plain artist cover (avatar, FS "navidrome-artist"
|
||||
// fallback) even with the scraper off. The external hooks read the path from
|
||||
// this function's return value, so no event is needed.
|
||||
Some(disk::provider_tier_path(dir, requested, surface))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fanart_gate_tests {
|
||||
use super::dims_satisfy_fanart;
|
||||
|
||||
#[test]
|
||||
fn gate_accepts_wide_16_9_and_rejects_square_or_small() {
|
||||
assert!(dims_satisfy_fanart(2000, 1125)); // 16:9, wide
|
||||
assert!(dims_satisfy_fanart(1280, 800)); // aspect 1.6 boundary
|
||||
assert!(dims_satisfy_fanart(1280, 640)); // aspect 2.0 boundary
|
||||
assert!(!dims_satisfy_fanart(2000, 2000)); // square portrait
|
||||
assert!(!dims_satisfy_fanart(1000, 560)); // width < 1280
|
||||
assert!(!dims_satisfy_fanart(1280, 600)); // aspect 2.13 > 2.0
|
||||
assert!(!dims_satisfy_fanart(1280, 0)); // div-by-zero guard
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use reqwest::Client;
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
@@ -19,12 +20,15 @@ fn random_salt() -> String {
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
pub fn build_cover_art_url(
|
||||
/// Build a token-authed Subsonic REST URL `{rest_base}/rest/{endpoint}.view`
|
||||
/// with the standard `u/t/s/v/c` auth params plus the given `extra` query
|
||||
/// pairs. Shared by all Subsonic GETs (cover art, `getArtistInfo2`, …).
|
||||
pub(crate) fn build_subsonic_url(
|
||||
rest_base: &str,
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
cover_art_id: &str,
|
||||
size: u32,
|
||||
extra: &[(&str, &str)],
|
||||
) -> String {
|
||||
let base = rest_base.trim_end_matches('/');
|
||||
let api_base = if base.ends_with("/rest") {
|
||||
@@ -34,25 +38,43 @@ pub fn build_cover_art_url(
|
||||
};
|
||||
let salt = random_salt();
|
||||
let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
|
||||
let endpoint = format!("{api_base}/getCoverArt.view");
|
||||
let endpoint_url = format!("{api_base}/{endpoint}.view");
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("id", cover_art_id);
|
||||
serializer.append_pair("size", &size.to_string());
|
||||
for (k, v) in extra {
|
||||
serializer.append_pair(k, v);
|
||||
}
|
||||
serializer.append_pair("u", username);
|
||||
serializer.append_pair("t", &token);
|
||||
serializer.append_pair("s", &salt);
|
||||
serializer.append_pair("v", "1.16.1");
|
||||
serializer.append_pair("c", SUBSONIC_CLIENT);
|
||||
let query = serializer.finish();
|
||||
match Url::parse(&endpoint) {
|
||||
match Url::parse(&endpoint_url) {
|
||||
Ok(mut url) => {
|
||||
url.set_query(Some(&query));
|
||||
url.to_string()
|
||||
}
|
||||
Err(_) => format!("{endpoint}?{query}"),
|
||||
Err(_) => format!("{endpoint_url}?{query}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_cover_art_url(
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
cover_art_id: &str,
|
||||
size: u32,
|
||||
) -> String {
|
||||
let size_s = size.to_string();
|
||||
build_subsonic_url(
|
||||
rest_base,
|
||||
"getCoverArt",
|
||||
username,
|
||||
password,
|
||||
&[("id", cover_art_id), ("size", &size_s)],
|
||||
)
|
||||
}
|
||||
|
||||
/// Outcome of a single fetch attempt: transient errors are worth retrying,
|
||||
/// permanent ones (a real 4xx like 404 — the cover simply does not exist) are
|
||||
/// not, so we never hammer the server for genuinely-missing art.
|
||||
@@ -62,8 +84,21 @@ enum FetchAttempt {
|
||||
Permanent(String),
|
||||
}
|
||||
|
||||
async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
|
||||
let resp = match client.get(url).send().await {
|
||||
async fn fetch_cover_once(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
) -> FetchAttempt {
|
||||
let mut req = client.get(url);
|
||||
if let Some(reg) = registry {
|
||||
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||
req = reg.apply_for_http_url(sid, url, req);
|
||||
} else if let Some(ctx) = reg.get_for_server_url(url) {
|
||||
req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
|
||||
}
|
||||
}
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
// Connection reset / timeout / DNS — transient under server load.
|
||||
Err(e) => return FetchAttempt::Transient(e.to_string()),
|
||||
@@ -83,10 +118,15 @@ async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
|
||||
pub async fn fetch_cover_bytes(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut last_err = String::from("cover fetch failed");
|
||||
for attempt in 0..COVER_FETCH_ATTEMPTS {
|
||||
match fetch_cover_once(client, url).await {
|
||||
match fetch_cover_once(client, url, registry, server_ref).await {
|
||||
FetchAttempt::Ok(bytes) => return Ok(bytes),
|
||||
FetchAttempt::Permanent(e) => return Err(e),
|
||||
FetchAttempt::Transient(e) => {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
mod backfill_worker;
|
||||
mod disk;
|
||||
mod encode;
|
||||
mod external;
|
||||
mod external_ensure;
|
||||
mod fetch;
|
||||
|
||||
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
|
||||
@@ -133,6 +135,27 @@ pub struct CoverCacheEnsureArgs {
|
||||
/// with the album/artist name. On-demand UI ensures leave it `None`.
|
||||
#[serde(default)]
|
||||
pub library_server_id: Option<String>,
|
||||
/// External artwork (§16): when true, an artist `fanart`/`banner` ensure may
|
||||
/// fetch from fanart.tv into `{tier}-{provider}.webp`. Gated by the master
|
||||
/// toggle (off by default); the project key is embedded (`FANART_PROJECT_KEY`).
|
||||
#[serde(default)]
|
||||
pub external_artwork_enabled: bool,
|
||||
/// Surface intent for external artwork — `fanart` for the 16:9 artist
|
||||
/// background. `None` on plain cover ensures.
|
||||
#[serde(default)]
|
||||
pub surface_kind: Option<String>,
|
||||
/// Artist display name — context for the §19 name→MusicBrainz fallback when
|
||||
/// the artist carries no tag MBID. `None` skips that fallback.
|
||||
#[serde(default)]
|
||||
pub artist_name: Option<String>,
|
||||
/// Album title currently in context (fullscreen playback) — disambiguates
|
||||
/// the name→MusicBrainz query (§19).
|
||||
#[serde(default)]
|
||||
pub album_title: Option<String>,
|
||||
/// Optional BYOK personal fanart.tv key from settings — sent in addition to
|
||||
/// the project key (§22). Falls back to the `PSYSONIC_FANART_CLIENT_KEY` env.
|
||||
#[serde(default)]
|
||||
pub external_artwork_byok: Option<String>,
|
||||
}
|
||||
|
||||
fn cover_dir_for_args(root: &Path, args: &CoverCacheEnsureArgs) -> PathBuf {
|
||||
@@ -148,6 +171,9 @@ const COVER_CPU_UI_CONCURRENCY: usize = 2;
|
||||
const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2;
|
||||
/// Upper bound for the runtime encode-pool knob (matches the worker cap).
|
||||
const COVER_CPU_BACKFILL_MAX: usize = 16;
|
||||
/// External providers (fanart.tv) get their own low-concurrency HTTP lane so
|
||||
/// they can never starve Navidrome cover / getArtistInfo2 fetches (§26).
|
||||
const FANART_HTTP_CONCURRENCY: usize = 4;
|
||||
|
||||
pub struct CoverCacheState {
|
||||
pub root: PathBuf,
|
||||
@@ -158,6 +184,13 @@ pub struct CoverCacheState {
|
||||
pub http_sem: Arc<Semaphore>,
|
||||
pub cover_cpu_ui_sem: Arc<Semaphore>,
|
||||
pub cover_cpu_backfill_sem: Arc<Semaphore>,
|
||||
/// External-provider (fanart.tv) HTTP lane — separate from `http_sem` so
|
||||
/// external fetches never starve Navidrome cover / getArtistInfo2 (§26).
|
||||
pub fanart_http_sem: Arc<Semaphore>,
|
||||
/// MusicBrainz name→MBID lane — a single permit, so the §19 resolver runs
|
||||
/// strictly serially and the caller's ≥1s spacing keeps us under MB's rate
|
||||
/// limit (their ToS).
|
||||
pub musicbrainz_sem: Arc<Semaphore>,
|
||||
/// Live permit count of `cover_cpu_backfill_sem` (the semaphore itself only
|
||||
/// exposes *available* permits, not the configured ceiling).
|
||||
cover_cpu_backfill_max: AtomicUsize,
|
||||
@@ -180,6 +213,8 @@ impl CoverCacheState {
|
||||
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
|
||||
cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)),
|
||||
cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)),
|
||||
fanart_http_sem: Arc::new(Semaphore::new(FANART_HTTP_CONCURRENCY)),
|
||||
musicbrainz_sem: Arc::new(Semaphore::new(1)),
|
||||
cover_cpu_backfill_max: AtomicUsize::new(COVER_CPU_BACKFILL_CONCURRENCY),
|
||||
})
|
||||
}
|
||||
@@ -229,7 +264,7 @@ impl CoverCacheState {
|
||||
) -> Result<CoverCacheEnsureResult, String> {
|
||||
let this = state.lock().await;
|
||||
let dir = cover_dir_for_args(&this.root, args);
|
||||
if let Some(path) = peek_tier_path(&dir, args.tier) {
|
||||
if let Some(path) = external_ensure::peek_cover_path(&dir, args.tier, args) {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
@@ -254,6 +289,8 @@ impl CoverCacheState {
|
||||
let root = this.root.clone();
|
||||
let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone());
|
||||
let cover_cpu_sem = this.cpu_sem_for(args.library_bulk);
|
||||
let fanart_sem = this.fanart_http_sem.clone();
|
||||
let musicbrainz_sem = this.musicbrainz_sem.clone();
|
||||
drop(this);
|
||||
|
||||
if cover_fetch_recently_failed(&dir) {
|
||||
@@ -264,6 +301,33 @@ impl CoverCacheState {
|
||||
});
|
||||
}
|
||||
|
||||
// For an external artist surface (`fanart` 16:9 background or `banner`
|
||||
// strip), try fanart.tv before the Navidrome fallback. On any miss it
|
||||
// falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome
|
||||
// stays the display fallback (§28).
|
||||
if args.external_artwork_enabled && !args.library_bulk && args.cache_kind == "artist" {
|
||||
if let Some(surface) = external_ensure::external_surface(args.surface_kind.as_deref()) {
|
||||
if let Some(path) = external_ensure::try_external_fanart(
|
||||
app,
|
||||
args,
|
||||
&dir,
|
||||
&client,
|
||||
&fanart_sem,
|
||||
&musicbrainz_sem,
|
||||
args.tier,
|
||||
surface,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let requested = args.tier;
|
||||
let quiet = args.library_bulk;
|
||||
let tiers_now: Vec<u32> = if args.library_bulk {
|
||||
@@ -290,7 +354,10 @@ impl CoverCacheState {
|
||||
let source = if let Some(img) = load_image_from_disk(&dir) {
|
||||
CoverSource::Image(img)
|
||||
} else {
|
||||
match download_cover_payload(&dir, &client, &http_sem, args).await {
|
||||
let http_registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
match download_cover_payload(&dir, &client, &http_sem, args, http_registry).await {
|
||||
Ok(bytes) => CoverSource::Bytes(bytes),
|
||||
Err(err) => {
|
||||
log_cover_fetch_failure(app, args, &err);
|
||||
@@ -468,6 +535,7 @@ async fn download_cover_payload(
|
||||
client: &Client,
|
||||
http_sem: &Semaphore,
|
||||
args: &CoverCacheEnsureArgs,
|
||||
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let _permit = http_sem
|
||||
.acquire()
|
||||
@@ -485,7 +553,13 @@ async fn download_cover_payload(
|
||||
&args.cover_art_id,
|
||||
fetch_size,
|
||||
);
|
||||
fetch::fetch_cover_bytes(client, &url).await
|
||||
fetch::fetch_cover_bytes(
|
||||
client,
|
||||
&url,
|
||||
registry.as_deref(),
|
||||
Some(args.server_index_key.as_str()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn spawn_derive_remaining_tiers(
|
||||
@@ -824,6 +898,7 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_ensure(
|
||||
app: AppHandle,
|
||||
@@ -923,6 +998,17 @@ pub async fn cover_cache_clear_server(
|
||||
}
|
||||
invalidate_dir_usage_cache(&server_index_key);
|
||||
drop(guard);
|
||||
// §12/B.4: the on-disk external tiers (`{tier}-fanart.webp` / `-banner.webp`)
|
||||
// + `.miss-*` markers went with the dir removal above; also drop the
|
||||
// `artist_artwork_lookup` rows for this server so no resolution state lingers.
|
||||
if let Some(rt) = app.try_state::<LibraryRuntime>() {
|
||||
let store = rt.store.clone();
|
||||
let key = server_index_key.clone();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// Clearing drops files the cheap idle-gate signature can't see, so re-arm
|
||||
// the backfill worker — otherwise the next sync-idle would skip the rescan.
|
||||
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
@@ -935,6 +1021,65 @@ pub async fn cover_cache_clear_server(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete only external-provider artifacts under a server's cover dir — the
|
||||
/// `{tier}-{provider}.webp` tiers and `.miss-{provider}` markers — leaving the
|
||||
/// canonical Navidrome `{tier}.webp` and `.fetch-failed` untouched (Navidrome
|
||||
/// tiers have no `-` in the stem; their marker is `.fetch-failed`, not
|
||||
/// `.miss-*`). FS-only so it is testable against a real `tempdir`. Returns the
|
||||
/// number of files removed.
|
||||
fn purge_external_files(server_dir: &Path) -> usize {
|
||||
fn is_external(name: &str) -> bool {
|
||||
(name.ends_with(".webp") && name.contains('-')) || name.starts_with(".miss-")
|
||||
}
|
||||
fn walk(dir: &Path, count: &mut usize) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, count);
|
||||
} else if p.file_name().and_then(|n| n.to_str()).is_some_and(is_external)
|
||||
&& std::fs::remove_file(&p).is_ok()
|
||||
{
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut count = 0;
|
||||
walk(server_dir, &mut count);
|
||||
count
|
||||
}
|
||||
|
||||
/// Opt-out purge (§9, §12, Appendix B.4): drop every external artwork artifact
|
||||
/// for a server — `{tier}-{provider}.webp`, `.miss-{provider}`, and the
|
||||
/// `artist_artwork_lookup` rows — while leaving the canonical Navidrome covers
|
||||
/// intact. Fired when the user turns the External Artwork toggle off. Unlike
|
||||
/// `cover_cache_clear_server`, Navidrome tiers survive.
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_purge_external(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let path = cover_server_dir(&guard.root, &server_index_key);
|
||||
if path.is_dir() {
|
||||
purge_external_files(&path);
|
||||
}
|
||||
invalidate_dir_usage_cache(&server_index_key);
|
||||
drop(guard);
|
||||
if let Some(rt) = app.try_state::<LibraryRuntime>() {
|
||||
let store = rt.store.clone();
|
||||
let key = server_index_key.clone();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a server's cover-cache bucket on disk after the user edits the
|
||||
/// primary URL (and the derived index key changes). Used by the URL-change
|
||||
/// remigration pipeline (dual-server-address spec §8.3) so cached covers
|
||||
@@ -1214,7 +1359,10 @@ mod tests {
|
||||
|
||||
use super::decode_image_bytes;
|
||||
use super::disk::{cover_dir, tier_path};
|
||||
use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner};
|
||||
use super::{
|
||||
count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, purge_external_files,
|
||||
rename_bucket_inner,
|
||||
};
|
||||
use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -1409,4 +1557,33 @@ mod tests {
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_external_removes_only_external_artifacts() {
|
||||
let root = fresh_tmpdir("purge-external");
|
||||
let entity = root.join("artist").join("ar-1");
|
||||
fs::create_dir_all(&entity).unwrap();
|
||||
// Navidrome canonical — must survive.
|
||||
fs::write(entity.join("2000.webp"), b"n").unwrap();
|
||||
fs::write(entity.join("512.webp"), b"n").unwrap();
|
||||
fs::write(entity.join(".fetch-failed"), b"1").unwrap();
|
||||
// External — must go.
|
||||
fs::write(entity.join("2000-fanart.webp"), b"f").unwrap();
|
||||
fs::write(entity.join("512-fanart.webp"), b"f").unwrap();
|
||||
fs::write(entity.join("2000-banner.webp"), b"b").unwrap();
|
||||
fs::write(entity.join(".miss-fanart"), b"1").unwrap();
|
||||
fs::write(entity.join(".miss-banner"), b"1").unwrap();
|
||||
|
||||
assert_eq!(purge_external_files(&root), 5);
|
||||
|
||||
assert!(entity.join("2000.webp").exists());
|
||||
assert!(entity.join("512.webp").exists());
|
||||
assert!(entity.join(".fetch-failed").exists());
|
||||
assert!(!entity.join("2000-fanart.webp").exists());
|
||||
assert!(!entity.join("512-fanart.webp").exists());
|
||||
assert!(!entity.join("2000-banner.webp").exists());
|
||||
assert!(!entity.join(".miss-fanart").exists());
|
||||
assert!(!entity.join(".miss-banner").exists());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-22
@@ -64,7 +64,28 @@ fn on_second_instance<R: tauri::Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: associate this process with an explicit AppUserModelID. Windows uses
|
||||
/// it to name the app in taskbar grouping and the SMTC media controls; without it
|
||||
/// the media tile reads "Unknown application". Must match the AppUserModelID the
|
||||
/// installer sets on the Start-menu shortcut so the name/icon resolve.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::core::w;
|
||||
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
// SAFETY: a Win32 call with a static wide string; errors are non-fatal.
|
||||
unsafe {
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// Windows: bind this process to an explicit AppUserModelID before any window
|
||||
// or the SMTC media controls are created, so the OS can resolve the app
|
||||
// name/icon for taskbar grouping and the media tile (#1102 follow-up: the
|
||||
// Quick-Settings / lock-screen media tile showed "Unknown application").
|
||||
#[cfg(target_os = "windows")]
|
||||
set_app_user_model_id();
|
||||
|
||||
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -85,6 +106,7 @@ pub fn run() {
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.manage(audio_engine)
|
||||
.manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new()))
|
||||
.manage(ShortcutMap::default())
|
||||
.manage(discord::DiscordState::new())
|
||||
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
|
||||
@@ -217,7 +239,10 @@ pub fn run() {
|
||||
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
|
||||
flags_bits,
|
||||
);
|
||||
let subsonic = psysonic_integration::subsonic::SubsonicClient::new(
|
||||
let registry = app_for_sched.state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>();
|
||||
let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry(
|
||||
Some(registry.as_ref()),
|
||||
&session.server_id,
|
||||
session.base_url.clone(),
|
||||
session.username.clone(),
|
||||
session.password.clone(),
|
||||
@@ -230,7 +255,8 @@ pub fn run() {
|
||||
scope.clone(),
|
||||
flags,
|
||||
)
|
||||
.with_playback_hint(hint);
|
||||
.with_playback_hint(hint)
|
||||
.with_http_registry(Some(Arc::clone(®istry)));
|
||||
if let Some(tok) = session.navidrome_token.clone() {
|
||||
sched = sched.with_navidrome_credentials(
|
||||
psysonic_library::sync::capability::NavidromeProbeCredentials {
|
||||
@@ -502,11 +528,20 @@ pub fn run() {
|
||||
let app_handle = app.handle().clone();
|
||||
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
||||
match event {
|
||||
MediaControlEvent::Toggle
|
||||
| MediaControlEvent::Play
|
||||
| MediaControlEvent::Pause => {
|
||||
// Keep Play/Pause distinct from Toggle: the OS
|
||||
// (notably macOS on audio-route changes, e.g. a
|
||||
// headphone disconnect) sends an explicit Pause,
|
||||
// and collapsing all three into a toggle would
|
||||
// resume paused playback on the new device (#1094).
|
||||
MediaControlEvent::Toggle => {
|
||||
let _ = app_handle.emit("media:play-pause", ());
|
||||
}
|
||||
MediaControlEvent::Play => {
|
||||
let _ = app_handle.emit("media:play", ());
|
||||
}
|
||||
MediaControlEvent::Pause => {
|
||||
let _ = app_handle.emit("media:pause", ());
|
||||
}
|
||||
MediaControlEvent::Next => {
|
||||
let _ = app_handle.emit("media:next", ());
|
||||
}
|
||||
@@ -599,24 +634,15 @@ pub fn run() {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label() == "main" {
|
||||
api.prevent_close();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// On macOS the red close button quits the app entirely.
|
||||
// Route through JS so playback position + Orbit state get
|
||||
// flushed; exit_app on the way back stops the audio engine.
|
||||
let _ = window.emit("app:force-quit", ());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Pause rendering before JS decides whether to hide to tray or exit.
|
||||
if let Some(w) = window.app_handle().get_webview_window("main") {
|
||||
let _ = w.eval(PAUSE_RENDERING_JS);
|
||||
}
|
||||
// Let JS decide: minimize to tray or exit, based on user setting.
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
// All platforms: pause rendering, then let JS decide hide-to-tray
|
||||
// vs exit based on the minimizeToTray setting. macOS previously
|
||||
// always force-quit on the red close button, ignoring the setting
|
||||
// (#1103). The tray "Exit" item still emits app:force-quit for an
|
||||
// unconditional quit.
|
||||
if let Some(w) = window.app_handle().get_webview_window("main") {
|
||||
let _ = w.eval(PAUSE_RENDERING_JS);
|
||||
}
|
||||
let _ = window.emit("window:close-requested", ());
|
||||
} else if window.label() == "mini" {
|
||||
// Native close on the mini: hide instead of destroying so
|
||||
// state is preserved, and restore the main window.
|
||||
@@ -661,6 +687,9 @@ pub fn run() {
|
||||
migration_inspect,
|
||||
migration_run,
|
||||
resolve_host_addresses,
|
||||
server_http_context_sync,
|
||||
server_http_context_sync_all,
|
||||
server_http_context_clear,
|
||||
psysonic_syncfs::sync::batch::calculate_sync_payload,
|
||||
exit_app,
|
||||
cli_publish_player_snapshot,
|
||||
@@ -714,6 +743,8 @@ pub fn run() {
|
||||
audio::preview::audio_preview_set_volume,
|
||||
audio::mix_commands::audio_set_crossfade,
|
||||
audio::mix_commands::audio_set_gapless,
|
||||
audio::mix_commands::audio_begin_outgoing_fade,
|
||||
audio::mix_commands::audio_set_autodj_suppress,
|
||||
audio::mix_commands::audio_set_normalization,
|
||||
audio::device_commands::audio_list_devices,
|
||||
audio::device_commands::audio_canonicalize_selected_device,
|
||||
@@ -820,6 +851,7 @@ pub fn run() {
|
||||
cover_cache::cover_cache_configure,
|
||||
cover_cache::cover_cache_clear,
|
||||
cover_cache::cover_cache_clear_server,
|
||||
cover_cache::cover_cache_purge_external,
|
||||
cover_cache::cover_cache_rename_server_bucket,
|
||||
cover_cache::cover_cache_stats_server,
|
||||
cover_cache::cover_cache_get_pipeline_queue_stats,
|
||||
|
||||
@@ -84,6 +84,15 @@ pub(crate) fn mpris_set_metadata(
|
||||
let duration = duration_secs.map(Duration::from_secs_f64);
|
||||
let mut guard = controls.lock().unwrap();
|
||||
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||
|
||||
// #1102: Windows SMTC cannot render our cached WebP covers. souvlaki loads
|
||||
// the file and SetThumbnail/set_metadata succeed, but the lock screen and
|
||||
// Quick-Settings media tile show a blank cover (the OS thumbnail decoder
|
||||
// does not handle WebP, even with the Store WebP extension installed).
|
||||
// Transcode local WebP covers to PNG for the OS media controls; macOS
|
||||
// (ImageIO) decodes WebP fine, so other platforms pass through unchanged.
|
||||
let cover_url = smtc_cover_url(cover_url);
|
||||
|
||||
ctrl.set_metadata(MediaMetadata {
|
||||
title: title.as_deref(),
|
||||
artist: artist.as_deref(),
|
||||
@@ -94,6 +103,48 @@ pub(crate) fn mpris_set_metadata(
|
||||
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||
}
|
||||
|
||||
/// Rewrite a cached WebP cover URL to a PNG the OS media controls can render.
|
||||
/// Windows SMTC cannot decode WebP thumbnails (#1102); other platforms and any
|
||||
/// non-`file://`/non-WebP URL pass through unchanged.
|
||||
fn smtc_cover_url(cover_url: Option<String>) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(url) = cover_url.as_deref() {
|
||||
if let Some(path) = url.strip_prefix("file://") {
|
||||
let is_webp = std::path::Path::new(path)
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("webp"));
|
||||
if is_webp {
|
||||
match webp_file_to_temp_png(path) {
|
||||
Ok(png) => return Some(format!("file://{png}")),
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[mpris] cover WebP->PNG transcode failed: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cover_url
|
||||
}
|
||||
|
||||
/// Decode a WebP file (libwebp, the same codec that wrote the cover cache) and
|
||||
/// re-encode it as a PNG in the temp dir, returning the native path. A single
|
||||
/// reusable file is fine: souvlaki reads it synchronously inside `set_metadata`,
|
||||
/// and the controls mutex serializes calls so it is never written concurrently.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
|
||||
let bytes = std::fs::read(webp_path).map_err(|e| e.to_string())?;
|
||||
let decoded = webp::Decoder::new(&bytes)
|
||||
.decode()
|
||||
.ok_or_else(|| "WebP decode returned None".to_string())?;
|
||||
let img = decoded.to_image();
|
||||
let out = std::env::temp_dir().join("psysonic-smtc-cover.png");
|
||||
img.save_with_format(&out, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(out.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn mpris_set_playback(
|
||||
controls: tauri::State<MprisControls>,
|
||||
|
||||
@@ -36,7 +36,10 @@ pub(crate) use integration::{
|
||||
unregister_global_shortcut,
|
||||
};
|
||||
pub(crate) use migration::{migration_inspect, migration_run};
|
||||
pub(crate) use network::resolve_host_addresses;
|
||||
pub(crate) use network::{
|
||||
resolve_host_addresses, server_http_context_clear, server_http_context_sync,
|
||||
server_http_context_sync_all,
|
||||
};
|
||||
|
||||
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
|
||||
// and analysis admin commands now live in their domain crates. invoke_handler!
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
//! dual-server-address add/edit form (UI hint only — not for connect).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
|
||||
use tauri::State;
|
||||
use tokio::net::lookup_host;
|
||||
|
||||
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
|
||||
@@ -58,6 +63,34 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<Strin
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn server_http_context_sync(
|
||||
registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
wire: ServerHttpContextSyncWire,
|
||||
) -> Result<(), String> {
|
||||
registry.sync(wire);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn server_http_context_sync_all(
|
||||
registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
entries: Vec<ServerHttpContextSyncWire>,
|
||||
) -> Result<(), String> {
|
||||
registry.sync_all(entries);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn server_http_context_clear(
|
||||
registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_id: String,
|
||||
app_server_id: String,
|
||||
) -> Result<(), String> {
|
||||
registry.remove(&server_id, &app_server_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
|
||||
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
|
||||
fn strip_port(input: &str) -> String {
|
||||
|
||||
@@ -415,7 +415,7 @@ pub(crate) fn toggle_tray_icon(
|
||||
|
||||
pub(crate) use crate::audio::stop_audio_engine;
|
||||
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Niri, Sway, i3,
|
||||
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
|
||||
/// set by the compositor / DE.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -423,6 +423,7 @@ pub(crate) fn is_tiling_wm() -> bool {
|
||||
// Direct compositor signatures (most reliable).
|
||||
let direct = [
|
||||
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
|
||||
"NIRI_SOCKET", // Niri
|
||||
"SWAYSOCK", // Sway
|
||||
"I3SOCK", // i3
|
||||
]
|
||||
@@ -437,7 +438,7 @@ pub(crate) fn is_tiling_wm() -> bool {
|
||||
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop = desktop.to_lowercase();
|
||||
let tiling_wms = [
|
||||
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
|
||||
"hyprland", "niri", "sway", "i3", "bspwm", "awesome", "openbox",
|
||||
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
|
||||
];
|
||||
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
//! strict.
|
||||
#![cfg_attr(debug_assertions, allow(dead_code))]
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use std::sync::atomic::{AtomicIsize, AtomicU32, Ordering};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use windows::{
|
||||
core::w,
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
System::Com::{
|
||||
@@ -29,7 +30,7 @@ use windows::{
|
||||
},
|
||||
WindowsAndMessaging::{
|
||||
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
||||
WM_COMMAND, WM_NCDESTROY,
|
||||
RegisterWindowMessageW, WM_COMMAND, WM_NCDESTROY,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -61,6 +62,10 @@ static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
|
||||
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
// Registered window-message id for the shell's "TaskbarButtonCreated"
|
||||
// broadcast (0 until registered in `init`).
|
||||
static TASKBAR_BUTTON_CREATED_MSG: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
// ── ICO resource loader ──────────────────────────────────────────────────────
|
||||
|
||||
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
|
||||
@@ -174,6 +179,15 @@ unsafe extern "system" fn subclass_proc(
|
||||
_uid: usize,
|
||||
data: usize,
|
||||
) -> LRESULT {
|
||||
// The shell sends this once the taskbar button exists (on the first window
|
||||
// show) and again after an explorer.exe restart — the only safe moment to
|
||||
// (re)add the thumbnail buttons. See `add_thumb_buttons`.
|
||||
let tb_created = TASKBAR_BUTTON_CREATED_MSG.load(Ordering::SeqCst);
|
||||
if tb_created != 0 && msg == tb_created {
|
||||
add_thumb_buttons();
|
||||
return DefSubclassProc(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
|
||||
if msg == WM_COMMAND {
|
||||
let hi = (wparam.0 >> 16) as u32;
|
||||
let lo = (wparam.0 & 0xFFFF) as u32;
|
||||
@@ -211,6 +225,43 @@ unsafe extern "system" fn subclass_proc(
|
||||
DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
}
|
||||
|
||||
// ── Thumbnail-button (re)attach ────────────────────────────────────────────────
|
||||
|
||||
/// (Re)add the three media buttons to the taskbar thumbnail toolbar.
|
||||
///
|
||||
/// Must run only after the shell has created the window's taskbar button and
|
||||
/// broadcast `TaskbarButtonCreated`. Calling `ThumbBarAddButtons` before that
|
||||
/// point returns `S_OK` but silently adds nothing — which is why the buttons
|
||||
/// disappeared once the main window started hidden/deferred (PR #1030 moved the
|
||||
/// window to `visible: false` with a deferred show, so at `setup` time the
|
||||
/// taskbar button does not exist yet).
|
||||
unsafe fn add_thumb_buttons() {
|
||||
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
|
||||
let hwnd_raw = HWND_VAL.load(Ordering::SeqCst);
|
||||
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
|
||||
|
||||
let h_prev = HICON_PREV.load(Ordering::SeqCst);
|
||||
let h_play = HICON_PLAY.load(Ordering::SeqCst);
|
||||
let h_next = HICON_NEXT.load(Ordering::SeqCst);
|
||||
if h_prev == 0 || h_play == 0 || h_next == 0 { return; }
|
||||
|
||||
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
|
||||
let hwnd = HWND(hwnd_raw as *mut _);
|
||||
|
||||
// Harmless on an already-initialised object; required again after an
|
||||
// explorer restart recreates the taskbar button.
|
||||
let _ = taskbar.HrInit();
|
||||
|
||||
let buttons = make_buttons(
|
||||
HICON(h_prev as *mut _),
|
||||
HICON(h_play as *mut _),
|
||||
HICON(h_next as *mut _),
|
||||
);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
|
||||
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public init ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
@@ -242,11 +293,12 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
||||
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
|
||||
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
|
||||
|
||||
let buttons = make_buttons(h_prev, h_play, h_next);
|
||||
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
|
||||
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Register the shell's "TaskbarButtonCreated" message. The buttons are
|
||||
// added from the subclass proc when it fires (after the first window
|
||||
// show), because the deferred/hidden main window means the taskbar
|
||||
// button does not exist yet at this point.
|
||||
let tb_msg = RegisterWindowMessageW(w!("TaskbarButtonCreated"));
|
||||
TASKBAR_BUTTON_CREATED_MSG.store(tb_msg, Ordering::SeqCst);
|
||||
|
||||
let raw = Box::into_raw(Box::new(taskbar));
|
||||
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.49.0-dev",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+52
-2
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
@@ -52,7 +53,36 @@ export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
|
||||
coverAutoDownloadEnabled = enabled;
|
||||
}
|
||||
|
||||
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
export type CoverEnsureOpts = {
|
||||
/** External-artwork surface intent — `'fanart'` for the 16:9 artist background (§28). */
|
||||
surfaceKind?: string;
|
||||
/** §19 name→MusicBrainz context: the artist display name + the album in context. */
|
||||
artistName?: string;
|
||||
albumTitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* External-artwork ensure fields (§28). `externalArtworkEnabled` is gated by the
|
||||
* master toggle AND restricted to the external artist surfaces (`fanart` /
|
||||
* `banner`), so plain album/artist cover ensures are never affected.
|
||||
*/
|
||||
function externalEnsureFields(ref: CoverArtRef, opts?: CoverEnsureOpts) {
|
||||
const surfaceKind = opts?.surfaceKind;
|
||||
const isExternalSurface = surfaceKind === 'fanart' || surfaceKind === 'banner';
|
||||
const theme = useThemeStore.getState();
|
||||
const externalArtworkEnabled =
|
||||
isExternalSurface && ref.cacheKind === 'artist' && theme.externalArtworkEnabled;
|
||||
return {
|
||||
externalArtworkEnabled,
|
||||
surfaceKind,
|
||||
artistName: opts?.artistName,
|
||||
albumTitle: opts?.albumTitle,
|
||||
// BYOK personal fanart.tv key (§22), only when the external branch will run.
|
||||
externalArtworkByok: externalArtworkEnabled ? theme.externalArtworkByok : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier, opts?: CoverEnsureOpts) {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const scope = ref.serverScope;
|
||||
if (scope.kind === 'server') {
|
||||
@@ -69,6 +99,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
),
|
||||
username: scope.username,
|
||||
password: scope.password,
|
||||
...externalEnsureFields(ref, opts),
|
||||
};
|
||||
}
|
||||
const server =
|
||||
@@ -94,6 +125,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
...externalEnsureFields(ref, opts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,9 +157,10 @@ export async function coverCacheEnsure(
|
||||
ref: CoverArtRef,
|
||||
tier: CoverArtTier,
|
||||
_priority?: string,
|
||||
opts?: CoverEnsureOpts,
|
||||
): Promise<CoverCacheEnsureResult> {
|
||||
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
|
||||
args: ensureArgsFromRef(ref, tier),
|
||||
args: ensureArgsFromRef(ref, tier, opts),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +189,23 @@ export async function coverCacheClearServer(serverIndexKey: string): Promise<voi
|
||||
return invoke('cover_cache_clear_server', { serverIndexKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-out purge: when the External Artwork toggle is turned off, drop every
|
||||
* fetched external image + `.miss-*` marker + lookup row across all configured
|
||||
* servers (Navidrome covers are left intact). Fire-and-forget; per-server
|
||||
* failures are swallowed so one unreachable server can't block the rest.
|
||||
*/
|
||||
export async function purgeExternalArtworkAllServers(): Promise<void> {
|
||||
const { servers } = useAuthStore.getState();
|
||||
await Promise.all(
|
||||
servers.map(s =>
|
||||
invoke('cover_cache_purge_external', {
|
||||
serverIndexKey: serverIndexKeyForProfile(s),
|
||||
}).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function coverCacheStatsServer(
|
||||
serverIndexKey: string,
|
||||
): Promise<Pick<CoverCacheStats, 'bytes' | 'entryCount'>> {
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface SyncStateDto {
|
||||
serverLastScanIso?: string | null;
|
||||
indexesLastModifiedMs?: number | null;
|
||||
artistsLastModifiedMs?: number | null;
|
||||
ignoredArticles?: string | null;
|
||||
localTrackCount?: number | null;
|
||||
serverTrackCount?: number | null;
|
||||
lastError?: string | null;
|
||||
@@ -237,6 +238,7 @@ export interface LibraryArtistDto {
|
||||
serverId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
nameSort?: string | null;
|
||||
albumCount?: number | null;
|
||||
syncedAt: number;
|
||||
rawJson: unknown;
|
||||
|
||||
@@ -116,6 +116,21 @@ export interface OrbitState {
|
||||
export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const;
|
||||
export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number];
|
||||
|
||||
/**
|
||||
* Host's playback-transition preferences, mirrored into the session so guests
|
||||
* blend tracks the same way the host does (otherwise each client uses its own
|
||||
* local transition settings, re-introducing the drift the Catch-Up button
|
||||
* exists to fix). Optional on the wire — a session hosted by a build that
|
||||
* predates transition sync simply omits it, and guests keep their own.
|
||||
*/
|
||||
export interface OrbitTransitionSettings {
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeSecs: number;
|
||||
crossfadeTrimSilence: boolean;
|
||||
autodjSmoothSkip: boolean;
|
||||
gaplessEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface OrbitSettings {
|
||||
/** Guest suggestions go straight into the host's play queue. */
|
||||
autoApprove: boolean;
|
||||
@@ -127,6 +142,13 @@ export interface OrbitSettings {
|
||||
* field fall back to 15 via `effectiveShuffleIntervalMs`.
|
||||
*/
|
||||
shuffleIntervalMin?: OrbitShuffleIntervalMin;
|
||||
/**
|
||||
* Host's track-transition prefs (crossfade / gapless / AutoDJ), refreshed
|
||||
* every host tick from the host's own playback settings. Guests adopt these
|
||||
* for the session and restore their own on leave. Optional: absent on
|
||||
* pre-transition-sync sessions.
|
||||
*/
|
||||
transitions?: OrbitTransitionSettings;
|
||||
}
|
||||
|
||||
export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { pingWithCredentials, ping } from './subsonic';
|
||||
import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
|
||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||
import { getStarred } from './subsonicStarRating';
|
||||
import { search } from './subsonicSearch';
|
||||
@@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
|
||||
expect(r.openSubsonic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||
it('sends resolved custom headers on the ping request', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'https://music.example.com',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
});
|
||||
|
||||
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'http://192.168.0.10:4533',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +31,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
||||
reset();
|
||||
useAuthStore.setState({
|
||||
servers: [{
|
||||
id: SID,
|
||||
name: 'Probe',
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
}],
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('probes once, caches the result, then skips on the next poll', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({
|
||||
url: 'https://music.example.com',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
});
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
|
||||
|
||||
+56
-2
@@ -1,6 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import {
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
api,
|
||||
apiWithCredentials,
|
||||
secureRandomSalt,
|
||||
type ServerHttpHeaderProfile,
|
||||
} from './subsonicClient';
|
||||
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
@@ -61,6 +65,47 @@ export async function pingWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */
|
||||
export async function pingWithCredentialsForProfile(
|
||||
profile: Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>,
|
||||
endpointBaseUrl: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = endpointBaseUrl.startsWith('http')
|
||||
? endpointBaseUrl.replace(/\/$/, '')
|
||||
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(profile.password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: {
|
||||
u: profile.username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
},
|
||||
headers: headersForServerRequest(profile, endpointBaseUrl),
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||
@@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<InstantMixProbeResult> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
@@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials(
|
||||
'getRandomSongs.view',
|
||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
const raw = data.randomSongs?.song;
|
||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
@@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials(
|
||||
'getSimilarSongs.view',
|
||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
const sRaw = simData.similarSongs?.song;
|
||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||
@@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer(
|
||||
const ctx = buildCapabilityContext(identity);
|
||||
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
||||
const store = useAuthStore.getState();
|
||||
const headerProfile = findServerByIdOrIndexKey(serverId);
|
||||
|
||||
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
||||
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
|
||||
@@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer(
|
||||
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
|
||||
if (force || listMissing || audiomuseStale) {
|
||||
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
headerProfile,
|
||||
).then(extensions => {
|
||||
const st = useAuthStore.getState();
|
||||
if (extensions === null) {
|
||||
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
|
||||
@@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer(
|
||||
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
||||
const cached = store.instantMixProbeByServer[serverId];
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ import { version } from '../../package.json';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
|
||||
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
|
||||
export type ServerHttpHeaderProfile = Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>;
|
||||
|
||||
export function secureRandomSalt(): string {
|
||||
const buf = new Uint8Array(8);
|
||||
crypto.getRandomValues(buf);
|
||||
@@ -32,10 +39,13 @@ export async function apiWithCredentials<T>(
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
headers,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
});
|
||||
@@ -78,6 +88,7 @@ export async function apiForServer<T>(
|
||||
endpoint,
|
||||
extra,
|
||||
timeout,
|
||||
server,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,8 +99,13 @@ export async function api<T>(
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const { baseUrl, params } = getClient();
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const connectBase = useAuthStore.getState().getBaseUrl();
|
||||
const headers =
|
||||
server && connectBase ? headersForServerRequest(server, connectBase) : {};
|
||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||
params: { ...params, ...extra },
|
||||
headers,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
signal,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getSongWithCredentials(
|
||||
@@ -6,6 +6,7 @@ export async function getSongWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
||||
@@ -14,6 +15,8 @@ export async function getSongWithCredentials(
|
||||
password,
|
||||
'getSong.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
@@ -26,6 +29,7 @@ export async function getAlbumWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
@@ -33,6 +37,8 @@ export async function getAlbumWithCredentials(
|
||||
password,
|
||||
'getAlbum.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
@@ -43,6 +49,7 @@ export async function getArtistWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
||||
serverUrl,
|
||||
@@ -50,6 +57,8 @@ export async function getArtistWithCredentials(
|
||||
password,
|
||||
'getArtist.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
|
||||
@@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('sends custom gate headers when a header profile is supplied', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
|
||||
url: 'https://music.test',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
});
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||
|
||||
export interface OpenSubsonicExtension {
|
||||
name: string;
|
||||
@@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
||||
@@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
||||
} catch {
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
|
||||
export type PlayQueueResult = { current?: string; position?: number; songs: SubsonicSong[] };
|
||||
|
||||
function parsePlayQueueResponse(
|
||||
data: { playQueue?: { current?: string; position?: number; entry?: SubsonicSong[] } },
|
||||
): PlayQueueResult {
|
||||
const pq = data.playQueue;
|
||||
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
|
||||
}
|
||||
|
||||
export async function getPlayQueue(): Promise<PlayQueueResult> {
|
||||
try {
|
||||
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
|
||||
const pq = data.playQueue;
|
||||
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
|
||||
return parsePlayQueueResponse(data);
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayQueueForServer(serverId: string): Promise<PlayQueueResult> {
|
||||
if (!serverId) return { songs: [] };
|
||||
try {
|
||||
const data = await apiForServer<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getPlayQueue.view',
|
||||
);
|
||||
return parsePlayQueueResponse(data);
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
|
||||
@@ -161,6 +161,8 @@ export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
export interface SubsonicArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Article-stripped lowercase sort key (local index / OpenSubsonic). */
|
||||
nameSort?: string;
|
||||
albumCount?: number;
|
||||
coverArt?: string;
|
||||
starred?: string;
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilter
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import '../store/previewPlayerVolumeSync';
|
||||
@@ -102,6 +103,7 @@ export function AppShell() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
useIdlePlayQueuePull(connStatus);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
|
||||
+198
-211
@@ -1,10 +1,11 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { type ReactNode } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { formatBytes } from '../utils/format/formatBytes';
|
||||
import { useAppUpdater } from '../hooks/useAppUpdater';
|
||||
import Modal from './Modal';
|
||||
import Changelog from './appUpdater/Changelog';
|
||||
|
||||
export default function AppUpdater() {
|
||||
@@ -18,222 +19,208 @@ export default function AppUpdater() {
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
|
||||
<div
|
||||
className="eq-popup update-modal"
|
||||
style={{ zIndex: 3001 }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="eq-popup-header update-modal-header">
|
||||
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
|
||||
<span className="update-modal-versions">
|
||||
v{currentVersion} → <strong>v{release.version}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="app-updater-dismiss"
|
||||
onClick={() => setDismissed(true)}
|
||||
data-tooltip={t('common.updaterRemindBtn')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<X size={14} />
|
||||
// Footer actions — state-dependent. Downloading has no actions (no footer).
|
||||
// When there is no in-app install (AUR / from-source), "Remind me later" is the
|
||||
// primary action, so it gets the accent button; Skip stays a clear button.
|
||||
let footer: ReactNode = null;
|
||||
if (dlState === 'idle') {
|
||||
footer = (
|
||||
<>
|
||||
<button className="btn btn-surface" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
className={`btn ${showInstallBtn ? 'btn-surface' : 'btn-primary'}`}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (dlState === 'done' && useTauriUpdater) {
|
||||
footer = (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleRestartNow}>
|
||||
<RefreshCw size={14} />
|
||||
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
} else if (dlState === 'done') {
|
||||
footer = (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
} else if (dlState === 'error') {
|
||||
footer = (
|
||||
<>
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Scrollable body: changelog + download area — single overflow container */}
|
||||
<div className="update-modal-body">
|
||||
{/* Collapsible Changelog */}
|
||||
{release.body && (
|
||||
<div className="update-modal-changelog">
|
||||
<button
|
||||
type="button"
|
||||
className="update-modal-changelog-toggle"
|
||||
onClick={() => setChangelogOpen(v => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transform: changelogOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{t('common.updaterChangelog')}
|
||||
</button>
|
||||
{changelogOpen && (
|
||||
<div className="update-modal-changelog-body">
|
||||
<Changelog body={release.body} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download / AUR area */}
|
||||
<div className="update-modal-download-area">
|
||||
{showAurHint ? (
|
||||
<div className="update-modal-aur">
|
||||
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : useTauriUpdater ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-mac-info">
|
||||
<div className="update-modal-mac-info-main">
|
||||
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
|
||||
</div>
|
||||
<div className="update-modal-mac-info-sub">
|
||||
{t('common.updaterMacReady', {
|
||||
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
})}
|
||||
</div>
|
||||
<div className="update-modal-trust-badges">
|
||||
<span className="update-modal-trust-badge">
|
||||
<ShieldCheck size={12} />
|
||||
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
|
||||
</span>
|
||||
<span className="update-modal-trust-badge">
|
||||
<CheckCircle2 size={12} />
|
||||
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{formatBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<CheckCircle2 size={32} className="update-modal-done-icon" />
|
||||
<div className="update-modal-done-title">
|
||||
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
|
||||
</div>
|
||||
<div className="update-modal-done-countdown">
|
||||
{countdown !== null
|
||||
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
|
||||
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">{asset.name}</span>
|
||||
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{formatBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
|
||||
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
|
||||
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
|
||||
<FolderOpen size={14} />
|
||||
{t('common.updaterShowFolder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="update-modal-asset-none">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
|
||||
>
|
||||
{t('common.updaterOpenGitHub')}
|
||||
</button>
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => setDismissed(true)}
|
||||
icon={<ArrowUpCircle size={18} />}
|
||||
title={t('common.updaterModalTitle')}
|
||||
subtitle={<>v{currentVersion} → <strong>v{release.version}</strong></>}
|
||||
closeLabel={t('common.updaterRemindBtn')}
|
||||
footer={footer}
|
||||
>
|
||||
{/* Collapsible changelog */}
|
||||
{release.body && (
|
||||
<div className="update-modal-changelog">
|
||||
<button
|
||||
type="button"
|
||||
className="update-modal-changelog-toggle"
|
||||
onClick={() => setChangelogOpen(v => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transform: changelogOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{t('common.updaterChangelog')}
|
||||
</button>
|
||||
{changelogOpen && (
|
||||
<div className="update-modal-changelog-body">
|
||||
<Changelog body={release.body} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
)}
|
||||
|
||||
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
|
||||
<div className="update-modal-footer">
|
||||
{dlState === 'idle' && (
|
||||
<>
|
||||
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
|
||||
{t('common.updaterSkipBtn')}
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
{showInstallBtn && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{useTauriUpdater
|
||||
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
|
||||
: t('common.updaterDownloadBtn')}
|
||||
{/* Download / AUR area */}
|
||||
<div className="update-modal-download-area">
|
||||
{showAurHint ? (
|
||||
<div className="update-modal-aur">
|
||||
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
|
||||
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
|
||||
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
|
||||
</div>
|
||||
) : useTauriUpdater ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-mac-info">
|
||||
<div className="update-modal-mac-info-main">
|
||||
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
|
||||
</div>
|
||||
<div className="update-modal-mac-info-sub">
|
||||
{t('common.updaterMacReady', {
|
||||
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
|
||||
})}
|
||||
</div>
|
||||
<div className="update-modal-trust-badges">
|
||||
<span className="update-modal-trust-badge">
|
||||
<ShieldCheck size={12} />
|
||||
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
|
||||
</span>
|
||||
<span className="update-modal-trust-badge">
|
||||
<CheckCircle2 size={12} />
|
||||
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{formatBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<CheckCircle2 size={32} className="update-modal-done-icon" />
|
||||
<div className="update-modal-done-title">
|
||||
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
|
||||
</div>
|
||||
<div className="update-modal-done-countdown">
|
||||
{countdown !== null
|
||||
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
|
||||
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">{asset.name}</span>
|
||||
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'downloading' && (
|
||||
<div className="update-modal-progress">
|
||||
<div className="app-updater-progress-bar">
|
||||
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="app-updater-pct">{pct}%</span>
|
||||
<span className="update-modal-dl-bytes">
|
||||
{formatBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'done' && (
|
||||
<div className="update-modal-done">
|
||||
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
|
||||
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
|
||||
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
|
||||
<FolderOpen size={14} />
|
||||
{t('common.updaterShowFolder')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
|
||||
{dlState === 'done' && useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-primary" onClick={handleRestartNow}>
|
||||
<RefreshCw size={14} />
|
||||
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'done' && !useTauriUpdater && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
|
||||
{t('common.updaterRemindBtn')}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="update-modal-asset-none">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
|
||||
>
|
||||
{t('common.updaterOpenGitHub')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react';
|
||||
import type { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { usePlayQueueSyncLedState } from '../hooks/usePlayQueueSyncLedState';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { switchActiveServer } from '../utils/server/switchActiveServer';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
@@ -21,6 +23,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
const navigate = useNavigate();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const {
|
||||
ledVariant,
|
||||
localQueueSyncPaused,
|
||||
queueHandoffReason,
|
||||
pullInFlight,
|
||||
syncRingVisible,
|
||||
pullFromActiveServer,
|
||||
} = usePlayQueueSyncLedState(status);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [switchingId, setSwitchingId] = useState<string | null>(null);
|
||||
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
|
||||
@@ -51,9 +61,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (hostRef.current?.contains(t)) return;
|
||||
if (menuPanelRef.current?.contains(t)) return;
|
||||
const target = e.target as Node;
|
||||
if (hostRef.current?.contains(target)) return;
|
||||
if (menuPanelRef.current?.contains(target)) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -72,7 +82,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
navigate('/settings', { state: { tab: 'servers' } });
|
||||
};
|
||||
|
||||
const onTriggerClick = () => {
|
||||
const onMetaClick = () => {
|
||||
if (!multi) {
|
||||
goServerSettings();
|
||||
return;
|
||||
@@ -80,6 +90,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
setMenuOpen(o => !o);
|
||||
};
|
||||
|
||||
const onSyncClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (status !== 'connected') return;
|
||||
void pullFromActiveServer();
|
||||
};
|
||||
|
||||
const onPickServer = async (srv: ServerProfile) => {
|
||||
if (srv.id === activeServerId) {
|
||||
setMenuOpen(false);
|
||||
@@ -97,28 +113,46 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
};
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const tooltip = multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
const tooltip = pullInFlight
|
||||
? t('connection.queuePulling')
|
||||
: ledVariant === 'queue-handoff'
|
||||
? localQueueSyncPaused && !queueHandoffReason
|
||||
? t('connection.queueLocalEditHint')
|
||||
: t('connection.queuePullHint', { server: serverName })
|
||||
: ledVariant === 'connected'
|
||||
? t('connection.queueSynced')
|
||||
: multi
|
||||
? t('connection.switchServerHint')
|
||||
: status === 'connected'
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div className="connection-indicator-host" ref={hostRef}>
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onTriggerClick}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
role={multi ? 'button' : undefined}
|
||||
aria-haspopup={multi ? 'menu' : undefined}
|
||||
aria-expanded={multi ? menuOpen : undefined}
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<div className="connection-indicator">
|
||||
<button
|
||||
type="button"
|
||||
className={`connection-sync-btn${syncRingVisible ? ' connection-sync-btn--visible' : ''}${pullInFlight ? ' connection-sync-btn--busy' : ''}`}
|
||||
onClick={onSyncClick}
|
||||
disabled={status !== 'connected' || pullInFlight}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label={t('connection.queuePullAria')}
|
||||
>
|
||||
<RefreshCw size={13} className="connection-sync-icon" aria-hidden />
|
||||
<div className={`connection-led connection-led--${ledVariant}`} />
|
||||
</button>
|
||||
<div
|
||||
className="connection-meta connection-meta--clickable"
|
||||
onClick={onMetaClick}
|
||||
data-tooltip={multi ? t('connection.switchServerHint') : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
role={multi ? 'button' : undefined}
|
||||
aria-haspopup={multi ? 'menu' : undefined}
|
||||
aria-expanded={multi ? menuOpen : undefined}
|
||||
>
|
||||
<span className="connection-type">{label}</span>
|
||||
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/helpers/renderWithProviders';
|
||||
import Equalizer from './Equalizer';
|
||||
import { useEqStore } from '../store/eqStore';
|
||||
|
||||
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
function resetEq(over: Record<string, unknown> = {}): void {
|
||||
useEqStore.setState({
|
||||
gains: [...FLAT],
|
||||
enabled: true,
|
||||
preGain: 0,
|
||||
activePreset: 'Flat',
|
||||
customPresets: [],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Equalizer preset picker', () => {
|
||||
beforeEach(() => resetEq());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows the active AutoEQ profile name in the picker', () => {
|
||||
// AutoEQ sets activePreset to the headphone name — neither a built-in nor a
|
||||
// saved custom preset. It must still be visible in the picker.
|
||||
resetEq({ activePreset: 'Sennheiser HD 600', customPresets: [] });
|
||||
const { container } = renderWithProviders(<Equalizer />);
|
||||
|
||||
expect(screen.getByText('Sennheiser HD 600')).toBeInTheDocument();
|
||||
// AutoEQ profiles are not deletable custom presets → no delete button.
|
||||
expect(container.querySelector('[data-tooltip="Delete preset"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the delete button only for a saved custom preset', () => {
|
||||
resetEq({
|
||||
activePreset: 'My Mix',
|
||||
customPresets: [{ name: 'My Mix', gains: [...FLAT], builtin: false }],
|
||||
});
|
||||
const { container } = renderWithProviders(<Equalizer />);
|
||||
|
||||
expect(screen.getByText('My Mix')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-tooltip="Delete preset"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows no delete button for a built-in preset', () => {
|
||||
resetEq({ activePreset: 'Rock' });
|
||||
const { container } = renderWithProviders(<Equalizer />);
|
||||
|
||||
expect(container.querySelector('[data-tooltip="Delete preset"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user