merge(pre-release): sync qconnect-poc-v3 with latest local integration state
cx
No files matched your search
@@ -14,12 +14,6 @@ LAST_FM_APP_REGISTER=your_lastfm_username
|
||||
DISCOGS_API_CLIENT_KEY=your_consumer_key_here
|
||||
DISCOGS_API_CLIENT_SECRET=your_consumer_secret_here
|
||||
|
||||
# Spotify API Configuration (for playlist import)
|
||||
# Create your own Spotify application at: https://developer.spotify.com/dashboard
|
||||
# These credentials are used for importing playlists from Spotify
|
||||
|
||||
SPOTIFY_API_CLIENT_ID=your_client_id_here
|
||||
SPOTIFY_API_CLIENT_SECRET=your_client_secret_here
|
||||
|
||||
# Tidal API Configuration (for playlist import)
|
||||
# Create your own Tidal application at: https://developer.tidal.com/
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "pre-release"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "pre-release"
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Redirect PRs to pre-release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
redirect:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Change base branch from main to pre-release
|
||||
await github.rest.pulls.update({
|
||||
...context.repo,
|
||||
pull_number: pr.number,
|
||||
base: 'pre-release'
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: pr.number,
|
||||
body: `Base branch changed from \`main\` to \`pre-release\` automatically.\n\nIn this project, \`main\` is reserved for tagged releases. All PRs should target \`pre-release\` for integration testing first. Thanks!`
|
||||
});
|
||||
@@ -17,6 +17,13 @@ on:
|
||||
default: "1"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: aur-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-aur:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,13 +35,21 @@ jobs:
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
# Extract version from the triggering workflow's head branch (tag)
|
||||
REF="${{ github.event.workflow_run.head_branch }}"
|
||||
REF="$WORKFLOW_HEAD_BRANCH"
|
||||
if [[ ! "$REF" =~ ^v[0-9]+(\.[0-9]+){1,3}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid workflow_run head branch for release: $REF"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${REF#v}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
VERSION="$DISPATCH_VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected version: $VERSION"
|
||||
@@ -110,6 +125,9 @@ jobs:
|
||||
- name: Clone AUR repo
|
||||
run: |
|
||||
git clone ssh://aur@aur.archlinux.org/qbz-bin.git /tmp/aur-qbz-bin
|
||||
cd /tmp/aur-qbz-bin
|
||||
# Ensure we are on master (recover from detached HEAD if remote branch was corrupted)
|
||||
git checkout master 2>/dev/null || git checkout -b master
|
||||
|
||||
- name: Determine pkgrel
|
||||
id: pkgrel
|
||||
|
||||
@@ -95,23 +95,35 @@ jobs:
|
||||
# Install SDK extensions - flatpak-builder will auto-resolve the correct version based on the SDK
|
||||
flatpak install --user -y --noninteractive flathub \
|
||||
org.freedesktop.Sdk.Extension.node20//25.08 \
|
||||
org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||
org.freedesktop.Sdk.Extension.rust-stable//25.08 \
|
||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||
|
||||
- name: Build Flatpak
|
||||
id: build_flatpak
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
mkdir -p flatpak-build flatpak-repo dist
|
||||
BUNDLE_NAME="QBZ.flatpak"
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
BUNDLE_NAME="qbz_${VERSION}_x86_64.flatpak"
|
||||
fi
|
||||
flatpak-builder --user --force-clean --repo=flatpak-repo \
|
||||
flatpak-build packaging/flatpak/com.blitzfc.qbz.yml
|
||||
flatpak build-bundle flatpak-repo dist/QBZ.flatpak com.blitzfc.qbz
|
||||
flatpak build-bundle flatpak-repo "dist/${BUNDLE_NAME}" com.blitzfc.qbz
|
||||
echo "bundle_name=${BUNDLE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
ls -la dist
|
||||
|
||||
- name: Upload Flatpak bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qbz-flatpak
|
||||
path: dist/QBZ.flatpak
|
||||
path: dist/${{ steps.build_flatpak.outputs.bundle_name }}
|
||||
|
||||
- name: Publish GitHub Release Asset
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/QBZ.flatpak
|
||||
files: dist/${{ steps.build_flatpak.outputs.bundle_name }}
|
||||
prerelease: ${{ contains(steps.release.outputs.version, '-rc') }}
|
||||
make_latest: ${{ contains(steps.release.outputs.version, '-rc') && 'false' || 'true' }}
|
||||
@@ -124,3 +124,5 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
prerelease: ${{ contains(steps.release.outputs.version, '-rc') }}
|
||||
make_latest: ${{ contains(steps.release.outputs.version, '-rc') && 'false' || 'true' }}
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac;
|
||||
StartupWMClass=com.blitzfc.qbz
|
||||
StartupNotify=true
|
||||
MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp;
|
||||
@@ -156,3 +156,14 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
prerelease: ${{ contains(steps.release.outputs.version, '-rc') }}
|
||||
make_latest: ${{ contains(steps.release.outputs.version, '-rc') && 'false' || 'true' }}
|
||||
|
||||
- name: Trigger APT repo update
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.APT_REPO_TOKEN }}
|
||||
repository: vicrodh/qbz-apt
|
||||
event-type: new-release
|
||||
client-payload: '{"tag": "${{ github.ref_name }}"}'
|
||||
@@ -8,7 +8,6 @@ dist/
|
||||
|
||||
# Rust
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -74,3 +73,5 @@ qbz-nix-docs/
|
||||
# AI agent instructions (untracked)
|
||||
.github/copilot-instructions.md
|
||||
/crates/target/
|
||||
/crates/Cargo.lock
|
||||
test-results/
|
||||
@@ -7,37 +7,30 @@
|
||||
<a href="https://github.com/vicrodh/qbz/releases"><img src="https://img.shields.io/github/v/release/vicrodh/qbz?style=flat-square" alt="Release" /></a>
|
||||
<a href="https://aur.archlinux.org/packages/qbz-bin"><img src="https://img.shields.io/aur/version/qbz-bin?style=flat-square&logo=archlinux" alt="AUR" /></a>
|
||||
<a href="https://snapcraft.io/qbz-player"><img src="https://img.shields.io/badge/snap-qbz--player-0b0b0b?style=flat-square&logo=snapcraft" alt="Snap" /></a>
|
||||
<a href="https://flathub.org/apps/com.blitzfc.qbz"><img src="https://img.shields.io/flathub/v/com.blitzfc.qbz?style=flat-square&logo=flathub" alt="Flathub" /></a>
|
||||
<a href="https://github.com/vicrodh/qbz"><img src="https://img.shields.io/github/license/vicrodh/qbz?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/vicrodh/qbz"><img src="https://img.shields.io/badge/platform-Linux-0b0b0b?style=flat-square&logo=linux" alt="Platform" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://techforpalestine.org/learn-more"><img src="https://raw.githubusercontent.com/Safouene1/support-palestine-banner/master/StandWithPalestine.svg" alt="StandWithPalestine" /></a>
|
||||
</p>
|
||||
|
||||
# QBZ
|
||||
|
||||
QBZ is a free and open source (FOSS) high-fidelity streaming client for Linux with native playback. It is a real desktop application, not a web wrapper, so it can use DAC passthrough, switch sample rates per track, and deliver bit-perfect audio.
|
||||
|
||||
## 1.1.8 Highlights
|
||||
|
||||
- DAC Setup Wizard for guided PipeWire bit-perfect configuration
|
||||
- Immersive Player overhaul with tabbed panels and visualizer
|
||||
- Genre filtering with 3-level hierarchy
|
||||
- Playlist suggestions based on artist similarity
|
||||
- Remote control API (LAN + PWA)
|
||||
- Configurable key bindings
|
||||
- New languages: German and French, plus expanded Spanish coverage
|
||||
|
||||
See `docs/release-1.1.8/CHANGELOG_HIGHLIGHTS.md` for details.
|
||||
|
||||
## Legal / Branding
|
||||
|
||||
- This application uses the Qobuz API but is not certified by Qobuz.
|
||||
- Qobuz™ is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or certified by Qobuz.
|
||||
- Qobuz is a trademark of Qobuz. QBZ is not affiliated with, endorsed by, or certified by Qobuz.
|
||||
- The offline library is a temporary playback cache for listening without an internet connection while you have a valid subscription. You do not receive a license to keep or redistribute the content. If your subscription becomes invalid, QBZ will remove all cached content after 3 days.
|
||||
- Credentials may be stored in your system keyring if you have a keyring configured.
|
||||
- Qobuz Terms of Service: https://www.qobuz.com/us-en/legal/terms
|
||||
|
||||
## Why QBZ
|
||||
|
||||
Browsers cap audio output around 48 kHz, while Qobuz™ streams up to 192 kHz. QBZ uses a native playback pipeline with direct device control, exclusive mode, and no forced resampling so your system and DAC receive the original resolution, with caching and system integrations that wrappers cannot provide.
|
||||
Browsers cap audio output around 48 kHz, while Qobuz streams up to 192 kHz. QBZ uses a native playback pipeline with direct device control, exclusive mode, and no forced resampling so your system and DAC receive the original resolution, with caching and system integrations that wrappers cannot provide.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -71,11 +64,16 @@ Optional: external drives and network mounts
|
||||
sudo snap connect qbz-player:removable-media
|
||||
```
|
||||
|
||||
### Flatpak
|
||||
### Flatpak (Flathub)
|
||||
|
||||
```bash
|
||||
# Download from releases
|
||||
flatpak install ./QBZ.flatpak
|
||||
flatpak install flathub com.blitzfc.qbz
|
||||
```
|
||||
|
||||
Or install from a `.flatpak` file downloaded from [Releases](https://github.com/vicrodh/qbz/releases):
|
||||
|
||||
```bash
|
||||
flatpak install --user ./QBZ.flatpak
|
||||
```
|
||||
|
||||
#### Important for Audiophiles
|
||||
@@ -83,7 +81,7 @@ flatpak install ./QBZ.flatpak
|
||||
Due to Flatpak sandbox restrictions, **PipeWire backend cannot guarantee bit-perfect playback**. The sandbox prevents QBZ from controlling the PipeWire daemon's sample rate configuration.
|
||||
|
||||
**For bit-perfect audio in Flatpak:**
|
||||
- Use **ALSA Direct backend** in Settings → Audio → Audio Backend
|
||||
- Use **ALSA Direct backend** in Settings > Audio > Audio Backend
|
||||
- Select your DAC from the device list
|
||||
- Enable DAC Passthrough
|
||||
|
||||
@@ -120,13 +118,23 @@ chmod +x QBZ.AppImage
|
||||
|
||||
### DEB (Debian/Ubuntu/Mint/Pop!_OS/Zorin)
|
||||
|
||||
Download the `.deb` from [Releases](https://github.com/vicrodh/qbz/releases).
|
||||
**APT Repository (recommended):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://vicrodh.github.io/qbz-apt/qbz-archive-keyring.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/qbz-archive-keyring.gpg > /dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/qbz-archive-keyring.gpg arch=$(dpkg --print-architecture)] https://vicrodh.github.io/qbz-apt stable main" | sudo tee /etc/apt/sources.list.d/qbz.list
|
||||
sudo apt update && sudo apt install qbz
|
||||
```
|
||||
|
||||
Updates are automatic via `sudo apt update && sudo apt upgrade qbz`.
|
||||
|
||||
**Manual install:** Download the `.deb` from [Releases](https://github.com/vicrodh/qbz/releases).
|
||||
|
||||
```bash
|
||||
sudo apt install ./qbz_*.deb
|
||||
```
|
||||
|
||||
> **Requires glibc 2.38+** (Ubuntu 24.04+, Debian 13+, Mint 22+). Older releases like Ubuntu 22.04 or Pop!_OS 22.04 ship glibc 2.35 and won't work — use Flatpak, Snap, or AppImage instead.
|
||||
> **Requires glibc 2.38+** (Ubuntu 24.04+, Debian 13+, Mint 22+). Older releases like Ubuntu 22.04 or Pop!_OS 22.04 ship glibc 2.35 and won't work -- use Flatpak, Snap, or AppImage instead.
|
||||
|
||||
### RPM (Fedora/openSUSE/RHEL-based)
|
||||
|
||||
@@ -138,91 +146,165 @@ sudo dnf install ./qbz-*.rpm
|
||||
|
||||
> **Requires glibc 2.38+** (Fedora 39+, openSUSE Tumbleweed, RHEL 10+). For older releases, use Flatpak, Snap, or AppImage instead.
|
||||
|
||||
> **Note:** Pre-built binaries include all API integrations (Last.fm, Discogs, Spotify, Apple Music, Tidal, Deezer) ready to use. If you build from source, you'll need to provide your own API keys.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Coming soon.
|
||||
> **Note:** Pre-built binaries include all API integrations (Last.fm, Discogs, Apple Music, Tidal, Deezer) ready to use. Spotify playlist import uses embed scraping (no API key needed). If you build from source, you'll need to provide your own API keys for the other services.
|
||||
|
||||
## Features
|
||||
|
||||
### Streaming and Playback
|
||||
- Qobuz™ authentication and full catalog search (albums, tracks, artists, playlists).
|
||||
- Native decoding for FLAC, MP3, AAC, and ALAC with real-time playback state updates.
|
||||
- Quality selection with automatic fallback across Qobuz™ tiers.
|
||||
- Audio device enumeration and per-device output selection.
|
||||
- Exclusive mode and DAC passthrough for bit-perfect playback.
|
||||
- Preserve original sample rates end-to-end where supported.
|
||||
- Gapless-ready playback pipeline with precise position tracking.
|
||||
### Audio and Playback
|
||||
- **Bit-perfect playback** with DAC passthrough and per-track sample rate switching (44.1 kHz to 192 kHz).
|
||||
- **Four audio backends:** PipeWire, ALSA, ALSA Direct (hw: bypass), and PulseAudio.
|
||||
- **DAC Setup Wizard** for guided bit-perfect configuration.
|
||||
- Native decoding for FLAC, MP3, AAC, ALAC, WavPack, Ogg Vorbis, and Opus via Symphonia.
|
||||
- Quality selection with automatic fallback across Qobuz tiers.
|
||||
- Per-device output selection with exclusive mode.
|
||||
- Gapless playback with precise position tracking.
|
||||
- **Loudness normalization** (EBU R128) with ReplayGain support.
|
||||
- Two-level audio cache: L1 in-memory (400 MB) with L2 disk spillover (800 MB) and next-track prefetching.
|
||||
|
||||
### Queue and Library
|
||||
- Queue management with shuffle, repeat, and history navigation.
|
||||
- In-memory audio cache with LRU eviction and next-track prefetching.
|
||||
- Favorites and playlists from your Qobuz™ account.
|
||||
- Local library backend: directory scanning, metadata extraction, CUE sheet parsing, and SQLite indexing.
|
||||
- Queue management with shuffle, repeat (track/queue/off), and history navigation.
|
||||
- Favorites and playlists from your Qobuz account.
|
||||
- **Local library:** directory scanning, metadata extraction with lofty, CUE sheet parsing, and SQLite indexing.
|
||||
- Grid and list views with search, A-Z index, and grouping by artist or album.
|
||||
- Multi-disc album grouping with disc headers in album views.
|
||||
- Multi-disc album grouping with disc headers.
|
||||
- Local artwork detection (folder and embedded) with Discogs fallback.
|
||||
- **Tag editor for local library:** Edit metadata (title, artist, album, genre, year) with sidecar storage that preserves original files.
|
||||
- **Tag editor:** edit metadata (title, artist, album, genre, year) with sidecar storage that preserves original files.
|
||||
- **Virtualized lists** for smooth scrolling across large libraries.
|
||||
|
||||
### Playlist Import
|
||||
- Import public playlists from Spotify, Apple Music, Tidal, and Deezer into your Qobuz™ library.
|
||||
- Import public playlists from Spotify, Apple Music, Tidal, and Deezer into your Qobuz library.
|
||||
- Automatic track matching with fuzzy search.
|
||||
- Batch import with progress tracking.
|
||||
|
||||
### Network Casting
|
||||
- Chromecast device discovery and streaming.
|
||||
- DLNA/UPnP device discovery and streaming (AVTransport SOAP).
|
||||
- **Chromecast** device discovery and streaming.
|
||||
- **DLNA/UPnP** device discovery and streaming (AVTransport SOAP).
|
||||
- Unified cast picker with protocol selection.
|
||||
- Seamless playback handoff to network devices.
|
||||
|
||||
### Integrations
|
||||
- MPRIS media controls and media key support on Linux.
|
||||
- **MPRIS** media controls and media key support.
|
||||
- **Last.fm** scrobbling and now-playing updates.
|
||||
- **ListenBrainz** scrobbling with offline queue and listen history sync.
|
||||
- **MusicBrainz** artist enrichment, musician credits, recording relationships, and album personnel.
|
||||
- **Discogs** artwork fetching for local library.
|
||||
- Desktop notifications for track changes.
|
||||
- Last.fm scrobbling and now-playing updates.
|
||||
- **MusicBrainz integration:** Artist enrichment, musician credits, recording relationships, and detailed album personnel.
|
||||
- **ListenBrainz integration:** Scrobbling with listen history sync.
|
||||
- Discogs artwork fetching for local library.
|
||||
- Shareable Qobuz™ URLs and universal SongLink links (Odesli).
|
||||
- Shareable Qobuz URLs and universal SongLink links (Odesli).
|
||||
|
||||
### Immersive Player
|
||||
- Full-screen expanded player with tabbed panel system.
|
||||
- **14 visualization panels:** spectrum analyzer, oscilloscope, spectrogram, energy bands, Lissajous curves, transient pulse, vinyl animation, coverflow, and more.
|
||||
- **Synchronized lyrics** with line-by-line display and mini view.
|
||||
- Queue, track info, playback history, and suggestions panels.
|
||||
|
||||
### Interface
|
||||
- Now playing, queue panel, and full-screen playback views.
|
||||
- 26 built-in themes (Dark, OLED, Nord, Dracula, Tokyo Night, Catppuccin Mocha, Breeze, Adwaita, and more).
|
||||
- **Auto-theme:** generate themes dynamically from your DE color scheme, wallpaper, or a custom image (experimental).
|
||||
- **Native KDE title bar** on Plasma Wayland via KWin window rules.
|
||||
- Focus mode for distraction-free listening.
|
||||
- Mini player mode.
|
||||
- **Musician pages:** Explore performers, composers, and producers with their discography and roles.
|
||||
- **Label pages:** Browse record label catalogs.
|
||||
- **Album credits:** View detailed personnel and recording credits from MusicBrainz.
|
||||
- **Track info modal:** Detailed track metadata, recording info, and file details.
|
||||
- **Artist network sidebar:** Explore related artists and collaborators.
|
||||
- Keyboard shortcuts for common actions.
|
||||
- English and Spanish localization.
|
||||
- **Musician pages:** explore performers, composers, and producers with their discography and roles.
|
||||
- **Label pages:** browse record label catalogs.
|
||||
- **Album credits:** detailed personnel and recording credits from MusicBrainz.
|
||||
- **Image lightbox** with full-screen artwork viewing.
|
||||
- **Custom artist images:** right-click any artist to set a custom image.
|
||||
- Configurable keyboard shortcuts.
|
||||
- UI zoom from 80% to 200%.
|
||||
- 5 font choices including system font.
|
||||
- **4 languages:** English, Spanish, German, and French.
|
||||
|
||||
### Recommendations and Discovery
|
||||
- **Home page** with personalized sections: recent albums, continue listening, top artists, favorites, and weekly suggestions.
|
||||
- Genre filtering with 3-level hierarchy.
|
||||
- **Artist similarity engine** using vector embeddings for radio and suggestions.
|
||||
- Playlist suggestions based on artist similarity.
|
||||
- Dynamic suggestions with seed-based recommendations.
|
||||
|
||||
### Offline Support
|
||||
- Offline mode detection with automatic reconnection.
|
||||
- Offline playback cache for previously streamed content.
|
||||
- ListenBrainz and Last.fm offline queuing with automatic sync.
|
||||
|
||||
### Remote Control
|
||||
- Built-in HTTP API for LAN remote control.
|
||||
- Self-signed TLS with auto-generated certificates.
|
||||
|
||||
### Settings
|
||||
- Audio device selection and quality preferences.
|
||||
- API keys configuration for self-hosted builds.
|
||||
- Theme and appearance options.
|
||||
- **Update notifications:** Check for new releases with What's New changelogs.
|
||||
|
||||
### Performance
|
||||
- Low CPU usage in idle and playback with adaptive audio thread scheduling.
|
||||
|
||||
## Open Source
|
||||
|
||||
QBZ is MIT-licensed and fully open source. No telemetry, no lock-in, and no hidden services. Just a clean, transparent player built for Linux audio fans.
|
||||
|
||||
## Inspiration
|
||||
|
||||
QBZ draws inspiration from the broader Linux audio community that values open tools and high-fidelity playback.
|
||||
- Theme and appearance options with live preview.
|
||||
- System tray with minimize-to-tray and configurable behavior.
|
||||
- **Update notifications** with What's New changelogs.
|
||||
- Graphics composition settings for GPU/Wayland troubleshooting.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Desktop:** Rust + Tauri 2
|
||||
- **Frontend:** SvelteKit 5 + TypeScript + Vite
|
||||
- **Audio:** rodio + symphonia
|
||||
- **Networking:** reqwest
|
||||
- **Local library:** walkdir + lofty + rusqlite
|
||||
- **Integrations:** souvlaki (MPRIS), notify-rust, SongLink (Odesli)
|
||||
- **UX:** svelte-i18n, lucide-svelte
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Desktop shell** | Rust + Tauri 2.0 |
|
||||
| **Frontend** | SvelteKit + Svelte 5 (runes) + TypeScript + Vite |
|
||||
| **Audio decoding** | Symphonia (all codecs) via vendored rodio |
|
||||
| **Audio backends** | PipeWire, ALSA (via alsa-rs), ALSA Direct (hw:), PulseAudio |
|
||||
| **Networking** | reqwest (rustls-tls), axum (local API server) |
|
||||
| **Database** | rusqlite (bundled SQLite, WAL mode) |
|
||||
| **Local library** | walkdir + lofty (metadata) + image (thumbnails) |
|
||||
| **Desktop integration** | souvlaki (MPRIS), notify-rust, keyring |
|
||||
| **Casting** | rust_cast (Chromecast), rupnp (DLNA/UPnP), mdns-sd (AirPlay) |
|
||||
| **Internationalization** | svelte-i18n (4 locales) |
|
||||
| **Icons** | lucide-svelte |
|
||||
|
||||
### Multi-Crate Architecture
|
||||
|
||||
The Rust backend is organized into independent crates for modularity:
|
||||
|
||||
```
|
||||
crates/
|
||||
qbz-models/ Shared domain types (Track, Album, Artist, etc.)
|
||||
qbz-audio/ Audio backends, loudness analysis, device management
|
||||
qbz-player/ Playback engine, streaming, queue control
|
||||
qbz-qobuz/ Qobuz API client and authentication
|
||||
qbz-core/ Orchestrator coordinating player, audio, and API
|
||||
qbz-library/ Local library scanning, metadata, thumbnails
|
||||
qbz-integrations/ Last.fm, ListenBrainz, MusicBrainz, Discogs
|
||||
qbz-cache/ L1 memory + L2 disk audio caching
|
||||
qbz-cast/ Chromecast, DLNA/UPnP, AirPlay casting
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qbz/
|
||||
├── src/ Frontend (SvelteKit + Svelte 5)
|
||||
│ ├── lib/
|
||||
│ │ ├── components/ 120+ Svelte components
|
||||
│ │ │ ├── views/ 20+ view components (Home, Search, Settings, etc.)
|
||||
│ │ │ └── immersive/ Immersive player panels and visualizers
|
||||
│ │ ├── stores/ 40+ state stores (Svelte 5 runes)
|
||||
│ │ ├── services/ Backend integration services
|
||||
│ │ ├── i18n/locales/ en, es, de, fr translations
|
||||
│ │ ├── app/ Bootstrap and initialization
|
||||
│ │ └── utils/ Helpers (zoom, keyboard, sanitize, etc.)
|
||||
│ └── routes/ SvelteKit routes (main + miniplayer)
|
||||
├── src-tauri/
|
||||
│ ├── src/
|
||||
│ │ ├── auto_theme/ Dynamic theme generation from DE/wallpaper
|
||||
│ │ ├── audio/ Audio backend re-exports
|
||||
│ │ ├── config/ 13 settings modules
|
||||
│ │ ├── commands_v2.rs V2 command handlers (active API surface)
|
||||
│ │ ├── core_bridge.rs Bridge to multi-crate architecture
|
||||
│ │ └── ... 30+ feature modules
|
||||
│ ├── vendor/
|
||||
│ │ ├── rodio/ Vendored audio playback (f32 pipeline)
|
||||
│ │ └── cpal/ Vendored audio device library
|
||||
│ └── Cargo.toml
|
||||
├── crates/ 9 independent Rust crates (see above)
|
||||
├── packaging/
|
||||
│ ├── aur/ Arch Linux PKGBUILD
|
||||
│ └── flatpak/ Flatpak manifest and metainfo
|
||||
├── scripts/ Build, dev, and CI helper scripts
|
||||
├── .github/workflows/ CI/CD (Linux x86_64, aarch64, Flatpak, AUR)
|
||||
└── snapcraft.yaml Snap package definition
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
@@ -231,22 +313,41 @@ QBZ draws inspiration from the broader Linux audio community that values open to
|
||||
- Rust (latest stable)
|
||||
- Node.js 20+
|
||||
- Linux with audio support (PipeWire, ALSA, or PulseAudio)
|
||||
- System dependencies: `webkit2gtk-4.1`, `gtk3`, `alsa-lib`
|
||||
|
||||
### Setup
|
||||
### System Dependencies
|
||||
|
||||
**Debian/Ubuntu:**
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev \
|
||||
libappindicator3-dev librsvg2-dev libssl-dev pkg-config
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel gtk3-devel alsa-lib-devel \
|
||||
libappindicator-gtk3-devel librsvg2-devel openssl-devel pkg-config
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S webkit2gtk-4.1 gtk3 alsa-lib libappindicator-gtk3 \
|
||||
librsvg openssl pkg-config
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/vicrodh/qbz.git
|
||||
cd qbz
|
||||
|
||||
# Install dependencies
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
npm run tauri dev
|
||||
|
||||
# Build for production
|
||||
# Build for production (generates DEB, RPM, and AppImage)
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
@@ -306,14 +407,8 @@ DISCOGS_API_CLIENT_SECRET=your_consumer_secret
|
||||
|
||||
#### Spotify Integration (Playlist Import)
|
||||
|
||||
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
||||
2. Create a new application
|
||||
3. Add to `.env`:
|
||||
|
||||
```env
|
||||
SPOTIFY_API_CLIENT_ID=your_client_id
|
||||
SPOTIFY_API_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
Spotify playlist import uses embed page scraping — no API credentials needed.
|
||||
Playlists are limited to the first ~50 tracks due to Spotify embed limitations.
|
||||
|
||||
#### Tidal Integration (Playlist Import)
|
||||
|
||||
@@ -346,36 +441,6 @@ mv ~/.local/share/qbz-nix ~/.local/share/qbz
|
||||
|
||||
If you run both development and production builds, they now share the same data directories.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qbz/
|
||||
├── src/ # Frontend (SvelteKit)
|
||||
├── src-tauri/
|
||||
│ └── src/
|
||||
│ ├── api/ # Qobuz™ API client
|
||||
│ ├── player/ # Audio playback engine
|
||||
│ ├── queue/ # Queue management
|
||||
│ ├── cache/ # Audio cache and prefetch
|
||||
│ ├── cast/ # Chromecast & DLNA casting
|
||||
│ ├── library/ # Local library backend
|
||||
│ ├── lastfm/ # Last.fm integration
|
||||
│ ├── listenbrainz/ # ListenBrainz integration
|
||||
│ ├── musicbrainz/ # MusicBrainz integration
|
||||
│ ├── discogs/ # Discogs integration
|
||||
│ ├── playlist_import/ # Spotify/Tidal import
|
||||
│ ├── share/ # SongLink / share utilities
|
||||
│ ├── media_controls/ # MPRIS integration
|
||||
│ ├── updates/ # Release updates checker
|
||||
│ └── commands/ # Tauri IPC commands
|
||||
├── packaging/
|
||||
│ ├── aur/ # Arch Linux PKGBUILD
|
||||
│ └── flatpak/ # Flatpak manifest
|
||||
├── docs/
|
||||
│ └── release-1.1.8/ # Release notes
|
||||
└── static/ # Static assets and logo
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Audio Playback
|
||||
@@ -387,7 +452,7 @@ qbz/
|
||||
- Future: Byte-level seeking will be implemented in a future release to fix this
|
||||
|
||||
**First Sample Rate Change**
|
||||
- The first large sample rate change (e.g., 88.2kHz → 44.1kHz) may have a brief delay as the hardware stabilizes
|
||||
- The first large sample rate change (e.g., 88.2kHz to 44.1kHz) may have a brief delay as the hardware stabilizes
|
||||
- Subsequent changes of the same type are smooth
|
||||
- This is normal hardware behavior and not a bug
|
||||
|
||||
@@ -406,7 +471,7 @@ qbz/
|
||||
|
||||
### Graphics and Rendering
|
||||
|
||||
QBZ uses WebKit for rendering. Some hardware/driver combinations may cause crashes or poor performance. Settings are available in **Settings → Appearance → Composition**.
|
||||
QBZ uses WebKit for rendering. Some hardware/driver combinations may cause crashes or poor performance. Settings are available in **Settings > Appearance > Composition**.
|
||||
|
||||
#### Quick Recovery
|
||||
|
||||
@@ -441,7 +506,7 @@ Set these before launching QBZ to override settings:
|
||||
```bash
|
||||
QBZ_FORCE_X11=1 qbz
|
||||
```
|
||||
Or enable "Force X11 backend" in Settings → Appearance → Composition.
|
||||
Or enable "Force X11 backend" in Settings > Appearance > Composition.
|
||||
|
||||
**Intel Arc GPU (EGL crashes, "Could not create default EGL display")**
|
||||
```bash
|
||||
@@ -460,7 +525,7 @@ QBZ_HARDWARE_ACCEL=0 qbz
|
||||
|
||||
**XWayland scaling issues (blurry UI)**
|
||||
|
||||
After enabling Force X11, configure scaling in Settings → Appearance → Composition:
|
||||
After enabling Force X11, configure scaling in Settings > Appearance > Composition:
|
||||
- **GDK_SCALE**: Integer scaling (1, 2)
|
||||
- **GDK_DPI_SCALE**: Fractional scaling (0.5, 1, 1.5, 2)
|
||||
|
||||
@@ -472,6 +537,10 @@ After enabling Force X11, configure scaling in Settings → Appearance → Compo
|
||||
|
||||
All settings require a restart to take effect.
|
||||
|
||||
## Open Source
|
||||
|
||||
QBZ is MIT-licensed and fully open source. No telemetry, no lock-in, and no hidden services. Just a clean, transparent player built for Linux audio fans.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please read `CONTRIBUTING.md` before submitting issues or pull requests.
|
||||
|
||||
@@ -22,10 +22,8 @@ serde_json = { workspace = true }
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Audio - Vendored rodio for bit-perfect playback
|
||||
# This is the same vendored version used by qbz-nix
|
||||
# CRITICAL: Must use symphonia-all feature for f32 pipeline
|
||||
rodio = { path = "../../src-tauri/vendor/rodio", features = ["symphonia-all"] }
|
||||
# Audio playback (f32 pipeline is now upstream in rodio 0.22)
|
||||
rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||
|
||||
# Audio analysis
|
||||
symphonia = { workspace = true }
|
||||
|
||||
@@ -12,9 +12,9 @@ use super::backend::{AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, Ba
|
||||
use rodio::{
|
||||
cpal::{
|
||||
traits::{DeviceTrait, HostTrait},
|
||||
BufferSize, SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, SupportedStreamConfig,
|
||||
BufferSize, SampleFormat, StreamConfig, SupportedBufferSize, SupportedStreamConfig,
|
||||
},
|
||||
OutputStream, OutputStreamHandle,
|
||||
DeviceSinkBuilder, MixerDeviceSink,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -31,6 +31,35 @@ const COMMON_SAMPLE_RATES: &[u32] = &[
|
||||
384000, // Ultra high-res
|
||||
];
|
||||
|
||||
/// Find the best fallback sample rate in the same family.
|
||||
/// 44.1kHz family: 44100, 88200, 176400, 352800
|
||||
/// 48kHz family: 48000, 96000, 192000, 384000
|
||||
fn find_best_fallback_rate(requested: u32, supported: &[u32]) -> u32 {
|
||||
let is_441_family = requested % 44100 == 0;
|
||||
|
||||
// Find highest supported rate in the same family that's <= requested
|
||||
let mut candidates: Vec<u32> = supported
|
||||
.iter()
|
||||
.filter(|&&r| {
|
||||
if is_441_family {
|
||||
r % 44100 == 0
|
||||
} else {
|
||||
r % 48000 == 0
|
||||
}
|
||||
})
|
||||
.filter(|&&r| r <= requested)
|
||||
.copied()
|
||||
.collect();
|
||||
candidates.sort();
|
||||
|
||||
if let Some(&best) = candidates.last() {
|
||||
return best;
|
||||
}
|
||||
|
||||
// No rate in the same family — use highest supported rate overall
|
||||
supported.iter().copied().max().unwrap_or(48000)
|
||||
}
|
||||
|
||||
/// Extract supported sample rates from a CPAL device
|
||||
fn get_supported_sample_rates(device: &rodio::cpal::Device) -> Option<Vec<u32>> {
|
||||
use rodio::cpal::traits::DeviceTrait;
|
||||
@@ -45,7 +74,7 @@ fn get_supported_sample_rates(device: &rodio::cpal::Device) -> Option<Vec<u32>>
|
||||
let mut supported = Vec::new();
|
||||
|
||||
for rate in COMMON_SAMPLE_RATES {
|
||||
let sample_rate = rodio::cpal::SampleRate(*rate);
|
||||
let sample_rate = *rate;
|
||||
// Check if any config supports this rate
|
||||
let is_supported = configs_vec.iter().any(|config| {
|
||||
sample_rate >= config.min_sample_rate() && sample_rate <= config.max_sample_rate()
|
||||
@@ -233,6 +262,66 @@ fn find_card_number_by_name(short_name: &str) -> Option<String> {
|
||||
.map(|c| c.number.clone())
|
||||
}
|
||||
|
||||
/// Extract card name from an ALSA device ID.
|
||||
/// `front:CARD=C20,DEV=0` -> `"C20"`, `hw:0,0` -> card 0 short name, etc.
|
||||
fn extract_card_name_from_device(device_id: &str) -> Option<String> {
|
||||
if device_id.starts_with("front:CARD=") || device_id.starts_with("sysdefault:CARD=") {
|
||||
// front:CARD=C20,DEV=0 -> C20
|
||||
let after_card = device_id.split("CARD=").nth(1)?;
|
||||
Some(after_card.split(',').next()?.to_string())
|
||||
} else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") {
|
||||
// hw:0,0 -> card number 0 -> look up short name
|
||||
let prefix = if device_id.starts_with("hw:") { "hw:" } else { "plughw:" };
|
||||
let card_num = device_id.strip_prefix(prefix)?.split(',').next()?;
|
||||
let cards = read_proc_asound_cards();
|
||||
cards.iter()
|
||||
.find(|c| c.number == card_num)
|
||||
.map(|c| c.short_name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Read hardware-supported sample rates from /proc/asound/cardN/stream0.
|
||||
/// Returns None if rates cannot be determined (treat as "try anyway").
|
||||
fn get_hw_supported_rates(card_name: &str) -> Option<Vec<u32>> {
|
||||
let card_num = find_card_number_by_name(card_name)?;
|
||||
let stream_path = format!("/proc/asound/card{}/stream0", card_num);
|
||||
let content = fs::read_to_string(&stream_path).ok()?;
|
||||
|
||||
let mut in_playback = false;
|
||||
let mut all_rates = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "Playback:" {
|
||||
in_playback = true;
|
||||
} else if trimmed == "Capture:" {
|
||||
in_playback = false;
|
||||
}
|
||||
if in_playback && trimmed.starts_with("Rates:") {
|
||||
let rates_str = trimmed.trim_start_matches("Rates:").trim();
|
||||
if rates_str.contains("continuous") {
|
||||
return None; // Any rate supported — don't restrict
|
||||
}
|
||||
for rate_str in rates_str.split(',') {
|
||||
if let Ok(rate) = rate_str.trim().parse::<u32>() {
|
||||
if !all_rates.contains(&rate) {
|
||||
all_rates.push(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_rates.is_empty() {
|
||||
None
|
||||
} else {
|
||||
all_rates.sort();
|
||||
Some(all_rates)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ALSA Backend Implementation
|
||||
// ============================================================================
|
||||
@@ -247,7 +336,8 @@ impl AlsaBackend {
|
||||
let available_hosts = rodio::cpal::available_hosts();
|
||||
|
||||
// Check if ALSA is available
|
||||
if !available_hosts.iter().any(|h| h.name() == "ALSA") {
|
||||
// cpal 0.17 changed HostId::name() from "ALSA" to "Alsa" (uses stringify!)
|
||||
if !available_hosts.iter().any(|h| h.name().eq_ignore_ascii_case("alsa")) {
|
||||
return Err("ALSA host not available on this system".to_string());
|
||||
}
|
||||
|
||||
@@ -255,7 +345,7 @@ impl AlsaBackend {
|
||||
let host = rodio::cpal::host_from_id(
|
||||
available_hosts
|
||||
.into_iter()
|
||||
.find(|h| h.name() == "ALSA")
|
||||
.find(|h| h.name().eq_ignore_ascii_case("alsa"))
|
||||
.ok_or("ALSA host not found".to_string())?
|
||||
).map_err(|e| format!("Failed to create ALSA host: {}", e))?;
|
||||
|
||||
@@ -390,8 +480,8 @@ impl AlsaBackend {
|
||||
|
||||
if let Ok(output_devices) = self.host.output_devices() {
|
||||
for device in output_devices {
|
||||
if let Ok(name) = device.name() {
|
||||
map.insert(name, device);
|
||||
if let Ok(description) = device.description() {
|
||||
map.insert(description.name().to_string(), device);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,6 +523,26 @@ impl AlsaBackend {
|
||||
(device_id.to_string(), format!("plug:{}", device_id))
|
||||
};
|
||||
|
||||
// Pre-check: read /proc/asound/cardN/stream0 to verify rate support
|
||||
// before opening the PCM device. This avoids the "device busy" issue
|
||||
// where ALSA Direct opens the device, fails to configure the rate, and
|
||||
// leaves the device in a state CPAL can't open afterwards.
|
||||
if let Some(card_name) = extract_card_name_from_device(device_id) {
|
||||
if let Some(hw_rates) = get_hw_supported_rates(&card_name) {
|
||||
if !hw_rates.contains(&config.sample_rate) {
|
||||
log::info!(
|
||||
"[ALSA Backend] Hardware rates for '{}': {:?}. {}Hz not supported, skipping ALSA Direct",
|
||||
card_name, hw_rates, config.sample_rate
|
||||
);
|
||||
return None;
|
||||
}
|
||||
log::info!(
|
||||
"[ALSA Backend] Hardware confirms support for {}Hz (card '{}', rates: {:?})",
|
||||
config.sample_rate, card_name, hw_rates
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Respect ALSA plugin selection from settings
|
||||
let try_hw_first = match config.alsa_plugin {
|
||||
Some(AlsaPlugin::Hw) => true,
|
||||
@@ -461,8 +571,57 @@ impl AlsaBackend {
|
||||
let error = super::backend::AlsaDirectError::from_alsa_error(&e);
|
||||
log::warn!("[ALSA Backend] hw attempt failed: {}", error);
|
||||
|
||||
if matches!(error, super::backend::AlsaDirectError::DeviceBusy(_)) {
|
||||
// PipeWire may be holding the ALSA device open.
|
||||
// Suspend PipeWire sinks to release the device, then retry.
|
||||
log::info!("[ALSA Backend] Device busy — suspending PipeWire sinks to release ALSA device");
|
||||
if let Ok(output) = std::process::Command::new("pactl")
|
||||
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
log::info!("[ALSA Backend] PipeWire sink suspended, waiting for device release...");
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
match super::AlsaDirectStream::new(&hw_device, config.sample_rate, config.channels) {
|
||||
Ok(stream) => {
|
||||
log::info!("[ALSA Backend] ✓ Direct hw stream created after PipeWire suspend");
|
||||
return Some(Ok((stream, super::backend::BitPerfectMode::DirectHardware)));
|
||||
}
|
||||
Err(e2) => {
|
||||
log::warn!("[ALSA Backend] Retry after suspend also failed: {}", e2);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// If retry failed or suspend failed, fall through to error
|
||||
log::error!("[ALSA Backend] Cannot acquire device even after PipeWire suspend");
|
||||
return Some(Err(format!(
|
||||
"ALSA Direct failed: {}. Device may be in use or inaccessible.",
|
||||
error
|
||||
)));
|
||||
}
|
||||
|
||||
if matches!(error, super::backend::AlsaDirectError::InvalidParams(_)) {
|
||||
// Hardware doesn't support this rate/format natively.
|
||||
// Return None to let the player fall back to CPAL/rodio
|
||||
// which can resample (e.g. 176.4kHz → 88.2kHz).
|
||||
// Brief delay: ALSA Direct opened the PCM (then failed to configure it).
|
||||
// The kernel needs a moment to fully release the device before CPAL can open it.
|
||||
log::info!(
|
||||
"[ALSA Backend] Hardware doesn't support {}Hz natively, releasing device for CPAL fallback",
|
||||
config.sample_rate
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
return None;
|
||||
}
|
||||
|
||||
if !error.allows_plughw_fallback() {
|
||||
// Non-recoverable error (busy, permissions, etc.)
|
||||
// Non-recoverable error (permissions, etc.)
|
||||
log::error!("[ALSA Backend] Cannot fallback - error type: {:?}", error);
|
||||
return Some(Err(format!(
|
||||
"ALSA Direct failed: {}. Device may be in use or inaccessible.",
|
||||
@@ -489,6 +648,43 @@ impl AlsaBackend {
|
||||
Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)))
|
||||
}
|
||||
Err(e) => {
|
||||
let error = super::backend::AlsaDirectError::from_alsa_error(&e);
|
||||
|
||||
if matches!(error, super::backend::AlsaDirectError::DeviceBusy(_)) {
|
||||
log::info!("[ALSA Backend] plughw device busy — suspending PipeWire sinks to release ALSA device");
|
||||
if let Ok(output) = std::process::Command::new("pactl")
|
||||
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
log::info!("[ALSA Backend] PipeWire sink suspended, retrying plughw...");
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
|
||||
Ok(stream) => {
|
||||
log::info!("[ALSA Backend] ✓ plughw stream created after PipeWire suspend");
|
||||
return Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)));
|
||||
}
|
||||
Err(e2) => {
|
||||
log::error!("[ALSA Backend] plughw retry after suspend also failed: {}", e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(error, super::backend::AlsaDirectError::InvalidParams(_)) {
|
||||
// Hardware doesn't support this rate even via plughw.
|
||||
// Return None to let the player fall back to CPAL/rodio
|
||||
// which can resample (e.g. 176.4kHz → 88.2kHz).
|
||||
log::info!(
|
||||
"[ALSA Backend] Hardware doesn't support {}Hz even via plughw, releasing device for CPAL fallback",
|
||||
config.sample_rate
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
return None;
|
||||
}
|
||||
|
||||
log::error!("[ALSA Backend] plughw fallback also failed: {}", e);
|
||||
Some(Err(format!(
|
||||
"Bit-perfect playback could not be established. hw failed, plughw failed: {}",
|
||||
@@ -575,6 +771,22 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a hardware device supports a given sample rate.
|
||||
/// Returns `Some(true)` if supported, `Some(false)` if not, `None` if unknown.
|
||||
/// Uses /proc/asound/cardN/stream0 for accurate hardware capabilities.
|
||||
pub fn device_supports_sample_rate(device_id: &str, sample_rate: u32) -> Option<bool> {
|
||||
let card_name = extract_card_name_from_device(device_id)?;
|
||||
let hw_rates = get_hw_supported_rates(&card_name)?;
|
||||
Some(hw_rates.contains(&sample_rate))
|
||||
}
|
||||
|
||||
/// Get the hardware-supported sample rates for a device.
|
||||
/// Returns None if rates cannot be determined.
|
||||
pub fn get_device_supported_rates(device_id: &str) -> Option<Vec<u32>> {
|
||||
let card_name = extract_card_name_from_device(device_id)?;
|
||||
get_hw_supported_rates(&card_name)
|
||||
}
|
||||
|
||||
impl AudioBackend for AlsaBackend {
|
||||
fn backend_type(&self) -> AudioBackendType {
|
||||
AudioBackendType::Alsa
|
||||
@@ -587,7 +799,7 @@ impl AudioBackend for AlsaBackend {
|
||||
fn create_output_stream(
|
||||
&self,
|
||||
config: &BackendConfig,
|
||||
) -> BackendResult<(OutputStream, OutputStreamHandle)> {
|
||||
) -> BackendResult<MixerDeviceSink> {
|
||||
log::info!(
|
||||
"[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}",
|
||||
config.sample_rate,
|
||||
@@ -603,9 +815,9 @@ impl AudioBackend for AlsaBackend {
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.find(|d| {
|
||||
d.name()
|
||||
d.description()
|
||||
.ok()
|
||||
.map(|n| n == *device_id)
|
||||
.map(|desc| desc.name() == device_id.as_str())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| format!("Device '{}' not found", device_id))?
|
||||
@@ -616,21 +828,12 @@ impl AudioBackend for AlsaBackend {
|
||||
.ok_or("No default ALSA device available")?
|
||||
};
|
||||
|
||||
let device_name = device.name().unwrap_or_else(|_| "unknown".to_string());
|
||||
let device_name = device
|
||||
.description()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
log::info!("[ALSA Backend] Using device: {}", device_name);
|
||||
|
||||
// Create StreamConfig with requested sample rate
|
||||
let stream_config = StreamConfig {
|
||||
channels: config.channels,
|
||||
sample_rate: SampleRate(config.sample_rate),
|
||||
buffer_size: if config.exclusive_mode {
|
||||
// Smaller buffer for exclusive mode = lower latency
|
||||
BufferSize::Fixed(512)
|
||||
} else {
|
||||
BufferSize::Default
|
||||
},
|
||||
};
|
||||
|
||||
// Check if device supports this configuration
|
||||
let supported_configs = device
|
||||
.supported_output_configs()
|
||||
@@ -639,26 +842,50 @@ impl AudioBackend for AlsaBackend {
|
||||
let mut found_matching = false;
|
||||
for range in supported_configs {
|
||||
if range.channels() == config.channels
|
||||
&& config.sample_rate >= range.min_sample_rate().0
|
||||
&& config.sample_rate <= range.max_sample_rate().0
|
||||
&& config.sample_rate >= range.min_sample_rate()
|
||||
&& config.sample_rate <= range.max_sample_rate()
|
||||
{
|
||||
found_matching = true;
|
||||
log::info!(
|
||||
"[ALSA Backend] Device supports {}Hz (range: {}-{}Hz)",
|
||||
config.sample_rate,
|
||||
range.min_sample_rate().0,
|
||||
range.max_sample_rate().0
|
||||
range.min_sample_rate(),
|
||||
range.max_sample_rate()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_matching {
|
||||
log::warn!(
|
||||
"[ALSA Backend] Device may not support {}Hz, attempting anyway",
|
||||
// If device doesn't support the requested rate, find best fallback
|
||||
let effective_rate = if !found_matching {
|
||||
if let Some(rates) = get_supported_sample_rates(&device) {
|
||||
let fallback = find_best_fallback_rate(config.sample_rate, &rates);
|
||||
log::warn!(
|
||||
"[ALSA Backend] Device doesn't support {}Hz. Supported: {:?}. Falling back to {}Hz (rodio will resample)",
|
||||
config.sample_rate, rates, fallback
|
||||
);
|
||||
fallback
|
||||
} else {
|
||||
log::warn!(
|
||||
"[ALSA Backend] Could not determine supported rates, attempting {}Hz anyway",
|
||||
config.sample_rate
|
||||
);
|
||||
config.sample_rate
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config.sample_rate
|
||||
};
|
||||
|
||||
// Rebuild StreamConfig with effective rate
|
||||
let stream_config = StreamConfig {
|
||||
channels: config.channels,
|
||||
sample_rate: effective_rate,
|
||||
buffer_size: if config.exclusive_mode {
|
||||
BufferSize::Fixed(512)
|
||||
} else {
|
||||
BufferSize::Fixed(effective_rate / 10)
|
||||
},
|
||||
};
|
||||
|
||||
// Create SupportedStreamConfig
|
||||
let supported_config = SupportedStreamConfig::new(
|
||||
@@ -668,26 +895,62 @@ impl AudioBackend for AlsaBackend {
|
||||
SampleFormat::F32,
|
||||
);
|
||||
|
||||
// Create OutputStream with custom config
|
||||
let stream = OutputStream::try_from_device_config(&device, supported_config)
|
||||
// In exclusive mode, PipeWire may have re-acquired the device after the
|
||||
// previous ALSA Direct stream released it. Suspend PipeWire before opening.
|
||||
if config.exclusive_mode {
|
||||
log::info!("[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream");
|
||||
if let Ok(output) = std::process::Command::new("pactl")
|
||||
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
log::info!("[ALSA Backend] PipeWire sink suspended");
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create MixerDeviceSink with custom config
|
||||
let mixer_sink = DeviceSinkBuilder::from_device(device)
|
||||
.map_err(|e| {
|
||||
if config.exclusive_mode {
|
||||
format!(
|
||||
"Failed to create exclusive ALSA stream at {}Hz: {}. Device may be in use by another application.",
|
||||
config.sample_rate, e
|
||||
effective_rate, e
|
||||
)
|
||||
} else {
|
||||
format!("Failed to create ALSA stream at {}Hz: {}", config.sample_rate, e)
|
||||
format!("Failed to create ALSA device sink builder at {}Hz: {}", effective_rate, e)
|
||||
}
|
||||
})?
|
||||
.with_supported_config(&supported_config)
|
||||
.open_stream()
|
||||
.map_err(|e| {
|
||||
if config.exclusive_mode {
|
||||
format!(
|
||||
"Failed to create exclusive ALSA stream at {}Hz: {}. Device may be in use by another application.",
|
||||
effective_rate, e
|
||||
)
|
||||
} else {
|
||||
format!("Failed to create ALSA stream at {}Hz: {}", effective_rate, e)
|
||||
}
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})",
|
||||
config.sample_rate,
|
||||
config.exclusive_mode
|
||||
);
|
||||
if effective_rate != config.sample_rate {
|
||||
log::info!(
|
||||
"[ALSA Backend] Output stream created at {}Hz (resampled from {}Hz, exclusive: {})",
|
||||
effective_rate, config.sample_rate, config.exclusive_mode
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})",
|
||||
config.sample_rate, config.exclusive_mode
|
||||
);
|
||||
}
|
||||
|
||||
Ok(stream)
|
||||
Ok(mixer_sink)
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
|
||||
@@ -44,10 +44,10 @@ impl SpectralAnalyzer {
|
||||
smoothing_factor: f32,
|
||||
) -> Self {
|
||||
let clamped_fft = match fft_size {
|
||||
512 | 1024 | 2048 => fft_size,
|
||||
512 | 1024 | 2048 | 4096 | 8192 => fft_size,
|
||||
_ => 1024,
|
||||
};
|
||||
let clamped_bands = num_bands.clamp(48, 192);
|
||||
let clamped_bands = num_bands.clamp(48, 1024);
|
||||
let clamped_rate = update_rate_hz.clamp(20, 60);
|
||||
let clamped_smoothing = smoothing_factor.clamp(0.0, 0.98);
|
||||
|
||||
@@ -122,14 +122,15 @@ impl SpectralAnalyzer {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// RMS energy + soft compression for a stable visual dynamic range.
|
||||
// RMS energy with linear scaling — no power compression so the
|
||||
// frontend's exponential transform has full dynamic range to work with.
|
||||
let rms = if count > 0 {
|
||||
(sum / count as f32).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let compressed = (rms * 18.0).powf(0.55).clamp(0.0, 1.0);
|
||||
self.bands_raw[band_idx] = compressed;
|
||||
let scaled = (rms * 25.0).clamp(0.0, 1.0);
|
||||
self.bands_raw[band_idx] = scaled;
|
||||
}
|
||||
|
||||
// Exponential smoothing with fast attack / slower release.
|
||||
|
||||
@@ -95,17 +95,17 @@ where
|
||||
S: Source<Item = f32>,
|
||||
{
|
||||
#[inline]
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
self.inner.current_frame_len()
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.current_span_len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> u16 {
|
||||
fn channels(&self) -> std::num::NonZero<u16> {
|
||||
self.inner.channels()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> u32 {
|
||||
fn sample_rate(&self) -> std::num::NonZero<u32> {
|
||||
self.inner.sample_rate()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Provides a unified interface for different audio backends (PipeWire, ALSA, PulseAudio)
|
||||
//! allowing users to choose their preferred audio stack.
|
||||
|
||||
use rodio::{OutputStream, OutputStreamHandle};
|
||||
use rodio::MixerDeviceSink;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Supported audio backends
|
||||
@@ -220,7 +220,7 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn create_output_stream(
|
||||
&self,
|
||||
config: &BackendConfig,
|
||||
) -> BackendResult<(OutputStream, OutputStreamHandle)>;
|
||||
) -> BackendResult<MixerDeviceSink>;
|
||||
|
||||
/// Check if this backend is available on the current system
|
||||
fn is_available(&self) -> bool;
|
||||
|
||||
@@ -179,17 +179,17 @@ where
|
||||
S: Source<Item = f32>,
|
||||
{
|
||||
#[inline]
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
self.inner.current_frame_len()
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.current_span_len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> u16 {
|
||||
fn channels(&self) -> std::num::NonZero<u16> {
|
||||
self.inner.channels()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> u32 {
|
||||
fn sample_rate(&self) -> std::num::NonZero<u32> {
|
||||
self.inner.sample_rate()
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ where
|
||||
S: Source<Item = f32>,
|
||||
{
|
||||
pub fn new(source: S, gain_atomic: Arc<AtomicU32>, initial_gain: f32) -> Self {
|
||||
let sample_rate = source.sample_rate();
|
||||
let channels = source.channels() as u32;
|
||||
let sample_rate = source.sample_rate().get();
|
||||
let channels = source.channels().get() as u32;
|
||||
// 50ms ramp in total samples (all channels)
|
||||
let ramp_samples = (sample_rate * channels * 50) / 1000;
|
||||
|
||||
@@ -116,17 +116,17 @@ where
|
||||
S: Source<Item = f32>,
|
||||
{
|
||||
#[inline]
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
self.inner.current_frame_len()
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.current_span_len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> u16 {
|
||||
fn channels(&self) -> std::num::NonZero<u16> {
|
||||
self.inner.channels()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> u32 {
|
||||
fn sample_rate(&self) -> std::num::NonZero<u32> {
|
||||
self.inner.sample_rate()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use backend::{
|
||||
BitPerfectMode,
|
||||
};
|
||||
pub use alsa_direct::AlsaDirectStream;
|
||||
pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw};
|
||||
pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw, device_supports_sample_rate, get_device_supported_rates};
|
||||
pub use diagnostic::{AudioDiagnostic, DiagnosticSource, BitDepthResult};
|
||||
pub use loudness::{ReplayGainData, extract_replaygain, calculate_gain_factor, db_to_linear};
|
||||
pub use dynamic_amplify::DynamicAmplify;
|
||||
|
||||
@@ -211,15 +211,19 @@ impl AnalyzerState {
|
||||
self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain
|
||||
);
|
||||
|
||||
// Update shared atomic
|
||||
if let Some(ref atomic) = self.gain_atomic {
|
||||
atomic.store(gain.to_bits(), Ordering::Relaxed);
|
||||
// Only update the live gain on the FIRST measurement.
|
||||
// Refinements update the cache only — applying gain changes mid-song
|
||||
// causes audible volume fluctuations within a single track.
|
||||
if !self.initial_done {
|
||||
if let Some(ref atomic) = self.gain_atomic {
|
||||
atomic.store(gain.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
self.samples_at_last_measure = self.samples_fed;
|
||||
self.initial_done = true;
|
||||
|
||||
// Cache the result (store the adjustment in dB, not the linear gain)
|
||||
// Always cache the latest measurement for next playback
|
||||
cache.set(self.track_id, adjustment_db, 0.0, "ebur128");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ use super::backend::{AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
||||
use rodio::{
|
||||
cpal::{
|
||||
traits::{DeviceTrait, HostTrait},
|
||||
BufferSize, SampleFormat, SampleRate, StreamConfig, SupportedBufferSize, SupportedStreamConfig,
|
||||
BufferSize, SampleFormat, StreamConfig, SupportedBufferSize, SupportedStreamConfig,
|
||||
},
|
||||
OutputStream, OutputStreamHandle,
|
||||
DeviceSinkBuilder, MixerDeviceSink,
|
||||
};
|
||||
use std::process::Command;
|
||||
|
||||
@@ -21,17 +21,6 @@ pub struct PipeWireBackend {
|
||||
host: rodio::cpal::Host,
|
||||
}
|
||||
|
||||
/// Calculate the optimal PipeWire quantum for a given sample rate.
|
||||
/// Returns a power-of-2 value matching common audio interface expectations.
|
||||
fn quantum_for_sample_rate(sample_rate: u32) -> u32 {
|
||||
match sample_rate {
|
||||
r if r <= 48000 => 1024,
|
||||
r if r <= 96000 => 2048,
|
||||
r if r <= 192000 => 4096,
|
||||
_ => 8192,
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeWireBackend {
|
||||
pub fn new() -> BackendResult<Self> {
|
||||
Ok(Self {
|
||||
@@ -41,6 +30,7 @@ impl PipeWireBackend {
|
||||
|
||||
/// Reset PipeWire clock.force-rate and clock.force-quantum to 0.
|
||||
/// Call this when playback stops so other apps aren't stuck at a forced rate.
|
||||
/// Quantum reset is kept for safety even though we no longer force it.
|
||||
pub fn reset_pipewire_clock() {
|
||||
log::info!("[PipeWire Backend] Resetting clock.force-rate and clock.force-quantum to 0");
|
||||
let _ = Command::new("pw-metadata")
|
||||
@@ -51,6 +41,113 @@ impl PipeWireBackend {
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Get the ALSA card number for a PipeWire/PulseAudio sink name.
|
||||
/// Parses `pactl list sinks` to find the `alsa.card` property.
|
||||
fn get_alsa_card_for_sink(sink_name: &str) -> Option<String> {
|
||||
let output = Command::new("pactl")
|
||||
.args(["list", "sinks"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut in_target_sink = false;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("Sink #") {
|
||||
if in_target_sink {
|
||||
return None; // Passed target sink without finding alsa.card
|
||||
}
|
||||
} else if trimmed.starts_with("Name:") {
|
||||
let name = trimmed.trim_start_matches("Name:").trim();
|
||||
in_target_sink = name == sink_name;
|
||||
} else if in_target_sink && trimmed.starts_with("alsa.card = ") {
|
||||
let card = trimmed
|
||||
.trim_start_matches("alsa.card = ")
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
return Some(card);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Query the DAC's supported sample rates from /proc/asound/cardN/stream0.
|
||||
/// Returns None if rates can't be determined (non-USB device, continuous range, etc.)
|
||||
fn get_sink_supported_rates(sink_name: &str) -> Option<Vec<u32>> {
|
||||
let alsa_card = Self::get_alsa_card_for_sink(sink_name)?;
|
||||
|
||||
let stream_path = format!("/proc/asound/card{}/stream0", alsa_card);
|
||||
let content = std::fs::read_to_string(&stream_path).ok()?;
|
||||
|
||||
// Collect all rates from Playback Rates: lines (handles multiple alt settings)
|
||||
let mut in_playback = false;
|
||||
let mut all_rates = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "Playback:" {
|
||||
in_playback = true;
|
||||
} else if trimmed == "Capture:" {
|
||||
in_playback = false;
|
||||
}
|
||||
if in_playback && trimmed.starts_with("Rates:") {
|
||||
let rates_str = trimmed.trim_start_matches("Rates:").trim();
|
||||
if rates_str.contains("continuous") {
|
||||
return None; // Any rate in range is supported
|
||||
}
|
||||
for rate_str in rates_str.split(',') {
|
||||
if let Ok(rate) = rate_str.trim().parse::<u32>() {
|
||||
if !all_rates.contains(&rate) {
|
||||
all_rates.push(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_rates.is_empty() {
|
||||
None
|
||||
} else {
|
||||
all_rates.sort();
|
||||
Some(all_rates)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the best fallback sample rate in the same family.
|
||||
/// 44.1kHz family: 44100, 88200, 176400, 352800
|
||||
/// 48kHz family: 48000, 96000, 192000, 384000
|
||||
fn find_best_fallback_rate(requested: u32, supported: &[u32]) -> u32 {
|
||||
let is_441_family = requested % 44100 == 0;
|
||||
|
||||
// Find highest supported rate in the same family that's <= requested
|
||||
let mut candidates: Vec<u32> = supported
|
||||
.iter()
|
||||
.filter(|&&r| {
|
||||
if is_441_family {
|
||||
r % 44100 == 0
|
||||
} else {
|
||||
r % 48000 == 0
|
||||
}
|
||||
})
|
||||
.filter(|&&r| r <= requested)
|
||||
.copied()
|
||||
.collect();
|
||||
candidates.sort();
|
||||
|
||||
if let Some(&best) = candidates.last() {
|
||||
return best;
|
||||
}
|
||||
|
||||
// No rate in the same family — use highest supported rate overall
|
||||
supported.iter().copied().max().unwrap_or(48000)
|
||||
}
|
||||
|
||||
/// Parse pactl output to get device list with pretty names
|
||||
fn enumerate_pipewire_sinks(&self) -> BackendResult<Vec<AudioDevice>> {
|
||||
// Get default sink
|
||||
@@ -168,7 +265,7 @@ impl AudioBackend for PipeWireBackend {
|
||||
fn create_output_stream(
|
||||
&self,
|
||||
config: &BackendConfig,
|
||||
) -> BackendResult<(OutputStream, OutputStreamHandle)> {
|
||||
) -> BackendResult<MixerDeviceSink> {
|
||||
let target_sink = config.device_id.clone();
|
||||
|
||||
// Temporarily set default sink to target (if specified)
|
||||
@@ -198,15 +295,63 @@ impl AudioBackend for PipeWireBackend {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
|
||||
// Force PipeWire to use the requested sample rate (for bit-perfect playback)
|
||||
log::info!("[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata", config.sample_rate);
|
||||
// Check if the DAC supports the requested sample rate.
|
||||
// Query via /proc/asound/ (USB DACs list discrete supported rates).
|
||||
// If unsupported, fall back to the nearest rate in the same family
|
||||
// (e.g., 176.4kHz → 88.2kHz). Rodio resamples from track rate to
|
||||
// stream rate automatically.
|
||||
let effective_sink = target_sink.clone().or_else(|| {
|
||||
Command::new("pactl")
|
||||
.args(["get-default-sink"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let effective_rate = if let Some(ref sink_name) = effective_sink {
|
||||
match Self::get_sink_supported_rates(sink_name) {
|
||||
Some(rates) if rates.contains(&config.sample_rate) => {
|
||||
log::info!(
|
||||
"[PipeWire Backend] DAC supports {}Hz (available: {:?})",
|
||||
config.sample_rate, rates
|
||||
);
|
||||
config.sample_rate
|
||||
}
|
||||
Some(rates) => {
|
||||
let fallback = Self::find_best_fallback_rate(config.sample_rate, &rates);
|
||||
log::warn!(
|
||||
"[PipeWire Backend] DAC doesn't support {}Hz. Supported: {:?}. Falling back to {}Hz (resampled by rodio)",
|
||||
config.sample_rate, rates, fallback
|
||||
);
|
||||
fallback
|
||||
}
|
||||
None => {
|
||||
log::info!(
|
||||
"[PipeWire Backend] Could not determine DAC supported rates, using {}Hz",
|
||||
config.sample_rate
|
||||
);
|
||||
config.sample_rate
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config.sample_rate
|
||||
};
|
||||
|
||||
// Force PipeWire to use the effective sample rate (for bit-perfect playback)
|
||||
log::info!("[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata", effective_rate);
|
||||
let metadata_result = Command::new("pw-metadata")
|
||||
.args(["-n", "settings", "0", "clock.force-rate", &config.sample_rate.to_string()])
|
||||
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()])
|
||||
.output();
|
||||
|
||||
match metadata_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("[PipeWire Backend] Sample rate forced to {}Hz", config.sample_rate);
|
||||
log::info!("[PipeWire Backend] Sample rate forced to {}Hz", effective_rate);
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -217,27 +362,11 @@ impl AudioBackend for PipeWireBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// Force quantum if bit-perfect mode is enabled
|
||||
if config.pw_force_bitperfect {
|
||||
let quantum = quantum_for_sample_rate(config.sample_rate);
|
||||
log::info!("[PipeWire Backend] Forcing quantum to {} via pw-metadata (bit-perfect)", quantum);
|
||||
let quantum_result = Command::new("pw-metadata")
|
||||
.args(["-n", "settings", "0", "clock.force-quantum", &quantum.to_string()])
|
||||
.output();
|
||||
|
||||
match quantum_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("[PipeWire Backend] Quantum forced to {}", quantum);
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("[PipeWire Backend] Failed to force quantum: {}", stderr);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[PipeWire Backend] Error executing pw-metadata for quantum: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: clock.force-quantum is intentionally NOT set.
|
||||
// rodio 0.22's MixerDeviceSink has its own internal mixer thread that
|
||||
// cannot synchronize with PipeWire's forced quantum, causing massive
|
||||
// buffer underruns at sample rates >= 88.2kHz. clock.force-rate alone
|
||||
// is sufficient for bit-perfect sample rate switching.
|
||||
|
||||
// Wait for PipeWire to apply the sample rate change
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
@@ -246,43 +375,76 @@ impl AudioBackend for PipeWireBackend {
|
||||
log::info!("[PipeWire Backend] Creating fresh CPAL host...");
|
||||
let fresh_host = rodio::cpal::default_host();
|
||||
|
||||
// Find the "pulse" or "pipewire" CPAL device
|
||||
let device = fresh_host
|
||||
// Find a CPAL device backed by PulseAudio/PipeWire.
|
||||
// Newer CPAL description().name() returns friendly labels like
|
||||
// "PipeWire Sound Server" instead of raw ids ("pipewire"/"pulse").
|
||||
let mut best_device: Option<rodio::cpal::Device> = None;
|
||||
let mut best_score: u8 = 0;
|
||||
let mut available_output_devices: Vec<String> = Vec::new();
|
||||
|
||||
for device in fresh_host
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate CPAL devices: {}", e))?
|
||||
.find(|d| {
|
||||
d.name()
|
||||
.ok()
|
||||
.map(|n| n == "pulse" || n == "pipewire")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"Could not find 'pulse' or 'pipewire' CPAL device. Is PulseAudio/PipeWire running?".to_string()
|
||||
})?;
|
||||
{
|
||||
let device_name = device
|
||||
.description()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let device_name_lower = device_name.to_ascii_lowercase();
|
||||
available_output_devices.push(device_name.clone());
|
||||
|
||||
let device_name = device.name().unwrap_or_else(|_| "unknown".to_string());
|
||||
let score = if device_name_lower == "pipewire" || device_name_lower == "pulse" {
|
||||
3
|
||||
} else if device_name_lower.contains("pipewire sound server")
|
||||
|| device_name_lower.contains("pulseaudio sound server")
|
||||
{
|
||||
2
|
||||
} else if device_name_lower.contains("pipewire")
|
||||
|| device_name_lower.contains("pulseaudio")
|
||||
{
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_device = Some(device);
|
||||
}
|
||||
}
|
||||
|
||||
let device = best_device.ok_or_else(|| {
|
||||
format!(
|
||||
"Could not find 'pulse' or 'pipewire' CPAL device. Is PulseAudio/PipeWire running? Available output devices: {:?}",
|
||||
available_output_devices
|
||||
)
|
||||
})?;
|
||||
|
||||
let device_name = device
|
||||
.description()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
log::info!("[PipeWire Backend] Using CPAL device: {}", device_name);
|
||||
|
||||
// Create output stream with custom sample rate configuration
|
||||
log::info!(
|
||||
"[PipeWire Backend] Creating stream: {}Hz, {} channels, exclusive: {}",
|
||||
"[PipeWire Backend] Creating stream: {}Hz (track: {}Hz), {} channels, exclusive: {}",
|
||||
effective_rate,
|
||||
config.sample_rate,
|
||||
config.channels,
|
||||
config.exclusive_mode
|
||||
);
|
||||
|
||||
// Create StreamConfig with desired sample rate
|
||||
// Create StreamConfig with effective sample rate
|
||||
// Note: buffer_size here is unused — with_supported_config() resets it.
|
||||
// The actual buffer size is set via with_buffer_size() below.
|
||||
let stream_config = StreamConfig {
|
||||
channels: config.channels,
|
||||
sample_rate: SampleRate(config.sample_rate),
|
||||
buffer_size: if config.exclusive_mode {
|
||||
BufferSize::Fixed(512) // Lower latency for exclusive mode
|
||||
} else {
|
||||
BufferSize::Default
|
||||
},
|
||||
sample_rate: effective_rate,
|
||||
buffer_size: BufferSize::Default,
|
||||
};
|
||||
|
||||
// Check if device supports this configuration
|
||||
// Check if CPAL device supports this configuration
|
||||
let supported_configs = device
|
||||
.supported_output_configs()
|
||||
.map_err(|e| format!("Failed to get supported configs: {}", e))?;
|
||||
@@ -290,15 +452,15 @@ impl AudioBackend for PipeWireBackend {
|
||||
let mut found_matching = false;
|
||||
for range in supported_configs {
|
||||
if range.channels() == config.channels
|
||||
&& config.sample_rate >= range.min_sample_rate().0
|
||||
&& config.sample_rate <= range.max_sample_rate().0
|
||||
&& effective_rate >= range.min_sample_rate()
|
||||
&& effective_rate <= range.max_sample_rate()
|
||||
{
|
||||
found_matching = true;
|
||||
log::info!(
|
||||
"[PipeWire Backend] Device supports {}Hz (range: {}-{}Hz)",
|
||||
config.sample_rate,
|
||||
range.min_sample_rate().0,
|
||||
range.max_sample_rate().0
|
||||
"[PipeWire Backend] CPAL device supports {}Hz (range: {}-{}Hz)",
|
||||
effective_rate,
|
||||
range.min_sample_rate(),
|
||||
range.max_sample_rate()
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -306,8 +468,8 @@ impl AudioBackend for PipeWireBackend {
|
||||
|
||||
if !found_matching {
|
||||
log::warn!(
|
||||
"[PipeWire Backend] Device may not support {}Hz, attempting anyway",
|
||||
config.sample_rate
|
||||
"[PipeWire Backend] CPAL device may not support {}Hz, attempting anyway",
|
||||
effective_rate
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,13 +481,43 @@ impl AudioBackend for PipeWireBackend {
|
||||
SampleFormat::F32,
|
||||
);
|
||||
|
||||
// Create OutputStream with custom config
|
||||
let stream = OutputStream::try_from_device_config(&device, supported_config)
|
||||
.map_err(|e| format!("Failed to create output stream at {}Hz: {}", config.sample_rate, e))?;
|
||||
// Compute buffer size — must be applied AFTER with_supported_config()
|
||||
// because that method resets buffer_size to Default via ..Default::default().
|
||||
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
|
||||
// ONLY buffer between the mixer and audio hardware.
|
||||
let cpal_buffer_size = if config.exclusive_mode {
|
||||
BufferSize::Fixed(512) // Low latency for exclusive mode
|
||||
} else {
|
||||
// ~100ms buffer, matching old vendored cpal period size.
|
||||
// Prevents underruns at high sample rates (192kHz = 19200 frames).
|
||||
BufferSize::Fixed(effective_rate / 10)
|
||||
};
|
||||
log::info!("[PipeWire Backend] Buffer size: {:?}", cpal_buffer_size);
|
||||
|
||||
log::info!("[PipeWire Backend] Output stream created successfully at {}Hz", config.sample_rate);
|
||||
// Create MixerDeviceSink with custom config
|
||||
let mixer_sink = DeviceSinkBuilder::from_device(device)
|
||||
.map_err(|e| format!("Failed to create device sink builder: {}", e))?
|
||||
.with_supported_config(&supported_config)
|
||||
.with_buffer_size(cpal_buffer_size)
|
||||
.open_stream()
|
||||
.map_err(|e| format!("Failed to create output stream at {}Hz: {}", effective_rate, e))?;
|
||||
|
||||
Ok(stream)
|
||||
log::info!("[PipeWire Backend] Output stream created successfully at {}Hz", effective_rate);
|
||||
|
||||
// Re-apply clock.force-rate AFTER stream creation.
|
||||
// When resuming after PipeWire dropped the stream during pause,
|
||||
// the graph may have reverted to the DAC's default rate (e.g. 44100).
|
||||
// The pre-stream force-rate can be ignored if no streams were active.
|
||||
// Re-applying now that the stream exists forces PipeWire to reconfigure
|
||||
// the graph at the correct rate.
|
||||
if effective_rate != 44100 && effective_rate != 48000 {
|
||||
let _ = Command::new("pw-metadata")
|
||||
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()])
|
||||
.output();
|
||||
log::info!("[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation", effective_rate);
|
||||
}
|
||||
|
||||
Ok(mixer_sink)
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use super::backend::{AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult};
|
||||
use super::pipewire_backend::PipeWireBackend;
|
||||
use rodio::{OutputStream, OutputStreamHandle};
|
||||
use rodio::MixerDeviceSink;
|
||||
|
||||
pub struct PulseBackend {
|
||||
// Reuse PipeWire backend implementation (they're compatible)
|
||||
@@ -33,7 +33,7 @@ impl AudioBackend for PulseBackend {
|
||||
fn create_output_stream(
|
||||
&self,
|
||||
config: &BackendConfig,
|
||||
) -> BackendResult<(OutputStream, OutputStreamHandle)> {
|
||||
) -> BackendResult<MixerDeviceSink> {
|
||||
// Delegate to PipeWire backend (same mechanism)
|
||||
self.inner.create_output_stream(config)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
/// Number of frequency bins to send to frontend
|
||||
pub const NUM_BARS: usize = 16;
|
||||
|
||||
/// FFT size (must be power of 2)
|
||||
pub const FFT_SIZE: usize = 1024;
|
||||
/// FFT size (must be power of 2). 4096 gives ~10.7 Hz bin resolution at 44.1 kHz,
|
||||
/// enabling detailed spectral analysis for visualizers like Linebed.
|
||||
pub const FFT_SIZE: usize = 4096;
|
||||
|
||||
/// Target frames per second for visualization updates
|
||||
pub const TARGET_FPS: u64 = 30;
|
||||
|
||||
@@ -68,17 +68,17 @@ where
|
||||
S: Source<Item = f32>,
|
||||
{
|
||||
#[inline]
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
self.inner.current_frame_len()
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.current_span_len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> u16 {
|
||||
fn channels(&self) -> std::num::NonZero<u16> {
|
||||
self.inner.channels()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> u32 {
|
||||
fn sample_rate(&self) -> std::num::NonZero<u32> {
|
||||
self.inner.sample_rate()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct AudioCache {
|
||||
|
||||
impl Default for AudioCache {
|
||||
fn default() -> Self {
|
||||
Self::new(300 * 1024 * 1024) // 300MB for ~3-4 Hi-Res tracks
|
||||
Self::new(400 * 1024 * 1024) // 400MB for ~4-5 Hi-Res tracks
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! QBZ Cache - Audio caching system
|
||||
//!
|
||||
//! Provides two-level caching for audio data:
|
||||
//! - **L1 (Memory)**: In-memory LRU cache (~300MB, fast access)
|
||||
//! - **L2 (Disk)**: Disk-based playback cache (~500MB, persistent)
|
||||
//! - **L1 (Memory)**: In-memory LRU cache (~400MB, fast access)
|
||||
//! - **L2 (Disk)**: Disk-based playback cache (~800MB, persistent)
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
@@ -15,7 +15,7 @@
|
||||
//! ┌─────────────────────────────────────────────────────────┐
|
||||
//! │ L1 Memory Cache (AudioCache) │
|
||||
//! │ - Fast HashMap + LRU tracking │
|
||||
//! │ - ~300MB limit (3-4 Hi-Res tracks) │
|
||||
//! │ - ~400MB limit (3-4 Hi-Res tracks) │
|
||||
//! │ - Evicted tracks spill to L2 │
|
||||
//! └─────────────────────────────────────────────────────────┘
|
||||
//! │ miss/evict
|
||||
@@ -23,7 +23,7 @@
|
||||
//! ┌─────────────────────────────────────────────────────────┐
|
||||
//! │ L2 Disk Cache (PlaybackCache) │
|
||||
//! │ - File-based storage │
|
||||
//! │ - ~500MB limit │
|
||||
//! │ - ~800MB limit │
|
||||
//! │ - LRU eviction by access time │
|
||||
//! └─────────────────────────────────────────────────────────┘
|
||||
//! │ miss
|
||||
@@ -38,11 +38,11 @@
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
//! // Create L2 disk cache
|
||||
//! let playback_cache = Arc::new(PlaybackCache::new(500 * 1024 * 1024).unwrap());
|
||||
//! let playback_cache = Arc::new(PlaybackCache::new(800 * 1024 * 1024).unwrap());
|
||||
//!
|
||||
//! // Create L1 memory cache with L2 spillover
|
||||
//! let audio_cache = AudioCache::with_playback_cache(
|
||||
//! 300 * 1024 * 1024,
|
||||
//! 400 * 1024 * 1024,
|
||||
//! playback_cache.clone()
|
||||
//! );
|
||||
//!
|
||||
|
||||
@@ -109,6 +109,19 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject an already-authenticated session (e.g. from OAuth flow).
|
||||
/// Emits a LoggedIn event so the rest of the system knows auth state changed.
|
||||
pub async fn set_session(&self, session: UserSession) -> Result<(), CoreError> {
|
||||
let client = self.client.read().await;
|
||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||
client.set_session(session.clone()).await;
|
||||
self.emit(CoreEvent::LoggedIn {
|
||||
session,
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Logout the current user
|
||||
pub async fn logout(&self) -> Result<(), CoreError> {
|
||||
let client = self.client.read().await;
|
||||
|
||||
@@ -4,7 +4,7 @@ use reqwest::Client;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::{IntegrationError, IntegrationResult};
|
||||
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession};
|
||||
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession, LastFmSimilarArtist};
|
||||
|
||||
/// Cloudflare Workers proxy URL - handles API credentials and signature generation
|
||||
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
|
||||
@@ -214,4 +214,87 @@ impl LastFmClient {
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get similar artists for a given artist name
|
||||
///
|
||||
/// Uses Last.fm's artist.getSimilar which returns genre-accurate similarity.
|
||||
/// Requires authentication (user must have Last.fm connected).
|
||||
pub async fn get_similar_artists(
|
||||
&self,
|
||||
artist: &str,
|
||||
limit: u32,
|
||||
) -> IntegrationResult<Vec<LastFmSimilarArtist>> {
|
||||
// artist.getSimilar is a public read endpoint - no session key needed.
|
||||
// The proxy handles the API key.
|
||||
let url = format!("{}/artist.getSimilar", LASTFM_PROXY_URL);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&json!({
|
||||
"artist": artist,
|
||||
"limit": limit,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(IntegrationError::internal(format!(
|
||||
"Last.fm artist.getSimilar failed: {}",
|
||||
text
|
||||
)));
|
||||
}
|
||||
|
||||
let text = response.text().await?;
|
||||
|
||||
let data: serde_json::Value = serde_json::from_str(&text)?;
|
||||
|
||||
// Handle Last.fm error responses
|
||||
if let Some(error) = data.get("error") {
|
||||
let message = data
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(IntegrationError::api(
|
||||
error.as_u64().unwrap_or(0) as u32,
|
||||
message.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let artists = data
|
||||
.get("similarartists")
|
||||
.and_then(|sa| sa.get("artist"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| {
|
||||
let name = item.get("name")?.as_str()?.to_string();
|
||||
// Last.fm returns match as string "0" to "1"
|
||||
let match_score: f64 = item
|
||||
.get("match")
|
||||
.and_then(|m| {
|
||||
m.as_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| m.as_f64())
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
let mbid = item
|
||||
.get("mbid")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(LastFmSimilarArtist {
|
||||
name,
|
||||
match_score,
|
||||
mbid,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(artists)
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,4 @@ mod client;
|
||||
mod models;
|
||||
|
||||
pub use client::LastFmClient;
|
||||
pub use models::LastFmSession;
|
||||
pub use models::{LastFmSession, LastFmSimilarArtist};
|
||||
@@ -48,3 +48,15 @@ pub(crate) enum LastFmResponse<T> {
|
||||
Success(T),
|
||||
Error { error: u32, message: String },
|
||||
}
|
||||
|
||||
/// A similar artist from Last.fm's artist.getSimilar
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LastFmSimilarArtist {
|
||||
/// Artist name
|
||||
pub name: String,
|
||||
/// Similarity score from 0.0 to 1.0
|
||||
pub match_score: f64,
|
||||
/// MusicBrainz ID if available
|
||||
pub mbid: Option<String>,
|
||||
}
|
||||
@@ -227,6 +227,44 @@ impl ListenBrainzCache {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Batch mark multiple listens as sent
|
||||
pub fn mark_listens_sent(&self, ids: &[i64]) -> Result<(), String> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let placeholders: Vec<String> = ids.iter().map(|_| "?".to_string()).collect();
|
||||
let sql = format!(
|
||||
"UPDATE listen_queue SET sent = 1 WHERE id IN ({})",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let params: Vec<Box<dyn rusqlite::types::ToSql>> = ids
|
||||
.iter()
|
||||
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
|
||||
.collect();
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
self.conn
|
||||
.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| format!("Failed to batch mark sent: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get count of unsent listens in queue
|
||||
pub fn get_queue_count(&self) -> Result<u32, String> {
|
||||
let count: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM listen_queue WHERE sent = 0", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
/// Clear all queued listens
|
||||
pub fn clear_queue(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM listen_queue", [])
|
||||
.map_err(|e| format!("Failed to clear queue: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete old sent listens
|
||||
pub fn cleanup_sent(&self, older_than_days: u32) -> Result<u64, String> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - (older_than_days as i64 * 86400);
|
||||
|
||||
@@ -124,7 +124,7 @@ impl ListenBrainzClient {
|
||||
config.user_name = Some(user_name.clone());
|
||||
}
|
||||
|
||||
log::info!("ListenBrainz connected as: {}", user_name);
|
||||
log::info!("ListenBrainz connected");
|
||||
|
||||
Ok(UserInfo { user_name })
|
||||
}
|
||||
|
||||
@@ -108,16 +108,6 @@ pub struct SubmitListensPayload {
|
||||
pub payload: Vec<Listen>,
|
||||
}
|
||||
|
||||
/// Response from ListenBrainz API
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListenBrainzResponse {
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// User info response
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct UserInfo {
|
||||
@@ -126,6 +116,7 @@ pub struct UserInfo {
|
||||
|
||||
/// Token validation response
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TokenValidationResponse {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
//! MusicBrainz cache for resolved entities
|
||||
//! MusicBrainz cache for resolved entities and settings
|
||||
//!
|
||||
//! SQLite-based cache with TTL expiration for MusicBrainz lookups.
|
||||
//! Caches:
|
||||
//! - Resolved tracks (by ISRC)
|
||||
//! - Resolved artists (by name)
|
||||
//! - Resolved releases (by title/artist)
|
||||
//! Also persists integration settings (enabled state).
|
||||
|
||||
use rusqlite::{Connection, Result as SqlResult};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::models::{MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType};
|
||||
use super::models::{
|
||||
ArtistMetadata, ArtistRelationships, LocationDiscoveryResponse,
|
||||
MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType,
|
||||
};
|
||||
|
||||
/// TTL for recording cache (30 days)
|
||||
const RECORDING_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
/// TTL for artist cache (7 days)
|
||||
const ARTIST_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
/// TTL for release cache (30 days)
|
||||
const RELEASE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
/// TTL for artist relationships cache (7 days)
|
||||
const RELATIONS_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
/// TTL for artist metadata cache (30 days)
|
||||
const METADATA_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
/// TTL for scene discovery cache (30 days)
|
||||
const SCENE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
/// TTL for Qobuz artist validation cache (30 days)
|
||||
const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CacheStats {
|
||||
pub tracks: u64,
|
||||
pub recordings: u64,
|
||||
pub artists: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub releases: u64,
|
||||
pub relations: u64,
|
||||
}
|
||||
|
||||
/// MusicBrainz cache
|
||||
@@ -45,6 +61,69 @@ impl MusicBrainzCache {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"
|
||||
-- Settings (enabled state, etc.)
|
||||
CREATE TABLE IF NOT EXISTS mb_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Recordings indexed by ISRC
|
||||
CREATE TABLE IF NOT EXISTS mb_recordings (
|
||||
isrc TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_recordings_fetched ON mb_recordings(fetched_at);
|
||||
|
||||
-- Artists indexed by normalized name
|
||||
CREATE TABLE IF NOT EXISTS mb_artists (
|
||||
name_normalized TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_artists_fetched ON mb_artists(fetched_at);
|
||||
|
||||
-- Releases indexed by UPC/barcode
|
||||
CREATE TABLE IF NOT EXISTS mb_releases (
|
||||
barcode TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_releases_fetched ON mb_releases(fetched_at);
|
||||
|
||||
-- Artist relationships indexed by MBID
|
||||
CREATE TABLE IF NOT EXISTS mb_artist_relations (
|
||||
mbid TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_relations_fetched ON mb_artist_relations(fetched_at);
|
||||
|
||||
-- Artist metadata (location, genres, life span) indexed by MBID
|
||||
CREATE TABLE IF NOT EXISTS mb_artist_metadata (
|
||||
mbid TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_metadata_fetched ON mb_artist_metadata(fetched_at);
|
||||
|
||||
-- Scene discovery results indexed by area + seed hash
|
||||
CREATE TABLE IF NOT EXISTS mb_scene_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_scene_fetched ON mb_scene_cache(fetched_at);
|
||||
|
||||
-- Qobuz artist validation cache
|
||||
CREATE TABLE IF NOT EXISTS mb_qobuz_validation (
|
||||
name_normalized TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_qobuz_validation_fetched ON mb_qobuz_validation(fetched_at);
|
||||
|
||||
-- V2 resolved tracks (simple cache)
|
||||
CREATE TABLE IF NOT EXISTS resolved_tracks (
|
||||
isrc TEXT PRIMARY KEY,
|
||||
recording_mbid TEXT NOT NULL,
|
||||
@@ -56,6 +135,7 @@ impl MusicBrainzCache {
|
||||
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
-- V2 resolved artists (simple cache)
|
||||
CREATE TABLE IF NOT EXISTS resolved_artists (
|
||||
name_lower TEXT PRIMARY KEY,
|
||||
mbid TEXT NOT NULL,
|
||||
@@ -72,7 +152,6 @@ impl MusicBrainzCache {
|
||||
key TEXT PRIMARY KEY,
|
||||
value INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO cache_stats (key, value) VALUES ('hits', 0);
|
||||
INSERT OR IGNORE INTO cache_stats (key, value) VALUES ('misses', 0);
|
||||
",
|
||||
@@ -80,9 +159,315 @@ impl MusicBrainzCache {
|
||||
.map_err(|e| format!("Failed to init MusicBrainz schema: {}", e))
|
||||
}
|
||||
|
||||
/// Get cached track by ISRC
|
||||
fn current_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Normalize artist name for consistent cache keys
|
||||
pub fn normalize_name(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
.trim()
|
||||
.replace(['\'', '"', '.', ','], "")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ============ Settings ============
|
||||
|
||||
/// Check if MusicBrainz is enabled
|
||||
pub fn is_enabled(&self) -> Result<bool, String> {
|
||||
let result: rusqlite::Result<String> = self.conn.query_row(
|
||||
"SELECT value FROM mb_settings WHERE key = 'enabled'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
);
|
||||
match result {
|
||||
Ok(val) => Ok(val != "0"),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(true), // Default enabled
|
||||
Err(e) => Err(format!("Failed to get enabled state: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set enabled state
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
let value = if enabled { "1" } else { "0" };
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_settings (key, value) VALUES ('enabled', ?)",
|
||||
[value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Recording Cache (JSON-serialized) ============
|
||||
|
||||
/// Get cached recording by ISRC (legacy format)
|
||||
pub fn get_recording(&self, isrc: &str) -> Result<Option<serde_json::Value>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - RECORDING_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_recordings WHERE isrc = ? AND fetched_at > ?",
|
||||
params![isrc, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query recording cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached recording: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a recording (JSON-serialized)
|
||||
pub fn set_recording<T: serde::Serialize>(&self, isrc: &str, data: &T) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize recording: {}", e))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_recordings (isrc, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![isrc, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache recording: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Artist Cache (JSON-serialized) ============
|
||||
|
||||
/// Get cached artist by name (JSON-serialized)
|
||||
pub fn get_artist_by_name<T: serde::de::DeserializeOwned>(&self, name: &str) -> Result<Option<T>, String> {
|
||||
let normalized = Self::normalize_name(name);
|
||||
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_artists WHERE name_normalized = ? AND fetched_at > ?",
|
||||
params![normalized, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query artist cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached artist: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache an artist (JSON-serialized)
|
||||
pub fn set_artist_by_name<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<(), String> {
|
||||
let normalized = Self::normalize_name(name);
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize artist: {}", e))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_artists (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![normalized, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache artist: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Release Cache ============
|
||||
|
||||
/// Get cached release by barcode
|
||||
pub fn get_release<T: serde::de::DeserializeOwned>(&self, barcode: &str) -> Result<Option<T>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_releases WHERE barcode = ? AND fetched_at > ?",
|
||||
params![barcode, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query release cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached release: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a release
|
||||
pub fn set_release<T: serde::Serialize>(&self, barcode: &str, data: &T) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize release: {}", e))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_releases (barcode, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![barcode, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache release: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Artist Relations Cache ============
|
||||
|
||||
/// Get cached artist relationships by MBID
|
||||
pub fn get_artist_relations(&self, mbid: &str) -> Result<Option<ArtistRelationships>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - RELATIONS_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_artist_relations WHERE mbid = ? AND fetched_at > ?",
|
||||
params![mbid, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query relations cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached relations: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache artist relationships
|
||||
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_artist_relations (mbid, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![mbid, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache relations: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Artist Metadata Cache ============
|
||||
|
||||
/// Get cached artist metadata by MBID
|
||||
pub fn get_artist_metadata(&self, mbid: &str) -> Result<Option<ArtistMetadata>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - METADATA_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_artist_metadata WHERE mbid = ? AND fetched_at > ?",
|
||||
params![mbid, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query metadata cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached metadata: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache artist metadata
|
||||
pub fn set_artist_metadata(&self, mbid: &str, data: &ArtistMetadata) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize metadata: {}", e))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_artist_metadata (mbid, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![mbid, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache metadata: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Scene Discovery Cache ============
|
||||
|
||||
/// Get cached scene discovery results
|
||||
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_scene_cache WHERE cache_key = ? AND fetched_at > ?",
|
||||
params![cache_key, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query scene cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached scene: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache scene discovery results
|
||||
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize scene: {}", e))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![cache_key, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache scene: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Qobuz Validation Cache ============
|
||||
|
||||
/// Get cached Qobuz validation result for an artist name
|
||||
pub fn get_qobuz_validation(&self, name_normalized: &str) -> Result<Option<String>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - QOBUZ_VALIDATION_TTL_SECS;
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_qobuz_validation WHERE name_normalized = ? AND fetched_at > ?",
|
||||
params![name_normalized, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query validation cache: {}", e))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Cache Qobuz validation result
|
||||
pub fn set_qobuz_validation(&self, name_normalized: &str, data: &str) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_qobuz_validation (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![name_normalized, data, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache validation: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ V2 Resolved Types (structured cache) ============
|
||||
|
||||
/// Get cached track by ISRC (V2 structured format)
|
||||
pub fn get_track(&self, isrc: &str) -> Result<Option<ResolvedTrack>, String> {
|
||||
let result: SqlResult<ResolvedTrack> = self.conn.query_row(
|
||||
let result: rusqlite::Result<ResolvedTrack> = self.conn.query_row(
|
||||
"SELECT recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence
|
||||
FROM resolved_tracks WHERE isrc = ?",
|
||||
[isrc],
|
||||
@@ -90,7 +475,6 @@ impl MusicBrainzCache {
|
||||
let artist_mbids_json: String = row.get(2)?;
|
||||
let isrcs_json: String = row.get(4)?;
|
||||
let confidence_str: String = row.get(5)?;
|
||||
|
||||
Ok(ResolvedTrack {
|
||||
recording_mbid: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
@@ -107,7 +491,6 @@ impl MusicBrainzCache {
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(track) => {
|
||||
self.increment_stat("hits");
|
||||
@@ -121,7 +504,7 @@ impl MusicBrainzCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a resolved track
|
||||
/// Cache a resolved track (V2 structured format)
|
||||
pub fn put_track(&self, isrc: &str, track: &ResolvedTrack) -> Result<(), String> {
|
||||
let artist_mbids_json = serde_json::to_string(&track.artist_mbids).unwrap_or_default();
|
||||
let isrcs_json = serde_json::to_string(&track.isrcs).unwrap_or_default();
|
||||
@@ -132,38 +515,26 @@ impl MusicBrainzCache {
|
||||
MatchConfidence::Low => "low",
|
||||
MatchConfidence::None => "none",
|
||||
};
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO resolved_tracks (isrc, recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
isrc,
|
||||
track.recording_mbid,
|
||||
track.title,
|
||||
artist_mbids_json,
|
||||
track.release_mbid,
|
||||
isrcs_json,
|
||||
confidence,
|
||||
],
|
||||
params![isrc, track.recording_mbid, track.title, artist_mbids_json, track.release_mbid, isrcs_json, confidence],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache track: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get cached artist by name
|
||||
/// Get cached artist by name (V2 structured format)
|
||||
pub fn get_artist(&self, name: &str) -> Result<Option<ResolvedArtist>, String> {
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
let result: SqlResult<ResolvedArtist> = self.conn.query_row(
|
||||
let result: rusqlite::Result<ResolvedArtist> = self.conn.query_row(
|
||||
"SELECT mbid, name, sort_name, artist_type, country, disambiguation, confidence
|
||||
FROM resolved_artists WHERE name_lower = ?",
|
||||
[&name_lower],
|
||||
|row| {
|
||||
let artist_type_str: String = row.get(3)?;
|
||||
let confidence_str: String = row.get(6)?;
|
||||
|
||||
Ok(ResolvedArtist {
|
||||
mbid: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
@@ -181,7 +552,6 @@ impl MusicBrainzCache {
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(artist) => {
|
||||
self.increment_stat("hits");
|
||||
@@ -195,7 +565,7 @@ impl MusicBrainzCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a resolved artist
|
||||
/// Cache a resolved artist (V2 structured format)
|
||||
pub fn put_artist(&self, artist: &ResolvedArtist) -> Result<(), String> {
|
||||
let name_lower = artist.name.to_lowercase();
|
||||
let artist_type = match artist.artist_type {
|
||||
@@ -213,92 +583,110 @@ impl MusicBrainzCache {
|
||||
MatchConfidence::Low => "low",
|
||||
MatchConfidence::None => "none",
|
||||
};
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO resolved_artists (name_lower, mbid, name, sort_name, artist_type, country, disambiguation, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
name_lower,
|
||||
artist.mbid,
|
||||
artist.name,
|
||||
artist.sort_name,
|
||||
artist_type,
|
||||
artist.country,
|
||||
artist.disambiguation,
|
||||
confidence,
|
||||
],
|
||||
params![name_lower, artist.mbid, artist.name, artist.sort_name, artist_type, artist.country, artist.disambiguation, confidence],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache artist: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Maintenance ============
|
||||
|
||||
/// Clear expired entries from all tables
|
||||
pub fn cleanup_expired(&self) -> Result<usize, String> {
|
||||
let now = Self::current_timestamp();
|
||||
let mut total_deleted = 0;
|
||||
|
||||
let tables_and_ttls = [
|
||||
("mb_recordings", RECORDING_TTL_SECS),
|
||||
("mb_artists", ARTIST_TTL_SECS),
|
||||
("mb_releases", RELEASE_TTL_SECS),
|
||||
("mb_artist_relations", RELATIONS_TTL_SECS),
|
||||
("mb_artist_metadata", METADATA_TTL_SECS),
|
||||
("mb_scene_cache", SCENE_TTL_SECS),
|
||||
("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS),
|
||||
];
|
||||
|
||||
for (table, ttl) in &tables_and_ttls {
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
&format!("DELETE FROM {} WHERE fetched_at <= ?", table),
|
||||
params![now - ttl],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup {}: {}", table, e))?;
|
||||
}
|
||||
|
||||
if total_deleted > 0 {
|
||||
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted);
|
||||
}
|
||||
Ok(total_deleted)
|
||||
}
|
||||
|
||||
/// Clear all cached data (not settings)
|
||||
pub fn clear_all(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"
|
||||
DELETE FROM mb_recordings;
|
||||
DELETE FROM mb_artists;
|
||||
DELETE FROM mb_releases;
|
||||
DELETE FROM mb_artist_relations;
|
||||
DELETE FROM mb_artist_metadata;
|
||||
DELETE FROM mb_scene_cache;
|
||||
DELETE FROM mb_qobuz_validation;
|
||||
DELETE FROM resolved_tracks;
|
||||
DELETE FROM resolved_artists;
|
||||
UPDATE cache_stats SET value = 0;
|
||||
",
|
||||
)
|
||||
.map_err(|e| format!("Failed to clear MusicBrainz cache: {}", e))?;
|
||||
log::info!("MusicBrainz cache cleared");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub fn get_stats(&self) -> Result<CacheStats, String> {
|
||||
let tracks: u64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM resolved_tracks", [], |row| row.get(0))
|
||||
let recordings: i64 = self.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let artists: u64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM resolved_artists", [], |row| row.get(0))
|
||||
let artists: i64 = self.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let hits: u64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT value FROM cache_stats WHERE key = 'hits'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let releases: i64 = self.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let misses: u64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT value FROM cache_stats WHERE key = 'misses'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let relations: i64 = self.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(CacheStats {
|
||||
tracks,
|
||||
artists,
|
||||
hits,
|
||||
misses,
|
||||
recordings: recordings as u64,
|
||||
artists: artists as u64,
|
||||
releases: releases as u64,
|
||||
relations: relations as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// TTL-based cleanup (V2 style)
|
||||
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
|
||||
let tracks_deleted = self.conn
|
||||
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
|
||||
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
|
||||
let artists_deleted = self.conn
|
||||
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff])
|
||||
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
|
||||
Ok((tracks_deleted, artists_deleted))
|
||||
}
|
||||
|
||||
fn increment_stat(&self, key: &str) {
|
||||
let _ = self.conn.execute(
|
||||
"UPDATE cache_stats SET value = value + 1 WHERE key = ?",
|
||||
[key],
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear expired entries (older than ttl_days)
|
||||
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
|
||||
|
||||
let tracks_deleted = self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM resolved_tracks WHERE cached_at < ?",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
|
||||
|
||||
let artists_deleted = self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM resolved_artists WHERE cached_at < ?",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
|
||||
|
||||
Ok((tracks_deleted, artists_deleted))
|
||||
}
|
||||
}
|
||||
@@ -271,4 +271,360 @@ impl MusicBrainzClient {
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ============ Extended API Methods ============
|
||||
|
||||
/// Search recordings by title and artist
|
||||
pub async fn search_recording(&self, title: &str, artist: &str) -> IntegrationResult<RecordingSearchResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let query = format!(
|
||||
"recording:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(title),
|
||||
Self::escape_query(artist)
|
||||
);
|
||||
let url = format!("{}/recording?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
self.check_response(&response).await;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get artist details with relationships and tags
|
||||
pub async fn get_artist_with_relations(&self, mbid: &str) -> IntegrationResult<ArtistFullResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let url = format!("{}/artist/{}?inc=artist-rels+tags&fmt=json", base, mbid);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Fetch artist tags only (lightweight, no relations)
|
||||
pub async fn get_artist_tags(&self, mbid: &str) -> IntegrationResult<Vec<String>> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let url = format!("{}/artist/{}?inc=tags&fmt=json", base, mbid);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let artist: ArtistFullResponse = response.json().await.map_err(|e| {
|
||||
IntegrationError::internal(format!("Failed to parse MusicBrainz response: {}", e))
|
||||
})?;
|
||||
|
||||
let mut tags: Vec<_> = artist
|
||||
.tags
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|tag| tag.count.unwrap_or(0) > 0)
|
||||
.collect();
|
||||
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
|
||||
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect())
|
||||
}
|
||||
|
||||
/// Search artists by tag (genre)
|
||||
pub async fn search_artists_by_tag(&self, tag: &str, limit: usize) -> IntegrationResult<ArtistSearchResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let limit = limit.min(100).max(1);
|
||||
let query = format!("tag:\"{}\"", Self::escape_query(tag));
|
||||
let url = format!(
|
||||
"{}/artist?query={}&fmt=json&limit={}",
|
||||
base, urlencoding::encode(&query), limit
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Search artists by tag AND area
|
||||
pub async fn search_artists_by_tag_and_area(
|
||||
&self,
|
||||
tag: &str,
|
||||
area_name: &str,
|
||||
country: Option<&str>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> IntegrationResult<ArtistSearchResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let limit = limit.min(100).max(1);
|
||||
let search_area = country.unwrap_or(area_name);
|
||||
let query = format!(
|
||||
"tag:\"{}\" AND area:\"{}\"",
|
||||
Self::escape_query(tag),
|
||||
Self::escape_query(search_area)
|
||||
);
|
||||
let url = format!(
|
||||
"{}/artist?query={}&fmt=json&limit={}&offset={}",
|
||||
base, urlencoding::encode(&query), limit, offset
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Search releases by barcode (UPC/EAN)
|
||||
pub async fn search_release_by_barcode(&self, barcode: &str) -> IntegrationResult<ReleaseSearchResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let url = format!("{}/release?query=barcode:{}&fmt=json&limit=5", base, barcode);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Search releases by title and artist
|
||||
pub async fn search_release(&self, title: &str, artist: &str) -> IntegrationResult<ReleaseSearchResponse> {
|
||||
self.search_releases_extended(title, artist, None, 5).await
|
||||
}
|
||||
|
||||
/// Search releases with extended options
|
||||
pub async fn search_releases_extended(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
catalog_number: Option<&str>,
|
||||
limit: usize,
|
||||
) -> IntegrationResult<ReleaseSearchResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
|
||||
format!(
|
||||
"catno:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(catno),
|
||||
Self::escape_query(artist)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"release:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(title),
|
||||
Self::escape_query(artist)
|
||||
)
|
||||
};
|
||||
|
||||
let limit = limit.min(25).max(1);
|
||||
let url = format!(
|
||||
"{}/release?query={}&fmt=json&limit={}",
|
||||
base, urlencoding::encode(&query), limit
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get full release details including tracks
|
||||
pub async fn get_release_with_tracks(&self, release_id: &str) -> IntegrationResult<ReleaseFullResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let url = format!(
|
||||
"{}/release/{}?inc=recordings+artist-credits+labels+tags&fmt=json",
|
||||
base, release_id
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Browse artists by area MBID
|
||||
pub async fn browse_artists_by_area(
|
||||
&self,
|
||||
area_id: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> IntegrationResult<ArtistBrowseResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let limit = limit.min(100).max(1);
|
||||
let url = format!(
|
||||
"{}/artist?area={}&fmt=json&limit={}&offset={}&inc=tags",
|
||||
base, area_id, limit, offset
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Search for an area by name
|
||||
pub async fn search_area(&self, name: &str, area_type: Option<&str>) -> IntegrationResult<AreaSearchResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let query = if let Some(atype) = area_type {
|
||||
format!(
|
||||
"area:\"{}\" AND type:\"{}\"",
|
||||
Self::escape_query(name),
|
||||
Self::escape_query(atype)
|
||||
)
|
||||
} else {
|
||||
format!("area:\"{}\"", Self::escape_query(name))
|
||||
};
|
||||
|
||||
let url = format!("{}/area?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Look up an area and its parent relationships
|
||||
pub async fn get_area_with_relations(&self, area_id: &str) -> IntegrationResult<AreaDetailResponse> {
|
||||
self.check_enabled().await?;
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base = self.base_url().await;
|
||||
let url = format!("{}/area/{}?inc=area-rels&fmt=json", base, area_id);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let response = self.handle_response_status(response).await?;
|
||||
response.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Resolve a city area to its parent subdivision (state/region)
|
||||
pub async fn resolve_parent_subdivision(&self, area_id: &str) -> IntegrationResult<Option<(String, String)>> {
|
||||
let mut current_id = area_id.to_string();
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let max_hops = 5;
|
||||
|
||||
for _hop in 0..max_hops {
|
||||
let detail = self.get_area_with_relations(¤t_id).await?;
|
||||
path.push(format!("{}[{:?}]", detail.name, detail.area_type));
|
||||
|
||||
let parents: Vec<_> = detail
|
||||
.relations
|
||||
.as_ref()
|
||||
.map(|rels| {
|
||||
rels.iter()
|
||||
.filter(|rel| {
|
||||
rel.relation_type == "part of"
|
||||
&& rel.direction.as_deref() == Some("backward")
|
||||
})
|
||||
.filter_map(|rel| rel.area.as_ref())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if parents.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let has_country_parent = parents.iter().any(|p| {
|
||||
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("country")).unwrap_or(false)
|
||||
});
|
||||
|
||||
if has_country_parent {
|
||||
let own_type = detail.area_type.as_deref().unwrap_or("");
|
||||
if own_type.eq_ignore_ascii_case("subdivision") {
|
||||
if current_id == area_id {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some((detail.name.clone(), detail.id.clone())));
|
||||
}
|
||||
if current_id == area_id {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some((detail.name.clone(), detail.id.clone())));
|
||||
}
|
||||
|
||||
let next = parents
|
||||
.iter()
|
||||
.find(|p| {
|
||||
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("subdivision")).unwrap_or(false)
|
||||
})
|
||||
.or_else(|| {
|
||||
parents.iter().find(|p| {
|
||||
let t = p.area_type.as_deref().unwrap_or("");
|
||||
!t.eq_ignore_ascii_case("city") && !t.eq_ignore_ascii_case("country")
|
||||
})
|
||||
})
|
||||
.or_else(|| parents.first());
|
||||
|
||||
match next {
|
||||
Some(parent) => { current_id = parent.id.clone(); }
|
||||
None => { return Ok(None); }
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ============ Internal Helpers ============
|
||||
|
||||
async fn check_enabled(&self) -> IntegrationResult<()> {
|
||||
if !self.is_enabled().await {
|
||||
return Err(IntegrationError::ServiceUnavailable(
|
||||
"MusicBrainz integration is disabled".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
async fn check_response(&self, _response: &reqwest::Response) {
|
||||
// Placeholder for response logging/metrics
|
||||
}
|
||||
|
||||
async fn handle_response_status(&self, response: reqwest::Response) -> IntegrationResult<reqwest::Response> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status();
|
||||
if status.as_u16() == 429 {
|
||||
return Err(IntegrationError::RateLimited(60));
|
||||
}
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(IntegrationError::internal(format!("MusicBrainz API error {}: {}", status, text)))
|
||||
}
|
||||
|
||||
/// Escape special characters in Lucene queries
|
||||
fn escape_query(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace(':', "\\:")
|
||||
.replace('(', "\\(")
|
||||
.replace(')', "\\)")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
.replace('{', "\\{")
|
||||
.replace('}', "\\}")
|
||||
.replace('^', "\\^")
|
||||
.replace('~', "\\~")
|
||||
.replace('*', "\\*")
|
||||
.replace('?', "\\?")
|
||||
.replace('!', "\\!")
|
||||
.replace('+', "\\+")
|
||||
.replace('-', "\\-")
|
||||
.replace('&', "\\&")
|
||||
.replace('|', "\\|")
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,46 @@ pub struct ArtistResult {
|
||||
pub artist_type: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub disambiguation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub aliases: Option<Vec<Alias>>,
|
||||
#[serde(rename = "life-span")]
|
||||
pub life_span: Option<LifeSpan>,
|
||||
#[serde(default)]
|
||||
pub area: Option<Area>,
|
||||
#[serde(rename = "begin-area", default)]
|
||||
pub begin_area: Option<Area>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<Tag>>,
|
||||
}
|
||||
|
||||
/// Artist alias
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Alias {
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub alias_type: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub primary: Option<bool>,
|
||||
}
|
||||
|
||||
/// MusicBrainz area (city, state, country, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Area {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub area_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Community tag (used for genres)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Tag {
|
||||
pub name: String,
|
||||
pub count: Option<i32>,
|
||||
}
|
||||
|
||||
/// Life span for an artist
|
||||
@@ -203,3 +241,388 @@ pub struct ResolvedRelease {
|
||||
pub country: Option<String>,
|
||||
pub confidence: MatchConfidence,
|
||||
}
|
||||
|
||||
// ============ Full Response Types (for lookups with includes) ============
|
||||
|
||||
/// Full artist response (with includes like relations, tags)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistFullResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub artist_type: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub disambiguation: Option<String>,
|
||||
#[serde(rename = "life-span")]
|
||||
pub life_span: Option<LifeSpan>,
|
||||
#[serde(default)]
|
||||
pub area: Option<Area>,
|
||||
#[serde(rename = "begin-area", default)]
|
||||
pub begin_area: Option<Area>,
|
||||
pub relations: Option<Vec<Relation>>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<Tag>>,
|
||||
}
|
||||
|
||||
/// Release search response
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseSearchResponse {
|
||||
pub created: Option<String>,
|
||||
pub count: i32,
|
||||
pub offset: i32,
|
||||
pub releases: Vec<ReleaseResult>,
|
||||
}
|
||||
|
||||
/// Single release in search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseResult {
|
||||
pub id: String,
|
||||
pub score: Option<i32>,
|
||||
pub title: String,
|
||||
pub status: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub barcode: Option<String>,
|
||||
#[serde(rename = "label-info")]
|
||||
pub label_info: Option<Vec<LabelInfo>>,
|
||||
#[serde(rename = "release-group")]
|
||||
pub release_group: Option<ReleaseGroupRef>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
pub media: Option<Vec<ReleaseSearchMedium>>,
|
||||
#[serde(rename = "track-count")]
|
||||
pub track_count: Option<u16>,
|
||||
}
|
||||
|
||||
/// Simplified medium info for search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseSearchMedium {
|
||||
pub format: Option<String>,
|
||||
#[serde(rename = "track-count")]
|
||||
pub track_count: Option<u16>,
|
||||
}
|
||||
|
||||
/// Label information
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LabelInfo {
|
||||
#[serde(rename = "catalog-number")]
|
||||
pub catalog_number: Option<String>,
|
||||
pub label: Option<LabelRef>,
|
||||
}
|
||||
|
||||
/// Reference to a label
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LabelRef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Full release response from lookup endpoint (with tracks)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseFullResponse {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub barcode: Option<String>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
#[serde(rename = "label-info")]
|
||||
pub label_info: Option<Vec<LabelInfo>>,
|
||||
#[serde(rename = "release-group")]
|
||||
pub release_group: Option<ReleaseGroupRef>,
|
||||
pub media: Option<Vec<Medium>>,
|
||||
pub tags: Option<Vec<Tag>>,
|
||||
}
|
||||
|
||||
/// A medium (disc) containing tracks
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Medium {
|
||||
pub position: Option<u8>,
|
||||
pub format: Option<String>,
|
||||
#[serde(rename = "track-count")]
|
||||
pub track_count: Option<u16>,
|
||||
pub tracks: Option<Vec<MediumTrack>>,
|
||||
}
|
||||
|
||||
/// A track on a medium
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MediumTrack {
|
||||
pub position: Option<u8>,
|
||||
pub number: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
pub recording: Option<TrackRecording>,
|
||||
}
|
||||
|
||||
/// Recording reference within a track
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TrackRecording {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
}
|
||||
|
||||
/// Artist browse response (from browse API)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistBrowseResponse {
|
||||
#[serde(rename = "artist-count")]
|
||||
pub artist_count: Option<i32>,
|
||||
#[serde(rename = "artist-offset")]
|
||||
pub artist_offset: Option<i32>,
|
||||
pub artists: Vec<ArtistResult>,
|
||||
}
|
||||
|
||||
/// Area search response
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AreaSearchResponse {
|
||||
pub count: Option<i32>,
|
||||
pub offset: Option<i32>,
|
||||
pub areas: Vec<AreaResult>,
|
||||
}
|
||||
|
||||
/// Single area in search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AreaResult {
|
||||
pub id: String,
|
||||
pub score: Option<i32>,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub area_type: Option<String>,
|
||||
#[serde(rename = "iso-3166-1-codes", default)]
|
||||
pub iso_codes: Option<Vec<String>>,
|
||||
#[serde(rename = "life-span")]
|
||||
pub life_span: Option<LifeSpan>,
|
||||
}
|
||||
|
||||
/// Area detail response (from /area/{id}?inc=area-rels)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AreaDetailResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub area_type: Option<String>,
|
||||
#[serde(rename = "iso-3166-1-codes", default)]
|
||||
pub iso_codes: Option<Vec<String>>,
|
||||
#[serde(rename = "iso-3166-2-codes", default)]
|
||||
pub iso_3166_2_codes: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub relations: Option<Vec<AreaRelation>>,
|
||||
}
|
||||
|
||||
/// Area relationship entry
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AreaRelation {
|
||||
#[serde(rename = "type")]
|
||||
pub relation_type: String,
|
||||
pub direction: Option<String>,
|
||||
pub area: Option<AreaRelTarget>,
|
||||
}
|
||||
|
||||
/// Target area in an area relationship
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AreaRelTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub area_type: Option<String>,
|
||||
#[serde(rename = "iso-3166-1-codes", default)]
|
||||
pub iso_codes: Option<Vec<String>>,
|
||||
#[serde(rename = "iso-3166-2-codes", default)]
|
||||
pub iso_3166_2_codes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ============ Relationship Types ============
|
||||
|
||||
/// Related artist (for relationships)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelatedArtist {
|
||||
pub mbid: String,
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub period: Option<Period>,
|
||||
pub ended: bool,
|
||||
}
|
||||
|
||||
/// Time period for a relationship
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Period {
|
||||
pub begin: Option<String>,
|
||||
pub end: Option<String>,
|
||||
}
|
||||
|
||||
/// Artist relationships
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtistRelationships {
|
||||
pub members: Vec<RelatedArtist>,
|
||||
pub past_members: Vec<RelatedArtist>,
|
||||
pub groups: Vec<RelatedArtist>,
|
||||
pub collaborators: Vec<RelatedArtist>,
|
||||
}
|
||||
|
||||
impl ArtistRelationships {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
members: Vec::new(),
|
||||
past_members: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
collaborators: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.members.is_empty()
|
||||
&& self.past_members.is_empty()
|
||||
&& self.groups.is_empty()
|
||||
&& self.collaborators.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Artist Metadata (Location Discovery) ============
|
||||
|
||||
/// Precision level for artist location data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocationPrecision {
|
||||
City,
|
||||
State,
|
||||
Country,
|
||||
}
|
||||
|
||||
/// Resolved location for an artist
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtistLocation {
|
||||
pub city: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub country_code: Option<String>,
|
||||
pub display_name: String,
|
||||
pub precision: LocationPrecision,
|
||||
}
|
||||
|
||||
/// Affinity seeds extracted from an artist's tags
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AffinitySeeds {
|
||||
pub genres: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub normalized_seeds: Vec<String>,
|
||||
}
|
||||
|
||||
/// Complete artist metadata for location discovery
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtistMetadata {
|
||||
pub mbid: String,
|
||||
pub name: String,
|
||||
pub artist_type: ArtistType,
|
||||
pub life_span: Option<LifeSpan>,
|
||||
pub location: Option<ArtistLocation>,
|
||||
pub affinity_seeds: AffinitySeeds,
|
||||
}
|
||||
|
||||
/// A candidate artist from location discovery, validated against Qobuz
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocationCandidate {
|
||||
pub mbid: String,
|
||||
pub mb_name: String,
|
||||
pub qobuz_id: Option<i64>,
|
||||
pub qobuz_name: Option<String>,
|
||||
pub qobuz_image: Option<String>,
|
||||
pub score: i32,
|
||||
pub genres: Vec<String>,
|
||||
pub qobuz_albums_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Response from the location discovery pipeline
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocationDiscoveryResponse {
|
||||
pub artists: Vec<LocationCandidate>,
|
||||
pub scene_label: String,
|
||||
pub genre_summary: String,
|
||||
pub total_candidates: usize,
|
||||
pub has_more: bool,
|
||||
pub next_offset: usize,
|
||||
}
|
||||
|
||||
// ============ Musician Types ============
|
||||
|
||||
/// Musician confidence level for MusicBrainz <-> Qobuz matching
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MusicianConfidence {
|
||||
Confirmed,
|
||||
Contextual,
|
||||
Weak,
|
||||
None,
|
||||
}
|
||||
|
||||
impl MusicianConfidence {
|
||||
pub fn level(&self) -> u8 {
|
||||
match self {
|
||||
Self::Confirmed => 3,
|
||||
Self::Contextual => 2,
|
||||
Self::Weak => 1,
|
||||
Self::None => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MusicianConfidence {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved musician with confidence assessment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedMusician {
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub mbid: Option<String>,
|
||||
pub qobuz_artist_id: Option<i64>,
|
||||
pub confidence: MusicianConfidence,
|
||||
pub bands: Vec<String>,
|
||||
pub appears_on_count: usize,
|
||||
}
|
||||
|
||||
impl ResolvedMusician {
|
||||
pub fn empty(name: String, role: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
role,
|
||||
mbid: None,
|
||||
qobuz_artist_id: None,
|
||||
confidence: MusicianConfidence::None,
|
||||
bands: Vec::new(),
|
||||
appears_on_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Album appearance for a musician
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlbumAppearance {
|
||||
pub album_id: String,
|
||||
pub album_title: String,
|
||||
pub album_artwork: String,
|
||||
pub artist_name: String,
|
||||
pub year: Option<String>,
|
||||
pub role_on_album: String,
|
||||
}
|
||||
|
||||
/// Musician appearances response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MusicianAppearances {
|
||||
pub albums: Vec<AlbumAppearance>,
|
||||
pub total: usize,
|
||||
}
|
||||
@@ -192,12 +192,21 @@ impl LibraryDatabase {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_images_fetched ON artist_images(fetched_at);
|
||||
|
||||
-- Custom album covers (user-uploaded covers for Qobuz albums)
|
||||
CREATE TABLE IF NOT EXISTS custom_album_covers (
|
||||
album_id TEXT PRIMARY KEY,
|
||||
custom_image_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Downloaded purchases registry (permanent — user owns these files)
|
||||
CREATE TABLE IF NOT EXISTS downloaded_purchases (
|
||||
track_id INTEGER PRIMARY KEY,
|
||||
track_id INTEGER NOT NULL,
|
||||
format_id INTEGER NOT NULL DEFAULT 0,
|
||||
album_id TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
downloaded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
downloaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (track_id, format_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_purchases_album
|
||||
@@ -683,6 +692,52 @@ impl LibraryDatabase {
|
||||
.map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
|
||||
}
|
||||
|
||||
// Migration: Add format_id to downloaded_purchases (compound PK: track_id + format_id)
|
||||
let has_format_id: bool = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('downloaded_purchases') WHERE name = 'format_id'",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_format_id {
|
||||
log::info!("Running migration: adding format_id to downloaded_purchases (compound PK)");
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
DROP TABLE IF EXISTS downloaded_purchases_new;
|
||||
|
||||
CREATE TABLE downloaded_purchases_new (
|
||||
track_id INTEGER NOT NULL,
|
||||
format_id INTEGER NOT NULL DEFAULT 0,
|
||||
album_id TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
downloaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (track_id, format_id)
|
||||
);
|
||||
|
||||
INSERT INTO downloaded_purchases_new (track_id, format_id, album_id, file_path, downloaded_at)
|
||||
SELECT track_id, 0, album_id, file_path, downloaded_at
|
||||
FROM downloaded_purchases;
|
||||
|
||||
DROP TABLE downloaded_purchases;
|
||||
ALTER TABLE downloaded_purchases_new RENAME TO downloaded_purchases;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_purchases_album
|
||||
ON downloaded_purchases(album_id);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!(
|
||||
"downloaded_purchases format_id migration failed: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -935,6 +990,17 @@ impl LibraryDatabase {
|
||||
.map_err(|e| LibraryError::Database(e.to_string()));
|
||||
}
|
||||
|
||||
// Detect if this file is a Qobuz purchased download
|
||||
let is_purchase: bool = self.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM downloaded_purchases WHERE file_path = ?1",
|
||||
params![track.file_path],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap_or(0) > 0;
|
||||
|
||||
let source = if is_purchase { "qobuz_purchase" } else { "user" };
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
r#"INSERT OR REPLACE INTO local_tracks
|
||||
@@ -942,8 +1008,8 @@ impl LibraryDatabase {
|
||||
disc_number, year, genre, catalog_number, duration_secs, format, bit_depth,
|
||||
sample_rate, channels, file_size_bytes, cue_file_path,
|
||||
cue_start_secs, cue_end_secs, artwork_path, last_modified, indexed_at,
|
||||
album_group_key, album_group_title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
album_group_key, album_group_title, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
params![
|
||||
track.file_path,
|
||||
track.title,
|
||||
@@ -968,7 +1034,8 @@ impl LibraryDatabase {
|
||||
track.last_modified,
|
||||
track.indexed_at,
|
||||
track.album_group_key,
|
||||
track.album_group_title
|
||||
track.album_group_title,
|
||||
source
|
||||
],
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
@@ -980,7 +1047,7 @@ impl LibraryDatabase {
|
||||
pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT * FROM local_tracks WHERE id = ?")
|
||||
.prepare(&format!("SELECT {} FROM local_tracks WHERE id = ?", Self::TRACK_COLUMNS))
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
stmt.query_row(params![id], |row| Self::row_to_track(row))
|
||||
@@ -992,7 +1059,7 @@ impl LibraryDatabase {
|
||||
pub fn get_track_by_path(&self, path: &str) -> Result<Option<LocalTrack>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT * FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL")
|
||||
.prepare(&format!("SELECT {} FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL", Self::TRACK_COLUMNS))
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
stmt.query_row(params![path], |row| Self::row_to_track(row))
|
||||
@@ -1251,15 +1318,15 @@ impl LibraryDatabase {
|
||||
|
||||
/// Get tracks for an album group
|
||||
pub fn get_album_tracks(&self, group_key: &str) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM local_tracks \
|
||||
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ? \
|
||||
ORDER BY disc_number, track_number, title",
|
||||
Self::TRACK_COLUMNS
|
||||
);
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
r#"
|
||||
SELECT * FROM local_tracks
|
||||
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ?
|
||||
ORDER BY disc_number, track_number, title
|
||||
"#,
|
||||
)
|
||||
.prepare(&sql)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
@@ -1622,13 +1689,10 @@ impl LibraryDatabase {
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT * FROM local_tracks
|
||||
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1)
|
||||
{} {}
|
||||
{}
|
||||
"#,
|
||||
source_filter, network_filter, limit_clause
|
||||
"SELECT {} FROM local_tracks \
|
||||
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \
|
||||
{} {} {}",
|
||||
Self::TRACK_COLUMNS, source_filter, network_filter, limit_clause
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
@@ -1689,35 +1753,44 @@ impl LibraryDatabase {
|
||||
// === Helpers ===
|
||||
|
||||
/// Convert a database row to LocalTrack
|
||||
/// Column list for SELECT queries (avoids fragile SELECT * with positional indices)
|
||||
const TRACK_COLUMNS: &'static str =
|
||||
"id, file_path, title, artist, album, album_artist, \
|
||||
track_number, disc_number, year, genre, duration_secs, format, \
|
||||
bit_depth, sample_rate, channels, file_size_bytes, \
|
||||
cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \
|
||||
last_modified, indexed_at, album_group_key, album_group_title, \
|
||||
source, qobuz_track_id, catalog_number";
|
||||
|
||||
fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> {
|
||||
Ok(LocalTrack {
|
||||
id: row.get(0)?,
|
||||
file_path: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
artist: row.get(3)?,
|
||||
album: row.get(4)?,
|
||||
album_artist: row.get(5)?,
|
||||
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(),
|
||||
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(),
|
||||
track_number: row.get(6)?,
|
||||
disc_number: row.get(7)?,
|
||||
year: row.get(8)?,
|
||||
genre: row.get(9)?,
|
||||
catalog_number: row.get(26).ok().flatten(),
|
||||
duration_secs: row.get(10)?,
|
||||
format: Self::parse_format(&row.get::<_, String>(11)?),
|
||||
bit_depth: row.get(12)?,
|
||||
sample_rate: row.get::<_, f64>(13)?,
|
||||
channels: row.get(14)?,
|
||||
file_size_bytes: row.get(15)?,
|
||||
cue_file_path: row.get(16)?,
|
||||
cue_start_secs: row.get(17)?,
|
||||
cue_end_secs: row.get(18)?,
|
||||
artwork_path: row.get(19)?,
|
||||
last_modified: row.get(20)?,
|
||||
indexed_at: row.get(21)?,
|
||||
source: row.get(24).ok().flatten(),
|
||||
qobuz_track_id: row.get(25).ok().flatten(),
|
||||
id: row.get(0)?, // id
|
||||
file_path: row.get(1)?, // file_path
|
||||
title: row.get(2)?, // title
|
||||
artist: row.get(3)?, // artist
|
||||
album: row.get(4)?, // album
|
||||
album_artist: row.get(5)?, // album_artist
|
||||
track_number: row.get(6)?, // track_number
|
||||
disc_number: row.get(7)?, // disc_number
|
||||
year: row.get(8)?, // year
|
||||
genre: row.get(9)?, // genre
|
||||
duration_secs: row.get(10)?, // duration_secs
|
||||
format: Self::parse_format(&row.get::<_, String>(11)?), // format
|
||||
bit_depth: row.get(12)?, // bit_depth
|
||||
sample_rate: row.get::<_, f64>(13)?, // sample_rate
|
||||
channels: row.get(14)?, // channels
|
||||
file_size_bytes: row.get(15)?, // file_size_bytes
|
||||
cue_file_path: row.get(16)?, // cue_file_path
|
||||
cue_start_secs: row.get(17)?, // cue_start_secs
|
||||
cue_end_secs: row.get(18)?, // cue_end_secs
|
||||
artwork_path: row.get(19)?, // artwork_path
|
||||
last_modified: row.get(20)?, // last_modified
|
||||
indexed_at: row.get(21)?, // indexed_at
|
||||
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key
|
||||
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title
|
||||
source: row.get(24).ok().flatten(), // source
|
||||
qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id
|
||||
catalog_number: row.get(26).ok().flatten(), // catalog_number
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3281,6 +3354,8 @@ impl LibraryDatabase {
|
||||
file_path: &str,
|
||||
bit_depth: Option<u32>,
|
||||
sample_rate: Option<f64>,
|
||||
track_number: Option<u32>,
|
||||
disc_number: Option<u32>,
|
||||
) -> Result<(), LibraryError> {
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -3311,8 +3386,8 @@ impl LibraryDatabase {
|
||||
artist,
|
||||
album.unwrap_or("Unknown Album"),
|
||||
artist, // Use artist as album_artist for proper grouping
|
||||
0, // track_number - will be updated if metadata is available
|
||||
None::<u32>, // disc_number
|
||||
track_number.map(|v| v as i64),
|
||||
disc_number.map(|v| v as i64),
|
||||
None::<u32>, // year
|
||||
duration_secs as i64,
|
||||
"flac", // Default format for downloads
|
||||
@@ -3457,6 +3532,34 @@ impl LibraryDatabase {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get all custom artist images (for bulk lookup)
|
||||
pub fn get_all_custom_artist_images(
|
||||
&self,
|
||||
) -> Result<std::collections::HashMap<String, String>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT artist_name, custom_image_path FROM artist_images WHERE custom_image_path IS NOT NULL",
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to query custom artist images: {}", e))
|
||||
})?;
|
||||
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
if let Ok((artist_name, custom_image_path)) = row {
|
||||
map.insert(artist_name, custom_image_path);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Get all canonical artist names mapping (for bulk lookup)
|
||||
pub fn get_all_canonical_names(
|
||||
&self,
|
||||
@@ -3523,6 +3626,95 @@ impl LibraryDatabase {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Custom Album Covers ===
|
||||
|
||||
/// Set a custom album cover
|
||||
pub fn set_custom_album_cover(
|
||||
&self,
|
||||
album_id: &str,
|
||||
custom_image_path: &str,
|
||||
) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO custom_album_covers (album_id, custom_image_path, created_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![album_id, custom_image_path, now],
|
||||
)
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to set custom album cover: {}", e))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get custom album cover path for a single album
|
||||
pub fn get_custom_album_cover(
|
||||
&self,
|
||||
album_id: &str,
|
||||
) -> Result<Option<String>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1",
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![album_id], |row| row.get::<_, String>(0))
|
||||
.optional()
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to query custom album cover: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Remove a custom album cover
|
||||
pub fn remove_custom_album_cover(
|
||||
&self,
|
||||
album_id: &str,
|
||||
) -> Result<(), LibraryError> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM custom_album_covers WHERE album_id = ?1",
|
||||
params![album_id],
|
||||
)
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to remove custom album cover: {}", e))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all custom album covers (album_id -> file_path)
|
||||
pub fn get_all_custom_album_covers(
|
||||
&self,
|
||||
) -> Result<std::collections::HashMap<String, String>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT album_id, custom_image_path FROM custom_album_covers")
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to query custom album covers: {}", e))
|
||||
})?;
|
||||
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
if let Ok((album_id, path)) = row {
|
||||
map.insert(album_id, path);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// === Offline Mode: Local Content Detection ===
|
||||
|
||||
/// Check if a track exists locally by Qobuz track ID
|
||||
@@ -3740,18 +3932,19 @@ impl LibraryDatabase {
|
||||
|
||||
// ── Downloaded Purchases Registry ──
|
||||
|
||||
/// Record a track as downloaded on this computer.
|
||||
/// Record a track as downloaded on this computer with its format.
|
||||
pub fn mark_purchase_downloaded(
|
||||
&self,
|
||||
track_id: i64,
|
||||
album_id: Option<&str>,
|
||||
file_path: &str,
|
||||
format_id: i64,
|
||||
) -> Result<(), LibraryError> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO downloaded_purchases (track_id, album_id, file_path, downloaded_at)
|
||||
VALUES (?1, ?2, ?3, datetime('now'))",
|
||||
rusqlite::params![track_id, album_id, file_path],
|
||||
"INSERT OR REPLACE INTO downloaded_purchases (track_id, format_id, album_id, file_path, downloaded_at)
|
||||
VALUES (?1, ?2, ?3, ?4, datetime('now'))",
|
||||
rusqlite::params![track_id, format_id, album_id, file_path],
|
||||
)
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to mark purchase downloaded: {}", e))
|
||||
@@ -3772,22 +3965,69 @@ impl LibraryDatabase {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all downloaded track IDs for fast lookup.
|
||||
/// Get all downloaded track IDs for fast lookup (any format).
|
||||
/// Automatically removes stale entries where the file no longer exists on disk.
|
||||
pub fn get_downloaded_purchase_track_ids(&self) -> Result<Vec<i64>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT track_id FROM downloaded_purchases")
|
||||
.prepare("SELECT track_id, format_id, file_path FROM downloaded_purchases")
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to prepare statement: {}", e))
|
||||
})?;
|
||||
|
||||
let ids = stmt
|
||||
.query_map([], |row| row.get::<_, i64>(0))
|
||||
let rows: Vec<(i64, i64, String)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to query downloaded purchases: {}", e))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect rows: {}", e)))?;
|
||||
|
||||
let mut stale: Vec<(i64, i64)> = Vec::new();
|
||||
let mut valid_ids: Vec<i64> = Vec::new();
|
||||
|
||||
for (track_id, format_id, file_path) in &rows {
|
||||
if std::path::Path::new(file_path).exists() {
|
||||
valid_ids.push(*track_id);
|
||||
} else {
|
||||
stale.push((*track_id, *format_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries where the file no longer exists
|
||||
if !stale.is_empty() {
|
||||
log::info!("Removing {} stale downloaded_purchases entries (files deleted)", stale.len());
|
||||
for (track_id, format_id) in &stale {
|
||||
let _ = self.conn.execute(
|
||||
"DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2",
|
||||
rusqlite::params![track_id, format_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
valid_ids.sort_unstable();
|
||||
valid_ids.dedup();
|
||||
Ok(valid_ids)
|
||||
}
|
||||
|
||||
/// Get all downloaded (track_id, format_id) pairs for building per-format lookup.
|
||||
pub fn get_downloaded_purchase_formats(&self) -> Result<Vec<(i64, i64)>, LibraryError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT track_id, format_id FROM downloaded_purchases")
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to prepare statement: {}", e))
|
||||
})?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
|
||||
.map_err(|e| {
|
||||
LibraryError::Database(format!("Failed to query downloaded purchases: {}", e))
|
||||
})?;
|
||||
|
||||
ids.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect ids: {}", e)))
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect formats: {}", e)))
|
||||
}
|
||||
}
|
||||
@@ -106,35 +106,52 @@ impl MetadataExtractor {
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
for (i, token) in tokens.iter().enumerate() {
|
||||
let has_digits = token.chars().any(|c| c.is_ascii_digit());
|
||||
if (*token == "disc" || *token == "disk" || *token == "cd") && (has_digits || tokens.get(i + 1).map_or(false, |t| t.chars().all(|c| c.is_ascii_digit()))) {
|
||||
return true;
|
||||
}
|
||||
if token.starts_with("disc") {
|
||||
let rest = &token[4..];
|
||||
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if token.starts_with("disk") {
|
||||
let rest = &token[4..];
|
||||
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if token.starts_with("cd") {
|
||||
let rest = &token[2..];
|
||||
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if *token == "bonus" && tokens.get(i + 1).map_or(false, |t| *t == "disc" || *t == "disk" || *t == "cd") {
|
||||
return true;
|
||||
}
|
||||
// Must contain at least one disc-related token
|
||||
let has_disc_token = tokens.iter().any(|token| {
|
||||
*token == "disc" || *token == "disk" || *token == "cd"
|
||||
|| (token.starts_with("disc") && token[4..].chars().all(|c| c.is_ascii_digit()) && !token[4..].is_empty())
|
||||
|| (token.starts_with("disk") && token[4..].chars().all(|c| c.is_ascii_digit()) && !token[4..].is_empty())
|
||||
|| (token.starts_with("cd") && token[2..].chars().all(|c| c.is_ascii_digit()) && !token[2..].is_empty())
|
||||
});
|
||||
|
||||
if !has_disc_token {
|
||||
return false;
|
||||
}
|
||||
|
||||
false
|
||||
// A genuine disc folder name consists ONLY of disc-related tokens,
|
||||
// digits, and common modifiers like "bonus". If other words remain
|
||||
// after filtering these out, the name is an album title that happens
|
||||
// to contain "Disc 1" etc., not a standalone disc folder.
|
||||
// Examples that ARE disc folders: "Disc 1", "CD2", "Bonus Disc", "disc01"
|
||||
// Examples that are NOT: "Relaxation Disc1", "Now 75 - CD1",
|
||||
// "100 Popular Classics, Disc 1"
|
||||
let has_extra_words = tokens.iter().any(|token| {
|
||||
// Pure digits are fine
|
||||
if token.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
// Disc keywords are fine
|
||||
if *token == "disc" || *token == "disk" || *token == "cd" {
|
||||
return false;
|
||||
}
|
||||
// Disc+number compounds are fine (disc1, cd02, etc.)
|
||||
for prefix in &["disc", "disk", "cd"] {
|
||||
if token.starts_with(prefix) {
|
||||
let rest = &token[prefix.len()..];
|
||||
if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Common disc folder modifiers are fine
|
||||
if matches!(*token, "bonus" | "extra" | "side" | "part") {
|
||||
return false;
|
||||
}
|
||||
// Anything else means this is not a pure disc folder
|
||||
true
|
||||
});
|
||||
|
||||
!has_extra_words
|
||||
}
|
||||
|
||||
fn disc_number_from_name(name: &str) -> Option<u32> {
|
||||
@@ -176,6 +193,73 @@ impl MetadataExtractor {
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to extract a track number from the filename.
|
||||
/// Handles common patterns like:
|
||||
/// - "01 - Title.flac"
|
||||
/// - "01. Title.flac"
|
||||
/// - "01_Title.flac"
|
||||
/// - "Track 01.flac"
|
||||
/// - "1-01 Title.flac" (disc-track)
|
||||
pub fn infer_track_number_from_filename(file_path: &Path) -> Option<u32> {
|
||||
let stem = file_path.file_stem()?.to_str()?;
|
||||
let trimmed = stem.trim();
|
||||
|
||||
// Pattern: starts with digits
|
||||
if let Some(cap) = trimmed.strip_prefix(|c: char| c.is_ascii_digit()) {
|
||||
// Collect leading digits
|
||||
let digit_end = 1 + cap.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
let num_str = &trimmed[..digit_end];
|
||||
let rest = &trimmed[digit_end..];
|
||||
|
||||
// Check for disc-track pattern FIRST: "D-TT" like "1-01 Title", "2-05 Song"
|
||||
// Only when leading number is 1-2 digits (disc number) followed by dash+digits
|
||||
if digit_end <= 2 && rest.starts_with('-') {
|
||||
let after_dash = &rest[1..];
|
||||
let track_digits: String = after_dash.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if !track_digits.is_empty() {
|
||||
if let Ok(n) = track_digits.parse::<u32>() {
|
||||
if n > 0 && n < 10000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular pattern: digits followed by separator
|
||||
// "01 - Title", "01. Title", "01_Title", "01-Title"
|
||||
let rest_trimmed = rest.trim_start();
|
||||
let has_separator = rest_trimmed.starts_with('-')
|
||||
|| rest_trimmed.starts_with('.')
|
||||
|| rest_trimmed.starts_with('_')
|
||||
|| rest_trimmed.starts_with(' ')
|
||||
|| rest_trimmed.is_empty();
|
||||
|
||||
if has_separator {
|
||||
if let Ok(n) = num_str.parse::<u32>() {
|
||||
if n > 0 && n < 10000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern: "Track 01" or "Track01"
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("track") {
|
||||
let after = trimmed[5..].trim_start();
|
||||
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if !digits.is_empty() {
|
||||
if let Ok(n) = digits.parse::<u32>() {
|
||||
if n > 0 && n < 10000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn infer_disc_number(file_path: &Path) -> Option<u32> {
|
||||
let parent_dir = file_path.parent()?;
|
||||
let parent_name = parent_dir.file_name()?.to_str()?;
|
||||
@@ -185,17 +269,49 @@ impl MetadataExtractor {
|
||||
Self::disc_number_from_name(parent_name)
|
||||
}
|
||||
|
||||
fn album_root_dir(file_path: &Path) -> Option<PathBuf> {
|
||||
let parent_dir = file_path.parent()?;
|
||||
let parent_name = parent_dir.file_name().and_then(|s| s.to_str());
|
||||
/// Returns true if the folder name looks like an audio encoding/quality
|
||||
/// directory (e.g., "FLAC 24-bit - 96 kHz", "MP3 320 kbps").
|
||||
fn is_encoding_folder(name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
let first_word = lower
|
||||
.split(|c: char| c.is_whitespace() || c == '-' || c == '_')
|
||||
.find(|tok| !tok.is_empty());
|
||||
|
||||
if let Some(name) = parent_name {
|
||||
if Self::is_disc_folder(name) {
|
||||
return parent_dir.parent().map(|p| p.to_path_buf());
|
||||
if let Some(word) = first_word {
|
||||
if matches!(
|
||||
word,
|
||||
"flac" | "mp3" | "aac" | "alac" | "wav" | "aiff" | "ogg"
|
||||
| "dsd" | "opus" | "wma" | "ape" | "pcm"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Some(parent_dir.to_path_buf())
|
||||
// Standalone bitrate patterns like "320kbps"
|
||||
if lower.contains("kbps") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn album_root_dir(file_path: &Path) -> Option<PathBuf> {
|
||||
let mut dir = file_path.parent()?.to_path_buf();
|
||||
|
||||
// Skip past disc and encoding subdirectories to find the actual album root.
|
||||
// Handles: album/track, album/disc1/track, album/FLAC 24-96/track,
|
||||
// album/FLAC 24-96/disc1/track
|
||||
for _ in 0..2 {
|
||||
let name = dir.file_name().and_then(|s| s.to_str());
|
||||
match name {
|
||||
Some(n) if Self::is_disc_folder(n) || Self::is_encoding_folder(n) => {
|
||||
dir = dir.parent()?.to_path_buf();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
fn infer_artist_album(file_path: &Path) -> (Option<String>, Option<String>) {
|
||||
@@ -319,7 +435,8 @@ impl MetadataExtractor {
|
||||
album_artist: tag.get_string(&ItemKey::AlbumArtist).map(|s| s.to_string()),
|
||||
album_group_key,
|
||||
album_group_title,
|
||||
track_number: tag.track().map(|t| t as u32),
|
||||
track_number: tag.track().map(|t| t as u32)
|
||||
.or_else(|| Self::infer_track_number_from_filename(file_path)),
|
||||
disc_number: tag
|
||||
.disk()
|
||||
.and_then(|d| if d > 0 { Some(d as u32) } else { None })
|
||||
@@ -363,7 +480,7 @@ impl MetadataExtractor {
|
||||
album_artist: None,
|
||||
album_group_key,
|
||||
album_group_title,
|
||||
track_number: None,
|
||||
track_number: Self::infer_track_number_from_filename(file_path),
|
||||
disc_number: inferred_disc,
|
||||
year: None,
|
||||
genre: None,
|
||||
@@ -631,4 +748,154 @@ mod tests {
|
||||
AudioFormat::Mp3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_encoding_folder() {
|
||||
// QBZ-generated quality folder names
|
||||
assert!(MetadataExtractor::is_encoding_folder("FLAC 16-bit - 44.1 kHz"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("FLAC 24-bit - 96 kHz"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("FLAC 24-bit - 192 kHz"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("MP3 320 kbps"));
|
||||
|
||||
// Common encoding folder names from other tools
|
||||
assert!(MetadataExtractor::is_encoding_folder("FLAC"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("flac"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("MP3"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("WAV"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("ALAC"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("DSD"));
|
||||
assert!(MetadataExtractor::is_encoding_folder("320kbps"));
|
||||
|
||||
// Not encoding folders
|
||||
assert!(!MetadataExtractor::is_encoding_folder("Abbey Road"));
|
||||
assert!(!MetadataExtractor::is_encoding_folder("Disc 1"));
|
||||
assert!(!MetadataExtractor::is_encoding_folder("The Beatles"));
|
||||
assert!(!MetadataExtractor::is_encoding_folder("2024"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_root_dir_plain() {
|
||||
// artist/album/track.flac -> album/
|
||||
let path = Path::new("/music/EELS/Beautiful Freak/01 - Novocaine.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_root_dir_disc_folder() {
|
||||
// artist/album/disc1/track.flac -> album/
|
||||
let path = Path::new("/music/EELS/Beautiful Freak/Disc 1/01 - Novocaine.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_root_dir_encoding_folder() {
|
||||
// artist/album/quality/track.flac -> album/
|
||||
let path = Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/01 - Novocaine.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_root_dir_encoding_and_disc() {
|
||||
// artist/album/quality/disc1/track.flac -> album/
|
||||
let path = Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/Disc 1/01 - Novocaine.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_track_number_from_filename() {
|
||||
// Common patterns: "01 - Title"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/01 - Novocaine.flac")),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/12 - Beautiful Freak.flac")),
|
||||
Some(12)
|
||||
);
|
||||
// "01. Title"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/03. Song Name.flac")),
|
||||
Some(3)
|
||||
);
|
||||
// "01_Title"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/05_Track.flac")),
|
||||
Some(5)
|
||||
);
|
||||
// Just number
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/07.flac")),
|
||||
Some(7)
|
||||
);
|
||||
// "Track 01"
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/Track 09.flac")),
|
||||
Some(9)
|
||||
);
|
||||
// "1-01 Title" (disc-track)
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/2-05 Song.flac")),
|
||||
Some(5)
|
||||
);
|
||||
// Not a track number (title starting with non-track digits)
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/Novocaine.flac")),
|
||||
None
|
||||
);
|
||||
// Zero is not a valid track number
|
||||
assert_eq!(
|
||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/00 - Intro.flac")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disc_folder_true() {
|
||||
// Pure disc folders
|
||||
assert!(MetadataExtractor::is_disc_folder("Disc 1"));
|
||||
assert!(MetadataExtractor::is_disc_folder("disc 2"));
|
||||
assert!(MetadataExtractor::is_disc_folder("Disc1"));
|
||||
assert!(MetadataExtractor::is_disc_folder("disc01"));
|
||||
assert!(MetadataExtractor::is_disc_folder("CD 1"));
|
||||
assert!(MetadataExtractor::is_disc_folder("CD1"));
|
||||
assert!(MetadataExtractor::is_disc_folder("cd2"));
|
||||
assert!(MetadataExtractor::is_disc_folder("Disk 3"));
|
||||
assert!(MetadataExtractor::is_disc_folder("Bonus Disc"));
|
||||
assert!(MetadataExtractor::is_disc_folder("Bonus Disc 1"));
|
||||
assert!(MetadataExtractor::is_disc_folder("Extra CD 2"));
|
||||
assert!(MetadataExtractor::is_disc_folder("Side Disc 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disc_folder_false_album_names() {
|
||||
// Album names containing disc/cd keywords — NOT disc folders (issue #147)
|
||||
assert!(!MetadataExtractor::is_disc_folder("100 Popular Classics, Disc 1"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("100 Popular Classics_ Best Loved Works of the Great Composers, Disc 1"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("Relaxation Disc1"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("Now 75 - CD1"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("Match of the Day - The Album CD1"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("20 Blues Greats"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("The Beatles"));
|
||||
assert!(!MetadataExtractor::is_disc_folder("Abbey Road"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_root_dir_album_with_disc_in_name() {
|
||||
// Issue #147: album names containing "Disc N" should NOT be treated as disc folders
|
||||
let path = Path::new("/music/Various Artists/100 Popular Classics, Disc 1/01 - Track.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/Various Artists/100 Popular Classics, Disc 1"));
|
||||
|
||||
let path = Path::new("/music/Various Artists/Relaxation Disc1/01 - Track.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/Various Artists/Relaxation Disc1"));
|
||||
|
||||
let path = Path::new("/music/Various Artists/Now 75 - CD1/01 - Track.flac");
|
||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||
assert_eq!(root, Path::new("/music/Various Artists/Now 75 - CD1"));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ pub struct QueueTrack {
|
||||
/// Source identifier (e.g., "qobuz", "local", "plex")
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
/// Parental advisory / explicit content
|
||||
#[serde(default)]
|
||||
pub parental_warning: bool,
|
||||
}
|
||||
|
||||
fn default_streamable() -> bool {
|
||||
|
||||
@@ -54,6 +54,17 @@ impl Quality {
|
||||
Quality::Mp3,
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the next lower quality level, or None if already at the lowest (Mp3).
|
||||
/// Used for CDN fallback when a quality level consistently fails.
|
||||
pub fn lower(&self) -> Option<Quality> {
|
||||
match self {
|
||||
Quality::UltraHiRes => Some(Quality::HiRes),
|
||||
Quality::HiRes => Some(Quality::Lossless),
|
||||
Quality::Lossless => Some(Quality::Mp3),
|
||||
Quality::Mp3 => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Quality {
|
||||
@@ -207,6 +218,28 @@ pub struct Album {
|
||||
pub upc: Option<String>,
|
||||
/// Editorial description/review of the album
|
||||
pub description: Option<String>,
|
||||
/// Album goodies (booklets, liner notes PDFs)
|
||||
#[serde(default)]
|
||||
pub goodies: Option<Vec<Goody>>,
|
||||
}
|
||||
|
||||
/// A downloadable extra bundled with an album (e.g. PDF booklet)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Goody {
|
||||
#[serde(default)]
|
||||
pub id: u64,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
/// Original (full-size) URL
|
||||
#[serde(default)]
|
||||
pub original_url: String,
|
||||
/// File format id (e.g. 21 for PDF)
|
||||
#[serde(default)]
|
||||
pub file_format_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -31,8 +31,8 @@ rand = "0.8"
|
||||
# Audio decoding
|
||||
symphonia = { workspace = true }
|
||||
|
||||
# Vendored audio libraries (same as qbz-audio)
|
||||
rodio = { path = "../../src-tauri/vendor/rodio", features = ["symphonia-all"] }
|
||||
# Audio playback
|
||||
rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||
|
||||
# HTTP client for streaming
|
||||
reqwest = { workspace = true }
|
||||
@@ -22,11 +22,10 @@ use std::sync::mpsc::{self, Sender, SyncSender, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use rodio::{Decoder, OutputStream, Source};
|
||||
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Source};
|
||||
use rodio::buffer::SamplesBuffer;
|
||||
use rodio::decoder::Mp4Type;
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use rodio::cpal::{StreamConfig, SampleRate, BufferSize, SupportedStreamConfig, SupportedBufferSize, SampleFormat};
|
||||
use rodio::cpal::{StreamConfig, BufferSize, SupportedStreamConfig, SupportedBufferSize, SampleFormat};
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
@@ -131,11 +130,18 @@ impl Seek for CursorMediaSource {
|
||||
/// Audio specifications extracted from decoded audio
|
||||
#[allow(dead_code)]
|
||||
struct AudioSpecs {
|
||||
samples: SamplesBuffer<f32>,
|
||||
samples: SamplesBuffer,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
}
|
||||
|
||||
fn cpal_device_name(device: &rodio::cpal::Device) -> Option<String> {
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|description| description.name().to_string())
|
||||
}
|
||||
|
||||
fn decode_with_symphonia(data: &[u8]) -> Result<AudioSpecs, String> {
|
||||
let source = Box::new(CursorMediaSource::new(data.to_vec())) as Box<dyn MediaSource>;
|
||||
let mss = MediaSourceStream::new(source, Default::default());
|
||||
@@ -204,7 +210,11 @@ fn decode_with_symphonia(data: &[u8]) -> Result<AudioSpecs, String> {
|
||||
}
|
||||
|
||||
Ok(AudioSpecs {
|
||||
samples: SamplesBuffer::new(channels, sample_rate, samples),
|
||||
samples: SamplesBuffer::new(
|
||||
std::num::NonZero::new(channels).unwrap(),
|
||||
std::num::NonZero::new(sample_rate).unwrap(),
|
||||
samples,
|
||||
),
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
@@ -305,23 +315,22 @@ fn decode_with_fallback(
|
||||
}
|
||||
}
|
||||
|
||||
let mp4_attempts = [Mp4Type::M4a, Mp4Type::Mp4];
|
||||
for hint in mp4_attempts {
|
||||
let hint_label = format!("{:?}", hint);
|
||||
// Try mp4 fallback (rodio 0.22 removed Mp4Type hint)
|
||||
{
|
||||
let attempt = panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
Decoder::new_mp4(BufReader::new(Cursor::new(data.to_vec())), hint)
|
||||
Decoder::new_mp4(BufReader::new(Cursor::new(data.to_vec())))
|
||||
}));
|
||||
|
||||
match attempt {
|
||||
Ok(Ok(decoder)) => {
|
||||
log::info!("Decoded audio using mp4 fallback ({})", hint_label);
|
||||
log::info!("Decoded audio using mp4 fallback");
|
||||
return Ok(Box::new(decoder));
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
log::warn!("mp4 fallback ({}) failed: {}", hint_label, err);
|
||||
log::warn!("mp4 fallback failed: {}", err);
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("mp4 fallback ({}) panicked", hint_label);
|
||||
log::warn!("mp4 fallback panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,29 +344,27 @@ fn decode_with_fallback(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create OutputStream with custom sample rate configuration
|
||||
/// Create MixerDeviceSink with custom sample rate configuration
|
||||
fn create_output_stream_with_config(
|
||||
device: &rodio::cpal::Device,
|
||||
device: rodio::cpal::Device,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
exclusive_mode: bool,
|
||||
) -> Result<(OutputStream, rodio::OutputStreamHandle), String> {
|
||||
) -> Result<MixerDeviceSink, String> {
|
||||
log::info!(
|
||||
"Creating OutputStream: {}Hz, {} channels, exclusive: {}",
|
||||
"Creating MixerDeviceSink: {}Hz, {} channels, exclusive: {}",
|
||||
sample_rate,
|
||||
channels,
|
||||
exclusive_mode
|
||||
);
|
||||
|
||||
// Create StreamConfig with desired sample rate
|
||||
// Note: buffer_size here is unused — with_supported_config() resets it.
|
||||
// The actual buffer size is set via with_buffer_size() below.
|
||||
let config = StreamConfig {
|
||||
channels,
|
||||
sample_rate: SampleRate(sample_rate),
|
||||
buffer_size: if exclusive_mode {
|
||||
BufferSize::Fixed(512) // Lower latency for exclusive mode
|
||||
} else {
|
||||
BufferSize::Default
|
||||
},
|
||||
sample_rate,
|
||||
buffer_size: BufferSize::Default,
|
||||
};
|
||||
|
||||
// Check if device supports this configuration
|
||||
@@ -368,15 +375,15 @@ fn create_output_stream_with_config(
|
||||
let mut found_matching = false;
|
||||
for range in supported_configs {
|
||||
if range.channels() == channels
|
||||
&& sample_rate >= range.min_sample_rate().0
|
||||
&& sample_rate <= range.max_sample_rate().0
|
||||
&& sample_rate >= range.min_sample_rate()
|
||||
&& sample_rate <= range.max_sample_rate()
|
||||
{
|
||||
found_matching = true;
|
||||
log::info!(
|
||||
"Device supports {}Hz (range: {}-{}Hz)",
|
||||
sample_rate,
|
||||
range.min_sample_rate().0,
|
||||
range.max_sample_rate().0
|
||||
range.min_sample_rate(),
|
||||
range.max_sample_rate()
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -397,14 +404,37 @@ fn create_output_stream_with_config(
|
||||
SampleFormat::F32,
|
||||
);
|
||||
|
||||
// Create OutputStream with custom config
|
||||
match OutputStream::try_from_device_config(device, supported_config) {
|
||||
Ok((stream, handle)) => {
|
||||
log::info!("OutputStream created successfully at {}Hz", sample_rate);
|
||||
Ok((stream, handle))
|
||||
// Compute buffer size — must be applied AFTER with_supported_config()
|
||||
// because that method resets buffer_size to Default via ..Default::default().
|
||||
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
|
||||
// ONLY buffer between the mixer and audio hardware.
|
||||
let cpal_buffer_size = if exclusive_mode {
|
||||
BufferSize::Fixed(512) // Low latency for exclusive mode
|
||||
} else {
|
||||
// ~100ms buffer, matching old vendored cpal period size.
|
||||
// Prevents underruns at high sample rates (192kHz = 19200 frames).
|
||||
BufferSize::Fixed(sample_rate / 10)
|
||||
};
|
||||
log::info!("Buffer size: {:?}", cpal_buffer_size);
|
||||
|
||||
// Create MixerDeviceSink with custom config
|
||||
match DeviceSinkBuilder::from_device(device) {
|
||||
Ok(builder) => {
|
||||
match builder.with_supported_config(&supported_config)
|
||||
.with_buffer_size(cpal_buffer_size)
|
||||
.open_stream() {
|
||||
Ok(mixer_sink) => {
|
||||
log::info!("MixerDeviceSink created successfully at {}Hz", sample_rate);
|
||||
Ok(mixer_sink)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to open stream at {}Hz: {}", sample_rate, e);
|
||||
Err(format!("Failed to create output stream: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("❌ Failed to create OutputStream at {}Hz: {}", sample_rate, e);
|
||||
log::error!("Failed to create device sink builder: {}", e);
|
||||
Err(format!("Failed to create output stream: {}", e))
|
||||
}
|
||||
}
|
||||
@@ -412,7 +442,7 @@ fn create_output_stream_with_config(
|
||||
|
||||
/// Output stream type - either rodio or ALSA Direct
|
||||
enum StreamType {
|
||||
Rodio(OutputStream, rodio::OutputStreamHandle),
|
||||
Rodio(MixerDeviceSink),
|
||||
#[cfg(target_os = "linux")]
|
||||
AlsaDirect(Arc<qbz_audio::AlsaDirectStream>),
|
||||
}
|
||||
@@ -486,12 +516,12 @@ fn try_init_stream_with_backend(
|
||||
|
||||
// Fallback to regular rodio stream (PipeWire, Pulse, ALSA via CPAL)
|
||||
match backend.create_output_stream(&config) {
|
||||
Ok(stream) => {
|
||||
Ok(mixer_sink) => {
|
||||
log::info!("Stream created via {:?} backend at {}Hz", backend_type, sample_rate);
|
||||
Some(Ok(StreamType::Rodio(stream.0, stream.1)))
|
||||
Some(Ok(StreamType::Rodio(mixer_sink)))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("❌ Backend stream creation failed: {}", e);
|
||||
log::error!("Backend stream creation failed: {}", e);
|
||||
Some(Err(e))
|
||||
}
|
||||
}
|
||||
@@ -844,7 +874,7 @@ impl Player {
|
||||
let found = host.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut devices| {
|
||||
devices.find(|d| d.name().ok().as_ref() == Some(name))
|
||||
devices.find(|d| cpal_device_name(d).as_deref() == Some(name.as_str()))
|
||||
});
|
||||
|
||||
match found {
|
||||
@@ -868,7 +898,7 @@ impl Player {
|
||||
|
||||
let device = match device {
|
||||
Some(d) => {
|
||||
if let Ok(name) = d.name() {
|
||||
if let Some(name) = cpal_device_name(&d) {
|
||||
log::info!("Using audio device: {}", name);
|
||||
state.set_current_device(Some(name));
|
||||
}
|
||||
@@ -881,17 +911,19 @@ impl Player {
|
||||
}
|
||||
};
|
||||
|
||||
match OutputStream::try_from_device(&device) {
|
||||
Ok((stream, handle)) => {
|
||||
match DeviceSinkBuilder::from_device(device)
|
||||
.and_then(|b| b.open_sink_or_fallback())
|
||||
{
|
||||
Ok(mixer_sink) => {
|
||||
log::info!("Audio output initialized successfully");
|
||||
Some(StreamType::Rodio(stream, handle))
|
||||
Some(StreamType::Rodio(mixer_sink))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create audio output on device: {}. Trying default...", e);
|
||||
match OutputStream::try_default() {
|
||||
Ok((stream, handle)) => {
|
||||
match DeviceSinkBuilder::open_default_sink() {
|
||||
Ok(mixer_sink) => {
|
||||
log::info!("Fallback to default audio output succeeded");
|
||||
Some(StreamType::Rodio(stream, handle))
|
||||
Some(StreamType::Rodio(mixer_sink))
|
||||
}
|
||||
Err(e2) => {
|
||||
log::error!("Failed to create default audio output: {}", e2);
|
||||
@@ -996,7 +1028,7 @@ impl Player {
|
||||
if (dac_passthrough || using_alsa_direct) && format_changed {
|
||||
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
|
||||
log::info!(
|
||||
"Sample rate/channels changed from {:?}Hz/{:?}ch to {}Hz/{}ch - recreating OutputStream ({})",
|
||||
"Sample rate/channels changed from {:?}Hz/{:?}ch to {}Hz/{}ch - recreating audio stream ({})",
|
||||
*current_sample_rate,
|
||||
*current_channels,
|
||||
sample_rate,
|
||||
@@ -1004,7 +1036,7 @@ impl Player {
|
||||
mode
|
||||
);
|
||||
} else {
|
||||
log::info!("Creating initial OutputStream");
|
||||
log::info!("Creating initial audio stream");
|
||||
}
|
||||
// Drop old stream
|
||||
drop(stream_opt.take());
|
||||
@@ -1036,7 +1068,7 @@ impl Player {
|
||||
let found = host.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut devices| {
|
||||
devices.find(|d| d.name().ok().as_ref() == Some(name))
|
||||
devices.find(|d| cpal_device_name(d).as_deref() == Some(name.as_str()))
|
||||
});
|
||||
|
||||
match found {
|
||||
@@ -1066,17 +1098,17 @@ impl Player {
|
||||
};
|
||||
|
||||
// Set current device name
|
||||
if let Ok(name) = device.name() {
|
||||
if let Some(name) = cpal_device_name(&device) {
|
||||
log::info!("Using audio device: {}", name);
|
||||
thread_state.set_current_device(Some(name));
|
||||
}
|
||||
|
||||
create_output_stream_with_config(
|
||||
&device,
|
||||
device,
|
||||
sample_rate,
|
||||
channels,
|
||||
dac_passthrough,
|
||||
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
).map(StreamType::Rodio)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1086,7 +1118,7 @@ impl Player {
|
||||
host.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut devices| {
|
||||
devices.find(|d| d.name().ok().as_ref() == Some(name))
|
||||
devices.find(|d| cpal_device_name(d).as_deref() == Some(name.as_str()))
|
||||
})
|
||||
.or_else(|| {
|
||||
log::warn!("Device '{}' not found, using default", name);
|
||||
@@ -1103,16 +1135,16 @@ impl Player {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(name) = device.name() {
|
||||
if let Some(name) = cpal_device_name(&device) {
|
||||
thread_state.set_current_device(Some(name));
|
||||
}
|
||||
|
||||
create_output_stream_with_config(
|
||||
&device,
|
||||
device,
|
||||
sample_rate,
|
||||
channels,
|
||||
dac_passthrough,
|
||||
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
).map(StreamType::Rodio)
|
||||
};
|
||||
|
||||
// Handle stream creation result
|
||||
@@ -1151,7 +1183,7 @@ impl Player {
|
||||
} else if format_changed {
|
||||
// Format changed but DAC passthrough is disabled - reuse existing stream
|
||||
log::info!(
|
||||
"Audio format changed from {:?}Hz/{:?}ch to {}Hz/{}ch - reusing OutputStream (DAC passthrough disabled, gapless enabled)",
|
||||
"Audio format changed from {:?}Hz/{:?}ch to {}Hz/{}ch - reusing audio stream (DAC passthrough disabled, gapless enabled)",
|
||||
*current_sample_rate,
|
||||
*current_channels,
|
||||
sample_rate,
|
||||
@@ -1168,7 +1200,7 @@ impl Player {
|
||||
if let Some(engine) = current_engine.take() {
|
||||
engine.stop();
|
||||
// Small delay to allow the audio sink to fully release its
|
||||
// reference to the OutputStreamHandle before creating a new sink.
|
||||
// reference to the mixer before creating a new player.
|
||||
// This prevents "resource busy" errors on rapid track switches.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
@@ -1178,8 +1210,8 @@ impl Player {
|
||||
|
||||
// Create PlaybackEngine from StreamType
|
||||
let mut engine = match stream {
|
||||
StreamType::Rodio(_stream, handle) => {
|
||||
match PlaybackEngine::new_rodio(handle) {
|
||||
StreamType::Rodio(ref mixer_sink) => {
|
||||
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
|
||||
Ok(e) => {
|
||||
*consecutive_sink_failures = 0;
|
||||
thread_state.set_stream_error(false);
|
||||
@@ -1353,7 +1385,7 @@ impl Player {
|
||||
if (dac_passthrough || using_alsa_direct) && format_changed {
|
||||
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
|
||||
log::info!(
|
||||
"Streaming: Sample rate/channels changed to {}Hz/{}ch - recreating OutputStream ({})",
|
||||
"Streaming: Sample rate/channels changed to {}Hz/{}ch - recreating audio stream ({})",
|
||||
sample_rate,
|
||||
channels,
|
||||
mode
|
||||
@@ -1380,7 +1412,7 @@ impl Player {
|
||||
host.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut devices| {
|
||||
devices.find(|d| d.name().ok().as_ref() == Some(name))
|
||||
devices.find(|d| cpal_device_name(d).as_deref() == Some(name.as_str()))
|
||||
})
|
||||
.or_else(|| host.default_output_device())
|
||||
} else {
|
||||
@@ -1393,16 +1425,16 @@ impl Player {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(name) = device.name() {
|
||||
if let Some(name) = cpal_device_name(&device) {
|
||||
thread_state.set_current_device(Some(name));
|
||||
}
|
||||
|
||||
create_output_stream_with_config(
|
||||
&device,
|
||||
device,
|
||||
sample_rate,
|
||||
channels,
|
||||
dac_passthrough,
|
||||
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
).map(StreamType::Rodio)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1412,8 +1444,8 @@ impl Player {
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
};
|
||||
create_output_stream_with_config(&device, sample_rate, channels, dac_passthrough)
|
||||
.map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
create_output_stream_with_config(device, sample_rate, channels, dac_passthrough)
|
||||
.map(StreamType::Rodio)
|
||||
};
|
||||
|
||||
match stream_result {
|
||||
@@ -1446,8 +1478,8 @@ impl Player {
|
||||
|
||||
// Create PlaybackEngine
|
||||
let mut engine = match stream {
|
||||
StreamType::Rodio(_stream, handle) => {
|
||||
match PlaybackEngine::new_rodio(handle) {
|
||||
StreamType::Rodio(ref mixer_sink) => {
|
||||
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
|
||||
Ok(e) => {
|
||||
*consecutive_sink_failures = 0;
|
||||
thread_state.set_stream_error(false);
|
||||
@@ -1639,8 +1671,8 @@ impl Player {
|
||||
};
|
||||
|
||||
let mut engine = match stream {
|
||||
StreamType::Rodio(_stream, handle) => {
|
||||
match PlaybackEngine::new_rodio(handle) {
|
||||
StreamType::Rodio(ref mixer_sink) => {
|
||||
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create engine for resume: {}", e);
|
||||
@@ -1760,8 +1792,8 @@ impl Player {
|
||||
}
|
||||
|
||||
let mut engine = match stream {
|
||||
StreamType::Rodio(_stream, handle) => {
|
||||
match PlaybackEngine::new_rodio(handle) {
|
||||
StreamType::Rodio(ref mixer_sink) => {
|
||||
match PlaybackEngine::new_rodio(&mixer_sink.mixer()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create rodio engine for seek: {}", e);
|
||||
@@ -1871,13 +1903,6 @@ impl Player {
|
||||
}
|
||||
};
|
||||
|
||||
// Only Rodio supports sink queueing
|
||||
if engine.is_alsa_direct() {
|
||||
log::info!("Gapless: ALSA Direct backend, cannot queue — ignoring PlayNext for track {}", track_id);
|
||||
thread_state.set_gapless_ready(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify format compatibility (same sample rate and channels)
|
||||
if let (Some(cur_sr), Some(cur_ch)) = (*current_sample_rate, *current_channels) {
|
||||
if sample_rate != cur_sr || channels != cur_ch {
|
||||
@@ -2038,6 +2063,29 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
// ALSA Direct gapless: the writer thread signals transitions
|
||||
// via an atomic flag instead of position-based detection
|
||||
if let Some(ref engine) = current_engine {
|
||||
if engine.take_source_transition() {
|
||||
if let Some(ref pending) = gapless_pending {
|
||||
log::info!(
|
||||
"ALSA Direct gapless transition: track {} -> {}",
|
||||
thread_state.current_track_id.load(Ordering::SeqCst),
|
||||
pending.track_id
|
||||
);
|
||||
thread_state.current_track_id.store(pending.track_id, Ordering::SeqCst);
|
||||
thread_state.duration.store(pending.duration_secs, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
current_audio_data = Some(pending.data.clone());
|
||||
current_normalization_gain = pending.normalization_gain;
|
||||
thread_state.set_normalization_gain(pending.normalization_gain);
|
||||
thread_state.set_gapless_next_track_id(0);
|
||||
gapless_pending = None;
|
||||
gapless_request_armed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gapless readiness: signal frontend when ~5s remain
|
||||
// Only when: playing, not streaming, no gapless already pending, gapless enabled
|
||||
let gapless_enabled = thread_settings
|
||||
|
||||
@@ -4,56 +4,139 @@
|
||||
//! - Rodio (PipeWire, Pulse, ALSA via CPAL) - uses rodio::Sink
|
||||
//! - ALSA Direct (hw: devices) - bypasses rodio, writes directly to ALSA PCM
|
||||
//!
|
||||
//! This abstraction allows the player to work with both approaches transparently.
|
||||
//! ALSA Direct uses a single long-lived writer thread with a source queue
|
||||
//! to enable gapless playback. When one source ends, the next is picked up
|
||||
//! seamlessly without interrupting the PCM stream.
|
||||
|
||||
use qbz_audio::AlsaDirectStream;
|
||||
use rodio::{OutputStreamHandle, Sink, Source};
|
||||
use rodio::{mixer::Mixer, Player as RodioPlayer, Source};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A boxed sample iterator that can be sent across threads
|
||||
type BoxedSampleIter = Box<dyn Iterator<Item = f32> + Send>;
|
||||
|
||||
/// Thread-safe source queue for gapless playback.
|
||||
/// The writer thread consumes sources; append() pushes new ones.
|
||||
pub(crate) struct SourceQueue {
|
||||
queue: Mutex<VecDeque<BoxedSampleIter>>,
|
||||
/// Notifies the writer thread that a new source is available
|
||||
notify: Condvar,
|
||||
}
|
||||
|
||||
impl SourceQueue {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
queue: Mutex::new(VecDeque::new()),
|
||||
notify: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new source to the back of the queue
|
||||
fn push(&self, source: BoxedSampleIter) {
|
||||
let mut q = self.queue.lock().unwrap();
|
||||
q.push_back(source);
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
/// Try to pop the next source (non-blocking)
|
||||
fn try_pop(&self) -> Option<BoxedSampleIter> {
|
||||
let mut q = self.queue.lock().unwrap();
|
||||
q.pop_front()
|
||||
}
|
||||
|
||||
/// Wait for a source to become available (with timeout)
|
||||
/// Returns None on timeout (used to check stop/pause flags)
|
||||
fn wait_for_source(&self, timeout: Duration) -> Option<BoxedSampleIter> {
|
||||
let mut q = self.queue.lock().unwrap();
|
||||
if q.is_empty() {
|
||||
let (guard, _) = self.notify.wait_timeout(q, timeout).unwrap();
|
||||
q = guard;
|
||||
}
|
||||
q.pop_front()
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.queue.lock().unwrap().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified playback engine
|
||||
pub enum PlaybackEngine {
|
||||
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
|
||||
Rodio {
|
||||
sink: Sink,
|
||||
sink: RodioPlayer,
|
||||
},
|
||||
/// Direct ALSA (hw: devices, bit-perfect)
|
||||
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
|
||||
AlsaDirect {
|
||||
stream: Arc<AlsaDirectStream>,
|
||||
is_playing: Arc<AtomicBool>,
|
||||
should_stop: Arc<AtomicBool>, // Separate flag for full stop vs pause
|
||||
should_stop: Arc<AtomicBool>,
|
||||
position_frames: Arc<AtomicU64>,
|
||||
duration_frames: Arc<AtomicU64>,
|
||||
source_queue: Arc<SourceQueue>,
|
||||
playback_thread: Option<thread::JoinHandle<()>>,
|
||||
hardware_volume: bool, // Use ALSA mixer for volume control
|
||||
/// Signals that the writer thread has consumed a source and moved to next
|
||||
source_transition: Arc<AtomicBool>,
|
||||
hardware_volume: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlaybackEngine {
|
||||
/// Create Rodio engine
|
||||
pub fn new_rodio(stream_handle: &OutputStreamHandle) -> Result<Self, String> {
|
||||
let sink = Sink::try_new(stream_handle)
|
||||
.map_err(|e| format!("Failed to create Sink: {}", e))?;
|
||||
|
||||
pub fn new_rodio(mixer: &Mixer) -> Result<Self, String> {
|
||||
let sink = RodioPlayer::connect_new(mixer);
|
||||
Ok(Self::Rodio { sink })
|
||||
}
|
||||
|
||||
/// Create ALSA Direct engine
|
||||
/// Create ALSA Direct engine with gapless source queue.
|
||||
/// Spawns a single writer thread that lives for the engine's lifetime.
|
||||
pub fn new_alsa_direct(stream: Arc<AlsaDirectStream>, hardware_volume: bool) -> Self {
|
||||
let is_playing = Arc::new(AtomicBool::new(false));
|
||||
let should_stop = Arc::new(AtomicBool::new(false));
|
||||
let position_frames = Arc::new(AtomicU64::new(0));
|
||||
let duration_frames = Arc::new(AtomicU64::new(0));
|
||||
let source_queue = Arc::new(SourceQueue::new());
|
||||
let source_transition = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Spawn the single long-lived writer thread
|
||||
let handle = {
|
||||
let stream_c = stream.clone();
|
||||
let playing_c = is_playing.clone();
|
||||
let stop_c = should_stop.clone();
|
||||
let pos_c = position_frames.clone();
|
||||
let dur_c = duration_frames.clone();
|
||||
let queue_c = source_queue.clone();
|
||||
let transition_c = source_transition.clone();
|
||||
let channels = stream.channels();
|
||||
|
||||
thread::spawn(move || {
|
||||
alsa_writer_thread(
|
||||
stream_c, playing_c, stop_c, pos_c, dur_c,
|
||||
queue_c, transition_c, channels,
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
Self::AlsaDirect {
|
||||
stream,
|
||||
is_playing: Arc::new(AtomicBool::new(false)),
|
||||
should_stop: Arc::new(AtomicBool::new(false)),
|
||||
position_frames: Arc::new(AtomicU64::new(0)),
|
||||
duration_frames: Arc::new(AtomicU64::new(0)),
|
||||
playback_thread: None,
|
||||
is_playing,
|
||||
should_stop,
|
||||
position_frames,
|
||||
duration_frames,
|
||||
source_queue,
|
||||
playback_thread: Some(handle),
|
||||
source_transition,
|
||||
hardware_volume,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append audio source
|
||||
/// Append audio source.
|
||||
/// For ALSA Direct: pushes to the source queue for gapless transition.
|
||||
/// For Rodio: delegates to Sink's built-in queue.
|
||||
pub fn append<S>(&mut self, source: S) -> Result<(), String>
|
||||
where
|
||||
S: Source<Item = f32> + Send + 'static,
|
||||
@@ -64,109 +147,31 @@ impl PlaybackEngine {
|
||||
Ok(())
|
||||
}
|
||||
Self::AlsaDirect {
|
||||
stream,
|
||||
is_playing,
|
||||
should_stop,
|
||||
position_frames,
|
||||
duration_frames,
|
||||
playback_thread,
|
||||
hardware_volume: _,
|
||||
source_queue,
|
||||
source_transition,
|
||||
..
|
||||
} => {
|
||||
// For ALSA Direct, we need to spawn a thread that:
|
||||
// 1. Streams f32 samples from source (no buffering entire file)
|
||||
// 2. Writes to ALSA PCM (converts f32 to hardware format)
|
||||
// 3. Tracks position
|
||||
// 4. Supports pause/resume without terminating
|
||||
let is_first = source_queue.is_empty()
|
||||
&& !is_playing.load(Ordering::SeqCst);
|
||||
|
||||
let stream_clone = stream.clone();
|
||||
let is_playing_clone = is_playing.clone();
|
||||
let should_stop_clone = should_stop.clone();
|
||||
let position_clone = position_frames.clone();
|
||||
let duration_clone = duration_frames.clone();
|
||||
// Box the source iterator and push to queue
|
||||
let boxed: BoxedSampleIter = Box::new(source.into_iter());
|
||||
source_queue.push(boxed);
|
||||
|
||||
let channels = stream.channels();
|
||||
if is_first {
|
||||
// First source: reset position, clear stop, start playing
|
||||
position_frames.store(0, Ordering::SeqCst);
|
||||
should_stop.store(false, Ordering::SeqCst);
|
||||
source_transition.store(false, Ordering::SeqCst);
|
||||
is_playing.store(true, Ordering::SeqCst);
|
||||
log::info!("[ALSA Direct Engine] First source queued, playback starting");
|
||||
} else {
|
||||
log::info!("[ALSA Direct Engine] Source queued for gapless transition");
|
||||
}
|
||||
|
||||
is_playing.store(true, Ordering::SeqCst);
|
||||
should_stop.store(false, Ordering::SeqCst);
|
||||
position_clone.store(0, Ordering::SeqCst);
|
||||
|
||||
log::info!("[ALSA Direct Engine] Starting streaming playback thread");
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
// Stream samples in chunks (no pre-buffering entire file)
|
||||
const CHUNK_SIZE: usize = 8192; // frames per chunk
|
||||
let chunk_samples = CHUNK_SIZE * channels as usize;
|
||||
|
||||
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
|
||||
|
||||
let mut total_frames: u64 = 0;
|
||||
let mut source_iter = source.into_iter();
|
||||
let mut natural_end = false;
|
||||
|
||||
'playback: loop {
|
||||
// Check if we should stop completely (not just pause)
|
||||
if should_stop_clone.load(Ordering::SeqCst) {
|
||||
log::info!("[ALSA Direct Engine] Stop requested, terminating thread");
|
||||
break 'playback;
|
||||
}
|
||||
|
||||
// Check if paused - wait instead of terminating
|
||||
while !is_playing_clone.load(Ordering::SeqCst) {
|
||||
// Still check for stop while paused
|
||||
if should_stop_clone.load(Ordering::SeqCst) {
|
||||
log::info!("[ALSA Direct Engine] Stop requested while paused");
|
||||
break 'playback;
|
||||
}
|
||||
// Sleep briefly to avoid busy-waiting
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// Fill buffer from source
|
||||
buffer_f32.clear();
|
||||
for _ in 0..chunk_samples {
|
||||
match source_iter.next() {
|
||||
Some(sample) => buffer_f32.push(sample),
|
||||
None => break, // End of stream
|
||||
}
|
||||
}
|
||||
|
||||
if buffer_f32.is_empty() {
|
||||
// End of stream
|
||||
log::info!("[ALSA Direct Engine] Stream ended (total frames: {})", total_frames);
|
||||
natural_end = true;
|
||||
break 'playback;
|
||||
}
|
||||
|
||||
// Write to ALSA (auto-converts based on detected format)
|
||||
// This is bit-perfect: no resampling, no mixing, direct to hardware
|
||||
if let Err(e) = stream_clone.write_f32(&buffer_f32) {
|
||||
log::error!("[ALSA Direct Engine] Write failed: {}", e);
|
||||
break 'playback;
|
||||
}
|
||||
|
||||
// Update position
|
||||
let frames_written = buffer_f32.len() / channels as usize;
|
||||
total_frames += frames_written as u64;
|
||||
position_clone.store(total_frames, Ordering::SeqCst);
|
||||
duration_clone.store(total_frames, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// Only drain if song ended naturally (not skipped/stopped)
|
||||
// This prevents 2-5s delay when rapidly changing tracks
|
||||
if natural_end {
|
||||
log::info!("[ALSA Direct Engine] Song ended naturally, draining buffer");
|
||||
if let Err(e) = stream_clone.drain() {
|
||||
log::warn!("[ALSA Direct Engine] Drain failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
log::info!("[ALSA Direct Engine] Playback interrupted, skipping drain for faster response");
|
||||
}
|
||||
|
||||
is_playing_clone.store(false, Ordering::SeqCst);
|
||||
log::info!("[ALSA Direct Engine] Playback thread finished");
|
||||
});
|
||||
|
||||
*playback_thread = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -194,8 +199,15 @@ impl PlaybackEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop
|
||||
pub fn stop(self) {
|
||||
/// Stop playback and release resources.
|
||||
/// For ALSA Direct, signals the writer thread and waits for it to exit.
|
||||
/// The Drop impl handles the same cleanup if stop() is not called explicitly.
|
||||
pub fn stop(mut self) {
|
||||
self.stop_inner();
|
||||
}
|
||||
|
||||
/// Internal stop logic shared by stop() and Drop
|
||||
fn stop_inner(&mut self) {
|
||||
match self {
|
||||
Self::Rodio { sink } => {
|
||||
sink.stop();
|
||||
@@ -207,17 +219,17 @@ impl PlaybackEngine {
|
||||
playback_thread,
|
||||
..
|
||||
} => {
|
||||
if should_stop.load(Ordering::SeqCst) {
|
||||
return; // Already stopped
|
||||
}
|
||||
log::info!("[ALSA Direct Engine] Stop requested");
|
||||
// Signal thread to stop completely
|
||||
should_stop.store(true, Ordering::SeqCst);
|
||||
is_playing.store(false, Ordering::SeqCst);
|
||||
|
||||
// Wait for playback thread to finish
|
||||
if let Some(handle) = playback_thread {
|
||||
if let Some(handle) = playback_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
|
||||
// Stop PCM
|
||||
if let Err(e) = stream.stop() {
|
||||
log::warn!("[ALSA Direct Engine] Stop failed: {}", e);
|
||||
}
|
||||
@@ -231,39 +243,42 @@ impl PlaybackEngine {
|
||||
Self::Rodio { sink } => sink.set_volume(volume),
|
||||
Self::AlsaDirect { stream, hardware_volume, .. } => {
|
||||
if *hardware_volume {
|
||||
// Try hardware mixer control
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Err(e) = stream.set_hardware_volume(volume) {
|
||||
log::warn!("[ALSA Direct Engine] Hardware volume failed: {}. Volume slider may not work.", e);
|
||||
log::warn!("[ALSA Direct Engine] Hardware volume failed: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hardware volume disabled - volume control is handled by DAC/amplifier
|
||||
log::debug!("[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if playback queue is empty
|
||||
/// Check if playback queue is empty (all sources consumed, not playing)
|
||||
pub fn empty(&self) -> bool {
|
||||
match self {
|
||||
Self::Rodio { sink } => sink.empty(),
|
||||
Self::AlsaDirect {
|
||||
is_playing,
|
||||
position_frames,
|
||||
duration_frames,
|
||||
source_queue,
|
||||
..
|
||||
} => {
|
||||
if !is_playing.load(Ordering::SeqCst) {
|
||||
let pos = position_frames.load(Ordering::SeqCst);
|
||||
let dur = duration_frames.load(Ordering::SeqCst);
|
||||
// Consider empty if stopped and reached the end
|
||||
pos >= dur && dur > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
!is_playing.load(Ordering::SeqCst) && source_queue.is_empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a gapless source transition just happened.
|
||||
/// Returns true once, then resets the flag.
|
||||
pub fn take_source_transition(&self) -> bool {
|
||||
match self {
|
||||
Self::Rodio { .. } => false,
|
||||
Self::AlsaDirect { source_transition, .. } => {
|
||||
source_transition.compare_exchange(
|
||||
true, false, Ordering::SeqCst, Ordering::SeqCst
|
||||
).is_ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,12 +287,8 @@ impl PlaybackEngine {
|
||||
#[allow(dead_code)]
|
||||
pub fn position_secs(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Rodio { .. } => None, // Rodio doesn't expose position directly
|
||||
Self::AlsaDirect {
|
||||
position_frames,
|
||||
stream,
|
||||
..
|
||||
} => {
|
||||
Self::Rodio { .. } => None,
|
||||
Self::AlsaDirect { position_frames, stream, .. } => {
|
||||
let frames = position_frames.load(Ordering::SeqCst);
|
||||
let sample_rate = stream.sample_rate() as u64;
|
||||
Some(frames / sample_rate)
|
||||
@@ -290,11 +301,7 @@ impl PlaybackEngine {
|
||||
pub fn duration_secs(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Rodio { .. } => None,
|
||||
Self::AlsaDirect {
|
||||
duration_frames,
|
||||
stream,
|
||||
..
|
||||
} => {
|
||||
Self::AlsaDirect { duration_frames, stream, .. } => {
|
||||
let frames = duration_frames.load(Ordering::SeqCst);
|
||||
let sample_rate = stream.sample_rate() as u64;
|
||||
Some(frames / sample_rate)
|
||||
@@ -308,3 +315,128 @@ impl PlaybackEngine {
|
||||
matches!(self, Self::AlsaDirect { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Single long-lived writer thread for ALSA Direct.
|
||||
///
|
||||
/// Continuously reads samples from the current source and writes to ALSA.
|
||||
/// When a source ends, seamlessly picks up the next one from the queue
|
||||
/// (gapless transition). If no next source is available, drains the ALSA
|
||||
/// buffer and waits for the next source or a stop signal.
|
||||
fn alsa_writer_thread(
|
||||
stream: Arc<AlsaDirectStream>,
|
||||
is_playing: Arc<AtomicBool>,
|
||||
should_stop: Arc<AtomicBool>,
|
||||
position_frames: Arc<AtomicU64>,
|
||||
duration_frames: Arc<AtomicU64>,
|
||||
source_queue: Arc<SourceQueue>,
|
||||
source_transition: Arc<AtomicBool>,
|
||||
channels: u16,
|
||||
) {
|
||||
const CHUNK_FRAMES: usize = 8192;
|
||||
let chunk_samples = CHUNK_FRAMES * channels as usize;
|
||||
let mut buffer_f32 = Vec::with_capacity(chunk_samples);
|
||||
let mut current_source: Option<BoxedSampleIter> = None;
|
||||
let mut total_frames: u64 = 0;
|
||||
|
||||
log::info!("[ALSA Direct Engine] Writer thread started (gapless-capable)");
|
||||
|
||||
'thread: loop {
|
||||
// Check global stop
|
||||
if should_stop.load(Ordering::SeqCst) {
|
||||
log::info!("[ALSA Direct Engine] Stop signal, writer thread exiting");
|
||||
break 'thread;
|
||||
}
|
||||
|
||||
// If no current source, try to get one
|
||||
if current_source.is_none() {
|
||||
// Wait for a source (with 100ms timeout to recheck stop flag)
|
||||
match source_queue.wait_for_source(Duration::from_millis(100)) {
|
||||
Some(src) => {
|
||||
current_source = Some(src);
|
||||
total_frames = 0;
|
||||
position_frames.store(0, Ordering::SeqCst);
|
||||
log::info!("[ALSA Direct Engine] Acquired new source from queue");
|
||||
}
|
||||
None => {
|
||||
// No source available, loop back to check stop
|
||||
continue 'thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait while paused
|
||||
while !is_playing.load(Ordering::SeqCst) {
|
||||
if should_stop.load(Ordering::SeqCst) {
|
||||
break 'thread;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// Fill buffer from current source
|
||||
buffer_f32.clear();
|
||||
let source = current_source.as_mut().unwrap();
|
||||
let mut source_ended = false;
|
||||
|
||||
for _ in 0..chunk_samples {
|
||||
match source.next() {
|
||||
Some(sample) => buffer_f32.push(sample),
|
||||
None => {
|
||||
source_ended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write whatever we have to ALSA (even partial chunks on source end)
|
||||
if !buffer_f32.is_empty() {
|
||||
if let Err(e) = stream.write_f32(&buffer_f32) {
|
||||
log::error!("[ALSA Direct Engine] Write failed: {}", e);
|
||||
break 'thread;
|
||||
}
|
||||
|
||||
let frames_written = buffer_f32.len() / channels as usize;
|
||||
total_frames += frames_written as u64;
|
||||
position_frames.store(total_frames, Ordering::SeqCst);
|
||||
duration_frames.store(total_frames, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if source_ended {
|
||||
log::info!(
|
||||
"[ALSA Direct Engine] Source ended (total frames: {})",
|
||||
total_frames
|
||||
);
|
||||
|
||||
// Try to get next source immediately (gapless transition)
|
||||
match source_queue.try_pop() {
|
||||
Some(next_src) => {
|
||||
log::info!("[ALSA Direct Engine] Gapless transition to next source");
|
||||
current_source = Some(next_src);
|
||||
total_frames = 0;
|
||||
position_frames.store(0, Ordering::SeqCst);
|
||||
// Signal that a transition happened
|
||||
source_transition.store(true, Ordering::SeqCst);
|
||||
// Continue immediately — no drain, no gap
|
||||
}
|
||||
None => {
|
||||
// No next source — this is a natural end of playback
|
||||
log::info!("[ALSA Direct Engine] No next source, draining ALSA buffer");
|
||||
if let Err(e) = stream.drain() {
|
||||
log::warn!("[ALSA Direct Engine] Drain failed: {}", e);
|
||||
}
|
||||
current_source = None;
|
||||
is_playing.store(false, Ordering::SeqCst);
|
||||
// Don't break — stay alive waiting for next append()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is_playing.store(false, Ordering::SeqCst);
|
||||
log::info!("[ALSA Direct Engine] Writer thread finished");
|
||||
}
|
||||
|
||||
impl Drop for PlaybackEngine {
|
||||
fn drop(&mut self) {
|
||||
self.stop_inner();
|
||||
}
|
||||
}
|
||||
@@ -658,17 +658,17 @@ impl IncrementalStreamingSource {
|
||||
}
|
||||
|
||||
impl Source for IncrementalStreamingSource {
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
// We don't know frame boundaries in the queue
|
||||
None
|
||||
}
|
||||
|
||||
fn channels(&self) -> u16 {
|
||||
self.channels
|
||||
fn channels(&self) -> std::num::NonZero<u16> {
|
||||
std::num::NonZero::new(self.channels).unwrap()
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
fn sample_rate(&self) -> std::num::NonZero<u32> {
|
||||
std::num::NonZero::new(self.sample_rate).unwrap()
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
|
||||
@@ -765,6 +765,7 @@ mod tests {
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: Some("test".to_string()),
|
||||
parental_warning: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,12 @@ const BUNDLE_BASE_URL: &str = "https://play.qobuz.com";
|
||||
pub struct BundleTokens {
|
||||
pub app_id: String,
|
||||
pub secrets: Vec<String>,
|
||||
/// OAuth private key used for the /oauth/callback exchange.
|
||||
/// Present in recent bundle versions; None on older bundles.
|
||||
pub private_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract app_id and secrets from Qobuz bundle
|
||||
/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle
|
||||
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
||||
// Step 1: Get login page to find bundle URL
|
||||
let login_page = client
|
||||
@@ -51,7 +54,15 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(BundleTokens { app_id, secrets })
|
||||
// Step 5: Extract OAuth private_key (optional - present in newer bundles)
|
||||
let private_key = extract_private_key(&bundle_content);
|
||||
if private_key.is_some() {
|
||||
log::info!("OAuth private_key extracted from bundle");
|
||||
} else {
|
||||
log::debug!("OAuth private_key not found in bundle (older bundle version)");
|
||||
}
|
||||
|
||||
Ok(BundleTokens { app_id, secrets, private_key })
|
||||
}
|
||||
|
||||
fn extract_bundle_url(html: &str) -> Result<String> {
|
||||
@@ -173,10 +184,20 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Extracted {} secrets", secrets.len());
|
||||
log::info!("Extracted app secrets from bundle");
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
fn extract_private_key(bundle: &str) -> Option<String> {
|
||||
// Pattern: privateKey:"VALUE" (the static OAuth key used in /oauth/callback)
|
||||
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#)
|
||||
.expect("Invalid regex");
|
||||
|
||||
re.captures(bundle)
|
||||
.and_then(|caps| caps.name("key"))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -171,6 +171,107 @@ impl QobuzClient {
|
||||
*self.session.write().await = None;
|
||||
}
|
||||
|
||||
/// Inject an already-authenticated session (e.g. from OAuth flow).
|
||||
/// Use this when the session was obtained outside this client instance.
|
||||
pub async fn set_session(&self, session: UserSession) {
|
||||
*self.session.write().await = Some(session);
|
||||
}
|
||||
|
||||
/// Exchange an OAuth code for a full user session.
|
||||
///
|
||||
/// This implements the new Qobuz OAuth flow:
|
||||
/// 1. GET /oauth/callback?code=CODE&private_key=KEY → { token }
|
||||
/// 2. POST /user/login with X-User-Auth-Token: token, body=extra=partner → UserSession
|
||||
pub async fn login_with_oauth_code(&self, code: &str) -> Result<UserSession> {
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
let tokens = self.tokens.read().await;
|
||||
let tokens = tokens.as_ref().ok_or_else(|| {
|
||||
ApiError::BundleExtractionError("Client not initialized".to_string())
|
||||
})?;
|
||||
let app_id = tokens.app_id.clone();
|
||||
let private_key = tokens.private_key.clone().ok_or_else(|| {
|
||||
ApiError::BundleExtractionError(
|
||||
"OAuth private key not available in bundle".to_string(),
|
||||
)
|
||||
})?;
|
||||
let _ = tokens; // drop read lock
|
||||
|
||||
// Step 1: Exchange code for token
|
||||
let callback_url = endpoints::build_url(endpoints::paths::OAUTH_CALLBACK);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"X-App-Id",
|
||||
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||
);
|
||||
|
||||
log::info!("[OAuth] Exchanging code for token via /oauth/callback");
|
||||
let callback_response = self
|
||||
.http
|
||||
.get(&callback_url)
|
||||
.headers(headers)
|
||||
.query(&[("code", code), ("private_key", &private_key), ("app_id", &app_id)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !callback_response.status().is_success() {
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"OAuth callback failed with status {}",
|
||||
callback_response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let callback_json: serde_json::Value = callback_response.json().await?;
|
||||
let token = callback_json["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
ApiError::ApiResponse("OAuth callback: no token in response".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
log::info!("[OAuth] Got token, fetching user session via /user/login");
|
||||
|
||||
// Step 2: Fetch user session using the token
|
||||
let user_login_url = endpoints::build_url(endpoints::paths::USER_LOGIN);
|
||||
let mut auth_headers = HeaderMap::new();
|
||||
auth_headers.insert(
|
||||
"X-App-Id",
|
||||
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||
);
|
||||
auth_headers.insert(
|
||||
"X-User-Auth-Token",
|
||||
HeaderValue::from_str(&token).map_err(|_| {
|
||||
ApiError::AuthenticationError("Invalid OAuth token format".into())
|
||||
})?,
|
||||
);
|
||||
|
||||
let login_response = self
|
||||
.http
|
||||
.post(&user_login_url)
|
||||
.headers(auth_headers)
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.body("extra=partner")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match login_response.status() {
|
||||
StatusCode::OK => {
|
||||
let json: serde_json::Value = login_response.json().await?;
|
||||
let session = super::auth::parse_login_response(&json)?;
|
||||
*self.session.write().await = Some(session.clone());
|
||||
log::info!("[OAuth] Session established");
|
||||
Ok(session)
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||
"OAuth token rejected by user/login".to_string(),
|
||||
)),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"user/login OAuth step failed with status {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current user info (display name, subscription, and expiry if available)
|
||||
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
|
||||
self.session.read().await.as_ref().map(|s| {
|
||||
@@ -182,11 +283,6 @@ impl QobuzClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current user ID
|
||||
pub async fn get_user_id(&self) -> Option<u64> {
|
||||
self.session.read().await.as_ref().map(|s| s.user_id)
|
||||
}
|
||||
|
||||
/// Get user auth token header value (public for catalog search)
|
||||
pub async fn auth_token(&self) -> Result<String> {
|
||||
self.session
|
||||
@@ -1159,11 +1255,8 @@ impl QobuzClient {
|
||||
match self.get_stream_url(track_id, *quality).await {
|
||||
Ok(url) if !url.has_restrictions() => {
|
||||
log::info!(
|
||||
"Got stream URL - requested format_id={}, got format_id={}, sample_rate={}Hz, bit_depth={:?}",
|
||||
quality.id(),
|
||||
url.format_id,
|
||||
url.sampling_rate,
|
||||
url.bit_depth
|
||||
"Got stream URL for requested quality format_id={}",
|
||||
quality.id()
|
||||
);
|
||||
return Ok(url);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,9 @@ pub mod paths {
|
||||
// User
|
||||
pub const USER_LOGIN: &str = "/user/login";
|
||||
|
||||
// OAuth
|
||||
pub const OAUTH_CALLBACK: &str = "/oauth/callback";
|
||||
|
||||
// Track
|
||||
pub const TRACK_GET: &str = "/track/get";
|
||||
pub const TRACK_GET_LIST: &str = "/track/getList";
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# QBZ 1.1.15-rc-1 Beta Test Checklist
|
||||
|
||||
Thanks for helping validate this release candidate.
|
||||
|
||||
## Important context
|
||||
|
||||
- This RC includes significant backend changes.
|
||||
- Please test your normal daily flows end-to-end, not only new features.
|
||||
- Any regression report is valuable, even if it looks minor.
|
||||
|
||||
## Region note for Purchases
|
||||
|
||||
Qobuz purchases are not available in all countries (for example Mexico). If Purchases is unavailable in your region/account, report that as "not testable" and continue with the rest of the checklist.
|
||||
|
||||
## Purchases validation (if available on your account)
|
||||
|
||||
1. Open `Purchases` from sidebar.
|
||||
2. Confirm albums and tracks load (no empty-error state).
|
||||
3. Search by artist/album/track and confirm results update correctly.
|
||||
4. Open album detail and confirm tracklist renders.
|
||||
5. Verify format selector options are visible.
|
||||
6. Download one track to a chosen folder.
|
||||
7. Download full album to a chosen folder.
|
||||
8. Restart app and verify downloaded state persists.
|
||||
9. Confirm unavailable purchases show clear unavailable UI.
|
||||
|
||||
## Core regression checklist (all testers)
|
||||
|
||||
1. Login, restart app, and confirm session persistence.
|
||||
2. Home loads correctly and navigation remains responsive.
|
||||
3. Search (all tabs you normally use) returns expected results.
|
||||
4. Playback basics: play/pause, next/prev, seekbar, queue advance.
|
||||
5. Queue operations: add, reorder, shuffle, clear queue, history behavior.
|
||||
6. Favorites: tracks/albums/artists/playlists (load and toggle).
|
||||
7. Playlists: open, edit, reorder tracks, play all.
|
||||
8. Local Library: browse albums/artists/tracks and play local content.
|
||||
9. Settings panels: open each section and confirm no command-not-found errors.
|
||||
10. Immersive mode: open, switch tabs, return to main player.
|
||||
|
||||
## Report template
|
||||
|
||||
Please include:
|
||||
|
||||
- OS + desktop environment
|
||||
- QBZ version: `1.1.15-rc-1`
|
||||
- Repro steps
|
||||
- Expected vs actual behavior
|
||||
- Console/terminal logs if available
|
||||
- Screenshot/video if useful
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# QBZ 1.1.15 Full Changelog
|
||||
|
||||
This document details all changes introduced in version 1.1.15 relative to the v1.1.14 tag.
|
||||
|
||||
**Summary:** ~200 commits across all branches merged into pre-release; 19 files changed in the multi-select/navigation work alone.
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
### Purchases view
|
||||
|
||||
- New `PurchasesView` component browses the authenticated user's Qobuz purchases via the V2 API
|
||||
- Local download registry stored in a dedicated SQLite table; tracks which purchases have been downloaded and to which local path
|
||||
- V2 backend commands: `v2_get_purchases`, `v2_get_downloaded_purchases`, `v2_mark_purchase_downloaded`
|
||||
- Downloads organized into quality subfolders to match the Qobuz file naming convention
|
||||
- Mock data removed; real API integration only
|
||||
|
||||
### Your Mixes
|
||||
|
||||
- New **Your Mixes** card section on the Home view
|
||||
- Four mix types surfaced: **Daily Q** (`DynamicSuggestView`), **Weekly Q** (`WeeklySuggestView`), **Fav Q** (`FavQView`), **Top Q** (`TopQView`)
|
||||
- Each view: full track list with artwork, quality badges, shuffle, search/filter, and now multi-select
|
||||
- Video/animated assets for mix cover placeholders
|
||||
|
||||
### Multi-select bulk actions
|
||||
|
||||
All track list views support multi-select mode:
|
||||
|
||||
| View | Source |
|
||||
|---|---|
|
||||
| AlbumDetailView | `TrackRow` checkbox via existing selectable props |
|
||||
| ArtistDetailView | Custom track rows with injected checkbox |
|
||||
| PlaylistDetailView | Virtualized list; `VirtualizedTrackList` extended with `selectable`/`selected`/`onToggleSelect` |
|
||||
| FavoritesView | Virtualized list via same props |
|
||||
| DynamicSuggestView (Daily Q) | `TrackRow` checkbox |
|
||||
| WeeklySuggestView | `TrackRow` checkbox |
|
||||
| FavQView | `TrackRow` checkbox |
|
||||
| TopQView | `TrackRow` checkbox |
|
||||
| LabelView | Custom track rows with injected checkbox |
|
||||
|
||||
**BulkActionBar component** (`src/lib/components/BulkActionBar.svelte`):
|
||||
- Sticky-bottom bar with count label, queue split-button (Add to Queue / Play Next), Add to Playlist, Add/Remove Favorites, Remove from Playlist
|
||||
- Animated entry (`slideUp` 180 ms)
|
||||
- `placement` prop retained for future use; all current views use bottom placement
|
||||
- Positioned after all track rows; views already carry `padding-bottom: 100px` so no track is ever hidden
|
||||
|
||||
**Backend V2 commands added** (`src-tauri/src/commands_v2.rs`):
|
||||
- `v2_add_tracks_to_queue_next` — insert tracks at front of queue
|
||||
- `v2_bulk_toggle_favorites` — toggle favorite state for a list of track IDs in one call
|
||||
|
||||
**i18n keys added** (all four locales: en, es, de, fr):
|
||||
- `actions.select`
|
||||
- `actions.cancelSelection`
|
||||
- `actions.selectedTracks` (interpolated: `{count} selected`)
|
||||
|
||||
### Custom artist images
|
||||
|
||||
- `customArtistImageStore` — Svelte store keyed by artist ID; populated from a bulk backend fetch on login
|
||||
- Right-click context menu on any artist image opens a file picker; selected image is resized (max 800 px) and thumbnailed (200 px) in the Rust backend and stored in the app data directory
|
||||
- V2 backend commands: `v2_set_custom_artist_image`, `v2_remove_custom_artist_image`, `v2_get_custom_artist_image`, `v2_bulk_get_custom_artist_images`
|
||||
- `resolveArtistImage(id, fallback)` helper used across: `HomeView`, `SearchView`, `FavoritesView`, `ArtistDetailView`, `LabelView`
|
||||
- Images sync immediately on assignment without requiring navigation
|
||||
|
||||
### Image lightbox
|
||||
|
||||
- `ImageLightbox` component: full-screen overlay, click-outside or Escape to dismiss, natural image dimensions
|
||||
- Wired to artist photo (ArtistDetailView) and album artwork (AlbumDetailView)
|
||||
- Round mask removed by design — images display at their natural crop
|
||||
|
||||
### Qobuz Radio
|
||||
|
||||
- Album radio button in AlbumDetailView header
|
||||
- Artist radio and track radio in ArtistDetailView and track context menus
|
||||
- Submenu lists available Qobuz radio variants for the selected artist/track
|
||||
- V2 backend: `v2_create_qobuz_artist_radio`, `v2_create_qobuz_track_radio`
|
||||
|
||||
### Session restore
|
||||
|
||||
- App serializes active view + selected item IDs to a session file on navigation
|
||||
- On launch, `restoreView` in `navigationStore` rehydrates without triggering data re-fetches
|
||||
- Startup page setting in Settings → General: `home`, `library`, `purchases`, `last`
|
||||
|
||||
### Shareable and deep links — Odesli/Songlink integration
|
||||
|
||||
- `LinkResolverService` — backend service that accepts any streaming platform URL
|
||||
- Phase 1: direct platform API calls for Spotify, Apple Music, Tidal, Deezer, YouTube Music, Amazon Music
|
||||
- Phase 2: falls back to Odesli universal API (`song.link`) when direct resolution fails
|
||||
- Falls back again to smart Qobuz search (title + artist) if Odesli has no Qobuz entry
|
||||
- Share outward: track context menu → "Share via Songlink" copies a `song.link` URL to clipboard
|
||||
- Paste inward: paste any recognized URL to open the Qobuz equivalent in-app
|
||||
|
||||
### Font selector
|
||||
|
||||
- `fontFamily` setting in Appearance; persisted in user config
|
||||
- Applied via CSS custom property on `:root`; immediate effect
|
||||
- Options include system-ui, Inter, Roboto, and a handful of monospace/serif choices
|
||||
|
||||
### System / Auto-theme
|
||||
|
||||
- `autoThemeStore` and V2 commands `v2_generate_auto_theme`, `v2_detect_system_theme`
|
||||
- Desktop environment detection: KDE (reads `~/.config/kdeglobals`), GNOME (GSettings schema), others
|
||||
- On DEs without an accent API: extracts a dominant palette from the current album artwork using a k-means color quantizer in the Rust backend
|
||||
- Generated colors injected as CSS custom properties; can be individually overridden with the inline color picker in Appearance settings
|
||||
- Bootstrap restore: theme is re-applied from stored config on startup before the first paint
|
||||
|
||||
---
|
||||
|
||||
## Redesigns
|
||||
|
||||
### Label view
|
||||
|
||||
Previous design was a flat list of popular tracks. New design is a full-page layout with:
|
||||
|
||||
1. **Hero header** — label logo/image, name, album count, carousel jump-links
|
||||
2. **Popular Tracks section** — play-all controls, multi-select, show-more/show-less (5 → 20 → 50)
|
||||
3. **Releases** — horizontal scroll carousel with "See All" pagination
|
||||
4. **Playlists** — horizontal scroll carousel
|
||||
5. **Artists** — horizontal scroll carousel with custom artist image support
|
||||
|
||||
---
|
||||
|
||||
## Improvements
|
||||
|
||||
### Audio
|
||||
|
||||
- **PipeWire force bit-perfect** — new option in Audio settings; sets `node.rate` to track's native sample rate and disables PipeWire's built-in resampler, delivering raw PCM to the hardware node without any mixer involvement
|
||||
- Bit-perfect remains non-negotiable: `try_from_device_config()` path is preserved; this option is additive
|
||||
|
||||
### Performance
|
||||
|
||||
- **L1 cache: 400 MB, L2 cache: 800 MB** — raised from previous defaults; reduces backend re-fetches for users with large streaming histories
|
||||
- Label view artist image carousel deferred to idle frames via batched `Promise.allSettled`; prevents paint jank on first render
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **UI scale options** — 85% and 95% added between the existing 80% and 100% steps
|
||||
- **Track multi-select persists during scroll** — selection state held in a `Set<number>` (not DOM state); survives virtual scroll culling in Favorites and Playlist views
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Multi-select action bar positioning (ADR-001 + layout)
|
||||
|
||||
**Problem:** `BulkActionBar` placed before the column header (`position: sticky; bottom: 0`) was rendered at the *top* of the track list, not the bottom. On scroll, it floated over tracks.
|
||||
|
||||
**Root cause (ADR-001):** AlbumDetailView and WeeklySuggestView used `filter(t => ...)` inside async functions — `t` shadows the svelte-i18n `$t` store, breaking `vite-plugin-svelte`'s CSS extraction pipeline. This caused the entire component's CSS to be dropped at runtime.
|
||||
|
||||
**Fix:**
|
||||
- `filter(t => ...)` → `filter(track => ...)` / `filter(trk => ...)` in all affected functions
|
||||
- `BulkActionBar` moved to the end of the track row container, after `{/each}`, still inside the scrollable section
|
||||
- All affected views already had `padding-bottom: 100px`; no track is ever hidden under the sticky bar
|
||||
|
||||
### Scroll position bleeding between items
|
||||
|
||||
**Problem:** `scrollPositions: Map<ViewType, number>` used a single key per view type. Opening Album B after Album A would restore Album A's saved scroll offset in Album B.
|
||||
|
||||
**Fix:** Key changed to `"viewType:itemId"` — e.g. `"album:MAQJXZA123456"`, `"artist:300213"`, `"playlist:42"`. Views without a specific item (search, favorites tabs) retain the view-type-only key unchanged. The `scrollKey()` helper builds the composite string; `saveScrollPosition` and `getSavedScrollPosition` accept an optional `itemId` parameter.
|
||||
|
||||
### Scroll position TTL
|
||||
|
||||
**Problem:** Saved positions were permanent; an album visited weeks ago would restore a stale offset.
|
||||
|
||||
**Fix:** `ScrollEntry` now stores `{ scrollTop, savedAt: Date.now() }`. `getSavedScrollPosition` returns 0 if `Date.now() - savedAt > 3_600_000` (1 hour). Constant `SCROLL_TTL_MS` at the top of `navigationStore.ts` for easy adjustment.
|
||||
|
||||
---
|
||||
|
||||
## Architecture (Internal)
|
||||
|
||||
> This section is for contributors. Users can skip it.
|
||||
|
||||
### V2 multi-crate migration (Codex)
|
||||
|
||||
The entire Tauri command layer was migrated from a monolithic `src-tauri/src/` module to a workspace of independent Rust crates with no Tauri dependency:
|
||||
|
||||
| Crate | Responsibility |
|
||||
|---|---|
|
||||
| `qbz-core` | Shared types, error types, DB utilities |
|
||||
| `qbz-models` | Domain model structs (Track, Album, Artist, …) |
|
||||
| `qbz-player` | Audio engine, queue, playback state |
|
||||
| `qbz-audio` | PipeWire/ALSA backend wrappers |
|
||||
| `qbz-qobuz` | Qobuz API client |
|
||||
| `qbz-cache` | Two-level cache (L1 in-memory, L2 SQLite) |
|
||||
| `qbz-library` | Local library scan and indexing |
|
||||
| `qbz-integrations` | Last.fm, MPRIS, Odesli, Plex |
|
||||
|
||||
All frontend `invoke()` calls use `v2_*` or `runtime_*` prefixes. Legacy command modules (the old `src-tauri/src/library/commands.rs` et al.) have been deleted or reduced to stubs; no V2 command delegates to a legacy function. This separation means the player, library, and integrations layers can run headless (TUI, CLI) without Tauri being present.
|
||||
|
||||
The `commands_v2.rs` file added in this release adds the multi-select bulk commands and the purchase registry commands on top of the already-migrated stack.
|
||||
@@ -0,0 +1,29 @@
|
||||
# QBZ 1.1.15 Release Highlights
|
||||
|
||||
## New Features
|
||||
|
||||
- **Purchases** — browse your Qobuz purchase history, track local downloads, re-download any purchase
|
||||
- **Your Mixes** — Daily Q, Weekly Q, Fav Q, and Top Q on the Home view; each with a full dedicated view
|
||||
- **Multi-select tracks** — checkbox selection mode across all track lists; bulk queue, playlist, and favorites actions
|
||||
- **Custom artist images** — right-click any artist photo to replace it; persisted per artist, synced globally across all views
|
||||
- **Image lightbox** — click any album artwork or artist photo to view full-size
|
||||
- **Qobuz Radio** — start artist or track radio from any view; submenu shows available stations
|
||||
- **Session restore** — app returns to your last view on launch; configurable startup page in Settings → General
|
||||
- **Shareable links** — paste any Songlink/Odesli URL to open the Qobuz equivalent; share tracks outward via the track context menu
|
||||
- **Font selector** — choose UI font family in Settings → Appearance
|
||||
- **System theme** — auto-generates a color scheme from your DE accent or current album art; swatches are editable
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Label view** completely rebuilt: Popular Tracks, Releases, Playlists, and Artists sections
|
||||
- **Cache raised** to L1 400 MB / L2 800 MB
|
||||
- **UI scale** — 85% and 95% options added
|
||||
- **PipeWire force bit-perfect** — bypass volume mixer for raw PCM delivery
|
||||
- **Custom artist image sync** — changes in Artist Detail reflect immediately everywhere
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Multi-select action bar no longer overlaps the last visible track
|
||||
- Scroll position is now scoped per item (album, artist, playlist) — different items no longer share positions
|
||||
- Saved scroll positions expire after 1 hour
|
||||
- PostCSS CSS-extraction failures caused by `t`-as-parameter shadowing fixed in AlbumDetailView, WeeklySuggestView, and related views
|
||||
@@ -0,0 +1,41 @@
|
||||
# 1.1.15 — Your Mixes, Purchases & Multi-select
|
||||
|
||||
Largest feature release since 1.1.8. Ten new user-facing features, a completely rebuilt Label view, and targeted fixes for scroll behavior and layout.
|
||||
|
||||
## New Features
|
||||
|
||||
- **Purchases** — browse and re-download your Qobuz purchase history directly in the app; local download registry persists across restarts. Experimental since it was blindfold developed haha. Opt-in in settings
|
||||
- **Your Mixes** — Daily Q, Weekly Q, Fav Q, and Top Q surfaced on the Home view, each with its own full view, artwork, queue controls, and search/filter
|
||||
- **Multi-select tracks** — checkbox selection mode across all track lists (Album, Artist, Playlist, Favorites, Label, and all four Mixes views); bulk queue, playlist, and favorites actions via a sticky action bar
|
||||
- **Custom artist images** — right-click any artist photo to replace it with a local image; persisted per artist and reflected immediately everywhere the artist appears
|
||||
- **Image lightbox** — click any album artwork or artist photo for a full-screen overlay; Escape or click-outside to dismiss
|
||||
- **Qobuz Radio** — start artist or track radio from any context menu or artist page; submenu lists available stations. QBZ radio is still available if you liked it.
|
||||
- **Session restore** — app returns to your last view on launch; configurable startup page in Settings → General (Home, Library, Purchases, or Last). Opt-in in settings
|
||||
- **Shareable links** — paste any Songlink/Odesli or streaming platform URL to open the Qobuz match in-app; share tracks outward via the track context menu. Ctrl+L to launch opener.
|
||||
- **Font selector** — choose UI font family in Settings → Appearance; takes effect immediately
|
||||
- **System theme** — auto-generates a color scheme from your desktop accent color (KDE/GNOME) or current album artwork; generated swatches are editable with the inline color picker
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Label view redesigned** — completely rebuilt as a multi-section page: hero header, Popular Tracks with play-all and multi-select, Releases carousel, Playlists, and Artists with custom image support
|
||||
- **Cache limits raised** — L1 increased to 400 MB, L2 to 800 MB, reducing re-fetches on large libraries. This will be configurable soon.
|
||||
- **UI scale options** — 85 % and 95 % steps added to the existing scale selector in Appearance
|
||||
- **PipeWire force bit-perfect** — new audio option to bypass the volume mixer and deliver raw PCM directly to the hardware sink; non-negotiable bit-perfect path preserved. Experimental, please use with care.
|
||||
- **Custom artist image sync** — images assigned in Artist Detail reflect immediately in Home, Search, Favorites, and Label without restarting
|
||||
- **More viewers** — For full-screen playback sessions, there are now more options besides bars.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Scroll position bleeding between items** — opening a second album no longer restores the first album's saved scroll offset; positions are now scoped per item ID
|
||||
- **Scroll position TTL** — saved positions expire after one hour; stale offsets from days-old visits no longer teleport you mid-list
|
||||
- **PostCSS CSS-extraction failures** — views that used `t` as an arrow-function parameter (shadowing the svelte-i18n store) had their CSS silently dropped; fixed across AlbumDetailView, WeeklySuggestView, and related helpers
|
||||
- **Auto reload audio settings** — In some DAC models, especially portable ones, the DAC name may change upon restarting. It now auto-refreshes when the app is launched. It is opt-in so as not to affect those who do not need it.
|
||||
- **Settings that do not persist** — fixed several settings that were not connected and did not respect the user's selection.
|
||||
- **Play queue move and clear bugs** — track reordering now lands in the correct position; clearing the queue while a track is playing no longer skips the first enqueued track afterward (by @vorce, PR #79)
|
||||
|
||||
---
|
||||
|
||||
Special thanks to **@jrgn9**, **@ThaYke**, and **@turby666** for QA testing this release.
|
||||
As always, thank you to everyone who reports bugs, tests edge cases, and takes the time to give feedback — you are the best QA team.
|
||||
|
||||
Full changelog: https://github.com/vicrodh/qbz/compare/v1.1.14...v1.1.15
|
||||
@@ -0,0 +1,259 @@
|
||||
# QBZ 1.1.19 Full Changelog
|
||||
|
||||
**Codename:** Limpiando la casa
|
||||
**Commits:** ~200 since v1.1.18
|
||||
|
||||
---
|
||||
|
||||
## Audio Engine
|
||||
|
||||
### rodio 0.22 migration
|
||||
- Removed vendored rodio/cpal fork entirely; now uses upstream rodio 0.22
|
||||
- All decoder, stream, and buffer management updated to new API
|
||||
- Explicit 100ms buffer size replaces vendored cpal tuning
|
||||
- PipeWire clock.force-quantum disabled for rodio 0.22 compatibility
|
||||
|
||||
### ALSA improvements
|
||||
- Sample rate fallback when DAC doesn't support the requested rate
|
||||
- PipeWire sink suspended before ALSA Direct exclusive access to prevent DeviceBusy errors
|
||||
- ALSA Direct gapless playback enabled and defaulted to ON
|
||||
- Gapless restrictions removed for DAC Passthrough and ALSA Direct modes
|
||||
- Pre-check hardware rates via /proc/asound before attempting ALSA Direct
|
||||
|
||||
### Smart quality downgrade
|
||||
- When hardware can't handle a sample rate, QBZ selects the best compatible quality automatically
|
||||
- Quality badge tooltip shows "Hardware compatibility" explanation
|
||||
- Cached audio also verified against hardware rates before playback
|
||||
|
||||
### Other audio fixes
|
||||
- Hot-reload gapless and normalization settings to active player
|
||||
- Stop updating live gain during EBU R128 refinement passes
|
||||
- Audio settings sync on startup for OAuth login path
|
||||
- Output device name polling during playback
|
||||
- Configured device name shown when player has no active stream
|
||||
- Deprecated CPAL `name()` replaced with `description()`
|
||||
|
||||
---
|
||||
|
||||
## Booklet Viewer
|
||||
|
||||
- Albums with digital booklets show a booklet button in the album header
|
||||
- Full in-app PDF viewer with page navigation
|
||||
- MuPDF native backend replaces pdfjs-dist for crisp rendering and smaller bundle
|
||||
- Download button to save booklet PDF locally
|
||||
- Auto-fit page width on load
|
||||
- V2 backend command fetches PDF through Tauri to bypass CORS
|
||||
- Goody/booklet model added to qbz-models crate
|
||||
- i18n keys added to all 4 locales
|
||||
|
||||
---
|
||||
|
||||
## Neon Visualizers
|
||||
|
||||
Three new Canvas 2D visualizers under Immersive > Neon:
|
||||
|
||||
### Laser (NeonFlowPanel)
|
||||
- Neon laser beams that pulse, bend, and shift with bass/mid/high energy
|
||||
- Dual rendering layers for glow effect
|
||||
|
||||
### Tunnel (TunnelFlowPanel)
|
||||
- Depth-tunnel with warped elliptical rings
|
||||
- Curved wisps flow outward from center
|
||||
- Watercolor cloud layer for atmosphere
|
||||
|
||||
### Comet (CometFlowPanel)
|
||||
- Watercolor nebula with curved wisps, tunnel rings, and central abyss
|
||||
- Album art color extraction — samples artwork, derives 4 dominant hues + darkest color
|
||||
- Palette lerps smoothly on track change
|
||||
- Time-based hue mutation for continuous color evolution
|
||||
- Fades to near-black during silence; wisps, rings, and clouds scale with total energy
|
||||
- Corner vignettes using darkest album art color with bass-driven drift
|
||||
- Beat detection spawns wisps on bass transients
|
||||
|
||||
### Shared
|
||||
- All panels have per-panel FPS configuration
|
||||
- Dedicated submenu with fixed positioning and hover bridge
|
||||
|
||||
---
|
||||
|
||||
## Immersive Mode
|
||||
|
||||
- **3-mode background** — Full (GPU shader), Lite (CSS blur), Off; configurable in settings
|
||||
- **Auto-degrade** — sustained low FPS triggers automatic downgrade with dismissible modal notification
|
||||
- **Per-panel FPS** — each visualizer has independent FPS settings; default 15 FPS
|
||||
- **Configurable ambient FPS** — separate from panel FPS
|
||||
- **Ambient background fixes** — 8x8 downscale replaces CSS blur; dark artwork mood preserved; MIRRORED_REPEAT for texture edges
|
||||
- **Composition profiles** — GSK renderer selection and blur toggle per profile
|
||||
- **GPU detection** — AMD and Intel identification for profile recommendations
|
||||
- **GSK renderer control** — Cairo, GL, NGL, or Vulkan backend selection
|
||||
- **Immersive settings** moved to end of Appearance section with standard collapsible pattern
|
||||
|
||||
---
|
||||
|
||||
## Artist Discovery
|
||||
|
||||
### MusicBrainz tag-based recommendations
|
||||
- "You May Also Like" uses MusicBrainz genre tags instead of typical similar-artist APIs
|
||||
- Deterministic shuffle for varied results across visits
|
||||
- Shows 6 artists with 2 reserves for instant thumbs-down replacement
|
||||
- Similarity percentage displayed per recommended artist
|
||||
|
||||
### Tag-scoped dismissals
|
||||
- Thumbs down dismisses an artist from a specific tag/genre context only
|
||||
- Same artist can still appear in different genre contexts
|
||||
- SQLite table with composite key (tag + normalized artist name)
|
||||
- Instant removal from list with reserve pull-in
|
||||
|
||||
### Artist Network sidebar
|
||||
- Opens by default; preference persisted in localStorage
|
||||
- State restoration after Queue/Lyrics close
|
||||
- Clean transitions when navigating between artists (state reset on artist change)
|
||||
- Simplified header: "Artist Network" title, PanelRightClose icon
|
||||
- Empty relationship sections hidden automatically
|
||||
|
||||
---
|
||||
|
||||
## Label Releases View
|
||||
|
||||
- Completely redesigned with label logo header
|
||||
- Sorting: date, title, popularity
|
||||
- Filters: album, EP, single, live
|
||||
- Search bar with animated expand
|
||||
- Group-by-artist toggle with collapsible sections
|
||||
- Performance improvements for large label catalogs
|
||||
- Consistent back button and navigation height
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- Removed hardcoded key material from credential encryption
|
||||
- Cryptographically secure session ID generation (replaces deterministic)
|
||||
- DOM-based HTML sanitization replacing regex-based sanitizer
|
||||
- Sensitive identifiers and secrets redacted from backend logs
|
||||
- Removed unused user_id accessors from Qobuz and Tauri API modules
|
||||
- Hardened release-aur CI workflow permissions and input validation
|
||||
|
||||
---
|
||||
|
||||
## Spotify Import
|
||||
|
||||
- Removed Spotify API dependency entirely
|
||||
- Uses embed page scraping for playlist import — no credentials needed
|
||||
- Link resolver updated for new scraping approach
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
- **Explicit content badges** across the entire app
|
||||
- **Search track context menu** — right-click works on search result tracks
|
||||
- **Track/disc number extraction** from file tags during local library import
|
||||
- **Window size validation** prevents GDK crash from corrupt DB values (by @arminfelder, PR #136)
|
||||
- **Cancel download** for purchase album downloads
|
||||
- **Miniplayer keyboard shortcuts**
|
||||
- **Autoconfig-graphics CLI tool** (`qbz --autoconfig-graphics`)
|
||||
- **Custom artwork for playlists** and folder sidebar visibility controls
|
||||
- **Image cache** — frontend image cache with size-aware loading, LRU eviction, and settings UI
|
||||
- **Granular navigation history** with per-item scroll position
|
||||
|
||||
---
|
||||
|
||||
## Stability
|
||||
|
||||
- **Graceful shutdown** on RunEvent::Exit to prevent heap corruption
|
||||
- **WebKit double-destroy prevention** — secondary windows close before main window
|
||||
- **Window size clamping** to screen resolution; physical/logical pixel mismatch fixed
|
||||
- **Flatpak tray icon** path corrected for sandbox environment
|
||||
- **Snap MPRIS** declared as slot instead of plug
|
||||
- **Titlebar double-click maximize** fixed
|
||||
- **Opaque main window** default for shutdown stability on Linux
|
||||
- **WebKit EGL shutdown workarounds** applied defensively
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Equalizer animation no longer appears active when player is paused
|
||||
- Home settings changes apply immediately without page reload
|
||||
- Navigation scroll position scoped per item with 1-hour TTL
|
||||
- Session restore no longer recalls stale scroll positions
|
||||
- Download paths restructured to Artist/Album [FORMAT][Quality]/track
|
||||
- EPs and singles backfilled when artist page omits them
|
||||
- Search grid no longer collapses to single column on keystroke
|
||||
- Escape on empty search input blurs focus instead of navigating
|
||||
- Multi-select add-to-playlist sends all tracks in one call
|
||||
- Deduplicate albums with quality subfolders in local library
|
||||
- Reco meta caches cleared when image cache is cleared
|
||||
- ML/reco module optimized for home view loading
|
||||
- Artist resolution parallelized for card rendering
|
||||
- Force dmabuf respected as full GPU opt-in for compositing
|
||||
- FavQ and TopQ context types added to playback context
|
||||
- Last.fm uses user-scoped storage for session restore
|
||||
- Qobuz purchase source detection on library scan
|
||||
- Svelte a11y and component warnings resolved across 26+ components
|
||||
- ADR-001 compliance maintained across all new components
|
||||
|
||||
---
|
||||
|
||||
## Scene Discovery
|
||||
|
||||
- **Explore by location** — discover artists from the same city, country, or musical scene as any artist
|
||||
- MusicBrainz-powered location and genre affinity matching with Qobuz catalog validation
|
||||
- Progress tracking with animated loading phases (searching → validating → done)
|
||||
- Two view modes: responsive grid and sidepanel with artist discography
|
||||
- Alphabetical A-Z grouping with jump navigation index
|
||||
- Genre multi-select filter with searchable popup (when >9 genres)
|
||||
- Text search across discovered artists
|
||||
- Sticky compact header on scroll with scene label, country flag, and genre summary
|
||||
- Paginated "Load More" for large scenes
|
||||
- Filtered count display (e.g., "42 / 100 artists")
|
||||
|
||||
---
|
||||
|
||||
## Discovery Tabs (Redesigned Home)
|
||||
|
||||
### 3-tab home layout
|
||||
- **Home** — customizable feed with drag-to-reorder sections and per-section visibility toggles
|
||||
- **Editor's Picks** — fixed Qobuz editorial curation (New Releases, Editor's Picks, Qobuzissimes, Press Awards, Popular Albums)
|
||||
- **For You** — personalized recommendations built from listening history
|
||||
|
||||
### Home tab customization
|
||||
- Sections include: New Releases, Press Awards, Popular Albums, Qobuzissimes, Editor's Picks, Qobuz Playlists, Your Mixes, Essential Discography, Recently Played, Continue Listening, Your Top Artists, More From Favorites
|
||||
- Settings modal (gear icon) to toggle visibility and reorder sections
|
||||
- Genre filter available on Home and Editor's Picks tabs
|
||||
|
||||
### For You tab
|
||||
- Progressive loading with skeleton placeholders per section
|
||||
- **Radio Stations** — 9 album-based radios (3 recent + 3 favorites + 3 top artists); dominant color extraction per card
|
||||
- **Artists to Follow** — 10 suggested artists from similar-to-your-top-3 with styled 210px cards
|
||||
- **Spotlight** — featured artist card with top tracks, playlists, "Create Radio" button, and "Qobuz Radio Station" subtitle; dominant color from artist image
|
||||
- **Similar to [Album]** — albums related to a featured album
|
||||
- **Rediscover Your Library** — neglected/forgotten albums from your collection
|
||||
- **Essentials [Genre]** — essential albums from one of your top genres
|
||||
|
||||
### Sticky compact header
|
||||
- Appears after 60px scroll; hides greeting and shows compact tab navigation with genre filter
|
||||
- Edge-to-edge positioning with negative margin pattern (matching ArtistDetailView jump-nav)
|
||||
- Time-based greeting (Morning/Afternoon/Evening) with i18n and username interpolation
|
||||
|
||||
### Session cache
|
||||
- Album, artist, and track data cached to avoid redundant API calls on navigation
|
||||
- Scroll position preserved per tab
|
||||
|
||||
---
|
||||
|
||||
## Packaging
|
||||
|
||||
- **Cargo.lock tracked in git** — enables deterministic builds for distro packagers (NixOS, etc.)
|
||||
- **Tauri version sync** — @tauri-apps/api updated to 2.10.1 matching Rust crate 2.10.x
|
||||
- **AUR cleanup** — removed non-functional aur-source and aur-git packages; only qbz-bin maintained
|
||||
- **AUR maintainer email** updated
|
||||
- **APT repository** — CI triggers APT repo update on release publish
|
||||
- **Flatpak** — PipeWire stream matching restored; quality guidelines applied
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
- **@arminfelder** — Window size validation (PR #136)
|
||||
@@ -0,0 +1,55 @@
|
||||
# QBZ 1.1.19 Release Highlights
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Scene Discovery** — explore artists from the same city or musical scene; MusicBrainz-powered with genre filtering, A-Z grouping, and sidepanel discography view
|
||||
- **Discovery Tabs** — redesigned Home with 3 tabs: customizable Home, Editor's Picks, and personalized For You with Radio Stations, Spotlight, and Rediscover Your Library
|
||||
- **Audio engine upgraded** — vendored rodio/cpal removed; now on upstream rodio 0.22 with ALSA sample rate fallback, gapless on all backends, and smart quality downgrade
|
||||
- **Booklet viewer** — albums with digital booklets get an in-app PDF viewer powered by native MuPDF; includes download button
|
||||
- **3 Neon visualizers** — Laser, Tunnel, and Comet; all music-reactive with bass/mid/high response. Comet extracts colors from album artwork
|
||||
- **Artist discovery** — MusicBrainz tag-based recommendations with tag-scoped thumbs down and similarity percentages
|
||||
- **Label Releases redesigned** — logo header, sorting, filters, group-by-artist toggle, and search
|
||||
- **Explicit badges** — shown across the entire app
|
||||
|
||||
## Scene Discovery
|
||||
|
||||
- Explore artists from the same city or musical scene — powered by MusicBrainz location and genre affinity
|
||||
- Grid and sidepanel views with A-Z grouping, genre filter, and text search
|
||||
- Sticky header with scene label and country flag on scroll
|
||||
|
||||
## Discovery Tabs (Redesigned Home)
|
||||
|
||||
- **3-tab home**: Home (customizable sections), Editor's Picks (editorial curation), For You (personalized)
|
||||
- For You features Radio Stations, Artists to Follow, Spotlight artist, Rediscover Your Library, and Genre Essentials
|
||||
- Sticky compact header with tab navigation and genre filter appears on scroll
|
||||
- Progressive loading with session cache for instant back-navigation
|
||||
|
||||
## Audio
|
||||
|
||||
- ALSA sample rate fallback when DAC doesn't support requested rate
|
||||
- PipeWire suspension before ALSA Direct exclusive access
|
||||
- Gapless playback now available on ALSA Direct, defaults to ON
|
||||
- Smart quality downgrade with hardware compatibility tooltip
|
||||
|
||||
## Immersive
|
||||
|
||||
- 3-mode background system (Full/Lite/Off) with auto-degrade on low FPS
|
||||
- Per-panel FPS settings for each visualizer
|
||||
- Comet visualizer adopts album art palette and fades on silence
|
||||
|
||||
## Security
|
||||
|
||||
- Removed hardcoded key material, cryptographic session IDs, DOM-based sanitization, log redaction
|
||||
|
||||
## Stability
|
||||
|
||||
- Graceful shutdown — no more heap corruption on exit
|
||||
- Window size validation and clamping
|
||||
- Flatpak tray icon and Snap MPRIS fixes
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Equalizer bars no longer animate when paused
|
||||
- Navigation scroll position scoped per item with 1-hour TTL
|
||||
- Home settings apply immediately without reload
|
||||
- Download paths corrected to Artist/Album/track structure
|
||||
@@ -0,0 +1,131 @@
|
||||
# 1.1.19 — Limpiando la casa
|
||||
|
||||
Massive housekeeping release with two headline features: **Scene Discovery** for exploring artists by location and musical scene, and a **redesigned Home with 3 Discovery Tabs** (Home, Editor's Picks, For You). Also upgraded the audio engine, hardened security, rebuilt the booklet viewer, and added three new Neon visualizers — all while fixing dozens of stability issues across shutdown, window management, and audio routing.
|
||||
|
||||
---
|
||||
|
||||
## Scene Discovery
|
||||
|
||||
Discover artists from the same city, country, or musical scene as any artist in your library. Powered by MusicBrainz location and genre affinity matching, validated against the Qobuz catalog.
|
||||
|
||||
- **Two view modes** — responsive grid or sidepanel with selected artist's discography
|
||||
- **A-Z grouping** — alphabetical jump navigation for large scenes
|
||||
- **Genre multi-select filter** — searchable popup when >9 genres; real-time filtering with count display
|
||||
- **Text search** — filter discovered artists by name
|
||||
- **Sticky compact header** — scene label, country flag, and genre summary appear on scroll
|
||||
- **Progressive loading** — animated phases (searching → validating → done) with paginated "Load More"
|
||||
|
||||
## Discovery Tabs — Redesigned Home
|
||||
|
||||
The Home view has been redesigned as a 3-tab discovery experience:
|
||||
|
||||
- **Home** — customizable feed with drag-to-reorder sections and per-section visibility toggles. Sections include New Releases, Press Awards, Popular Albums, Qobuzissimes, Editor's Picks, Qobuz Playlists, Your Mixes, Essential Discography, Recently Played, Continue Listening, Your Top Artists, and More From Favorites
|
||||
- **Editor's Picks** — fixed Qobuz editorial curation in a curated order
|
||||
- **For You** — personalized recommendations built from your listening history:
|
||||
- **Radio Stations** — 9 album-based radios with dominant color extraction per card
|
||||
- **Artists to Follow** — 10 suggestions from similar-to-your-top-3 artists
|
||||
- **Spotlight** — featured artist with top tracks, playlists, and "Create Radio" button
|
||||
- **Similar to [Album]**, **Rediscover Your Library**, and **Essentials [Genre]** sections
|
||||
- **Sticky compact header** — appears after 60px scroll with tab navigation and genre filter
|
||||
- **Session cache** — cached data and per-tab scroll position for instant back-navigation
|
||||
- **Time-based greeting** — Morning/Afternoon/Evening with username, can be disabled
|
||||
|
||||
---
|
||||
|
||||
## Audio Engine Upgrade — rodio 0.22
|
||||
|
||||
The vendored rodio/cpal fork has been removed entirely. QBZ now uses upstream rodio 0.22, which means faster updates, fewer maintenance headaches, and broader hardware compatibility.
|
||||
|
||||
- **ALSA sample rate fallback** — if your DAC doesn't support the requested rate, QBZ now falls back gracefully instead of failing silently
|
||||
- **PipeWire suspension** — PipeWire sink is suspended before ALSA Direct takes exclusive access, eliminating DeviceBusy errors on first play
|
||||
- **Gapless playback on ALSA Direct** — gapless is now available on all backends and defaults to ON
|
||||
- **Smart quality downgrade** — when hardware can't handle a sample rate, QBZ automatically selects the best compatible quality and shows a tooltip explaining why
|
||||
|
||||
## Booklet Viewer — MuPDF
|
||||
|
||||
- **PDF booklets** — albums with digital booklets now show a booklet button; opens a full in-app viewer
|
||||
- **MuPDF backend** — replaced pdfjs-dist with native MuPDF rendering for crisp, fast page display
|
||||
- **Download button** — save the booklet PDF directly from the viewer
|
||||
|
||||
## Neon Visualizers
|
||||
|
||||
Three new Canvas 2D visualizers under the Neon category in Immersive mode:
|
||||
|
||||
- **Laser** — neon laser beams that pulse and bend with the music
|
||||
- **Tunnel** — depth-tunnel effect with warped rings and flowing wisps
|
||||
- **Comet** — watercolor nebula with curved wisps, tunnel rings, and a central abyss; extracts colors from album artwork so each album has its own palette
|
||||
|
||||
All three respond dynamically to bass, mids, and highs. The Comet visualizer fades nearly to black during silence and breathes back to life when the music returns. Subtle corner vignettes using the darkest album art color protect track info text readability.
|
||||
|
||||
## Immersive Improvements
|
||||
|
||||
- **3-mode background** — Full (GPU shader), Lite (CSS blur), or Off; auto-degrades on sustained low FPS with a dismissible notification
|
||||
- **Per-panel FPS** — each visualizer panel has its own FPS setting; defaults to 15 FPS for battery life
|
||||
- **Configurable ambient FPS** — separate from panel FPS
|
||||
|
||||
## Artist Discovery — MusicBrainz Tags
|
||||
|
||||
- **Tag-based recommendations** — "You May Also Like" in the Artist Network sidebar now uses MusicBrainz genre tags to find related artists, breaking away from the typical "similar artist" bubble
|
||||
- **Tag-scoped thumbs down** — dismiss an artist from a specific genre context (e.g., AC/DC dismissed from "electronic" but still valid in "metal"); instant removal with reserve replacement
|
||||
- **Similarity percentage** — each recommended artist shows how closely they match based on shared tags
|
||||
|
||||
## Artist Network Sidebar
|
||||
|
||||
- **Open by default** — sidebar auto-opens when visiting an artist page; preference persisted
|
||||
- **State restoration** — closing the sidebar for Queue or Lyrics remembers the previous state and restores it when they close
|
||||
- **Clean transitions** — no more stale data flash when navigating between artists
|
||||
- **Simplified header** — "Artist Network" title with PanelRightClose icon; empty relationship sections are hidden
|
||||
|
||||
## Security Hardening
|
||||
|
||||
- Removed hardcoded key material from credential encryption
|
||||
- Cryptographically secure session ID generation
|
||||
- DOM-based HTML sanitization replacing regex
|
||||
- Sensitive identifiers redacted from backend logs
|
||||
- Hardened CI workflow permissions and input handling
|
||||
|
||||
## Label Releases View
|
||||
|
||||
- **Redesigned** — new layout with label logo, sorting (date/title/popularity), filters (album/EP/single/live), and search
|
||||
- **Group by artist** — toggle to organize releases by artist with collapsible sections
|
||||
- **Performance** — virtualized rendering for labels with hundreds of releases
|
||||
|
||||
## New Features
|
||||
|
||||
- **Scene Discovery** — explore artists by location and musical scene (see above)
|
||||
- **Discovery Tabs** — redesigned Home with 3-tab layout (see above)
|
||||
- **Explicit content badges** — shown across the entire app where applicable
|
||||
- **Search track context menu** — right-click on search result tracks now works
|
||||
- **Track/disc number extraction** — local library now reads track and disc numbers from file tags during import
|
||||
- **Spotify import via embed scraping** — removed API dependency; imports work without Spotify credentials
|
||||
- **Window size validation** — prevents GDK crashes from corrupt stored dimensions (by @arminfelder, PR #136)
|
||||
- **GPU detection** — AMD and Intel GPU identification for composition profile recommendations
|
||||
- **GSK renderer control** — choose between Cairo, GL, NGL, or Vulkan rendering backends
|
||||
- **Cancel download** — purchase album downloads can now be cancelled mid-progress
|
||||
- **Miniplayer shortcuts** — keyboard shortcuts for miniplayer controls
|
||||
|
||||
## Stability Fixes
|
||||
|
||||
- **Graceful shutdown** — eliminated heap corruption on exit caused by double-destroy of WebKit windows
|
||||
- **Window size clamping** — prevents oversized windows from crashing GDK on restore
|
||||
- **Secondary window teardown** — miniplayer and secondary windows close cleanly before app exit
|
||||
- **Flatpak tray icon** — correct icon path resolution inside the Flatpak sandbox
|
||||
- **Snap MPRIS** — declared as slot instead of plug for proper media control integration
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Equalizer animation no longer appears active when player is paused
|
||||
- Home settings changes apply immediately without page reload
|
||||
- Navigation scroll position is now per-item with 1-hour TTL
|
||||
- Session restore no longer recalls stale scroll positions
|
||||
- Download paths restructured to Artist/Album [FORMAT][Quality]/track
|
||||
- EPs and singles backfilled when artist page omits them
|
||||
- Audio settings sync on startup for OAuth login path
|
||||
- Output device name updates during playback via polling
|
||||
- Svelte a11y and component warnings resolved across 26+ components
|
||||
|
||||
---
|
||||
|
||||
Special thanks to **@arminfelder** for contributing window size validation (PR #136).
|
||||
|
||||
Full changelog: https://github.com/vicrodh/qbz/compare/v1.1.18...v1.1.19
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
description = "QBZ — Native hi-fi Qobuz desktop player for Linux";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# VERSION BUMP: update version, rev, and hashes
|
||||
# when tagging a new release.
|
||||
# ──────────────────────────────────────────────
|
||||
qbzVersion = "1.1.20";
|
||||
qbzRev = "v${qbzVersion}";
|
||||
srcHash = ""; # nix build will report the correct hash on first run
|
||||
npmHash = ""; # nix build will report the correct hash on first run
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage rec {
|
||||
pname = "qbz";
|
||||
version = qbzVersion;
|
||||
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "vicrodh";
|
||||
repo = "qbz";
|
||||
rev = qbzRev;
|
||||
hash = srcHash;
|
||||
};
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
buildAndTestSubdir = cargoRoot;
|
||||
|
||||
cargoLock = {
|
||||
lockFile = "${src}/src-tauri/Cargo.lock";
|
||||
};
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
name = "${pname}-${version}-npm-deps";
|
||||
inherit src;
|
||||
hash = npmHash;
|
||||
};
|
||||
|
||||
env.LIBCLANG_PATH = "${pkgs.lib.getLib pkgs.llvmPackages.libclang}/lib";
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
clang
|
||||
cargo-tauri.hook
|
||||
nodejs
|
||||
npmHooks.npmConfigHook
|
||||
pkg-config
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
alsa-lib
|
||||
openssl
|
||||
webkitgtk_4_1
|
||||
libappindicator-gtk3
|
||||
libayatana-appindicator
|
||||
];
|
||||
|
||||
checkFlags = [
|
||||
# These require a writable HOME and D-Bus keyring service
|
||||
"--skip=credentials::tests::test_credentials_roundtrip"
|
||||
"--skip=credentials::tests::test_encryption_roundtrip"
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/qbz \
|
||||
--prefix LD_LIBRARY_PATH : ${
|
||||
pkgs.lib.makeLibraryPath [
|
||||
pkgs.libappindicator
|
||||
pkgs.libappindicator-gtk3
|
||||
pkgs.libayatana-appindicator
|
||||
]
|
||||
}
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Native, full-featured hi-fi Qobuz desktop player for Linux";
|
||||
homepage = "https://qbz.lol";
|
||||
license = licenses.mit;
|
||||
mainProgram = "qbz";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
# Dev shell with all build dependencies
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${system}.default ];
|
||||
packages = with pkgs; [
|
||||
rust-analyzer
|
||||
rustfmt
|
||||
clippy
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ Type=Application
|
||||
Icon=com.blitzfc.qbz
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
Terminal=false
|
||||
StartupWMClass=QBZ
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;
|
||||
StartupWMClass=com.blitzfc.qbz
|
||||
StartupNotify=true
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac;
|
||||
MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp;
|
||||
@@ -2,43 +2,37 @@
|
||||
<component type="desktop-application">
|
||||
<id>com.blitzfc.qbz</id>
|
||||
<name>QBZ</name>
|
||||
<summary>Native Qobuz client with hi-res audio support</summary>
|
||||
<summary>Stream hi-res music from Qobuz with bit-perfect playback</summary>
|
||||
<description>
|
||||
<p>
|
||||
QBZ is a native Qobuz streaming client for Linux built for audiophiles who want reliable, bit-perfect playback without browser sample-rate limitations.
|
||||
QBZ is a native Qobuz client for Linux focused on reliable hi-res playback and desktop integration.
|
||||
</p>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>Bit-perfect playback up to 24-bit/192kHz</li>
|
||||
<li>Native Linux audio backends (ALSA, PulseAudio, PipeWire)</li>
|
||||
<li>PipeWire and PulseAudio audio output</li>
|
||||
<li>DAC routing and exclusive mode support</li>
|
||||
<li>Offline playlist support</li>
|
||||
<li>Local music library with offline playback</li>
|
||||
<li>Immersive and miniplayer display modes</li>
|
||||
<li>Artist blacklist for personalized listening</li>
|
||||
<li>MPRIS integration for desktop media controls</li>
|
||||
<li>Last.fm scrobbling support</li>
|
||||
<li>Last.fm and ListenBrainz scrobbling</li>
|
||||
<li>Plex library integration</li>
|
||||
<li>Direct streaming or smart cache playback</li>
|
||||
<li>Highly customizable interface and behavior</li>
|
||||
</ul>
|
||||
</description>
|
||||
<developer id="com.blitzfc">
|
||||
<name>QBZ Contributors</name>
|
||||
</developer>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<url type="homepage">https://github.com/vicrodh/qbz</url>
|
||||
<url type="bugtracker">https://github.com/vicrodh/qbz/issues</url>
|
||||
<url type="vcs-browser">https://github.com/vicrodh/qbz</url>
|
||||
<categories>
|
||||
<category>AudioVideo</category>
|
||||
<category>Audio</category>
|
||||
<category>Music</category>
|
||||
<category>Player</category>
|
||||
</categories>
|
||||
<keywords>
|
||||
<keyword>music</keyword>
|
||||
<keyword>audio</keyword>
|
||||
<keyword>qobuz</keyword>
|
||||
<keyword>hifi</keyword>
|
||||
<keyword>streaming</keyword>
|
||||
<keyword>audiophile</keyword>
|
||||
</keywords>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#2b6cb0</color>
|
||||
<color type="primary" scheme_preference="dark">#63b3ed</color>
|
||||
</branding>
|
||||
<launchable type="desktop-id">com.blitzfc.qbz.desktop</launchable>
|
||||
<provides>
|
||||
<binary>qbz</binary>
|
||||
@@ -46,37 +40,47 @@
|
||||
<content_rating type="oars-1.1" />
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Home and discovery view</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/v1.1.13/flatpak/screenshots/home-discover.png</image>
|
||||
<caption xml:lang="en">Home and discovery view</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5da726719b983770c7f4c2ff52bb51e14d/packaging/flatpak/screenshots/home-discover.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Immersive cover flow mode</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/v1.1.13/flatpak/screenshots/immersive-cover-flow.png</image>
|
||||
<caption xml:lang="en">Immersive cover flow mode</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5da726719b983770c7f4c2ff52bb51e14d/packaging/flatpak/screenshots/immersive-cover-flow.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Local library view</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/v1.1.13/flatpak/screenshots/local-library.png</image>
|
||||
<caption xml:lang="en">Local library view</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5da726719b983770c7f4c2ff52bb51e14d/packaging/flatpak/screenshots/local-library.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="1.1.13" date="2026-02-12">
|
||||
<release version="1.1.18" date="2026-02-26">
|
||||
<description>
|
||||
<p>Flathub submission release with upstream metadata files.</p>
|
||||
<p>Miniplayer window with compact and micro surfaces. OAuth browser login for new Qobuz auth flow. Window size persistence, HiDPI scaling fix, and artist image context menus.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.12" date="2026-02-12">
|
||||
<release version="1.1.16" date="2026-02-23">
|
||||
<description>
|
||||
<p>Bug fix release addressing graphics rendering regressions and performance improvements.</p>
|
||||
<p>OAuth browser authentication, window size persistence, HiDPI scaling fix for all backends, and login flow improvements.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.10" date="2026-02-10">
|
||||
<release version="1.1.15" date="2026-02-23">
|
||||
<description>
|
||||
<p>Multi-select tracks across all views with bulk queue and playlist actions. Scroll position now scoped per item and expires after one hour.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.11" date="2026-02-10">
|
||||
<description>
|
||||
<p>Discover browse pages for albums and playlists, localized playlist tags, preserved Qobuz artwork on playlist copy, and UI fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.9" date="2026-02-07">
|
||||
<description>
|
||||
<p>Maintenance release with Home performance improvements, playback auto-resume after settings changes, and playlist import fixes.</p>
|
||||
<p>Maintenance release: faster Home view, playback auto-resume after settings change, playlist import improvements, and bug fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.8" date="2026-01-25">
|
||||
<description>
|
||||
<p>Major release: genre filtering, playlist suggestions, immersive player, DAC wizard, key bindings, artist blacklist.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qbz-nix",
|
||||
"version": "1.1.15-rc-2",
|
||||
"version": "1.1.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qbz-nix",
|
||||
"version": "1.1.15-rc-2",
|
||||
"version": "1.1.20",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"svelte": "^5.53.0",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
@@ -780,9 +780,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.2.tgz",
|
||||
"integrity": "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -794,9 +794,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-eXBg7ibkNUZ+sTwbFiDKou0BAckeV6kIigK7y5Ko4mB/5A1KLhuzEKovsmfvsL8mQorkoincMFGnQuIT92SKqA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -808,9 +808,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-UCbaTklREjrc5U47ypLulAgg4njaqfOVLU18VrCrI+6E5MQjuG0lSWaqLlAJwsD7NpFV249XgB0Bi37Zh5Sz4g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -822,9 +822,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-dP67MA0cCMHFT2g5XyjtpVOtp7y4UyUxN3dhLdt11at5cPKnSm4lY+EhwNvDXIMzAMIo2KU+mc9wxaAQJTn7sQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -836,9 +836,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-WDUPLUwfYV9G1yxNRJdXcvISW15mpvod1Wv3ok+Ws93w1HjIVmCIFxsG2DquO+3usMNCpJQ0wqO+3GhFdl6Fow==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -850,9 +850,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-Ng95wtHVEulRwn7R0tMrlUuiLVL/HXA8Lt/MYVpy88+s5ikpntzZba1qEulTuPnPIZuOPcW9wNEiqvZxZmgmqQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -864,9 +864,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.2.tgz",
|
||||
"integrity": "sha512-AEXMESUDWWGqD6LwO/HkqCZgUE1VCJ1OhbvYGsfqX2Y6w5quSXuyoy/Fg3nRqiwro+cJYFxiw5v4kB2ZDLhxrw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -878,9 +878,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.2.tgz",
|
||||
"integrity": "sha512-ZV7EljjBDwBBBSv570VWj0hiNTdHt9uGznDtznBB4Caj3ch5rgD4I2K1GQrtbvJ/QiB+663lLgOdcADMNVC29Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -892,9 +892,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-uvjwc8NtQVPAJtq4Tt7Q49FOodjfbf6NpqXyW/rjXoV+iZ3EJAHLNAnKT5UJBc6ffQVgmXTUL2ifYiLABlGFqA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -906,9 +906,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-s3KoWVNnye9mm/2WpOZ3JeUiediUVw6AvY/H7jNA6qgKA2V2aM25lMkVarTDfiicn/DLq3O0a81jncXszoyCFA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -920,9 +920,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-gi21faacK+J8aVSyAUptML9VQN26JRxe484IbF+h3hpG+sNVoMXPduhREz2CcYr5my0NE3MjVvQ5bMKX71pfVA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -934,9 +934,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-qSlWiXnVaS/ceqXNfnoFZh4IiCA0EwvCivivTGbEu1qv2o+WTHpn1zNmCTAoOG5QaVr2/yhCoLScQtc/7RxshA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -948,9 +948,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-rPyuLFNoF1B0+wolH277E780NUKf+KoEDb3OyoLbAO18BbeKi++YN6gC/zuJoPPDlQRL3fIxHxCxVEWiem2yXw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -962,9 +962,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-g+0ZLMook31iWV4PvqKU0i9E78gaZgYpSrYPed/4Bu+nGTgfOPtfs1h11tSSRPXSjC5EzLTjV/1A7L2Vr8pJoQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -976,9 +976,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-i+sGeRGsjKZcQRh3BRfpLsM3LX3bi4AoEVqmGDyc50L6KfYsN45wVCSz70iQMwPWr3E5opSiLOwsC9WB4/1pqg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -990,9 +990,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-C1vLcKc4MfFV6I0aWsC7B2Y9QcsiEcvKkfxprwkPfLaN8hQf0/fKHwSF2lcYzA9g4imqnhic729VB9Fo70HO3Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1004,9 +1004,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-68gHUK/howpQjh7g7hlD9DvTTt4sNLp1Bb+Yzw2Ki0xvscm2cOdCLZNJNhd2jW8lsTPrHAHuF751BygifW4bkQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1018,9 +1018,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-1e30XAuaBP1MAizaOBApsgeGZge2/Byd6wV4a8oa6jPdHELbRHBiw7wvo4dp7Ie2PE8TZT4pj9RLGZv9N4qwlw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1032,9 +1032,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.2.tgz",
|
||||
"integrity": "sha512-4BJucJBGbuGnH6q7kpPqGJGzZnYrpAzRd60HQSt3OpX/6/YVgSsJnNzR8Ot74io50SeVT4CtCWe/RYIAymFPwA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1046,9 +1046,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.2.tgz",
|
||||
"integrity": "sha512-cT2MmXySMo58ENv8p6/O6wI/h/gLnD3D6JoajwXFZH6X9jz4hARqUhWpGuQhOgLNXscfZYRQMJvZDtWNzMAIDw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1060,9 +1060,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.2.tgz",
|
||||
"integrity": "sha512-sZnyUgGkuzIXaK3jNMPmUIyJrxu/PjmATQrocpGA1WbCPX8H5tfGgRSuYtqBYAvLuIGp8SPRb1O4d1Fkb5fXaQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1074,9 +1074,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-sDpFbenhmWjNcEbBcoTV0PWvW5rPJFvu+P7XoTY0YLGRupgLbFY0XPfwIbJOObzO7QgkRDANh65RjhPmgSaAjQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1088,9 +1088,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-GvJ03TqqaweWCigtKQVBErw2bEhu1tyfNQbarwr94wCGnczA9HF8wqEe3U/Lfu6EdeNP0p6R+APeHVwEqVxpUQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1102,9 +1102,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.2.tgz",
|
||||
"integrity": "sha512-KvXsBvp13oZz9JGe5NYS7FNizLe99Ny+W8ETsuCyjXiKdiGrcz2/J/N8qxZ/RSwivqjQguug07NLHqrIHrqfYw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1116,9 +1116,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.2.tgz",
|
||||
"integrity": "sha512-xNO+fksQhsAckRtDSPWaMeT1uIM+JrDRXlerpnWNXhn1TdB3YZ6uKBMBTKP0eX9XtYEP978hHk1f8332i2AW8Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1238,9 +1238,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -1248,9 +1248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
|
||||
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -1264,23 +1264,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.9.6",
|
||||
"@tauri-apps/cli-darwin-x64": "2.9.6",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
|
||||
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1295,9 +1295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
|
||||
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1312,9 +1312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
|
||||
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1329,9 +1329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
|
||||
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1346,9 +1346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
|
||||
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1363,9 +1363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
|
||||
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1380,9 +1380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
|
||||
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1397,9 +1397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
|
||||
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1414,9 +1414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
|
||||
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1431,9 +1431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
|
||||
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1448,9 +1448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
|
||||
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1972,9 +1972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
|
||||
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
@@ -2606,9 +2606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||
"integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2622,31 +2622,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.2",
|
||||
"@rollup/rollup-android-arm64": "4.55.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.2",
|
||||
"@rollup/rollup-darwin-x64": "4.55.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -2729,9 +2729,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.0.tgz",
|
||||
"integrity": "sha512-7dhHkSamGS2vtoBmIW2hRab+gl5Z60alEHZB4910ePqqJNxAWnDAxsofVmlZ2tREmWyHNE+A1nCKwICAquoD2A==",
|
||||
"version": "5.53.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz",
|
||||
"integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -2740,7 +2740,7 @@
|
||||
"@types/estree": "^1.0.5",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"acorn": "^8.12.1",
|
||||
"aria-query": "^5.3.1",
|
||||
"aria-query": "5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.6.3",
|
||||
@@ -2804,9 +2804,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte/node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"name": "qbz-nix",
|
||||
"version": "1.1.15-rc-2",
|
||||
"version": "1.1.20",
|
||||
"description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "svelte-kit sync && vite dev --force",
|
||||
"dev": "svelte-kit sync && vite dev",
|
||||
"dev:clean-runtime": "bash ./scripts/clean-dev-runtime.sh",
|
||||
"dev:ghost-doctor": "bash ./scripts/dev-ghost-doctor.sh",
|
||||
"dev:ghost-reset": "bash ./scripts/dev-ghost-doctor.sh --no-start",
|
||||
"dev:ultra-restore": "bash ./scripts/dev-ultra-restore.sh",
|
||||
"dev:ultra-reset": "bash ./scripts/dev-ultra-restore.sh --no-start",
|
||||
"dev:ultra-full-reset": "bash ./scripts/dev-ultra-restore.sh --full --no-start",
|
||||
"dev:reset-last-view": "bash ./scripts/dev-reset-last-view.sh",
|
||||
"dev:tauri": "./scripts/dev-with-env.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
@@ -34,7 +40,7 @@
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"svelte": "^5.53.0",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
|
||||
@@ -35,7 +35,7 @@ build() {
|
||||
cd "${srcdir}/qbz-${pkgver}"
|
||||
npm ci
|
||||
npm run build
|
||||
npm run tauri build -- --bundles none
|
||||
npx tauri build --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
|
||||
@@ -6,6 +6,7 @@ Type=Application
|
||||
Icon=qbz
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
Terminal=false
|
||||
StartupWMClass=QBZ
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;
|
||||
StartupWMClass=com.blitzfc.qbz
|
||||
StartupNotify=true
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac;
|
||||
MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp;
|
||||
@@ -1,4 +1,4 @@
|
||||
# Maintainer: blitzkriegfc <blitzkriegfc@proton.me>
|
||||
# Maintainer: blitzkriegfc <blitzkriegfc@gmail.com>
|
||||
pkgname=qbz-bin
|
||||
pkgver=1.1.11
|
||||
pkgrel=1
|
||||
|
||||
@@ -6,6 +6,7 @@ Type=Application
|
||||
Icon=qbz
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
Terminal=false
|
||||
StartupWMClass=QBZ
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;
|
||||
StartupWMClass=com.blitzfc.qbz
|
||||
StartupNotify=true
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac;
|
||||
MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp;
|
||||
@@ -6,6 +6,7 @@ Type=Application
|
||||
Icon=com.blitzfc.qbz
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
Terminal=false
|
||||
StartupWMClass=QBZ
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;
|
||||
StartupWMClass=com.blitzfc.qbz
|
||||
StartupNotify=true
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac;
|
||||
MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp;
|
||||
@@ -2,7 +2,7 @@
|
||||
<component type="desktop-application">
|
||||
<id>com.blitzfc.qbz</id>
|
||||
<name>QBZ</name>
|
||||
<summary>Stream hi-res music from Qobuz</summary>
|
||||
<summary>Hi-fi music player for Qobuz</summary>
|
||||
<description>
|
||||
<p>
|
||||
QBZ is a native Qobuz client for Linux focused on reliable hi-res playback and desktop integration.
|
||||
@@ -11,12 +11,19 @@
|
||||
<li>Bit-perfect playback up to 24-bit/192kHz</li>
|
||||
<li>PipeWire and PulseAudio audio output</li>
|
||||
<li>DAC routing and exclusive mode support</li>
|
||||
<li>Offline playlist support</li>
|
||||
<li>Local music library with offline playback</li>
|
||||
<li>Immersive and miniplayer display modes</li>
|
||||
<li>Artist blacklist for personalized listening</li>
|
||||
<li>MPRIS integration for desktop media controls</li>
|
||||
<li>Last.fm scrobbling support</li>
|
||||
<li>Last.fm and ListenBrainz scrobbling</li>
|
||||
<li>Plex library integration</li>
|
||||
<li>Direct streaming or smart cache playback</li>
|
||||
<li>Highly customizable interface and behavior</li>
|
||||
</ul>
|
||||
</description>
|
||||
<developer_name>QBZ Contributors</developer_name>
|
||||
<developer id="com.blitzfc">
|
||||
<name>QBZ Contributors</name>
|
||||
</developer>
|
||||
<url type="homepage">https://github.com/vicrodh/qbz</url>
|
||||
<url type="bugtracker">https://github.com/vicrodh/qbz/issues</url>
|
||||
<url type="vcs-browser">https://github.com/vicrodh/qbz</url>
|
||||
@@ -26,20 +33,6 @@
|
||||
<color type="primary" scheme_preference="light">#2b6cb0</color>
|
||||
<color type="primary" scheme_preference="dark">#63b3ed</color>
|
||||
</branding>
|
||||
<categories>
|
||||
<category>AudioVideo</category>
|
||||
<category>Audio</category>
|
||||
<category>Music</category>
|
||||
<category>Player</category>
|
||||
</categories>
|
||||
<keywords>
|
||||
<keyword>music</keyword>
|
||||
<keyword>audio</keyword>
|
||||
<keyword>qobuz</keyword>
|
||||
<keyword>hifi</keyword>
|
||||
<keyword>streaming</keyword>
|
||||
<keyword>audiophile</keyword>
|
||||
</keywords>
|
||||
<launchable type="desktop-id">com.blitzfc.qbz.desktop</launchable>
|
||||
<provides>
|
||||
<binary>qbz</binary>
|
||||
@@ -48,18 +41,43 @@
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption xml:lang="en">Home and discovery view</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5d47f3b44568af93679e17f07d2b836999/packaging/flatpak/screenshots/home-discover.png</image>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5da726719b983770c7f4c2ff52bb51e14d/packaging/flatpak/screenshots/home-discover.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption xml:lang="en">Immersive cover flow mode</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5d47f3b44568af93679e17f07d2b836999/packaging/flatpak/screenshots/immersive-cover-flow.png</image>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5da726719b983770c7f4c2ff52bb51e14d/packaging/flatpak/screenshots/immersive-cover-flow.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption xml:lang="en">Local library view</caption>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5d47f3b44568af93679e17f07d2b836999/packaging/flatpak/screenshots/local-library.png</image>
|
||||
<image>https://raw.githubusercontent.com/vicrodh/qbz/cff32e5da726719b983770c7f4c2ff52bb51e14d/packaging/flatpak/screenshots/local-library.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="1.1.20" date="2026-03-11">
|
||||
<description>
|
||||
<p>Fix startup crash on Debian sid and Fedora (missing libjpeg). Fix partial cover art in AppImage. Fix track number display for compilations and box sets. Fix PipeWire bit-perfect stream match rule in Hi-Fi Wizard. Security update for quinn-proto. Test fixes for cross-distro builds.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.19" date="2026-03-09">
|
||||
<description>
|
||||
<p>Scene Discovery: explore artists from the same city or musical scene. Redesigned Home with 3 Discovery Tabs (Home, Editor's Picks, For You) with personalized radio stations, spotlight artist, and genre filtering. Neon visualizers (Laser, Tunnel, Comet) with album art color extraction. In-app PDF booklet viewer. Audio engine upgrade to rodio 0.22 with ALSA gapless playback. Security hardening and stability fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.18" date="2026-02-26">
|
||||
<description>
|
||||
<p>Miniplayer window with compact and micro surfaces. OAuth browser login for new Qobuz auth flow. Window size persistence, HiDPI scaling fix, and artist image context menus.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.16" date="2026-02-23">
|
||||
<description>
|
||||
<p>OAuth browser authentication, window size persistence, HiDPI scaling fix for all backends, and login flow improvements.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.15" date="2026-02-23">
|
||||
<description>
|
||||
<p>Multi-select tracks across all views with bulk queue and playlist actions. Scroll position now scoped per item and expires after one hour.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.1.11" date="2026-02-10">
|
||||
<description>
|
||||
<p>Discover browse pages for albums and playlists, localized playlist tags, preserved Qobuz artwork on playlist copy, and UI fixes.</p>
|
||||
|
||||
@@ -5,43 +5,31 @@ sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.node20
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
- org.freedesktop.Sdk.Extension.llvm20
|
||||
command: qbz
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --share=network
|
||||
- --socket=x11
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --socket=pulseaudio
|
||||
- --device=dri
|
||||
|
||||
# Standard user directories
|
||||
- --filesystem=xdg-download
|
||||
# Native PipeWire access
|
||||
- --filesystem=xdg-run/pipewire-0:ro
|
||||
|
||||
# User music library
|
||||
- --filesystem=xdg-music
|
||||
- --filesystem=xdg-cache
|
||||
- --filesystem=xdg-config
|
||||
|
||||
# External drives and network mounts (NAS support)
|
||||
- --filesystem=/mnt
|
||||
- --filesystem=/media
|
||||
- --filesystem=/run/media
|
||||
|
||||
# Migration: Read-only access to old App ID data for one-time migration
|
||||
# This allows copying user data from com.blitzkriegfc.qbz to com.blitzfc.qbz
|
||||
# Can be removed in a future release after users have migrated
|
||||
- --filesystem=~/.var/app/com.blitzkriegfc.qbz:ro
|
||||
|
||||
# Required for tray icon to be visible (Tauri writes icon to temp location)
|
||||
# Required for tray icon visibility in Flatpak sandbox
|
||||
- --filesystem=xdg-run/tray-icon:create
|
||||
|
||||
# Portals and notifications
|
||||
# Notifications and tray
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --talk-name=org.freedesktop.portal.FileChooser
|
||||
- --talk-name=org.mpris.MediaPlayer2.*
|
||||
|
||||
# OAuth browser authentication
|
||||
- --talk-name=org.freedesktop.portal.OpenURI
|
||||
# MPRIS media controls (own-name auto-granted: name contains appid)
|
||||
- --talk-name=org.mpris.MediaPlayer2.*
|
||||
|
||||
modules:
|
||||
# intltool required for libdbusmenu
|
||||
@@ -147,11 +135,12 @@ modules:
|
||||
- name: qbz
|
||||
buildsystem: simple
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/node20/bin:/usr/lib/sdk/rust-stable/bin
|
||||
append-path: /usr/lib/sdk/node20/bin:/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin
|
||||
build-args:
|
||||
- --share=network
|
||||
env:
|
||||
npm_config_nodedir: /usr/lib/sdk/node20
|
||||
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
|
||||
build-commands:
|
||||
- npm ci
|
||||
- export PATH="$PWD/node_modules/.bin:$PATH" && npm run build
|
||||
|
||||
|
After Width: | Height: | Size: 298 KiB |
@@ -6,5 +6,7 @@ Icon=${SNAP}/meta/gui/qbz.png
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Music;Player;
|
||||
Terminal=false
|
||||
StartupWMClass=QBZ
|
||||
StartupWMClass=com.blitzfc.qbz
|
||||
StartupNotify=true
|
||||
Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac;
|
||||
MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp;
|
||||
@@ -20,20 +20,21 @@ check_pattern() {
|
||||
}
|
||||
|
||||
# Template loop variable shadowing.
|
||||
check_pattern "template each-loop shadowing (as t)" '\{#each[^\n]* as t\b'
|
||||
check_pattern "template each-loop shadowing (as t)" '\{#each[^\n]*\bas t\b'
|
||||
|
||||
# Dangerous callbacks in files using $t(...).
|
||||
while IFS= read -r file; do
|
||||
if rg -q '\b(map|forEach|filter|find|some|every|reduce)\(\s*t\s*=>|\b(map|forEach|filter|find|some|every|reduce)\(\s*\([^)]*,\s*t\s*\)\s*=>' "${file}"; then
|
||||
echo
|
||||
echo "Found callback shadowing in file using \$t(...): ${file}"
|
||||
rg -n '\b(map|forEach|filter|find|some|every|reduce)\(\s*t\s*=>|\b(map|forEach|filter|find|some|every|reduce)\(\s*\([^)]*,\s*t\s*\)\s*=>' "${file}"
|
||||
had_issues=1
|
||||
fi
|
||||
done < <(rg -l --glob '*.svelte' '\$t\(' "${SRC_DIR}")
|
||||
# Plain variable/function-loop names using `t`.
|
||||
check_pattern "variable declaration named t" '\b(const|let|var)\s+t\b'
|
||||
check_pattern "for-loop variable named t" 'for\s*\(\s*(const|let|var)\s+t\b'
|
||||
|
||||
# Any $derived expression using variable name t.
|
||||
check_pattern "usage of t inside \$derived" '\$derived\([^)]*\bt\b'
|
||||
# Callback parameters named `t` (single or parenthesized).
|
||||
check_pattern "arrow callback parameter named t" '\bt\s*=>|\(\s*t(?:\s*:[^)]*)?\s*\)\s*=>|\(\s*[^,)]*,\s*t(?:\s*:[^)]*)?\s*\)\s*=>'
|
||||
|
||||
# get(t) is fragile in Svelte files that import i18n store `t`.
|
||||
check_pattern "get(t) usage in .svelte files" '\bget\s*\(\s*t\s*\)'
|
||||
|
||||
# Lightweight same-line checks for $derived expressions.
|
||||
check_pattern "store translation access inside \$derived (same line)" '\$derived(?:\.by)?\([^)]*(\$t\(|get\(t\))'
|
||||
check_pattern "usage of t inside \$derived (same line)" '\$derived(?:\.by)?\([^)]*\bt\b'
|
||||
|
||||
|
||||
if [[ "${had_issues}" -ne 0 ]]; then
|
||||
|
||||
@@ -4,9 +4,28 @@ set -euo pipefail
|
||||
# Kill stale dev processes that commonly leave Vite/Svelte CSS pipeline in a bad state.
|
||||
pkill -f '[n]ode .*vite dev' || true
|
||||
pkill -f '[n]ode .*tauri dev' || true
|
||||
pkill -f '[c]argo run' || true
|
||||
pkill -f '[t]arget/debug/qbz' || true
|
||||
pkill -f '[t]arget/debug/qbz-nix' || true
|
||||
|
||||
# Clean generated frontend caches.
|
||||
rm -rf .svelte-kit .vite node_modules/.vite
|
||||
# Force-free dev ports in case a process survives pattern-based kill.
|
||||
for port in 1420 1421; do
|
||||
pids="$(lsof -ti :"${port}" 2>/dev/null || true)"
|
||||
if [[ -n "${pids}" ]]; then
|
||||
kill ${pids} 2>/dev/null || true
|
||||
sleep 0.2
|
||||
# Escalate only if still alive after TERM.
|
||||
still_alive="$(lsof -ti :"${port}" 2>/dev/null || true)"
|
||||
if [[ -n "${still_alive}" ]]; then
|
||||
kill -9 ${still_alive} 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[qbz] Dev runtime cleaned (.svelte-kit/.vite + stale vite/tauri/qbz processes)."
|
||||
# Clean generated frontend caches.
|
||||
rm -rf .svelte-kit .vite node_modules/.vite build dist .cache
|
||||
|
||||
echo "[qbz] Dev runtime cleaned (processes + ports 1420/1421 + cache directories)."
|
||||
|
||||
# Run guardrail check so runtime clean does not hide code-level ghost triggers.
|
||||
bash ./scripts/check-no-t-shadow.sh
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
NO_START=false
|
||||
if [[ "${1:-}" == "--no-start" ]]; then
|
||||
NO_START=true
|
||||
fi
|
||||
|
||||
echo "[qbz] Ghost doctor: checking for t-shadow and fragile i18n patterns..."
|
||||
bash ./scripts/check-no-t-shadow.sh
|
||||
|
||||
echo "[qbz] Ghost doctor: cleaning runtime state..."
|
||||
bash ./scripts/clean-dev-runtime.sh
|
||||
|
||||
if [[ "${NO_START}" == "true" ]]; then
|
||||
echo "[qbz] Ghost doctor complete (no-start mode)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[qbz] Ghost doctor: starting tauri dev in foreground..."
|
||||
exec npm run tauri dev
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GLOBAL_DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/qbz"
|
||||
LAST_USER_ID_FILE="${GLOBAL_DATA_DIR}/last_user_id"
|
||||
|
||||
USER_ID="${1:-}"
|
||||
if [[ -z "${USER_ID}" ]]; then
|
||||
if [[ ! -f "${LAST_USER_ID_FILE}" ]]; then
|
||||
echo "[qbz] No last_user_id marker found at ${LAST_USER_ID_FILE}"
|
||||
echo "[qbz] Pass a user id explicitly: bash ./scripts/dev-reset-last-view.sh <user_id>"
|
||||
exit 1
|
||||
fi
|
||||
USER_ID="$(tr -d '[:space:]' < "${LAST_USER_ID_FILE}")"
|
||||
fi
|
||||
|
||||
if [[ -z "${USER_ID}" ]]; then
|
||||
echo "[qbz] Could not determine the user id to reset."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_PATH="${GLOBAL_DATA_DIR}/users/${USER_ID}/session.db"
|
||||
if [[ ! -f "${DB_PATH}" ]]; then
|
||||
echo "[qbz] Session database not found: ${DB_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[qbz] Resetting persisted last_view for user ${USER_ID}"
|
||||
echo "[qbz] Database: ${DB_PATH}"
|
||||
|
||||
before_state="$(sqlite3 "${DB_PATH}" "SELECT last_view || '|' || COALESCE(view_context_id, '') || '|' || COALESCE(view_context_type, '') FROM player_state WHERE id = 1;")"
|
||||
echo "[qbz] Before: ${before_state:-<missing>}"
|
||||
|
||||
sqlite3 "${DB_PATH}" <<'SQL'
|
||||
UPDATE player_state
|
||||
SET last_view = 'home',
|
||||
view_context_id = NULL,
|
||||
view_context_type = NULL
|
||||
WHERE id = 1;
|
||||
SQL
|
||||
|
||||
after_state="$(sqlite3 "${DB_PATH}" "SELECT last_view || '|' || COALESCE(view_context_id, '') || '|' || COALESCE(view_context_type, '') FROM player_state WHERE id = 1;")"
|
||||
echo "[qbz] After: ${after_state:-<missing>}"
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
FULL=false
|
||||
NO_START=false
|
||||
RUN_CHECK=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--full|-f)
|
||||
FULL=true
|
||||
;;
|
||||
--no-start)
|
||||
NO_START=true
|
||||
;;
|
||||
--check)
|
||||
RUN_CHECK=true
|
||||
;;
|
||||
*)
|
||||
echo "[qbz] Unknown option: ${arg}"
|
||||
echo "Usage: bash ./scripts/dev-ultra-restore.sh [--full|-f] [--no-start] [--check]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "[qbz] Ultra restore: stopping dev processes..."
|
||||
pkill -f '[n]ode .*vite dev' || true
|
||||
pkill -f '[n]ode .*tauri dev' || true
|
||||
pkill -f '[c]argo run' || true
|
||||
pkill -f '[t]arget/debug/qbz' || true
|
||||
pkill -f '[t]arget/debug/qbz-nix' || true
|
||||
|
||||
echo "[qbz] Ultra restore: freeing dev ports..."
|
||||
for port in 1420 1421; do
|
||||
pids="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${pids}" ]]; then
|
||||
kill ${pids} 2>/dev/null || true
|
||||
sleep 0.5
|
||||
still_alive="$(lsof -ti :"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
if [[ -n "${still_alive}" ]]; then
|
||||
kill -9 ${still_alive} 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[qbz] Ultra restore: clearing frontend caches..."
|
||||
rm -rf \
|
||||
.svelte-kit \
|
||||
.vite \
|
||||
build \
|
||||
dist \
|
||||
.cache \
|
||||
node_modules/.vite \
|
||||
node_modules/.vite_temp \
|
||||
node_modules/.svelte \
|
||||
node_modules/.cache \
|
||||
node_modules/.postcss \
|
||||
tsconfig.tsbuildinfo \
|
||||
.eslintcache
|
||||
|
||||
echo "[qbz] Ultra restore: clearing temp vite directories..."
|
||||
rm -rf /tmp/vite-* 2>/dev/null || true
|
||||
|
||||
if [[ "${FULL}" == "true" ]]; then
|
||||
echo "[qbz] Ultra restore: full mode enabled (Rust/Tauri clean)..."
|
||||
rm -rf src-tauri/target/debug/.fingerprint src-tauri/target/debug/incremental
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
cargo clean --manifest-path src-tauri/Cargo.toml || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[qbz] Ultra restore: running ghost guard checks..."
|
||||
bash ./scripts/check-no-t-shadow.sh
|
||||
|
||||
if [[ "${RUN_CHECK}" == "true" ]]; then
|
||||
echo "[qbz] Ultra restore: running svelte-check..."
|
||||
npm run -s check
|
||||
fi
|
||||
|
||||
if [[ "${NO_START}" == "true" ]]; then
|
||||
echo "[qbz] Ultra restore complete (no-start mode)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[qbz] Ultra restore: starting tauri dev in foreground..."
|
||||
exec npm run tauri dev
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
name: qbz-player
|
||||
title: "QBZ — Native Qobuz Client for Linux"
|
||||
version: '1.1.15-rc-2'
|
||||
version: '1.1.20'
|
||||
summary: Native Qobuz player for Linux with bit-perfect audio and DAC control
|
||||
description: |
|
||||
QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback.
|
||||
@@ -79,15 +79,21 @@ apps:
|
||||
- x11
|
||||
- unity7
|
||||
- gsettings
|
||||
- pulseaudio
|
||||
- alsa
|
||||
- pipewire
|
||||
slots:
|
||||
- mpris-player
|
||||
|
||||
slots:
|
||||
mpris-player:
|
||||
interface: mpris
|
||||
name: qbz
|
||||
|
||||
parts:
|
||||
qbz:
|
||||
plugin: rust
|
||||
source: https://github.com/vicrodh/qbz.git
|
||||
source-tag: v1.1.15-rc-2
|
||||
source-tag: v1.1.20
|
||||
build-packages:
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libgtk-3-dev
|
||||
@@ -151,7 +157,7 @@ parts:
|
||||
|
||||
# Install binary, desktop file, and icon
|
||||
mkdir -p $SNAPCRAFT_PART_INSTALL/bin
|
||||
cp src-tauri/target/release/qbz-nix $SNAPCRAFT_PART_INSTALL/bin/qbz
|
||||
cp src-tauri/target/release/qbz $SNAPCRAFT_PART_INSTALL/bin/qbz
|
||||
mkdir -p $SNAPCRAFT_PART_INSTALL/packaging/snap/snap/gui
|
||||
cp packaging/snap/snap/gui/qbz.desktop $SNAPCRAFT_PART_INSTALL/packaging/snap/snap/gui/
|
||||
cp packaging/snap/snap/gui/qbz.png $SNAPCRAFT_PART_INSTALL/packaging/snap/snap/gui/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "qbz-nix"
|
||||
version = "1.1.15-rc-2"
|
||||
version = "1.1.20"
|
||||
description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)"
|
||||
authors = ["blitzkriegfc"]
|
||||
edition = "2021"
|
||||
@@ -40,7 +40,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP Client
|
||||
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream", "blocking", "multipart"], default-features = false }
|
||||
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "native-tls", "stream", "blocking", "multipart"], default-features = false }
|
||||
|
||||
# Async Runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -80,7 +80,7 @@ env_logger = "0.11"
|
||||
rand = "0.8"
|
||||
|
||||
# Audio playback
|
||||
rodio = { version = "0.19", features = ["symphonia-all"] }
|
||||
rodio = { version = "0.22", features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
|
||||
# Audio visualization (FFT)
|
||||
@@ -92,12 +92,23 @@ ebur128 = "0.1"
|
||||
# Media controls (MPRIS on Linux)
|
||||
souvlaki = "0.7"
|
||||
|
||||
# Desktop notifications
|
||||
notify-rust = "4"
|
||||
# Desktop notifications → ashpd in [target.linux] deps below
|
||||
|
||||
# Image processing (tray icon + thumbnails)
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
# PDF rendering with MuPDF engine (high-quality rasterization for booklet viewer)
|
||||
# NOTE: Use sys-lib-* for zlib/harfbuzz/freetype to avoid symbol conflicts with
|
||||
# GTK/GdkPixbuf. But NOT libjpeg — Ubuntu ships libjpeg8 (libjpeg.so.8) while
|
||||
# Debian/Fedora ship libjpeg62-turbo (libjpeg.so.62). Let mupdf vendor its own
|
||||
# libjpeg to avoid cross-distro breakage (GitHub #161, #162).
|
||||
mupdf = { version = "0.5", default-features = false, features = [
|
||||
"system-fonts",
|
||||
"sys-lib-zlib",
|
||||
"sys-lib-harfbuzz",
|
||||
"sys-lib-freetype",
|
||||
] }
|
||||
|
||||
# URL opening
|
||||
open = "5"
|
||||
|
||||
@@ -132,8 +143,8 @@ futures-util = "0.3"
|
||||
|
||||
# Platform-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Note: xdg-portal feature disabled due to ashpd/zbus version incompatibility
|
||||
tauri-plugin-dialog = { version = "2.6.0" }
|
||||
ashpd = { version = "0.13", default-features = false, features = ["tokio", "notification"] }
|
||||
|
||||
# Direct ALSA access for bit-perfect playback (bypasses CPAL limitations)
|
||||
alsa = "0.9"
|
||||
@@ -147,6 +158,3 @@ tauri-plugin-dialog = { version = "2.6.0" }
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[patch.crates-io]
|
||||
cpal = { path = "vendor/cpal" }
|
||||
rodio = { path = "vendor/rodio" }
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 283 KiB |
@@ -16,9 +16,11 @@ const BUNDLE_BASE_URL: &str = "https://play.qobuz.com";
|
||||
pub struct BundleTokens {
|
||||
pub app_id: String,
|
||||
pub secrets: Vec<String>,
|
||||
/// OAuth private key for the /oauth/callback exchange (newer bundles).
|
||||
pub private_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract app_id and secrets from Qobuz bundle
|
||||
/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle
|
||||
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
||||
// Step 1: Get login page to find bundle URL
|
||||
let login_page = client.get(LOGIN_PAGE_URL).send().await?.text().await?;
|
||||
@@ -41,7 +43,10 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(BundleTokens { app_id, secrets })
|
||||
// Step 5: Extract OAuth private_key (optional)
|
||||
let private_key = extract_private_key(&bundle_content);
|
||||
|
||||
Ok(BundleTokens { app_id, secrets, private_key })
|
||||
}
|
||||
|
||||
fn extract_bundle_url(html: &str) -> Result<String> {
|
||||
@@ -176,10 +181,18 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Extracted {} secrets", secrets.len());
|
||||
log::info!("Extracted app secrets from bundle");
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
fn extract_private_key(bundle: &str) -> Option<String> {
|
||||
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#)
|
||||
.expect("Invalid regex");
|
||||
re.captures(bundle)
|
||||
.and_then(|caps| caps.name("key"))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -174,6 +174,169 @@ impl QobuzClient {
|
||||
*self.session.write().await = None;
|
||||
}
|
||||
|
||||
/// Inject an already-authenticated session (e.g. obtained via OAuth flow).
|
||||
pub async fn set_session(&self, session: UserSession) {
|
||||
*self.session.write().await = Some(session);
|
||||
}
|
||||
|
||||
/// Exchange an OAuth code for a full user session.
|
||||
///
|
||||
/// Step 1: GET /oauth/callback?code=CODE&private_key=KEY → { token }
|
||||
/// Step 2: POST /user/login with X-User-Auth-Token: token, body=extra=partner
|
||||
pub async fn login_with_oauth_code(&self, code: &str) -> Result<UserSession> {
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
let tokens = self.tokens.read().await;
|
||||
let tokens = tokens.as_ref().ok_or_else(|| {
|
||||
ApiError::BundleExtractionError("Client not initialized".to_string())
|
||||
})?;
|
||||
let app_id = tokens.app_id.clone();
|
||||
let private_key = tokens.private_key.clone().ok_or_else(|| {
|
||||
ApiError::BundleExtractionError(
|
||||
"OAuth private key not available — bundle may be outdated".to_string(),
|
||||
)
|
||||
})?;
|
||||
let _ = tokens;
|
||||
|
||||
// Step 1: Exchange code for token via /oauth/callback
|
||||
let callback_url = endpoints::build_url(paths::OAUTH_CALLBACK);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"X-App-Id",
|
||||
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||
);
|
||||
|
||||
log::info!("[OAuth] Exchanging code at /oauth/callback");
|
||||
let resp = self
|
||||
.http
|
||||
.get(&callback_url)
|
||||
.headers(headers)
|
||||
.query(&[
|
||||
("code", code),
|
||||
("private_key", &private_key),
|
||||
("app_id", &app_id),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
log::error!("[OAuth] /oauth/callback failed {}: {}", status, body);
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"OAuth callback failed: {} — {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
let token = json["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
ApiError::ApiResponse("No token in OAuth callback response".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
log::info!("[OAuth] Token received, fetching session via /user/login");
|
||||
|
||||
// Step 2: GET /user/login with X-User-Auth-Token to fetch full profile
|
||||
let user_login_url = endpoints::build_url(paths::USER_LOGIN);
|
||||
let mut auth_headers = HeaderMap::new();
|
||||
auth_headers.insert(
|
||||
"X-App-Id",
|
||||
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||
);
|
||||
auth_headers.insert(
|
||||
"X-User-Auth-Token",
|
||||
HeaderValue::from_str(&token).map_err(|_| {
|
||||
ApiError::AuthenticationError("Invalid OAuth token format".into())
|
||||
})?,
|
||||
);
|
||||
|
||||
let login_resp = self
|
||||
.http
|
||||
.post(&user_login_url)
|
||||
.headers(auth_headers)
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.body("extra=partner")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match login_resp.status() {
|
||||
StatusCode::OK => {
|
||||
let login_json: Value = login_resp.json().await?;
|
||||
let session = parse_login_response(&login_json)?;
|
||||
*self.session.write().await = Some(session.clone());
|
||||
log::info!("[OAuth] Session established");
|
||||
Ok(session)
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||
"OAuth token rejected".to_string(),
|
||||
)),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"user/login OAuth step failed: {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-authenticate using a previously obtained `user_auth_token`.
|
||||
///
|
||||
/// Used at bootstrap when OAuth credentials are stored but email/password are not.
|
||||
/// Calls `POST /user/login` with `X-User-Auth-Token` header — the same as step 2
|
||||
/// of the OAuth code exchange flow. Returns an error if the token has expired.
|
||||
pub async fn login_with_token(&self, token: &str) -> Result<UserSession> {
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
let tokens = self.tokens.read().await;
|
||||
let app_id = tokens
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?
|
||||
.app_id
|
||||
.clone();
|
||||
let _ = tokens;
|
||||
|
||||
let user_login_url = endpoints::build_url(paths::USER_LOGIN);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"X-App-Id",
|
||||
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-User-Auth-Token",
|
||||
HeaderValue::from_str(token).map_err(|_| {
|
||||
ApiError::AuthenticationError("Invalid token format".into())
|
||||
})?,
|
||||
);
|
||||
|
||||
log::info!("[OAuth] Restoring session from saved token");
|
||||
let resp = self
|
||||
.http
|
||||
.post(&user_login_url)
|
||||
.headers(headers)
|
||||
.header("Content-Type", "text/plain;charset=UTF-8")
|
||||
.body("extra=partner")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
let json: Value = resp.json().await?;
|
||||
let session = parse_login_response(&json)?;
|
||||
*self.session.write().await = Some(session.clone());
|
||||
log::info!("[OAuth] Session restored");
|
||||
Ok(session)
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||
"OAuth token expired or invalid".to_string(),
|
||||
)),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"Token re-auth failed: {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current user info (display name, subscription, and expiry if available)
|
||||
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
|
||||
self.session.read().await.as_ref().map(|s| {
|
||||
@@ -185,11 +348,6 @@ impl QobuzClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current user ID
|
||||
pub async fn get_user_id(&self) -> Option<u64> {
|
||||
self.session.read().await.as_ref().map(|s| s.user_id)
|
||||
}
|
||||
|
||||
/// Get user auth token header value (public for catalog search)
|
||||
pub async fn auth_token(&self) -> Result<String> {
|
||||
self.session
|
||||
@@ -473,6 +631,33 @@ impl QobuzClient {
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get album suggestions (similar albums) from /album/suggest
|
||||
pub async fn get_album_suggest(
|
||||
&self,
|
||||
album_id: &str,
|
||||
) -> Result<AlbumSuggestResponse> {
|
||||
let url = endpoints::build_url(paths::ALBUM_SUGGEST);
|
||||
let http_response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.api_headers().await?)
|
||||
.query(&[("album_id", album_id)])
|
||||
.send()
|
||||
.await?;
|
||||
let status = http_response.status();
|
||||
log::debug!("[API] get_album_suggest({}) status={}", album_id, status);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"get_album_suggest({}) status {}",
|
||||
album_id, status
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = http_response.json().await?;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get featured albums by type (new-releases, press-awards, most-streamed)
|
||||
pub async fn get_featured_albums(
|
||||
&self,
|
||||
@@ -1738,11 +1923,8 @@ impl QobuzClient {
|
||||
match self.get_stream_url(track_id, *quality).await {
|
||||
Ok(url) if !url.has_restrictions() => {
|
||||
log::info!(
|
||||
"Got stream URL - requested format_id={}, got format_id={}, sample_rate={}Hz, bit_depth={:?}",
|
||||
quality.id(),
|
||||
url.format_id,
|
||||
url.sampling_rate,
|
||||
url.bit_depth
|
||||
"Got stream URL for requested quality format_id={}",
|
||||
quality.id()
|
||||
);
|
||||
return Ok(url);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
|
||||
pub mod paths {
|
||||
// User
|
||||
pub const USER_LOGIN: &str = "/user/login";
|
||||
pub const OAUTH_CALLBACK: &str = "/oauth/callback";
|
||||
pub const PURCHASE_GET_USER_PURCHASES: &str = "/purchase/getUserPurchases";
|
||||
pub const PURCHASE_GET_USER_PURCHASES_IDS: &str = "/purchase/getUserPurchasesIds";
|
||||
pub const DYNAMIC_SUGGEST: &str = "/dynamic/suggest";
|
||||
@@ -20,6 +21,7 @@ pub mod paths {
|
||||
pub const ALBUM_GET: &str = "/album/get";
|
||||
pub const ALBUM_SEARCH: &str = "/album/search";
|
||||
pub const ALBUM_GET_FEATURED: &str = "/album/getFeatured";
|
||||
pub const ALBUM_SUGGEST: &str = "/album/suggest";
|
||||
|
||||
// Artist
|
||||
pub const ARTIST_GET: &str = "/artist/get";
|
||||
|
||||
@@ -625,6 +625,8 @@ pub struct PurchaseTrack {
|
||||
pub streamable: bool,
|
||||
#[serde(default)]
|
||||
pub downloaded: bool,
|
||||
#[serde(default)]
|
||||
pub downloaded_format_ids: Vec<u32>,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub purchased_at: Option<i64>,
|
||||
}
|
||||
@@ -691,6 +693,13 @@ pub struct RadioResponse {
|
||||
pub tracks: SearchResultsPage<Track>,
|
||||
}
|
||||
|
||||
/// Album suggestion response from /album/suggest
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlbumSuggestResponse {
|
||||
pub algorithm: Option<String>,
|
||||
pub albums: Option<SearchResultsPage<Album>>,
|
||||
}
|
||||
|
||||
/// Favorites container
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Favorites {
|
||||
|
||||
@@ -27,7 +27,7 @@ impl Performer {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use crate::api::performers::parse_performers;
|
||||
/// use qbz_nix_lib::api::performers::parse_performers;
|
||||
///
|
||||
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
|
||||
/// assert_eq!(performers.len(), 2);
|
||||
|
||||
@@ -979,6 +979,7 @@ async fn play_album(
|
||||
artist_id: t.performer.as_ref().map(|p| p.id),
|
||||
streamable: t.streamable,
|
||||
source: Some("qobuz".to_string()),
|
||||
parental_warning: t.parental_warning,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -7,8 +7,9 @@ use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::api::QobuzClient;
|
||||
use crate::musicbrainz::models::{ArtistFullResponse, ArtistRelationships, Period, RelatedArtist};
|
||||
use crate::musicbrainz::{MusicBrainzCache, MusicBrainzClient};
|
||||
use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
|
||||
use qbz_integrations::musicbrainz::{ArtistFullResponse, ArtistRelationships, Period, RelatedArtist};
|
||||
use qbz_integrations::MusicBrainzClient;
|
||||
|
||||
use super::sparse_vector::SparseVector;
|
||||
use super::store::ArtistVectorStore;
|
||||
@@ -18,8 +19,8 @@ use super::weights::RelationshipWeights;
|
||||
pub struct ArtistVectorBuilder {
|
||||
/// Vector store for persistence
|
||||
store: Arc<Mutex<Option<ArtistVectorStore>>>,
|
||||
/// MusicBrainz client for relationship data
|
||||
mb_client: Arc<MusicBrainzClient>,
|
||||
/// MusicBrainz client for relationship data (behind Mutex for shared ownership)
|
||||
mb_client: Arc<Mutex<MusicBrainzClient>>,
|
||||
/// MusicBrainz cache
|
||||
mb_cache: Arc<Mutex<Option<MusicBrainzCache>>>,
|
||||
/// Qobuz client for similar artists
|
||||
@@ -45,7 +46,7 @@ impl ArtistVectorBuilder {
|
||||
/// Create a new builder with the given dependencies
|
||||
pub fn new(
|
||||
store: Arc<Mutex<Option<ArtistVectorStore>>>,
|
||||
mb_client: Arc<MusicBrainzClient>,
|
||||
mb_client: Arc<Mutex<MusicBrainzClient>>,
|
||||
mb_cache: Arc<Mutex<Option<MusicBrainzCache>>>,
|
||||
qobuz_client: Arc<RwLock<QobuzClient>>,
|
||||
weights: RelationshipWeights,
|
||||
@@ -196,10 +197,12 @@ impl ArtistVectorBuilder {
|
||||
artist_mbid
|
||||
);
|
||||
// Fetch from API
|
||||
let response = self
|
||||
.mb_client
|
||||
let mb_client = self.mb_client.lock().await;
|
||||
let response = mb_client
|
||||
.get_artist_with_relations(artist_mbid)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(mb_client);
|
||||
log::info!(
|
||||
"[VectorBuilder] MusicBrainz API response received for {}",
|
||||
artist_mbid
|
||||
@@ -361,8 +364,8 @@ impl ArtistVectorBuilder {
|
||||
/// This is a best-effort match based on artist name
|
||||
pub async fn resolve_qobuz_to_mbid(
|
||||
qobuz_client: &Arc<RwLock<QobuzClient>>,
|
||||
mb_client: &Arc<MusicBrainzClient>,
|
||||
_mb_cache: &Arc<Mutex<MusicBrainzCache>>,
|
||||
mb_client: &Arc<Mutex<MusicBrainzClient>>,
|
||||
_mb_cache: &Arc<Mutex<Option<MusicBrainzCache>>>,
|
||||
qobuz_artist_id: u64,
|
||||
) -> Result<Option<String>, String> {
|
||||
// Get artist name from Qobuz
|
||||
@@ -376,7 +379,11 @@ pub async fn resolve_qobuz_to_mbid(
|
||||
};
|
||||
|
||||
// Search MusicBrainz for this artist
|
||||
let mb_result = mb_client.search_artist(&artist_name).await?;
|
||||
let client = mb_client.lock().await;
|
||||
let mb_result = client
|
||||
.search_artist(&artist_name, 10)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Return first high-confidence match (score >= 80)
|
||||
if let Some(artist) = mb_result.artists.first() {
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Generate a complete set of CSS custom properties from a ThemePalette.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{GeneratedTheme, PaletteColor, SystemColorScheme, ThemePalette};
|
||||
|
||||
/// Generate a full CSS theme from an extracted palette.
|
||||
pub fn generate_theme(palette: &ThemePalette, source: &str) -> GeneratedTheme {
|
||||
let mut vars = HashMap::new();
|
||||
|
||||
// Backgrounds
|
||||
vars.insert("--bg-primary".into(), palette.bg_primary.to_hex());
|
||||
vars.insert("--bg-secondary".into(), palette.bg_secondary.to_hex());
|
||||
vars.insert("--bg-tertiary".into(), palette.bg_tertiary.to_hex());
|
||||
vars.insert("--bg-hover".into(), palette.bg_hover.to_hex());
|
||||
|
||||
// Text colors
|
||||
let (text_primary, text_secondary, text_muted, text_disabled) = if palette.is_dark {
|
||||
(
|
||||
PaletteColor::new(255, 255, 255),
|
||||
PaletteColor::new(204, 204, 204),
|
||||
PaletteColor::new(136, 136, 136),
|
||||
PaletteColor::new(85, 85, 85),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
PaletteColor::new(15, 15, 15),
|
||||
PaletteColor::new(68, 68, 68),
|
||||
PaletteColor::new(102, 102, 102),
|
||||
PaletteColor::new(153, 153, 153),
|
||||
)
|
||||
};
|
||||
|
||||
// Adjust text-primary for contrast if needed
|
||||
let text_primary = ensure_text_contrast(text_primary, &palette.bg_primary, palette.is_dark);
|
||||
|
||||
vars.insert("--text-primary".into(), text_primary.to_hex());
|
||||
vars.insert("--text-secondary".into(), text_secondary.to_hex());
|
||||
vars.insert("--text-muted".into(), text_muted.to_hex());
|
||||
vars.insert("--text-disabled".into(), text_disabled.to_hex());
|
||||
|
||||
// Accent
|
||||
vars.insert("--accent-primary".into(), palette.accent.to_hex());
|
||||
vars.insert(
|
||||
"--accent-hover".into(),
|
||||
palette.accent.shift_lightness(0.10).to_hex(),
|
||||
);
|
||||
vars.insert(
|
||||
"--accent-active".into(),
|
||||
palette.accent.shift_lightness(-0.10).to_hex(),
|
||||
);
|
||||
|
||||
// Button text: white if accent is dark, black if light
|
||||
let btn_text = if palette.accent.luminance() < 0.5 {
|
||||
PaletteColor::new(255, 255, 255)
|
||||
} else {
|
||||
PaletteColor::new(0, 0, 0)
|
||||
};
|
||||
vars.insert("--btn-primary-text".into(), btn_text.to_hex());
|
||||
|
||||
// Status colors (fixed safe values, consistent with existing themes)
|
||||
if palette.is_dark {
|
||||
vars.insert("--danger".into(), "#ef4444".into());
|
||||
vars.insert("--danger-bg".into(), "rgba(239, 68, 68, 0.1)".into());
|
||||
vars.insert("--danger-border".into(), "rgba(239, 68, 68, 0.3)".into());
|
||||
vars.insert("--danger-hover".into(), "rgba(239, 68, 68, 0.2)".into());
|
||||
|
||||
vars.insert("--warning".into(), "#fbbf24".into());
|
||||
vars.insert("--warning-bg".into(), "rgba(251, 191, 36, 0.1)".into());
|
||||
vars.insert("--warning-border".into(), "rgba(251, 191, 36, 0.3)".into());
|
||||
vars.insert("--warning-hover".into(), "rgba(251, 191, 36, 0.2)".into());
|
||||
} else {
|
||||
vars.insert("--danger".into(), "#dc2626".into());
|
||||
vars.insert("--danger-bg".into(), "rgba(220, 38, 38, 0.1)".into());
|
||||
vars.insert("--danger-border".into(), "rgba(220, 38, 38, 0.3)".into());
|
||||
vars.insert("--danger-hover".into(), "rgba(220, 38, 38, 0.15)".into());
|
||||
|
||||
vars.insert("--warning".into(), "#d97706".into());
|
||||
vars.insert("--warning-bg".into(), "rgba(217, 119, 6, 0.1)".into());
|
||||
vars.insert("--warning-border".into(), "rgba(217, 119, 6, 0.3)".into());
|
||||
vars.insert("--warning-hover".into(), "rgba(217, 119, 6, 0.15)".into());
|
||||
}
|
||||
|
||||
// Borders: subtle shifts from bg_primary
|
||||
let border_subtle = if palette.is_dark {
|
||||
palette.bg_primary.shift_lightness(0.08)
|
||||
} else {
|
||||
palette.bg_primary.shift_lightness(-0.08)
|
||||
};
|
||||
let border_strong = if palette.is_dark {
|
||||
palette.bg_primary.shift_lightness(0.14)
|
||||
} else {
|
||||
palette.bg_primary.shift_lightness(-0.14)
|
||||
};
|
||||
vars.insert("--border-subtle".into(), border_subtle.to_hex());
|
||||
vars.insert("--border-strong".into(), border_strong.to_hex());
|
||||
|
||||
// Alpha tokens: white-based for dark, black-based for light
|
||||
let alpha_base = if palette.is_dark {
|
||||
(255, 255, 255)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
let alpha_levels: &[(f64, &str)] = &[
|
||||
(0.04, "--alpha-4"),
|
||||
(0.05, "--alpha-5"),
|
||||
(0.06, "--alpha-6"),
|
||||
(0.08, "--alpha-8"),
|
||||
(0.10, "--alpha-10"),
|
||||
(0.15, "--alpha-15"),
|
||||
(0.18, "--alpha-18"),
|
||||
(0.20, "--alpha-20"),
|
||||
(0.25, "--alpha-25"),
|
||||
(0.30, "--alpha-30"),
|
||||
(0.35, "--alpha-35"),
|
||||
(0.40, "--alpha-40"),
|
||||
(0.45, "--alpha-45"),
|
||||
(0.50, "--alpha-50"),
|
||||
(0.60, "--alpha-60"),
|
||||
(0.70, "--alpha-70"),
|
||||
(0.80, "--alpha-80"),
|
||||
(0.85, "--alpha-85"),
|
||||
(0.90, "--alpha-90"),
|
||||
(0.95, "--alpha-95"),
|
||||
];
|
||||
|
||||
for (alpha, name) in alpha_levels {
|
||||
vars.insert(
|
||||
name.to_string(),
|
||||
format!(
|
||||
"rgba({}, {}, {}, {})",
|
||||
alpha_base.0, alpha_base.1, alpha_base.2, alpha
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GeneratedTheme {
|
||||
variables: vars,
|
||||
is_dark: palette.is_dark,
|
||||
source: source.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a full CSS theme directly from a DE color scheme.
|
||||
///
|
||||
/// Instead of k-means extraction, this maps the DE's semantic colors (window bg,
|
||||
/// view bg, button bg, selection, etc.) directly to CSS variables — producing a
|
||||
/// theme that matches the system look exactly.
|
||||
pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme {
|
||||
let mut vars = HashMap::new();
|
||||
|
||||
// Determine dark/light from the window background
|
||||
let window_bg = scheme
|
||||
.window_bg
|
||||
.unwrap_or(PaletteColor::new(40, 40, 40));
|
||||
let is_dark = window_bg.luminance() < 0.5;
|
||||
|
||||
// ── Backgrounds ──────────────────────────────────────────────────────
|
||||
vars.insert("--bg-primary".into(), window_bg.to_hex());
|
||||
|
||||
let bg_secondary = scheme.view_bg.unwrap_or_else(|| {
|
||||
if is_dark {
|
||||
window_bg.shift_lightness(0.03)
|
||||
} else {
|
||||
window_bg.shift_lightness(-0.03)
|
||||
}
|
||||
});
|
||||
vars.insert("--bg-secondary".into(), bg_secondary.to_hex());
|
||||
|
||||
let bg_tertiary = scheme.button_bg.unwrap_or_else(|| {
|
||||
if is_dark {
|
||||
window_bg.shift_lightness(0.10)
|
||||
} else {
|
||||
window_bg.shift_lightness(-0.10)
|
||||
}
|
||||
});
|
||||
vars.insert("--bg-tertiary".into(), bg_tertiary.to_hex());
|
||||
|
||||
// Hover: midpoint between primary and secondary
|
||||
let bg_hover = scheme.window_bg_alt.unwrap_or_else(|| {
|
||||
PaletteColor::new(
|
||||
((window_bg.r as u16 + bg_secondary.r as u16) / 2) as u8,
|
||||
((window_bg.g as u16 + bg_secondary.g as u16) / 2) as u8,
|
||||
((window_bg.b as u16 + bg_secondary.b as u16) / 2) as u8,
|
||||
)
|
||||
});
|
||||
vars.insert("--bg-hover".into(), bg_hover.to_hex());
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────
|
||||
let text_primary = scheme
|
||||
.window_fg
|
||||
.unwrap_or(if is_dark {
|
||||
PaletteColor::new(223, 223, 223)
|
||||
} else {
|
||||
PaletteColor::new(36, 36, 36)
|
||||
});
|
||||
let text_primary = ensure_text_contrast(text_primary, &window_bg, is_dark);
|
||||
vars.insert("--text-primary".into(), text_primary.to_hex());
|
||||
|
||||
// Secondary: slightly dimmed from primary
|
||||
let text_secondary = scheme.view_fg.unwrap_or_else(|| {
|
||||
text_primary.shift_lightness(if is_dark { -0.10 } else { 0.10 })
|
||||
});
|
||||
vars.insert("--text-secondary".into(), text_secondary.to_hex());
|
||||
|
||||
let text_muted = scheme.window_fg_inactive.unwrap_or_else(|| {
|
||||
text_primary.shift_lightness(if is_dark { -0.25 } else { 0.25 })
|
||||
});
|
||||
vars.insert("--text-muted".into(), text_muted.to_hex());
|
||||
|
||||
let text_disabled = text_muted.shift_lightness(if is_dark { -0.10 } else { 0.10 });
|
||||
vars.insert("--text-disabled".into(), text_disabled.to_hex());
|
||||
|
||||
// ── Accent (selection) ───────────────────────────────────────────────
|
||||
let accent = scheme
|
||||
.accent
|
||||
.or(scheme.selection_bg)
|
||||
.unwrap_or(PaletteColor::new(0, 120, 215));
|
||||
vars.insert("--accent-primary".into(), accent.to_hex());
|
||||
|
||||
let accent_hover = scheme
|
||||
.selection_hover
|
||||
.unwrap_or_else(|| accent.shift_lightness(0.10));
|
||||
vars.insert("--accent-hover".into(), accent_hover.to_hex());
|
||||
vars.insert(
|
||||
"--accent-active".into(),
|
||||
accent.shift_lightness(-0.10).to_hex(),
|
||||
);
|
||||
|
||||
// Button text on accent
|
||||
let btn_text = scheme.selection_fg.unwrap_or(
|
||||
if accent.luminance() < 0.5 {
|
||||
PaletteColor::new(255, 255, 255)
|
||||
} else {
|
||||
PaletteColor::new(0, 0, 0)
|
||||
},
|
||||
);
|
||||
vars.insert("--btn-primary-text".into(), btn_text.to_hex());
|
||||
|
||||
// ── Status colors ────────────────────────────────────────────────────
|
||||
// Use system negative/neutral if available, else fallback to safe defaults
|
||||
let danger = scheme
|
||||
.fg_negative
|
||||
.unwrap_or(if is_dark {
|
||||
PaletteColor::new(239, 68, 68)
|
||||
} else {
|
||||
PaletteColor::new(220, 38, 38)
|
||||
});
|
||||
vars.insert("--danger".into(), danger.to_hex());
|
||||
vars.insert("--danger-bg".into(), danger.to_rgba(0.1));
|
||||
vars.insert("--danger-border".into(), danger.to_rgba(0.3));
|
||||
vars.insert(
|
||||
"--danger-hover".into(),
|
||||
danger.to_rgba(if is_dark { 0.2 } else { 0.15 }),
|
||||
);
|
||||
|
||||
let warning = scheme
|
||||
.fg_neutral
|
||||
.unwrap_or(if is_dark {
|
||||
PaletteColor::new(251, 191, 36)
|
||||
} else {
|
||||
PaletteColor::new(217, 119, 6)
|
||||
});
|
||||
vars.insert("--warning".into(), warning.to_hex());
|
||||
vars.insert("--warning-bg".into(), warning.to_rgba(0.1));
|
||||
vars.insert("--warning-border".into(), warning.to_rgba(0.3));
|
||||
vars.insert(
|
||||
"--warning-hover".into(),
|
||||
warning.to_rgba(if is_dark { 0.2 } else { 0.15 }),
|
||||
);
|
||||
|
||||
// ── Borders ──────────────────────────────────────────────────────────
|
||||
let border_subtle = if is_dark {
|
||||
window_bg.shift_lightness(0.06)
|
||||
} else {
|
||||
window_bg.shift_lightness(-0.06)
|
||||
};
|
||||
let border_strong = if is_dark {
|
||||
window_bg.shift_lightness(0.12)
|
||||
} else {
|
||||
window_bg.shift_lightness(-0.12)
|
||||
};
|
||||
vars.insert("--border-subtle".into(), border_subtle.to_hex());
|
||||
vars.insert("--border-strong".into(), border_strong.to_hex());
|
||||
|
||||
// ── Alpha tokens ─────────────────────────────────────────────────────
|
||||
let alpha_base = if is_dark {
|
||||
(255, 255, 255)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
let alpha_levels: &[(f64, &str)] = &[
|
||||
(0.04, "--alpha-4"),
|
||||
(0.05, "--alpha-5"),
|
||||
(0.06, "--alpha-6"),
|
||||
(0.08, "--alpha-8"),
|
||||
(0.10, "--alpha-10"),
|
||||
(0.15, "--alpha-15"),
|
||||
(0.18, "--alpha-18"),
|
||||
(0.20, "--alpha-20"),
|
||||
(0.25, "--alpha-25"),
|
||||
(0.30, "--alpha-30"),
|
||||
(0.35, "--alpha-35"),
|
||||
(0.40, "--alpha-40"),
|
||||
(0.45, "--alpha-45"),
|
||||
(0.50, "--alpha-50"),
|
||||
(0.60, "--alpha-60"),
|
||||
(0.70, "--alpha-70"),
|
||||
(0.80, "--alpha-80"),
|
||||
(0.85, "--alpha-85"),
|
||||
(0.90, "--alpha-90"),
|
||||
(0.95, "--alpha-95"),
|
||||
];
|
||||
for (alpha, name) in alpha_levels {
|
||||
vars.insert(
|
||||
name.to_string(),
|
||||
format!(
|
||||
"rgba({}, {}, {}, {})",
|
||||
alpha_base.0, alpha_base.1, alpha_base.2, alpha
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GeneratedTheme {
|
||||
variables: vars,
|
||||
is_dark,
|
||||
source: "system-colors".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure text has at least WCAG AA contrast (4.5:1) against the background.
|
||||
fn ensure_text_contrast(
|
||||
text: PaletteColor,
|
||||
bg: &PaletteColor,
|
||||
is_dark: bool,
|
||||
) -> PaletteColor {
|
||||
if text.contrast_ratio(bg) >= 4.5 {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Shift text toward white (dark theme) or black (light theme) until contrast is met
|
||||
let (h, s, l) = text.to_hsl();
|
||||
let direction = if is_dark { 0.05 } else { -0.05 };
|
||||
let mut new_l = l;
|
||||
|
||||
for _ in 0..20 {
|
||||
new_l = (new_l + direction).clamp(0.0, 1.0);
|
||||
let candidate = PaletteColor::from_hsl(h, s, new_l);
|
||||
if candidate.contrast_ratio(bg) >= 4.5 {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute fallback
|
||||
if is_dark {
|
||||
PaletteColor::new(255, 255, 255)
|
||||
} else {
|
||||
PaletteColor::new(0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_theme_dark() {
|
||||
let palette = ThemePalette {
|
||||
bg_primary: PaletteColor::new(15, 15, 20),
|
||||
bg_secondary: PaletteColor::new(26, 26, 30),
|
||||
bg_tertiary: PaletteColor::new(42, 42, 48),
|
||||
bg_hover: PaletteColor::new(31, 31, 35),
|
||||
accent: PaletteColor::new(66, 133, 244),
|
||||
is_dark: true,
|
||||
all_colors: vec![],
|
||||
};
|
||||
|
||||
let theme = generate_theme(&palette, "test");
|
||||
|
||||
assert!(theme.is_dark);
|
||||
assert_eq!(theme.source, "test");
|
||||
assert!(theme.variables.contains_key("--bg-primary"));
|
||||
assert!(theme.variables.contains_key("--accent-primary"));
|
||||
assert!(theme.variables.contains_key("--alpha-50"));
|
||||
assert!(theme.variables.contains_key("--danger"));
|
||||
assert!(theme.variables.contains_key("--border-subtle"));
|
||||
|
||||
// Dark theme should have white-based alphas
|
||||
let alpha50 = theme.variables.get("--alpha-50").unwrap();
|
||||
assert!(alpha50.starts_with("rgba(255, 255, 255"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_theme_light() {
|
||||
let palette = ThemePalette {
|
||||
bg_primary: PaletteColor::new(245, 245, 245),
|
||||
bg_secondary: PaletteColor::new(235, 235, 235),
|
||||
bg_tertiary: PaletteColor::new(220, 220, 220),
|
||||
bg_hover: PaletteColor::new(240, 240, 240),
|
||||
accent: PaletteColor::new(26, 115, 232),
|
||||
is_dark: false,
|
||||
all_colors: vec![],
|
||||
};
|
||||
|
||||
let theme = generate_theme(&palette, "test-light");
|
||||
|
||||
assert!(!theme.is_dark);
|
||||
|
||||
// Light theme should have black-based alphas
|
||||
let alpha50 = theme.variables.get("--alpha-50").unwrap();
|
||||
assert!(alpha50.starts_with("rgba(0, 0, 0"));
|
||||
|
||||
// Text should be dark
|
||||
let text = theme.variables.get("--text-primary").unwrap();
|
||||
assert!(text.starts_with("#0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_required_tokens_present() {
|
||||
let palette = ThemePalette {
|
||||
bg_primary: PaletteColor::new(20, 20, 25),
|
||||
bg_secondary: PaletteColor::new(30, 30, 35),
|
||||
bg_tertiary: PaletteColor::new(45, 45, 50),
|
||||
bg_hover: PaletteColor::new(35, 35, 40),
|
||||
accent: PaletteColor::new(100, 200, 100),
|
||||
is_dark: true,
|
||||
all_colors: vec![],
|
||||
};
|
||||
|
||||
let theme = generate_theme(&palette, "completeness-test");
|
||||
|
||||
let required = [
|
||||
"--bg-primary",
|
||||
"--bg-secondary",
|
||||
"--bg-tertiary",
|
||||
"--bg-hover",
|
||||
"--text-primary",
|
||||
"--text-secondary",
|
||||
"--text-muted",
|
||||
"--text-disabled",
|
||||
"--accent-primary",
|
||||
"--accent-hover",
|
||||
"--accent-active",
|
||||
"--btn-primary-text",
|
||||
"--danger",
|
||||
"--danger-bg",
|
||||
"--danger-border",
|
||||
"--danger-hover",
|
||||
"--warning",
|
||||
"--warning-bg",
|
||||
"--warning-border",
|
||||
"--warning-hover",
|
||||
"--border-subtle",
|
||||
"--border-strong",
|
||||
"--alpha-4",
|
||||
"--alpha-5",
|
||||
"--alpha-6",
|
||||
"--alpha-8",
|
||||
"--alpha-10",
|
||||
"--alpha-15",
|
||||
"--alpha-18",
|
||||
"--alpha-20",
|
||||
"--alpha-25",
|
||||
"--alpha-30",
|
||||
"--alpha-35",
|
||||
"--alpha-40",
|
||||
"--alpha-45",
|
||||
"--alpha-50",
|
||||
"--alpha-60",
|
||||
"--alpha-70",
|
||||
"--alpha-80",
|
||||
"--alpha-85",
|
||||
"--alpha-90",
|
||||
"--alpha-95",
|
||||
];
|
||||
|
||||
for key in &required {
|
||||
assert!(
|
||||
theme.variables.contains_key(*key),
|
||||
"Missing required token: {}",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Auto-theme generation from images and system wallpapers.
|
||||
//!
|
||||
//! Extracts dominant colors via k-means clustering, then maps them
|
||||
//! to the full set of CSS custom properties expected by the QBZ frontend.
|
||||
|
||||
pub mod generator;
|
||||
pub mod palette;
|
||||
pub mod system;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A single RGB color.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PaletteColor {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl PaletteColor {
|
||||
pub fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
/// Relative luminance (ITU-R BT.709) in [0.0, 1.0].
|
||||
pub fn luminance(&self) -> f64 {
|
||||
fn linearize(c: u8) -> f64 {
|
||||
let s = c as f64 / 255.0;
|
||||
if s <= 0.04045 {
|
||||
s / 12.92
|
||||
} else {
|
||||
((s + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
0.2126 * linearize(self.r) + 0.7152 * linearize(self.g) + 0.0722 * linearize(self.b)
|
||||
}
|
||||
|
||||
/// HSL saturation in [0.0, 1.0].
|
||||
pub fn saturation(&self) -> f64 {
|
||||
let (r, g, b) = (self.r as f64 / 255.0, self.g as f64 / 255.0, self.b as f64 / 255.0);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let delta = max - min;
|
||||
if delta < 1e-6 {
|
||||
return 0.0;
|
||||
}
|
||||
let l = (max + min) / 2.0;
|
||||
if l <= 0.5 {
|
||||
delta / (max + min)
|
||||
} else {
|
||||
delta / (2.0 - max - min)
|
||||
}
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio against another color (range [1, 21]).
|
||||
pub fn contrast_ratio(&self, other: &PaletteColor) -> f64 {
|
||||
let l1 = self.luminance();
|
||||
let l2 = other.luminance();
|
||||
let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
|
||||
(lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
/// Shift lightness by `amount` (-1.0 to 1.0) in HSL space. Returns a new color.
|
||||
pub fn shift_lightness(&self, amount: f64) -> PaletteColor {
|
||||
let (h, s, l) = self.to_hsl();
|
||||
let new_l = (l + amount).clamp(0.0, 1.0);
|
||||
PaletteColor::from_hsl(h, s, new_l)
|
||||
}
|
||||
|
||||
/// Convert to HSL (h in [0, 360), s and l in [0, 1]).
|
||||
pub fn to_hsl(&self) -> (f64, f64, f64) {
|
||||
let (r, g, b) = (self.r as f64 / 255.0, self.g as f64 / 255.0, self.b as f64 / 255.0);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let delta = max - min;
|
||||
|
||||
if delta < 1e-6 {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
|
||||
let s = if l <= 0.5 {
|
||||
delta / (max + min)
|
||||
} else {
|
||||
delta / (2.0 - max - min)
|
||||
};
|
||||
|
||||
let h = if (max - r).abs() < 1e-6 {
|
||||
((g - b) / delta) % 6.0
|
||||
} else if (max - g).abs() < 1e-6 {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
};
|
||||
let h = (h * 60.0 + 360.0) % 360.0;
|
||||
|
||||
(h, s, l)
|
||||
}
|
||||
|
||||
/// Construct from HSL values.
|
||||
pub fn from_hsl(h: f64, s: f64, l: f64) -> PaletteColor {
|
||||
if s < 1e-6 {
|
||||
let v = (l * 255.0).round() as u8;
|
||||
return PaletteColor::new(v, v, v);
|
||||
}
|
||||
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let h_prime = h / 60.0;
|
||||
let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
|
||||
let m = l - c / 2.0;
|
||||
|
||||
let (r1, g1, b1) = match h_prime as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
|
||||
PaletteColor::new(
|
||||
((r1 + m) * 255.0).round() as u8,
|
||||
((g1 + m) * 255.0).round() as u8,
|
||||
((b1 + m) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// CSS hex string (#rrggbb).
|
||||
pub fn to_hex(&self) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
|
||||
}
|
||||
|
||||
/// CSS rgba() string with given alpha (0.0 to 1.0).
|
||||
pub fn to_rgba(&self, alpha: f64) -> String {
|
||||
format!("rgba({}, {}, {}, {})", self.r, self.g, self.b, alpha)
|
||||
}
|
||||
|
||||
/// Euclidean distance in RGB space.
|
||||
pub fn distance(&self, other: &PaletteColor) -> f64 {
|
||||
let dr = self.r as f64 - other.r as f64;
|
||||
let dg = self.g as f64 - other.g as f64;
|
||||
let db = self.b as f64 - other.b as f64;
|
||||
(dr * dr + dg * dg + db * db).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracted palette from an image.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemePalette {
|
||||
pub bg_primary: PaletteColor,
|
||||
pub bg_secondary: PaletteColor,
|
||||
pub bg_tertiary: PaletteColor,
|
||||
pub bg_hover: PaletteColor,
|
||||
pub accent: PaletteColor,
|
||||
pub is_dark: bool,
|
||||
pub all_colors: Vec<PaletteColor>,
|
||||
}
|
||||
|
||||
/// A fully generated theme ready for CSS injection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneratedTheme {
|
||||
/// CSS variable name (e.g. "--bg-primary") to value (e.g. "#0f0f0f").
|
||||
pub variables: HashMap<String, String>,
|
||||
pub is_dark: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Full color scheme read from the desktop environment.
|
||||
///
|
||||
/// Maps directly to the semantic color roles in KDE kdeglobals, GNOME dconf, etc.
|
||||
/// Each field is optional because not all DEs expose all colors.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemColorScheme {
|
||||
// Backgrounds
|
||||
pub window_bg: Option<PaletteColor>,
|
||||
pub window_bg_alt: Option<PaletteColor>,
|
||||
pub view_bg: Option<PaletteColor>,
|
||||
pub button_bg: Option<PaletteColor>,
|
||||
pub header_bg: Option<PaletteColor>,
|
||||
pub header_bg_inactive: Option<PaletteColor>,
|
||||
pub tooltip_bg: Option<PaletteColor>,
|
||||
|
||||
// Foregrounds (text)
|
||||
pub window_fg: Option<PaletteColor>,
|
||||
pub window_fg_inactive: Option<PaletteColor>,
|
||||
pub view_fg: Option<PaletteColor>,
|
||||
pub button_fg: Option<PaletteColor>,
|
||||
|
||||
// Selection / accent
|
||||
pub selection_bg: Option<PaletteColor>,
|
||||
pub selection_fg: Option<PaletteColor>,
|
||||
pub selection_hover: Option<PaletteColor>,
|
||||
pub accent: Option<PaletteColor>,
|
||||
|
||||
// Semantic
|
||||
pub fg_link: Option<PaletteColor>,
|
||||
pub fg_negative: Option<PaletteColor>,
|
||||
pub fg_neutral: Option<PaletteColor>,
|
||||
pub fg_positive: Option<PaletteColor>,
|
||||
|
||||
// Window manager
|
||||
pub wm_active_bg: Option<PaletteColor>,
|
||||
pub wm_active_fg: Option<PaletteColor>,
|
||||
pub wm_inactive_bg: Option<PaletteColor>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_luminance_black() {
|
||||
let black = PaletteColor::new(0, 0, 0);
|
||||
assert!((black.luminance() - 0.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_luminance_white() {
|
||||
let white = PaletteColor::new(255, 255, 255);
|
||||
assert!((white.luminance() - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contrast_ratio_bw() {
|
||||
let black = PaletteColor::new(0, 0, 0);
|
||||
let white = PaletteColor::new(255, 255, 255);
|
||||
let ratio = black.contrast_ratio(&white);
|
||||
assert!((ratio - 21.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hsl_roundtrip() {
|
||||
let c = PaletteColor::new(66, 133, 244); // Google Blue
|
||||
let (h, s, l) = c.to_hsl();
|
||||
let back = PaletteColor::from_hsl(h, s, l);
|
||||
assert!((c.r as i16 - back.r as i16).unsigned_abs() <= 1);
|
||||
assert!((c.g as i16 - back.g as i16).unsigned_abs() <= 1);
|
||||
assert!((c.b as i16 - back.b as i16).unsigned_abs() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saturation_gray() {
|
||||
let gray = PaletteColor::new(128, 128, 128);
|
||||
assert!(gray.saturation() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_format() {
|
||||
let c = PaletteColor::new(15, 15, 15);
|
||||
assert_eq!(c.to_hex(), "#0f0f0f");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! K-means palette extraction from images.
|
||||
|
||||
use image::GenericImageView;
|
||||
use std::path::Path;
|
||||
|
||||
use super::PaletteColor;
|
||||
use super::ThemePalette;
|
||||
|
||||
/// Load an image, downsample, and extract dominant colors via k-means.
|
||||
pub fn extract_palette(image_path: &str) -> Result<ThemePalette, String> {
|
||||
let path = Path::new(image_path);
|
||||
if !path.exists() {
|
||||
return Err(format!("Image not found: {}", image_path));
|
||||
}
|
||||
|
||||
let img = image::open(path).map_err(|e| format!("Failed to open image: {}", e))?;
|
||||
|
||||
// Downsample to ~100x100 for fast processing
|
||||
let thumb = img.resize_exact(100, 100, image::imageops::FilterType::Lanczos3);
|
||||
|
||||
let mut pixels: Vec<[f64; 3]> = Vec::with_capacity(10_000);
|
||||
for (_x, _y, rgba) in thumb.pixels() {
|
||||
// Skip semi-transparent pixels
|
||||
if rgba[3] < 200 {
|
||||
continue;
|
||||
}
|
||||
pixels.push([rgba[0] as f64, rgba[1] as f64, rgba[2] as f64]);
|
||||
}
|
||||
|
||||
if pixels.is_empty() {
|
||||
return Err("Image contains no opaque pixels".to_string());
|
||||
}
|
||||
|
||||
let clusters = kmeans(&pixels, 6, 25);
|
||||
|
||||
if clusters.is_empty() {
|
||||
return Err("K-means produced no clusters".to_string());
|
||||
}
|
||||
|
||||
build_palette(clusters)
|
||||
}
|
||||
|
||||
/// Extract palette directly from raw RGB pixel data (for testing or custom sources).
|
||||
pub fn extract_palette_from_pixels(pixels: &[[f64; 3]]) -> Result<ThemePalette, String> {
|
||||
if pixels.is_empty() {
|
||||
return Err("No pixels provided".to_string());
|
||||
}
|
||||
let clusters = kmeans(pixels, 6, 25);
|
||||
if clusters.is_empty() {
|
||||
return Err("K-means produced no clusters".to_string());
|
||||
}
|
||||
build_palette(clusters)
|
||||
}
|
||||
|
||||
/// A cluster result: centroid + pixel count.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cluster {
|
||||
centroid: [f64; 3],
|
||||
count: usize,
|
||||
}
|
||||
|
||||
/// Simple k-means clustering on RGB values.
|
||||
fn kmeans(pixels: &[[f64; 3]], k: usize, max_iters: usize) -> Vec<Cluster> {
|
||||
let n = pixels.len();
|
||||
if n == 0 || k == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let k = k.min(n);
|
||||
|
||||
// Initialize centroids by evenly sampling from the pixel list
|
||||
let mut centroids: Vec<[f64; 3]> = Vec::with_capacity(k);
|
||||
let step = n / k;
|
||||
for i in 0..k {
|
||||
centroids.push(pixels[i * step]);
|
||||
}
|
||||
|
||||
let mut assignments = vec![0usize; n];
|
||||
|
||||
for _ in 0..max_iters {
|
||||
let mut changed = false;
|
||||
|
||||
// Assignment step
|
||||
for (idx, pixel) in pixels.iter().enumerate() {
|
||||
let mut best_cluster = 0;
|
||||
let mut best_dist = f64::MAX;
|
||||
for (ci, centroid) in centroids.iter().enumerate() {
|
||||
let dist = rgb_dist_sq(pixel, centroid);
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best_cluster = ci;
|
||||
}
|
||||
}
|
||||
if assignments[idx] != best_cluster {
|
||||
assignments[idx] = best_cluster;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
|
||||
// Update step
|
||||
let mut sums = vec![[0.0f64; 3]; k];
|
||||
let mut counts = vec![0usize; k];
|
||||
for (idx, pixel) in pixels.iter().enumerate() {
|
||||
let ci = assignments[idx];
|
||||
sums[ci][0] += pixel[0];
|
||||
sums[ci][1] += pixel[1];
|
||||
sums[ci][2] += pixel[2];
|
||||
counts[ci] += 1;
|
||||
}
|
||||
|
||||
for ci in 0..k {
|
||||
if counts[ci] > 0 {
|
||||
centroids[ci][0] = sums[ci][0] / counts[ci] as f64;
|
||||
centroids[ci][1] = sums[ci][1] / counts[ci] as f64;
|
||||
centroids[ci][2] = sums[ci][2] / counts[ci] as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final clusters
|
||||
let mut counts = vec![0usize; k];
|
||||
for &a in &assignments {
|
||||
counts[a] += 1;
|
||||
}
|
||||
|
||||
centroids
|
||||
.into_iter()
|
||||
.zip(counts.into_iter())
|
||||
.filter(|(_, count)| *count > 0)
|
||||
.map(|(centroid, count)| Cluster { centroid, count })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rgb_dist_sq(a: &[f64; 3], b: &[f64; 3]) -> f64 {
|
||||
let dr = a[0] - b[0];
|
||||
let dg = a[1] - b[1];
|
||||
let db = a[2] - b[2];
|
||||
dr * dr + dg * dg + db * db
|
||||
}
|
||||
|
||||
/// Build a ThemePalette from k-means clusters.
|
||||
fn build_palette(mut clusters: Vec<Cluster>) -> Result<ThemePalette, String> {
|
||||
// Sort by pixel count (dominant first)
|
||||
clusters.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
|
||||
let all_colors: Vec<PaletteColor> = clusters
|
||||
.iter()
|
||||
.map(|c| {
|
||||
PaletteColor::new(
|
||||
c.centroid[0].round() as u8,
|
||||
c.centroid[1].round() as u8,
|
||||
c.centroid[2].round() as u8,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let dominant = all_colors[0];
|
||||
let is_dark = dominant.luminance() < 0.5;
|
||||
|
||||
// Check if the image is monochromatic (all clusters very similar)
|
||||
let max_distance = all_colors
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|c| dominant.distance(c))
|
||||
.fold(0.0f64, f64::max);
|
||||
|
||||
let is_monochrome = max_distance < 40.0;
|
||||
|
||||
// Assign background roles from dominant color with lightness shifts
|
||||
let (bg_primary, bg_secondary, bg_tertiary, bg_hover) = if is_dark {
|
||||
(
|
||||
dominant.shift_lightness(-0.05), // slightly darker
|
||||
dominant.shift_lightness(0.03),
|
||||
dominant.shift_lightness(0.08),
|
||||
dominant.shift_lightness(0.02),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
dominant.shift_lightness(0.05), // slightly lighter
|
||||
dominant.shift_lightness(-0.03),
|
||||
dominant.shift_lightness(-0.08),
|
||||
dominant.shift_lightness(-0.02),
|
||||
)
|
||||
};
|
||||
|
||||
// Find best accent: most saturated cluster with WCAG AA contrast (4.5:1) against bg_primary
|
||||
let accent = if is_monochrome {
|
||||
// Monochrome fallback: use a standard accent
|
||||
if is_dark {
|
||||
PaletteColor::new(66, 133, 244) // blue
|
||||
} else {
|
||||
PaletteColor::new(26, 115, 232) // darker blue
|
||||
}
|
||||
} else {
|
||||
find_best_accent(&all_colors, &bg_primary, is_dark)
|
||||
};
|
||||
|
||||
Ok(ThemePalette {
|
||||
bg_primary,
|
||||
bg_secondary,
|
||||
bg_tertiary,
|
||||
bg_hover,
|
||||
accent,
|
||||
is_dark,
|
||||
all_colors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the best accent color: prioritize saturation, then ensure WCAG AA contrast.
|
||||
fn find_best_accent(
|
||||
colors: &[PaletteColor],
|
||||
bg: &PaletteColor,
|
||||
is_dark: bool,
|
||||
) -> PaletteColor {
|
||||
// Sort candidates by saturation (highest first), skip the dominant (index 0)
|
||||
let mut candidates: Vec<(usize, f64)> = colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(1)
|
||||
.map(|(i, c)| (i, c.saturation()))
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
for (idx, _sat) in &candidates {
|
||||
let color = colors[*idx];
|
||||
let ratio = color.contrast_ratio(bg);
|
||||
if ratio >= 4.5 {
|
||||
return color;
|
||||
}
|
||||
// Try adjusting lightness to meet contrast
|
||||
let adjusted = adjust_for_contrast(&color, bg, is_dark);
|
||||
if adjusted.contrast_ratio(bg) >= 4.5 {
|
||||
return adjusted;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the most saturated and force-adjust
|
||||
if let Some((idx, _)) = candidates.first() {
|
||||
let color = colors[*idx];
|
||||
adjust_for_contrast(&color, bg, is_dark)
|
||||
} else {
|
||||
// Ultimate fallback
|
||||
if is_dark {
|
||||
PaletteColor::new(66, 133, 244)
|
||||
} else {
|
||||
PaletteColor::new(26, 115, 232)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust a color's lightness to achieve WCAG AA contrast against a background.
|
||||
fn adjust_for_contrast(
|
||||
color: &PaletteColor,
|
||||
bg: &PaletteColor,
|
||||
is_dark: bool,
|
||||
) -> PaletteColor {
|
||||
let (h, s, l) = color.to_hsl();
|
||||
let direction = if is_dark { 0.05 } else { -0.05 };
|
||||
|
||||
let mut new_l = l;
|
||||
for _ in 0..20 {
|
||||
new_l = (new_l + direction).clamp(0.0, 1.0);
|
||||
let candidate = PaletteColor::from_hsl(h, s, new_l);
|
||||
if candidate.contrast_ratio(bg) >= 4.5 {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
PaletteColor::from_hsl(h, s, new_l)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_kmeans_basic() {
|
||||
let pixels: Vec<[f64; 3]> = vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[10.0, 10.0, 10.0],
|
||||
[5.0, 5.0, 5.0],
|
||||
[250.0, 250.0, 250.0],
|
||||
[240.0, 240.0, 240.0],
|
||||
[245.0, 245.0, 245.0],
|
||||
];
|
||||
let clusters = kmeans(&pixels, 2, 20);
|
||||
assert_eq!(clusters.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_pixels() {
|
||||
// A mix of dark blues and bright oranges
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..500 {
|
||||
pixels.push([20.0, 30.0, 80.0]); // dark blue
|
||||
}
|
||||
for _ in 0..300 {
|
||||
pixels.push([240.0, 160.0, 40.0]); // orange
|
||||
}
|
||||
for _ in 0..200 {
|
||||
pixels.push([10.0, 10.0, 15.0]); // near-black
|
||||
}
|
||||
|
||||
let palette = extract_palette_from_pixels(&pixels).unwrap();
|
||||
assert!(palette.is_dark); // dominant is dark
|
||||
assert!(palette.accent.saturation() > 0.1); // accent should be saturated
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_monochrome_fallback() {
|
||||
// All pixels are very similar grays
|
||||
let pixels: Vec<[f64; 3]> = (0..1000).map(|_| [50.0, 52.0, 48.0]).collect();
|
||||
let palette = extract_palette_from_pixels(&pixels).unwrap();
|
||||
// Should still produce a valid palette with fallback accent
|
||||
assert!(palette.accent.saturation() > 0.1 || palette.accent.luminance() > 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accent_has_contrast() {
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..700 {
|
||||
pixels.push([15.0, 15.0, 20.0]);
|
||||
}
|
||||
for _ in 0..300 {
|
||||
pixels.push([200.0, 50.0, 80.0]);
|
||||
}
|
||||
|
||||
let palette = extract_palette_from_pixels(&pixels).unwrap();
|
||||
let ratio = palette.accent.contrast_ratio(&palette.bg_primary);
|
||||
// Should meet or approach WCAG AA
|
||||
assert!(ratio >= 3.0, "Accent contrast ratio too low: {}", ratio);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
//! Desktop environment detection, wallpaper path retrieval, and system accent color.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use super::{PaletteColor, SystemColorScheme};
|
||||
|
||||
/// Supported desktop environments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DesktopEnvironment {
|
||||
Gnome,
|
||||
KdePlasma,
|
||||
Cosmic,
|
||||
Xfce,
|
||||
Cinnamon,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl DesktopEnvironment {
|
||||
/// Human-readable name.
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Gnome => "GNOME",
|
||||
Self::KdePlasma => "KDE Plasma",
|
||||
Self::Cosmic => "COSMIC",
|
||||
Self::Xfce => "Xfce",
|
||||
Self::Cinnamon => "Cinnamon",
|
||||
Self::Unknown(name) => name.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the current desktop environment.
|
||||
pub fn detect_desktop_environment() -> DesktopEnvironment {
|
||||
// Try each env var in priority order
|
||||
let candidates = [
|
||||
env::var("XDG_CURRENT_DESKTOP"),
|
||||
env::var("XDG_SESSION_DESKTOP"),
|
||||
env::var("DESKTOP_SESSION"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if let Ok(val) = candidate {
|
||||
let upper = val.to_uppercase();
|
||||
if upper.contains("GNOME") || upper.contains("UNITY") || upper.contains("UBUNTU") {
|
||||
return DesktopEnvironment::Gnome;
|
||||
}
|
||||
if upper.contains("KDE") || upper.contains("PLASMA") {
|
||||
return DesktopEnvironment::KdePlasma;
|
||||
}
|
||||
if upper.contains("COSMIC") {
|
||||
return DesktopEnvironment::Cosmic;
|
||||
}
|
||||
if upper.contains("XFCE") {
|
||||
return DesktopEnvironment::Xfce;
|
||||
}
|
||||
if upper.contains("CINNAMON") || upper.contains("X-CINNAMON") {
|
||||
return DesktopEnvironment::Cinnamon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let name = candidates
|
||||
.iter()
|
||||
.find_map(|c| c.as_ref().ok().cloned())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
DesktopEnvironment::Unknown(name)
|
||||
}
|
||||
|
||||
/// Get the current wallpaper path for the detected DE.
|
||||
pub fn get_system_wallpaper() -> Result<String, String> {
|
||||
let de = detect_desktop_environment();
|
||||
get_wallpaper_for_de(&de)
|
||||
}
|
||||
|
||||
fn get_wallpaper_for_de(de: &DesktopEnvironment) -> Result<String, String> {
|
||||
match de {
|
||||
DesktopEnvironment::Gnome => get_gnome_wallpaper(),
|
||||
DesktopEnvironment::KdePlasma => get_kde_wallpaper(),
|
||||
DesktopEnvironment::Cosmic => get_cosmic_wallpaper(),
|
||||
DesktopEnvironment::Cinnamon => get_cinnamon_wallpaper(),
|
||||
DesktopEnvironment::Xfce => get_xfce_wallpaper(),
|
||||
DesktopEnvironment::Unknown(name) => {
|
||||
Err(format!("Unsupported desktop environment: {}", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the system accent color for the detected DE.
|
||||
pub fn get_system_accent_color() -> Result<PaletteColor, String> {
|
||||
let de = detect_desktop_environment();
|
||||
get_accent_for_de(&de)
|
||||
}
|
||||
|
||||
fn get_accent_for_de(de: &DesktopEnvironment) -> Result<PaletteColor, String> {
|
||||
match de {
|
||||
DesktopEnvironment::Gnome => get_gnome_accent(),
|
||||
DesktopEnvironment::KdePlasma => get_kde_accent(),
|
||||
DesktopEnvironment::Cosmic => get_cosmic_accent(),
|
||||
_ => Err(format!(
|
||||
"Accent color not supported for {}",
|
||||
de.display_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// --- GNOME ---
|
||||
|
||||
fn get_gnome_wallpaper() -> Result<String, String> {
|
||||
// Try dark variant first, then standard
|
||||
for key in &["picture-uri-dark", "picture-uri"] {
|
||||
let output = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.background", key])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run gsettings: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if let Some(path) = parse_gsettings_uri(&raw) {
|
||||
if PathBuf::from(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Could not determine GNOME wallpaper".into())
|
||||
}
|
||||
|
||||
fn get_gnome_accent() -> Result<PaletteColor, String> {
|
||||
let output = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "accent-color"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run gsettings: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("gsettings accent-color not available (requires GNOME 47+)".into());
|
||||
}
|
||||
|
||||
let raw = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.trim_matches('\'')
|
||||
.to_lowercase();
|
||||
|
||||
// GNOME accent color names → approximate RGB values
|
||||
let color = match raw.as_str() {
|
||||
"blue" => PaletteColor::new(53, 132, 228),
|
||||
"teal" => PaletteColor::new(38, 162, 105),
|
||||
"green" => PaletteColor::new(51, 209, 122),
|
||||
"yellow" => PaletteColor::new(246, 211, 45),
|
||||
"orange" => PaletteColor::new(255, 120, 0),
|
||||
"red" => PaletteColor::new(224, 27, 36),
|
||||
"pink" => PaletteColor::new(220, 138, 221),
|
||||
"purple" => PaletteColor::new(145, 65, 172),
|
||||
"slate" => PaletteColor::new(111, 131, 150),
|
||||
_ => return Err(format!("Unknown GNOME accent color: {}", raw)),
|
||||
};
|
||||
|
||||
Ok(color)
|
||||
}
|
||||
|
||||
// --- KDE Plasma ---
|
||||
|
||||
fn get_kde_wallpaper() -> Result<String, String> {
|
||||
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||
let config_path = format!(
|
||||
"{}/.config/plasma-org.kde.plasma.desktop-appletsrc",
|
||||
home
|
||||
);
|
||||
|
||||
let content =
|
||||
fs::read_to_string(&config_path).map_err(|e| format!("Cannot read Plasma config: {}", e))?;
|
||||
|
||||
// Look for Image= under [Wallpaper][org.kde.image][General]
|
||||
let mut in_wallpaper_section = false;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.starts_with('[') {
|
||||
// Check if we're entering a wallpaper image section
|
||||
in_wallpaper_section = trimmed.contains("Wallpaper")
|
||||
&& trimmed.contains("org.kde.image")
|
||||
&& trimmed.contains("General");
|
||||
}
|
||||
|
||||
if in_wallpaper_section && trimmed.starts_with("Image=") {
|
||||
let value = trimmed.trim_start_matches("Image=").trim();
|
||||
if let Some(path) = parse_file_uri(value) {
|
||||
if PathBuf::from(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
// Try as plain path
|
||||
if PathBuf::from(value).exists() {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not find wallpaper in Plasma config".into())
|
||||
}
|
||||
|
||||
fn get_kde_accent() -> Result<PaletteColor, String> {
|
||||
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||
let config_path = format!("{}/.config/kdeglobals", home);
|
||||
|
||||
let content =
|
||||
fs::read_to_string(&config_path).map_err(|e| format!("Cannot read kdeglobals: {}", e))?;
|
||||
|
||||
// 1. Try explicit AccentColor in [General] (Plasma 6 custom accent)
|
||||
let mut in_general = false;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed == "[General]" {
|
||||
in_general = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('[') {
|
||||
in_general = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_general && trimmed.starts_with("AccentColor=") {
|
||||
let value = trimmed.trim_start_matches("AccentColor=").trim();
|
||||
return parse_rgb_csv(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: read from color scheme sections (DecorationFocus or Selection background)
|
||||
// Priority: Colors:Selection DecorationFocus → BackgroundNormal → Colors:View DecorationFocus
|
||||
let fallback_sections = [
|
||||
("[Colors:Selection]", "DecorationFocus"),
|
||||
("[Colors:Selection]", "BackgroundNormal"),
|
||||
("[Colors:View]", "DecorationFocus"),
|
||||
];
|
||||
|
||||
for (section, key) in &fallback_sections {
|
||||
if let Some(color) = read_kde_color_key(&content, section, key) {
|
||||
return Ok(color);
|
||||
}
|
||||
}
|
||||
|
||||
Err("AccentColor not found in kdeglobals (no explicit accent or color scheme)".into())
|
||||
}
|
||||
|
||||
/// Read a specific key from a KDE config section, parsing "r,g,b" format.
|
||||
fn read_kde_color_key(content: &str, section: &str, key: &str) -> Option<PaletteColor> {
|
||||
let mut in_section = false;
|
||||
let prefix = format!("{}=", key);
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed == section {
|
||||
in_section = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('[') {
|
||||
in_section = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_section && trimmed.starts_with(&prefix) {
|
||||
let value = trimmed[prefix.len()..].trim();
|
||||
return parse_rgb_csv(value).ok();
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// --- COSMIC ---
|
||||
|
||||
fn get_cosmic_wallpaper() -> Result<String, String> {
|
||||
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||
|
||||
// COSMIC stores wallpaper config in its own config system
|
||||
let config_paths = [
|
||||
format!(
|
||||
"{}/.config/cosmic/com.system76.CosmicBackground/v1/all",
|
||||
home
|
||||
),
|
||||
format!(
|
||||
"{}/.config/cosmic/com.system76.CosmicBackground/v1/backgrounds",
|
||||
home
|
||||
),
|
||||
];
|
||||
|
||||
for config_path in &config_paths {
|
||||
if let Ok(content) = fs::read_to_string(config_path) {
|
||||
// COSMIC config may contain paths in RON format or plain text
|
||||
// Look for file paths
|
||||
if let Some(path) = extract_path_from_cosmic_config(&content) {
|
||||
if PathBuf::from(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not find wallpaper in COSMIC config".into())
|
||||
}
|
||||
|
||||
fn get_cosmic_accent() -> Result<PaletteColor, String> {
|
||||
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||
|
||||
// Try dark and light theme accent
|
||||
let accent_paths = [
|
||||
format!(
|
||||
"{}/.config/cosmic/com.system76.CosmicTheme.Dark/v1/accent",
|
||||
home
|
||||
),
|
||||
format!(
|
||||
"{}/.config/cosmic/com.system76.CosmicTheme.Light/v1/accent",
|
||||
home
|
||||
),
|
||||
];
|
||||
|
||||
for path in &accent_paths {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Some(color) = parse_cosmic_color(&content) {
|
||||
return Ok(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not read COSMIC accent color".into())
|
||||
}
|
||||
|
||||
// --- Cinnamon ---
|
||||
|
||||
fn get_cinnamon_wallpaper() -> Result<String, String> {
|
||||
let output = Command::new("gsettings")
|
||||
.args(["get", "org.cinnamon.desktop.background", "picture-uri"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run gsettings: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Could not get Cinnamon wallpaper via gsettings".into());
|
||||
}
|
||||
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if let Some(path) = parse_gsettings_uri(&raw) {
|
||||
if PathBuf::from(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not determine Cinnamon wallpaper".into())
|
||||
}
|
||||
|
||||
// --- XFCE ---
|
||||
|
||||
fn get_xfce_wallpaper() -> Result<String, String> {
|
||||
let output = Command::new("xfconf-query")
|
||||
.args([
|
||||
"-c",
|
||||
"xfce4-desktop",
|
||||
"-p",
|
||||
"/backdrop/screen0/monitoreDP-1/workspace0/last-image",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xfconf-query: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if PathBuf::from(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try generic monitor path
|
||||
let output = Command::new("xfconf-query")
|
||||
.args([
|
||||
"-c",
|
||||
"xfce4-desktop",
|
||||
"-l",
|
||||
"-v",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to list xfce4-desktop properties: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("last-image") {
|
||||
// Format: /backdrop/.../last-image /path/to/image
|
||||
let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
|
||||
if parts.len() == 2 {
|
||||
let path = parts[1].trim();
|
||||
if PathBuf::from(path).exists() {
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not determine XFCE wallpaper".into())
|
||||
}
|
||||
|
||||
// --- Parsing helpers ---
|
||||
|
||||
/// Parse gsettings output like `'file:///path/to/wallpaper.jpg'` into a filesystem path.
|
||||
fn parse_gsettings_uri(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim().trim_matches('\'').trim_matches('"');
|
||||
parse_file_uri(trimmed).or_else(|| {
|
||||
// Some gsettings return plain paths
|
||||
if PathBuf::from(trimmed).is_absolute() {
|
||||
Some(trimmed.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract filesystem path from `file:///path` URI.
|
||||
fn parse_file_uri(uri: &str) -> Option<String> {
|
||||
if let Some(path) = uri.strip_prefix("file://") {
|
||||
// URL-decode basic cases (spaces as %20)
|
||||
let decoded = path.replace("%20", " ");
|
||||
Some(decoded)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "r,g,b" CSV format (KDE).
|
||||
fn parse_rgb_csv(value: &str) -> Result<PaletteColor, String> {
|
||||
let parts: Vec<&str> = value.split(',').collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(format!("Invalid RGB CSV: {}", value));
|
||||
}
|
||||
let r = parts[0]
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.map_err(|_| format!("Invalid R value: {}", parts[0]))?;
|
||||
let g = parts[1]
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.map_err(|_| format!("Invalid G value: {}", parts[1]))?;
|
||||
let b = parts[2]
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.map_err(|_| format!("Invalid B value: {}", parts[2]))?;
|
||||
Ok(PaletteColor::new(r, g, b))
|
||||
}
|
||||
|
||||
/// Best-effort extraction of image path from COSMIC config content.
|
||||
fn extract_path_from_cosmic_config(content: &str) -> Option<String> {
|
||||
// COSMIC config may have paths in various formats
|
||||
// Look for common image file patterns
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim().trim_matches('"').trim_matches('\'');
|
||||
|
||||
// Check for file:// URI
|
||||
if let Some(path) = parse_file_uri(trimmed) {
|
||||
if is_image_path(&path) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for absolute path to an image
|
||||
if trimmed.starts_with('/') && is_image_path(trimmed) {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
// Look for path embedded in the line (e.g., in RON format)
|
||||
if let Some(start) = trimmed.find('/') {
|
||||
let potential = &trimmed[start..];
|
||||
// Find end of path (before closing quote/paren)
|
||||
let end = potential
|
||||
.find(|c: char| c == '"' || c == '\'' || c == ')' || c == ',')
|
||||
.unwrap_or(potential.len());
|
||||
let path = &potential[..end];
|
||||
if is_image_path(path) && PathBuf::from(path).is_absolute() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse COSMIC color format (may be RON-like with RGBA floats).
|
||||
fn parse_cosmic_color(content: &str) -> Option<PaletteColor> {
|
||||
// COSMIC accent is typically stored as float RGBA (0.0-1.0)
|
||||
// Try to extract numeric values
|
||||
let nums: Vec<f64> = content
|
||||
.split(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
|
||||
.filter_map(|s| s.parse::<f64>().ok())
|
||||
.collect();
|
||||
|
||||
if nums.len() >= 3 {
|
||||
// Check if values are in 0-1 range (float) or 0-255 range (int)
|
||||
let (r, g, b) = if nums[0] <= 1.0 && nums[1] <= 1.0 && nums[2] <= 1.0 {
|
||||
(
|
||||
(nums[0] * 255.0).round() as u8,
|
||||
(nums[1] * 255.0).round() as u8,
|
||||
(nums[2] * 255.0).round() as u8,
|
||||
)
|
||||
} else {
|
||||
(nums[0] as u8, nums[1] as u8, nums[2] as u8)
|
||||
};
|
||||
Some(PaletteColor::new(r, g, b))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full color scheme ────────────────────────────────────────────────────
|
||||
|
||||
/// Read the full system color scheme from the current DE.
|
||||
pub fn get_system_color_scheme() -> Result<SystemColorScheme, String> {
|
||||
let de = detect_desktop_environment();
|
||||
match de {
|
||||
DesktopEnvironment::KdePlasma => get_kde_color_scheme(),
|
||||
DesktopEnvironment::Gnome => get_gnome_color_scheme(),
|
||||
_ => Err(format!(
|
||||
"Full color scheme not supported for {}",
|
||||
de.display_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_kde_color_scheme() -> Result<SystemColorScheme, String> {
|
||||
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||
let config_path = format!("{}/.config/kdeglobals", home);
|
||||
let content =
|
||||
fs::read_to_string(&config_path).map_err(|e| format!("Cannot read kdeglobals: {}", e))?;
|
||||
|
||||
// Also check [General] AccentColor
|
||||
let accent_explicit = read_kde_color_key(&content, "[General]", "AccentColor");
|
||||
|
||||
let scheme = SystemColorScheme {
|
||||
// Window
|
||||
window_bg: read_kde_color_key(&content, "[Colors:Window]", "BackgroundNormal"),
|
||||
window_bg_alt: read_kde_color_key(&content, "[Colors:Window]", "BackgroundAlternate"),
|
||||
view_bg: read_kde_color_key(&content, "[Colors:View]", "BackgroundNormal"),
|
||||
button_bg: read_kde_color_key(&content, "[Colors:Button]", "BackgroundNormal"),
|
||||
header_bg: read_kde_color_key(&content, "[Colors:Header]", "BackgroundNormal"),
|
||||
header_bg_inactive: read_kde_color_key(&content, "[Colors:Header][Inactive]", "BackgroundNormal"),
|
||||
tooltip_bg: read_kde_color_key(&content, "[Colors:Tooltip]", "BackgroundNormal"),
|
||||
|
||||
// Foregrounds
|
||||
window_fg: read_kde_color_key(&content, "[Colors:Window]", "ForegroundNormal"),
|
||||
window_fg_inactive: read_kde_color_key(&content, "[Colors:Window]", "ForegroundInactive"),
|
||||
view_fg: read_kde_color_key(&content, "[Colors:View]", "ForegroundNormal"),
|
||||
button_fg: read_kde_color_key(&content, "[Colors:Button]", "ForegroundNormal"),
|
||||
|
||||
// Selection / accent
|
||||
selection_bg: read_kde_color_key(&content, "[Colors:Selection]", "BackgroundNormal"),
|
||||
selection_fg: read_kde_color_key(&content, "[Colors:Selection]", "ForegroundNormal"),
|
||||
selection_hover: read_kde_color_key(&content, "[Colors:Selection]", "DecorationHover"),
|
||||
accent: accent_explicit.or_else(|| {
|
||||
read_kde_color_key(&content, "[Colors:Selection]", "DecorationFocus")
|
||||
}),
|
||||
|
||||
// Semantic
|
||||
fg_link: read_kde_color_key(&content, "[Colors:Window]", "ForegroundLink"),
|
||||
fg_negative: read_kde_color_key(&content, "[Colors:Window]", "ForegroundNegative"),
|
||||
fg_neutral: read_kde_color_key(&content, "[Colors:Window]", "ForegroundNeutral"),
|
||||
fg_positive: read_kde_color_key(&content, "[Colors:Window]", "ForegroundPositive"),
|
||||
|
||||
// Window manager
|
||||
wm_active_bg: read_kde_color_key(&content, "[WM]", "activeBackground"),
|
||||
wm_active_fg: read_kde_color_key(&content, "[WM]", "activeForeground"),
|
||||
wm_inactive_bg: read_kde_color_key(&content, "[WM]", "inactiveBackground"),
|
||||
};
|
||||
|
||||
// Validate: at minimum we need window_bg and some accent
|
||||
if scheme.window_bg.is_none() {
|
||||
return Err("KDE color scheme missing Colors:Window BackgroundNormal".into());
|
||||
}
|
||||
|
||||
Ok(scheme)
|
||||
}
|
||||
|
||||
fn get_gnome_color_scheme() -> Result<SystemColorScheme, String> {
|
||||
// GNOME exposes much less via dconf. We can detect dark vs light and accent.
|
||||
// Full background/text colors are determined by GTK theme, not easily readable.
|
||||
let accent = get_gnome_accent().ok();
|
||||
|
||||
// Detect dark/light preference
|
||||
let is_dark = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
let val = String::from_utf8_lossy(&o.stdout).trim().to_lowercase();
|
||||
Some(val.contains("dark"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
// Build a minimal scheme with GNOME defaults
|
||||
let (bg, bg_alt, view_bg, btn_bg, fg, fg_inactive) = if is_dark {
|
||||
(
|
||||
PaletteColor::new(36, 36, 36), // Adwaita Dark
|
||||
PaletteColor::new(48, 48, 48),
|
||||
PaletteColor::new(30, 30, 30),
|
||||
PaletteColor::new(60, 60, 60),
|
||||
PaletteColor::new(255, 255, 255),
|
||||
PaletteColor::new(140, 140, 140),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
PaletteColor::new(246, 245, 244), // Adwaita Light
|
||||
PaletteColor::new(235, 235, 235),
|
||||
PaletteColor::new(255, 255, 255),
|
||||
PaletteColor::new(225, 225, 225),
|
||||
PaletteColor::new(36, 36, 36),
|
||||
PaletteColor::new(120, 120, 120),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(SystemColorScheme {
|
||||
window_bg: Some(bg),
|
||||
window_bg_alt: Some(bg_alt),
|
||||
view_bg: Some(view_bg),
|
||||
button_bg: Some(btn_bg),
|
||||
header_bg: None,
|
||||
header_bg_inactive: None,
|
||||
tooltip_bg: None,
|
||||
window_fg: Some(fg),
|
||||
window_fg_inactive: Some(fg_inactive),
|
||||
view_fg: Some(fg),
|
||||
button_fg: Some(PaletteColor::new(255, 255, 255)),
|
||||
selection_bg: accent.clone(),
|
||||
selection_fg: Some(PaletteColor::new(255, 255, 255)),
|
||||
selection_hover: None,
|
||||
accent,
|
||||
fg_link: None,
|
||||
fg_negative: Some(PaletteColor::new(224, 27, 36)),
|
||||
fg_neutral: Some(PaletteColor::new(205, 147, 9)),
|
||||
fg_positive: Some(PaletteColor::new(38, 162, 105)),
|
||||
wm_active_bg: None,
|
||||
wm_active_fg: None,
|
||||
wm_inactive_bg: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_image_path(path: &str) -> bool {
|
||||
let lower = path.to_lowercase();
|
||||
lower.ends_with(".png")
|
||||
|| lower.ends_with(".jpg")
|
||||
|| lower.ends_with(".jpeg")
|
||||
|| lower.ends_with(".webp")
|
||||
|| lower.ends_with(".bmp")
|
||||
|| lower.ends_with(".tiff")
|
||||
|| lower.ends_with(".tif")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_de() {
|
||||
// Just verify it doesn't panic
|
||||
let _de = detect_desktop_environment();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gsettings_uri() {
|
||||
assert_eq!(
|
||||
parse_gsettings_uri("'file:///home/user/wallpaper.jpg'"),
|
||||
Some("/home/user/wallpaper.jpg".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_gsettings_uri("'file:///home/user/my%20wallpaper.png'"),
|
||||
Some("/home/user/my wallpaper.png".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_file_uri() {
|
||||
assert_eq!(
|
||||
parse_file_uri("file:///home/user/pic.jpg"),
|
||||
Some("/home/user/pic.jpg".to_string())
|
||||
);
|
||||
assert_eq!(parse_file_uri("/just/a/path"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rgb_csv() {
|
||||
let color = parse_rgb_csv("66,133,244").unwrap();
|
||||
assert_eq!(color, PaletteColor::new(66, 133, 244));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rgb_csv_with_spaces() {
|
||||
let color = parse_rgb_csv(" 66 , 133 , 244 ").unwrap();
|
||||
assert_eq!(color, PaletteColor::new(66, 133, 244));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cosmic_color_float() {
|
||||
let color = parse_cosmic_color("(0.26, 0.52, 0.96, 1.0)").unwrap();
|
||||
assert_eq!(color.r, 66);
|
||||
assert_eq!(color.g, 133);
|
||||
assert_eq!(color.b, 245);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_image_path() {
|
||||
assert!(is_image_path("/home/user/wall.jpg"));
|
||||
assert!(is_image_path("/home/user/wall.PNG"));
|
||||
assert!(is_image_path("/home/user/wall.webp"));
|
||||
assert!(!is_image_path("/home/user/wall.mp4"));
|
||||
assert!(!is_image_path("/home/user/wall.txt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Graphics auto-configuration tool
|
||||
//!
|
||||
//! Detects the current GPU, display server, desktop environment, and compositor,
|
||||
//! then recommends and applies optimal graphics settings.
|
||||
//!
|
||||
//! Usage: `qbz --autoconfig-graphics`
|
||||
|
||||
use crate::config::graphics_settings::GraphicsSettingsStore;
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
/// Detected environment information
|
||||
struct Environment {
|
||||
display_server: String,
|
||||
gpu_nvidia: bool,
|
||||
gpu_amd: bool,
|
||||
gpu_intel: bool,
|
||||
gpu_name: String,
|
||||
desktop: String,
|
||||
is_vm: bool,
|
||||
}
|
||||
|
||||
/// Recommended configuration
|
||||
struct Recommendation {
|
||||
hardware_acceleration: bool,
|
||||
force_x11: bool,
|
||||
gsk_renderer: Option<String>,
|
||||
disable_dmabuf: bool,
|
||||
disable_blur_background: bool,
|
||||
rationale: Vec<String>,
|
||||
}
|
||||
|
||||
/// Run the autoconfig-graphics CLI tool.
|
||||
pub fn run() {
|
||||
eprintln!("[QBZ AutoConfig] Detecting environment...");
|
||||
eprintln!();
|
||||
|
||||
let env = detect_environment();
|
||||
print_environment(&env);
|
||||
|
||||
let rec = compute_recommendation(&env);
|
||||
print_recommendation(&rec);
|
||||
|
||||
eprintln!();
|
||||
eprint!("Apply this configuration? [Y/n] ");
|
||||
io::stderr().flush().ok();
|
||||
|
||||
let mut input = String::new();
|
||||
if io::stdin().lock().read_line(&mut input).is_ok() {
|
||||
let answer = input.trim().to_lowercase();
|
||||
if answer.is_empty() || answer == "y" || answer == "yes" {
|
||||
apply_recommendation(&rec);
|
||||
} else {
|
||||
eprintln!("[QBZ AutoConfig] Aborted. No changes made.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("[QBZ AutoConfig] Could not read input. No changes made.");
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_environment() -> Environment {
|
||||
let display_server = detect_display_server();
|
||||
let gpu_nvidia = is_nvidia_gpu();
|
||||
let gpu_amd = is_amd_gpu();
|
||||
let gpu_intel = is_intel_gpu();
|
||||
let gpu_name = detect_gpu_name(gpu_nvidia, gpu_amd, gpu_intel);
|
||||
let desktop = detect_desktop();
|
||||
let is_vm = is_virtual_machine();
|
||||
|
||||
Environment {
|
||||
display_server,
|
||||
gpu_nvidia,
|
||||
gpu_amd,
|
||||
gpu_intel,
|
||||
gpu_name,
|
||||
desktop,
|
||||
is_vm,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_display_server() -> String {
|
||||
let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
|| std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("wayland");
|
||||
|
||||
if is_wayland {
|
||||
"Wayland".to_string()
|
||||
} else {
|
||||
"X11".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_nvidia_gpu() -> bool {
|
||||
std::path::Path::new("/proc/driver/nvidia/version").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("nvidia")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_amd_gpu() -> bool {
|
||||
std::path::Path::new("/sys/module/amdgpu").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("amdgpu")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_intel_gpu() -> bool {
|
||||
std::path::Path::new("/sys/module/i915").exists()
|
||||
|| std::fs::read_to_string("/proc/modules")
|
||||
.map(|m| m.lines().any(|l| l.starts_with("i915")))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_virtual_machine() -> bool {
|
||||
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
|
||||
let p = product.trim().to_lowercase();
|
||||
if p.contains("virtualbox")
|
||||
|| p.contains("vmware")
|
||||
|| p.contains("qemu")
|
||||
|| p.contains("bochs")
|
||||
|| p.contains("hyper-v")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(vendor) = std::fs::read_to_string("/sys/class/dmi/id/sys_vendor") {
|
||||
let v = vendor.trim().to_lowercase();
|
||||
if v.contains("innotek")
|
||||
|| v.contains("vmware")
|
||||
|| v.contains("qemu")
|
||||
|| v.contains("xen")
|
||||
|| v.contains("parallels")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Ok(h) = std::fs::read_to_string("/sys/hypervisor/type") {
|
||||
if !h.trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn detect_gpu_name(nvidia: bool, amd: bool, intel: bool) -> String {
|
||||
// Try to get a human-readable GPU name
|
||||
if nvidia {
|
||||
if let Ok(version) = std::fs::read_to_string("/proc/driver/nvidia/version") {
|
||||
if let Some(line) = version.lines().next() {
|
||||
return format!("NVIDIA ({})", line.trim());
|
||||
}
|
||||
}
|
||||
return "NVIDIA (driver loaded)".to_string();
|
||||
}
|
||||
if amd {
|
||||
// Try to read GPU model from DRM
|
||||
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("card") && !name.contains('-') {
|
||||
let model_path = entry.path().join("device/product_name");
|
||||
if let Ok(model) = std::fs::read_to_string(&model_path) {
|
||||
let model = model.trim();
|
||||
if !model.is_empty() {
|
||||
return format!("AMD {}", model);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "AMD (amdgpu driver loaded)".to_string();
|
||||
}
|
||||
if intel {
|
||||
return "Intel (i915 driver loaded)".to_string();
|
||||
}
|
||||
"Unknown / None detected".to_string()
|
||||
}
|
||||
|
||||
fn detect_desktop() -> String {
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
||||
let session = std::env::var("XDG_SESSION_DESKTOP").unwrap_or_default();
|
||||
let de = std::env::var("DESKTOP_SESSION").unwrap_or_default();
|
||||
|
||||
if !desktop.is_empty() {
|
||||
desktop
|
||||
} else if !session.is_empty() {
|
||||
session
|
||||
} else if !de.is_empty() {
|
||||
de
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn print_environment(env: &Environment) {
|
||||
eprintln!(" Display server : {}", env.display_server);
|
||||
eprintln!(" GPU : {}", env.gpu_name);
|
||||
eprintln!(" Desktop : {}", env.desktop);
|
||||
if env.is_vm {
|
||||
eprintln!(" Virtual machine: Yes");
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
fn compute_recommendation(env: &Environment) -> Recommendation {
|
||||
let mut rationale = Vec::new();
|
||||
|
||||
// VM: software rendering, no blur
|
||||
if env.is_vm {
|
||||
rationale.push("Virtual machine detected: using software rendering".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: false,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("cairo".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: true,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
let is_wayland = env.display_server == "Wayland";
|
||||
let desktop_lower = env.desktop.to_lowercase();
|
||||
let is_gnome = desktop_lower.contains("gnome");
|
||||
|
||||
// NVIDIA + Wayland + GNOME: known stutter combo
|
||||
if env.gpu_nvidia && is_wayland && is_gnome {
|
||||
rationale.push("NVIDIA + Wayland + GNOME: using GL renderer, DMA-BUF off".to_string());
|
||||
rationale.push("This combination has known compositing issues".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("gl".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// NVIDIA + Wayland (non-GNOME)
|
||||
if env.gpu_nvidia && is_wayland {
|
||||
rationale.push("NVIDIA + Wayland: using GL renderer, DMA-BUF off".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("gl".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// NVIDIA + X11
|
||||
if env.gpu_nvidia {
|
||||
rationale.push("NVIDIA + X11: full hardware acceleration, DMA-BUF off".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("gl".to_string()),
|
||||
disable_dmabuf: true,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// AMD + Wayland
|
||||
if env.gpu_amd && is_wayland {
|
||||
rationale.push("AMD + Wayland: NGL renderer with DMA-BUF".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("ngl".to_string()),
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// AMD + X11
|
||||
if env.gpu_amd {
|
||||
rationale.push("AMD + X11: full hardware acceleration".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Intel + Wayland
|
||||
if env.gpu_intel && is_wayland {
|
||||
rationale.push("Intel + Wayland: NGL renderer with DMA-BUF".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: Some("ngl".to_string()),
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Intel + X11
|
||||
if env.gpu_intel {
|
||||
rationale.push("Intel + X11: full hardware acceleration".to_string());
|
||||
return Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown GPU: safe defaults
|
||||
rationale.push("No known GPU detected: using safe defaults".to_string());
|
||||
Recommendation {
|
||||
hardware_acceleration: true,
|
||||
force_x11: false,
|
||||
gsk_renderer: None,
|
||||
disable_dmabuf: false,
|
||||
disable_blur_background: false,
|
||||
rationale,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_recommendation(rec: &Recommendation) {
|
||||
eprintln!("[QBZ AutoConfig] Recommended configuration:");
|
||||
eprintln!(
|
||||
" hardware_acceleration : {}",
|
||||
if rec.hardware_acceleration {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
);
|
||||
eprintln!(
|
||||
" force_x11 : {}",
|
||||
if rec.force_x11 { "on" } else { "off" }
|
||||
);
|
||||
eprintln!(
|
||||
" gsk_renderer : {}",
|
||||
rec.gsk_renderer.as_deref().unwrap_or("auto")
|
||||
);
|
||||
eprintln!(
|
||||
" disable_dmabuf : {}",
|
||||
if rec.disable_dmabuf { "yes" } else { "no" }
|
||||
);
|
||||
eprintln!(
|
||||
" disable_blur_background: {}",
|
||||
if rec.disable_blur_background {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
);
|
||||
eprintln!();
|
||||
for reason in &rec.rationale {
|
||||
eprintln!(" Rationale: {}", reason);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_recommendation(rec: &Recommendation) {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Apply graphics settings to DB
|
||||
match GraphicsSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_hardware_acceleration(rec.hardware_acceleration) {
|
||||
errors.push(format!("hardware_acceleration: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_force_x11(rec.force_x11) {
|
||||
errors.push(format!("force_x11: {}", e));
|
||||
}
|
||||
if let Err(e) = store.set_gsk_renderer(rec.gsk_renderer.clone()) {
|
||||
errors.push(format!("gsk_renderer: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("graphics settings store: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply developer settings (force_dmabuf)
|
||||
if rec.disable_dmabuf {
|
||||
// Note: we don't set force_dmabuf=true here because the default behavior
|
||||
// already disables DMA-BUF for NVIDIA+Wayland. We only reset it to ensure
|
||||
// it's not forced ON.
|
||||
match crate::config::developer_settings::DeveloperSettingsStore::new() {
|
||||
Ok(store) => {
|
||||
if let Err(e) = store.set_force_dmabuf(false) {
|
||||
errors.push(format!("force_dmabuf: {}", e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("developer settings store: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply blur background setting to localStorage config file
|
||||
// Note: This is stored in localStorage by the frontend, so we write a hint
|
||||
// that the frontend will read on next startup
|
||||
if rec.disable_blur_background {
|
||||
eprintln!(
|
||||
"[QBZ AutoConfig] Note: blur background will be disabled. You can toggle this in Settings > Appearance."
|
||||
);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
eprintln!();
|
||||
eprintln!("[QBZ AutoConfig] Configuration applied successfully.");
|
||||
eprintln!("[QBZ AutoConfig] Restart QBZ to take effect.");
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!("[QBZ AutoConfig] Some settings could not be applied:");
|
||||
for e in &errors {
|
||||
eprintln!(" - {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
//! Re-exports from qbz-cache crate for backwards compatibility.
|
||||
//!
|
||||
//! Provides two-level caching for audio data:
|
||||
//! - L1: In-memory LRU cache (fast, limited to ~300MB)
|
||||
//! - L2: Disk-based playback cache (slower, larger ~500MB)
|
||||
//! - L1: In-memory LRU cache (fast, limited to ~400MB)
|
||||
//! - L2: Disk-based playback cache (slower, larger ~800MB)
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. When a track is evicted from memory, it's saved to disk cache
|
||||
|
||||
@@ -771,7 +771,7 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), Stri
|
||||
Ok(decoder) => {
|
||||
use rodio::Source;
|
||||
// Assume 16-bit for rodio-decoded formats (MP3, etc.)
|
||||
Ok((decoder.sample_rate(), decoder.channels(), 16))
|
||||
Ok((decoder.sample_rate().get(), decoder.channels().get(), 16))
|
||||
}
|
||||
Err(_) => {
|
||||
// Default fallback
|
||||
@@ -1234,16 +1234,27 @@ fn get_pipewire_device_descriptions() -> std::collections::HashMap<String, Strin
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
|
||||
fn cpal_device_name(device: &rodio::cpal::Device) -> Option<String> {
|
||||
use rodio::cpal::traits::DeviceTrait;
|
||||
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|description| description.name().to_string())
|
||||
}
|
||||
|
||||
/// Get available audio output devices
|
||||
#[tauri::command]
|
||||
pub fn get_audio_devices() -> Result<Vec<AudioDevice>, String> {
|
||||
log::info!("Command: get_audio_devices");
|
||||
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
|
||||
let host = rodio::cpal::default_host();
|
||||
|
||||
let default_device_name = host.default_output_device().and_then(|d| d.name().ok());
|
||||
let default_device_name = host
|
||||
.default_output_device()
|
||||
.and_then(|d| cpal_device_name(&d));
|
||||
|
||||
// Get friendly names from PipeWire/PulseAudio
|
||||
let friendly_names = get_pipewire_device_descriptions();
|
||||
@@ -1252,7 +1263,7 @@ pub fn get_audio_devices() -> Result<Vec<AudioDevice>, String> {
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.filter_map(|device| {
|
||||
device.name().ok().map(|name| {
|
||||
cpal_device_name(&device).map(|name| {
|
||||
let is_default = default_device_name
|
||||
.as_ref()
|
||||
.map(|d| d == &name)
|
||||
@@ -1376,7 +1387,9 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
|
||||
let host = rodio::cpal::default_host();
|
||||
|
||||
// Get default device name from CPAL
|
||||
let default_device_name = host.default_output_device().and_then(|d| d.name().ok());
|
||||
let default_device_name = host
|
||||
.default_output_device()
|
||||
.and_then(|d| cpal_device_name(&d));
|
||||
|
||||
log::info!("CPAL default device: {:?}", default_device_name);
|
||||
|
||||
@@ -1386,48 +1399,48 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.enumerate()
|
||||
.filter_map(|(idx, device)| {
|
||||
match device.name() {
|
||||
Ok(name) => {
|
||||
let is_default = default_device_name
|
||||
.as_ref()
|
||||
.map(|d| d == &name)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Get detailed device info for logging
|
||||
let configs_info = device
|
||||
.supported_output_configs()
|
||||
.ok()
|
||||
.map(|configs| {
|
||||
let config_strs: Vec<String> = configs
|
||||
.take(3) // Just first 3 configs for brevity
|
||||
.map(|c| format!("{}ch/{}Hz", c.channels(), c.max_sample_rate().0))
|
||||
.collect();
|
||||
config_strs.join(", ")
|
||||
})
|
||||
.unwrap_or_else(|| "no configs".to_string());
|
||||
|
||||
log::info!(
|
||||
" [{}] Device: '{}' (default: {}) - Configs: {}",
|
||||
idx,
|
||||
name,
|
||||
is_default,
|
||||
configs_info
|
||||
);
|
||||
|
||||
// CRITICAL: Use CPAL device name as both name and description
|
||||
// This ensures the name we save is the exact name CPAL can find later
|
||||
Some(PipewireSink {
|
||||
name: name.clone(),
|
||||
description: name, // CPAL names are already user-friendly in PipeWire
|
||||
volume: None, // Volume not available via CPAL
|
||||
is_default,
|
||||
})
|
||||
let name = match cpal_device_name(&device) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
log::warn!(" [{}] Failed to get device description", idx);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(" [{}] Failed to get device name: {}", idx, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let is_default = default_device_name
|
||||
.as_ref()
|
||||
.map(|d| d == &name)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Get detailed device info for logging
|
||||
let configs_info = device
|
||||
.supported_output_configs()
|
||||
.ok()
|
||||
.map(|configs| {
|
||||
let config_strs: Vec<String> = configs
|
||||
.take(3) // Just first 3 configs for brevity
|
||||
.map(|c| format!("{}ch/{}Hz", c.channels(), c.max_sample_rate()))
|
||||
.collect();
|
||||
config_strs.join(", ")
|
||||
})
|
||||
.unwrap_or_else(|| "no configs".to_string());
|
||||
|
||||
log::info!(
|
||||
" [{}] Device: '{}' (default: {}) - Configs: {}",
|
||||
idx,
|
||||
name,
|
||||
is_default,
|
||||
configs_info
|
||||
);
|
||||
|
||||
// CRITICAL: Use CPAL device name as both name and description
|
||||
// This ensures the name we save is the exact name CPAL can find later
|
||||
Some(PipewireSink {
|
||||
name: name.clone(),
|
||||
description: name, // CPAL names are already user-friendly in PipeWire
|
||||
volume: None, // Volume not available via CPAL
|
||||
is_default,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1443,17 +1456,19 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
|
||||
pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
|
||||
log::info!("Command: get_pipewire_sinks (non-Linux, using cpal)");
|
||||
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
|
||||
let host = rodio::cpal::default_host();
|
||||
|
||||
let default_device_name = host.default_output_device().and_then(|d| d.name().ok());
|
||||
let default_device_name = host
|
||||
.default_output_device()
|
||||
.and_then(|d| cpal_device_name(&d));
|
||||
|
||||
let sinks: Vec<PipewireSink> = host
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.filter_map(|device| {
|
||||
device.name().ok().map(|name| {
|
||||
cpal_device_name(&device).map(|name| {
|
||||
let is_default = default_device_name
|
||||
.as_ref()
|
||||
.map(|d| d == &name)
|
||||
@@ -1506,7 +1521,7 @@ pub fn set_pipewire_default_sink(_sink_name: String) -> Result<(), String> {
|
||||
/// DEBUG: Get CPAL device names for comparison with PipeWire sinks
|
||||
#[tauri::command]
|
||||
pub fn debug_get_cpal_devices() -> Result<Vec<String>, String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
|
||||
log::info!("Command: debug_get_cpal_devices");
|
||||
|
||||
@@ -1515,7 +1530,7 @@ pub fn debug_get_cpal_devices() -> Result<Vec<String>, String> {
|
||||
let devices: Vec<String> = host
|
||||
.output_devices()
|
||||
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
||||
.filter_map(|device| device.name().ok())
|
||||
.filter_map(|device| cpal_device_name(&device))
|
||||
.collect();
|
||||
|
||||
log::info!("CPAL devices: {:?}", devices);
|
||||
|
||||
@@ -41,12 +41,16 @@ pub struct AudioSettings {
|
||||
/// Target loudness in LUFS for normalization.
|
||||
/// Common values: -14.0 (Spotify/YouTube), -18.0 (audiophile), -23.0 (EBU broadcast)
|
||||
pub normalization_target_lufs: f32,
|
||||
/// When true, tracks with the same format are cross-faded seamlessly via Rodio Sink queueing.
|
||||
/// Only works with cached tracks on Rodio backend (not ALSA Direct or streaming).
|
||||
/// When true, consecutive same-format tracks play without gap.
|
||||
/// Works on Rodio (PipeWire/Pulse) and ALSA Direct backends. Requires cached tracks.
|
||||
pub gapless_enabled: bool,
|
||||
/// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect.
|
||||
/// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough.
|
||||
pub pw_force_bitperfect: bool,
|
||||
/// When true, reload audio settings from DB into the player on app startup.
|
||||
/// Useful when Player::new() may hold stale settings (e.g., after Flatpak updates).
|
||||
/// Default: false (most users don't need this).
|
||||
pub sync_audio_on_startup: bool,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -67,8 +71,9 @@ impl Default for AudioSettings {
|
||||
device_sample_rate_limits: HashMap::new(), // Per-device limits (empty = no limit)
|
||||
normalization_enabled: false, // Off by default — preserves bit-perfect pipeline
|
||||
normalization_target_lufs: -14.0, // Spotify/YouTube standard
|
||||
gapless_enabled: false, // Off by default — user opts in
|
||||
gapless_enabled: true, // On by default — works for same-format tracks on all backends
|
||||
pw_force_bitperfect: false, // Off by default — experimental PipeWire feature
|
||||
sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +162,10 @@ impl AudioSettingsStore {
|
||||
"ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE audio_settings ADD COLUMN sync_audio_on_startup INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -175,7 +184,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -212,6 +221,7 @@ impl AudioSettingsStore {
|
||||
normalization_target_lufs: row.get::<_, Option<f64>>(13)?.unwrap_or(-14.0) as f32,
|
||||
gapless_enabled: row.get::<_, Option<i64>>(14)?.unwrap_or(0) != 0,
|
||||
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
|
||||
sync_audio_on_startup: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -435,6 +445,16 @@ impl AudioSettingsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_sync_audio_on_startup(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET sync_audio_on_startup = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set sync_audio_on_startup: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_normalization_target_lufs(&self, target: f32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
@@ -482,7 +502,8 @@ impl AudioSettingsStore {
|
||||
normalization_target_lufs = ?14,
|
||||
gapless_enabled = ?15,
|
||||
device_sample_rate_limits = ?16,
|
||||
pw_force_bitperfect = ?17
|
||||
pw_force_bitperfect = ?17,
|
||||
sync_audio_on_startup = ?18
|
||||
WHERE id = 1",
|
||||
params![
|
||||
defaults.output_device,
|
||||
@@ -502,6 +523,7 @@ impl AudioSettingsStore {
|
||||
defaults.gapless_enabled as i64,
|
||||
limits_json,
|
||||
defaults.pw_force_bitperfect as i64,
|
||||
defaults.sync_audio_on_startup as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to reset audio settings: {}", e))?;
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
//! Stores GPU/rendering preferences that take effect before WebView initialization.
|
||||
//! These settings are device-level (not per-user) and persist across sessions.
|
||||
//!
|
||||
//! - hardware_acceleration: GPU rendering toggle (default: on). Read at startup
|
||||
//! as the default value; env var QBZ_HARDWARE_ACCEL=0|1 overrides.
|
||||
//! - force_x11: force X11/XWayland backend on Wayland sessions (default: off)
|
||||
//! Env var QBZ_FORCE_X11=1|0 always overrides the stored value.
|
||||
//! - gdk_scale / gdk_dpi_scale: display scaling overrides for XWayland
|
||||
//!
|
||||
//! Note: hardware_acceleration is kept in the DB for legacy compatibility but
|
||||
//! is no longer read at startup. GPU rendering defaults are now determined by
|
||||
//! auto-detection (Wayland/NVIDIA). Use QBZ_HARDWARE_ACCEL=0 env var to
|
||||
//! explicitly disable all GPU rendering.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,6 +18,8 @@ use std::sync::{Arc, Mutex};
|
||||
static GRAPHICS_USING_FALLBACK: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_IS_WAYLAND: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_HAS_NVIDIA: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_HAS_AMD: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_HAS_INTEL: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_IS_VM: AtomicBool = AtomicBool::new(false);
|
||||
static GRAPHICS_HW_ACCEL: AtomicBool = AtomicBool::new(true);
|
||||
static GRAPHICS_FORCE_X11: AtomicBool = AtomicBool::new(false);
|
||||
@@ -30,6 +29,8 @@ pub fn set_startup_graphics_state(
|
||||
using_fallback: bool,
|
||||
is_wayland: bool,
|
||||
has_nvidia: bool,
|
||||
has_amd: bool,
|
||||
has_intel: bool,
|
||||
is_vm: bool,
|
||||
hw_accel: bool,
|
||||
force_x11: bool,
|
||||
@@ -37,6 +38,8 @@ pub fn set_startup_graphics_state(
|
||||
GRAPHICS_USING_FALLBACK.store(using_fallback, Ordering::SeqCst);
|
||||
GRAPHICS_IS_WAYLAND.store(is_wayland, Ordering::SeqCst);
|
||||
GRAPHICS_HAS_NVIDIA.store(has_nvidia, Ordering::SeqCst);
|
||||
GRAPHICS_HAS_AMD.store(has_amd, Ordering::SeqCst);
|
||||
GRAPHICS_HAS_INTEL.store(has_intel, Ordering::SeqCst);
|
||||
GRAPHICS_IS_VM.store(is_vm, Ordering::SeqCst);
|
||||
GRAPHICS_HW_ACCEL.store(hw_accel, Ordering::SeqCst);
|
||||
GRAPHICS_FORCE_X11.store(force_x11, Ordering::SeqCst);
|
||||
@@ -49,7 +52,7 @@ pub fn is_using_graphics_fallback() -> bool {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphicsSettings {
|
||||
/// Legacy field (kept for DB compat, not used at startup anymore)
|
||||
/// GPU rendering toggle. Read at startup as default; env var QBZ_HARDWARE_ACCEL overrides.
|
||||
pub hardware_acceleration: bool,
|
||||
/// Force X11 (XWayland) backend on Wayland sessions (requires restart)
|
||||
pub force_x11: bool,
|
||||
@@ -57,6 +60,8 @@ pub struct GraphicsSettings {
|
||||
pub gdk_scale: Option<String>,
|
||||
/// GDK_DPI_SCALE override for XWayland (None = auto). Float values: "0.5", "1", "1.5"
|
||||
pub gdk_dpi_scale: Option<String>,
|
||||
/// GSK_RENDERER override (None = auto). Values: "gl", "ngl", "vulkan", "cairo"
|
||||
pub gsk_renderer: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for GraphicsSettings {
|
||||
@@ -66,6 +71,7 @@ impl Default for GraphicsSettings {
|
||||
force_x11: false,
|
||||
gdk_scale: None,
|
||||
gdk_dpi_scale: None,
|
||||
gsk_renderer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +111,7 @@ impl GraphicsSettingsStore {
|
||||
);
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_scale TEXT;");
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gdk_dpi_scale TEXT;");
|
||||
let _ = conn.execute_batch("ALTER TABLE graphics_settings ADD COLUMN gsk_renderer TEXT;");
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -112,7 +119,7 @@ impl GraphicsSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<GraphicsSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale FROM graphics_settings WHERE id = 1",
|
||||
"SELECT hardware_acceleration, force_x11, gdk_scale, gdk_dpi_scale, gsk_renderer FROM graphics_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(GraphicsSettings {
|
||||
@@ -120,6 +127,7 @@ impl GraphicsSettingsStore {
|
||||
force_x11: row.get::<_, i64>(1)? != 0,
|
||||
gdk_scale: row.get::<_, Option<String>>(2)?,
|
||||
gdk_dpi_scale: row.get::<_, Option<String>>(3)?,
|
||||
gsk_renderer: row.get::<_, Option<String>>(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -165,6 +173,16 @@ impl GraphicsSettingsStore {
|
||||
.map_err(|e| format!("Failed to set gdk_dpi_scale: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_gsk_renderer(&self, value: Option<String>) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE graphics_settings SET gsk_renderer = ?1 WHERE id = 1",
|
||||
params![value],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set gsk_renderer: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for Tauri state management
|
||||
@@ -290,6 +308,10 @@ pub struct GraphicsStartupStatus {
|
||||
pub is_wayland: bool,
|
||||
/// True if NVIDIA GPU was detected
|
||||
pub has_nvidia: bool,
|
||||
/// True if AMD GPU was detected
|
||||
pub has_amd: bool,
|
||||
/// True if Intel GPU was detected
|
||||
pub has_intel: bool,
|
||||
/// True if running in a virtual machine
|
||||
pub is_vm: bool,
|
||||
/// True if hardware acceleration is enabled
|
||||
@@ -305,6 +327,8 @@ pub fn get_graphics_startup_status() -> GraphicsStartupStatus {
|
||||
using_fallback: GRAPHICS_USING_FALLBACK.load(Ordering::SeqCst),
|
||||
is_wayland: GRAPHICS_IS_WAYLAND.load(Ordering::SeqCst),
|
||||
has_nvidia: GRAPHICS_HAS_NVIDIA.load(Ordering::SeqCst),
|
||||
has_amd: GRAPHICS_HAS_AMD.load(Ordering::SeqCst),
|
||||
has_intel: GRAPHICS_HAS_INTEL.load(Ordering::SeqCst),
|
||||
is_vm: GRAPHICS_IS_VM.load(Ordering::SeqCst),
|
||||
hardware_accel_enabled: GRAPHICS_HW_ACCEL.load(Ordering::SeqCst),
|
||||
force_x11_active: GRAPHICS_FORCE_X11.load(Ordering::SeqCst),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Image cache settings persistence
|
||||
//!
|
||||
//! Stores user preferences for the Qobuz image cache:
|
||||
//! - enabled: whether to cache images locally (default: true)
|
||||
//! - max_size_mb: maximum cache size in megabytes (default: 200)
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageCacheSettings {
|
||||
pub enabled: bool,
|
||||
pub max_size_mb: u32,
|
||||
}
|
||||
|
||||
impl Default for ImageCacheSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_size_mb: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageCacheSettingsStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl ImageCacheSettingsStore {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("image_cache_settings.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open image cache settings database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL: {}", e))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS image_cache_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 200
|
||||
);
|
||||
INSERT OR IGNORE INTO image_cache_settings (id, enabled, max_size_mb)
|
||||
VALUES (1, 1, 200);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to create image cache settings table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> Result<ImageCacheSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT enabled, max_size_mb FROM image_cache_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ImageCacheSettings {
|
||||
enabled: row.get::<_, i32>(0)? != 0,
|
||||
max_size_mb: row.get::<_, u32>(1)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| format!("Failed to read image cache settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE image_cache_settings SET enabled = ?1 WHERE id = 1",
|
||||
params![enabled as i32],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update image cache enabled: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_max_size_mb(&self, max_size_mb: u32) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE image_cache_settings SET max_size_mb = ?1 WHERE id = 1",
|
||||
params![max_size_mb],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update image cache max size: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageCacheSettingsState {
|
||||
pub store: Arc<Mutex<Option<ImageCacheSettingsStore>>>,
|
||||
}
|
||||
|
||||
impl ImageCacheSettingsState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(ImageCacheSettingsStore::new()?))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||