mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a8ad673d | |||
| a1d8116b96 | |||
| 48a39d5930 | |||
| 73c766cb8d | |||
| 9711047fc6 | |||
| 9ead421451 | |||
| da1ad78fbe | |||
| de1924d5e3 | |||
| 85e91f5375 | |||
| 20cbdd123d | |||
| 098788b56f | |||
| 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
|
||||
|
||||
|
||||
+405
@@ -21,6 +21,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 +177,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 +244,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# 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.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.
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"lastModified": 1781074563,
|
||||
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+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-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
|
||||
}
|
||||
|
||||
Generated
+110
-110
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"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": {
|
||||
|
||||
+9
-8
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"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,7 +64,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",
|
||||
|
||||
@@ -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
+8
-7
@@ -4114,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4186,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4216,7 +4216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -4225,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
@@ -4266,6 +4266,7 @@ dependencies = [
|
||||
"psysonic-analysis",
|
||||
"psysonic-audio",
|
||||
"psysonic-core",
|
||||
"psysonic-library",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -3,9 +3,10 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.48.0-dev"
|
||||
version = "1.48.0"
|
||||
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]
|
||||
|
||||
@@ -291,7 +291,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 +516,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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -14,10 +14,11 @@ use super::decode::build_source;
|
||||
use super::engine::{audio_http_client, 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::preview::preview_clear_for_new_main_playback;
|
||||
@@ -53,9 +54,13 @@ 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>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let start_paused = start_paused.unwrap_or(false);
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// ── Ghost-command guard ───────────────────────────────────────────────────
|
||||
@@ -311,23 +316,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 +344,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 ──────────────────────────────────────
|
||||
@@ -401,7 +409,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 +423,26 @@ pub async fn audio_play(
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -166,15 +166,24 @@ 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.
|
||||
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||
.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 +209,10 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,17 +61,20 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
}
|
||||
let url = match snap.url.as_deref() {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false,
|
||||
_ => { crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: url empty/none"); return false; }
|
||||
};
|
||||
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: no AudioEngine state");
|
||||
return false;
|
||||
};
|
||||
|
||||
// Skip radio — live streams don't have a resume position.
|
||||
if engine.radio_state.lock().unwrap().is_some() {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: radio active");
|
||||
return false;
|
||||
}
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume: url={url} (kind={})", if url.starts_with("psysonic-local://") { "local" } else { "http" });
|
||||
|
||||
// Build a PlayInput without re-downloading:
|
||||
// - psysonic-local:// → seekable file
|
||||
@@ -117,7 +121,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
None => return false, // not fully cached yet — frontend will re-fetch
|
||||
None => { crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: HTTP not fully cached (no RAM cache, no spill) for url={url}"); return false; } // not fully cached yet — frontend will re-fetch
|
||||
}
|
||||
};
|
||||
PlayInput::Bytes(bytes)
|
||||
@@ -130,6 +134,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: generation race (early, after url/cache build)");
|
||||
return false; // raced with another audio_play
|
||||
}
|
||||
|
||||
@@ -168,12 +173,13 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
{
|
||||
Ok(ps) => ps,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] source build failed: {e}");
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: source build failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_eprintln!("[psysonic] DIAG-1090 try_resume false: generation race (late, after source build)");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -187,9 +193,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 +216,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,24 @@ 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;
|
||||
}
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
@@ -92,6 +93,15 @@ pub(crate) async fn reopen_output_stream(
|
||||
// Falls back gracefully to the frontend path if conditions aren't met.
|
||||
let resumed = try_resume_after_device_change(app, &snapshot).await;
|
||||
|
||||
// [DIAG #1090 — TEMPORARY] What does Rust hand to the frontend? resumed=true
|
||||
// → null payload (frontend does nothing). resumed=false → f64 → frontend
|
||||
// calls playTrack. This tells us which frontend branch the hang follows.
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] DIAG-1090 reopen returning: resumed={resumed} was_playing={} payload={}",
|
||||
snapshot.is_playing,
|
||||
if resumed { "null".to_string() } else { format!("{:?}", snapshot.current_time_secs) }
|
||||
);
|
||||
|
||||
match notify {
|
||||
ReopenNotify::DeviceChanged => {
|
||||
// null → Rust already resumed; frontend skips playTrack
|
||||
@@ -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,35 @@ 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() {
|
||||
// [DIAG #1090 — TEMPORARY] Every poll: what does cpal report as the
|
||||
// current default, and is anything pinned? If `current_default` never
|
||||
// changes when the OS default switches, cpal isn't detecting it (root A).
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] DIAG-1090 poll: pinned={pinned:?} current_default={current_default:?} last_default={last_default:?}"
|
||||
);
|
||||
|
||||
// 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.
|
||||
@@ -305,6 +333,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// [DIAG #1090 — TEMPORARY] Case 1 fired: cpal saw the default change.
|
||||
// If you see THIS line but audio stays on the old device, the reopen
|
||||
// path opens cpal's stale default (root B).
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] DIAG-1090 Case-1 fired: {last_default:?} -> {current_default:?} — reopening on new default"
|
||||
);
|
||||
|
||||
last_default = current_default.clone();
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
|
||||
@@ -7,21 +7,32 @@ use rodio::Player;
|
||||
|
||||
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>>,
|
||||
@@ -147,6 +158,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 +234,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 +251,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 +264,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 +277,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 +360,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 +386,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::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,
|
||||
};
|
||||
@@ -401,47 +400,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 +418,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 })
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::MASTER_HEADROOM;
|
||||
use super::helpers::{
|
||||
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
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.
|
||||
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
||||
/// `format=` query param on Subsonic stream URLs (transcode targets).
|
||||
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||
url.split('?')
|
||||
.nth(1)?
|
||||
.split('&')
|
||||
.find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
||||
if k.eq_ignore_ascii_case("format") {
|
||||
Some(v.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Symphonia container hint for preview downloads — mirrors main playback:
|
||||
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
|
||||
pub(crate) fn resolve_preview_format_hint(
|
||||
url: &str,
|
||||
content_type: Option<&str>,
|
||||
content_disposition: Option<&str>,
|
||||
stream_suffix: Option<&str>,
|
||||
bytes: &[u8],
|
||||
) -> Option<String> {
|
||||
let media_hint = content_type
|
||||
.and_then(content_type_to_hint)
|
||||
.or_else(|| {
|
||||
content_disposition.and_then(format_hint_from_content_disposition)
|
||||
});
|
||||
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
|
||||
resolve_playback_format_hint(
|
||||
url_hint.as_deref(),
|
||||
stream_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
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 preview_http = preview_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 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) = 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,
|
||||
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,
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||
})
|
||||
.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,
|
||||
start_sec: f64,
|
||||
duration_sec: f64,
|
||||
volume: f32,
|
||||
format_suffix: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -134,48 +363,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 bytes = preview_http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
||||
.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 = preview_format_hint_from_url(&url);
|
||||
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
|
||||
@@ -194,7 +399,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);
|
||||
|
||||
@@ -271,6 +477,55 @@ pub async fn audio_preview_play(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
Some("audio/mpeg"),
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("mp3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_uses_subsonic_suffix() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
Some("flac"),
|
||||
&[0x00, 0x01, 0x02, 0x03],
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_format_hint_from_url_reads_format_query_param() {
|
||||
assert_eq!(
|
||||
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
|
||||
Some("opus".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 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,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 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,
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//! 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)),
|
||||
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,6 +3,7 @@ name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
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,378 @@
|
||||
//! 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",
|
||||
];
|
||||
|
||||
/// 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_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_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 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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -248,6 +248,23 @@ CREATE TABLE play_session (
|
||||
CHECK (completion IN ('partial', 'full'))
|
||||
);
|
||||
|
||||
CREATE TABLE track_genre (
|
||||
server_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
genre TEXT NOT NULL,
|
||||
album_id TEXT,
|
||||
library_id TEXT,
|
||||
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
|
||||
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE library_data_migration (
|
||||
id TEXT PRIMARY KEY,
|
||||
cursor_rowid INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at INTEGER,
|
||||
started_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0;
|
||||
CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0;
|
||||
CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0;
|
||||
@@ -293,3 +310,7 @@ CREATE INDEX idx_play_session_started
|
||||
CREATE INDEX idx_track_fact_mood_tag
|
||||
ON track_fact(server_id, fact_kind, value_text, track_id)
|
||||
WHERE fact_kind = 'mood_tag';
|
||||
|
||||
CREATE INDEX idx_track_genre_browse
|
||||
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
|
||||
WHERE album_id IS NOT NULL AND album_id != '';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- psysonic-library schema v2 — large-library ingest policy (R7-15).
|
||||
-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a
|
||||
-- deep offset on a large catalog, the strategy selector stops choosing N1 for
|
||||
-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive
|
||||
-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall.
|
||||
ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Remap detection (§6.9) and unstable-id servers: without these indexes
|
||||
-- each upsert in a 500-row batch can scan the whole track table.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_remap_path
|
||||
ON track(server_id, server_path)
|
||||
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_remap_hash
|
||||
ON track(server_id, content_hash)
|
||||
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Browse / sort-by-title without sorting the full server slice on every page.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_title
|
||||
ON track(server_id, title COLLATE NOCASE)
|
||||
WHERE deleted = 0;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Advanced search filters on genre and year (partial indexes — only non-null rows).
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre
|
||||
ON track(server_id, genre COLLATE NOCASE)
|
||||
WHERE deleted = 0 AND genre IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_year
|
||||
ON track(server_id, year)
|
||||
WHERE deleted = 0 AND year IS NOT NULL;
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Player listening history — see workdocs player-stats spec §3.1
|
||||
CREATE TABLE play_session (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
listened_sec REAL NOT NULL,
|
||||
position_max_sec REAL NOT NULL,
|
||||
completion TEXT NOT NULL,
|
||||
end_reason TEXT NOT NULL,
|
||||
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
|
||||
CHECK (completion IN ('partial', 'full'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_play_session_server_time
|
||||
ON play_session(server_id, started_at_ms DESC);
|
||||
|
||||
CREATE INDEX idx_play_session_track
|
||||
ON play_session(server_id, track_id, started_at_ms DESC);
|
||||
|
||||
CREATE INDEX idx_play_session_started
|
||||
ON play_session(started_at_ms DESC);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Full-resync orphan sweep (mark-and-sweep via generation stamp).
|
||||
-- Rows ingested during a resync pass carry the active `resync_gen`; after
|
||||
-- IS-6 succeeds, live rows with a stale generation are soft-deleted.
|
||||
ALTER TABLE track ADD COLUMN resync_gen INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Atomic mood tags for Advanced Search (EXISTS on track_fact).
|
||||
CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag
|
||||
ON track_fact(server_id, fact_kind, value_text, track_id)
|
||||
WHERE fact_kind = 'mood_tag';
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
|
||||
DELETE FROM track_fact
|
||||
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Genre album browse: filter by (server, genre) then group by album_id.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre_album_browse
|
||||
ON track(server_id, genre COLLATE NOCASE, album_id)
|
||||
WHERE deleted = 0
|
||||
AND genre IS NOT NULL
|
||||
AND TRIM(genre) != ''
|
||||
AND album_id IS NOT NULL
|
||||
AND album_id != '';
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Genre album browse sort: (server, genre, album name, album_id) covering walk.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre_album_name_browse
|
||||
ON track(server_id, genre COLLATE NOCASE, album COLLATE NOCASE, album_id)
|
||||
WHERE deleted = 0
|
||||
AND genre IS NOT NULL
|
||||
AND TRIM(genre) != ''
|
||||
AND album_id IS NOT NULL
|
||||
AND album_id != '';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Repair for DBs that recorded legacy migrations 002–011 (removed) before
|
||||
-- multi-genre tables shipped. Safe on fresh installs (IF NOT EXISTS).
|
||||
CREATE TABLE IF NOT EXISTS track_genre (
|
||||
server_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
genre TEXT NOT NULL,
|
||||
album_id TEXT,
|
||||
library_id TEXT,
|
||||
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
|
||||
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_genre_browse
|
||||
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
|
||||
WHERE album_id IS NOT NULL AND album_id != '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS library_data_migration (
|
||||
id TEXT PRIMARY KEY,
|
||||
cursor_rowid INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at INTEGER,
|
||||
started_at INTEGER
|
||||
);
|
||||
@@ -49,6 +49,7 @@ type AlbumBrowseTrackRow = (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
@@ -460,8 +461,8 @@ fn build_album_from_tracks(
|
||||
);
|
||||
|
||||
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
|
||||
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
|
||||
MAX(t.starred_at), MAX(t.synced_at)";
|
||||
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
|
||||
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at)";
|
||||
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
|
||||
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
|
||||
});
|
||||
@@ -648,8 +649,8 @@ fn build_album_from_fts(
|
||||
let where_sql = w.where_sql();
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!(
|
||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.album_artist, t.artist_id, \
|
||||
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
||||
FROM track t \
|
||||
WHERE {where_sql}"
|
||||
);
|
||||
@@ -668,13 +669,27 @@ fn build_album_from_fts(
|
||||
r.get(7)?,
|
||||
r.get(8)?,
|
||||
r.get(9)?,
|
||||
r.get(10)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
|
||||
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
|
||||
for (
|
||||
server_id,
|
||||
album_id,
|
||||
album,
|
||||
track_artist,
|
||||
album_artist,
|
||||
artist_id,
|
||||
year,
|
||||
genre,
|
||||
cover_art_id,
|
||||
starred_at,
|
||||
synced_at,
|
||||
) in rows
|
||||
{
|
||||
if !seen.insert(album_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
@@ -682,7 +697,10 @@ fn build_album_from_fts(
|
||||
server_id,
|
||||
id: album_id,
|
||||
name: album,
|
||||
artist,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(
|
||||
track_artist,
|
||||
album_artist,
|
||||
),
|
||||
artist_id,
|
||||
song_count: None,
|
||||
duration_sec: None,
|
||||
@@ -868,10 +886,10 @@ fn resolve_clause(
|
||||
}));
|
||||
}
|
||||
("compilation", EntityKind::Album) => {
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Album);
|
||||
}
|
||||
("compilation", EntityKind::Track) => {
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
|
||||
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Track);
|
||||
}
|
||||
("compilation", _) => return Ok(None),
|
||||
// `text` is handled by the entity builder (FTS / LIKE), never here.
|
||||
@@ -882,8 +900,25 @@ fn resolve_clause(
|
||||
|
||||
if c.field == "genre" {
|
||||
let v = json_to_text(&c.field, c.value.as_ref())?;
|
||||
let sql = match entity {
|
||||
EntityKind::Track => {
|
||||
"EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)"
|
||||
.to_string()
|
||||
}
|
||||
EntityKind::Album => {
|
||||
"EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = a.server_id AND tg.album_id = a.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)"
|
||||
.to_string()
|
||||
}
|
||||
_ => {
|
||||
return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string());
|
||||
}
|
||||
};
|
||||
return Ok(Some(SqlFragment {
|
||||
sql: format!("{col} = ? COLLATE NOCASE"),
|
||||
sql,
|
||||
params: vec![v],
|
||||
}));
|
||||
}
|
||||
@@ -1156,19 +1191,21 @@ fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
|
||||
}
|
||||
|
||||
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
|
||||
let track_artist: Option<String> = r.get(3)?;
|
||||
let album_artist: Option<String> = r.get(5)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(track_artist, album_artist),
|
||||
artist_id: r.get(4)?,
|
||||
song_count: Some(r.get(5)?),
|
||||
duration_sec: Some(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)?,
|
||||
song_count: Some(r.get(6)?),
|
||||
duration_sec: Some(r.get(7)?),
|
||||
year: r.get(8)?,
|
||||
genre: r.get(9)?,
|
||||
cover_art_id: r.get(10)?,
|
||||
starred_at: r.get(11)?,
|
||||
synced_at: r.get(12)?,
|
||||
raw_json: Value::Null,
|
||||
})
|
||||
}
|
||||
@@ -1264,9 +1301,21 @@ fn compilation_filter_fragment(
|
||||
field: &str,
|
||||
op: FilterOp,
|
||||
value: Option<&Value>,
|
||||
table_alias: &str,
|
||||
kind: EntityKind,
|
||||
) -> Result<Option<SqlFragment>, String> {
|
||||
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
|
||||
let comp_sql = match kind {
|
||||
EntityKind::Album => crate::album_compilation_filter::compilation_predicate_sql(
|
||||
"a",
|
||||
Some("a.artist"),
|
||||
None,
|
||||
),
|
||||
EntityKind::Track => crate::album_compilation_filter::compilation_predicate_sql(
|
||||
"t",
|
||||
Some("t.artist"),
|
||||
Some("t.album_artist"),
|
||||
),
|
||||
_ => crate::album_compilation_filter::compilation_raw_json_sql("t"),
|
||||
};
|
||||
match op {
|
||||
FilterOp::IsTrue => Ok(Some(SqlFragment {
|
||||
sql: comp_sql,
|
||||
@@ -2032,6 +2081,45 @@ mod tests {
|
||||
assert_eq!(resp.albums[0].id, "al_comp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_matches_va_album_artist_on_track_groups() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut comp = track("s1", "t_comp", "Hit", "Alice", "Comp Album");
|
||||
comp.album_id = Some("al_comp".into());
|
||||
comp.album_artist = Some("Various Artists".into());
|
||||
comp.raw_json = "{}".into();
|
||||
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
|
||||
reg.album_id = Some("al_reg".into());
|
||||
reg.raw_json = "{}".into();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[comp, reg])
|
||||
.unwrap();
|
||||
let mut r = req("s1", &[EntityKind::Album]);
|
||||
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al_comp");
|
||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_grouped_album_browse_prefers_album_artist_over_track_artist() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut t1 = track("s1", "t1", "Anthem", "Groove Armada", "Back to Mine");
|
||||
t1.album_id = Some("al_mix".into());
|
||||
t1.album_artist = Some("Underworld".into());
|
||||
let mut t2 = track("s1", "t2", "Zebra", "UNKLE", "Back to Mine");
|
||||
t2.album_id = Some("al_mix".into());
|
||||
t2.album_artist = Some("Underworld".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[t1, t2])
|
||||
.unwrap();
|
||||
let r = req("s1", &[EntityKind::Album]);
|
||||
let resp = run_advanced_search(&store, &r).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Underworld"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_filter_on_track_grouped_album_browse() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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 \
|
||||
@@ -113,13 +113,26 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut sql = String::from(
|
||||
"SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \
|
||||
FROM track t \
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0 \
|
||||
AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \
|
||||
AND t.album_id IS NOT NULL AND t.album_id != ''",
|
||||
);
|
||||
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
|
||||
let mut sql = if scoped {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
};
|
||||
let mut params: Vec<rusqlite::types::Value> =
|
||||
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
@@ -127,8 +140,8 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
params.push(rusqlite::types::Value::Text(scope.to_string()));
|
||||
}
|
||||
sql.push_str(
|
||||
" GROUP BY t.genre COLLATE NOCASE \
|
||||
ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC",
|
||||
" GROUP BY tg.genre COLLATE NOCASE \
|
||||
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
@@ -337,6 +350,26 @@ mod tests {
|
||||
assert_eq!(counts[0].song_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_scope_reads_library_id_from_track_raw_json() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut scoped = make_row("s1", "r1", "al_a", 1);
|
||||
scoped.genre = Some("Rock".into());
|
||||
scoped.library_id = None;
|
||||
scoped.raw_json = r#"{"libraryId":"lib1"}"#.into();
|
||||
let mut other = make_row("s1", "r2", "al_b", 1);
|
||||
other.genre = Some("Rock".into());
|
||||
other.library_id = None;
|
||||
other.raw_json = r#"{"libraryId":"lib2"}"#.into();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_album_stars_clears_all_when_server_list_empty() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -396,6 +396,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>,
|
||||
@@ -442,7 +476,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",
|
||||
@@ -492,6 +526,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>,
|
||||
@@ -994,7 +1045,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",
|
||||
@@ -1041,7 +1092,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 {
|
||||
@@ -1177,7 +1228,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))?;
|
||||
|
||||
@@ -19,19 +19,20 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for s in sort {
|
||||
let col = match s.field.as_str() {
|
||||
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
|
||||
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
|
||||
"year" => "COALESCE(a.year, la.year)",
|
||||
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE".to_string(),
|
||||
"artist" => format!("COALESCE(a.artist, {la_artist}) COLLATE NOCASE"),
|
||||
"year" => "COALESCE(a.year, la.year)".to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
SortDir::Asc => "ASC",
|
||||
SortDir::Desc => "DESC",
|
||||
};
|
||||
keys.push(format!("{col} {dir}"));
|
||||
keys.push(format!("{col} {dir}", col = col));
|
||||
}
|
||||
if keys.is_empty() {
|
||||
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
|
||||
@@ -44,10 +45,16 @@ fn count_genre_albums(
|
||||
conn: &rusqlite::Connection,
|
||||
where_sql: &str,
|
||||
params: &[SqlValue],
|
||||
library_scoped: bool,
|
||||
) -> Result<u32, rusqlite::Error> {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}"
|
||||
);
|
||||
let from = if library_scoped {
|
||||
"FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0"
|
||||
} else {
|
||||
"FROM track_genre tg"
|
||||
};
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
|
||||
let n: i64 = conn.query_row(
|
||||
&count_sql,
|
||||
rusqlite::params_from_iter(params.iter()),
|
||||
@@ -107,28 +114,29 @@ pub fn list_albums_by_genre(
|
||||
let order_sql = genre_album_order_sql(&req.sort);
|
||||
|
||||
let mut where_clauses = vec![
|
||||
"t.deleted = 0".to_string(),
|
||||
"t.server_id = ?1".to_string(),
|
||||
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
|
||||
"t.genre = ?2 COLLATE NOCASE".to_string(),
|
||||
"tg.server_id = ?1".to_string(),
|
||||
"tg.album_id IS NOT NULL AND tg.album_id != ''".to_string(),
|
||||
"tg.genre = ?2 COLLATE NOCASE".to_string(),
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(genre.to_string()),
|
||||
];
|
||||
|
||||
let library_scoped = trimmed_nonempty(req.library_scope.as_deref()).is_some();
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
where_clauses.push(library_scope_equals_sql("t"));
|
||||
params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let sql = format!(
|
||||
"SELECT \
|
||||
la.server_id, \
|
||||
la.album_id, \
|
||||
COALESCE(a.name, la.album_name), \
|
||||
COALESCE(a.artist, la.artist), \
|
||||
COALESCE(a.artist, {la_artist}), \
|
||||
COALESCE(a.artist_id, la.artist_id), \
|
||||
COALESCE(a.song_count, la.track_count), \
|
||||
COALESCE(a.duration_sec, la.duration_sec), \
|
||||
@@ -140,10 +148,11 @@ pub fn list_albums_by_genre(
|
||||
a.raw_json \
|
||||
FROM ( \
|
||||
SELECT \
|
||||
t.server_id, \
|
||||
t.album_id, \
|
||||
tg.server_id, \
|
||||
tg.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
@@ -152,9 +161,11 @@ pub fn list_albums_by_genre(
|
||||
MAX(t.synced_at) AS synced_at, \
|
||||
COUNT(*) AS track_count, \
|
||||
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
|
||||
FROM track t \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE {where_sql} \
|
||||
GROUP BY t.server_id, t.album_id \
|
||||
GROUP BY tg.server_id, tg.album_id \
|
||||
) la \
|
||||
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
|
||||
{order_sql} \
|
||||
@@ -167,7 +178,7 @@ pub fn list_albums_by_genre(
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let total = if req.include_total {
|
||||
Some(count_genre_albums(conn, &where_sql, &count_params)?)
|
||||
Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -282,4 +293,49 @@ mod tests {
|
||||
assert_eq!(all.total, Some(3));
|
||||
assert!(all.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_albums_by_atomic_genre_from_compound_tag() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track(
|
||||
"s1",
|
||||
"t1",
|
||||
"al_a",
|
||||
"Noise Metal/Dark Ambient/Experimental Black Metal",
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
let dark = list_albums_by_genre(
|
||||
&store,
|
||||
&LibraryGenreAlbumsRequest {
|
||||
server_id: "s1".into(),
|
||||
genre: "Dark Ambient".into(),
|
||||
library_scope: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
include_total: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(dark.total, Some(1));
|
||||
assert_eq!(dark.albums.len(), 1);
|
||||
assert_eq!(dark.albums[0].id, "al_a");
|
||||
|
||||
let noise = list_albums_by_genre(
|
||||
&store,
|
||||
&LibraryGenreAlbumsRequest {
|
||||
server_id: "s1".into(),
|
||||
genre: "Noise Metal".into(),
|
||||
library_scope: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
include_total: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(noise.total, Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Atomic genre resolution for multi-value tags (OpenSubsonic `genres[]` first,
|
||||
//! Navidrome-default string split as fallback).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{params, Transaction};
|
||||
use serde_json::Value;
|
||||
|
||||
const GENRE_SEPARATORS: [&str; 3] = [";", "/", ","];
|
||||
|
||||
/// Fallback split when the server sent no `genres[]` array (legacy Subsonic).
|
||||
pub fn split_genre_tags(raw: &str) -> Vec<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = vec![trimmed.to_string()];
|
||||
for sep in GENRE_SEPARATORS {
|
||||
let mut next = Vec::new();
|
||||
for part in parts {
|
||||
for sub in part.split(sep) {
|
||||
next.push(sub.to_string());
|
||||
}
|
||||
}
|
||||
parts = next;
|
||||
}
|
||||
dedupe_genres(parts)
|
||||
}
|
||||
|
||||
fn dedupe_genres(genres: Vec<String>) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for g in genres {
|
||||
let t = g.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = t.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_genres_array_value(value: &Value) -> Option<Vec<String>> {
|
||||
let arr = value.as_array()?;
|
||||
if arr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for item in arr {
|
||||
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
|
||||
let t = name.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
} else if let Some(s) = item.as_str() {
|
||||
let t = s.trim();
|
||||
if !t.is_empty() {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(dedupe_genres(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_genres_json_str(genres_json: &str) -> Option<Vec<String>> {
|
||||
let trimmed = genres_json.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let value: Value = serde_json::from_str(trimmed).ok()?;
|
||||
parse_genres_array_value(&value)
|
||||
}
|
||||
|
||||
/// Source-priority resolver (§2.0): `genres[]` from parsed payload, else split `genre`.
|
||||
pub fn genres_for_track_value(raw_json: &Value, genre: Option<&str>) -> Vec<String> {
|
||||
if let Some(genres) = raw_json.get("genres").and_then(parse_genres_array_value) {
|
||||
return genres;
|
||||
}
|
||||
genre
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(split_genre_tags)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Backfill path: `genres_json` from `json_extract(raw_json, '$.genres')`.
|
||||
pub fn genres_for_track_extracted(genres_json: Option<&str>, genre: Option<&str>) -> Vec<String> {
|
||||
if let Some(json) = genres_json {
|
||||
if let Some(genres) = parse_genres_json_str(json) {
|
||||
return genres;
|
||||
}
|
||||
}
|
||||
genre
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(split_genre_tags)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn genres_for_track_raw_json(raw_json: &str, genre: Option<&str>) -> Vec<String> {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(raw_json) {
|
||||
return genres_for_track_value(&value, genre);
|
||||
}
|
||||
genre
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(split_genre_tags)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn replace_track_genre_rows(
|
||||
tx: &Transaction<'_>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
album_id: Option<&str>,
|
||||
library_id: Option<&str>,
|
||||
genres: &[String],
|
||||
) -> rusqlite::Result<()> {
|
||||
tx.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
params![server_id, track_id],
|
||||
)?;
|
||||
if genres.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO track_genre (server_id, track_id, genre, album_id, library_id) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
for genre in genres {
|
||||
insert.execute(params![server_id, track_id, genre, album_id, library_id])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_track_genre_for_track(
|
||||
conn: &rusqlite::Connection,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
params![server_id, track_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_track_genre_for_server_tracks(
|
||||
conn: &rusqlite::Connection,
|
||||
server_id: &str,
|
||||
track_ids: &[String],
|
||||
) -> rusqlite::Result<()> {
|
||||
if track_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for id in track_ids {
|
||||
delete_track_genre_for_track(conn, server_id, id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn split_separators_and_dedupe() {
|
||||
assert_eq!(
|
||||
split_genre_tags("Rock/Jazz"),
|
||||
vec!["Rock".to_string(), "Jazz".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
split_genre_tags("Rock; Jazz, Electronic"),
|
||||
vec![
|
||||
"Rock".to_string(),
|
||||
"Jazz".to_string(),
|
||||
"Electronic".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(split_genre_tags("Rock/rock/ROCK"), vec!["Rock".to_string()]);
|
||||
assert!(split_genre_tags("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_wins_over_genre_string() {
|
||||
let raw = json!({
|
||||
"genres": [{"name": "A"}, {"name": "B"}],
|
||||
"genre": "A/B/C"
|
||||
});
|
||||
assert_eq!(
|
||||
genres_for_track_value(&raw, Some("A/B/C")),
|
||||
vec!["A".to_string(), "B".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_string_array_and_empty_array_fallback() {
|
||||
let bare = json!({ "genres": ["A", "B"] });
|
||||
assert_eq!(
|
||||
genres_for_track_value(&bare, None),
|
||||
vec!["A".to_string(), "B".to_string()]
|
||||
);
|
||||
let empty = json!({ "genres": [], "genre": "A/B" });
|
||||
assert_eq!(
|
||||
genres_for_track_value(&empty, Some("A/B")),
|
||||
vec!["A".to_string(), "B".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_json_matches_value_path() {
|
||||
let genres_json = r#"[{"name":"Jazz"},{"name":"Rock"}]"#;
|
||||
assert_eq!(
|
||||
genres_for_track_extracted(Some(genres_json), Some("Noise/Metal")),
|
||||
vec!["Jazz".to_string(), "Rock".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! One-time blocking backfill: populate `track_genre` from existing `track` rows.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::genre_tags::{genres_for_track_extracted, replace_track_genre_rows};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
pub const GENRE_TAGS_MIGRATION_ID: &str = "genre_tags_v1";
|
||||
|
||||
const BATCH_SIZE: i64 = 10_000;
|
||||
|
||||
type BackfillTrackRow = (
|
||||
i64,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
|
||||
crate::store::ensure_genre_tags_schema(conn)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreTagsInspectDto {
|
||||
pub needed: bool,
|
||||
pub total_tracks: u64,
|
||||
pub done_tracks: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreTagsProgressEvent {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn migration_completed(conn: &Connection) -> Result<bool, rusqlite::Error> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
fn count_live_tracks(conn: &Connection) -> Result<u64, rusqlite::Error> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n.max(0) as u64)
|
||||
}
|
||||
|
||||
fn cursor_rowid(conn: &Connection) -> Result<i64, rusqlite::Error> {
|
||||
let rowid: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(rowid.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub fn inspect_genre_tags_backfill(store: &LibraryStore) -> Result<GenreTagsInspectDto, String> {
|
||||
store.with_conn_mut("genre_tags.ensure_schema", ensure_genre_tags_tables)?;
|
||||
store.with_read_conn(|conn| {
|
||||
let total_tracks = count_live_tracks(conn)?;
|
||||
if total_tracks == 0 {
|
||||
return Ok(GenreTagsInspectDto {
|
||||
needed: false,
|
||||
total_tracks: 0,
|
||||
done_tracks: 0,
|
||||
});
|
||||
}
|
||||
if migration_completed(conn)? {
|
||||
return Ok(GenreTagsInspectDto {
|
||||
needed: false,
|
||||
total_tracks,
|
||||
done_tracks: total_tracks,
|
||||
});
|
||||
}
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![cursor],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(GenreTagsInspectDto {
|
||||
needed: true,
|
||||
total_tracks,
|
||||
done_tracks: done.max(0) as u64,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, done: u64, total: u64) -> Result<(), String> {
|
||||
app.emit(
|
||||
"genre_tags:progress",
|
||||
GenreTagsProgressEvent { done, total },
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn run_genre_tags_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> {
|
||||
run_genre_tags_backfill_impl(store, Some(app))
|
||||
}
|
||||
|
||||
fn run_genre_tags_backfill_impl(
|
||||
store: &LibraryStore,
|
||||
app: Option<&AppHandle>,
|
||||
) -> Result<(), String> {
|
||||
let inspect = inspect_genre_tags_backfill(store)?;
|
||||
if !inspect.needed {
|
||||
return Ok(());
|
||||
}
|
||||
let total = inspect.total_tracks;
|
||||
|
||||
loop {
|
||||
let (batch_done, finished) = store.with_conn_mut("genre_tags.backfill", |conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok::<(i64, bool), rusqlite::Error>((total as i64, true));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
|
||||
VALUES (?1, 0, ?2) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
started_at = COALESCE(library_data_migration.started_at, excluded.started_at)",
|
||||
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
|
||||
)?;
|
||||
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rowid, server_id, id, genre, \
|
||||
CASE WHEN json_valid(raw_json) THEN json_extract(raw_json, '$.genres') END, \
|
||||
album_id, library_id \
|
||||
FROM track \
|
||||
WHERE deleted = 0 AND rowid > ?1 \
|
||||
ORDER BY rowid \
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
|
||||
let rows: Vec<BackfillTrackRow> =
|
||||
stmt
|
||||
.query_map(params![cursor, BATCH_SIZE], |r| {
|
||||
Ok((
|
||||
r.get(0)?,
|
||||
r.get(1)?,
|
||||
r.get(2)?,
|
||||
r.get(3)?,
|
||||
r.get(4)?,
|
||||
r.get(5)?,
|
||||
r.get(6)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
if rows.is_empty() {
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
|
||||
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
|
||||
WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
|
||||
)?;
|
||||
return Ok((total as i64, true));
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut last_rowid = cursor;
|
||||
for (rowid, server_id, track_id, genre, genres_json, album_id, library_id) in rows {
|
||||
let genres = genres_for_track_extracted(
|
||||
genres_json.as_deref(),
|
||||
genre.as_deref(),
|
||||
);
|
||||
replace_track_genre_rows(
|
||||
&tx,
|
||||
&server_id,
|
||||
&track_id,
|
||||
album_id.as_deref(),
|
||||
library_id.as_deref(),
|
||||
&genres,
|
||||
)?;
|
||||
last_rowid = rowid;
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID, last_rowid],
|
||||
)?;
|
||||
|
||||
let done: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
|
||||
params![last_rowid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((done, false))
|
||||
})?;
|
||||
|
||||
if let Some(app) = app {
|
||||
emit_progress(app, batch_done.max(0) as u64, total)?;
|
||||
}
|
||||
|
||||
if finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: all live tracks processed but `completed_at` not set
|
||||
// (can happen when rowid gaps from soft-deletes make done == total early).
|
||||
store.with_conn_mut("genre_tags.backfill.finalize", |conn| {
|
||||
if migration_completed(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
let cursor = cursor_rowid(conn)?;
|
||||
let pending: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid > ?1",
|
||||
params![cursor],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if pending == 0 {
|
||||
conn.execute(
|
||||
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
|
||||
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
|
||||
WHERE id = ?1",
|
||||
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::track::{TrackRepository, TrackRow};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
fn track(server_id: &str, id: &str, genre: &str, deleted: bool) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server_id.into(),
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
title_sort: None,
|
||||
artist: Some("Artist".into()),
|
||||
artist_id: None,
|
||||
album: "Album".into(),
|
||||
album_id: Some("al1".into()),
|
||||
album_artist: None,
|
||||
duration_sec: 100,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: None,
|
||||
genre: Some(genre.into()),
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: Some("lib1".into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_marks_complete_when_rowid_gaps_leave_pending_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let live: Vec<TrackRow> = (1..=5)
|
||||
.map(|n| track("s1", &format!("t{n}"), "Rock", false))
|
||||
.collect();
|
||||
let mut batch = live;
|
||||
for n in 6..=20 {
|
||||
batch.push(track("s1", &format!("del{n}"), "Rock", true));
|
||||
}
|
||||
batch.push(track("s1", "t6", "Jazz", false));
|
||||
TrackRepository::new(&store).upsert_batch(&batch).unwrap();
|
||||
|
||||
run_genre_tags_backfill_impl(&store, None).unwrap();
|
||||
|
||||
let inspect = inspect_genre_tags_backfill(&store).unwrap();
|
||||
assert!(!inspect.needed, "backfill should complete despite rowid gaps");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,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;
|
||||
|
||||
@@ -244,8 +244,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 +262,29 @@ fn query_albums(
|
||||
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
|
||||
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
||||
append_library_scope(&mut sql, &mut params, library_scope);
|
||||
sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?");
|
||||
sql.push_str(" GROUP BY t.server_id, t.album_id ORDER BY best_rank LIMIT ?");
|
||||
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut out = Vec::new();
|
||||
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
let track_artist: Option<String> = r.get(3)?;
|
||||
let album_artist: Option<String> = r.get(4)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
artist: crate::album_compilation_filter::pick_album_group_artist(
|
||||
track_artist,
|
||||
album_artist,
|
||||
),
|
||||
artist_id: r.get(5)?,
|
||||
song_count: None,
|
||||
duration_sec: None,
|
||||
year: r.get(5)?,
|
||||
genre: r.get(6)?,
|
||||
cover_art_id: r.get(7)?,
|
||||
starred_at: r.get(8)?,
|
||||
synced_at: r.get(9)?,
|
||||
year: r.get(6)?,
|
||||
genre: r.get(7)?,
|
||||
cover_art_id: r.get(8)?,
|
||||
starred_at: r.get(9)?,
|
||||
synced_at: r.get(10)?,
|
||||
raw_json: serde_json::Value::Null,
|
||||
})
|
||||
})? {
|
||||
|
||||
@@ -44,12 +44,13 @@ pub fn list_lossless_albums(
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
let sql = format!(
|
||||
"SELECT \
|
||||
la.server_id, \
|
||||
la.album_id, \
|
||||
COALESCE(a.name, la.album_name), \
|
||||
COALESCE(a.artist, la.artist), \
|
||||
COALESCE(a.artist, {la_artist}), \
|
||||
COALESCE(a.artist_id, la.artist_id), \
|
||||
COALESCE(a.song_count, la.track_count), \
|
||||
COALESCE(a.duration_sec, la.duration_sec), \
|
||||
@@ -65,6 +66,7 @@ pub fn list_lossless_albums(
|
||||
t.album_id, \
|
||||
MAX(t.album) AS album_name, \
|
||||
MAX(t.artist) AS artist, \
|
||||
MAX(t.album_artist) AS album_artist, \
|
||||
MAX(t.artist_id) AS artist_id, \
|
||||
MAX(t.year) AS year, \
|
||||
MAX(t.genre) AS genre, \
|
||||
|
||||
@@ -29,7 +29,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
/// if none exists. All non-PK columns fall back to their schema DEFAULTs
|
||||
/// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …).
|
||||
pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.ensure", |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)",
|
||||
params![server_id, library_scope],
|
||||
@@ -73,7 +73,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
cursor: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?;
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_initial_sync_cursor", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -134,7 +134,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
flags: u32,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_capability_flags", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -192,7 +192,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
phase: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_sync_phase", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, sync_phase) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -212,7 +212,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_scan_iso: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_server_last_scan_iso", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -232,7 +232,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -330,7 +330,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_next_poll_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, next_poll_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -375,7 +375,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
stats: &Value,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(stats).map_err(|e| e.to_string())?;
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_poll_stats_json", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -412,7 +412,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_local_track_count", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -447,7 +447,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
count: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_server_track_count", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -488,7 +488,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
unreliable: bool,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -508,7 +508,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_last_full_sync_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -528,7 +528,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
epoch_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_last_delta_sync_at", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -549,7 +549,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
last_modified_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
@@ -570,7 +570,7 @@ impl<'a> SyncStateRepository<'a> {
|
||||
library_scope: &str,
|
||||
tier: &str,
|
||||
) -> Result<(), String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("sync_state.set_library_tier", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (server_id, library_scope, library_tier) \
|
||||
VALUES (?1, ?2, ?3) \
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use rusqlite::{params, OptionalExtension, Transaction};
|
||||
|
||||
use crate::genre_tags::{self, genres_for_track_raw_json};
|
||||
use crate::store::{LibraryStore, WriteOpTiming};
|
||||
|
||||
fn sync_track_genre_row(tx: &Transaction<'_>, row: &TrackRow) -> rusqlite::Result<()> {
|
||||
if row.deleted {
|
||||
return genre_tags::delete_track_genre_for_track(tx, &row.server_id, &row.id);
|
||||
}
|
||||
let genres = genres_for_track_raw_json(&row.raw_json, row.genre.as_deref());
|
||||
genre_tags::replace_track_genre_rows(
|
||||
tx,
|
||||
&row.server_id,
|
||||
&row.id,
|
||||
row.album_id.as_deref(),
|
||||
row.library_id.as_deref(),
|
||||
&genres,
|
||||
)
|
||||
}
|
||||
|
||||
/// One row of the `track` table — every hot column from spec §5.1 plus
|
||||
/// `raw_json` (the full normalized SubsonicSong). Sync code (PR-2/PR-3) is
|
||||
/// expected to project ingested payloads into this shape, not to talk SQL
|
||||
@@ -181,6 +197,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.raw_json,
|
||||
])?;
|
||||
}
|
||||
sync_track_genre_row(&tx, r)?;
|
||||
}
|
||||
drop(upsert);
|
||||
tx.commit()?;
|
||||
@@ -191,7 +208,7 @@ impl<'a> TrackRepository<'a> {
|
||||
|
||||
/// Next generation stamp for a full-resync orphan sweep on this server.
|
||||
pub fn next_resync_gen(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_conn("misc", |c| {
|
||||
self.store.with_conn("track.next_resync_gen", |c| {
|
||||
c.query_row(
|
||||
"SELECT COALESCE(MAX(resync_gen), 0) + 1 FROM track WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
@@ -203,7 +220,15 @@ impl<'a> TrackRepository<'a> {
|
||||
/// IS-7 — soft-delete live rows not re-stamped during the active resync.
|
||||
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
|
||||
let now = now_unix_ms();
|
||||
let changed = self.store.with_conn_mut("misc", |c| {
|
||||
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
|
||||
c.execute(
|
||||
"DELETE FROM track_genre \
|
||||
WHERE server_id = ?1 AND track_id IN ( \
|
||||
SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2 \
|
||||
)",
|
||||
params![server_id, resync_gen],
|
||||
)?;
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
|
||||
@@ -228,6 +253,18 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// All live rows for a Subsonic track id (any server). Used when legacy offline
|
||||
/// folders name the server by URL index key rather than profile UUID.
|
||||
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
|
||||
let rows = stmt
|
||||
.query_map(params![track_id], row_to_track_row)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
|
||||
/// preserve their order in the result; unknown / deleted refs
|
||||
/// are silently dropped (frontend reads `tracks.length` against
|
||||
@@ -292,6 +329,26 @@ impl<'a> TrackRepository<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy offline rows keyed by library `server_id` (index key scope).
|
||||
pub fn list_offline_local_paths(
|
||||
&self,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<(String, String, Option<String>)>, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![server_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
|
||||
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
|
||||
pub fn list_analysis_hash_bpm_ids_after(
|
||||
@@ -386,7 +443,7 @@ impl<'a> TrackRepository<'a> {
|
||||
if rows.is_empty() {
|
||||
return Ok(RemapStats::default());
|
||||
}
|
||||
self.store.with_conn_mut("misc", |conn| {
|
||||
self.store.with_conn_mut("track.upsert_batch_remap", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut remapped: Vec<RemapEntry> = Vec::new();
|
||||
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
|
||||
@@ -445,6 +502,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.synced_at,
|
||||
r.raw_json,
|
||||
])?;
|
||||
sync_track_genre_row(&tx, r)?;
|
||||
|
||||
if let Some(old_id) = detected_old {
|
||||
remap_existing_to_new(
|
||||
@@ -583,6 +641,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE id = ?1 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
@@ -1004,8 +1069,9 @@ mod tests {
|
||||
repo.upsert_batch_initial_ingest(&rows).unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(500),
|
||||
"initial ingest batch(500) took {elapsed:?}"
|
||||
elapsed < std::time::Duration::from_millis(1000),
|
||||
"initial ingest batch(500) took {elapsed:?}; includes per-row track_genre \
|
||||
maintenance and large raw_json payloads"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
|
||||
server_id: &str,
|
||||
old_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("track_id_history.lookup", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT new_id FROM track_id_history \
|
||||
WHERE server_id = ?1 AND old_id = ?2",
|
||||
@@ -40,7 +40,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
|
||||
/// Count the rows recorded for this server — used by tests and by
|
||||
/// post-sync diagnostics (Settings „Library index" panel later).
|
||||
pub fn count_for_server(&self, server_id: &str) -> Result<i64, String> {
|
||||
self.store.with_conn("misc", |conn| {
|
||||
self.store.with_conn("track_id_history.count", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
|
||||
@@ -9,7 +9,7 @@ use tauri::Manager;
|
||||
|
||||
/// Current head of the embedded migrations. Bump each time a new
|
||||
/// `migrations/NNN_*.sql` is added.
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 12;
|
||||
|
||||
/// Lowest applied schema version the current code can advance from purely
|
||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
||||
@@ -22,10 +22,20 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
|
||||
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
|
||||
|
||||
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
|
||||
/// Version 12 is above the removed legacy migrations 002–011 so existing DBs
|
||||
/// still pick up `track_genre` + `library_data_migration`.
|
||||
pub(crate) const MIGRATION_012_TRACK_GENRE_LEGACY: &str =
|
||||
include_str!("../migrations/012_track_genre_legacy_repair.sql");
|
||||
|
||||
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
|
||||
/// defensively before applying so the source order can stay readable.
|
||||
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
|
||||
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL), (12, MIGRATION_012_TRACK_GENRE_LEGACY)];
|
||||
|
||||
/// Idempotent repair — also runs after the migration runner on every open so
|
||||
/// DBs that recorded the wrong version numbers still get the tables.
|
||||
pub(crate) fn ensure_genre_tags_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(MIGRATION_012_TRACK_GENRE_LEGACY)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MigrationOutcome {
|
||||
@@ -67,6 +77,7 @@ impl LibraryStore {
|
||||
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
|
||||
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
|
||||
run_migrations(&write_conn).map_err(|e| e.to_string())?;
|
||||
ensure_genre_tags_schema(&write_conn).map_err(|e| e.to_string())?;
|
||||
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
|
||||
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -84,6 +95,7 @@ impl LibraryStore {
|
||||
let write_conn = Connection::open(&uri).expect("in-memory write connection");
|
||||
configure_write_connection(&write_conn).expect("write pragmas");
|
||||
run_migrations(&write_conn).expect("schema migration");
|
||||
ensure_genre_tags_schema(&write_conn).expect("genre tags schema");
|
||||
let read_conn = Connection::open(&uri).expect("in-memory read connection");
|
||||
configure_read_connection(&read_conn).expect("read pragmas");
|
||||
Self {
|
||||
@@ -103,6 +115,11 @@ impl LibraryStore {
|
||||
}
|
||||
|
||||
/// Writer connection — sync ingest, migrations, mutations.
|
||||
///
|
||||
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
|
||||
/// stable `module.action` label (e.g. `sync_state.set_sync_phase`,
|
||||
/// `track.upsert_batch_remap`), not the generic `"misc"`, so production
|
||||
/// stalls can be attributed to a specific call site.
|
||||
pub(crate) fn with_conn<R>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
@@ -551,8 +568,7 @@ mod tests {
|
||||
rows
|
||||
})
|
||||
.unwrap();
|
||||
// Embedded migrations are numbered 1..=head, all applied on a fresh DB.
|
||||
let expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
|
||||
let expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
|
||||
assert_eq!(versions, expected);
|
||||
}
|
||||
|
||||
@@ -569,11 +585,53 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count, LIBRARY_DB_SCHEMA_VERSION,
|
||||
count,
|
||||
MIGRATIONS.len() as i64,
|
||||
"one schema_migrations row per embedded migration, no duplicates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_012_repairs_db_that_recorded_legacy_versions_without_genre_tables() {
|
||||
let uri = in_memory_uri();
|
||||
let conn = Connection::open(&uri).expect("connection");
|
||||
configure_write_connection(&conn).expect("pragmas");
|
||||
conn.execute_batch(INITIAL_SQL).expect("initial");
|
||||
conn.execute("DROP TABLE IF EXISTS track_genre", [])
|
||||
.expect("drop track_genre");
|
||||
conn.execute("DROP TABLE IF EXISTS library_data_migration", [])
|
||||
.expect("drop cursor table");
|
||||
for version in 1..=11_i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?1)",
|
||||
params![version],
|
||||
)
|
||||
.expect("seed legacy versions");
|
||||
}
|
||||
|
||||
let outcome = run_migrations_with(
|
||||
&conn,
|
||||
MIGRATIONS,
|
||||
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
|
||||
no_op_hook,
|
||||
)
|
||||
.expect("apply v12 repair");
|
||||
assert_eq!(outcome, MigrationOutcome::Applied);
|
||||
ensure_genre_tags_schema(&conn).expect("ensure");
|
||||
|
||||
for table in ["track_genre", "library_data_migration"] {
|
||||
let exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master \
|
||||
WHERE type = 'table' AND name = ?1",
|
||||
params![table],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("table probe");
|
||||
assert_eq!(exists, 1, "missing table {table}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_virtual_table_exists() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
@@ -651,8 +709,7 @@ mod tests {
|
||||
rows
|
||||
})
|
||||
.unwrap();
|
||||
// Real embedded migrations (1..=head) plus the additive fixture.
|
||||
let mut expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
|
||||
let mut expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
|
||||
expected.push(FIXTURE_ADD_BIO_VERSION);
|
||||
assert_eq!(versions, expected);
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
|
||||
self.store
|
||||
.with_conn("misc", |c| {
|
||||
.with_conn("delta.local_track_watermark", |c| {
|
||||
c.query_row(
|
||||
"SELECT MAX(server_updated_at) FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0",
|
||||
@@ -297,7 +297,7 @@ impl<'a> DeltaSyncRunner<'a> {
|
||||
|
||||
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("misc", |c| {
|
||||
.with_conn("delta.local_album_ids", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT DISTINCT album_id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
|
||||
|
||||
@@ -115,7 +115,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn next_candidates(&self, budget: u32) -> Result<Vec<String>, SyncError> {
|
||||
self.store
|
||||
.with_conn("misc", |c| {
|
||||
.with_conn("tombstone.next_candidates", |c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
@@ -133,12 +133,16 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn mark_deleted(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("misc", |c| {
|
||||
.with_conn("tombstone.mark_deleted", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1, synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
rusqlite::params![self.server_id, id, now_unix_ms()],
|
||||
)?;
|
||||
c.execute(
|
||||
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
|
||||
rusqlite::params![self.server_id, id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(SyncError::Storage)
|
||||
@@ -146,7 +150,7 @@ impl<'a> TombstoneReconciler<'a> {
|
||||
|
||||
fn mark_synced(&self, id: &str) -> Result<(), SyncError> {
|
||||
self.store
|
||||
.with_conn("misc", |c| {
|
||||
.with_conn("tombstone.mark_synced", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET synced_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
|
||||
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-library = { path = "../psysonic-library" }
|
||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||
psysonic-audio = { path = "../psysonic-audio" }
|
||||
|
||||
|
||||
+100
-2
@@ -1,4 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Recursively sums the size of all files under `root`.
|
||||
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
|
||||
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
||||
total
|
||||
}
|
||||
|
||||
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
|
||||
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let rd = match std::fs::read_dir(&dir) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
|
||||
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
|
||||
let mut current = path.parent()?;
|
||||
loop {
|
||||
match current.file_name().and_then(|s| s.to_str()) {
|
||||
Some("cache") | Some("library") | Some("favorites") => {
|
||||
return Some(current.to_path_buf());
|
||||
}
|
||||
_ => current = current.parent()?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
|
||||
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
|
||||
/// boundary is reached, or removal fails.
|
||||
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
|
||||
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
|
||||
/// not under `boundary`, the function is a no-op.
|
||||
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
||||
let boundary_norm = normalize_path_for_prefix(boundary);
|
||||
let mut current = Some(start_dir.to_path_buf());
|
||||
while let Some(dir) = current {
|
||||
if dir == boundary || !dir.starts_with(boundary) {
|
||||
let dir_norm = normalize_path_for_prefix(&dir);
|
||||
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
|
||||
break;
|
||||
}
|
||||
match std::fs::read_dir(&dir) {
|
||||
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
|
||||
pub fn prune_empty_subdirs_under(root: &Path) {
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let children: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
for child in children {
|
||||
prune_empty_subdirs_under(&child);
|
||||
let is_empty = std::fs::read_dir(&child)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = std::fs::remove_dir(&child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,6 +153,17 @@ mod tests {
|
||||
assert!(path.exists(), "boundary dir must never be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_regular_files_under_lists_nested_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
|
||||
let sub = dir.path().join("Artist/Album");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
|
||||
let files = collect_regular_files_under(dir.path());
|
||||
assert_eq!(files.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -98,4 +175,25 @@ mod tests {
|
||||
assert!(!child.exists(), "empty leaf should be pruned");
|
||||
assert!(parent.exists(), "non-empty parent must stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tier_boundary_from_path_finds_cache_root() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let track = root
|
||||
.path()
|
||||
.join("vol/media/cache/my.server/Artist/Album/track.flac");
|
||||
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
|
||||
let boundary = local_tier_boundary_from_path(&track).unwrap();
|
||||
assert_eq!(boundary, root.path().join("vol/media/cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
|
||||
std::fs::create_dir_all(&cache).unwrap();
|
||||
prune_empty_subdirs_under(&root.path().join("cache"));
|
||||
assert!(!cache.exists());
|
||||
assert!(root.path().join("cache").exists(), "tier root preserved");
|
||||
}
|
||||
}
|
||||
|
||||
+1422
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,4 @@ mod fs_utils;
|
||||
pub mod offline;
|
||||
pub mod downloads;
|
||||
pub mod hot;
|
||||
pub mod local;
|
||||
|
||||
+81
-1
@@ -5,6 +5,8 @@ pub mod cli;
|
||||
mod cover_cache;
|
||||
mod library_analysis_backfill;
|
||||
mod lib_commands;
|
||||
mod theme_import;
|
||||
pub mod theme_animation;
|
||||
|
||||
pub use psysonic_integration::discord;
|
||||
|
||||
@@ -95,6 +97,10 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE,
|
||||
)
|
||||
.with_denylist(&["mini"])
|
||||
.build()
|
||||
)
|
||||
@@ -114,6 +120,38 @@ pub fn run() {
|
||||
let _ = window.set_title("Psysonic (Dev)");
|
||||
}
|
||||
|
||||
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
|
||||
// Poll a local theme.css and push it into the running app on save,
|
||||
// so theme authors get a live loop without re-importing a zip. The
|
||||
// frontend (dev only) installs it under the id in its
|
||||
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
|
||||
match args.get(i + 1).cloned() {
|
||||
Some(path) => {
|
||||
eprintln!("[theme-watch] watching {path}");
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
let mut last_css = String::new();
|
||||
loop {
|
||||
if let Ok(css) = std::fs::read_to_string(&p) {
|
||||
if css != last_css {
|
||||
last_css = css.clone();
|
||||
let _ = handle.emit("theme-watch:css", css);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
}
|
||||
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analysis cache (SQLite) ───────────────────────────────────
|
||||
{
|
||||
let cache = analysis_cache::AnalysisCache::init(app.handle())
|
||||
@@ -396,6 +434,9 @@ pub fn run() {
|
||||
let _ = win.set_decorations(false);
|
||||
let _ = linux_webkit_apply_wayland_gpu_font_tuning(&win);
|
||||
let _ = linux_webkit_reapply_cached_wayland_text_render_profile(&win);
|
||||
// Suppress WebKit's own MPRIS player so radio (HTML <audio>)
|
||||
// doesn't duplicate the souvlaki one (issue #1048).
|
||||
let _ = linux_webkit_disable_media_session(&win);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +562,7 @@ pub fn run() {
|
||||
use tauri::Manager;
|
||||
let engine = app.state::<audio::AudioEngine>();
|
||||
audio::start_device_watcher(&engine, app.handle().clone());
|
||||
audio::start_stream_idle_watcher(app.handle().clone());
|
||||
}
|
||||
|
||||
// ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.)
|
||||
@@ -591,8 +633,27 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_page_load(|webview, payload| {
|
||||
if webview.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
match payload.event() {
|
||||
tauri::webview::PageLoadEvent::Started => {
|
||||
let window = webview.window().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(48));
|
||||
let _ = window.show();
|
||||
});
|
||||
}
|
||||
tauri::webview::PageLoadEvent::Finished => {
|
||||
let _ = webview.window().show();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
theme_import::import_theme_zip,
|
||||
backup_export_library_db,
|
||||
backup_import_library_db,
|
||||
backup_export_full,
|
||||
@@ -619,6 +680,7 @@ pub fn run() {
|
||||
performance_cpu_snapshot,
|
||||
set_subsonic_wire_user_agent,
|
||||
no_compositing_mode,
|
||||
theme_animation_risk,
|
||||
linux_xdg_session_type,
|
||||
is_tiling_wm_cmd,
|
||||
open_mini_player,
|
||||
@@ -660,7 +722,9 @@ pub fn run() {
|
||||
audio::commands::audio_chain_preload,
|
||||
psysonic_integration::discord::discord_update_presence,
|
||||
psysonic_integration::discord::discord_clear_presence,
|
||||
psysonic_integration::remote::lastfm_request,
|
||||
psysonic_integration::remote::audioscrobbler_request,
|
||||
psysonic_integration::remote::listenbrainz_request,
|
||||
psysonic_integration::remote::maloja_request,
|
||||
psysonic_integration::navidrome::covers::upload_playlist_cover,
|
||||
psysonic_integration::navidrome::covers::upload_radio_cover,
|
||||
psysonic_integration::navidrome::covers::upload_artist_image,
|
||||
@@ -710,11 +774,14 @@ pub fn run() {
|
||||
psysonic_library::commands::library_advanced_search,
|
||||
psysonic_library::commands::library_list_lossless_albums,
|
||||
psysonic_library::commands::library_list_albums_by_genre,
|
||||
psysonic_library::commands::library_genre_tags_inspect,
|
||||
psysonic_library::commands::library_genre_tags_run,
|
||||
psysonic_library::commands::library_get_artist_lossless_browse,
|
||||
psysonic_library::commands::library_search_cross_server,
|
||||
psysonic_library::commands::library_get_track,
|
||||
psysonic_library::commands::library_get_tracks_batch,
|
||||
psysonic_library::commands::library_get_tracks_by_album,
|
||||
psysonic_library::commands::library_upsert_songs_from_api,
|
||||
psysonic_library::commands::library_get_artifact,
|
||||
psysonic_library::commands::library_get_facts,
|
||||
psysonic_library::commands::library_get_offline_path,
|
||||
@@ -775,6 +842,19 @@ pub fn run() {
|
||||
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
||||
psysonic_syncfs::cache::offline::delete_offline_track,
|
||||
psysonic_syncfs::cache::offline::get_offline_cache_size,
|
||||
psysonic_syncfs::cache::local::download_track_local,
|
||||
psysonic_syncfs::cache::local::probe_library_track_local,
|
||||
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
|
||||
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
|
||||
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
|
||||
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
|
||||
psysonic_syncfs::cache::local::probe_media_files,
|
||||
psysonic_syncfs::cache::local::get_media_tier_size,
|
||||
psysonic_syncfs::cache::local::purge_media_tier,
|
||||
psysonic_syncfs::cache::local::delete_media_file,
|
||||
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
|
||||
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
|
||||
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
|
||||
psysonic_syncfs::cache::hot::download_track_hot_cache,
|
||||
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
||||
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
||||
|
||||
@@ -49,8 +49,8 @@ pub(crate) struct LogTailDto {
|
||||
pub dropped: bool,
|
||||
}
|
||||
|
||||
/// Incremental tail of the in-memory runtime log buffer for the Performance
|
||||
/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for
|
||||
/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab.
|
||||
/// `after_seq` is the highest seq the UI already has (omit for
|
||||
/// the initial fetch of the most recent `max` lines).
|
||||
#[tauri::command]
|
||||
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
|
||||
|
||||
@@ -23,10 +23,12 @@ pub(crate) use perf::performance_cpu_snapshot;
|
||||
pub(crate) use platform::{
|
||||
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
|
||||
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
|
||||
theme_animation_risk,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use platform::{
|
||||
linux_webkit_apply_wayland_gpu_font_tuning, linux_webkit_reapply_cached_wayland_text_render_profile,
|
||||
linux_webkit_apply_wayland_gpu_font_tuning, linux_webkit_disable_media_session,
|
||||
linux_webkit_reapply_cached_wayland_text_render_profile,
|
||||
sync_wayland_text_profile_cache_from_disk,
|
||||
};
|
||||
pub(crate) use integration::{
|
||||
|
||||
@@ -136,7 +136,54 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// WebKitGTK auto-registers its **own** MPRIS player whenever an HTML
|
||||
/// `<audio>`/`<video>` element plays — internet radio uses one (`radioPlayer.ts`).
|
||||
/// That duplicates the app's souvlaki MPRIS player on Linux desktops that list
|
||||
/// every player (issue #1048: one feed as "psysonic", one as "Psysonic").
|
||||
/// Disabling the WebKit media session stops that registration, leaving souvlaki
|
||||
/// as the single now-playing source; radio metadata still reaches it through
|
||||
/// `mpris_set_metadata`.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_webkit_disable_media_session(win: &tauri::WebviewWindow) -> Result<(), String> {
|
||||
win.with_webview(|platform| {
|
||||
use webkit2gtk::glib::prelude::ObjectExt;
|
||||
use webkit2gtk::WebViewExt;
|
||||
if let Some(settings) = platform.inner().settings() {
|
||||
// `enable-media-session` (WebKitGTK 2.40+) has no typed setter in the
|
||||
// pinned binding; set it by GObject property name. The find_property
|
||||
// guard keeps older runtimes from panicking on an unknown property.
|
||||
if settings.find_property("enable-media-session").is_some() {
|
||||
settings.set_property("enable-media-session", false);
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
|
||||
/// Tauri command: true when theme animations may be costly on this setup —
|
||||
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
|
||||
/// compositing forced off. The frontend warns on animated themes when true.
|
||||
/// Always false off Linux.
|
||||
#[tauri::command]
|
||||
pub(crate) fn theme_animation_risk() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Compositing forced off → GPU-accelerated effects/animation are costly.
|
||||
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
crate::theme_animation::nvidia_quirk_active()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
|
||||
@@ -17,11 +17,16 @@ fn apply_linux_webkit_nvidia_quirk() {
|
||||
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
|
||||
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
|
||||
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
|
||||
// Detect once and record it for the UI's animated-theme CPU-load warning,
|
||||
// so the theme_animation_risk command never has to re-probe the GPU.
|
||||
let kind = needs_workaround();
|
||||
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
|
||||
|
||||
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
|
||||
matches!(s.split(',').next().map(str::trim), Some("x11"))
|
||||
});
|
||||
if forced_x11_gdk {
|
||||
match needs_workaround() {
|
||||
match kind {
|
||||
WorkaroundKind::None => {}
|
||||
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
|
||||
set_webkit_disable_dmabuf_renderer();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Startup-recorded display hint for the theme system.
|
||||
//!
|
||||
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
|
||||
//! before the webview/GTK init). We record whether it was needed so the UI can
|
||||
//! warn that animated themes may raise CPU load on this setup — without
|
||||
//! re-probing the GPU later. Read via the `theme_animation_risk` command
|
||||
//! (`lib_commands::app_api::platform`).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
|
||||
pub fn set_nvidia_quirk_active(active: bool) {
|
||||
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
|
||||
}
|
||||
|
||||
/// Whether the Nvidia WebKit quirk was needed at startup. Linux-only: read
|
||||
/// solely by the Linux branch of `theme_animation_risk`. False when unrecorded
|
||||
/// (GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`). Gated so it is
|
||||
/// not dead code on Windows/macOS, where the quirk never applies.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn nvidia_quirk_active() -> bool {
|
||||
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Local theme-package import.
|
||||
//!
|
||||
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
|
||||
//! to the frontend, which runs the full theme-store contract validation
|
||||
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
|
||||
//!
|
||||
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
|
||||
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
|
||||
//! in Rust, outside the webview, and every read is size-capped so a malformed
|
||||
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
|
||||
//! memory or escape the archive.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// On-disk archive cap. A real token-only theme zip is a few KB.
|
||||
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// Per-entry uncompressed caps — mirror the frontend/CI limits
|
||||
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
|
||||
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
const MAX_CSS_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportedThemeFiles {
|
||||
pub manifest: String,
|
||||
pub css: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("cannot read file info: {e}"))?
|
||||
.len();
|
||||
if len > MAX_ARCHIVE_BYTES {
|
||||
return Err(format!(
|
||||
"archive is too large (> {} KB)",
|
||||
MAX_ARCHIVE_BYTES / 1024
|
||||
));
|
||||
}
|
||||
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
|
||||
|
||||
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
|
||||
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
|
||||
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
|
||||
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
|
||||
|
||||
Ok(ImportedThemeFiles { manifest, css })
|
||||
}
|
||||
|
||||
/// Find the first non-directory entry whose file name equals `wanted` (at the
|
||||
/// archive root or under a single wrapping folder), reject path traversal, and
|
||||
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
|
||||
/// actual read are bounded, so a lying header cannot allocate past the cap.
|
||||
fn read_capped_entry<R: Read + std::io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
wanted: &str,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>, String> {
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("corrupt archive entry: {e}"))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
|
||||
let base = match entry.enclosed_name() {
|
||||
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
},
|
||||
None => return Err("archive contains an unsafe path".to_string()),
|
||||
};
|
||||
if base != wanted {
|
||||
continue;
|
||||
}
|
||||
if entry.size() > cap as u64 {
|
||||
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
entry
|
||||
.by_ref()
|
||||
.take(cap as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
|
||||
if buf.len() > cap {
|
||||
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||
}
|
||||
return String::from_utf8(buf)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("{wanted} is not valid UTF-8"));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.48.0",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -22,12 +22,13 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"visible": true,
|
||||
"visible": false,
|
||||
"backgroundColor": "#1e1e2e",
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; style-src 'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost tauri: blob:; style-src-elem 'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost tauri: blob:; font-src 'self' data: asset: http://asset.localhost https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
|
||||
+56
@@ -1,6 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from './utils/themes/themeInjection';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
@@ -13,14 +17,66 @@ export default function App() {
|
||||
useThemeStore(s => s.theme);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const installedThemes = useInstalledThemesStore(s => s.themes);
|
||||
|
||||
// Document-attribute hooks are shared between both window kinds — each
|
||||
// webview has its own `document`, and theme / font / track-preview tokens
|
||||
// are read by CSS in both trees.
|
||||
|
||||
// Installed community themes have no build-time CSS — inject their
|
||||
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
|
||||
// store. Runs before the data-theme effect below so the matching style exists
|
||||
// when the attribute is applied. The store hydrates synchronously, so an
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
|
||||
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
|
||||
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
|
||||
// selector and apply it — the syncInjectedThemes effect above re-injects, so
|
||||
// authoring is live without re-importing a zip. Never wired in production.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void import('@tauri-apps/api/event').then(({ listen }) => {
|
||||
const sub = listen<string>('theme-watch:css', ({ payload }) => {
|
||||
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
|
||||
if (!id) return;
|
||||
useInstalledThemesStore.getState().install({
|
||||
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
|
||||
});
|
||||
useThemeStore.getState().setTheme(id);
|
||||
});
|
||||
// Guard the mocked-in-tests case where listen() isn't a promise.
|
||||
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
|
||||
}).catch(() => {});
|
||||
return () => unlisten?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
// Expose app state on the theme root so themes can react to it with a
|
||||
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
|
||||
// The set of allowed state attributes is the contract's `stateSelectors`.
|
||||
// (Sidebar-collapsed is set in AppShell, where that state lives.)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
|
||||
}, [isPlaying]);
|
||||
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
|
||||
}, [isFullscreenOpen]);
|
||||
|
||||
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
|
||||
}, [lyricsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const API_KEY = '9917fb39049225a13bec225ad6d49054';
|
||||
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
|
||||
|
||||
export function lastfmIsConfigured(): boolean {
|
||||
return Boolean(API_KEY && API_SECRET);
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
if (typeof e === 'string') return e;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
}
|
||||
|
||||
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
|
||||
const entries = Object.entries(params) as [string, string][];
|
||||
try {
|
||||
const result = await invoke('lastfm_request', {
|
||||
params: entries,
|
||||
sign,
|
||||
get,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
});
|
||||
// Clear session error on any successful authenticated call
|
||||
if (sign) useAuthStore.getState().setLastfmSessionError(false);
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Last.fm error codes 4, 9, 14 = auth/session invalid
|
||||
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
|
||||
useAuthStore.getState().setLastfmSessionError(true);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetToken(): Promise<string> {
|
||||
try {
|
||||
const data = await call({ method: 'auth.getToken' }, false, true);
|
||||
return data.token as string;
|
||||
} catch (e) {
|
||||
throw new Error(errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
export function lastfmAuthUrl(token: string): string {
|
||||
return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`;
|
||||
}
|
||||
|
||||
export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> {
|
||||
try {
|
||||
const data = await call({ method: 'auth.getSession', token }, true, false);
|
||||
return { key: data.session.key as string, name: data.session.name as string };
|
||||
} catch (e) {
|
||||
throw new Error(errMsg(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetSimilarArtists(artistName: string): Promise<string[]> {
|
||||
try {
|
||||
const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true);
|
||||
const artists = data?.similarartists?.artist;
|
||||
if (!artists) return [];
|
||||
const arr = Array.isArray(artists) ? artists : [artists];
|
||||
return arr.map((a: any) => a.name as string);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetAllLovedTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
): Promise<Array<{ title: string; artist: string }>> {
|
||||
const results: Array<{ title: string; artist: string }> = [];
|
||||
let page = 1;
|
||||
const limit = 200;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const data = await call({
|
||||
method: 'user.getLovedTracks',
|
||||
user: username,
|
||||
sk: sessionKey,
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
}, false, true);
|
||||
|
||||
const tracks = data?.lovedtracks?.track;
|
||||
if (!tracks) break;
|
||||
const arr = Array.isArray(tracks) ? tracks : [tracks];
|
||||
for (const t of arr) {
|
||||
results.push({ title: t.name, artist: t.artist?.name ?? '' });
|
||||
}
|
||||
|
||||
const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1);
|
||||
if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks
|
||||
page++;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function lastfmGetTrackLoved(
|
||||
title: string,
|
||||
artist: string,
|
||||
sessionKey: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true);
|
||||
return data?.track?.userloved === '1' || data?.track?.userloved === 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmUpdateNowPlaying(
|
||||
track: { title: string; artist: string; album: string; duration: number },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({
|
||||
method: 'track.updateNowPlaying',
|
||||
track: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: String(Math.round(track.duration)),
|
||||
sk: sessionKey,
|
||||
}, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmLoveTrack(
|
||||
track: { title: string; artist: string },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmUnloveTrack(
|
||||
track: { title: string; artist: string },
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmUserInfo {
|
||||
playcount: number;
|
||||
registeredAt: number; // unix timestamp
|
||||
}
|
||||
|
||||
export async function lastfmGetUserInfo(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
): Promise<LastfmUserInfo | null> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true);
|
||||
const u = data?.user;
|
||||
if (!u) return null;
|
||||
return {
|
||||
playcount: Number(u.playcount),
|
||||
registeredAt: Number(u.registered?.unixtime ?? 0),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmRecentTrack {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
timestamp: number | null; // null = currently playing
|
||||
nowPlaying: boolean;
|
||||
}
|
||||
|
||||
export async function lastfmGetRecentTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
limit = 20,
|
||||
): Promise<LastfmRecentTrack[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true);
|
||||
const items = data?.recenttracks?.track;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((t: any) => ({
|
||||
name: t.name,
|
||||
artist: t.artist?.['#text'] ?? t.artist?.name ?? '',
|
||||
album: t.album?.['#text'] ?? '',
|
||||
timestamp: t.date?.uts ? Number(t.date.uts) : null,
|
||||
nowPlaying: t['@attr']?.nowplaying === 'true',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month';
|
||||
|
||||
export interface LastfmTopArtist {
|
||||
name: string;
|
||||
playcount: string;
|
||||
}
|
||||
|
||||
export interface LastfmTopAlbum {
|
||||
name: string;
|
||||
playcount: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface LastfmTopTrack {
|
||||
name: string;
|
||||
playcount: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export async function lastfmGetTopArtists(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopArtist[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.topartists?.artist;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetTopAlbums(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopAlbum[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.topalbums?.album;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetTopTracks(
|
||||
username: string,
|
||||
sessionKey: string,
|
||||
period: LastfmPeriod,
|
||||
limit = 10,
|
||||
): Promise<LastfmTopTrack[]> {
|
||||
try {
|
||||
const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
|
||||
const items = data?.toptracks?.track;
|
||||
if (!items) return [];
|
||||
const arr = Array.isArray(items) ? items : [items];
|
||||
return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmScrobble(
|
||||
track: { title: string; artist: string; album: string; duration: number },
|
||||
timestamp: number,
|
||||
sessionKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await call({
|
||||
method: 'track.scrobble',
|
||||
track: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: String(Math.round(track.duration)),
|
||||
timestamp: String(Math.floor(timestamp / 1000)),
|
||||
sk: sessionKey,
|
||||
}, true, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmTrackInfo {
|
||||
listeners: number;
|
||||
playcount: number;
|
||||
userPlaycount: number | null;
|
||||
userLoved: boolean;
|
||||
tags: string[];
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export async function lastfmGetTrackInfo(
|
||||
artist: string,
|
||||
track: string,
|
||||
username?: string,
|
||||
): Promise<LastfmTrackInfo | null> {
|
||||
try {
|
||||
const params: Record<string, string> = { method: 'track.getInfo', artist, track };
|
||||
if (username) params.username = username;
|
||||
const data = await call(params, false, true);
|
||||
const t = data?.track;
|
||||
if (!t) return null;
|
||||
const rawTags = t.toptags?.tag;
|
||||
const tags = rawTags
|
||||
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
|
||||
: [];
|
||||
const userPc = t.userplaycount != null ? Number(t.userplaycount) : null;
|
||||
return {
|
||||
listeners: Number(t.listeners) || 0,
|
||||
playcount: Number(t.playcount) || 0,
|
||||
userPlaycount: Number.isFinite(userPc) ? userPc : null,
|
||||
userLoved: t.userloved === '1' || t.userloved === 1,
|
||||
tags,
|
||||
url: t.url ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LastfmArtistStats {
|
||||
listeners: number;
|
||||
playcount: number;
|
||||
userPlaycount: number | null;
|
||||
tags: string[];
|
||||
url: string | null;
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
export async function lastfmGetArtistStats(
|
||||
artist: string,
|
||||
username?: string,
|
||||
): Promise<LastfmArtistStats | null> {
|
||||
try {
|
||||
const params: Record<string, string> = { method: 'artist.getInfo', artist };
|
||||
if (username) params.username = username;
|
||||
const data = await call(params, false, true);
|
||||
const a = data?.artist;
|
||||
if (!a) return null;
|
||||
const rawTags = a.tags?.tag;
|
||||
const tags = rawTags
|
||||
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
|
||||
: [];
|
||||
const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null;
|
||||
const bioRaw = (a.bio?.content || a.bio?.summary || '').trim();
|
||||
return {
|
||||
listeners: Number(a.stats?.listeners) || 0,
|
||||
playcount: Number(a.stats?.playcount) || 0,
|
||||
userPlaycount: Number.isFinite(userPc) ? userPc : null,
|
||||
tags,
|
||||
url: a.url ?? null,
|
||||
bio: bioRaw || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -473,6 +473,18 @@ export function libraryGetTrack(
|
||||
.then(track => (track ? { ...track, serverId } : track));
|
||||
}
|
||||
|
||||
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
|
||||
export function libraryUpsertSongsFromApi(
|
||||
serverId: string,
|
||||
songs: unknown[],
|
||||
): Promise<number> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
|
||||
}
|
||||
|
||||
/** `library_get_tracks_batch` cap (spec §7.1). */
|
||||
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
|
||||
|
||||
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
const indexKeyMap = new Map<string, string>();
|
||||
const remapped = refs.map(ref => {
|
||||
@@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
|
||||
})));
|
||||
}
|
||||
|
||||
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
|
||||
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
if (refs.length === 0) return [];
|
||||
const out: LibraryTrackDto[] = [];
|
||||
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
|
||||
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
|
||||
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
|
||||
out.push(...batch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function libraryGetTracksByAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
@@ -868,3 +892,24 @@ export function subscribeLibrarySyncIdle(
|
||||
handler(payload),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Genre tags startup backfill (multi-genre local index) ───────────────
|
||||
|
||||
export interface GenreTagsInspectDto {
|
||||
needed: boolean;
|
||||
totalTracks: number;
|
||||
doneTracks: number;
|
||||
}
|
||||
|
||||
export interface GenreTagsProgressEvent {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
|
||||
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
|
||||
}
|
||||
|
||||
export function libraryGenreTagsRun(): Promise<void> {
|
||||
return invoke<void>('library_genre_tags_run');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
import { parseItemGenres } from '../utils/library/genreTags';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
@@ -54,6 +55,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
||||
userRating: asNumber(o.rating),
|
||||
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
bitRate: asNumber(o.bitRate),
|
||||
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
|
||||
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
|
||||
@@ -166,6 +168,7 @@ function mapNdAlbum(o: Record<string, unknown>): SubsonicAlbum {
|
||||
duration: asNumber(o.duration) ?? 0,
|
||||
year: asNumber(o.maxYear) ?? asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
starred: starredFlag ? (starredAt ?? 'true') : undefined,
|
||||
userRating: asNumber(o.rating),
|
||||
isCompilation: o.compilation === true,
|
||||
@@ -383,6 +386,7 @@ export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Prom
|
||||
duration: 0,
|
||||
year: asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
};
|
||||
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { nowPlayingPresence, NOW_PLAYING_IDLE_MINUTES } from './nowPlayingPresence';
|
||||
import type { SubsonicNowPlaying, PlaybackReportState } from './subsonicTypes';
|
||||
|
||||
// The function only reads `state` and `minutesAgo`; cast a minimal fixture.
|
||||
function entry(partial: { state?: PlaybackReportState; minutesAgo?: number }): SubsonicNowPlaying {
|
||||
return { minutesAgo: 0, ...partial } as SubsonicNowPlaying;
|
||||
}
|
||||
|
||||
describe('nowPlayingPresence', () => {
|
||||
it('maps live playbackReport state authoritatively', () => {
|
||||
expect(nowPlayingPresence(entry({ state: 'playing' }))).toBe('playing');
|
||||
expect(nowPlayingPresence(entry({ state: 'starting' }))).toBe('playing');
|
||||
expect(nowPlayingPresence(entry({ state: 'paused' }))).toBe('paused');
|
||||
expect(nowPlayingPresence(entry({ state: 'stopped' }))).toBe('idle');
|
||||
});
|
||||
|
||||
it('live state wins over a stale minutesAgo', () => {
|
||||
// A paused session last reported minutes ago is still "paused", not idle.
|
||||
expect(nowPlayingPresence(entry({ state: 'paused', minutesAgo: 99 }))).toBe('paused');
|
||||
expect(nowPlayingPresence(entry({ state: 'playing', minutesAgo: 99 }))).toBe('playing');
|
||||
});
|
||||
|
||||
it('falls back to recency for legacy entries without a state', () => {
|
||||
expect(nowPlayingPresence(entry({ minutesAgo: 0 }))).toBe('playing');
|
||||
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES }))).toBe('playing');
|
||||
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES + 1 }))).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||
|
||||
/**
|
||||
* Derived liveness of a now-playing entry, surfaced as the indicator dot in the
|
||||
* "Who is listening?" popover. Unifies the two sources the server can provide:
|
||||
* the OpenSubsonic `playbackReport` transport state (Navidrome ≥ 0.62, when
|
||||
* present) and the classic `getNowPlaying` `minutesAgo` recency (legacy
|
||||
* fallback). This replaces rendering the raw "Nm ago" line.
|
||||
*/
|
||||
export type NowPlayingPresence = 'playing' | 'paused' | 'idle';
|
||||
|
||||
/**
|
||||
* Minutes since last activity beyond which a legacy entry (one without
|
||||
* playbackReport transport state) is treated as idle rather than actively
|
||||
* playing. Entries that carry a live `state` ignore this entirely.
|
||||
*/
|
||||
export const NOW_PLAYING_IDLE_MINUTES = 5;
|
||||
|
||||
/** Resolve the liveness an entry should display. Live `state` is authoritative;
|
||||
* otherwise recency (`minutesAgo`) decides playing vs idle. */
|
||||
export function nowPlayingPresence(entry: SubsonicNowPlaying): NowPlayingPresence {
|
||||
// playbackReport extension: trust the reported transport state.
|
||||
switch (entry.state) {
|
||||
case 'playing':
|
||||
case 'starting':
|
||||
return 'playing';
|
||||
case 'paused':
|
||||
return 'paused';
|
||||
case 'stopped':
|
||||
return 'idle';
|
||||
}
|
||||
// Legacy getNowPlaying: only recency is known. Recent = playing, stale = idle.
|
||||
return entry.minutesAgo > NOW_PLAYING_IDLE_MINUTES ? 'idle' : 'playing';
|
||||
}
|
||||
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
|
||||
default: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: () => true,
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { pingWithCredentials, ping } from './subsonic';
|
||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user