mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
139 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 | |||
| 36a0615dcb | |||
| 98bdf310d6 | |||
| 155ef88cc0 | |||
| 316c99ba07 | |||
| c33d1e64c5 | |||
| 276903d0af | |||
| a151cf5deb | |||
| ad8e376c9c | |||
| b2a5baa48d | |||
| cfc9419de7 | |||
| c6298d8c25 | |||
| 37089ea0f1 | |||
| 6118b3940f | |||
| 0878cbf308 | |||
| 68f0f09aae | |||
| 1b4fb9e9b3 | |||
| 5167d8f49e | |||
| 086c7e43b4 | |||
| 32832246c0 | |||
| e527cfe67f | |||
| 30e9db1a2b | |||
| 49ad3618a8 | |||
| 4148e063dd | |||
| 88f7a7bc90 | |||
| daa6fbbfd7 | |||
| aabd342a64 | |||
| fc34a0ec59 | |||
| f4e1086131 | |||
| 23f032b274 | |||
| aad1a6c3f0 | |||
| 10fc489ce9 | |||
| b3573e7107 | |||
| 9fb0d5638b | |||
| f9df918c72 | |||
| 03a1ba9582 | |||
| 2d3c723a6e | |||
| 40db6b08d2 | |||
| a66d932afe | |||
| f706336e58 | |||
| 1c23305887 | |||
| 41157ccaca | |||
| dc4eef1a97 |
@@ -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
|
||||
|
||||
|
||||
+686
@@ -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
|
||||
@@ -21,6 +302,142 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — rebuilt for much lower CPU/RAM
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
|
||||
|
||||
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
|
||||
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
|
||||
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
|
||||
|
||||
|
||||
|
||||
### Queue — Timeline display mode
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
|
||||
|
||||
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
|
||||
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
|
||||
|
||||
|
||||
|
||||
### Offline — unified local playback, library index join, and favorites sync
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
|
||||
|
||||
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
|
||||
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
|
||||
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
|
||||
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
|
||||
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
|
||||
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
|
||||
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
|
||||
|
||||
|
||||
|
||||
### Themes — community Theme Store
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
|
||||
|
||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
||||
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
||||
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
|
||||
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
|
||||
* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
|
||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||
|
||||
|
||||
|
||||
### Offline — local-bytes browse when the server is down
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
|
||||
|
||||
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
|
||||
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
|
||||
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
|
||||
* PlayerBar hides star rating and favorite controls while offline browse is active.
|
||||
|
||||
|
||||
|
||||
### Startup — themed loading splash before the app bundle loads
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)**
|
||||
|
||||
* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch.
|
||||
* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset.
|
||||
* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility.
|
||||
|
||||
|
||||
|
||||
### Servers — software and version on each server card
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1045](https://github.com/Psychotoxical/psysonic/pull/1045)**
|
||||
|
||||
* Each server card under **Settings → Servers** now shows the server software and version (e.g. `Navidrome 0.62.0`) under the server name. The value comes from the existing connection ping, so no extra request is made; it is hidden for servers that don't report it (plain Subsonic without OpenSubsonic).
|
||||
|
||||
|
||||
|
||||
### 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
|
||||
@@ -41,6 +458,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Playback — Preload Next Track setting removed
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
|
||||
|
||||
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
|
||||
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
|
||||
|
||||
|
||||
|
||||
### PsyLab — Performance Probe rename, Tuning tab, and log tools
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)**
|
||||
|
||||
* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab.
|
||||
* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection.
|
||||
* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable.
|
||||
|
||||
|
||||
|
||||
### PsyLab — Connections tab and Navidrome admin role
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* New **Connections** tab: session/endpoint status, active-server capability readout (OpenSubsonic, AudioMuse detection, provider/strategy, detection trust, resolved call route, and AudioMuse mode), and queue-playback server when it differs from the active profile.
|
||||
* Navidrome **admin vs standard user** badge via native login probe — useful when diagnosing plugin/settings visibility.
|
||||
|
||||
|
||||
|
||||
### Servers — capability framework with AudioMuse sonic routing (Navidrome ≥ 0.62)
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* New declarative **server-capability framework** (`src/serverCapabilities/`): a catalog picks a feature strategy per server generation, runs only the needed probes, and routes API calls — replacing scattered version checks in the UI and call sites.
|
||||
* Navidrome **0.62+**: detect the AudioMuse-AI plugin from `getOpenSubsonicExtensions` when `sonicSimilarity` is advertised — the first reliable signal. Settings shows an **auto-managed status indicator** (no manual toggle); older Navidrome keeps the manual toggle and the legacy `getSimilarSongs` Instant Mix probe.
|
||||
* **Path routing**: Instant Mix and Lucky Mix prefer the OpenSubsonic `getSonicSimilarTracks` endpoint when the plugin is present, falling back to legacy `getSimilarSongs`.
|
||||
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
### Servers — complete border on the active server card
|
||||
@@ -60,6 +525,227 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### 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)**
|
||||
|
||||
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
|
||||
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
|
||||
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
|
||||
|
||||
|
||||
|
||||
### Mainstage — hero backdrop stays in sync when skipping albums quickly
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
|
||||
|
||||
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
|
||||
|
||||
|
||||
|
||||
### Song rails — multi-artist credits link to each artist
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1023](https://github.com/Psychotoxical/psysonic/pull/1023)**
|
||||
|
||||
* **Random Picks**, **Discover Songs**, and other song cards now split OpenSubsonic `artists[]` into individually clickable names — the same behaviour as album track rows and the player bar, instead of one link for the whole joined credit string.
|
||||
* Album cards and the rest of the app share the same artist-ref helper, including when Subsonic returns a single ref object instead of a one-element array.
|
||||
|
||||
|
||||
|
||||
### Fullscreen player — corner clock follows Clock format setting
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1025](https://github.com/Psychotoxical/psysonic/pull/1025)**
|
||||
|
||||
* The wall clock in the fullscreen player now honours **Settings → System → Clock format** (24-hour vs 12-hour), matching the queue ETA and sleep-timer preview instead of always showing AM/PM.
|
||||
|
||||
|
||||
|
||||
### All Albums — Only compilations filter returns results
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1026](https://github.com/Psychotoxical/psysonic/pull/1026)**
|
||||
|
||||
* The **Only compilations** toggle on **All Albums** no longer returns an empty list when compilations are tagged via **Various Artists** as album artist or when genre is combined with other browse filters — local index SQL, track-grouped browse, and client-side detection now agree on the same compilation signals.
|
||||
|
||||
|
||||
|
||||
### Artist page — Top Tracks play button
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1031](https://github.com/Psychotoxical/psysonic/pull/1031)**
|
||||
|
||||
* Play on **Top Tracks** rows no longer silently does nothing when the artist page has top songs but no albums loaded (e.g. lossless artist view); playback starts from the clicked track and continues into the catalog when albums are available.
|
||||
|
||||
|
||||
|
||||
### PsyLab — tab bar no longer collapses on the Logs tab
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
|
||||
|
||||
* The PsyLab tab row keeps its height when the Logs flex layout fills the modal — tabs were previously squashed to a thin strip.
|
||||
|
||||
|
||||
|
||||
### 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)**
|
||||
|
||||
* Text fields no longer draw a double border when focused — they now show a single clean ring across every theme. The colour-blind-safe themes keep their stronger high-contrast focus ring on every field, including the header search.
|
||||
* Dropdown and popover borders now follow the active theme in all themes (a couple of themes previously rendered them with an unthemed colour).
|
||||
|
||||
|
||||
|
||||
### Home — Most Played no longer jumps the page when loading more
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#1053](https://github.com/Psychotoxical/psysonic/pull/1053)**
|
||||
|
||||
* Clicking the arrow to load more albums in the **Most Played** rail sometimes snapped the page up to an earlier section. Loading more now keeps the viewport in place.
|
||||
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -32,9 +32,6 @@ More translations are added over time.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
|
||||
|
||||
## What is Psysonic?
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||
@@ -137,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 |
|
||||
|
||||
@@ -157,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
|
||||
|
||||
@@ -197,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.
|
||||
+82
@@ -6,9 +6,91 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<script src="/startup-splash-preflight.js"></script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
--splash-muted: var(--startup-splash-muted, #a6adc8);
|
||||
--splash-accent: var(--startup-splash-accent, #cba6f7);
|
||||
--splash-track: var(--startup-splash-track, #313244);
|
||||
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
|
||||
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
opacity: 1;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
#app-startup-splash.app-startup-splash--hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--splash-muted);
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar {
|
||||
width: min(240px, 72vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--splash-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar-fill {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--splash-accent);
|
||||
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes app-startup-splash-indeterminate {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
|
||||
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--splash-logo-start)" />
|
||||
<stop offset="100%" stop-color="var(--splash-logo-end)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(220.53237,27.789086)">
|
||||
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="app-startup-splash__bar" aria-hidden="true">
|
||||
<div class="app-startup-splash__bar-fill"></div>
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,161 @@
|
||||
/**
|
||||
* Synchronous startup splash theme (before the Vite bundle loads).
|
||||
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
|
||||
*/
|
||||
(function startupSplashPreflight() {
|
||||
var THEME_KEY = 'psysonic_theme';
|
||||
var INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
var DEFAULT = {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
};
|
||||
var BUILTIN = {
|
||||
mocha: DEFAULT,
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
function readCssVar(css, name) {
|
||||
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
|
||||
var value = match && match[1] ? match[1].trim() : '';
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveScheduledTheme(state) {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
var now = new Date();
|
||||
var nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
var dayParts = state.timeDayStart.split(':').map(Number);
|
||||
var nightParts = state.timeNightStart.split(':').map(Number);
|
||||
var dayMins = dayParts[0] * 60 + dayParts[1];
|
||||
var nightMins = nightParts[0] * 60 + nightParts[1];
|
||||
var isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
function readThemeState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var s = parsed && parsed.state;
|
||||
if (!s) return null;
|
||||
return {
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme || 'mocha'),
|
||||
themeDay: String(s.themeDay || 'latte'),
|
||||
themeNight: String(s.themeNight || 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart || '07:00'),
|
||||
timeNightStart: String(s.timeNightStart || '19:00'),
|
||||
};
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
var themes = parsed && parsed.state && parsed.state.themes;
|
||||
return Array.isArray(themes) ? themes : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function paletteForTheme(themeId, installedThemes) {
|
||||
if (BUILTIN[themeId]) return BUILTIN[themeId];
|
||||
for (var i = 0; i < installedThemes.length; i += 1) {
|
||||
var theme = installedThemes[i];
|
||||
if (!theme || theme.id !== themeId || !theme.css) continue;
|
||||
var bg = readCssVar(theme.css, '--bg-app');
|
||||
var accent = readCssVar(theme.css, '--accent');
|
||||
if (!bg || !accent) break;
|
||||
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
|
||||
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|
||||
|| readCssVar(theme.css, '--accent-2')
|
||||
|| accent;
|
||||
return {
|
||||
bg: bg,
|
||||
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
|
||||
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
|
||||
accent: accent,
|
||||
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
|
||||
logoStart: logoStart,
|
||||
logoEnd: logoEnd,
|
||||
};
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
function applyPalette(themeId, palette) {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.style.setProperty('--startup-splash-bg', palette.bg);
|
||||
root.style.setProperty('--startup-splash-text', palette.text);
|
||||
root.style.setProperty('--startup-splash-muted', palette.muted);
|
||||
root.style.setProperty('--startup-splash-accent', palette.accent);
|
||||
root.style.setProperty('--startup-splash-track', palette.track);
|
||||
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
|
||||
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
|
||||
root.style.background = palette.bg;
|
||||
if (document.body) document.body.style.background = palette.bg;
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
|
||||
var palette = paletteForTheme(themeId, readInstalledThemes());
|
||||
applyPalette(themeId, palette);
|
||||
})();
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
var MAX_ATTEMPTS = 60;
|
||||
|
||||
function tryShowMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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
+10
-27
@@ -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",
|
||||
@@ -4252,14 +4254,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"twox-hash",
|
||||
"unicode-normalization",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
@@ -4268,6 +4268,7 @@ dependencies = [
|
||||
"psysonic-analysis",
|
||||
"psysonic-audio",
|
||||
"psysonic-core",
|
||||
"psysonic-library",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6603,15 +6604,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
dependencies = [
|
||||
"rand 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
@@ -6694,15 +6686,6 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
|
||||
@@ -3,9 +3,10 @@ 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"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
@@ -18,7 +19,7 @@ name = "psysonic"
|
||||
version.workspace = true
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
license.workspace = true
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
|
||||
@@ -21,6 +21,8 @@ accepted = [
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"GPL-3.0-or-later",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
@@ -38,5 +40,4 @@ targets = [
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -291,7 +292,7 @@ impl AnalysisBackfillQueueState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
|
||||
#[derive(Default)]
|
||||
pub struct PlaybackPriorityHints {
|
||||
middle_track_ids: Mutex<HashSet<String>>,
|
||||
@@ -516,6 +517,145 @@ pub async fn enqueue_track_analysis_from_file(
|
||||
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
|
||||
}
|
||||
|
||||
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
|
||||
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
|
||||
pub async fn enqueue_offline_library_analysis_from_file(
|
||||
app: &tauri::AppHandle,
|
||||
server_index_key: &str,
|
||||
library_server_id: &str,
|
||||
track_id: &str,
|
||||
file_path: &std::path::Path,
|
||||
explicit_priority: Option<AnalysisBackfillPriority>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::track_analysis_plan::plan_track_analysis_offline_library;
|
||||
|
||||
let mut file = tokio::fs::File::open(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut prefix = vec![0u8; 16384];
|
||||
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
|
||||
prefix.truncate(n);
|
||||
if prefix.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(&prefix);
|
||||
let plan = plan_track_analysis_offline_library(
|
||||
app,
|
||||
&[server_index_key, library_server_id],
|
||||
library_server_id,
|
||||
track_id,
|
||||
&content_hash,
|
||||
);
|
||||
if !plan.any() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
|
||||
track_id,
|
||||
server_index_key,
|
||||
library_server_id,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = tokio::fs::read(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let format_hint = file_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| !e.is_empty());
|
||||
let priority = explicit_priority.unwrap_or_else(|| {
|
||||
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
|
||||
});
|
||||
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
|
||||
app,
|
||||
cache_server_id: server_index_key,
|
||||
enrichment_server_id: library_server_id,
|
||||
track_id,
|
||||
bytes: &bytes,
|
||||
format_hint: format_hint.as_deref(),
|
||||
priority,
|
||||
plan,
|
||||
fetch_ms: 0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct OfflineLibraryAnalysisEnqueue<'a> {
|
||||
app: &'a tauri::AppHandle,
|
||||
cache_server_id: &'a str,
|
||||
enrichment_server_id: &'a str,
|
||||
track_id: &'a str,
|
||||
bytes: &'a [u8],
|
||||
format_hint: Option<&'a str>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
|
||||
fetch_ms: u64,
|
||||
}
|
||||
|
||||
async fn enqueue_track_analysis_offline_library_with_plan(
|
||||
args: OfflineLibraryAnalysisEnqueue<'_>,
|
||||
) -> Result<EnqueueTrackAnalysisOutcome, String> {
|
||||
if args.bytes.is_empty() || !args.plan.any() {
|
||||
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
|
||||
if args.plan.needs_full_cpu_seed() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
|
||||
args.track_id,
|
||||
content_hash,
|
||||
args.plan.need_waveform,
|
||||
args.plan.need_loudness,
|
||||
args.plan.enrichment.any()
|
||||
);
|
||||
submit_analysis_cpu_seed(
|
||||
args.app.clone(),
|
||||
args.cache_server_id.to_string(),
|
||||
args.track_id.to_string(),
|
||||
args.bytes.to_vec(),
|
||||
args.format_hint.map(str::to_string),
|
||||
args.priority,
|
||||
args.fetch_ms,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
|
||||
}
|
||||
if args.plan.needs_enrichment_only() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] enrichment-only track_id={} hash={}",
|
||||
args.track_id,
|
||||
content_hash
|
||||
);
|
||||
let bpm_started = std::time::Instant::now();
|
||||
let outcome = run_track_enrichment_from_bytes(
|
||||
args.app,
|
||||
args.enrichment_server_id,
|
||||
args.track_id,
|
||||
args.bytes,
|
||||
analysis_emits_ui_events(args.priority),
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
|
||||
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
let key = analysis_cache::TrackKey {
|
||||
server_id: args.cache_server_id.to_string(),
|
||||
track_id: args.track_id.to_string(),
|
||||
md5_16kb: content_hash.clone(),
|
||||
};
|
||||
let _ = cache.touch_track_status(&key, "failed");
|
||||
}
|
||||
return Err("track enrichment failed".to_string());
|
||||
}
|
||||
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
|
||||
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
|
||||
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
|
||||
}
|
||||
Ok(EnqueueTrackAnalysisOutcome::Complete)
|
||||
}
|
||||
|
||||
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
|
||||
pub async fn enqueue_analysis_seed(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -540,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())?;
|
||||
@@ -642,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
|
||||
|
||||
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
|
||||
}
|
||||
|
||||
/// Offline/library download: waveform cache and enrichment facts may live under the
|
||||
/// playback index key while library rows use the UUID — try every scope before seeding.
|
||||
pub fn plan_track_analysis_offline_library(
|
||||
app: &AppHandle,
|
||||
cache_server_ids: &[&str],
|
||||
_enrichment_server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) =
|
||||
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
|
||||
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
@@ -103,6 +116,32 @@ fn cache_gaps(
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_gaps_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let mut need_waveform = true;
|
||||
let mut need_loudness = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
if !nw {
|
||||
need_waveform = false;
|
||||
}
|
||||
if !nl {
|
||||
need_loudness = false;
|
||||
}
|
||||
if !need_waveform && !need_loudness {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(need_waveform, need_loudness)
|
||||
}
|
||||
|
||||
fn enrichment_plan(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
@@ -117,6 +156,45 @@ fn enrichment_plan(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enrichment_plan_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
let mut need_bpm = true;
|
||||
let mut need_valence = true;
|
||||
let mut need_arousal = true;
|
||||
let mut need_moods = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let plan = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
if !plan.need_bpm {
|
||||
need_bpm = false;
|
||||
}
|
||||
if !plan.need_valence {
|
||||
need_valence = false;
|
||||
}
|
||||
if !plan.need_arousal {
|
||||
need_arousal = false;
|
||||
}
|
||||
if !plan.need_moods {
|
||||
need_moods = false;
|
||||
}
|
||||
if !need_bpm && !need_valence && !need_arousal && !need_moods {
|
||||
break;
|
||||
}
|
||||
}
|
||||
psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
need_bpm,
|
||||
need_valence,
|
||||
need_arousal,
|
||||
need_moods,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_gaps_for_content(
|
||||
cache: Option<&AnalysisCache>,
|
||||
server_id: &str,
|
||||
@@ -194,4 +272,14 @@ mod tests {
|
||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
|
||||
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
|
||||
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
|
||||
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
@@ -39,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
|
||||
|
||||
@@ -166,15 +166,30 @@ impl SizedDecoder {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
// 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.
|
||||
//
|
||||
// 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),
|
||||
seekable: gate.clone(),
|
||||
}),
|
||||
None => Box::new(source),
|
||||
};
|
||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||
// and compete with the playback thread at track start.
|
||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
@@ -200,6 +215,10 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
@@ -302,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,
|
||||
@@ -575,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)"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,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.
|
||||
@@ -1020,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();
|
||||
}
|
||||
@@ -224,9 +234,18 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// The full `output_devices()` + per-device `description()` scan is the
|
||||
// CoreAudio HAL call that contends with the audio render thread and
|
||||
// produces a brief dropout once per poll interval (issue #996: stutter
|
||||
// every ~3s, cadence tracking the poll exactly). It is only needed to
|
||||
// detect a *pinned* output device disappearing. With no pin — system
|
||||
// default, the common case — only the current default is needed, a
|
||||
// single cheap query, so the full enumeration is skipped entirely.
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let need_full_enum = pinned.is_some();
|
||||
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
@@ -244,26 +263,28 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
// Empty list (only when we actually enumerated for a pinned device)
|
||||
// almost always means a transient enumeration failure, not that every
|
||||
// output device vanished. Treating it as "pinned missing" caused false
|
||||
// audio:device-reset (UI jumped back to system default) when switching
|
||||
// to external USB / class-compliant interfaces.
|
||||
if need_full_enum && available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Short preview playback on a secondary sink (same output stream).
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
@@ -8,13 +8,18 @@ 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, resolve_playback_format_hint,
|
||||
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,
|
||||
MASTER_HEADROOM,
|
||||
};
|
||||
use super::play_input::url_format_hint;
|
||||
use super::sources::PriorityBoostSource;
|
||||
use super::stream::{
|
||||
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
|
||||
RangedHttpSource, RangedMp4ProbeGate,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||
@@ -132,7 +137,206 @@ pub(crate) fn resolve_preview_format_hint(
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(state))
|
||||
}
|
||||
|
||||
/// Open a preview decoder — ranged HTTP when the server supports it (starts
|
||||
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
|
||||
async fn open_preview_decoder(
|
||||
url: &str,
|
||||
format_suffix: Option<&str>,
|
||||
gen: u64,
|
||||
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 = http_headers
|
||||
.apply(url, preview_http.get(url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?;
|
||||
|
||||
let mut stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
|
||||
.or_else(|| preview_format_hint_from_url(url))
|
||||
.or_else(|| url_format_hint(url));
|
||||
|
||||
let supports_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if stream_hint.is_none() && supports_range {
|
||||
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = http_headers
|
||||
.apply(url, preview_http.get(url))
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let stat = pr.status();
|
||||
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| stat == reqwest::StatusCode::OK;
|
||||
if ok {
|
||||
if let Ok(bytes) = pr.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[preview] ranged open — total={} KB, hint={:?}",
|
||||
total_usize / 1024,
|
||||
stream_hint
|
||||
);
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let playback_armed = Arc::new(AtomicBool::new(false));
|
||||
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
|
||||
tail_ready: tail_ready.clone(),
|
||||
buf: buf.clone(),
|
||||
downloaded_to: downloaded_to.clone(),
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
format_hint: stream_hint.clone(),
|
||||
});
|
||||
tokio::spawn(ranged_download_task(
|
||||
gen,
|
||||
state.preview_gen.clone(),
|
||||
preview_http,
|
||||
app.clone(),
|
||||
0.0,
|
||||
url.to_string(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.stream_completed_spill.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
None,
|
||||
None,
|
||||
http_headers.clone(),
|
||||
None,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
if let Some(ref gate) = mp4_probe_gate {
|
||||
wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready,
|
||||
tail_filled_from,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
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", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
return Ok(Some(decoder));
|
||||
}
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range,
|
||||
total_size,
|
||||
stream_hint
|
||||
);
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let content_disposition = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let hint = resolve_preview_format_hint(
|
||||
url,
|
||||
content_type.as_deref(),
|
||||
content_disposition.as_deref(),
|
||||
format_suffix,
|
||||
&bytes,
|
||||
);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
Ok(Some(decoder))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
|
||||
pub async fn audio_preview_play(
|
||||
id: String,
|
||||
url: String,
|
||||
@@ -164,65 +368,24 @@ pub async fn audio_preview_play(
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
||||
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
||||
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
||||
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
||||
// bounds how long the preview plays once the bytes are in memory, so a
|
||||
// long download just means a longer "loading" spinner before audio starts.
|
||||
let preview_http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(&state));
|
||||
let response = preview_http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?;
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let content_disposition = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
// ── Open decoder (ranged stream when possible) ───────────────────────────
|
||||
let decoder = match open_preview_decoder(
|
||||
&url,
|
||||
format_suffix.as_deref(),
|
||||
gen,
|
||||
&state,
|
||||
&app,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
// A newer preview started while we were downloading — bail.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ───────────────────────────────────────────────────────────────
|
||||
let hint = resolve_preview_format_hint(
|
||||
&url,
|
||||
content_type.as_deref(),
|
||||
content_disposition.as_deref(),
|
||||
format_suffix.as_deref(),
|
||||
&bytes,
|
||||
);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// ── Build source pipeline ────────────────────────────────────────────────
|
||||
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||
// before the seek made take_duration's wall-clock counter tick from
|
||||
@@ -241,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]
|
||||
|
||||
@@ -3,11 +3,14 @@ name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
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"
|
||||
|
||||
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
|| id.starts_with("ra-")
|
||||
}
|
||||
|
||||
/// Windows reserved device names (case-insensitive) — invalid as path components.
|
||||
const WINDOWS_RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
|
||||
/// Also used for media layout artist/album/title segments from server metadata.
|
||||
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let trimmed = segment.trim();
|
||||
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
trimmed
|
||||
let cleaned: String = trimmed
|
||||
.chars()
|
||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
||||
.collect()
|
||||
.map(|c| {
|
||||
if c.is_control() || FORBIDDEN.contains(&c) {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
return "_".to_string();
|
||||
}
|
||||
let upper = cleaned.to_ascii_uppercase();
|
||||
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
|
||||
return format!("_{cleaned}");
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
@@ -256,6 +277,13 @@ mod tests {
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_dot_dot_and_reserved_names() {
|
||||
assert_eq!(sanitize_path_segment(".."), "_");
|
||||
assert_eq!(sanitize_path_segment("CON"), "_CON");
|
||||
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_disk_usage_counts_canonical_only() {
|
||||
let server = test_server_dir("usage");
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! 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;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod track_analysis;
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Redact secrets and partially mask remote server hostnames before log lines
|
||||
//! are stored or exported (PsyLab / Settings log export).
|
||||
|
||||
const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
||||
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
|
||||
"access_token", "refresh_token", "auth",
|
||||
];
|
||||
|
||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||
"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
|
||||
}
|
||||
|
||||
/// Never panic on the logging hot path — fall back to the raw line if needed.
|
||||
pub fn sanitize_log_line_infallible(line: &str) -> String {
|
||||
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
|
||||
fn redact_bearer_tokens(line: &str) -> String {
|
||||
let marker = "Bearer ";
|
||||
let mut s = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = s[search_from..].find(marker) {
|
||||
let idx = search_from + rel;
|
||||
let start = idx + marker.len();
|
||||
let end = s[start..]
|
||||
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(s.len());
|
||||
if end > start {
|
||||
s.replace_range(start..end, "REDACTED");
|
||||
}
|
||||
search_from = start + "REDACTED".len();
|
||||
}
|
||||
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 {
|
||||
for sep in [':', '='] {
|
||||
let needle = format!("{key}{sep}");
|
||||
let lower = out.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find(&needle) {
|
||||
let idx = search_from + rel;
|
||||
let val_start = idx + needle.len();
|
||||
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 url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
|
||||
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
|
||||
return true;
|
||||
}
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
if let Some(next) = s[byte_off..].chars().nth(1) {
|
||||
return next.is_whitespace() || next == '"' || next == '\'';
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn redact_urls_in_text(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < line.len() {
|
||||
let slice = &line[cursor..];
|
||||
let rel = match (slice.find("http://"), slice.find("https://")) {
|
||||
(Some(h), Some(s)) => Some(h.min(s)),
|
||||
(Some(h), None) => Some(h),
|
||||
(None, Some(s)) => Some(s),
|
||||
(None, None) => None,
|
||||
};
|
||||
let Some(rel) = rel else {
|
||||
out.push_str(slice);
|
||||
break;
|
||||
};
|
||||
out.push_str(&slice[..rel]);
|
||||
let url_start = cursor + rel;
|
||||
let url_slice = &line[url_start..];
|
||||
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
|
||||
let mut url_end = scheme_len;
|
||||
for (off, ch) in url_slice[scheme_len..].char_indices() {
|
||||
let abs = scheme_len + off;
|
||||
if url_char_ends_url(ch, url_slice, abs) {
|
||||
break;
|
||||
}
|
||||
url_end = abs + ch.len_utf8();
|
||||
}
|
||||
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
|
||||
cursor = url_start + url_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
let (url, suffix) = split_trailing_punct(raw);
|
||||
let mut out = String::new();
|
||||
|
||||
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
out.push_str(&url[..scheme_end]);
|
||||
|
||||
let mut rest = &url[scheme_end..];
|
||||
if let Some(at) = rest.rfind('@') {
|
||||
// Drop userinfo entirely.
|
||||
out.push_str("***@");
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
|
||||
let (hostport, path) = split_host_path(rest);
|
||||
let (host, port) = split_host_port(&hostport);
|
||||
let masked_host = mask_hostname(&host);
|
||||
out.push_str(&masked_host);
|
||||
if let Some(p) = port {
|
||||
out.push(':');
|
||||
out.push_str(&p);
|
||||
}
|
||||
if let Some((path_only, query)) = path.split_once('?') {
|
||||
out.push_str(path_only);
|
||||
out.push('?');
|
||||
out.push_str(&redact_query_string(query));
|
||||
} else {
|
||||
out.push_str(&path);
|
||||
}
|
||||
|
||||
format!("{out}{suffix}")
|
||||
}
|
||||
|
||||
fn split_trailing_punct(raw: &str) -> (&str, &str) {
|
||||
let mut end = raw.len();
|
||||
while end > 0 {
|
||||
let ch = raw.as_bytes()[end - 1] as char;
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
(&raw[..end], &raw[end..])
|
||||
}
|
||||
|
||||
fn split_host_path(rest: &str) -> (String, String) {
|
||||
if rest.starts_with('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
let hostport = &rest[..=end];
|
||||
return (hostport.to_string(), rest[end + 1..].to_string());
|
||||
}
|
||||
}
|
||||
if let Some(slash) = rest.find('/') {
|
||||
(rest[..slash].to_string(), rest[slash..].to_string())
|
||||
} else {
|
||||
(rest.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_host_port(hostport: &str) -> (String, Option<String>) {
|
||||
if hostport.starts_with('[') {
|
||||
if let Some(end) = hostport.find("]:") {
|
||||
return (
|
||||
hostport[..=end].to_string(),
|
||||
Some(hostport[end + 2..].to_string()),
|
||||
);
|
||||
}
|
||||
return (hostport.to_string(), None);
|
||||
}
|
||||
if let Some((h, p)) = hostport.rsplit_once(':') {
|
||||
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
|
||||
return (h.to_string(), Some(p.to_string()));
|
||||
}
|
||||
}
|
||||
(hostport.to_string(), None)
|
||||
}
|
||||
|
||||
fn redact_query_string(query: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.map(|pair| {
|
||||
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if is_sensitive_query_key(k) {
|
||||
format!("{k}=REDACTED")
|
||||
} else {
|
||||
pair.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn is_sensitive_query_key(key: &str) -> bool {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
|
||||
}
|
||||
|
||||
fn is_lan_ipv4(ip: &str) -> bool {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let Ok(a) = parts[0].parse::<u8>() else { return false };
|
||||
let Ok(b) = parts[1].parse::<u8>() else { return false };
|
||||
a == 127
|
||||
|| a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
}
|
||||
|
||||
fn is_lan_ipv6(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
if h == "::1" {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fc") || h.starts_with("fd") {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = h.strip_prefix("::ffff:") {
|
||||
if rest.contains('.') {
|
||||
return is_lan_ipv4(rest);
|
||||
}
|
||||
if let Some((a, b)) = rest.split_once(':') {
|
||||
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
|
||||
let ip = format!(
|
||||
"{}.{}.{}.{}",
|
||||
(v1 >> 8) & 0xff,
|
||||
v1 & 0xff,
|
||||
(v2 >> 8) & 0xff,
|
||||
v2 & 0xff
|
||||
);
|
||||
return is_lan_ipv4(&ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_lan_host(host: &str) -> bool {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
let lower = stripped.to_ascii_lowercase();
|
||||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
if stripped.contains(':') {
|
||||
return is_lan_ipv6(stripped);
|
||||
}
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return is_lan_ipv4(stripped);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn mask_label_prefix(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
let c1 = chars.next();
|
||||
let c2 = chars.next();
|
||||
match (c1, c2) {
|
||||
(None, _) => "*".to_string(),
|
||||
(Some(a), None) => a.to_string(),
|
||||
(Some(a), Some(b)) => {
|
||||
let rest = label.chars().count().saturating_sub(2);
|
||||
let stars = rest.clamp(1, 4);
|
||||
format!("{a}{b}{}", "*".repeat(stars))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_public_ipv4(ip: &str) -> String {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return "***".to_string();
|
||||
}
|
||||
format!("{}.*.*.{}", parts[0], parts[3])
|
||||
}
|
||||
|
||||
fn mask_hostname(host: &str) -> String {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
if is_lan_host(stripped) {
|
||||
return host.to_string();
|
||||
}
|
||||
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return mask_public_ipv4(stripped);
|
||||
}
|
||||
|
||||
if stripped.contains(':') {
|
||||
return "[ipv6-redacted]".to_string();
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = stripped.split('.').collect();
|
||||
if parts.is_empty() {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let masked_first = mask_label_prefix(parts[0]);
|
||||
|
||||
if parts.len() == 1 {
|
||||
masked_first
|
||||
} else {
|
||||
format!("{}.{}", masked_first, parts[1..].join("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_subsonic_wire_auth_params() {
|
||||
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("t=REDACTED"));
|
||||
assert!(out.contains("s=REDACTED"));
|
||||
assert!(out.contains("p=REDACTED"));
|
||||
assert!(!out.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_remote_hostname_keeps_lan_ip() {
|
||||
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
|
||||
assert!(remote.contains("my****.example.com"));
|
||||
assert!(!remote.contains("my-server.example.com"));
|
||||
|
||||
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
|
||||
assert!(lan.contains("192.168.1.42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_bearer_and_password_kv() {
|
||||
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("Bearer REDACTED"));
|
||||
assert!(!out.contains("eyJhbGci"));
|
||||
assert!(out.contains("password=REDACTED"));
|
||||
assert!(!out.contains("sekrit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_url_userinfo() {
|
||||
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("***@10.0.0.5"));
|
||||
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\")";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains('—'));
|
||||
assert!(out.contains("RangedHttpSource"));
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ pub fn should_log_debug() -> bool {
|
||||
}
|
||||
|
||||
pub fn append_log_line(line: String) {
|
||||
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
|
||||
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
{
|
||||
let mut buf = log_buffer().lock().unwrap();
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Local playback disk layout — artist/album/track paths from library-index fields.
|
||||
//!
|
||||
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
|
||||
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
|
||||
//! index key; artist/album/filename segments are derived from track metadata only.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::cover_cache_layout::sanitize_path_segment;
|
||||
|
||||
/// Max length for a single path component after sanitization (Windows budget).
|
||||
pub const MAX_SEGMENT_LEN: usize = 120;
|
||||
|
||||
/// Inputs required to build hierarchical media paths (library index row projection).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TrackPathInput {
|
||||
pub artist: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub album: String,
|
||||
pub title: String,
|
||||
pub track_number: Option<i64>,
|
||||
pub disc_number: Option<i64>,
|
||||
pub suffix: Option<String>,
|
||||
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
|
||||
pub raw_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalTier {
|
||||
Ephemeral,
|
||||
Library,
|
||||
/// Auto-synced starred favorites — separate from user-pinned `library/`.
|
||||
Favorites,
|
||||
}
|
||||
|
||||
impl LocalTier {
|
||||
pub fn subdir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ephemeral => "cache",
|
||||
Self::Library => "library",
|
||||
Self::Favorites => "favorites",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"ephemeral" | "cache" => Some(Self::Ephemeral),
|
||||
"library" => Some(Self::Library),
|
||||
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
|
||||
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
|
||||
let artist_seg = artist_folder_segment(input);
|
||||
let album_seg = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let suffix = input
|
||||
.suffix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("");
|
||||
let track_n = input.track_number.unwrap_or(0);
|
||||
let disc_n = input.disc_number.unwrap_or(0);
|
||||
format!(
|
||||
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
|
||||
input
|
||||
.album_artist
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or(""),
|
||||
input.title.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
|
||||
pub fn relative_path_for_track(
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
let server_segment = sanitize_path_segment(server_index_key);
|
||||
let artist = artist_folder_segment(input);
|
||||
let album = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let ext = suffix.trim().trim_start_matches('.');
|
||||
let filename = if ext.is_empty() {
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
|
||||
} else {
|
||||
format!(
|
||||
"{}.{}",
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
|
||||
sanitize_path_segment(ext)
|
||||
)
|
||||
};
|
||||
PathBuf::from(server_segment)
|
||||
.join(artist)
|
||||
.join(album)
|
||||
.join(filename)
|
||||
}
|
||||
|
||||
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
|
||||
pub fn absolute_track_path(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
media_root
|
||||
.join(tier.subdir())
|
||||
.join(relative_path_for_track(server_index_key, input, suffix))
|
||||
}
|
||||
|
||||
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
|
||||
pub fn ensure_track_path_within_tier(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
absolute: &Path,
|
||||
) -> Result<(), String> {
|
||||
let tier_root = media_root.join(tier.subdir());
|
||||
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
|
||||
return Err(format!(
|
||||
"path `{}` escapes tier root `{}`",
|
||||
absolute.display(),
|
||||
tier_root.display()
|
||||
));
|
||||
};
|
||||
for comp in rel.components() {
|
||||
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
|
||||
return Err(format!(
|
||||
"path `{}` contains forbidden component `{comp:?}`",
|
||||
absolute.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artist_folder_segment(input: &TrackPathInput) -> String {
|
||||
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let chosen = if artist.is_empty() || track_is_compilation(input) {
|
||||
if !album_artist.is_empty() {
|
||||
album_artist
|
||||
} else {
|
||||
"Various Artists"
|
||||
}
|
||||
} else {
|
||||
artist
|
||||
};
|
||||
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn album_folder_segment(album: &str) -> String {
|
||||
let trimmed = album.trim();
|
||||
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
|
||||
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn track_filename_stem(input: &TrackPathInput) -> String {
|
||||
let title = input.title.trim();
|
||||
let title = if title.is_empty() { "Unknown Title" } else { title };
|
||||
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
|
||||
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
|
||||
if disc_n > 1 {
|
||||
format!("{disc_n:02}-{track_n:02} - {title}")
|
||||
} else {
|
||||
format!("{track_n:02} - {title}")
|
||||
}
|
||||
}
|
||||
|
||||
fn track_is_compilation(input: &TrackPathInput) -> bool {
|
||||
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
|
||||
return true;
|
||||
}
|
||||
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
raw_json_marks_compilation(raw)
|
||||
}
|
||||
|
||||
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
|
||||
fn raw_json_marks_compilation(raw: &str) -> bool {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
lower.contains("\"iscompilation\":true")
|
||||
|| lower.contains("\"iscompilation\": true")
|
||||
|| lower.contains("\"compilation\":true")
|
||||
|| lower.contains("\"compilation\": true")
|
||||
|| lower.contains("\"compilation\":1")
|
||||
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
|
||||
}
|
||||
|
||||
fn various_artists_label(s: &str) -> bool {
|
||||
let lower = s.trim().to_ascii_lowercase();
|
||||
lower.contains("various artists")
|
||||
}
|
||||
|
||||
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
|
||||
let sanitized = sanitize_path_segment(segment);
|
||||
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
|
||||
if sanitized.chars().count() <= max_len {
|
||||
return sanitized;
|
||||
}
|
||||
let hash = short_hash(segment);
|
||||
let keep = max_len.saturating_sub(1 + hash.len());
|
||||
let mut out = sanitized.chars().take(keep).collect::<String>();
|
||||
out.push('_');
|
||||
out.push_str(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0;
|
||||
for unit in s.encode_utf16() {
|
||||
h = h.wrapping_mul(31).wrapping_add(unit as u32);
|
||||
}
|
||||
format!("{:08x}", h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_input() -> TrackPathInput {
|
||||
TrackPathInput {
|
||||
artist: Some("Radiohead".to_string()),
|
||||
album_artist: None,
|
||||
album: "OK Computer".to_string(),
|
||||
title: "Paranoid Android".to_string(),
|
||||
track_number: Some(6),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_uses_library_segments() {
|
||||
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
|
||||
assert_eq!(
|
||||
rel,
|
||||
PathBuf::from("host_4533")
|
||||
.join("Radiohead")
|
||||
.join("OK Computer")
|
||||
.join("06 - Paranoid Android.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disc_adds_disc_prefix() {
|
||||
let mut input = sample_input();
|
||||
input.disc_number = Some(2);
|
||||
let rel = relative_path_for_track("srv", &input, "flac");
|
||||
assert!(rel
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_uses_album_artist_folder() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("Various Artists".to_string()),
|
||||
album_artist: Some("Original Soundtrack".to_string()),
|
||||
album: "Film Score".to_string(),
|
||||
title: "Main Theme".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_artist_falls_back_to_various_artists() {
|
||||
let input = TrackPathInput {
|
||||
artist: None,
|
||||
album_artist: None,
|
||||
album: "Comp".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_fingerprint_is_stable() {
|
||||
let a = layout_fingerprint(&sample_input());
|
||||
let b = layout_fingerprint(&sample_input());
|
||||
assert_eq!(a, b);
|
||||
assert!(a.contains("Radiohead"));
|
||||
assert!(a.contains("OK Computer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_subdirs_are_fixed() {
|
||||
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
|
||||
assert_eq!(LocalTier::Library.subdir(), "library");
|
||||
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
|
||||
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
|
||||
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
|
||||
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_includes_tier() {
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_metadata_does_not_escape_tier_root() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("..".to_string()),
|
||||
album_artist: None,
|
||||
album: "..".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_hash_matches_ts_imul31_utf16() {
|
||||
// "Radiohead" — same as mediaLayout.test parity anchor.
|
||||
assert_eq!(short_hash("Radiohead"), "3da68c3b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_and_truncate_uses_code_point_threshold() {
|
||||
let cyrillic_a = '\u{0430}';
|
||||
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
|
||||
assert!(hundred.len() > MAX_SEGMENT_LEN);
|
||||
assert_eq!(hundred.chars().count(), 100);
|
||||
assert_eq!(
|
||||
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
|
||||
hundred
|
||||
);
|
||||
|
||||
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
|
||||
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
|
||||
assert!(truncated.ends_with("_eef20600"));
|
||||
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
@@ -15,8 +16,6 @@ serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
|
||||
twox-hash = "2"
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
|
||||
|
||||
@@ -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,10 +0,0 @@
|
||||
-- Plain All Albums browse: read from `album` with ORDER BY name (not track GROUP BY).
|
||||
CREATE INDEX IF NOT EXISTS idx_album_server_name_browse
|
||||
ON album(server_id, name COLLATE NOCASE);
|
||||
|
||||
-- Scoped album EXISTS probes: (server, album, library) on live tracks.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_server_album_library_browse
|
||||
ON track(server_id, album_id, library_id)
|
||||
WHERE deleted = 0
|
||||
AND 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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,499 +0,0 @@
|
||||
//! Paginated All Albums browse from the local index (plain, no filters).
|
||||
//!
|
||||
//! Prefers the synced `album` table (≈5k rows) with LIMIT/OFFSET. Falls back to
|
||||
//! track `GROUP BY` only when the album catalog is not populated (N1 ingest).
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumBrowseRequest, LibraryAlbumBrowseResponse, LibraryAlbumDto, LibrarySortClause,
|
||||
SortDir,
|
||||
};
|
||||
use crate::search::library_scope_filter_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
|
||||
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
fn effective_scope_ids(req: &LibraryAlbumBrowseRequest) -> Vec<String> {
|
||||
if let Some(ids) = &req.library_scope_ids {
|
||||
let trimmed: Vec<_> = ids
|
||||
.iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
trimmed_nonempty(req.library_scope.as_deref())
|
||||
.map(|s| vec![s])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn album_table_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for s in sort {
|
||||
let col = match s.field.as_str() {
|
||||
"name" => "a.name COLLATE NOCASE",
|
||||
"artist" => "a.artist COLLATE NOCASE",
|
||||
"year" => "a.year",
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
SortDir::Asc => "ASC",
|
||||
SortDir::Desc => "DESC",
|
||||
};
|
||||
keys.push(format!("{col} {dir}"));
|
||||
}
|
||||
if keys.is_empty() {
|
||||
keys.push("a.name COLLATE NOCASE ASC".to_string());
|
||||
}
|
||||
keys.push("a.id ASC".to_string());
|
||||
format!("ORDER BY {}", keys.join(", "))
|
||||
}
|
||||
|
||||
fn track_group_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
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)",
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
SortDir::Asc => "ASC",
|
||||
SortDir::Desc => "DESC",
|
||||
};
|
||||
keys.push(format!("{col} {dir}"));
|
||||
}
|
||||
if keys.is_empty() {
|
||||
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
|
||||
}
|
||||
keys.push("la.album_id ASC".to_string());
|
||||
format!("ORDER BY {}", keys.join(", "))
|
||||
}
|
||||
|
||||
fn push_album_id_allowlist(
|
||||
where_clauses: &mut Vec<String>,
|
||||
params: &mut Vec<SqlValue>,
|
||||
column: &str,
|
||||
ids: Option<&[String]>,
|
||||
) {
|
||||
let Some(ids) = ids else {
|
||||
return;
|
||||
};
|
||||
if ids.is_empty() {
|
||||
where_clauses.push("1 = 0".to_string());
|
||||
return;
|
||||
}
|
||||
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
where_clauses.push(format!("{column} IN ({placeholders})"));
|
||||
for id in ids {
|
||||
params.push(SqlValue::Text(id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
|
||||
fn push_album_table_library_scope(
|
||||
where_clauses: &mut Vec<String>,
|
||||
params: &mut Vec<SqlValue>,
|
||||
scope_ids: &[String],
|
||||
) {
|
||||
if scope_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (clause, scope_params) = library_scope_filter_sql("t_scope", scope_ids);
|
||||
let Some(scope_clause) = clause else {
|
||||
return;
|
||||
};
|
||||
where_clauses.push(format!(
|
||||
"EXISTS (SELECT 1 FROM track t_scope \
|
||||
WHERE t_scope.server_id = a.server_id \
|
||||
AND t_scope.album_id = a.id \
|
||||
AND t_scope.deleted = 0 \
|
||||
AND {scope_clause})"
|
||||
));
|
||||
params.extend(scope_params);
|
||||
}
|
||||
|
||||
fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
|
||||
let raw: Option<String> = r.get(12)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
song_count: r.get(5)?,
|
||||
duration_sec: r.get(6)?,
|
||||
year: r.get(7)?,
|
||||
genre: r.get(8)?,
|
||||
cover_art_id: r.get(9)?,
|
||||
starred_at: r.get(10)?,
|
||||
synced_at: r.get(11)?,
|
||||
raw_json: raw
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn album_table_usable(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
|
||||
store
|
||||
.with_read_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM album
|
||||
WHERE server_id = ?1 AND song_count IS NOT NULL
|
||||
LIMIT 1
|
||||
)",
|
||||
rusqlite::params![server_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn list_albums_from_table(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryAlbumBrowseRequest,
|
||||
) -> Result<LibraryAlbumBrowseResponse, String> {
|
||||
let limit = req.limit.max(1);
|
||||
let offset = req.offset;
|
||||
let order_sql = album_table_order_sql(&req.sort);
|
||||
|
||||
let mut where_clauses = vec!["a.server_id = ?1".to_string()];
|
||||
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
|
||||
|
||||
let scope_ids = effective_scope_ids(req);
|
||||
push_album_table_library_scope(&mut where_clauses, &mut params, &scope_ids);
|
||||
push_album_id_allowlist(
|
||||
&mut where_clauses,
|
||||
&mut params,
|
||||
"a.id",
|
||||
req.restrict_album_ids.as_deref(),
|
||||
);
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let sql = format!(
|
||||
"SELECT \
|
||||
a.server_id, \
|
||||
a.id, \
|
||||
a.name, \
|
||||
a.artist, \
|
||||
a.artist_id, \
|
||||
a.song_count, \
|
||||
a.duration_sec, \
|
||||
a.year, \
|
||||
a.genre, \
|
||||
a.cover_art_id, \
|
||||
a.starred_at, \
|
||||
a.synced_at, \
|
||||
a.raw_json \
|
||||
FROM album a \
|
||||
WHERE {where_sql} \
|
||||
{order_sql} \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
let albums: Vec<LibraryAlbumDto> = store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let has_more = albums.len() as u32 == limit;
|
||||
Ok(LibraryAlbumBrowseResponse {
|
||||
albums,
|
||||
has_more,
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_albums_from_tracks(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryAlbumBrowseRequest,
|
||||
) -> Result<LibraryAlbumBrowseResponse, String> {
|
||||
let limit = req.limit.max(1);
|
||||
let offset = req.offset;
|
||||
let order_sql = track_group_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(),
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
|
||||
|
||||
let scope_ids = effective_scope_ids(req);
|
||||
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
|
||||
where_clauses.push(clause);
|
||||
params.extend(scope_params);
|
||||
}
|
||||
push_album_id_allowlist(
|
||||
&mut where_clauses,
|
||||
&mut params,
|
||||
"t.album_id",
|
||||
req.restrict_album_ids.as_deref(),
|
||||
);
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let sql = format!(
|
||||
"SELECT \
|
||||
la.server_id, \
|
||||
la.album_id, \
|
||||
COALESCE(a.name, la.album_name), \
|
||||
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), \
|
||||
COALESCE(a.year, la.year), \
|
||||
COALESCE(a.genre, la.genre), \
|
||||
COALESCE(a.cover_art_id, la.cover_art_id), \
|
||||
COALESCE(a.starred_at, la.starred_at), \
|
||||
COALESCE(a.synced_at, la.synced_at), \
|
||||
a.raw_json \
|
||||
FROM ( \
|
||||
SELECT \
|
||||
t.server_id, \
|
||||
t.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
MAX(t.cover_art_id) AS cover_art_id, \
|
||||
MAX(t.starred_at) AS starred_at, \
|
||||
MAX(t.synced_at) AS synced_at, \
|
||||
COUNT(*) AS track_count, \
|
||||
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
|
||||
FROM track t \
|
||||
WHERE {where_sql} \
|
||||
GROUP BY t.server_id, t.album_id \
|
||||
) la \
|
||||
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
|
||||
{order_sql} \
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
let albums: Vec<LibraryAlbumDto> = store
|
||||
.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let has_more = albums.len() as u32 == limit;
|
||||
Ok(LibraryAlbumBrowseResponse {
|
||||
albums,
|
||||
has_more,
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn browse_is_scoped(req: &LibraryAlbumBrowseRequest) -> bool {
|
||||
!effective_scope_ids(req).is_empty()
|
||||
|| req
|
||||
.restrict_album_ids
|
||||
.as_ref()
|
||||
.is_some_and(|ids| !ids.is_empty())
|
||||
}
|
||||
|
||||
pub fn list_albums(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryAlbumBrowseRequest,
|
||||
) -> Result<LibraryAlbumBrowseResponse, String> {
|
||||
let scope_ids = effective_scope_ids(req);
|
||||
// Unscoped, or a single library: `album` table + EXISTS (fast on ~5k rows).
|
||||
// Multi-library union: filter tracks by `library_id IN (...)` then GROUP BY.
|
||||
if album_table_usable(store, &req.server_id)?
|
||||
&& (!browse_is_scoped(req) || scope_ids.len() == 1)
|
||||
{
|
||||
return list_albums_from_table(store, req);
|
||||
}
|
||||
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
|
||||
return Ok(LibraryAlbumBrowseResponse {
|
||||
albums: Vec::new(),
|
||||
has_more: false,
|
||||
source: "local".to_string(),
|
||||
});
|
||||
}
|
||||
list_albums_from_tracks(store, req)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
|
||||
fn track(server: &str, id: &str, album_id: &str, album: &str, library_id: Option<&str>) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: format!("{album} track"),
|
||||
title_sort: None,
|
||||
artist: Some("Band".into()),
|
||||
artist_id: Some("art-1".into()),
|
||||
album: album.into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some("Band".into()),
|
||||
duration_sec: 200,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: Some(2020),
|
||||
genre: None,
|
||||
suffix: Some("mp3".into()),
|
||||
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: library_id.map(String::from),
|
||||
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: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn req(server: &str, limit: u32, offset: u32) -> LibraryAlbumBrowseRequest {
|
||||
LibraryAlbumBrowseRequest {
|
||||
server_id: server.into(),
|
||||
library_scope: None,
|
||||
library_scope_ids: None,
|
||||
sort: Vec::new(),
|
||||
restrict_album_ids: None,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_album(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
song_count: i64,
|
||||
) {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO album (server_id, id, name, artist, song_count, duration_sec, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, ?3, 'Band', ?4, 400, 1, '{}')",
|
||||
rusqlite::params![server_id, id, name, song_count],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_albums_grouped_from_tracks() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "al-1", "Alpha", None),
|
||||
track("s1", "t2", "al-2", "Beta", None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert!(!resp.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_album_table_when_synced_catalog_exists() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_album(&store, "s1", "al-1", "Alpha", 10);
|
||||
seed_album(&store, "s1", "al-2", "Beta", 8);
|
||||
|
||||
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert_eq!(resp.albums[0].name, "Alpha");
|
||||
assert_eq!(resp.albums[1].name, "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_scope_narrows_results() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "al-1", "In", Some("lib-a")),
|
||||
track("s1", "t2", "al-2", "Out", Some("lib-b")),
|
||||
])
|
||||
.unwrap();
|
||||
seed_album(&store, "s1", "al-1", "In", 1);
|
||||
seed_album(&store, "s1", "al-2", "Out", 1);
|
||||
|
||||
let mut scoped = req("s1", 50, 0);
|
||||
scoped.library_scope_ids = Some(vec!["lib-a".into()]);
|
||||
let resp = list_albums(&store, &scoped).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_library_scope_unions_albums() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
|
||||
track("s1", "t2", "al-2", "Beta", Some("lib-b")),
|
||||
track("s1", "t3", "al-3", "Gamma", Some("lib-c")),
|
||||
])
|
||||
.unwrap();
|
||||
seed_album(&store, "s1", "al-1", "Alpha", 1);
|
||||
seed_album(&store, "s1", "al-2", "Beta", 1);
|
||||
seed_album(&store, "s1", "al-3", "Gamma", 1);
|
||||
|
||||
let mut scoped = req("s1", 50, 0);
|
||||
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
|
||||
let resp = list_albums(&store, &scoped).unwrap();
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert_eq!(resp.albums[0].id, "al-1");
|
||||
assert_eq!(resp.albums[1].id, "al-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_library_scope_includes_track_only_albums() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
|
||||
track("s1", "t2", "al-2", "Zulu", Some("lib-b")),
|
||||
])
|
||||
.unwrap();
|
||||
seed_album(&store, "s1", "al-1", "Alpha", 1);
|
||||
|
||||
let mut scoped = req("s1", 50, 0);
|
||||
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
|
||||
let resp = list_albums(&store, &scoped).unwrap();
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert_eq!(resp.albums[0].id, "al-1");
|
||||
assert_eq!(resp.albums[1].id, "al-2");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
|
||||
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
|
||||
//! `isCompilation`, or `releaseTypes` containing `Compilation`), plus the same
|
||||
//! "Various Artists" heuristics the web UI uses when structured flags are absent.
|
||||
|
||||
/// SQL predicate on any row with a `raw_json` column (album or track).
|
||||
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
|
||||
@@ -17,6 +18,63 @@ pub fn compilation_raw_json_sql(table_alias: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn various_artists_like_sql(column: &str) -> String {
|
||||
format!(
|
||||
"lower(trim(coalesce({column}, ''))) LIKE '%various artists%'",
|
||||
column = column
|
||||
)
|
||||
}
|
||||
|
||||
/// Full compilation predicate for browse filters — JSON flags plus VA artist labels.
|
||||
pub fn compilation_predicate_sql(
|
||||
table_alias: &str,
|
||||
artist_column: Option<&str>,
|
||||
album_artist_column: Option<&str>,
|
||||
) -> String {
|
||||
let mut parts = vec![compilation_raw_json_sql(table_alias)];
|
||||
parts.push(format!(
|
||||
"lower(trim(coalesce(json_extract({a}.raw_json, '$.displayArtist'), ''))) LIKE '%various artists%'",
|
||||
a = table_alias
|
||||
));
|
||||
if let Some(col) = artist_column {
|
||||
parts.push(various_artists_like_sql(col));
|
||||
}
|
||||
if let Some(col) = album_artist_column {
|
||||
parts.push(various_artists_like_sql(col));
|
||||
}
|
||||
format!("({})", parts.join(" OR "))
|
||||
}
|
||||
|
||||
pub fn various_artists_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().contains("various artists")
|
||||
}
|
||||
|
||||
/// 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 !aa.is_empty() {
|
||||
return Some(aa.to_string());
|
||||
}
|
||||
track_artist.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -27,4 +85,80 @@ mod tests {
|
||||
assert!(sql.contains("$.compilation"));
|
||||
assert!(sql.contains("$.releaseTypes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_includes_artist_columns() {
|
||||
let sql = compilation_predicate_sql("t", Some("t.artist"), Some("t.album_artist"));
|
||||
assert!(sql.contains("t.artist"));
|
||||
assert!(sql.contains("t.album_artist"));
|
||||
assert!(sql.contains("$.displayArtist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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("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");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use tauri::State;
|
||||
use crate::dto::CatalogYearBoundsDto;
|
||||
use crate::dto::GenreAlbumCountDto;
|
||||
use crate::runtime::LibraryRuntime;
|
||||
use crate::search::library_scope_filter_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn reconcile_album_stars(
|
||||
) -> Result<(), String> {
|
||||
runtime
|
||||
.store
|
||||
.with_conn("misc", |conn| {
|
||||
.with_conn("browse.reconcile_album_stars", |conn| {
|
||||
if starred.is_empty() {
|
||||
conn.execute(
|
||||
"UPDATE album SET starred_at = NULL \
|
||||
@@ -106,52 +106,42 @@ pub fn library_get_catalog_year_bounds(
|
||||
catalog_year_bounds_for_server(&runtime.store, &server_id)
|
||||
}
|
||||
|
||||
fn effective_genre_count_scope_ids(
|
||||
library_scope: Option<&str>,
|
||||
library_scope_ids: Option<&[String]>,
|
||||
) -> Vec<String> {
|
||||
if let Some(ids) = library_scope_ids {
|
||||
let trimmed: Vec<_> = ids
|
||||
.iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
library_scope
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| vec![s.to_string()])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn genre_album_counts_for_server(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scope_ids: Option<&[String]>,
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
let scope_ids = effective_genre_count_scope_ids(library_scope, library_scope_ids);
|
||||
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(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
|
||||
sql.push_str(&format!(" AND {clause}"));
|
||||
params.extend(scope_params);
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
|
||||
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
|
||||
@@ -174,13 +164,11 @@ pub fn library_get_genre_album_counts(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
library_scope: Option<String>,
|
||||
library_scope_ids: Option<Vec<String>>,
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
genre_album_counts_for_server(
|
||||
&runtime.store,
|
||||
&server_id,
|
||||
library_scope.as_deref(),
|
||||
library_scope_ids.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,7 +320,7 @@ mod tests {
|
||||
.upsert_batch(&rock_one)
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None, None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
assert_eq!(counts.len(), 2);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 2);
|
||||
@@ -355,13 +343,33 @@ mod tests {
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1"), None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
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());
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn link_all_tracks_for_server(
|
||||
server_id: &str,
|
||||
now: i64,
|
||||
) -> Result<u32, String> {
|
||||
store.with_conn_mut("misc", |conn| {
|
||||
store.with_conn_mut("canonical.link_all_tracks", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id, isrc, mbid_recording FROM track \
|
||||
|
||||
@@ -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};
|
||||
@@ -20,16 +21,10 @@ use crate::cover_resolve::CoverEntryDto;
|
||||
use crate::cross_server;
|
||||
use crate::dto::{
|
||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||
LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
|
||||
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
|
||||
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest,
|
||||
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
|
||||
LibraryClusterArtistDetailResponse,
|
||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto,
|
||||
PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
||||
};
|
||||
use crate::live_search;
|
||||
@@ -186,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| {
|
||||
@@ -201,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)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -252,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,
|
||||
@@ -402,6 +399,40 @@ pub async fn library_get_tracks_by_album(
|
||||
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
|
||||
}
|
||||
|
||||
/// Upsert Subsonic API song payloads into the library index so pin/download can
|
||||
/// build `media/library/…` paths before a full sync has ingested the rows.
|
||||
#[tauri::command]
|
||||
pub fn library_upsert_songs_from_api(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
songs: Vec<serde_json::Value>,
|
||||
) -> Result<u32, String> {
|
||||
use crate::sync::subsonic_song_to_track_row;
|
||||
use psysonic_integration::subsonic::Song;
|
||||
|
||||
if songs.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let synced_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_secs() as i64;
|
||||
let repo = TrackRepository::new(&runtime.store);
|
||||
let mut rows = Vec::with_capacity(songs.len());
|
||||
for raw in songs {
|
||||
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
|
||||
rows.push(subsonic_song_to_track_row(
|
||||
&server_id,
|
||||
&song,
|
||||
&raw,
|
||||
synced_at,
|
||||
None,
|
||||
));
|
||||
}
|
||||
repo.upsert_batch(&rows)?;
|
||||
Ok(rows.len() as u32)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -448,7 +479,7 @@ pub async fn library_get_offline_path(
|
||||
) -> Result<OfflinePathDto, String> {
|
||||
let path = runtime
|
||||
.store
|
||||
.with_conn("misc", |conn| {
|
||||
.with_conn("cmd.get_offline_path", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT local_path FROM track_offline \
|
||||
WHERE server_id = ?1 AND track_id = ?2",
|
||||
@@ -479,16 +510,6 @@ pub async fn library_advanced_search(
|
||||
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_advanced_search(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterAdvancedSearchRequest,
|
||||
) -> Result<LibraryAdvancedSearchResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || crate::server_cluster::run_cluster_advanced_search(&store, request))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_list_lossless_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -498,15 +519,6 @@ pub async fn library_list_lossless_albums(
|
||||
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_list_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: crate::dto::LibraryAlbumBrowseRequest,
|
||||
) -> Result<crate::dto::LibraryAlbumBrowseResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || crate::album_browse::list_albums(&store, &request)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_list_albums_by_genre(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -517,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>,
|
||||
@@ -571,265 +600,6 @@ pub async fn library_search_cross_server(
|
||||
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_tracks(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterListTracksRequest,
|
||||
) -> Result<LibraryTracksEnvelope, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(100);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_tracks(
|
||||
&store,
|
||||
&servers_ordered,
|
||||
limit,
|
||||
offset,
|
||||
&request.library_scopes,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterListTracksRequest,
|
||||
) -> Result<LibraryClusterAlbumsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(100);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_albums(
|
||||
&store,
|
||||
&servers_ordered,
|
||||
limit,
|
||||
offset,
|
||||
&request.library_scopes,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_artists(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterListTracksRequest,
|
||||
) -> Result<LibraryClusterArtistsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(100);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_artists(
|
||||
&store,
|
||||
&servers_ordered,
|
||||
limit,
|
||||
offset,
|
||||
&request.library_scopes,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_favorites(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterScopeRequest,
|
||||
) -> Result<LibraryTracksEnvelope, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(500);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_favorite_tracks(&store, &servers_ordered, limit, offset)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_favorite_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterScopeRequest,
|
||||
) -> Result<LibraryClusterAlbumsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(500);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_favorite_albums(&store, &servers_ordered, limit, offset)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_favorite_artists(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterScopeRequest,
|
||||
) -> Result<LibraryClusterArtistsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(500);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_favorite_artists(&store, &servers_ordered, limit, offset)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_cluster_player_stats_year_summary(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterPlayerStatsRequest,
|
||||
) -> Result<PlaySessionYearSummaryDto, String> {
|
||||
crate::server_cluster::cluster_year_summary(
|
||||
&runtime.store,
|
||||
&request.servers_ordered,
|
||||
request.year,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_cluster_player_stats_heatmap(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterPlayerStatsRequest,
|
||||
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
|
||||
crate::server_cluster::cluster_heatmap(
|
||||
&runtime.store,
|
||||
&request.servers_ordered,
|
||||
request.year,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_cluster_player_stats_day_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterPlayerStatsDayDetailRequest,
|
||||
) -> Result<PlaySessionDayDetailDto, String> {
|
||||
crate::server_cluster::cluster_day_detail(
|
||||
&runtime.store,
|
||||
&request.servers_ordered,
|
||||
&request.date_iso,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_cluster_player_stats_recent_days(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterScopeRequest,
|
||||
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
|
||||
crate::server_cluster::cluster_recent_days(
|
||||
&runtime.store,
|
||||
&request.servers_ordered,
|
||||
request.limit.unwrap_or(30),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn library_cluster_player_stats_most_played(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterScopeRequest,
|
||||
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
|
||||
crate::server_cluster::cluster_most_played(
|
||||
&runtime.store,
|
||||
&request.servers_ordered,
|
||||
request.limit.unwrap_or(50),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_resolve_candidates(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterResolveRequest,
|
||||
) -> Result<LibraryClusterResolveResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || {
|
||||
if let Some(key) = request.cluster_key.filter(|k| !k.is_empty()) {
|
||||
let candidates = crate::server_cluster::resolve_candidates_by_cluster_key(
|
||||
&store,
|
||||
&request.servers_ordered,
|
||||
&key,
|
||||
)?;
|
||||
return Ok(LibraryClusterResolveResponse {
|
||||
candidates,
|
||||
cluster_key: Some(key),
|
||||
});
|
||||
}
|
||||
let server_id = request
|
||||
.server_id
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
|
||||
let track_id = request
|
||||
.track_id
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
|
||||
let cluster_key =
|
||||
crate::server_cluster::cluster_key_for_track(&store, server_id, track_id)?;
|
||||
let candidates = crate::server_cluster::resolve_candidates_for_track(
|
||||
&store,
|
||||
&request.servers_ordered,
|
||||
server_id,
|
||||
track_id,
|
||||
)?;
|
||||
Ok(LibraryClusterResolveResponse {
|
||||
candidates,
|
||||
cluster_key,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_album_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterEntityDetailRequest,
|
||||
) -> Result<LibraryClusterAlbumDetailResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let server_id = request.server_id;
|
||||
let entity_id = request.entity_id;
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::cluster_album_detail(&store, &servers_ordered, &server_id, &entity_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_artist_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterEntityDetailRequest,
|
||||
) -> Result<LibraryClusterArtistDetailResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let server_id = request.server_id;
|
||||
let entity_id = request.entity_id;
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::cluster_artist_detail(&store, &servers_ordered, &server_id, &entity_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_search_cluster(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
servers_ordered: Vec<String>,
|
||||
) -> Result<LibraryCrossServerSearchResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::run_cluster_search(&store, &query, limit, offset, &servers_ordered)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn hydrate_refs(
|
||||
@@ -855,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>,
|
||||
@@ -887,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;
|
||||
@@ -907,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,
|
||||
@@ -920,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),
|
||||
@@ -939,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,
|
||||
@@ -949,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,
|
||||
)
|
||||
@@ -1102,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(),
|
||||
@@ -1125,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);
|
||||
}
|
||||
@@ -1149,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);
|
||||
}
|
||||
@@ -1197,12 +988,6 @@ async fn library_sync_start_inner(
|
||||
};
|
||||
if let Some(runtime) = app_for_emit.try_state::<LibraryRuntime>() {
|
||||
let _ = runtime.store.checkpoint_wal("sync.checkpoint");
|
||||
if outcome.ok {
|
||||
let _ = crate::server_cluster::rebuild_cluster_keys_for_server(
|
||||
&runtime.store,
|
||||
&server_id_for_emit,
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome);
|
||||
|
||||
@@ -1284,7 +1069,7 @@ pub fn patch_content_hash(
|
||||
}
|
||||
runtime
|
||||
.store
|
||||
.with_conn("misc", |conn| {
|
||||
.with_conn("cmd.patch_content_hash", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE track SET content_hash = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
@@ -1331,7 +1116,7 @@ pub(crate) fn apply_track_patch(
|
||||
|
||||
runtime
|
||||
.store
|
||||
.with_conn("misc", |conn| {
|
||||
.with_conn("cmd.patch_track", |conn| {
|
||||
// One UPDATE per field present — keeps SQL simple and
|
||||
// matches the spec's per-field patch semantics.
|
||||
if let Some(v) = starred_at {
|
||||
@@ -1467,7 +1252,7 @@ pub fn library_purge_server(
|
||||
let mut report = PurgeReportDto::default();
|
||||
runtime
|
||||
.store
|
||||
.with_conn_mut("misc", |conn| {
|
||||
.with_conn_mut("cmd.purge_server", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let track_count: i64 =
|
||||
tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?;
|
||||
@@ -1883,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"));
|
||||
}
|
||||
|
||||
@@ -1900,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::filter::{EntityKind, FilterOp};
|
||||
use crate::repos::TrackRow;
|
||||
@@ -29,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>,
|
||||
@@ -366,14 +367,6 @@ pub struct PlaySessionRecentDayDto {
|
||||
pub partial_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionMostPlayedDto {
|
||||
pub track: LibraryTrackDto,
|
||||
pub track_play_count: u32,
|
||||
pub total_listened_sec: f64,
|
||||
}
|
||||
|
||||
/// Earliest/latest calendar years with at least one session (local TZ).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -399,38 +392,6 @@ pub struct GenreAlbumCountDto {
|
||||
pub song_count: u32,
|
||||
}
|
||||
|
||||
/// `library_list_albums` request — paginated plain All Albums browse (local index).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAlbumBrowseRequest {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
#[serde(default)]
|
||||
pub library_scope_ids: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
#[serde(default)]
|
||||
pub restrict_album_ids: Option<Vec<String>>,
|
||||
#[serde(default = "default_album_browse_limit")]
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
fn default_album_browse_limit() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
/// `library_list_albums` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAlbumBrowseResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -440,8 +401,6 @@ pub struct LibraryGenreAlbumsRequest {
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
#[serde(default)]
|
||||
pub library_scope_ids: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
#[serde(default = "default_genre_album_limit")]
|
||||
pub limit: u32,
|
||||
@@ -519,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,
|
||||
@@ -563,9 +524,6 @@ pub struct LibraryAdvancedSearchRequest {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Multiple music-folder ids (OR). Takes precedence over `library_scope` when non-empty.
|
||||
#[serde(default)]
|
||||
pub library_scope_ids: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
pub entity_types: Vec<EntityKind>,
|
||||
@@ -651,14 +609,6 @@ pub struct LibraryLosslessAlbumsRequest {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Multiple music-folder ids (OR). Preferred over `library_scope` when length > 1.
|
||||
#[serde(default)]
|
||||
pub library_scope_ids: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
/// Navidrome-scoped album ids from getAlbumList2 (authoritative when track `library_id` is sparse).
|
||||
#[serde(default)]
|
||||
pub restrict_album_ids: Option<Vec<String>>,
|
||||
#[serde(default = "default_lossless_limit")]
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
@@ -712,158 +662,6 @@ pub struct LibraryCrossServerSearchResponse {
|
||||
pub servers_searched: Vec<String>,
|
||||
}
|
||||
|
||||
/// Cluster candidate row for playback / write fan-out resolution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterCandidateDto {
|
||||
pub server_id: String,
|
||||
pub track_id: String,
|
||||
pub duration_sec: i64,
|
||||
pub priority_rank: u32,
|
||||
pub is_winner: bool,
|
||||
}
|
||||
|
||||
/// `library_cluster_list_tracks` request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterListTracksRequest {
|
||||
/// Ordered member server ids (index 0 = highest priority).
|
||||
pub servers_ordered: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
|
||||
#[serde(default)]
|
||||
pub library_scopes: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// `library_cluster_advanced_search` request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterAdvancedSearchRequest {
|
||||
/// Ordered member server ids (index 0 = highest priority).
|
||||
pub servers_ordered: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
pub entity_types: Vec<EntityKind>,
|
||||
#[serde(default)]
|
||||
pub filters: Vec<LibraryFilterClause>,
|
||||
#[serde(default)]
|
||||
pub starred_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub restrict_album_ids: Option<Vec<String>>,
|
||||
/// Per-member album allowlists from getAlbumList2 (`server_id` → album ids).
|
||||
#[serde(default)]
|
||||
pub restrict_album_scopes: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub query_album_title_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
#[serde(default)]
|
||||
pub skip_totals: bool,
|
||||
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
|
||||
#[serde(default)]
|
||||
pub library_scopes: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Merged album browse response for cluster scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterAlbumsResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Merged artist browse response for cluster scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterArtistsResponse {
|
||||
pub artists: Vec<LibraryArtistDto>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Cluster player stats / favorites scope request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterScopeRequest {
|
||||
pub servers_ordered: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterPlayerStatsRequest {
|
||||
pub servers_ordered: Vec<String>,
|
||||
pub year: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterPlayerStatsDayDetailRequest {
|
||||
pub servers_ordered: Vec<String>,
|
||||
pub date_iso: String,
|
||||
}
|
||||
|
||||
/// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterResolveRequest {
|
||||
pub servers_ordered: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cluster_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub server_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `library_cluster_resolve_candidates` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterResolveResponse {
|
||||
pub candidates: Vec<LibraryClusterCandidateDto>,
|
||||
#[serde(default)]
|
||||
pub cluster_key: Option<String>,
|
||||
}
|
||||
|
||||
/// `library_cluster_album_detail` request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterEntityDetailRequest {
|
||||
pub servers_ordered: Vec<String>,
|
||||
pub server_id: String,
|
||||
pub entity_id: String,
|
||||
}
|
||||
|
||||
/// Virtual aggregate album detail (spec §4).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterAlbumDetailResponse {
|
||||
pub album: LibraryAlbumDto,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
pub owner_server_id: String,
|
||||
pub related_albums: Vec<LibraryAlbumDto>,
|
||||
}
|
||||
|
||||
/// Virtual aggregate artist detail (spec §4).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterArtistDetailResponse {
|
||||
pub artist: LibraryArtistDto,
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub top_tracks: Vec<LibraryTrackDto>,
|
||||
pub owner_server_id: String,
|
||||
#[serde(default)]
|
||||
pub artist_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
|
||||
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
|
||||
/// Settings without a separate column.
|
||||
@@ -1067,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,
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::dto::{
|
||||
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
|
||||
SortDir,
|
||||
};
|
||||
use crate::search::library_scope_filter_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
@@ -18,36 +18,21 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
fn effective_genre_scope_ids(req: &LibraryGenreAlbumsRequest) -> Vec<String> {
|
||||
if let Some(ids) = &req.library_scope_ids {
|
||||
let trimmed: Vec<_> = ids
|
||||
.iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
trimmed_nonempty(req.library_scope.as_deref())
|
||||
.map(|s| vec![s])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -60,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()),
|
||||
@@ -123,29 +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 scope_ids = effective_genre_scope_ids(req);
|
||||
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
|
||||
where_clauses.push(clause);
|
||||
params.extend(scope_params);
|
||||
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), \
|
||||
@@ -157,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, \
|
||||
@@ -169,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} \
|
||||
@@ -184,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
|
||||
};
|
||||
@@ -270,7 +264,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Rock".into(),
|
||||
library_scope: Some("lib1".into()),
|
||||
library_scope_ids: None,
|
||||
sort: vec![LibrarySortClause {
|
||||
field: "name".into(),
|
||||
dir: SortDir::Asc,
|
||||
@@ -290,7 +283,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Rock".into(),
|
||||
library_scope: None,
|
||||
library_scope_ids: None,
|
||||
sort: vec![],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
@@ -301,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");
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,15 @@
|
||||
|
||||
pub(crate) mod bulk_ingest;
|
||||
pub mod advanced_search;
|
||||
pub mod album_browse;
|
||||
pub mod album_compilation_filter;
|
||||
pub mod browse_support;
|
||||
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;
|
||||
@@ -26,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;
|
||||
@@ -34,7 +37,6 @@ pub mod payload;
|
||||
pub mod repos;
|
||||
pub mod runtime;
|
||||
pub mod search;
|
||||
pub mod server_cluster;
|
||||
pub mod store;
|
||||
pub mod sync;
|
||||
pub(crate) mod track_fts;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})? {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user