mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-26 09:07:41 +00:00
Compare commits
97 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 | |||
| 07232cea9a | |||
| 6f555bdc96 | |||
| c1403f8bd6 | |||
| 0b7d9eae2d | |||
| 41c8187186 | |||
| 028eb65f7d | |||
| be3f1dc299 | |||
| 52fbb33b00 | |||
| 947711a98d | |||
| 891ab0dd5b | |||
| abc2c0b579 | |||
| 184e87a469 | |||
| 80822fd742 | |||
| 4902c0e25b | |||
| 3de7b57cc5 | |||
| 1a82376f8c | |||
| ea304357ca | |||
| 90452a8f8c | |||
| c503226b0a | |||
| 5cd01c90ac | |||
| 8593858f3a | |||
| c7d71ea57c | |||
| fb5a257735 | |||
| 707a41f615 | |||
| ae9be74719 |
@@ -56,6 +56,7 @@ jobs:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
@@ -71,6 +72,7 @@ jobs:
|
||||
- name: install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: vitest run --coverage
|
||||
run: npx vitest run --coverage
|
||||
- name: hot-path file coverage gate
|
||||
|
||||
@@ -176,18 +176,32 @@ jobs:
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$BODY" ]; then
|
||||
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
|
||||
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
fi
|
||||
BODY=""
|
||||
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
|
||||
BODY="$(cat /tmp/changelog-body.md)"
|
||||
fi
|
||||
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "$BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
- name: extract what's new for release asset
|
||||
id: whats-new
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
CHANNEL="${{ inputs.channel }}"
|
||||
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
|
||||
if [ "$CHANNEL" = "release" ]; then
|
||||
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
- name: create or update release
|
||||
id: create-release
|
||||
uses: actions/github-script@v9
|
||||
@@ -237,6 +251,14 @@ jobs:
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
- name: upload whats-new.md release asset
|
||||
if: steps.whats-new.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_TAG="${{ steps.tag.outputs.value }}"
|
||||
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
|
||||
|
||||
build-macos-windows:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
|
||||
@@ -37,6 +37,9 @@ src-tauri/lcov.info
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
|
||||
src/generated/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
|
||||
+494
-30
@@ -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
|
||||
@@ -99,6 +380,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### What's New — remote release notes
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1058](https://github.com/Psychotoxical/psysonic/pull/1058)**
|
||||
|
||||
* The **What's New** page shows user-friendly highlights from `WHATS_NEW.md` instead of embedding the full technical changelog in the app bundle.
|
||||
* RC and stable builds prefetch `whats-new.md` from the GitHub release on startup and cache it locally; offline users see a thin embedded fallback.
|
||||
* **`tauri:dev`** reads markdown straight from the repo for easy editing; shipped bundles embed only the current release-line slice. A **Full changelog** tab on the page shows the technical list for the same version.
|
||||
* CI uploads `whats-new.md` on each `next` / `release` tag alongside platform artifacts.
|
||||
* Remote download uses the existing Rust `fetch_url_bytes` proxy so GitHub release assets work without browser CORS.
|
||||
|
||||
|
||||
|
||||
### Music Network — scrobble to more than just Last.fm
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh), PR [#1066](https://github.com/Psychotoxical/psysonic/pull/1066)**
|
||||
|
||||
* **Settings → Integrations** now hosts a **Music Network** that scrobbles your plays to one or more services at once: **Last.fm**, **Libre.fm**, **Rocksky**, **ListenBrainz** (the public service or any compatible server), **Maloja** (native, Audioscrobbler or ListenBrainz API), **Koito**, and any **custom GNU FM** instance.
|
||||
* Choose a **primary** service — your loved tracks, similar artists and listening stats come from it — while scrobbles fan out to every connected service. A master switch turns the whole thing on or off.
|
||||
* **Last.fm works exactly as before**, including love, similar artists and the Statistics page; your existing connection is migrated automatically on first launch.
|
||||
* *Known limitation:* Rocksky currently rejects scrobbles whose title or artist contains non-standard (non-ASCII) characters — a Rocksky-side issue, not a Psysonic bug.
|
||||
|
||||
|
||||
|
||||
### Live — rich now-playing on Navidrome 0.62+
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1080](https://github.com/Psychotoxical/psysonic/pull/1080)**
|
||||
|
||||
* On servers that advertise the OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62), Psysonic reports live transport state and position so **Live** shows who is playing or paused and where in the track — including playback speed when the other client sends it.
|
||||
* The position bar glides between refreshes; pause and resume update the server immediately instead of waiting for the audio engine.
|
||||
* Play counts are unchanged — still driven by the existing scrobble path. Servers without the extension keep the previous now-playing behaviour.
|
||||
|
||||
|
||||
|
||||
### Title bar — selectable window button styles
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1083](https://github.com/Psychotoxical/psysonic/pull/1083), suggested by [@PHLAK](https://github.com/PHLAK)**
|
||||
|
||||
* The Linux custom title bar gets a **window button style** picker in **Settings → Appearance → Custom title bar** — choose between dots, dots with icons, flat, pill, outline, and minimal looks.
|
||||
* All styles now carry minimize/maximize/close icons for clear, colour-blind-friendly buttons, and an optional toggle hides the minimize button (maximize and close only).
|
||||
|
||||
|
||||
|
||||
### Playback speed — Semitones strategy, finer labels, and advanced fine steps
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1084](https://github.com/Psychotoxical/psysonic/pull/1084)**
|
||||
|
||||
* New **Semitones** strategy sets varispeed directly in semitones (±12 st, 0.1 step) instead of coarse speed steps; the speed readout now shows two decimals so every slider notch is visible.
|
||||
* Each strategy button has a short tooltip; **Advanced** mode adds an optional **Fine adjustment** toggle (0.01× / 0.01 st steps) in **Settings → Audio**.
|
||||
|
||||
|
||||
|
||||
### Now Playing — live status dot in "Who is listening?"
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
|
||||
|
||||
* Each listener in the **Who is listening?** popover now shows a small status dot — playing, paused, or idle — derived from the live playback report, replacing the previous "minutes ago" line. The status is also read out for screen readers and on hover, so it is never conveyed by colour alone.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
@@ -157,30 +496,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Settings → Servers — compact server cards
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1054](https://github.com/Psychotoxical/psysonic/pull/1054)**
|
||||
|
||||
* Two-line header: custom entry name plus `user@host`, HTTPS lock, and a clickable version info tooltip (hover or tap).
|
||||
* Navidrome **0.62+**: green **AudioMuse-AI** inline badge when the plugin is detected; older Navidrome keeps the manual toggle row below the card.
|
||||
* **Use** and **Active** share one rightmost slot (green badge vs primary button); card actions are edit → test → use/active. Delete lives in the edit form footer.
|
||||
* **TooltipPortal**: `data-tooltip-click` for click-pinned tooltips (touch and explicit open without the 1s hover delay).
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Now Playing — cards no longer blank out on track change
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
|
||||
|
||||
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
|
||||
|
||||
|
||||
|
||||
### Now Playing — metadata reads from the local library index first
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
|
||||
|
||||
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
|
||||
|
||||
|
||||
|
||||
### Library DB — named slow-write ops for stall diagnosis
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
|
||||
|
||||
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
|
||||
|
||||
### Servers — complete border on the active server card
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
|
||||
@@ -198,14 +525,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — macOS stutter from background device checks
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
|
||||
|
||||
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
|
||||
|
||||
|
||||
|
||||
### Track preview — Symphonia 0.6 format hints and fast stream start
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
|
||||
@@ -265,6 +584,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — macOS stutter from background device checks
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
|
||||
|
||||
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
|
||||
|
||||
|
||||
|
||||
### Now Playing — cards no longer blank out on track change
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
|
||||
|
||||
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
|
||||
|
||||
|
||||
|
||||
### Library DB — named slow-write ops for stall diagnosis
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
|
||||
|
||||
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
|
||||
|
||||
|
||||
|
||||
### Now Playing — metadata reads from the local library index first
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
|
||||
|
||||
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
|
||||
|
||||
|
||||
|
||||
### Themes — consistent focus borders on inputs and dropdowns
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
|
||||
@@ -282,6 +633,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Navidrome Now Playing and scrobble with local playback
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1055](https://github.com/Psychotoxical/psysonic/pull/1055)**
|
||||
|
||||
* **Show in Now Playing** and Navidrome play-count scrobbles no longer silently skip when audio plays from hot cache, offline library pins, or favorites-auto bytes.
|
||||
* Presence and queue sync target the **playback server** reachability gate, so a queue on server A still reports to Navidrome while browsing server B.
|
||||
|
||||
|
||||
|
||||
### Album grids — album artist on compilation cards
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1057](https://github.com/Psychotoxical/psysonic/pull/1057), reported in [#1056](https://github.com/Psychotoxical/psysonic/issues/1056)**
|
||||
|
||||
* Random Albums, New Releases, All Albums, and other album grids no longer show a track artist on compilation albums when the tags set a single album artist (e.g. **Underworld** on a various-artists mix); the card matches the album page.
|
||||
* Local index browse, live search, and FTS album dedupe prefer `album_artist` over per-track `artist`; Hero, Most Played, and offline pin labels use the same display helper.
|
||||
|
||||
|
||||
|
||||
### Local index — multi-genre browse, filters, and counts
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1059](https://github.com/Psychotoxical/psysonic/pull/1059)**
|
||||
|
||||
* Tracks tagged with several genres in one metadata field (e.g. `Noise Metal/Dark Ambient/Experimental Black Metal`) again match **each atomic genre** in Genres browse, All Albums filters, genre detail, and Advanced Search — not only the first segment.
|
||||
* New `track_genre` index (OpenSubsonic `genres[]` when present, Navidrome-default split fallback), maintained on sync; one-time blocking startup backfill for existing libraries with progress.
|
||||
* Migration v12 repairs databases that recorded legacy schema versions 2–11; TS network fallback uses robust `genreTagsFor` parsing.
|
||||
|
||||
|
||||
|
||||
### Dev startup — missing generated release-notes bundle
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1060](https://github.com/Psychotoxical/psysonic/pull/1060)**
|
||||
|
||||
* Fresh clones no longer crash Vite on `tauri:dev` when `src/generated/releaseNotesBundle.ts` is missing — `dev` and `tauri:dev` now run `prebuild:release-notes` before launch (file stays gitignored).
|
||||
|
||||
|
||||
|
||||
### What's New — release-notes cache file on disk (RC/stable)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1062](https://github.com/Psychotoxical/psysonic/pull/1062)**
|
||||
|
||||
* RC and stable builds now persist the downloaded `whats-new.md` slice under AppData — `plugin-fs` had mkdir but lacked recursive write scope, so the `release-notes/` folder appeared empty and every launch re-fetched from GitHub.
|
||||
|
||||
|
||||
|
||||
### Favorites — player-bar star stays synced in track lists
|
||||
|
||||
**By [@artplan1](https://github.com/artplan1), PR [#1063](https://github.com/Psychotoxical/psysonic/pull/1063)**
|
||||
|
||||
* Liking a song from the **player bar**, fullscreen player, or global shortcuts now updates the star in album tracklists, playlists, Random Mix, and Favorites — the row no longer reverts the instant the server sync completes.
|
||||
* List views seed starred state from a one-shot fetch and merge session `starredOverrides`; clearing those overrides on sync success had only patched `currentTrack` and the queue cache, so rows fell back to stale fetched values.
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — title cleanup
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1068](https://github.com/Psychotoxical/psysonic/pull/1068)**
|
||||
|
||||
* The song title no longer shows a leading track number, and letters with descenders (g, j, p, q, y) are no longer clipped along the bottom edge.
|
||||
|
||||
|
||||
|
||||
### Discord Rich Presence — album art and clearer settings
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1068](https://github.com/Psychotoxical/psysonic/pull/1068)**
|
||||
|
||||
* Album art shows again when the cover source is "Server (via album info)" — Discord was handed a local file path it cannot fetch and fell back to the app icon; it now receives a reachable image URL.
|
||||
* **Settings → Integrations:** added notices clarifying that this is the built-in Discord Rich Presence, and that the official Navidrome Discord RP plugin needs "Show in Now Playing" enabled instead.
|
||||
|
||||
|
||||
|
||||
### Internet radio — no more duplicate now-playing on Linux
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79, PR [#1069](https://github.com/Psychotoxical/psysonic/pull/1069)**
|
||||
|
||||
* On Linux, playing an internet radio station showed the track twice in the desktop "now playing" overlay — once from the app's own media controls and once from a second player the web engine registered for the stream. The extra player is now suppressed, so radio shows a single entry like regular tracks.
|
||||
|
||||
|
||||
|
||||
### Windows — idle app no longer blocks system sleep
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@Thraka](https://github.com/Thraka), PR [#1073](https://github.com/Psychotoxical/psysonic/pull/1073)**
|
||||
|
||||
* Psysonic no longer keeps the audio output device open while the app is idle — the CPAL stream opens on first playback and closes after **60 seconds** without active audio (pause), or **immediately** on Stop / natural queue end, so Windows `powercfg` no longer reports an in-use audio stream when music is not playing ([#1071](https://github.com/Psychotoxical/psysonic/issues/1071)).
|
||||
* Resume after a long pause uses the existing cold path (`audio:output-released` resets the warm-pause flag); post-sleep recovery skips reopening the stream when nothing is playing.
|
||||
* **Cold start while paused:** after `getPlayQueue` restores position, the seekbar shows the saved time immediately; the current track is hot-cache prefetched and the engine loads silently (`audio_play` with `startPaused`) at that position so the next Play is a warm `audio_resume` without an audible blip at the start of the track.
|
||||
* Rodio `Dropping DeviceSink` warnings on stream release are suppressed unless logging is in debug mode.
|
||||
* **Stop keeps the waveform:** stopping no longer wipes the seekbar waveform for the still-shown track — the cached analysis bins are kept and re-hydrated from the database, so the real waveform stays mounted instead of falling back to flat bars.
|
||||
|
||||
|
||||
|
||||
### Auto-install script — `curl | sudo bash` works again
|
||||
|
||||
**By [@kbennett2000](https://github.com/kbennett2000), PR [#1079](https://github.com/Psychotoxical/psysonic/pull/1079)**
|
||||
|
||||
* The Linux auto-installer (`install.sh`) failed before any download because `[INFO]` log lines were captured into the package URL and curl rejected the mangled string; logging now goes to stderr, the reinstall prompt reads from the terminal, and package downloads use `--fail --globoff`.
|
||||
|
||||
|
||||
|
||||
### Music Network — self-hosted scrobbling reaches the server
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1085](https://github.com/Psychotoxical/psysonic/pull/1085)**
|
||||
|
||||
* Self-hosted scrobble targets that use a pasted token (Koito, Maloja's ListenBrainz and Audioscrobbler compatibility surfaces) silently recorded nothing: the saved server address dropped the API path, so listens were sent to a route that does not exist while the account still showed as connected.
|
||||
* The correct API path is now kept when connecting. Reconnect an affected account once so it picks up the corrected address.
|
||||
|
||||
|
||||
|
||||
### Internet Radio — station management limited to server admins
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
|
||||
|
||||
* Navidrome 0.62 made creating, editing and deleting radio stations admin-only, so those actions failed for standard accounts. Add, edit and delete controls are now hidden for non-admin Navidrome users; playback and favourites stay available to everyone. Other server types are unaffected.
|
||||
|
||||
## [1.47.0]
|
||||
|
||||
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
|
||||
|
||||
+13
-7
@@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
|
||||
### Your Subsonic / Navidrome server
|
||||
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
|
||||
|
||||
### Last.fm
|
||||
If you connect a Last.fm account in Settings, Psysonic sends:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album)
|
||||
- **Love / Unlove** — when you mark a track as loved or unloved
|
||||
### Music Network (scrobble & enrichment services)
|
||||
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
|
||||
|
||||
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
|
||||
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
|
||||
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
|
||||
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
|
||||
|
||||
To each connected service, Psysonic may send:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
|
||||
- **Love / Unlove** — when you mark a track as loved, on services that support it
|
||||
|
||||
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
|
||||
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
@@ -37,7 +43,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th
|
||||
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
|
||||
|
||||
- Server profiles (URL, username, password)
|
||||
- Last.fm session key
|
||||
- Scrobble service credentials (session keys / API tokens)
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -49,6 +49,7 @@ Rules:
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
|
||||
1. Run workflow: **Promote main to next**.
|
||||
2. Workflow behavior:
|
||||
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
# What's New
|
||||
|
||||
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
|
||||
current line before promoting to `next` / `release`. Technical details and PR credits stay in
|
||||
`CHANGELOG.md`.
|
||||
|
||||
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
|
||||
|
||||
### Offline listening
|
||||
|
||||
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
|
||||
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
|
||||
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
|
||||
|
||||
### Music Network — scrobble beyond Last.fm
|
||||
|
||||
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
|
||||
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
|
||||
- A master switch turns the whole network on or off.
|
||||
|
||||
### Theme Store
|
||||
|
||||
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
|
||||
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
|
||||
- **Now Playing** follows every theme cleanly, including light palettes.
|
||||
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
|
||||
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
|
||||
|
||||
### Fullscreen player
|
||||
|
||||
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
|
||||
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
|
||||
|
||||
### Live — richer now playing on Navidrome 0.62+
|
||||
|
||||
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
|
||||
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
|
||||
|
||||
### Queue — Timeline mode
|
||||
|
||||
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
|
||||
|
||||
### Settings → Servers
|
||||
|
||||
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
|
||||
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
|
||||
|
||||
### Sidebar — pin Now Playing to the top
|
||||
|
||||
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
|
||||
|
||||
### Startup
|
||||
|
||||
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
|
||||
|
||||
## Improved
|
||||
|
||||
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
|
||||
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
|
||||
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
|
||||
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
|
||||
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
|
||||
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
|
||||
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
|
||||
- Mixed-server queues still report to the correct Navidrome server.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
|
||||
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
|
||||
- Discord Rich Presence shows album art again when covers come from the server.
|
||||
- Focus rings and dropdown borders follow the active theme consistently.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
|
||||
- **All Albums → Only compilations** returns results for common tagging patterns.
|
||||
- Album grids show the album artist on compilations instead of a random track artist.
|
||||
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
|
||||
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
|
||||
- **Home → Most Played** no longer jumps the page when you load more albums.
|
||||
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
|
||||
|
||||
### Other
|
||||
|
||||
- **Linux:** the `curl | bash` auto-installer works again.
|
||||
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
|
||||
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
|
||||
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
|
||||
- The active server card under **Settings → Servers** draws a complete border on all sides.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
|
||||
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
|
||||
}
|
||||
|
||||
Generated
+121
-121
@@ -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",
|
||||
@@ -56,7 +56,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
@@ -394,9 +394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -411,9 +411,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -428,9 +428,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -445,9 +445,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -462,9 +462,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -479,9 +479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -496,9 +496,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -513,9 +513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -530,9 +530,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -547,9 +547,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -564,9 +564,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -581,9 +581,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -598,9 +598,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -615,9 +615,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -632,9 +632,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -649,9 +649,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -666,9 +666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -683,9 +683,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -700,9 +700,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -717,9 +717,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -734,9 +734,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -751,9 +751,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -768,9 +768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -785,9 +785,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -802,9 +802,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -819,9 +819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2366,9 +2366,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -2379,32 +2379,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
@@ -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": {
|
||||
|
||||
+12
-8
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"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",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
|
||||
"dev": "npm run prebuild:release-notes && vite",
|
||||
"build": "npm run prebuild:release-notes && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run && npm run check:css-imports",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
|
||||
"tauri:build": "npm run prebuild:release-notes && tauri build",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage && npm run check:css-imports"
|
||||
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
@@ -63,10 +64,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"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')
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
|
||||
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
|
||||
*
|
||||
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
|
||||
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
|
||||
|
||||
function versionCore(version) {
|
||||
const m = version.trim().match(SEMVER_CORE);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function isPlainTriple(header) {
|
||||
return /^\d+\.\d+\.\d+$/.test(header.trim());
|
||||
}
|
||||
|
||||
function splitBlocks(raw) {
|
||||
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
|
||||
}
|
||||
|
||||
function headerVersion(block) {
|
||||
const m = block.match(/^## \[([^\]]+)\]/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function parseBlock(block) {
|
||||
const lines = block.split('\n');
|
||||
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
headerVersion: m[1],
|
||||
date: (m[2] ?? '').trim(),
|
||||
body: lines.slice(1).join('\n').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function findReleaseSection(raw, appVersion) {
|
||||
const blocks = splitBlocks(raw);
|
||||
|
||||
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
|
||||
if (exact) return parseBlock(exact);
|
||||
|
||||
const appCore = versionCore(appVersion);
|
||||
if (!appCore) return null;
|
||||
|
||||
const candidates = blocks.filter((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && versionCore(hv) === appCore;
|
||||
});
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const plain = candidates.find((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && isPlainTriple(hv);
|
||||
});
|
||||
return parseBlock(plain ?? candidates[0]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const allowEmpty = args.includes('--allow-empty');
|
||||
const positional = args.filter((a) => a !== '--allow-empty');
|
||||
const [file, version] = positional;
|
||||
|
||||
if (!file || !version) {
|
||||
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
const entry = findReleaseSection(raw, version);
|
||||
const body = entry?.body?.trim() ?? '';
|
||||
|
||||
if (!body) {
|
||||
if (allowEmpty) process.exit(0);
|
||||
console.error(`No release section found in ${file} for version ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) main();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const FIXTURE = `
|
||||
## [1.48.0] - 2026-06-10
|
||||
|
||||
## Highlights
|
||||
- One
|
||||
|
||||
## [1.47.0]
|
||||
- Old
|
||||
`;
|
||||
|
||||
describe('findReleaseSection', () => {
|
||||
it('matches base line for -rc versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
assert.match(entry.body, /Highlights/);
|
||||
});
|
||||
|
||||
it('matches base line for -dev versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/generated/releaseNotesBundle.ts for production bundles.
|
||||
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
|
||||
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
const version = pkg.version;
|
||||
|
||||
const whatsNewPath = join(root, 'WHATS_NEW.md');
|
||||
const changelogPath = join(root, 'CHANGELOG.md');
|
||||
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
|
||||
const changelogFull = readFileSync(changelogPath, 'utf8');
|
||||
|
||||
function sliceForVersion(full, fileLabel) {
|
||||
const entry = findReleaseSection(full, version);
|
||||
if (!entry?.body) {
|
||||
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
|
||||
return '';
|
||||
}
|
||||
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
|
||||
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
|
||||
}
|
||||
|
||||
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
|
||||
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
|
||||
|
||||
const outDir = join(root, 'src/generated');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
|
||||
|
||||
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
|
||||
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
|
||||
+19
-8
@@ -14,20 +14,22 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Log helpers write to stderr so functions that return values via stdout
|
||||
# (e.g. get_download_url) stay clean when called in command substitution.
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
echo -e "${BLUE}[INFO]${NC} $1" >&2
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
echo -e "${YELLOW}[WARN]${NC} $1" >&2
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ install_package() {
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
package_file="${package_file}.deb"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file" || {
|
||||
@@ -114,7 +116,7 @@ install_package() {
|
||||
}
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
package_file="${package_file}.rpm"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file"
|
||||
@@ -128,8 +130,17 @@ install_package() {
|
||||
check_installed() {
|
||||
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
|
||||
warn "${APP_NAME} appears to be already installed."
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r
|
||||
echo
|
||||
# Under `curl ... | bash`, stdin is the script stream itself, so
|
||||
# read the answer from the controlling terminal instead. Probe by
|
||||
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
|
||||
# without a controlling terminal; only open() reports the failure.
|
||||
if { : < /dev/tty; } 2>/dev/null; then
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
|
||||
echo
|
||||
else
|
||||
warn "No terminal available for prompt; skipping reinstall."
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
|
||||
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"
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-app-write-recursive",
|
||||
"fs:scope-download-recursive",
|
||||
"fs:scope-home-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
@@ -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,15 +11,16 @@ 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::{
|
||||
build_playback_source_with_probe_fallback, select_play_input,
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
|
||||
PlayInputContext, SinkSwapInputs,
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
|
||||
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};
|
||||
@@ -53,9 +54,32 @@ pub async fn audio_play(
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
// 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 ───────────────────────────────────────────────────
|
||||
@@ -215,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
|
||||
@@ -242,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.
|
||||
@@ -281,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;
|
||||
@@ -311,23 +355,25 @@ pub async fn audio_play(
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
|
||||
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(new_handle) => {
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
|
||||
match super::engine::open_output_stream_blocking(
|
||||
&state,
|
||||
target_rate,
|
||||
hi_res_enabled,
|
||||
dev,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +383,8 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
|
||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||
@@ -389,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 {
|
||||
@@ -401,7 +459,7 @@ pub async fn audio_play(
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // skipped during pre-fill — abort silently
|
||||
}
|
||||
if !defer_playback_start {
|
||||
if !defer_playback_start && !start_paused {
|
||||
sink.play();
|
||||
}
|
||||
}
|
||||
@@ -415,24 +473,46 @@ pub async fn audio_play(
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
if defer_playback_start {
|
||||
// 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();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
spawn_legacy_stream_start_when_armed(
|
||||
spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
state.samples_played.clone(),
|
||||
state.current.clone(),
|
||||
app.clone(),
|
||||
gen_arc: state.generation.clone(),
|
||||
playback_armed: state.stream_playback_armed.clone(),
|
||||
samples_played: state.samples_played.clone(),
|
||||
current: state.current.clone(),
|
||||
app: app.clone(),
|
||||
duration_secs,
|
||||
);
|
||||
} else {
|
||||
hold_paused: start_paused,
|
||||
});
|
||||
} else if !start_paused {
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
}
|
||||
|
||||
@@ -445,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),
|
||||
@@ -516,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());
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
|
||||
//! enumeration + reopen path.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
@@ -78,16 +76,13 @@ pub async fn audio_set_device(
|
||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||
|
||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
state.stream_reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None).ok_or("device open timed out")?;
|
||||
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
state.device_default_rate
|
||||
};
|
||||
super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
|
||||
.map_err(|_| "device open timed out".to_string())?;
|
||||
|
||||
// Capture position and drop the active sink atomically so the position
|
||||
// reading is still valid (play_started / paused_at intact before take).
|
||||
|
||||
@@ -23,10 +23,11 @@ use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::play_input::{
|
||||
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint,
|
||||
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs,
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{
|
||||
build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource,
|
||||
};
|
||||
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
@@ -86,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,
|
||||
}
|
||||
}
|
||||
@@ -187,9 +189,14 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let sink = Arc::new(Player::connect_new(
|
||||
engine.stream_handle.lock().unwrap().mixer(),
|
||||
));
|
||||
let stream = match super::engine::ensure_output_stream_open(&engine) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
@@ -205,6 +212,8 @@ 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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -253,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),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::Emitter;
|
||||
@@ -41,8 +40,11 @@ pub(crate) async fn reopen_output_stream(
|
||||
};
|
||||
|
||||
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||
let stream_handle = engine.stream_handle.clone();
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
@@ -62,25 +64,33 @@ pub(crate) async fn reopen_output_stream(
|
||||
}
|
||||
};
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
if reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
let app_for_open = app.clone();
|
||||
let device_name_for_open = device_name.clone();
|
||||
let opened = tauri::async_runtime::spawn_blocking(move || {
|
||||
let engine = app_for_open.state::<AudioEngine>();
|
||||
super::engine::open_output_stream_blocking(
|
||||
&engine,
|
||||
open_rate,
|
||||
false,
|
||||
device_name_for_open,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
.unwrap_or(false);
|
||||
|
||||
let Some(handle) = new_handle else {
|
||||
if !opened {
|
||||
return false;
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
}
|
||||
// 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,24 +4,36 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
||||
|
||||
/// Reply channel handed back to the audio-stream thread once a re-open finishes.
|
||||
pub type StreamReopenReply = std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>;
|
||||
/// Stream-thread re-open request: `(desired_rate, is_hi_res, device_name, reply_tx)`.
|
||||
pub type StreamReopenRequest = (u32, bool, Option<String>, StreamReopenReply);
|
||||
/// Reply channel handed back to the audio-stream thread once an open finishes.
|
||||
pub type StreamOpenReply =
|
||||
std::sync::mpsc::SyncSender<(Arc<rodio::MixerDeviceSink>, u32)>;
|
||||
|
||||
/// Requests handled on the dedicated audio-stream thread (open / idle release).
|
||||
pub enum StreamThreadMsg {
|
||||
Open {
|
||||
desired_rate: u32,
|
||||
is_hi_res: bool,
|
||||
device_name: Option<String>,
|
||||
reply: StreamOpenReply,
|
||||
},
|
||||
Release {
|
||||
reply: std::sync::mpsc::SyncSender<()>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
|
||||
pub stream_handle: Arc<std::sync::Mutex<Option<Arc<rodio::MixerDeviceSink>>>>,
|
||||
/// Sample rate the output stream was last opened at (updated on every re-open).
|
||||
pub stream_sample_rate: Arc<AtomicU32>,
|
||||
/// The rate the device was opened at on cold start — used to restore the
|
||||
/// stream when Hi-Res is toggled off while a hi-res rate is active.
|
||||
pub device_default_rate: u32,
|
||||
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
||||
/// thread to re-open the output device. `device_name = None` → system default.
|
||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<StreamReopenRequest>,
|
||||
/// Open or release the CPAL output stream on the audio-stream thread.
|
||||
pub stream_thread_tx: std::sync::mpsc::SyncSender<StreamThreadMsg>,
|
||||
/// User-selected output device name (None = follow system default).
|
||||
pub selected_device: Arc<Mutex<Option<String>>>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
@@ -49,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.
|
||||
@@ -147,6 +168,15 @@ impl AudioCurrent {
|
||||
/// 3. Device default.
|
||||
/// 4. System default (last resort).
|
||||
///
|
||||
/// Rodio prints a stderr line on every intentional stream drop. Keep that only
|
||||
/// when runtime logging is in **debug** mode; normal/off silence the noise.
|
||||
fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc<rodio::MixerDeviceSink> {
|
||||
if !crate::logging::should_log_debug() {
|
||||
handle.log_on_drop(false);
|
||||
}
|
||||
Arc::new(handle)
|
||||
}
|
||||
|
||||
/// Returns `(stream_handle, actual_sample_rate)`.
|
||||
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
@@ -214,7 +244,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
|
||||
{
|
||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
||||
return (Arc::new(handle), desired_rate);
|
||||
return (finalize_mixer_device_sink(handle), desired_rate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +261,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||
rate, desired_rate
|
||||
);
|
||||
return (Arc::new(handle), rate);
|
||||
return (finalize_mixer_device_sink(handle), rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +274,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44100);
|
||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
||||
return (Arc::new(handle), rate);
|
||||
return (finalize_mixer_device_sink(handle), rate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +287,78 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
.and_then(|d| d.default_output_config().ok())
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44100);
|
||||
(Arc::new(handle), rate)
|
||||
(finalize_mixer_device_sink(handle), rate)
|
||||
}
|
||||
|
||||
fn probe_device_default_rate() -> u32 {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.default_output_config().ok())
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44_100)
|
||||
}
|
||||
|
||||
/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`.
|
||||
pub(crate) fn open_output_stream_blocking(
|
||||
engine: &AudioEngine,
|
||||
desired_rate: u32,
|
||||
is_hi_res: bool,
|
||||
device_name: Option<String>,
|
||||
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
|
||||
let rate = if desired_rate > 0 {
|
||||
desired_rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
|
||||
engine
|
||||
.stream_thread_tx
|
||||
.send(StreamThreadMsg::Open {
|
||||
desired_rate: rate,
|
||||
is_hi_res,
|
||||
device_name,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (handle, actual_rate) = reply_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.map_err(|_| "audio stream open timed out".to_string())?;
|
||||
engine
|
||||
.stream_sample_rate
|
||||
.store(actual_rate, std::sync::atomic::Ordering::Relaxed);
|
||||
*engine.stream_handle.lock().unwrap() = Some(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Ensure a live output stream exists; lazy-opens on first playback.
|
||||
pub(crate) fn ensure_output_stream_open(
|
||||
engine: &AudioEngine,
|
||||
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
|
||||
if let Some(handle) = engine.stream_handle.lock().unwrap().clone() {
|
||||
return Ok(handle);
|
||||
}
|
||||
let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let device = engine.selected_device.lock().unwrap().clone();
|
||||
open_output_stream_blocking(engine, open_rate, false, device)
|
||||
}
|
||||
|
||||
pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
|
||||
engine
|
||||
.stream_thread_tx
|
||||
.send(StreamThreadMsg::Release { reply: reply_tx })
|
||||
.map_err(|e| e.to_string())?;
|
||||
reply_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.map_err(|_| "audio stream release timed out".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
@@ -269,13 +370,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Channels: main thread ←→ audio-stream thread.
|
||||
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
|
||||
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
|
||||
let (init_tx, init_rx) =
|
||||
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
|
||||
let (reopen_tx, reopen_rx) =
|
||||
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
|
||||
// Channel: main thread ←→ audio-stream thread (lazy open + idle release).
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0);
|
||||
let (stream_thread_tx, stream_thread_rx) =
|
||||
std::sync::mpsc::sync_channel::<StreamThreadMsg>(4);
|
||||
|
||||
let device_default_rate = probe_device_default_rate();
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
@@ -296,52 +396,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
// Thread priority is kept at default during standard-mode playback.
|
||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
||||
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
|
||||
let handle = _stream.clone();
|
||||
init_tx.send((handle, rate)).ok();
|
||||
let mut _stream: Option<Arc<rodio::MixerDeviceSink>> = None;
|
||||
ready_tx.send(()).ok();
|
||||
|
||||
// Keep the stream alive and handle sample-rate / device-switch requests.
|
||||
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
// elevated priority would only waste scheduler budget.
|
||||
if is_hi_res {
|
||||
thread_priority::set_current_thread_priority(
|
||||
thread_priority::ThreadPriority::Max
|
||||
).ok();
|
||||
while let Ok(msg) = stream_thread_rx.recv() {
|
||||
match msg {
|
||||
StreamThreadMsg::Release { reply } => {
|
||||
_stream = None;
|
||||
let _ = reply.send(());
|
||||
}
|
||||
StreamThreadMsg::Open {
|
||||
desired_rate,
|
||||
is_hi_res,
|
||||
device_name,
|
||||
reply,
|
||||
} => {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
// elevated priority would only waste scheduler budget.
|
||||
if is_hi_res {
|
||||
thread_priority::set_current_thread_priority(
|
||||
thread_priority::ThreadPriority::Max,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
_stream = None;
|
||||
|
||||
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||
// latency stays roughly constant (≈93 ms) at all rates.
|
||||
#[cfg(target_os = "linux")]
|
||||
if desired_rate > 0 {
|
||||
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||
let latency_ms =
|
||||
(frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, actual_rate) =
|
||||
open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||
let new_handle = new_stream.clone();
|
||||
_stream = Some(new_stream);
|
||||
let _ = reply.send((new_handle, actual_rate));
|
||||
}
|
||||
}
|
||||
|
||||
drop(_stream); // close old stream before opening new one
|
||||
|
||||
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||
// latency stays roughly constant (≈93 ms) at all rates.
|
||||
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
|
||||
// get the same wall-clock quantum as PipeWire.
|
||||
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||
let new_handle = new_stream.clone();
|
||||
_stream = new_stream;
|
||||
reply_tx.send(new_handle).ok();
|
||||
}
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
|
||||
ready_rx.recv().expect("audio stream thread ready");
|
||||
|
||||
let engine = AudioEngine {
|
||||
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||
device_default_rate: initial_rate,
|
||||
stream_reopen_tx: reopen_tx,
|
||||
stream_handle: Arc::new(std::sync::Mutex::new(None)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
device_default_rate,
|
||||
stream_thread_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
@@ -374,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)),
|
||||
@@ -458,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::*;
|
||||
|
||||
@@ -16,6 +16,8 @@ pub mod device_commands;
|
||||
pub mod mix_commands;
|
||||
mod play_input;
|
||||
pub mod playback_rate;
|
||||
mod sink_swap;
|
||||
mod source_build;
|
||||
mod preserve_worker;
|
||||
pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
@@ -24,6 +26,7 @@ pub mod transport_commands;
|
||||
mod device_resume;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
mod stream_idle;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod power_resume;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -39,6 +42,7 @@ mod stream;
|
||||
|
||||
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
|
||||
pub use device_watcher::start_device_watcher;
|
||||
pub use stream_idle::start_stream_idle_watcher;
|
||||
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
|
||||
pub use helpers::{
|
||||
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
|
||||
|
||||
@@ -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,11 +15,10 @@ use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
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, resolve_playback_format_hint, sniff_stream_format_extension,
|
||||
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
||||
same_playback_target,
|
||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
};
|
||||
@@ -41,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>,
|
||||
},
|
||||
@@ -202,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,
|
||||
})
|
||||
}
|
||||
@@ -214,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
|
||||
@@ -253,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
|
||||
@@ -325,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,
|
||||
@@ -341,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,
|
||||
}));
|
||||
}
|
||||
@@ -379,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,
|
||||
));
|
||||
|
||||
@@ -401,47 +431,6 @@ async fn open_ranged_or_streaming_input(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
|
||||
/// playback, then reset counters and emit `audio:playing` so the UI does not
|
||||
/// extrapolate ahead of audible output.
|
||||
pub(super) fn spawn_legacy_stream_start_when_armed(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
current: Arc<Mutex<super::engine::AudioCurrent>>,
|
||||
app: AppHandle,
|
||||
duration_secs: f64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if playback_armed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
samples_played.store(0, Ordering::Relaxed);
|
||||
let sink = current.lock().unwrap().sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
{
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = Some(std::time::Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
}
|
||||
sink.play();
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pulled out of the format_hint extraction block in `audio_play` — strip the
|
||||
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
/// don't latch onto random query-param substrings; only accept short
|
||||
@@ -460,459 +449,3 @@ pub(crate) fn url_format_hint(url: &str) -> Option<String> {
|
||||
})
|
||||
.map(|s| s.to_lowercase())
|
||||
}
|
||||
|
||||
/// Arguments forwarded from `audio_play` into the source-build pipeline.
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
pub server_id: Option<&'a str>,
|
||||
pub url_format_hint: Option<&'a str>,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
use std::time::Instant;
|
||||
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
|
||||
match input {
|
||||
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
|
||||
format_hint.clone()
|
||||
}
|
||||
PlayInput::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged HTTP probe/decode failed in a way that may succeed after the
|
||||
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
|
||||
fn is_ranged_stream_probe_failure(err: &str) -> bool {
|
||||
err.contains("ranged-stream")
|
||||
&& (err.contains("format probe failed")
|
||||
|| err.contains("moov metadata")
|
||||
|| err.contains("end of stream"))
|
||||
}
|
||||
|
||||
/// Completed ranged download or spill file for `url`, if ready.
|
||||
async fn try_take_completed_stream_bytes(
|
||||
url: &str,
|
||||
state: &State<'_, AudioEngine>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
|
||||
return Some(data);
|
||||
}
|
||||
let spill_path = {
|
||||
let guard = state.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, url))
|
||||
.map(|p| p.path.clone())
|
||||
};
|
||||
if let Some(path) = spill_path {
|
||||
let data = tokio::fs::read(&path).await.ok()?;
|
||||
if !data.is_empty() {
|
||||
return Some(data);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
|
||||
async fn prefer_clean_http_bytes_for_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
ranged_data: Vec<u8>,
|
||||
format_hint: Option<&str>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
|
||||
if is_mp4 {
|
||||
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
|
||||
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|
||||
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
|
||||
);
|
||||
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
|
||||
if super::stream::isobmff_buffer_looks_complete(&fresh) {
|
||||
return Ok(Some(fresh));
|
||||
}
|
||||
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ranged_data))
|
||||
}
|
||||
|
||||
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
|
||||
pub(super) async fn wait_or_fetch_bytes_for_stream_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: using completed download ({} KiB)",
|
||||
data.len() / 1024
|
||||
);
|
||||
return prefer_clean_http_bytes_for_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
data,
|
||||
format_hint,
|
||||
"ranged-cache",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
|
||||
TRACK_READ_TIMEOUT_SECS
|
||||
);
|
||||
fetch_data(url, state, gen, app).await
|
||||
}
|
||||
|
||||
fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
err.contains("format probe failed")
|
||||
|| err.contains("could not open audio stream")
|
||||
|| err.contains("no playable audio track")
|
||||
}
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks,
|
||||
server_id,
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Some(ref h) = effective_hint {
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) if is_ranged_stream_probe_failure(&e) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
|
||||
e
|
||||
);
|
||||
let data = match wait_or_fetch_bytes_for_stream_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
effective_hint.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Err(e),
|
||||
};
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Err("ranged-stream: superseded during full-buffer fallback".into());
|
||||
}
|
||||
let bytes_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(&data),
|
||||
);
|
||||
if bytes_hint.as_ref() != effective_hint.as_ref() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
|
||||
bytes_hint,
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(pe) if is_in_memory_probe_failure(&pe) => {
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&data,
|
||||
bytes_hint.as_deref(),
|
||||
"ranged-cache-probe-fail",
|
||||
);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
|
||||
pe
|
||||
);
|
||||
let fresh = match fetch_data(url, state, gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Err(pe),
|
||||
};
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&fresh,
|
||||
bytes_hint.as_deref(),
|
||||
"http-refetch-after-probe-fail",
|
||||
);
|
||||
}
|
||||
build_source_from_play_input(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(pe) => Err(pe),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
pub(super) async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
)
|
||||
}
|
||||
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")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::device_watcher::{reopen_output_stream, ReopenNotify};
|
||||
use super::engine::AudioEngine;
|
||||
use super::engine::{request_stream_release, AudioEngine};
|
||||
use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release};
|
||||
|
||||
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
const DEBOUNCE: Duration = Duration::from_millis(900);
|
||||
@@ -30,10 +30,22 @@ pub(crate) fn debounce_allow_resume_reopen() -> bool {
|
||||
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
let device_name = match app.try_state::<AudioEngine>() {
|
||||
Some(e) => e.selected_device.lock().unwrap().clone(),
|
||||
None => return,
|
||||
let Some(state) = app.try_state::<AudioEngine>() else {
|
||||
return;
|
||||
};
|
||||
let engine = state.inner();
|
||||
|
||||
if !output_stream_is_needed(engine) {
|
||||
if engine.stream_handle.lock().unwrap().is_some() {
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
let _ = request_stream_release(engine);
|
||||
*engine.stream_handle.lock().unwrap() = None;
|
||||
let _ = app.emit("audio:output-released", ());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let device_name = engine.selected_device.lock().unwrap().clone();
|
||||
|
||||
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
|
||||
|
||||
@@ -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}"))??;
|
||||
@@ -399,7 +404,8 @@ pub async fn audio_preview_play(
|
||||
let source = PriorityBoostSource::new(source);
|
||||
|
||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(source);
|
||||
|
||||
|
||||
@@ -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())??;
|
||||
@@ -150,7 +150,8 @@ pub async fn audio_play_radio(
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(boosted);
|
||||
|
||||
@@ -183,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,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink
|
||||
//! into `state.current` (handing off the old one to a crossfade tail or a hard
|
||||
//! stop), and the legacy non-seekable path that holds a sink paused until the
|
||||
//! download task arms playback. Split out of `play_input.rs` so source
|
||||
//! selection and source building stay focused on their own concerns.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::engine::{AudioCurrent, AudioEngine};
|
||||
|
||||
/// Args for [`spawn_legacy_stream_start_when_armed`].
|
||||
pub(super) struct LegacyStreamStartWhenArmed {
|
||||
pub gen: u64,
|
||||
pub gen_arc: Arc<AtomicU64>,
|
||||
pub playback_armed: Arc<AtomicBool>,
|
||||
pub samples_played: Arc<AtomicU64>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
pub app: AppHandle,
|
||||
pub duration_secs: f64,
|
||||
pub hold_paused: bool,
|
||||
}
|
||||
|
||||
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
|
||||
/// playback, then reset counters and emit `audio:playing` so the UI does not
|
||||
/// extrapolate ahead of audible output.
|
||||
pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) {
|
||||
let LegacyStreamStartWhenArmed {
|
||||
gen,
|
||||
gen_arc,
|
||||
playback_armed,
|
||||
samples_played,
|
||||
current,
|
||||
app,
|
||||
duration_secs,
|
||||
hold_paused,
|
||||
} = args;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if playback_armed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
samples_played.store(0, Ordering::Relaxed);
|
||||
let sink = current.lock().unwrap().sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
if hold_paused {
|
||||
sink.pause();
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = None;
|
||||
if cur.paused_at.is_none() {
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
cur.seek_offset = 0.0;
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy track-stream: buffer ready, holding paused (silent prepare)"
|
||||
);
|
||||
} else {
|
||||
{
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
}
|
||||
sink.play();
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
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
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
cur.sink = Some(sink.clone());
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
if start_paused {
|
||||
sink.pause();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
} else {
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
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);
|
||||
}
|
||||
*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);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Source-building pipeline for `audio_play`: turn a resolved [`PlayInput`]
|
||||
//! into a fully wrapped rodio source, including the ranged-stream probe
|
||||
//! fallback (wait for / fetch a full download and retry from in-memory bytes
|
||||
//! when a partial ranged buffer can't be probed yet). Split out of
|
||||
//! `play_input.rs` so source *selection* stays separate from source *building*.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
|
||||
use super::play_input::PlayInput;
|
||||
use super::stream::TRACK_READ_TIMEOUT_SECS;
|
||||
|
||||
/// Arguments forwarded from `audio_play` into the source-build pipeline.
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
pub server_id: Option<&'a str>,
|
||||
pub url_format_hint: Option<&'a str>,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
|
||||
match input {
|
||||
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
|
||||
format_hint.clone()
|
||||
}
|
||||
PlayInput::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged HTTP probe/decode failed in a way that may succeed after the
|
||||
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
|
||||
fn is_ranged_stream_probe_failure(err: &str) -> bool {
|
||||
err.contains("ranged-stream")
|
||||
&& (err.contains("format probe failed")
|
||||
|| err.contains("moov metadata")
|
||||
|| err.contains("end of stream"))
|
||||
}
|
||||
|
||||
/// Completed ranged download or spill file for `url`, if ready.
|
||||
async fn try_take_completed_stream_bytes(
|
||||
url: &str,
|
||||
state: &State<'_, AudioEngine>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
|
||||
return Some(data);
|
||||
}
|
||||
let spill_path = {
|
||||
let guard = state.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, url))
|
||||
.map(|p| p.path.clone())
|
||||
};
|
||||
if let Some(path) = spill_path {
|
||||
let data = tokio::fs::read(&path).await.ok()?;
|
||||
if !data.is_empty() {
|
||||
return Some(data);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
|
||||
async fn prefer_clean_http_bytes_for_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
ranged_data: Vec<u8>,
|
||||
format_hint: Option<&str>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
|
||||
if is_mp4 {
|
||||
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
|
||||
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|
||||
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
|
||||
);
|
||||
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
|
||||
if super::stream::isobmff_buffer_looks_complete(&fresh) {
|
||||
return Ok(Some(fresh));
|
||||
}
|
||||
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ranged_data))
|
||||
}
|
||||
|
||||
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
|
||||
async fn wait_or_fetch_bytes_for_stream_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
use std::time::Instant;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: using completed download ({} KiB)",
|
||||
data.len() / 1024
|
||||
);
|
||||
return prefer_clean_http_bytes_for_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
data,
|
||||
format_hint,
|
||||
"ranged-cache",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
|
||||
TRACK_READ_TIMEOUT_SECS
|
||||
);
|
||||
fetch_data(url, state, gen, app).await
|
||||
}
|
||||
|
||||
fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
err.contains("format probe failed")
|
||||
|| err.contains("could not open audio stream")
|
||||
|| err.contains("no playable audio track")
|
||||
}
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks,
|
||||
server_id,
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Some(ref h) = effective_hint {
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) if is_ranged_stream_probe_failure(&e) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
|
||||
e
|
||||
);
|
||||
let data = match wait_or_fetch_bytes_for_stream_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
effective_hint.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Err(e),
|
||||
};
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Err("ranged-stream: superseded during full-buffer fallback".into());
|
||||
}
|
||||
let bytes_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(&data),
|
||||
);
|
||||
if bytes_hint.as_ref() != effective_hint.as_ref() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
|
||||
bytes_hint,
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(pe) if is_in_memory_probe_failure(&pe) => {
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&data,
|
||||
bytes_hint.as_deref(),
|
||||
"ranged-cache-probe-fail",
|
||||
);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
|
||||
pe
|
||||
);
|
||||
let fresh = match fetch_data(url, state, gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Err(pe),
|
||||
};
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&fresh,
|
||||
bytes_hint.as_deref(),
|
||||
"http-refetch-after-probe-fail",
|
||||
);
|
||||
}
|
||||
build_source_from_play_input(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(pe) => Err(pe),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
random_access,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
)
|
||||
}
|
||||
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",
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Release the CPAL/rodio output stream after playback has been idle so the OS
|
||||
//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use").
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// Wall-clock idle period before closing the output device handle.
|
||||
pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60;
|
||||
|
||||
const IDLE_POLL_SECS: u64 = 5;
|
||||
|
||||
/// Returns true while the app must keep an open output stream (playing, preview, crossfade).
|
||||
pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool {
|
||||
if engine.preview_sink.lock().unwrap().is_some() {
|
||||
return true;
|
||||
}
|
||||
if engine.fading_out_sink.lock().unwrap().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cur = engine.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.empty() {
|
||||
return false;
|
||||
}
|
||||
if cur.play_started.is_some() && cur.paused_at.is_none() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() {
|
||||
if !rs.flags.is_paused.load(Ordering::Relaxed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume.
|
||||
pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) {
|
||||
if let Some(s) = engine.preview_sink.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
if let Some(s) = engine.fading_out_sink.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
if let Some(s) = cur.sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
cur.play_started = None;
|
||||
}
|
||||
|
||||
fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> {
|
||||
super::engine::request_stream_release(engine)?;
|
||||
*engine.stream_handle.lock().unwrap() = None;
|
||||
let _ = app.emit("audio:output-released", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release the output device after the idle timer (pause with no other active audio).
|
||||
pub(crate) fn release_output_stream(
|
||||
engine: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if engine.stream_handle.lock().unwrap().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
close_output_device_handle(engine, app)?;
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio output stream released after {}s idle",
|
||||
OUTPUT_STREAM_IDLE_RELEASE_SECS
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release immediately on explicit stop / queue end — do not wait for the idle timer.
|
||||
pub(crate) fn release_output_stream_on_stop(
|
||||
engine: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if engine.stream_handle.lock().unwrap().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// `audio_stop` already tore down the main sink; clear any crossfade/preview tail
|
||||
// still tied to the open device before closing the handle.
|
||||
if engine.preview_sink.lock().unwrap().is_some()
|
||||
|| engine.fading_out_sink.lock().unwrap().is_some()
|
||||
{
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
}
|
||||
close_output_device_handle(engine, app)?;
|
||||
crate::app_eprintln!("[psysonic] audio output stream released on stop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the engine from `app` each poll (the engine is managed Tauri state),
|
||||
/// so it takes only the `AppHandle` — no engine reference is needed here.
|
||||
pub fn start_stream_idle_watcher(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut idle_since: Option<Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await;
|
||||
let Some(state) = app.try_state::<AudioEngine>() else {
|
||||
idle_since = None;
|
||||
continue;
|
||||
};
|
||||
let engine = state.inner();
|
||||
let stream_open = engine.stream_handle.lock().unwrap().is_some();
|
||||
if !stream_open {
|
||||
idle_since = None;
|
||||
continue;
|
||||
}
|
||||
if output_stream_is_needed(engine) {
|
||||
idle_since = None;
|
||||
continue;
|
||||
}
|
||||
let since = *idle_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) {
|
||||
continue;
|
||||
}
|
||||
let _ = release_output_stream(engine, &app);
|
||||
idle_since = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use ringbuf::HeapCons;
|
||||
use rodio::source::Zero;
|
||||
use rodio::{ChannelCount, Player, SampleRate};
|
||||
|
||||
use super::super::engine::AudioCurrent;
|
||||
use super::super::playback_rate::PlaybackRateAtomics;
|
||||
use super::super::stream::{RadioLiveState, RadioSharedFlags};
|
||||
|
||||
/// A device-less rodio `Player` with one infinite source appended, so
|
||||
/// `empty()` reports `false` without ever opening an output device.
|
||||
/// Returns the queue output too — keep it alive for the test's duration.
|
||||
fn nonempty_player() -> (Arc<Player>, rodio::queue::SourcesQueueOutput) {
|
||||
let (player, out) = Player::new();
|
||||
player.append(Zero::new(
|
||||
ChannelCount::new(2).unwrap(),
|
||||
SampleRate::new(44_100).unwrap(),
|
||||
));
|
||||
(Arc::new(player), out)
|
||||
}
|
||||
|
||||
fn radio_session(is_paused: bool) -> RadioLiveState {
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
RadioLiveState {
|
||||
url: "http://example.test/stream".to_string(),
|
||||
gen: 0,
|
||||
// Detached no-op task; never polled. Drop just aborts it.
|
||||
task: tokio::spawn(async {}),
|
||||
flags: Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(is_paused),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(tx),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_engine() -> AudioEngine {
|
||||
let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0);
|
||||
AudioEngine {
|
||||
stream_handle: Arc::new(Mutex::new(None)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
device_default_rate: 48_000,
|
||||
stream_thread_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
eq_pre_gain: Arc::new(AtomicU32::new(0)),
|
||||
playback_rate: PlaybackRateAtomics::new(),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||
stream_completed_spill: Arc::new(Mutex::new(None)),
|
||||
current_is_seekable: Arc::new(AtomicBool::new(true)),
|
||||
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)),
|
||||
normalization_target_lufs: Arc::new(AtomicU32::new(0)),
|
||||
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
current_analysis_track_id: Arc::new(Mutex::new(None)),
|
||||
current_playback_server_id: Arc::new(Mutex::new(None)),
|
||||
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
|
||||
preview_sink: Arc::new(Mutex::new(None)),
|
||||
preview_gen: Arc::new(AtomicU64::new(0)),
|
||||
preview_main_resume: Arc::new(AtomicBool::new(false)),
|
||||
preview_song_id: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_no_sink_and_no_preview() {
|
||||
let engine = minimal_engine();
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_sink_empty() {
|
||||
// A live but drained main sink (track finished) must not pin the device.
|
||||
let (player, _out) = Player::new(); // no source appended → empty()
|
||||
let player = Arc::new(player);
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(player);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_sink_playing() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None; // actively playing
|
||||
}
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_sink_paused() {
|
||||
// Non-empty sink but paused (paused_at set) — the idle watcher may release.
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = Some(12.0);
|
||||
}
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_preview_sink_present() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
*engine.preview_sink.lock().unwrap() = Some(sink);
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_fading_out_sink_present() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
*engine.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn needed_when_radio_playing() {
|
||||
let engine = minimal_engine();
|
||||
*engine.radio_state.lock().unwrap() = Some(radio_session(false));
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_when_radio_paused() {
|
||||
let engine = minimal_engine();
|
||||
*engine.radio_state.lock().unwrap() = Some(radio_session(true));
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
drop(cur);
|
||||
let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -287,11 +287,31 @@ pub async fn resolve_stream_url(url: String) -> String {
|
||||
resolve_playlist_url(&client, &url).await.unwrap_or(url)
|
||||
}
|
||||
|
||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||
/// `params` is a list of [key, value] pairs (method must be included).
|
||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||
/// Default Audioscrobbler v2 endpoint (Last.fm). Other presets (Libre.fm,
|
||||
/// Rocksky, GNU FM, Maloja compat) pass their own `base_url`.
|
||||
const LASTFM_API_BASE: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||
|
||||
/// Generic Audioscrobbler v2 transport. Provider-agnostic: the caller supplies
|
||||
/// the endpoint `base_url`, so Last.fm, Libre.fm, Rocksky, custom GNU FM and the
|
||||
/// Shared HTTP client for the Music Network provider transports
|
||||
/// (audioscrobbler / listenbrainz / maloja). A bounded timeout keeps a hung
|
||||
/// provider from leaving scrobble/probe/loved-sync promises unresolved — the
|
||||
/// sibling `fetch_*` commands in this module set the same kind of bound.
|
||||
fn provider_http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Maloja Audioscrobbler-compat surface all share this one command.
|
||||
///
|
||||
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
|
||||
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
|
||||
/// true a GET request is made, otherwise a form POST.
|
||||
#[tauri::command]
|
||||
pub async fn lastfm_request(
|
||||
pub async fn audioscrobbler_request(
|
||||
base_url: String,
|
||||
params: Vec<[String; 2]>,
|
||||
sign: bool,
|
||||
get: bool,
|
||||
@@ -300,6 +320,8 @@ pub async fn lastfm_request(
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url };
|
||||
|
||||
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
|
||||
map.insert("api_key".into(), api_key.clone());
|
||||
|
||||
@@ -317,17 +339,17 @@ pub async fn lastfm_request(
|
||||
|
||||
map.insert("format".into(), "json".into());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let client = provider_http_client()?;
|
||||
let resp = if get {
|
||||
client
|
||||
.get("https://ws.audioscrobbler.com/2.0/")
|
||||
.get(&base)
|
||||
.query(&map)
|
||||
.header("User-Agent", subsonic_wire_user_agent())
|
||||
.send()
|
||||
.await
|
||||
} else {
|
||||
client
|
||||
.post("https://ws.audioscrobbler.com/2.0/")
|
||||
.post(&base)
|
||||
.form(&map)
|
||||
.header("User-Agent", subsonic_wire_user_agent())
|
||||
.send()
|
||||
@@ -337,7 +359,88 @@ pub async fn lastfm_request(
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(err) = json.get("error") {
|
||||
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
|
||||
return Err(format!("Audioscrobbler {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
|
||||
}
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Generic ListenBrainz transport. Used by both the direct
|
||||
/// `api.listenbrainz.org` preset and the Maloja `/apis/listenbrainz` compat
|
||||
/// surface — they differ only by `base_url`. Auth is a `Token` header.
|
||||
///
|
||||
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
|
||||
/// is present the request is a POST with that body; otherwise a GET.
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_request(
|
||||
base_url: String,
|
||||
path: String,
|
||||
auth_token: String,
|
||||
json_body: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}{}", base_url.trim_end_matches('/'), path);
|
||||
let client = provider_http_client()?;
|
||||
|
||||
let mut req = if json_body.is_some() {
|
||||
client.post(&url)
|
||||
} else {
|
||||
client.get(&url)
|
||||
};
|
||||
req = req
|
||||
.header("Authorization", format!("Token {}", auth_token))
|
||||
.header("User-Agent", subsonic_wire_user_agent());
|
||||
if let Some(body) = json_body {
|
||||
req = req.json(&body);
|
||||
}
|
||||
|
||||
let resp = req.send().await.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = json.get("error").and_then(|m| m.as_str()).unwrap_or("");
|
||||
return Err(format!("ListenBrainz {} {}", status.as_u16(), msg));
|
||||
}
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Generic Maloja native (`/apis/mlj_1`) transport. Protocol-agnostic JSON:
|
||||
/// the caller builds the body (including the Maloja key) and chooses the path.
|
||||
///
|
||||
/// `path` is appended to `base_url`. When `json_body` is present the request is a
|
||||
/// POST with that body; otherwise a GET with `query` pairs.
|
||||
#[tauri::command]
|
||||
pub async fn maloja_request(
|
||||
base_url: String,
|
||||
path: String,
|
||||
query: Vec<[String; 2]>,
|
||||
json_body: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}{}", base_url.trim_end_matches('/'), path);
|
||||
let client = provider_http_client()?;
|
||||
|
||||
let resp = if let Some(body) = json_body {
|
||||
client.post(&url).json(&body)
|
||||
} else {
|
||||
let q: Vec<(String, String)> = query.into_iter().map(|[k, v]| (k, v)).collect();
|
||||
client.get(&url).query(&q)
|
||||
}
|
||||
.header("User-Agent", subsonic_wire_user_agent())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = json
|
||||
.get("error")
|
||||
.and_then(|e| e.get("desc").or_else(|| e.get("type")))
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("");
|
||||
return Err(format!("Maloja {} {}", status.as_u16(), msg));
|
||||
}
|
||||
|
||||
Ok(json)
|
||||
@@ -495,4 +598,66 @@ mod tests {
|
||||
Some("https://pls.example/audio".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── audioscrobbler_request ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn audioscrobbler_request_uses_custom_base_url() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/2.0/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
||||
r#"{"similarartists":{"artist":[{"name":"Boards of Canada"}]}}"#,
|
||||
"application/json",
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let base = format!("{}/2.0/", server.uri());
|
||||
let json = audioscrobbler_request(
|
||||
base,
|
||||
vec![
|
||||
["method".into(), "artist.getSimilar".into()],
|
||||
["artist".into(), "Aphex Twin".into()],
|
||||
],
|
||||
false,
|
||||
true,
|
||||
"key".into(),
|
||||
"secret".into(),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
json["similarartists"]["artist"][0]["name"].as_str(),
|
||||
Some("Boards of Canada")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn audioscrobbler_request_surfaces_api_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wm_path("/2.0/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(
|
||||
r#"{"error":9,"message":"Invalid session key"}"#,
|
||||
"application/json",
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let base = format!("{}/2.0/", server.uri());
|
||||
let err = audioscrobbler_request(
|
||||
base,
|
||||
vec![["method".into(), "track.scrobble".into()]],
|
||||
true,
|
||||
false,
|
||||
"key".into(),
|
||||
"secret".into(),
|
||||
)
|
||||
.await
|
||||
.expect_err("api error should map to Err");
|
||||
|
||||
assert!(err.contains("Audioscrobbler 9"), "unexpected error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -248,6 +248,23 @@ CREATE TABLE play_session (
|
||||
CHECK (completion IN ('partial', 'full'))
|
||||
);
|
||||
|
||||
CREATE TABLE track_genre (
|
||||
server_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
genre TEXT NOT NULL,
|
||||
album_id TEXT,
|
||||
library_id TEXT,
|
||||
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
|
||||
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE library_data_migration (
|
||||
id TEXT PRIMARY KEY,
|
||||
cursor_rowid INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at INTEGER,
|
||||
started_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0;
|
||||
CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0;
|
||||
CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0;
|
||||
@@ -293,3 +310,7 @@ CREATE INDEX idx_play_session_started
|
||||
CREATE INDEX idx_track_fact_mood_tag
|
||||
ON track_fact(server_id, fact_kind, value_text, track_id)
|
||||
WHERE fact_kind = 'mood_tag';
|
||||
|
||||
CREATE INDEX idx_track_genre_browse
|
||||
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
|
||||
WHERE album_id IS NOT NULL AND album_id != '';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- psysonic-library schema v2 — large-library ingest policy (R7-15).
|
||||
-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a
|
||||
-- deep offset on a large catalog, the strategy selector stops choosing N1 for
|
||||
-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive
|
||||
-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall.
|
||||
ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Remap detection (§6.9) and unstable-id servers: without these indexes
|
||||
-- each upsert in a 500-row batch can scan the whole track table.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_remap_path
|
||||
ON track(server_id, server_path)
|
||||
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_remap_hash
|
||||
ON track(server_id, content_hash)
|
||||
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Browse / sort-by-title without sorting the full server slice on every page.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_title
|
||||
ON track(server_id, title COLLATE NOCASE)
|
||||
WHERE deleted = 0;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Advanced search filters on genre and year (partial indexes — only non-null rows).
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre
|
||||
ON track(server_id, genre COLLATE NOCASE)
|
||||
WHERE deleted = 0 AND genre IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_year
|
||||
ON track(server_id, year)
|
||||
WHERE deleted = 0 AND year IS NOT NULL;
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Player listening history — see workdocs player-stats spec §3.1
|
||||
CREATE TABLE play_session (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
listened_sec REAL NOT NULL,
|
||||
position_max_sec REAL NOT NULL,
|
||||
completion TEXT NOT NULL,
|
||||
end_reason TEXT NOT NULL,
|
||||
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
|
||||
CHECK (completion IN ('partial', 'full'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_play_session_server_time
|
||||
ON play_session(server_id, started_at_ms DESC);
|
||||
|
||||
CREATE INDEX idx_play_session_track
|
||||
ON play_session(server_id, track_id, started_at_ms DESC);
|
||||
|
||||
CREATE INDEX idx_play_session_started
|
||||
ON play_session(started_at_ms DESC);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Full-resync orphan sweep (mark-and-sweep via generation stamp).
|
||||
-- Rows ingested during a resync pass carry the active `resync_gen`; after
|
||||
-- IS-6 succeeds, live rows with a stale generation are soft-deleted.
|
||||
ALTER TABLE track ADD COLUMN resync_gen INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Atomic mood tags for Advanced Search (EXISTS on track_fact).
|
||||
CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag
|
||||
ON track_fact(server_id, fact_kind, value_text, track_id)
|
||||
WHERE fact_kind = 'mood_tag';
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
|
||||
DELETE FROM track_fact
|
||||
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Genre album browse: filter by (server, genre) then group by album_id.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre_album_browse
|
||||
ON track(server_id, genre COLLATE NOCASE, album_id)
|
||||
WHERE deleted = 0
|
||||
AND genre IS NOT NULL
|
||||
AND TRIM(genre) != ''
|
||||
AND album_id IS NOT NULL
|
||||
AND album_id != '';
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Genre album browse sort: (server, genre, album name, album_id) covering walk.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre_album_name_browse
|
||||
ON track(server_id, genre COLLATE NOCASE, album COLLATE NOCASE, album_id)
|
||||
WHERE deleted = 0
|
||||
AND genre IS NOT NULL
|
||||
AND TRIM(genre) != ''
|
||||
AND album_id IS NOT NULL
|
||||
AND album_id != '';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Repair for DBs that recorded legacy migrations 002–011 (removed) before
|
||||
-- multi-genre tables shipped. Safe on fresh installs (IF NOT EXISTS).
|
||||
CREATE TABLE IF NOT EXISTS track_genre (
|
||||
server_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
genre TEXT NOT NULL,
|
||||
album_id TEXT,
|
||||
library_id TEXT,
|
||||
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
|
||||
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre_browse
|
||||
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
|
||||
WHERE album_id IS NOT NULL AND album_id != '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS library_data_migration (
|
||||
id TEXT PRIMARY KEY,
|
||||
cursor_rowid INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at INTEGER,
|
||||
started_at INTEGER
|
||||
);
|
||||
@@ -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.
|
||||
@@ -49,6 +49,7 @@ type AlbumBrowseTrackRow = (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
@@ -526,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,
|
||||
@@ -648,8 +651,8 @@ fn build_album_from_fts(
|
||||
let where_sql = w.where_sql();
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!(
|
||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.album_artist, t.artist_id, \
|
||||
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
||||
FROM track t \
|
||||
WHERE {where_sql}"
|
||||
);
|
||||
@@ -668,13 +671,27 @@ fn build_album_from_fts(
|
||||
r.get(7)?,
|
||||
r.get(8)?,
|
||||
r.get(9)?,
|
||||
r.get(10)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
|
||||
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
|
||||
for (
|
||||
server_id,
|
||||
album_id,
|
||||
album,
|
||||
track_artist,
|
||||
album_artist,
|
||||
artist_id,
|
||||
year,
|
||||
genre,
|
||||
cover_art_id,
|
||||
starred_at,
|
||||
synced_at,
|
||||
) in rows
|
||||
{
|
||||
if !seen.insert(album_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
@@ -682,7 +699,10 @@ fn build_album_from_fts(
|
||||
server_id,
|
||||
id: album_id,
|
||||
name: album,
|
||||
artist,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(
|
||||
track_artist,
|
||||
album_artist,
|
||||
),
|
||||
artist_id,
|
||||
song_count: None,
|
||||
duration_sec: None,
|
||||
@@ -776,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,
|
||||
@@ -882,8 +908,25 @@ fn resolve_clause(
|
||||
|
||||
if c.field == "genre" {
|
||||
let v = json_to_text(&c.field, c.value.as_ref())?;
|
||||
let sql = match entity {
|
||||
EntityKind::Track => {
|
||||
"EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)"
|
||||
.to_string()
|
||||
}
|
||||
EntityKind::Album => {
|
||||
"EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = a.server_id AND tg.album_id = a.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)"
|
||||
.to_string()
|
||||
}
|
||||
_ => {
|
||||
return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string());
|
||||
}
|
||||
};
|
||||
return Ok(Some(SqlFragment {
|
||||
sql: format!("{col} = ? COLLATE NOCASE"),
|
||||
sql,
|
||||
params: vec![v],
|
||||
}));
|
||||
}
|
||||
@@ -1144,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),
|
||||
})
|
||||
}
|
||||
@@ -1176,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,
|
||||
@@ -1254,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()"),
|
||||
@@ -2067,6 +2117,24 @@ mod tests {
|
||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_grouped_album_browse_prefers_album_artist_over_track_artist() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut t1 = track("s1", "t1", "Anthem", "Groove Armada", "Back to Mine");
|
||||
t1.album_id = Some("al_mix".into());
|
||||
t1.album_artist = Some("Underworld".into());
|
||||
let mut t2 = track("s1", "t2", "Zebra", "UNKLE", "Back to Mine");
|
||||
t2.album_id = Some("al_mix".into());
|
||||
t2.album_artist = Some("Underworld".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[t1, t2])
|
||||
.unwrap();
|
||||
let r = req("s1", &[EntityKind::Album]);
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Underworld"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_on_track_grouped_album_browse() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -49,16 +49,30 @@ pub fn various_artists_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().contains("various artists")
|
||||
}
|
||||
|
||||
/// Track-grouped album rows: prefer album artist when it marks a VA compilation.
|
||||
/// SQL mirror of [`pick_album_group_artist`] for track-grouped browse subqueries
|
||||
/// (`la`). Used where `ORDER BY` / `COALESCE(a.artist, …)` must stay in SQL;
|
||||
/// keep both implementations in sync.
|
||||
pub fn sql_track_group_display_artist(alias: &str) -> String {
|
||||
format!(
|
||||
"CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \
|
||||
THEN trim({a}.album_artist) \
|
||||
ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END",
|
||||
a = alias
|
||||
)
|
||||
}
|
||||
|
||||
/// Row-mapper form of the album-artist display rule — mirror of
|
||||
/// [`sql_track_group_display_artist`]. Prefer a non-empty album-artist tag;
|
||||
/// fall back to track artist only when album artist is absent (solo albums without TALB).
|
||||
pub fn pick_album_group_artist(
|
||||
track_artist: Option<String>,
|
||||
album_artist: Option<String>,
|
||||
) -> Option<String> {
|
||||
let aa = album_artist.as_deref().unwrap_or("").trim();
|
||||
if various_artists_label(aa) {
|
||||
if !aa.is_empty() {
|
||||
return Some(aa.to_string());
|
||||
}
|
||||
track_artist
|
||||
track_artist.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -81,14 +95,70 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_album_group_artist_prefers_va_album_artist() {
|
||||
fn pick_album_group_artist_prefers_nonempty_album_artist() {
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
|
||||
Some("Various Artists".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Groove Armada".into()), Some("Underworld".into())),
|
||||
Some("Underworld".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
|
||||
Some("Alice".to_string())
|
||||
Some("Bob".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_album_group_artist_falls_back_to_track_artist() {
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), None),
|
||||
Some("Alice".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pick_album_group_artist(Some("Alice".into()), Some("".into())),
|
||||
Some("Alice".to_string())
|
||||
);
|
||||
assert_eq!(pick_album_group_artist(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_track_group_display_artist_matches_pick_album_group_artist() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE la (artist TEXT, album_artist TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let sql = format!("SELECT {} FROM la", sql_track_group_display_artist("la"));
|
||||
|
||||
let cases: [(&str, &str); 7] = [
|
||||
("Groove Armada", "Underworld"),
|
||||
("Alice", ""),
|
||||
("", "Various Artists"),
|
||||
("Alice", "Bob"),
|
||||
(" ", "Bob"),
|
||||
("Alice", " "),
|
||||
("", ""),
|
||||
];
|
||||
|
||||
for (track, album) in cases {
|
||||
conn.execute("DELETE FROM la", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO la (artist, album_artist) VALUES (?1, ?2)",
|
||||
rusqlite::params![track, album],
|
||||
)
|
||||
.unwrap();
|
||||
let sql_out: Option<String> = conn.query_row(&sql, [], |r| r.get(0)).ok();
|
||||
let rust_out = pick_album_group_artist(
|
||||
(!track.is_empty()).then(|| track.to_string()),
|
||||
(!album.is_empty()).then(|| album.to_string()),
|
||||
);
|
||||
assert_eq!(
|
||||
sql_out, rust_out,
|
||||
"track={track:?} album={album:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,13 @@ pub fn get_artist_lossless_browse(
|
||||
}
|
||||
let album_where_sql = album_where.join(" AND ");
|
||||
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let albums_sql = format!(
|
||||
"SELECT \
|
||||
la.server_id, \
|
||||
la.album_id, \
|
||||
COALESCE(a.name, la.album_name), \
|
||||
COALESCE(a.artist, la.artist), \
|
||||
COALESCE(a.artist, {la_artist}), \
|
||||
COALESCE(a.artist_id, la.artist_id), \
|
||||
COALESCE(a.song_count, la.track_count), \
|
||||
COALESCE(a.duration_sec, la.duration_sec), \
|
||||
@@ -102,6 +103,7 @@ pub fn get_artist_lossless_browse(
|
||||
t.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -113,13 +113,26 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut sql = String::from(
|
||||
"SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \
|
||||
FROM track t \
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0 \
|
||||
AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != ''",
|
||||
);
|
||||
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
|
||||
let mut sql = if scoped {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
};
|
||||
let mut params: Vec<rusqlite::types::Value> =
|
||||
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
@@ -127,8 +140,8 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
params.push(rusqlite::types::Value::Text(scope.to_string()));
|
||||
}
|
||||
sql.push_str(
|
||||
" GROUP BY t.genre COLLATE NOCASE \
|
||||
ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC",
|
||||
" GROUP BY tg.genre COLLATE NOCASE \
|
||||
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
@@ -337,6 +350,26 @@ mod tests {
|
||||
assert_eq!(counts[0].song_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_scope_reads_library_id_from_track_raw_json() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut scoped = make_row("s1", "r1", "al_a", 1);
|
||||
scoped.genre = Some("Rock".into());
|
||||
scoped.library_id = None;
|
||||
scoped.raw_json = r#"{"libraryId":"lib1"}"#.into();
|
||||
let mut other = make_row("s1", "r2", "al_b", 1);
|
||||
other.genre = Some("Rock".into());
|
||||
other.library_id = None;
|
||||
other.raw_json = r#"{"libraryId":"lib2"}"#.into();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_album_stars_clears_all_when_server_list_empty() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
|
||||
@@ -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,
|
||||
@@ -526,6 +529,23 @@ pub async fn library_list_albums_by_genre(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_genre_tags_inspect(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
|
||||
crate::genre_tags_backfill::inspect_genre_tags_backfill(&runtime.store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_genre_tags_run(
|
||||
app: tauri::AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
) -> Result<(), String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || crate::genre_tags_backfill::run_genre_tags_backfill(&store, &app))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_artist_lossless_browse(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -605,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>,
|
||||
@@ -637,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;
|
||||
@@ -657,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,
|
||||
@@ -670,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),
|
||||
@@ -689,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,
|
||||
@@ -699,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,
|
||||
)
|
||||
@@ -852,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(),
|
||||
@@ -875,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);
|
||||
}
|
||||
@@ -899,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);
|
||||
}
|
||||
@@ -1627,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"));
|
||||
}
|
||||
|
||||
@@ -1644,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,
|
||||
|
||||
@@ -19,19 +19,20 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for s in sort {
|
||||
let col = match s.field.as_str() {
|
||||
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
|
||||
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
|
||||
"year" => "COALESCE(a.year, la.year)",
|
||||
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE".to_string(),
|
||||
"artist" => format!("COALESCE(a.artist, {la_artist}) COLLATE NOCASE"),
|
||||
"year" => "COALESCE(a.year, la.year)".to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
SortDir::Asc => "ASC",
|
||||
SortDir::Desc => "DESC",
|
||||
};
|
||||
keys.push(format!("{col} {dir}"));
|
||||
keys.push(format!("{col} {dir}", col = col));
|
||||
}
|
||||
if keys.is_empty() {
|
||||
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
|
||||
@@ -44,10 +45,16 @@ fn count_genre_albums(
|
||||
conn: &rusqlite::Connection,
|
||||
where_sql: &str,
|
||||
params: &[SqlValue],
|
||||
library_scoped: bool,
|
||||
) -> Result<u32, rusqlite::Error> {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}"
|
||||
);
|
||||
let from = if library_scoped {
|
||||
"FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0"
|
||||
} else {
|
||||
"FROM track_genre tg"
|
||||
};
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
|
||||
let n: i64 = conn.query_row(
|
||||
&count_sql,
|
||||
rusqlite::params_from_iter(params.iter()),
|
||||
@@ -107,28 +114,29 @@ pub fn list_albums_by_genre(
|
||||
let order_sql = genre_album_order_sql(&req.sort);
|
||||
|
||||
let mut where_clauses = vec![
|
||||
"t.deleted = 0".to_string(),
|
||||
"t.server_id = ?1".to_string(),
|
||||
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
|
||||
"t.genre = ?2 COLLATE NOCASE".to_string(),
|
||||
"tg.server_id = ?1".to_string(),
|
||||
"tg.album_id IS NOT NULL AND tg.album_id != ''".to_string(),
|
||||
"tg.genre = ?2 COLLATE NOCASE".to_string(),
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(genre.to_string()),
|
||||
];
|
||||
|
||||
let library_scoped = trimmed_nonempty(req.library_scope.as_deref()).is_some();
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
where_clauses.push(library_scope_equals_sql("t"));
|
||||
params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let sql = format!(
|
||||
"SELECT \
|
||||
la.server_id, \
|
||||
la.album_id, \
|
||||
COALESCE(a.name, la.album_name), \
|
||||
COALESCE(a.artist, la.artist), \
|
||||
COALESCE(a.artist, {la_artist}), \
|
||||
COALESCE(a.artist_id, la.artist_id), \
|
||||
COALESCE(a.song_count, la.track_count), \
|
||||
COALESCE(a.duration_sec, la.duration_sec), \
|
||||
@@ -140,10 +148,11 @@ pub fn list_albums_by_genre(
|
||||
a.raw_json \
|
||||
FROM ( \
|
||||
SELECT \
|
||||
t.server_id, \
|
||||
t.album_id, \
|
||||
tg.server_id, \
|
||||
tg.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
@@ -152,9 +161,11 @@ pub fn list_albums_by_genre(
|
||||
MAX(t.synced_at) AS synced_at, \
|
||||
COUNT(*) AS track_count, \
|
||||
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
|
||||
FROM track t \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE {where_sql} \
|
||||
GROUP BY t.server_id, t.album_id \
|
||||
GROUP BY tg.server_id, tg.album_id \
|
||||
) la \
|
||||
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
|
||||
{order_sql} \
|
||||
@@ -167,7 +178,7 @@ pub fn list_albums_by_genre(
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let total = if req.include_total {
|
||||
Some(count_genre_albums(conn, &where_sql, &count_params)?)
|
||||
Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -282,4 +293,49 @@ mod tests {
|
||||
assert_eq!(all.total, Some(3));
|
||||
assert!(all.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_albums_by_atomic_genre_from_compound_tag() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track(
|
||||
"s1",
|
||||
"t1",
|
||||
"al_a",
|
||||
"Noise Metal/Dark Ambient/Experimental Black Metal",
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
let dark = list_albums_by_genre(
|
||||
&store,
|
||||
&LibraryGenreAlbumsRequest {
|
||||
server_id: "s1".into(),
|
||||
genre: "Dark Ambient".into(),
|
||||
library_scope: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
include_total: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(dark.total, Some(1));
|
||||
assert_eq!(dark.albums.len(), 1);
|
||||
assert_eq!(dark.albums[0].id, "al_a");
|
||||
|
||||
let noise = list_albums_by_genre(
|
||||
&store,
|
||||
&LibraryGenreAlbumsRequest {
|
||||
server_id: "s1".into(),
|
||||
genre: "Noise Metal".into(),
|
||||
library_scope: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
include_total: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(noise.total, Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Atomic genre resolution for multi-value tags (OpenSubsonic `genres[]` first,
|
||||
//! Navidrome-default string split as fallback).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{params, Transaction};
|
||||
use serde_json::Value;
|
||||
|
||||
const GENRE_SEPARATORS: [&str; 3] = [";", "/", ","];
|
||||
|
||||
/// Fallback split when the server sent no `genres[]` array (legacy Subsonic).
|
||||
pub fn split_genre_tags(raw: &str) -> Vec<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = vec![trimmed.to_string()];
|
||||
for sep in GENRE_SEPARATORS {
|
||||
let mut next = Vec::new();
|
||||
for part in parts {
|
||||
for sub in part.split(sep) {
|
||||
next.push(sub.to_string());
|
||||
}
|
||||
}
|
||||
parts = next;
|
||||
}
|
||||
dedupe_genres(parts)
|
||||
}
|
||||
|
||||
fn dedupe_genres(genres: Vec<String>) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for g in genres {
|
||||
let t = g.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = t.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_genres_array_value(value: &Value) -> Option<Vec<String>> {
|
||||
let arr = value.as_array()?;
|
||||
if arr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for item in arr {
|
||||
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
|
||||
let t = name.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
} else if let Some(s) = item.as_str() {
|
||||
let t = s.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(dedupe_genres(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_genres_json_str(genres_json: &str) -> Option<Vec<String>> {
|
||||
let trimmed = genres_json.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let value: Value = serde_json::from_str(trimmed).ok()?;
|
||||
parse_genres_array_value(&value)
|
||||
}
|
||||
|
||||
/// Source-priority resolver (§2.0): `genres[]` from parsed payload, else split `genre`.
|
||||
pub fn genres_for_track_value(raw_json: &Value, genre: Option<&str>) -> Vec<String> {
|
||||
if let Some(genres) = raw_json.get("genres").and_then(parse_genres_array_value) {
|
||||
return genres;
|
||||
}
|
||||
genre
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(split_genre_tags)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Backfill path: `genres_json` from `json_extract(raw_json, '$.genres')`.
|
||||
pub fn genres_for_track_extracted(genres_json: Option<&str>, genre: Option<&str>) -> Vec<String> {
|
||||
if let Some(json) = genres_json {
|
||||
if let Some(genres) = parse_genres_json_str(json) {
|
||||
return genres;
|
||||
}
|
||||
}
|
||||
genre
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(split_genre_tags)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn genres_for_track_raw_json(raw_json: &str, genre: Option<&str>) -> Vec<String> {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(raw_json) {
|
||||
return genres_for_track_value(&value, genre);
|
||||
}
|
||||
genre
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(split_genre_tags)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn replace_track_genre_rows(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
album_id: Option<&str>,
|
||||
library_id: Option<&str>,
|
||||
genres: &[String],
|
||||
) -> rusqlite::Result<()> {
|
||||
tx.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
params![server_id, track_id],
|
||||
)?;
|
||||
if genres.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO track_genre (server_id, track_id, genre, album_id, library_id) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
for genre in genres {
|
||||
insert.execute(params![server_id, track_id, genre, album_id, library_id])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_track_genre_for_track(
|
||||
conn: &rusqlite::Connection,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
params![server_id, track_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_track_genre_for_server_tracks(
|
||||
conn: &rusqlite::Connection,
|
||||
server_id: &str,
|
||||
track_ids: &[String],
|
||||
) -> rusqlite::Result<()> {
|
||||
if track_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for id in track_ids {
|
||||
delete_track_genre_for_track(conn, server_id, id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn split_separators_and_dedupe() {
|
||||
assert_eq!(
|
||||
split_genre_tags("Rock/Jazz"),
|
||||
vec!["Rock".to_string(), "Jazz".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
split_genre_tags("Rock; Jazz, Electronic"),
|
||||
vec![
|
||||
"Rock".to_string(),
|
||||
"Jazz".to_string(),
|
||||
"Electronic".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(split_genre_tags("Rock/rock/ROCK"), vec!["Rock".to_string()]);
|
||||
assert!(split_genre_tags("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_wins_over_genre_string() {
|
||||
let raw = json!({
|
||||
"genres": [{"name": "A"}, {"name": "B"}],
|
||||
"genre": "A/B/C"
|
||||
});
|
||||
assert_eq!(
|
||||
genres_for_track_value(&raw, Some("A/B/C")),
|
||||
vec!["A".to_string(), "B".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_string_array_and_empty_array_fallback() {
|
||||
let bare = json!({ "genres": ["A", "B"] });
|
||||
assert_eq!(
|
||||
genres_for_track_value(&bare, None),
|
||||
vec!["A".to_string(), "B".to_string()]
|
||||
);
|
||||
let empty = json!({ "genres": [], "genre": "A/B" });
|
||||
assert_eq!(
|
||||
genres_for_track_value(&empty, Some("A/B")),
|
||||
vec!["A".to_string(), "B".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_json_matches_value_path() {
|
||||
let genres_json = r#"[{"name":"Jazz"},{"name":"Rock"}]"#;
|
||||
assert_eq!(
|
||||
genres_for_track_extracted(Some(genres_json), Some("Noise/Metal")),
|
||||
vec!["Jazz".to_string(), "Rock".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! One-time blocking backfill: populate `track_genre` from existing `track` rows.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::genre_tags::{genres_for_track_extracted, replace_track_genre_rows};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
pub const GENRE_TAGS_MIGRATION_ID: &str = "genre_tags_v1";
|
||||
|
||||
const BATCH_SIZE: i64 = 10_000;
|
||||
|
||||
type BackfillTrackRow = (
|
||||
i64,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
|
||||
crate::store::ensure_genre_tags_schema(conn)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreTagsInspectDto {
|
||||
pub needed: bool,
|
||||
pub total_tracks: u64,
|
||||
pub done_tracks: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreTagsProgressEvent {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn migration_completed(conn: &Connection) -> Result<bool, rusqlite::Error> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
fn count_live_tracks(conn: &Connection) -> Result<u64, rusqlite::Error> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n.max(0) as u64)
|
||||
}
|
||||
|
||||
fn cursor_rowid(conn: &Connection) -> Result<i64, rusqlite::Error> {
|
||||
let rowid: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(rowid.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub fn inspect_genre_tags_backfill(store: &LibraryStore) -> Result<GenreTagsInspectDto, String> {
|
||||
store.with_conn_mut("genre_tags.ensure_schema", ensure_genre_tags_tables)?;
|
||||
store.with_read_conn(|conn| {
|
||||
let total_tracks = count_live_tracks(conn)?;
|
||||
if total_tracks == 0 {
|
||||
return Ok(GenreTagsInspectDto {
|
||||
needed: false,
|
||||
total_tracks: 0,
|
||||
done_tracks: 0,
|
||||
});
|
||||
}
|
||||
if migration_completed(conn)? {
|
||||
return Ok(GenreTagsInspectDto {
|
||||
needed: false,
|
||||
total_tracks,
|
||||
done_tracks: total_tracks,
|
||||
});
|
||||
}
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![cursor],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(GenreTagsInspectDto {
|
||||
needed: true,
|
||||
total_tracks,
|
||||
done_tracks: done.max(0) as u64,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, done: u64, total: u64) -> Result<(), String> {
|
||||
app.emit(
|
||||
"genre_tags:progress",
|
||||
GenreTagsProgressEvent { done, total },
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn run_genre_tags_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> {
|
||||
run_genre_tags_backfill_impl(store, Some(app))
|
||||
}
|
||||
|
||||
fn run_genre_tags_backfill_impl(
|
||||
store: &LibraryStore,
|
||||
app: Option<&AppHandle>,
|
||||
) -> Result<(), String> {
|
||||
let inspect = inspect_genre_tags_backfill(store)?;
|
||||
if !inspect.needed {
|
||||
return Ok(());
|
||||
}
|
||||
let total = inspect.total_tracks;
|
||||
|
||||
loop {
|
||||
let (batch_done, finished) = store.with_conn_mut("genre_tags.backfill", |conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok::<(i64, bool), rusqlite::Error>((total as i64, true));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
|
||||
VALUES (?1, 0, ?2) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
started_at = COALESCE(library_data_migration.started_at, excluded.started_at)",
|
||||
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
|
||||
)?;
|
||||
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rowid, server_id, id, genre, \
|
||||
CASE WHEN json_valid(raw_json) THEN json_extract(raw_json, '$.genres') END, \
|
||||
album_id, library_id \
|
||||
FROM track \
|
||||
WHERE deleted = 0 AND rowid > ?1 \
|
||||
ORDER BY rowid \
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
|
||||
let rows: Vec<BackfillTrackRow> =
|
||||
stmt
|
||||
.query_map(params![cursor, BATCH_SIZE], |r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
r.get(3)?,
|
||||
r.get(4)?,
|
||||
r.get(5)?,
|
||||
r.get(6)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
if rows.is_empty() {
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
|
||||
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
|
||||
WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
|
||||
)?;
|
||||
return Ok((total as i64, true));
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut last_rowid = cursor;
|
||||
for (rowid, server_id, track_id, genre, genres_json, album_id, library_id) in rows {
|
||||
let genres = genres_for_track_extracted(
|
||||
genres_json.as_deref(),
|
||||
genre.as_deref(),
|
||||
);
|
||||
replace_track_genre_rows(
|
||||
&tx,
|
||||
&server_id,
|
||||
&track_id,
|
||||
album_id.as_deref(),
|
||||
library_id.as_deref(),
|
||||
&genres,
|
||||
)?;
|
||||
last_rowid = rowid;
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID, last_rowid],
|
||||
)?;
|
||||
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![last_rowid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((done, false))
|
||||
})?;
|
||||
|
||||
if let Some(app) = app {
|
||||
emit_progress(app, batch_done.max(0) as u64, total)?;
|
||||
}
|
||||
|
||||
if finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: all live tracks processed but `completed_at` not set
|
||||
// (can happen when rowid gaps from soft-deletes make done == total early).
|
||||
store.with_conn_mut("genre_tags.backfill.finalize", |conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let pending: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid > ?1",
|
||||
params![cursor],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if pending == 0 {
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
|
||||
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
|
||||
WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::track::{TrackRepository, TrackRow};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
fn track(server_id: &str, id: &str, genre: &str, deleted: bool) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server_id.into(),
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: None,
|
||||
album: "Album".into(),
|
||||
album_id: Some("al1".into()),
|
||||
album_artist: None,
|
||||
duration_sec: 100,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: None,
|
||||
genre: Some(genre.into()),
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: Some("lib1".into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_marks_complete_when_rowid_gaps_leave_pending_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let live: Vec<TrackRow> = (1..=5)
|
||||
.map(|n| track("s1", &format!("t{n}"), "Rock", false))
|
||||
.collect();
|
||||
let mut batch = live;
|
||||
for n in 6..=20 {
|
||||
batch.push(track("s1", &format!("del{n}"), "Rock", true));
|
||||
}
|
||||
batch.push(track("s1", "t6", "Jazz", false));
|
||||
TrackRepository::new(&store).upsert_batch(&batch).unwrap();
|
||||
|
||||
run_genre_tags_backfill_impl(&store, None).unwrap();
|
||||
|
||||
let inspect = inspect_genre_tags_backfill(&store).unwrap();
|
||||
assert!(!inspect.needed, "backfill should complete despite rowid gaps");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -25,6 +27,8 @@ pub mod dto;
|
||||
pub mod enrichment;
|
||||
pub mod filter;
|
||||
pub mod genre_album_browse;
|
||||
pub mod genre_tags;
|
||||
pub mod genre_tags_backfill;
|
||||
pub mod mood_groups;
|
||||
pub mod live_search;
|
||||
pub mod lossless_albums;
|
||||
|
||||
@@ -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,
|
||||
@@ -244,8 +245,9 @@ fn query_albums(
|
||||
ORDER BY rank \
|
||||
LIMIT ?\
|
||||
) \
|
||||
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \
|
||||
SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.album_artist), \
|
||||
MAX(t.artist_id), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
|
||||
MAX(t.starred_at), MAX(t.synced_at), MIN(h.rank) AS best_rank \
|
||||
FROM fts_hits h \
|
||||
JOIN track t ON t.rowid = h.rowid \
|
||||
WHERE t.server_id = ? \
|
||||
@@ -261,24 +263,29 @@ fn query_albums(
|
||||
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
|
||||
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
||||
append_library_scope(&mut sql, &mut params, library_scope);
|
||||
sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?");
|
||||
sql.push_str(" GROUP BY t.server_id, t.album_id ORDER BY best_rank LIMIT ?");
|
||||
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut out = Vec::new();
|
||||
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
let track_artist: Option<String> = r.get(3)?;
|
||||
let album_artist: Option<String> = r.get(4)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(
|
||||
track_artist,
|
||||
album_artist,
|
||||
),
|
||||
artist_id: r.get(5)?,
|
||||
song_count: None,
|
||||
duration_sec: None,
|
||||
year: r.get(5)?,
|
||||
genre: r.get(6)?,
|
||||
cover_art_id: r.get(7)?,
|
||||
starred_at: r.get(8)?,
|
||||
synced_at: r.get(9)?,
|
||||
year: r.get(6)?,
|
||||
genre: r.get(7)?,
|
||||
cover_art_id: r.get(8)?,
|
||||
starred_at: r.get(9)?,
|
||||
synced_at: r.get(10)?,
|
||||
raw_json: serde_json::Value::Null,
|
||||
})
|
||||
})? {
|
||||
|
||||
@@ -44,12 +44,13 @@ pub fn list_lossless_albums(
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let sql = format!(
|
||||
"SELECT \
|
||||
la.server_id, \
|
||||
la.album_id, \
|
||||
COALESCE(a.name, la.album_name), \
|
||||
COALESCE(a.artist, la.artist), \
|
||||
COALESCE(a.artist, {la_artist}), \
|
||||
COALESCE(a.artist_id, la.artist_id), \
|
||||
COALESCE(a.song_count, la.track_count), \
|
||||
COALESCE(a.duration_sec, la.duration_sec), \
|
||||
@@ -65,6 +66,7 @@ pub fn list_lossless_albums(
|
||||
t.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use rusqlite::{params, OptionalExtension, Transaction};
|
||||
|
||||
use crate::genre_tags::{self, genres_for_track_raw_json};
|
||||
use crate::store::{LibraryStore, WriteOpTiming};
|
||||
|
||||
fn sync_track_genre_row(tx: &Transaction<'_>, row: &TrackRow) -> rusqlite::Result<()> {
|
||||
if row.deleted {
|
||||
return genre_tags::delete_track_genre_for_track(tx, &row.server_id, &row.id);
|
||||
}
|
||||
let genres = genres_for_track_raw_json(&row.raw_json, row.genre.as_deref());
|
||||
genre_tags::replace_track_genre_rows(
|
||||
tx,
|
||||
&row.server_id,
|
||||
&row.id,
|
||||
row.album_id.as_deref(),
|
||||
row.library_id.as_deref(),
|
||||
&genres,
|
||||
)
|
||||
}
|
||||
|
||||
/// One row of the `track` table — every hot column from spec §5.1 plus
|
||||
/// `raw_json` (the full normalized SubsonicSong). Sync code (PR-2/PR-3) is
|
||||
/// expected to project ingested payloads into this shape, not to talk SQL
|
||||
@@ -181,6 +197,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.raw_json,
|
||||
])?;
|
||||
}
|
||||
sync_track_genre_row(&tx, r)?;
|
||||
}
|
||||
drop(upsert);
|
||||
tx.commit()?;
|
||||
@@ -204,6 +221,14 @@ impl<'a> TrackRepository<'a> {
|
||||
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
|
||||
let now = now_unix_ms();
|
||||
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
|
||||
c.execute(
|
||||
"DELETE FROM track_genre \
|
||||
WHERE server_id = ?1 AND track_id IN ( \
|
||||
SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2 \
|
||||
)",
|
||||
params![server_id, resync_gen],
|
||||
)?;
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
|
||||
@@ -423,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
|
||||
};
|
||||
@@ -434,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,
|
||||
@@ -477,6 +506,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.synced_at,
|
||||
r.raw_json,
|
||||
])?;
|
||||
sync_track_genre_row(&tx, r)?;
|
||||
|
||||
if let Some(old_id) = detected_old {
|
||||
remap_existing_to_new(
|
||||
@@ -517,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
|
||||
@@ -1043,8 +1111,9 @@ mod tests {
|
||||
repo.upsert_batch_initial_ingest(&rows).unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(500),
|
||||
"initial ingest batch(500) took {elapsed:?}"
|
||||
elapsed < std::time::Duration::from_millis(1000),
|
||||
"initial ingest batch(500) took {elapsed:?}; includes per-row track_genre \
|
||||
maintenance and large raw_json payloads"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1220,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 = 1;
|
||||
///
|
||||
/// 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
|
||||
@@ -22,10 +28,31 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
|
||||
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
|
||||
|
||||
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
|
||||
/// Version 12 is above the removed legacy migrations 002–011 so existing DBs
|
||||
/// 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)];
|
||||
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.
|
||||
pub(crate) fn ensure_genre_tags_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(MIGRATION_012_TRACK_GENRE_LEGACY)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MigrationOutcome {
|
||||
@@ -52,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 {
|
||||
@@ -65,9 +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())?;
|
||||
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())?;
|
||||
@@ -75,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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,13 +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");
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,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
|
||||
@@ -114,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
|
||||
@@ -131,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>(
|
||||
@@ -152,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 }))
|
||||
@@ -172,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,
|
||||
@@ -182,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())?;
|
||||
@@ -217,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())?;
|
||||
@@ -256,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(())
|
||||
}
|
||||
}
|
||||
@@ -290,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");
|
||||
@@ -409,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,
|
||||
@@ -457,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)
|
||||
}
|
||||
@@ -556,8 +874,7 @@ mod tests {
|
||||
rows
|
||||
})
|
||||
.unwrap();
|
||||
// Embedded migrations are numbered 1..=head, all applied on a fresh DB.
|
||||
let expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
|
||||
let expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
|
||||
assert_eq!(versions, expected);
|
||||
}
|
||||
|
||||
@@ -574,11 +891,53 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count, LIBRARY_DB_SCHEMA_VERSION,
|
||||
count,
|
||||
MIGRATIONS.len() as i64,
|
||||
"one schema_migrations row per embedded migration, no duplicates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_012_repairs_db_that_recorded_legacy_versions_without_genre_tables() {
|
||||
let uri = in_memory_uri();
|
||||
let conn = Connection::open(&uri).expect("connection");
|
||||
configure_write_connection(&conn).expect("pragmas");
|
||||
conn.execute_batch(INITIAL_SQL).expect("initial");
|
||||
conn.execute("DROP TABLE IF EXISTS track_genre", [])
|
||||
.expect("drop track_genre");
|
||||
conn.execute("DROP TABLE IF EXISTS library_data_migration", [])
|
||||
.expect("drop cursor table");
|
||||
for version in 1..=11_i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?1)",
|
||||
params![version],
|
||||
)
|
||||
.expect("seed legacy versions");
|
||||
}
|
||||
|
||||
let outcome = run_migrations_with(
|
||||
&conn,
|
||||
MIGRATIONS,
|
||||
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||||
no_op_hook,
|
||||
)
|
||||
.expect("apply v12 repair");
|
||||
assert_eq!(outcome, MigrationOutcome::Applied);
|
||||
ensure_genre_tags_schema(&conn).expect("ensure");
|
||||
|
||||
for table in ["track_genre", "library_data_migration"] {
|
||||
let exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master \
|
||||
WHERE type = 'table' AND name = ?1",
|
||||
params![table],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("table probe");
|
||||
assert_eq!(exists, 1, "missing table {table}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_virtual_table_exists() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
@@ -656,8 +1015,7 @@ mod tests {
|
||||
rows
|
||||
})
|
||||
.unwrap();
|
||||
// Real embedded migrations (1..=head) plus the additive fixture.
|
||||
let mut expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
|
||||
let mut expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
|
||||
expected.push(FIXTURE_ADD_BIO_VERSION);
|
||||
assert_eq!(versions, expected);
|
||||
}
|
||||
@@ -722,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());
|
||||
}
|
||||
|
||||
@@ -139,6 +139,10 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
rusqlite::params![self.server_id, id, now_unix_ms()],
|
||||
)?;
|
||||
c.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
rusqlite::params![self.server_id, id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
|
||||
+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()));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user