mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f9f9167c4 | |||
| 21033e82f4 | |||
| c7407af05b | |||
| 159973fb16 |
@@ -1,138 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something is broken or behaves incorrectly
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please fill in as much as you can — the more
|
||||
precise the report, the faster it can be reproduced and fixed.
|
||||
|
||||
**Before opening an issue:** if you're not sure whether the problem is on the Psysonic side
|
||||
or on your Subsonic-compatible server, please ask in the [Discord](https://discord.gg/AMnDRErm4u)
|
||||
or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) community first.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: What's wrong?
|
||||
description: A short description of the problem (one or two sentences).
|
||||
placeholder: e.g. The volume slider jumps back to 100 % every time I switch tracks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered, minimal steps that lead to the problem.
|
||||
placeholder: |
|
||||
1. Open Psysonic and connect to my Navidrome server.
|
||||
2. Start playback of any track.
|
||||
3. Drag the volume slider to ~30 %.
|
||||
4. Press the next button.
|
||||
5. Volume slider has snapped back to 100 %.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What should happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: What actually happens?
|
||||
description: Include any error toast or popup text exactly as shown.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Psysonic version
|
||||
description: |
|
||||
Exact version string from `Settings → About` (e.g. `1.45.2` or `1.46.0-dev`).
|
||||
placeholder: 1.45.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
- Other (please describe in "Anything else")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: Install source
|
||||
description: How did you install Psysonic?
|
||||
options:
|
||||
- AUR — psysonic
|
||||
- AUR — psysonic-bin
|
||||
- .deb (Linux)
|
||||
- .rpm (Linux)
|
||||
- flakes
|
||||
- .dmg (macOS)
|
||||
- .msi installer (Windows)
|
||||
- Built from source
|
||||
- Other (please describe in "Anything else")
|
||||
|
||||
- type: dropdown
|
||||
id: server
|
||||
attributes:
|
||||
label: Subsonic-compatible server
|
||||
options:
|
||||
- Navidrome
|
||||
- Gonic
|
||||
- Airsonic
|
||||
- LMS
|
||||
- Other Subsonic-compatible server
|
||||
- Not sure / not relevant
|
||||
|
||||
- type: input
|
||||
id: server_version
|
||||
attributes:
|
||||
label: Server version
|
||||
description: If your server has a version string, paste it here. Skip if the bug is clearly UI-side.
|
||||
placeholder: e.g. Navidrome 0.55.0
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
How to grab logs:
|
||||
1. `Settings → Logging → Debug` (enables verbose logging)
|
||||
2. Reproduce the bug
|
||||
3. `Settings → Logging → Export logs` and attach the resulting file or paste the relevant section below.
|
||||
|
||||
Logs help a lot, especially for playback / streaming / sync bugs.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / video
|
||||
description: Drag-and-drop is supported. Optional but appreciated for UI bugs.
|
||||
|
||||
- type: textarea
|
||||
id: anything_else
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Distro / WM / GPU driver, Tauri-specific quirks, theme, anything that might be relevant.
|
||||
@@ -1,16 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Usage questions / general help
|
||||
url: https://discord.gg/AMnDRErm4u
|
||||
about: |
|
||||
Have a question, need help connecting to your server, or not sure if a behaviour is
|
||||
a bug? Please ask in the Discord community first — the issue tracker is reserved for
|
||||
confirmed bugs and feature requests.
|
||||
- name: Telegram community
|
||||
url: https://t.me/+GLBx1_xeH28xYTJi
|
||||
about: Alternative chat channel for usage questions and general discussion.
|
||||
- name: AUR packaging issues
|
||||
url: https://aur.archlinux.org/packages/psysonic
|
||||
about: |
|
||||
For problems specific to the AUR `psysonic` or `psysonic-bin` packages (build failures,
|
||||
dependency mismatches, AUR metadata), please comment on the AUR page first.
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting an improvement. A short description of the use case is more
|
||||
valuable than a fully-specified solution — the implementation usually shifts during
|
||||
review, but the underlying need stays the same.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the use case?
|
||||
description: |
|
||||
What are you trying to do that's currently hard or impossible? Describe the workflow
|
||||
or situation, not the proposed solution.
|
||||
placeholder: |
|
||||
e.g. When I'm on a long flight I want to keep listening to my offline-cached tracks,
|
||||
but if I open Psysonic without an internet connection it shows the connection
|
||||
screen instead of letting me into the player.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How do you imagine this working? Sketches, UI ideas, command names — anything goes.
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Workarounds you've tried, or other approaches that didn't fit.
|
||||
|
||||
- type: textarea
|
||||
id: anything_else
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: Links to related issues, similar requests, screenshots, etc.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Dependabot: security updates only (no scheduled version bumps).
|
||||
#
|
||||
# Version updates are disabled via open-pull-requests-limit: 0. GitHub still opens
|
||||
# PRs when Dependabot/npm/cargo audit reports a vulnerability (and related
|
||||
# transitive fixes in the same bump). Routine minor/patch upgrades are manual.
|
||||
#
|
||||
# Requires "Dependabot security updates" enabled in repo Settings → Code security.
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 0
|
||||
labels:
|
||||
- dependencies
|
||||
- security
|
||||
groups:
|
||||
npm-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /src-tauri
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 0
|
||||
labels:
|
||||
- dependencies
|
||||
- security
|
||||
groups:
|
||||
cargo-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# Symphonia 0.6 is a coordinated migration (API break + isomp4 patch port).
|
||||
# See workdocs: internal/collaboration/tasks/2026-05-symphonia-0.6-migration/
|
||||
- dependency-name: symphonia
|
||||
versions: [">= 0.6"]
|
||||
- dependency-name: symphonia-adapter-libopus
|
||||
versions: [">= 0.3"]
|
||||
@@ -1,44 +0,0 @@
|
||||
# Hot-path source files for the per-file ≥70 % coverage gate (frontend).
|
||||
#
|
||||
# Mirrors `.github/hot-path-files.txt` for the Rust crates. Each entry is a
|
||||
# workspace-relative path; the gate script (`scripts/check-frontend-hot-path-
|
||||
# coverage.sh`) reads `coverage/coverage-summary.json` produced by
|
||||
# `vitest run --coverage` and fails the frontend-tests coverage job when a
|
||||
# listed file drops below the floor.
|
||||
#
|
||||
# Curation rule (mirrors the backend list): a file belongs here when its
|
||||
# hot-path code dominates the file and ≥70 % is a reasonable floor. Files
|
||||
# that are partially covered today but contain large untested surface area
|
||||
# (e.g. `playerStore.ts`, the unfinished half of `previewStore.ts`) live
|
||||
# OUTSIDE the gate until their tests grow. Add them as Phase 1–4 coverage
|
||||
# work lands.
|
||||
#
|
||||
# Deferred from the gate, with current coverage shown for reference:
|
||||
# - src/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
|
||||
# - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability)
|
||||
# - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
|
||||
# ── utils (already at or above threshold) ────────────────────────────
|
||||
src/utils/cover/coverArtRegisteredSizes.ts
|
||||
src/utils/server/serverDisplayName.ts
|
||||
src/utils/server/serverMagicString.ts
|
||||
src/utils/share/shareLink.ts
|
||||
src/utils/ui/dynamicColors.ts
|
||||
src/utils/playback/resolvePlaybackUrl.ts
|
||||
src/utils/share/copyEntityShareLink.ts
|
||||
|
||||
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
|
||||
src/utils/playback/shuffleArray.ts
|
||||
src/utils/audio/resolveReplayGainDb.ts
|
||||
src/utils/playback/songToTrack.ts
|
||||
src/utils/playback/buildInfiniteQueueCandidates.ts
|
||||
|
||||
# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ──
|
||||
src/app/windowKind.ts
|
||||
src/app/bootstrap.ts
|
||||
|
||||
# ── Phase B.2: mini-player webview tree (2026-05-12) ─────────────────
|
||||
src/app/MiniPlayerApp.tsx
|
||||
|
||||
# ── stores (added as their tests grew past the floor) ────────────────
|
||||
src/store/previewStore.ts
|
||||
@@ -1,42 +0,0 @@
|
||||
# Hot-path source files for the per-file ≥70% coverage gate.
|
||||
#
|
||||
# Background: cucadmuh asked for ≥80% per-function coverage on functions in
|
||||
# typical user-triggered sequences. The original implementation tried to
|
||||
# parse cargo-llvm-cov per-function region data, but the metric is not
|
||||
# reliable for async state-machines (async fn body lives in synthetic
|
||||
# closures, the "main" symbol is just 1-2 regions) or generic functions
|
||||
# (every monomorphic instantiation is a separate symbol). See
|
||||
# `scripts/check-hot-path-coverage.sh` header for details.
|
||||
#
|
||||
# Switched to file-level coverage: every file listed below must stay
|
||||
# at ≥70% line coverage. The list is the subset of source files where
|
||||
# the hot-path code dominates and 70% is a reasonable floor. Files like
|
||||
# `psysonic-syncfs/src/sync/batch.rs` aren't listed because their hot-
|
||||
# path functions ARE tested (parse_subsonic_songs / estimate_track_size
|
||||
# / track_sync_info_from_subsonic_json all 100%) but the same file
|
||||
# also holds a large amount of untested admin Tauri commands that drag
|
||||
# the file aggregate down. Those are tracked individually via direct
|
||||
# unit tests, not via this gate.
|
||||
#
|
||||
# Each line is a path relative to the workspace root (so `src-tauri/...`).
|
||||
# `#` for comments. CI runs cargo-llvm-cov + this gate; PRs that drop
|
||||
# any listed file below the threshold fail the rust-tests coverage job.
|
||||
|
||||
# ── psysonic-syncfs ──────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs
|
||||
src-tauri/crates/psysonic-syncfs/src/cache/offline.rs
|
||||
src-tauri/crates/psysonic-syncfs/src/file_transfer.rs
|
||||
|
||||
# ── psysonic-analysis ────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs
|
||||
src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs
|
||||
|
||||
# ── psysonic-audio ───────────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-audio/src/decode.rs
|
||||
src-tauri/crates/psysonic-audio/src/stream/icy.rs
|
||||
src-tauri/crates/psysonic-audio/src/progress_task.rs
|
||||
src-tauri/crates/psysonic-audio/src/ipc.rs
|
||||
|
||||
# ── psysonic-integration ─────────────────────────────────────────────
|
||||
src-tauri/crates/psysonic-integration/src/discord.rs
|
||||
src-tauri/crates/psysonic-integration/src/navidrome/client.rs
|
||||
@@ -1,82 +0,0 @@
|
||||
name: frontend-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: vitest run
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: vitest
|
||||
run: npm test
|
||||
|
||||
typecheck:
|
||||
name: tsc --noEmit
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
coverage:
|
||||
name: vitest --coverage (baseline + hot-path file gate)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- name: install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
- run: npm ci
|
||||
- name: vitest run --coverage
|
||||
run: npx vitest run --coverage
|
||||
- name: hot-path file coverage gate
|
||||
run: bash scripts/check-frontend-hot-path-coverage.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-coverage-lcov
|
||||
path: coverage/lcov.info
|
||||
if-no-files-found: error
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version bump changes to commit."
|
||||
exit 0
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No finalization changes to commit."
|
||||
exit 0
|
||||
|
||||
@@ -35,70 +35,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
# Refresh npmDepsHash + flake.lock on the channel branch *before* tagging.
|
||||
# Promote workflows push with GITHUB_TOKEN (no downstream workflow runs), and the
|
||||
# old post-tag verify-nix PR landed after app-v* tags were already cut.
|
||||
prepare-nix-sources:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
source_commit_sha: ${{ steps.final-sha.outputs.value }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
- name: verify nix build + push to Cachix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
- name: commit and push refreshed lock and hash (if changed)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="$(node -p 'require("./package.json").version')"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "HEAD:${{ inputs.target_branch }}"
|
||||
- name: capture source commit sha
|
||||
id: final-sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
create-release:
|
||||
needs: prepare-nix-sources
|
||||
if: ${{ !cancelled() && !failure() && (needs.prepare-nix-sources.result == 'success' || needs.prepare-nix-sources.result == 'skipped') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -110,7 +47,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.verify_nix && needs.prepare-nix-sources.outputs.source_commit_sha || inputs.source_ref }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -257,7 +194,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -331,7 +268,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: generate latest.json
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.package_version }}
|
||||
@@ -353,7 +290,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.create-release.outputs.source_commit_sha }}
|
||||
ref: ${{ inputs.source_ref }}
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -388,6 +325,68 @@ jobs:
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
|
||||
| xargs gh release upload "$RELEASE_TAG" --clobber
|
||||
|
||||
verify-nix:
|
||||
if: ${{ inputs.verify_nix }}
|
||||
needs: create-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.target_branch }}
|
||||
- name: install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v15
|
||||
- name: configure Cachix (managed signing)
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: psysonic
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
- name: compute npmDepsHash from package-lock.json
|
||||
id: npm-hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
- name: write npmDepsHash into nix/upstream-sources.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH='${{ steps.npm-hash.outputs.hash }}'
|
||||
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
|
||||
mv nix/upstream-sources.json.new nix/upstream-sources.json
|
||||
- name: refresh flake.lock (nixpkgs pin)
|
||||
run: nix flake update --accept-flake-config
|
||||
- name: verify nix build + push to Cachix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
|
||||
nix path-info --recursive .#psysonic | cachix push psysonic
|
||||
- name: open + auto-merge PR with refreshed lock and hash (if changed)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add flake.lock nix/upstream-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ needs.create-release.outputs.package_version }}"
|
||||
BRANCH="chore/nix-lock-refresh-${{ inputs.target_branch }}-v${VERSION}"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--base "${{ inputs.target_branch }}" \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
|
||||
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
|
||||
|
||||
bump-main-to-next-dev:
|
||||
if: ${{ inputs.channel == 'release' }}
|
||||
needs: create-release
|
||||
@@ -448,7 +447,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "No dev version bump required."
|
||||
exit 0
|
||||
@@ -464,4 +463,4 @@ jobs:
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(release): bump main to ${VERSION}" \
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, \`src-tauri/Cargo.lock\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
name: rust-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
- '.github/workflows/rust-tests.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src-tauri/**'
|
||||
- '.github/workflows/rust-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: cargo test --workspace
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: cargo test --workspace
|
||||
working-directory: src-tauri
|
||||
run: cargo test --workspace --all-targets
|
||||
|
||||
clippy:
|
||||
name: cargo clippy --workspace
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- name: cargo clippy --workspace
|
||||
working-directory: src-tauri
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
coverage:
|
||||
name: cargo llvm-cov (baseline + hot-path file gate)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: install Linux build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libasound2-dev cmake jq
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
- name: generate workspace coverage (lcov + json)
|
||||
working-directory: src-tauri
|
||||
run: |
|
||||
mkdir -p target/llvm-cov
|
||||
cargo llvm-cov --workspace --lcov --output-path lcov.info
|
||||
cargo llvm-cov --workspace --json --output-path target/llvm-cov/cov.json
|
||||
- name: hot-path file coverage gate
|
||||
run: bash scripts/check-hot-path-coverage.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rust-coverage-lcov
|
||||
path: src-tauri/lcov.info
|
||||
if-no-files-found: error
|
||||
@@ -32,17 +32,10 @@ dist-ssr
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/lcov.info
|
||||
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
# Local commit-instructions for agents (never commit)
|
||||
to_commit.md
|
||||
|
||||
# Claude Code memory (local only)
|
||||
memory/
|
||||
|
||||
@@ -63,4 +56,3 @@ result-*
|
||||
dev.sh
|
||||
shell.nix
|
||||
prod.sh
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+6
-1864
File diff suppressed because it is too large
Load Diff
-184
@@ -1,184 +0,0 @@
|
||||
# Contributing to Psysonic
|
||||
|
||||
Thanks for your interest in helping the project.
|
||||
|
||||
Psysonic is **GPLv3** — see [LICENSE](LICENSE). Forks and modifications are welcome under the license; for attribution expectations when publishing derivative work, see **Forks and Attribution** in the [README](README.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Before you write code](#before-you-write-code)
|
||||
- [Repository layout](#repository-layout)
|
||||
- [Environment and running the app](#environment-and-running-the-app)
|
||||
- [Where processes and conventions are documented](#where-processes-and-conventions-are-documented)
|
||||
- [House rules](#house-rules)
|
||||
- [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract)
|
||||
- [CI on pull requests to `main`](#ci-on-pull-requests-to-main)
|
||||
- [Local checks](#local-checks)
|
||||
- [Pull request expectations](#pull-request-expectations)
|
||||
- [Why we are wary of irreversible UI churn](#why-we-are-wary-of-irreversible-ui-churn)
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
npm install
|
||||
npm run tauri:dev # run the desktop app in dev mode
|
||||
npm test # frontend tests (Vitest)
|
||||
( cd src-tauri && cargo test --workspace --all-targets ) # backend tests
|
||||
```
|
||||
|
||||
Open pull requests against `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly. The rest of this document covers what reviewers look for, especially around the [Tauri contract](#the-rust--frontend-tauri-contract) and UI changes.
|
||||
|
||||
---
|
||||
|
||||
## Before you write code
|
||||
|
||||
- **Usage questions** ("is this a bug or my setup?") — please use [Discord](https://discord.gg/AMnDRErm4u) or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) first. The issue tracker is intended for confirmed bugs and feature requests (see [issue templates](.github/ISSUE_TEMPLATE/)).
|
||||
- **AUR packaging problems** — follow the AUR links in [README](README.md); those packages are maintained separately from this repository.
|
||||
- **Large features or UX overhauls** — consider discussing in chat or opening an issue early so effort aligns with product direction.
|
||||
- **Changes to the Tauri boundary** — read [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract) before opening a PR; reviewers will ask for a clear justification.
|
||||
- **Security issues** — please do **not** open a public issue. See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately (Discord or Telegram).
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
src/ React / TypeScript frontend
|
||||
src-tauri/ Rust backend (Tauri host process)
|
||||
public/ Static assets served by Vite
|
||||
scripts/ CI helpers (coverage gates, install, version sync)
|
||||
.github/ Workflows, issue templates, hot-path lists
|
||||
flake.nix Nix development shell + packaging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment and running the app
|
||||
|
||||
See [README](README.md) (**Development**) for the basic flow: from the repository root, `npm install` then `npm run tauri:dev` for development or `npm run tauri:build` for a release build. Use `npm install` while iterating; `npm ci` is what CI runs and is the right command when you want a reproducible install.
|
||||
|
||||
For non-Linux contributors, install the native dependencies Tauri requires on your OS — see the upstream [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) (Windows: WebView2 + MSVC build tools; macOS: Xcode Command Line Tools). The Linux package list used in CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml).
|
||||
|
||||
If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pinned toolchain and native dependencies. Adding `psysonic.cachix.org` as a substituter (badge in the [README](README.md)) lets you pull prebuilt dev-shell dependencies instead of rebuilding them locally.
|
||||
|
||||
---
|
||||
|
||||
## Where processes and conventions are documented
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
|
||||
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
|
||||
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
|
||||
|
||||
---
|
||||
|
||||
## House rules
|
||||
|
||||
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
|
||||
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
|
||||
3. **Linting and formatting:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
|
||||
5. **License:** new code must remain compatible with the project's GPLv3.
|
||||
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
|
||||
7. **i18n:** user-visible strings live in `src/locales/*.ts` (one TypeScript module per language) and are wired up in `src/i18n.ts`. English (`en.ts`) is the baseline — always add the key there. Other locales may be left for follow-up translation PRs if you don't speak the language, but keep the object shape consistent so missing keys are obvious.
|
||||
|
||||
---
|
||||
|
||||
## The Rust ↔ frontend (Tauri) contract
|
||||
|
||||
Treat `invoke` handlers, event names, and JSON/payload shapes as a **public API between two codebases**. Prefer **additive** changes (new optional fields, new commands/events) over silent renames or breaking shape changes.
|
||||
|
||||
When a breaking change is unavoidable, it should be:
|
||||
|
||||
- **narrow** and **documented in the PR**,
|
||||
- paired with updates on **both sides** of the boundary, and
|
||||
- paired with updates to any Vitest Tauri mocks that encode the contract.
|
||||
|
||||
Drive-by churn here is expensive: it hurts forks, complicates bisects, and forces every contributor to relearn the boundary. If the same outcome can be achieved inside Rust or inside React alone, default to that.
|
||||
|
||||
Align early: open an issue or chat thread before sending a PR that renames `invoke` commands, changes event payloads, or reshapes data across the boundary. Reviewers will ask for a clear benefit because every such change ripples through `src-tauri`, `src`, tests, and future contributors' mental model.
|
||||
|
||||
---
|
||||
|
||||
## CI on pull requests to `main`
|
||||
|
||||
PRs must target `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly.
|
||||
|
||||
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
|
||||
|
||||
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.): `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
|
||||
|
||||
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
|
||||
|
||||
---
|
||||
|
||||
## Local checks
|
||||
|
||||
Assume the repository root is `psysonic/` (for example after `git clone https://github.com/Psychotoxical/psysonic.git` and `cd psysonic`).
|
||||
|
||||
**Frontend** — from the repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
npx tsc --noEmit
|
||||
npm run test:coverage
|
||||
bash scripts/check-frontend-hot-path-coverage.sh
|
||||
```
|
||||
|
||||
The last command mirrors the optional hot-path gate used in CI; `jq` must be on `PATH`.
|
||||
|
||||
**Rust** — install the Linux packages your distro needs to build Tauri/WebKitGTK (the list used in Ubuntu CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml) under `apt-get install`), or use `nix develop`. Then:
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo test --workspace --all-targets
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
To reproduce the **coverage + hot-path** job locally you also need:
|
||||
|
||||
- `cargo-llvm-cov`
|
||||
- the `llvm-tools-preview` rustup component
|
||||
- `jq` on `PATH`
|
||||
|
||||
The exact `cargo llvm-cov` invocations and the gate call are taken from the `coverage` job in [`rust-tests.yml`](.github/workflows/rust-tests.yml). After generating `src-tauri/target/llvm-cov/cov.json` as that job does, run the gate from the **repository root**:
|
||||
|
||||
```bash
|
||||
bash scripts/check-hot-path-coverage.sh
|
||||
```
|
||||
|
||||
If you change both frontend and backend, run the relevant blocks above before opening a PR.
|
||||
|
||||
---
|
||||
|
||||
## Pull request expectations
|
||||
|
||||
- **Description:** what changed, who should notice (end users vs developers only), how to verify manually. Link the issue if the PR closes it.
|
||||
- **Scope:** stay on task; no unrelated reformatting or cleanup in the same PR.
|
||||
- **UI/UX:** describe the user flow; before/after screenshots help reviewers a lot.
|
||||
- **i18n:** see [House rules](#house-rules) — add the key to `en.ts` first, keep the shape of other locales consistent.
|
||||
- **Server compatibility:** the client targets the Subsonic API and is **Navidrome-first**; if a feature depends on server support, say so explicitly.
|
||||
- **Tauri boundary:** if you touched it, list added/removed/renamed commands and events, describe payload changes, and note how you verified both `src-tauri` and `src` (plus any updated tests/mocks). If you did **not** touch the boundary, saying so helps reviewers scope the review.
|
||||
- **Persisted settings / on-disk layout:** if you change how configuration or local data is stored, migrated, or located, spell out the impact on **existing installs** (one-time migration, backwards compatibility, or explicit break with rationale).
|
||||
|
||||
---
|
||||
|
||||
## Why we are wary of irreversible UI churn
|
||||
|
||||
Psysonic is a desktop app people use for hours: muscle memory, layout, themes, keyboard workflows, and accessibility settings all matter. Abrupt changes to navigation, information hierarchy, or visual language without a migration path:
|
||||
|
||||
- break **habits and power-user flows**;
|
||||
- complicate **themes and accessibility** (contrast, sizing, custom fonts);
|
||||
- increase support load and frustration — some users stay on old builds or fork.
|
||||
|
||||
We prefer **evolutionary** UI work: discuss large shifts early, ship in steps where possible, use settings or toggles when a breaking visual change is justified, and preserve predictability where users did not ask for an experiment. That is not a ban on fresh design — it is a preference to **not strand users** without a strong reason and a clear adaptation path.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Notice
|
||||
|
||||
Psysonic is licensed under the GNU GPLv3.
|
||||
|
||||
Psysonic, including features such as **Orbit** (synchronized shared
|
||||
listening sessions), was originally designed and implemented by the
|
||||
Psysonic project maintainers and contributors.
|
||||
|
||||
Forks and modified versions are welcome under the GPLv3, but they
|
||||
must preserve copyright notices, license information, and clear
|
||||
attribution to the original Psysonic project.
|
||||
|
||||
Modified versions must not imply that original Psysonic features were
|
||||
independently created by the fork, nor imply endorsement by the
|
||||
Psysonic project.
|
||||
|
||||
## Required Attribution for Derivative Works
|
||||
|
||||
The following attribution requirements apply to all derivative works
|
||||
under the additional terms permitted by **GPLv3 §7(b)**. Forks and
|
||||
redistributions must preserve these notices intact:
|
||||
|
||||
1. The unmodified contents of this `NOTICE.md` file must be retained
|
||||
in the source tree of any fork or derivative work.
|
||||
2. Derivative works that include or are based on the **Orbit**
|
||||
feature — its design, protocol, or implementation — must clearly
|
||||
state in their own documentation and release notes that the Orbit
|
||||
feature was originally designed and implemented in Psysonic, and
|
||||
must not present Orbit's design or implementation as independent
|
||||
original work of the fork.
|
||||
3. The user-facing name "Orbit" is a Psysonic feature mark and may
|
||||
not be reused by forks; see `TRADEMARK.md`. The attribution
|
||||
requirement in (2) applies regardless of what the feature is
|
||||
renamed to in a fork.
|
||||
4. The same applies to any other original Psysonic feature that a
|
||||
fork chooses to retain.
|
||||
|
||||
These terms do not restrict the freedoms granted by the GPLv3; they
|
||||
are attribution-preservation terms explicitly permitted by GPLv3
|
||||
§7(b) and must be carried forward in further redistribution.
|
||||
@@ -1,28 +1,20 @@
|
||||
<div align="center">
|
||||
# Psysonic
|
||||
|
||||
<div align="center">
|
||||
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
|
||||
|
||||
## A modern desktop client for self-hosted music libraries
|
||||
## The Ultimate Desktop Client for Self-Hosted Music Libraries
|
||||
|
||||
**Fast. Native. Beautiful. Built for people who actually care about their music collection.**
|
||||
|
||||
Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers, depending on the features supported by your server.
|
||||
**Fast. Beautiful. Native. Feature-packed.**
|
||||
Built primarily for **Navidrome**. Also compatible with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers with partial feature support.
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a>
|
||||
|
||||
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
|
||||
|
||||
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a> <a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian and Chinese.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
**No telemetry • Native performance • Navidrome-first • Community driven**
|
||||
**No telemetry • Native performance • Massive feature set • Community driven**
|
||||
|
||||
</div>
|
||||
|
||||
@@ -33,103 +25,95 @@ More translations are added over time.
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
|
||||
> Psysonic is under heavy active development. Bugs and rough edges are to be expected. We reserve the right to change, remove, or rework existing features at any time without prior notice.
|
||||
|
||||
## What is Psysonic?
|
||||
## Server Compatibility
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||
**Psysonic is optimized first and foremost for Navidrome.**
|
||||
|
||||
It is built with **Rust**, **Tauri v2** and **React**, with a strong focus on responsiveness, customization, practical music-library workflows and a user interface that does not require a manual before you can press play.
|
||||
Many advanced functions integrate directly with Navidrome APIs for the best possible experience. Other Subsonic-compatible servers generally work well, but some features may be limited depending on server capabilities.
|
||||
|
||||
Psysonic is **optimized first and foremost for Navidrome**. Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
|
||||
## Why Psysonic?
|
||||
|
||||
Most Subsonic clients feel like web wrappers.
|
||||
|
||||
**Psysonic does not.**
|
||||
|
||||
It is a true desktop experience built with **Rust**, **Tauri v2**, and **React** for users who care about speed, aesthetics, customization, and serious music library management.
|
||||
|
||||
If you host your own music, this is what the premium experience should feel like.
|
||||
|
||||
---
|
||||
|
||||
# Highlights
|
||||
# Core Features
|
||||
|
||||
## Playback & Queue
|
||||
## Playback Engine
|
||||
|
||||
* Gapless playback
|
||||
* Crossfade
|
||||
* ReplayGain support
|
||||
* LUFS-based Smart Loudness Normalization
|
||||
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
|
||||
* Smart Loudness Normalization
|
||||
* Infinite Queue
|
||||
* Smart Radio sessions
|
||||
* Fast and responsive playback handling
|
||||
* Low memory usage compared to heavy web-first clients
|
||||
* High responsiveness with low memory usage
|
||||
|
||||
## Audio Tools
|
||||
|
||||
* 10-band Equalizer
|
||||
* Equalizer presets
|
||||
* Presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device optimization
|
||||
* Loudness-aware playback options
|
||||
|
||||
## Library Management
|
||||
## Library Power
|
||||
|
||||
* Fast search across large libraries
|
||||
* Albums, artists, tracks and genres
|
||||
* Ratings support
|
||||
* Lightning-fast search
|
||||
* Albums / Artists / Tracks / Genres
|
||||
* Ratings system
|
||||
* Multi-select bulk actions
|
||||
* Drag & drop playlist management
|
||||
* Smart Playlists
|
||||
* Built for large self-hosted collections
|
||||
* Huge library friendly
|
||||
|
||||
## Lyrics & Discovery
|
||||
|
||||
* Synced lyrics with seek support
|
||||
* Lyrics provider support: [YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB and NetEase
|
||||
* Auto-scrolling sidebar lyrics
|
||||
* Auto-scroll sidebar lyrics
|
||||
* Fullscreen lyric mode
|
||||
* Last.fm scrobbling
|
||||
* Similar artists
|
||||
* Loved tracks and listening stats
|
||||
* Similar artists / love tracks / stats
|
||||
|
||||
## Sharing & Social Listening
|
||||
## Personalization
|
||||
|
||||
* Magic Strings sharing:
|
||||
|
||||
* share albums, artists and queues
|
||||
* Navidrome user management helpers
|
||||
* fast account sharing
|
||||
* Orbit shared listening sessions:
|
||||
|
||||
* host-controlled synchronized playback
|
||||
* session invites via link
|
||||
* guest song suggestions
|
||||
* real-time queue interaction
|
||||
|
||||
## Personalization & Accessibility
|
||||
|
||||
* Large theme collection
|
||||
* Catppuccin and Nord inspired styles
|
||||
* Huge theme collection
|
||||
* Catppuccin / Nord inspired styles
|
||||
* Glassmorphism effects
|
||||
* Font customization
|
||||
* Zoom controls
|
||||
* Keybind remapping
|
||||
* Theme Scheduler for automatic day/night switching
|
||||
* Colorblind-friendly theme options
|
||||
* Keyboard-friendly navigation
|
||||
* Theme Scheduler (day/night auto switch)
|
||||
|
||||
## Power User Extras
|
||||
|
||||
* CLI controls
|
||||
* USB / portable sync
|
||||
* Backup and restore settings
|
||||
* Backup & restore settings
|
||||
* In-app auto updater
|
||||
* LAN / remote auto switching
|
||||
|
||||
---
|
||||
|
||||
<div align="left">
|
||||
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
|
||||
</div>
|
||||
# Orbit (Upcoming)
|
||||
|
||||
Orbit brings synchronized shared listening sessions directly into Psysonic.
|
||||
## Listen Together. In Sync. Soon.
|
||||
|
||||
Start a session, invite others with a link and listen together with host-controlled playback, shared queue interaction and guest song suggestions. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
|
||||
Currently in final development and testing. Orbit will introduce synchronized shared listening sessions directly inside Psysonic.
|
||||
|
||||
* Host-controlled playback
|
||||
* Join via link
|
||||
* Shared listening sessions
|
||||
* Guest song suggestions
|
||||
* Real-time queue interaction
|
||||
|
||||
**Rolling out in an upcoming release. Community feedback will help shape the final experience.**
|
||||
|
||||
---
|
||||
|
||||
@@ -137,10 +121,12 @@ Start a session, invite others with a link and listen together with host-control
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native installer |
|
||||
| Windows | Native Installer *(certificate pending)* |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
Supports **8 languages** and growing.
|
||||
|
||||
---
|
||||
|
||||
# Install
|
||||
@@ -151,24 +137,20 @@ Start a session, invite others with a link and listen together with host-control
|
||||
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
|
||||
|
||||
> **AppImage runs under X11/XWayland** — it pins `GDK_BACKEND=x11` for a stable WebKitGTK stack. For a native-Wayland launch, use the `.deb`, `.rpm`, AUR, or Nix packages, which follow your session's display server.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
Download the latest installer from Releases.
|
||||
|
||||
> SmartScreen warnings may appear until the code-signing certificate is active.
|
||||
|
||||
## macOS
|
||||
|
||||
Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
Download the signed DMG from Releases.
|
||||
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
Contributor expectations (PRs, CI, Tauri boundary, UI): [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Psychotoxical/psysonic.git
|
||||
cd psysonic
|
||||
@@ -184,52 +166,31 @@ npm run tauri:build
|
||||
|
||||
---
|
||||
|
||||
# Privacy
|
||||
|
||||
Psysonic is built for self-hosted music collections. Your library is yours.
|
||||
# Privacy First
|
||||
|
||||
* No telemetry
|
||||
* No spyware nonsense
|
||||
* No analytics harvesting
|
||||
* No hidden tracking
|
||||
|
||||
See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVACY.md) for how each opt-in integration handles data.
|
||||
* Your library stays yours
|
||||
|
||||
---
|
||||
|
||||
# Community & Support
|
||||
# Community
|
||||
|
||||
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
|
||||
|
||||
* [Discord](https://discord.gg/AMnDRErm4u)
|
||||
* [Telegram](https://t.me/+GLBx1_xeH28xYTJi)
|
||||
* [GitHub Issues](https://github.com/Psychotoxical/psysonic/issues)
|
||||
* [Support Psysonic on Ko-fi](https://ko-fi.com/psychotoxic)
|
||||
Join Discord, report bugs, suggest features, share themes, shape the future.
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
Psysonic is licensed under the **GNU GPL v3.0**.
|
||||
|
||||
---
|
||||
|
||||
## Forks and Attribution
|
||||
|
||||
Psysonic is free and open-source software under the GPLv3. You are welcome to fork it, modify it and build upon it under the terms of the license.
|
||||
|
||||
If you publish a modified or rebranded version, please make it clear that your project is based on Psysonic and preserve proper attribution to the original project.
|
||||
|
||||
That is not about preventing forks. Forks are part of open source. It is about being honest with users and contributors about where the work comes from.
|
||||
|
||||
Features, design work and implementation ideas developed in Psysonic should not be presented as unrelated original work in downstream projects.
|
||||
GNU GPL v3.0
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Own your music. Enjoy the client too.
|
||||
## Stop using boring music clients.
|
||||
|
||||
**Psysonic brings a modern desktop experience to self-hosted music libraries.**
|
||||
## Use Psysonic.
|
||||
|
||||
</div>
|
||||
|
||||
+1
-6
@@ -21,7 +21,7 @@ Direct push to these branches is not part of normal human workflow. Use PRs and
|
||||
|
||||
## 2) Versioning rules (mandatory)
|
||||
|
||||
Version is authoritative in `package.json` and `package-lock.json`. Promotion workflows run `scripts/sync-tauri-version-from-package.js`, which also aligns `[workspace.package]` in `src-tauri/Cargo.toml`, `tauri.conf.json`, and the `psysonic*` workspace crate version fields in `src-tauri/Cargo.lock` (local `cargo build` alone does not commit that lock metadata).
|
||||
Version is authoritative in `package.json` and `package-lock.json`.
|
||||
|
||||
- `main` version format: `X.Y.Z-dev`
|
||||
- `next` version format: `X.Y.Z-rc.N`
|
||||
@@ -41,11 +41,6 @@ Rules:
|
||||
|
||||
1. Merge ready PRs into `main`.
|
||||
2. Confirm CI is green on `main`.
|
||||
3. Refresh the bundled Open-Source-Licenses data (maintainer-only, run locally):
|
||||
- Requires `cargo-about` (one-time install: `cargo install cargo-about --features cli`).
|
||||
- Run directly: `node scripts/generate-licenses.mjs` from the repo root.
|
||||
- Inspect the diff on `src/data/licenses.json` (size + entry count delta should be plausible).
|
||||
- Commit on `main` if the file changed. No npm script wrapper exists on purpose — adding one to `package.json` would trigger the `nix-npm-deps-hash-sync` workflow on every push. Contributors consume the committed JSON as-is.
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public GitHub issue for security problems.**
|
||||
|
||||
Report them privately so we can investigate and coordinate a fix before details are public:
|
||||
|
||||
- [Discord](https://discord.gg/AMnDRErm4u) — reach a maintainer directly
|
||||
- [Telegram](https://t.me/+GLBx1_xeH28xYTJi) — same
|
||||
|
||||
Include what you can: affected version, platform (Windows / macOS / Linux), steps to reproduce, and impact if known.
|
||||
|
||||
## What to expect
|
||||
|
||||
- We will acknowledge your report as soon as we can.
|
||||
- We will work with you on verification and timing of any public disclosure.
|
||||
- We do not offer a paid bug-bounty program; credit in the changelog or release notes is given when reporters want it and when it fits the fix.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- **This repository** — Psysonic desktop application source.
|
||||
- **AUR packages** ([`psysonic`](https://aur.archlinux.org/packages/psysonic), [`psysonic-bin`](https://aur.archlinux.org/packages/psysonic-bin)) are maintained separately; packaging issues there should go through the AUR unless they reflect a vulnerability in the upstream app itself.
|
||||
- **Your music server** (Navidrome, Gonic, etc.) is outside this project's scope; report server-side issues to those projects.
|
||||
|
||||
## Secure development
|
||||
|
||||
Pull requests are reviewed on `main`. **Dependency security updates** are tracked via Dependabot (PRs only for reported vulnerabilities and their fix paths—not routine version bumps). For general contribution expectations, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
@@ -1,65 +0,0 @@
|
||||
# Privacy & Telemetry
|
||||
|
||||
Privacy is not an afterthought in Psysonic. It has been part of the project’s foundation from the very beginning.
|
||||
|
||||
Psysonic was built with the clear intention of giving users control over their own music experience without silently observing what they do. We believe that people should be able to use software on their own systems without being monitored by the developers behind it.
|
||||
|
||||
## No Telemetry
|
||||
|
||||
Psysonic does **not** collect telemetry.
|
||||
|
||||
That means:
|
||||
|
||||
- no usage statistics
|
||||
- no background tracking
|
||||
- no hidden analytics
|
||||
- no hardware or system profiling
|
||||
- no automatic crash or behavior reporting
|
||||
- no data harvesting of any kind
|
||||
|
||||
Psysonic itself does not collect, store, sell, analyze, or transmit personal usage data.
|
||||
|
||||
## A Conscious Decision
|
||||
|
||||
We are aware that telemetry can make software development easier.
|
||||
|
||||
Anonymous usage statistics, crash reports, platform information, and diagnostic data can help developers find bugs faster, understand which systems are used most often, and prioritize fixes more efficiently.
|
||||
|
||||
However, we made a conscious decision not to build Psysonic around that model.
|
||||
|
||||
For us, respecting user privacy is more important than collecting additional data for convenience. Modern software already tracks more than enough, often by default and often without users fully understanding what is being collected. Psysonic intentionally takes a different approach.
|
||||
|
||||
## External Services Are Opt-In
|
||||
|
||||
Some Psysonic features can communicate with external services, such as:
|
||||
|
||||
- Last.fm
|
||||
- Bandsintown
|
||||
- Discord Rich Presence
|
||||
|
||||
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
|
||||
|
||||
If you enable one of these integrations, data may be transmitted to the respective external service provider as part of how that service works. Psysonic itself does not collect or process that data for its own analytics.
|
||||
|
||||
You decide which integrations you want to use.
|
||||
|
||||
## Community Feedback Instead of Silent Tracking
|
||||
|
||||
Instead of telemetry, Psysonic relies on direct community feedback.
|
||||
|
||||
Bug reports, feature requests, platform issues, and general discussions happen openly through community channels such as Discord and Telegram. Additional contact options, including WhatsApp and Facebook groups, are planned for the future.
|
||||
|
||||
We prefer talking to users directly over silently collecting information in the background.
|
||||
|
||||
This approach may sometimes require more communication, but it also keeps the relationship between the project and its users transparent and respectful.
|
||||
|
||||
## Our Position
|
||||
|
||||
Psysonic is built for people who want to enjoy their own music collection on their own terms.
|
||||
|
||||
No hidden tracking.
|
||||
No telemetry by default.
|
||||
No analytics quietly running in the background.
|
||||
|
||||
This is intentional, and it is not planned to change.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Trademark Notice
|
||||
|
||||
The Psysonic source code is licensed under the GNU GPLv3.
|
||||
The **Psysonic name, logo, wordmark, visual brand identity, and the
|
||||
names of original Psysonic features are not** — they are excluded
|
||||
from the GPLv3 grant and remain the property of the Psysonic project
|
||||
maintainers.
|
||||
|
||||
## Covered Marks
|
||||
|
||||
- The name **"Psysonic"** (in any capitalization or stylization)
|
||||
- The Psysonic logo and icon (`public/psysonic-inapp-logo.svg`,
|
||||
app icons, tray icons, installer artwork)
|
||||
- The Psysonic wordmark and any derivative branding
|
||||
- The names of original Psysonic features that function as feature
|
||||
identity, in particular **"Orbit"**
|
||||
|
||||
## What You May Do
|
||||
|
||||
- Use the Psysonic name to factually refer to the upstream project
|
||||
(e.g. *"based on Psysonic"*, *"a fork of Psysonic"*, *"compatible
|
||||
with Psysonic"*).
|
||||
- Redistribute unmodified Psysonic builds under the Psysonic name,
|
||||
in accordance with the GPLv3.
|
||||
- Link to the official Psysonic repository, website, and community
|
||||
channels.
|
||||
|
||||
## What You May Not Do
|
||||
|
||||
- Use the Psysonic name, logo, or branding for a **modified or
|
||||
rebranded fork**, distribution, or derivative product.
|
||||
- Name a fork in a way that suggests it *is* Psysonic, an official
|
||||
release of Psysonic, or endorsed by the Psysonic project (e.g.
|
||||
"Psysonic Pro", "Psysonic Plus", "Psysonic Next", "MyPsysonic").
|
||||
- Reuse the Psysonic logo, icon set, or installer artwork in a fork
|
||||
or derivative product, even with modifications.
|
||||
- Continue to use the feature name **"Orbit"** in a fork or
|
||||
derivative. If your fork ships the Orbit feature (or code derived
|
||||
from it), you must rename the user-facing feature.
|
||||
- Present original Psysonic features (such as Orbit) as independent
|
||||
creations of a fork. See `NOTICE.md` for the attribution
|
||||
requirements that apply to derivative works under GPLv3 §7(b).
|
||||
- Imply sponsorship, endorsement, or affiliation with the Psysonic
|
||||
project where none exists.
|
||||
|
||||
## Forking Done Right
|
||||
|
||||
You are warmly invited to fork the code under the GPLv3.
|
||||
If you publish a fork or derivative, please:
|
||||
|
||||
1. Choose your own distinct project name and branding.
|
||||
2. Keep the GPLv3 license, copyright notices, and the `NOTICE.md`
|
||||
file intact (this is required, not optional — see `NOTICE.md`).
|
||||
3. State clearly that your project is *based on Psysonic* and link
|
||||
back to the upstream repository.
|
||||
4. Replace Psysonic-branded assets (logos, icons, installer art,
|
||||
wordmarks) with your own.
|
||||
5. If you ship features that originated in Psysonic (such as Orbit),
|
||||
rename the user-facing feature and credit Psysonic as the
|
||||
original designer and implementer.
|
||||
|
||||
## Questions
|
||||
|
||||
If you are unsure whether a planned use of the Psysonic name or
|
||||
branding is acceptable, please open an issue or contact the
|
||||
maintainers before publishing.
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"lastModified": 1777578337,
|
||||
"narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"rev": "15f4ee454b1dce334612fa6843b3e05cf546efab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -3,19 +3,17 @@
|
||||
Psysonic for NixOS / nixpkgs: installable app + dev shell.
|
||||
|
||||
Packages:
|
||||
nix build .#psysonic # or .#default — desktop app; GDK follows session (no wrapper pin)
|
||||
nix build .#psysonic-gdk-session # same derivation (back-compat alias); see nixos-install.md
|
||||
nix build .#psysonic-x11-legacy # legacy: GDK_BACKEND=x11 wrapper (old default)
|
||||
nix build .#psysonic # or .#default — desktop app (.desktop + icon); GDK_BACKEND=x11 (default, fewer WebKit surprises)
|
||||
nix build .#psysonic-gdk-session # same app, no forced GDK x11 — optional; can misbehave on some stacks (see nixos-install.md)
|
||||
nix profile install .#psysonic
|
||||
|
||||
Run (after build, or from any clone with flake):
|
||||
nix run .#psysonic
|
||||
nix run .#psysonic-gdk-session # identical to psysonic
|
||||
nix run .#psysonic-x11-legacy # GDK x11 pinned (former default wrap)
|
||||
nix run .#psysonic-gdk-session
|
||||
nix run github:Psychotoxical/psysonic
|
||||
|
||||
Development:
|
||||
nix develop # mkShell (Rust/Node/WebKit deps); same GDK idea as installable (no GDK pin)
|
||||
nix develop # mkShell (Rust/Node/WebKit deps + hooks)
|
||||
nix shell .#devShells.default # same environment without entering subshell semantics
|
||||
Local cargo output: .build-local/ (gitignored; not copied into flake source tarball)
|
||||
|
||||
@@ -57,10 +55,6 @@
|
||||
nodejs_22
|
||||
rustc
|
||||
cargo
|
||||
clippy
|
||||
cargo-llvm-cov
|
||||
llvmPackages.llvm
|
||||
jq
|
||||
cmake
|
||||
pkg-config
|
||||
openssl
|
||||
@@ -87,8 +81,9 @@
|
||||
export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}"
|
||||
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
|
||||
export LLVM_COV="${pkgs.llvmPackages.llvm}/bin/llvm-cov"
|
||||
export LLVM_PROFDATA="${pkgs.llvmPackages.llvm}/bin/llvm-profdata"
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
unset CI
|
||||
'';
|
||||
|
||||
@@ -105,38 +100,28 @@
|
||||
inherit upstreamMeta;
|
||||
};
|
||||
|
||||
# Same app with GDK_BACKEND pinned to X11 — previous default wrapper behaviour (see nixos-install.md).
|
||||
psysonicX11LegacyFor =
|
||||
psysonicGdkSessionFor =
|
||||
system:
|
||||
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
|
||||
src = self;
|
||||
inherit upstreamMeta;
|
||||
forceGdkX11 = true;
|
||||
forceGdkX11 = false;
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
devShells = forSystem (system: { default = mkShellFor system; });
|
||||
|
||||
packages = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pX11 = psysonicX11LegacyFor system;
|
||||
in
|
||||
{
|
||||
psysonic = p;
|
||||
psysonic-gdk-session = p;
|
||||
psysonic-x11-legacy = pX11;
|
||||
default = p;
|
||||
}
|
||||
);
|
||||
packages = forSystem (system: {
|
||||
psysonic = psysonicFor system;
|
||||
psysonic-gdk-session = psysonicGdkSessionFor system;
|
||||
default = psysonicFor system;
|
||||
});
|
||||
|
||||
apps = forSystem (
|
||||
system:
|
||||
let
|
||||
p = psysonicFor system;
|
||||
pX11 = psysonicX11LegacyFor system;
|
||||
pGdk = psysonicGdkSessionFor system;
|
||||
in
|
||||
{
|
||||
default = {
|
||||
@@ -149,17 +134,9 @@
|
||||
};
|
||||
psysonic-gdk-session = {
|
||||
type = "app";
|
||||
program = lib.getExe p;
|
||||
program = lib.getExe pGdk;
|
||||
meta = {
|
||||
inherit (p.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
psysonic-x11-legacy = {
|
||||
type = "app";
|
||||
program = lib.getExe pX11;
|
||||
meta = {
|
||||
inherit (pX11.meta) description homepage license;
|
||||
inherit (pGdk.meta) description homepage license;
|
||||
mainProgram = "psysonic";
|
||||
};
|
||||
};
|
||||
|
||||
+10
-6
@@ -35,9 +35,9 @@
|
||||
gst_all_1,
|
||||
src,
|
||||
upstreamMeta,
|
||||
# When true, wrapProgram sets GDK_BACKEND=x11 (legacy conservative stack).
|
||||
# When false (default), only lib paths — GDK follows session; binary applies webkit2gtk-nvidia-quirk only.
|
||||
forceGdkX11 ? false,
|
||||
# When true (default), wrapProgram sets GDK_BACKEND=x11 for WebKit stability on many setups.
|
||||
# When false, GDK follows the session (e.g. native Wayland) — often better HiDPI sizing.
|
||||
forceGdkX11 ? true,
|
||||
}:
|
||||
|
||||
let
|
||||
@@ -138,7 +138,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
(cd src-tauri && cargo tauri build --no-bundle)
|
||||
(cd src-tauri && cargo tauri build --no-bundle -v)
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
@@ -163,7 +163,10 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
postFixup =
|
||||
let
|
||||
gdkX11Wrap = lib.optionalString forceGdkX11 ''
|
||||
--set GDK_BACKEND x11
|
||||
--set GDK_BACKEND x11 \
|
||||
'';
|
||||
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
|
||||
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
|
||||
'';
|
||||
in
|
||||
''
|
||||
@@ -171,7 +174,8 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
|
||||
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
|
||||
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
|
||||
${gdkX11Wrap}
|
||||
${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
|
||||
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
|
||||
'';
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
|
||||
"npmDepsHash": "sha256-WXfc5WsypV2HdUTaZLDzPN9OdgLq18/hcJVvKCn3LTU="
|
||||
}
|
||||
|
||||
+10
-10
@@ -85,27 +85,25 @@ environment.systemPackages = with pkgs; [
|
||||
];
|
||||
```
|
||||
|
||||
### Linux wrapper (default vs legacy X11)
|
||||
### Linux wrapper: default vs gdk-session
|
||||
|
||||
The flake exposes **three** Linux attributes (two are the **same derivation**):
|
||||
The flake exposes **two** installable packages on Linux. They are the same build; only the **wrapped runtime environment** differs:
|
||||
|
||||
| Flake attribute | Wrapper behaviour |
|
||||
|----------------|-------------------|
|
||||
| **`psysonic`**, **`default`**, **`psysonic-gdk-session`** | Wrappers prefix **libraries only** (**GStreamer**, **AppIndicator**); **`GDK_BACKEND`** is **not** pinned. The binary invokes **`webkit2gtk-nvidia-quirk`** early on Linux (unless **`PSYSONIC_WEBKIT_GPU_ACCEL`** is set); no extra **`WEBKIT_DISABLE_*`** heuristics in **`main.rs`**. Override with **`GDK_BACKEND`**, **`WEBKIT_DISABLE_*`**, etc. whenever you want. |
|
||||
| **`psysonic-x11-legacy`** | Former default: **`GDK_BACKEND=x11`** pinned in the wrapper. Use if you relied on **XWayland-ish** stability on messy stacks. Same binary as **`psysonic`**. |
|
||||
| **`psysonic`** (and **`default`**) | Sets **`GDK_BACKEND=x11`** together with the usual WebKit / GStreamer / AppIndicator paths. This is the **recommended default**: it matches the dev shell assumptions and avoids many WebKitGTK + Wayland edge cases. |
|
||||
| **`psysonic-gdk-session`** | **Does not** set `GDK_BACKEND`; GTK follows the session (e.g. native Wayland when available). Can improve **HiDPI sizing** on some desktops, but may cause **black window, broken scrolling, or tray quirks** on other GPU/compositor stacks—the same class of issues described under Linux / WebKit in the in-app Help. **Not default** on purpose. |
|
||||
|
||||
`psysonic-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path).
|
||||
|
||||
### Example: legacy X11 wrap
|
||||
Use the alternate package when you understand that trade-off:
|
||||
|
||||
```nix
|
||||
inputs.psysonic.packages.${system}.psysonic-x11-legacy
|
||||
inputs.psysonic.packages.${system}.psysonic-gdk-session
|
||||
```
|
||||
|
||||
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
|
||||
|
||||
```bash
|
||||
nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help
|
||||
nix run 'github:Psychotoxical/psysonic#psysonic-gdk-session' -- --help
|
||||
```
|
||||
|
||||
### Pinning a revision, branch, or tag
|
||||
@@ -144,7 +142,7 @@ From any machine with flakes:
|
||||
nix run 'github:Psychotoxical/psysonic'
|
||||
```
|
||||
|
||||
Same as `nix build` / `packages.<system>.default` (session-native **GDK**); uses the flake `apps` output. For an **X11-pinned** launcher (old default), use `'github:Psychotoxical/psysonic#psysonic-x11-legacy'` (see [Linux wrapper](#linux-wrapper-default-vs-legacy-x11) above). `psysonic-gdk-session` is an **alias**—same as **`psysonic`**. With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
|
||||
Same as `nix build` / `packages.<system>.default` (the **x11-wrapped** binary); uses the flake `apps` output. For the session-GDK variant, use `'github:Psychotoxical/psysonic#psysonic-gdk-session'` (see [Linux wrapper](#linux-wrapper-default-vs-gdk-session) above). With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
|
||||
|
||||
### Apply configuration
|
||||
|
||||
@@ -181,6 +179,8 @@ From a **flake-enabled** clone of the repo:
|
||||
|
||||
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
|
||||
|
||||
Optional **local-only** helpers (`dev.sh`, `shell.nix`, `prod.sh`) are **gitignored** — not part of the upstream tree; keep your own copies if you use them (e.g. a small `dev.sh` that runs `nix develop` and `npm run tauri:dev`).
|
||||
|
||||
## Desktop entry
|
||||
|
||||
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
|
||||
|
||||
Generated
+1129
-1887
File diff suppressed because it is too large
Load Diff
+23
-33
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0-dev",
|
||||
"version": "1.45.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run && npm run check:css-imports",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage && npm run check:css-imports"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
@@ -29,44 +26,37 @@
|
||||
"@fontsource-variable/rubik": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
"@fontsource-variable/unbounded": "^5.2.8",
|
||||
"@fontsource/opendyslexic": "^5.2.5",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"axios": "^1.16.0",
|
||||
"i18next": "^26.0.8",
|
||||
"lucide-react": "^1.17.0",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^25.8.16",
|
||||
"lucide-react": "^0.462.0",
|
||||
"md5": "^2.3.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"zustand": "^5.0.14"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/md5": "^2.3.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"esbuild": "^0.28.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.8"
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.46.0
|
||||
pkgver=1.44.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
@@ -53,9 +53,12 @@ package() {
|
||||
# Binary (in /usr/lib to make room for the wrapper)
|
||||
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
|
||||
|
||||
# Wrapper: thin exec (path hygiene only); GDK/session + WebKit mitigations come from main.rs / quirk (no GDK pin).
|
||||
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
|
||||
#!/bin/sh
|
||||
export GDK_BACKEND=x11
|
||||
export WEBKIT_DISABLE_COMPOSITING_MODE=1
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec /usr/lib/psysonic/psysonic "\$@"
|
||||
EOF
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Walk the same root CSS entrypoints as src/main.tsx and ensure every
|
||||
* relative @import target exists on disk. Catches postcss-import ENOENT
|
||||
* that Vitest does not surface (CSS is not fully resolved like vite build).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const stylesRoot = path.join(repoRoot, 'src', 'styles');
|
||||
|
||||
const ENTRY_RELS = [
|
||||
'themes/index.css',
|
||||
'layout/index.css',
|
||||
'components/index.css',
|
||||
'tracks/index.css',
|
||||
];
|
||||
|
||||
/** Quoted path in @import '...' or "..." (postcss-import style in this repo). */
|
||||
const IMPORT_RE = /@import\s+(['"])([^'"]+)\1/g;
|
||||
|
||||
function isFilesystemImport(spec) {
|
||||
if (spec.startsWith('http://') || spec.startsWith('https://') || spec.startsWith('data:')) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith('@')) return false;
|
||||
return spec.startsWith('./') || spec.startsWith('../');
|
||||
}
|
||||
|
||||
function walk(absPath, visited, missing) {
|
||||
const key = path.resolve(absPath);
|
||||
if (visited.has(key)) return;
|
||||
if (!fs.existsSync(key)) {
|
||||
missing.add(path.relative(repoRoot, key));
|
||||
visited.add(key);
|
||||
return;
|
||||
}
|
||||
visited.add(key);
|
||||
const text = fs.readFileSync(key, 'utf8');
|
||||
let m;
|
||||
IMPORT_RE.lastIndex = 0;
|
||||
while ((m = IMPORT_RE.exec(text)) !== null) {
|
||||
const spec = m[2];
|
||||
if (!isFilesystemImport(spec)) continue;
|
||||
const next = path.resolve(path.dirname(key), spec);
|
||||
walk(next, visited, missing);
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const missing = new Set();
|
||||
for (const rel of ENTRY_RELS) {
|
||||
walk(path.join(stylesRoot, rel), visited, missing);
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
const list = [...missing].sort().join('\n ');
|
||||
console.error(`check-css-import-graph: missing @import target(s):\n ${list}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Hot-path file coverage gate — frontend.
|
||||
#
|
||||
# Mirrors `scripts/check-hot-path-coverage.sh` for the Rust workspace. For
|
||||
# each source file listed in `.github/frontend-hot-path-files.txt`, verifies
|
||||
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions warning
|
||||
# annotations for files below the floor and exits 1 when any file is below.
|
||||
#
|
||||
# Why files instead of per-function: v8 coverage's per-function data is
|
||||
# fragile under React Compiler / Vite minification — file-level line
|
||||
# coverage tracks the underlying intent ("is the hot-path file thoroughly
|
||||
# tested?") more robustly.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-frontend-hot-path-coverage.sh [<summary.json>] [<hot-path-list.txt>]
|
||||
#
|
||||
# Defaults:
|
||||
# summary.json — coverage/coverage-summary.json
|
||||
# hot-path-list.txt — .github/frontend-hot-path-files.txt
|
||||
#
|
||||
# Requires: jq.
|
||||
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
JSON="${1:-coverage/coverage-summary.json}"
|
||||
HOT_PATH_LIST="${2:-.github/frontend-hot-path-files.txt}"
|
||||
THRESHOLD=70
|
||||
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "::error::Coverage summary not found at $JSON. Did you run 'npm run test:coverage' first?"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOT_PATH_LIST" ]]; then
|
||||
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The v8 / Istanbul coverage-summary.json has the form:
|
||||
# { "total": {...}, "<absolute-path>": { "lines": { "pct": 87.5 }, ... }, ... }
|
||||
# We want each file path + its line pct as a TSV keyed by basename suffix.
|
||||
ALL_FILES=$(mktemp)
|
||||
trap 'rm -f "$ALL_FILES"' EXIT
|
||||
jq -r 'to_entries[] | select(.key != "total") | [.key, .value.lines.pct] | @tsv' "$JSON" > "$ALL_FILES"
|
||||
|
||||
TOTAL=0
|
||||
BELOW=0
|
||||
NOT_FOUND=0
|
||||
|
||||
echo "── Hot-path file coverage check, frontend (threshold: ≥${THRESHOLD}%) ──"
|
||||
|
||||
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
|
||||
line="${raw_line%%#*}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[[ -z "$line" ]] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Match suffix — JSON paths are absolute, hot-path list is workspace-relative.
|
||||
pct=$(awk -F'\t' -v target="$line" '
|
||||
{
|
||||
path = $1
|
||||
gsub(/\\\\/, "/", path)
|
||||
gsub(/\\/, "/", path)
|
||||
n = length(path)
|
||||
tlen = length(target)
|
||||
if (n >= tlen && substr(path, n - tlen + 1) == target) {
|
||||
printf "%s\n", $2
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$ALL_FILES")
|
||||
|
||||
if [[ -z "$pct" ]]; then
|
||||
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed? or no test imports it yet)"
|
||||
NOT_FOUND=$((NOT_FOUND + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
pct_int=${pct%.*}
|
||||
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
|
||||
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
|
||||
BELOW=$((BELOW + 1))
|
||||
else
|
||||
printf " ok %s %.1f%%\n" "$line" "$pct"
|
||||
fi
|
||||
done < "$HOT_PATH_LIST"
|
||||
|
||||
echo
|
||||
echo "── Summary ─────────────────────────────────────────────────────────"
|
||||
echo "Checked: $TOTAL hot-path file(s)"
|
||||
echo "Below threshold: $BELOW"
|
||||
echo "Not found: $NOT_FOUND"
|
||||
|
||||
if [[ "$BELOW" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Hot-path file coverage gate.
|
||||
#
|
||||
# For each source file listed in `.github/hot-path-files.txt`, verifies
|
||||
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions
|
||||
# warning annotations for files below the floor and exits 1 when any
|
||||
# file is below.
|
||||
#
|
||||
# Why files instead of per-function: cargo-llvm-cov's per-function
|
||||
# region data is unreliable for async state-machines (most regions live
|
||||
# in synthetic closures) and generic functions (every instantiation is
|
||||
# a separate symbol). File-level line coverage is robustly measured and
|
||||
# tracks the underlying intent: "is the hot-path file thoroughly tested?".
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-hot-path-coverage.sh [<coverage.json>] [<hot-path-list.txt>]
|
||||
#
|
||||
# Defaults:
|
||||
# coverage.json — src-tauri/target/llvm-cov/cov.json
|
||||
# hot-path-list.txt — .github/hot-path-files.txt
|
||||
#
|
||||
# Requires: jq (preinstalled on Ubuntu runners; on Windows install via
|
||||
# `winget install jqlang.jq` or `choco install jq`).
|
||||
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
JSON="${1:-src-tauri/target/llvm-cov/cov.json}"
|
||||
HOT_PATH_LIST="${2:-.github/hot-path-files.txt}"
|
||||
THRESHOLD=70
|
||||
|
||||
if [[ ! -f "$JSON" ]]; then
|
||||
echo "::error::Coverage JSON not found at $JSON. Did you run cargo llvm-cov --workspace --json --output-path \"$JSON\" first?"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HOT_PATH_LIST" ]]; then
|
||||
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Pre-extract every file's line coverage % into a TSV keyed by basename.
|
||||
# We match by suffix (file path ends with the listed relative path) because
|
||||
# the JSON stores absolute paths that vary between Windows runners and Linux.
|
||||
ALL_FILES=$(mktemp)
|
||||
trap 'rm -f "$ALL_FILES"' EXIT
|
||||
jq -r '.data[0].files[] | [.filename, .summary.lines.percent] | @tsv' "$JSON" > "$ALL_FILES"
|
||||
|
||||
TOTAL=0
|
||||
BELOW=0
|
||||
NOT_FOUND=0
|
||||
|
||||
echo "── Hot-path file coverage check (threshold: ≥${THRESHOLD}%) ──────────"
|
||||
|
||||
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
|
||||
line="${raw_line%%#*}" # strip trailing comment
|
||||
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
||||
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
||||
[[ -z "$line" ]] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# Match suffix — the JSON stores absolute paths; the hot-path list uses
|
||||
# workspace-relative paths. Convert both to forward slashes first so the
|
||||
# endsWith works on Windows-encoded paths too.
|
||||
pct=$(awk -F'\t' -v target="$line" '
|
||||
{
|
||||
path = $1
|
||||
gsub(/\\\\/, "/", path)
|
||||
gsub(/\\/, "/", path)
|
||||
n = length(path)
|
||||
tlen = length(target)
|
||||
if (n >= tlen && substr(path, n - tlen + 1) == target) {
|
||||
printf "%s\n", $2
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$ALL_FILES")
|
||||
|
||||
if [[ -z "$pct" ]]; then
|
||||
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed?)"
|
||||
NOT_FOUND=$((NOT_FOUND + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# bash arithmetic doesn't do float. Truncate to int for comparison.
|
||||
pct_int=${pct%.*}
|
||||
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
|
||||
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
|
||||
BELOW=$((BELOW + 1))
|
||||
else
|
||||
printf " ok %s %.1f%%\n" "$line" "$pct"
|
||||
fi
|
||||
done < "$HOT_PATH_LIST"
|
||||
|
||||
echo
|
||||
echo "── Summary ─────────────────────────────────────────────────────────"
|
||||
echo "Checked: $TOTAL hot-path file(s)"
|
||||
echo "Below threshold: $BELOW"
|
||||
echo "Not found: $NOT_FOUND"
|
||||
|
||||
if [[ "$BELOW" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Generates src/data/licenses.json — the data backing Settings → System → Open Source Licenses.
|
||||
//
|
||||
// Maintainer-only operation: run before each release to refresh the bundled
|
||||
// dependency list. Requires `cargo-about` installed globally
|
||||
// (`cargo install cargo-about --features cli`). The npm-side license data is
|
||||
// read via npx without persisting a devDependency, so package.json stays clean
|
||||
// and the nix-npm-deps-hash-sync workflow is not triggered.
|
||||
//
|
||||
// Output lives in src/ (not public/) so Vite bundles it as a lazy JS chunk
|
||||
// — no runtime fetch, the data is fixed into the build artifact.
|
||||
//
|
||||
// Output format (per entry):
|
||||
// {
|
||||
// name: string,
|
||||
// version: string,
|
||||
// source: 'npm' | 'cargo',
|
||||
// licenses: string[], // SPDX ids, may be multi (dual-licensed)
|
||||
// repository?: string,
|
||||
// homepage?: string,
|
||||
// description?: string,
|
||||
// publisher?: string,
|
||||
// licenseText?: string, // full license text, may be empty if not found
|
||||
// }
|
||||
//
|
||||
// Usage: node scripts/generate-licenses.mjs
|
||||
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(__dirname, '..');
|
||||
const TAURI_DIR = join(REPO_ROOT, 'src-tauri');
|
||||
const OUT_DIR = join(REPO_ROOT, 'src', 'data');
|
||||
const OUT_PATH = join(OUT_DIR, 'licenses.json');
|
||||
|
||||
function die(msg) {
|
||||
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function step(msg) {
|
||||
console.log(`\x1b[36m→\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
function ok(msg) {
|
||||
console.log(`\x1b[32m✓\x1b[0m ${msg}`);
|
||||
}
|
||||
|
||||
// ── Cargo side via cargo-about ──────────────────────────────────────────────
|
||||
function checkCargoAbout() {
|
||||
try {
|
||||
execSync('cargo about --version', { stdio: 'ignore' });
|
||||
} catch {
|
||||
die(
|
||||
'cargo-about not found. Install with:\n' +
|
||||
' cargo install cargo-about --features cli',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function generateCargoLicenses() {
|
||||
step('Running cargo-about (this resolves the full dependency graph and may take ~30s)…');
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
[
|
||||
'about',
|
||||
'generate',
|
||||
'about.hbs',
|
||||
'--config', 'about.toml',
|
||||
'--manifest-path', 'Cargo.toml',
|
||||
'--all-features',
|
||||
],
|
||||
{ cwd: TAURI_DIR, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('cargo-about failed.');
|
||||
}
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
if (!parsed?.licenses) die('cargo-about output missing `licenses` key.');
|
||||
|
||||
// Flatten: pivot from license-grouped to crate-grouped.
|
||||
const byCrate = new Map();
|
||||
for (const lic of parsed.licenses) {
|
||||
for (const u of lic.used_by ?? []) {
|
||||
const key = `${u.name}@${u.version}`;
|
||||
if (!byCrate.has(key)) {
|
||||
byCrate.set(key, {
|
||||
name: u.name,
|
||||
version: u.version,
|
||||
source: 'cargo',
|
||||
licenses: [],
|
||||
repository: u.repository || undefined,
|
||||
description: u.description || undefined,
|
||||
licenseText: '',
|
||||
});
|
||||
}
|
||||
const entry = byCrate.get(key);
|
||||
if (!entry.licenses.includes(lic.id)) entry.licenses.push(lic.id);
|
||||
// Append text — a crate may have multiple licenses (dual). Keep them all,
|
||||
// separated by a clear divider.
|
||||
if (lic.text) {
|
||||
if (entry.licenseText) {
|
||||
entry.licenseText += `\n\n--- ${lic.id} ---\n\n${lic.text}`;
|
||||
} else {
|
||||
entry.licenseText = lic.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(`cargo: ${byCrate.size} crates`);
|
||||
return [...byCrate.values()];
|
||||
}
|
||||
|
||||
// ── npm side via license-checker-rseidelsohn (npx) ──────────────────────────
|
||||
function readLicenseFile(path) {
|
||||
if (!path || !existsSync(path)) return '';
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a notice/copyright file alongside the license file.
|
||||
function findExtraLicenseTexts(packagePath) {
|
||||
if (!packagePath || !existsSync(packagePath)) return '';
|
||||
let extra = '';
|
||||
try {
|
||||
const entries = readdirSync(packagePath);
|
||||
for (const name of entries) {
|
||||
const upper = name.toUpperCase();
|
||||
if (
|
||||
upper === 'NOTICE' ||
|
||||
upper === 'NOTICE.MD' ||
|
||||
upper === 'NOTICE.TXT' ||
|
||||
upper === 'COPYRIGHT' ||
|
||||
upper === 'COPYRIGHT.MD' ||
|
||||
upper === 'COPYRIGHT.TXT'
|
||||
) {
|
||||
const full = join(packagePath, name);
|
||||
if (statSync(full).isFile()) {
|
||||
const txt = readFileSync(full, 'utf-8').trim();
|
||||
if (txt) extra += `\n\n--- ${name} ---\n\n${txt}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
function generateNpmLicenses() {
|
||||
step('Running license-checker-rseidelsohn via npx (this enumerates node_modules)…');
|
||||
const result = spawnSync(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'license-checker-rseidelsohn@latest',
|
||||
'--json',
|
||||
'--production',
|
||||
'--excludePrivatePackages',
|
||||
],
|
||||
{ cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
console.error(result.stderr);
|
||||
die('license-checker-rseidelsohn failed.');
|
||||
}
|
||||
const data = JSON.parse(result.stdout);
|
||||
const out = [];
|
||||
for (const [key, info] of Object.entries(data)) {
|
||||
// key looks like `package-name@1.2.3` or `@scope/name@1.2.3`.
|
||||
const at = key.lastIndexOf('@');
|
||||
if (at <= 0) continue;
|
||||
const name = key.slice(0, at);
|
||||
const version = key.slice(at + 1);
|
||||
const licenses = Array.isArray(info.licenses)
|
||||
? info.licenses
|
||||
: typeof info.licenses === 'string'
|
||||
? [info.licenses]
|
||||
: [];
|
||||
const text =
|
||||
readLicenseFile(info.licenseFile) +
|
||||
findExtraLicenseTexts(info.path);
|
||||
out.push({
|
||||
name,
|
||||
version,
|
||||
source: 'npm',
|
||||
licenses: licenses.map(String),
|
||||
repository: info.repository || undefined,
|
||||
homepage: info.url || undefined,
|
||||
publisher: info.publisher || undefined,
|
||||
description: undefined, // license-checker doesn't surface descriptions
|
||||
licenseText: text.trim(),
|
||||
});
|
||||
}
|
||||
ok(`npm: ${out.length} packages`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Self ────────────────────────────────────────────────────────────────────
|
||||
function readSelfPackageJson() {
|
||||
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
|
||||
return {
|
||||
name: pkg.name ?? 'psysonic',
|
||||
version: pkg.version ?? '0.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
function main() {
|
||||
step(`Generating ${OUT_PATH.replace(REPO_ROOT + '/', '')}`);
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
checkCargoAbout();
|
||||
const cargoEntries = generateCargoLicenses();
|
||||
const npmEntries = generateNpmLicenses();
|
||||
|
||||
const entries = [...cargoEntries, ...npmEntries];
|
||||
// Stable sort: name, then version. Case-insensitive on name.
|
||||
entries.sort((a, b) => {
|
||||
const n = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
if (n !== 0) return n;
|
||||
return a.version.localeCompare(b.version);
|
||||
});
|
||||
|
||||
const self = readSelfPackageJson();
|
||||
const stats = {
|
||||
npm: npmEntries.length,
|
||||
cargo: cargoEntries.length,
|
||||
total: entries.length,
|
||||
withFullText: entries.filter((e) => e.licenseText && e.licenseText.length > 0).length,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
project: self,
|
||||
stats,
|
||||
entries,
|
||||
};
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(payload, null, 0) + '\n');
|
||||
const sizeKb = (statSync(OUT_PATH).size / 1024).toFixed(1);
|
||||
ok(`Wrote ${OUT_PATH} (${stats.total} entries, ${sizeKb} KB)`);
|
||||
ok(` ${stats.cargo} cargo + ${stats.npm} npm; ${stats.withFullText} with full license text`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Align src-tauri/Cargo.toml, src-tauri/tauri.conf.json, and workspace entries in
|
||||
* src-tauri/Cargo.lock with package.json "version".
|
||||
* Align src-tauri/Cargo.toml and src-tauri/tauri.conf.json with package.json "version".
|
||||
* Used after npm version in promote workflows so bundle names match release semver.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
@@ -29,21 +28,3 @@ const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.version = version;
|
||||
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
|
||||
console.log(`tauri.conf.json -> ${version}`);
|
||||
|
||||
/** @param {string} lockText */
|
||||
function syncCargoLockWorkspaceVersions(lockText, targetVersion) {
|
||||
return lockText.replace(
|
||||
/^(name = "psysonic[^"]*"\nversion = ")[^"]+"/gm,
|
||||
`$1${targetVersion}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const lockPath = path.join(root, 'src-tauri', 'Cargo.lock');
|
||||
let lock = fs.readFileSync(lockPath, 'utf8');
|
||||
const updatedLock = syncCargoLockWorkspaceVersions(lock, version);
|
||||
if (updatedLock !== lock) {
|
||||
fs.writeFileSync(lockPath, updatedLock);
|
||||
console.log(`Cargo.lock workspace crates -> ${version}`);
|
||||
} else {
|
||||
console.log(`Cargo.lock workspace crates already at ${version}`);
|
||||
}
|
||||
|
||||
Generated
+999
-1624
File diff suppressed because it is too large
Load Diff
+35
-55
@@ -1,28 +1,13 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.48.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
mockall = "0.13"
|
||||
proptest = "1"
|
||||
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version.workspace = true
|
||||
version = "1.45.0"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
edition = "2021"
|
||||
rust-version = "1.89"
|
||||
|
||||
[lib]
|
||||
name = "psysonic_lib"
|
||||
@@ -36,13 +21,7 @@ path = "src/main.rs"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "crates/psysonic-core" }
|
||||
psysonic-analysis = { path = "crates/psysonic-analysis" }
|
||||
psysonic-audio = { path = "crates/psysonic-audio" }
|
||||
psysonic-library = { path = "crates/psysonic-library" }
|
||||
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
|
||||
psysonic-integration = { path = "crates/psysonic-integration" }
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
@@ -51,61 +30,62 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
|
||||
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "rustls-tls-native-roots", "blocking"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.8"
|
||||
md5 = "0.7"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
biquad = "0.6"
|
||||
ringbuf = "0.5"
|
||||
biquad = "0.4"
|
||||
ringbuf = "0.3"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
|
||||
discord-rich-presence = "1.1"
|
||||
discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
|
||||
id3 = "1.17"
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "8"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
webp = "0.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
mach2 = "0.5"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
zbus = { version = "5.9", default-features = false, features = ["blocking-api"] }
|
||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
webkit2gtk-nvidia-quirk = "1.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Power",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
|
||||
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
|
||||
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
|
||||
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
|
||||
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
|
||||
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
|
||||
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
|
||||
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
|
||||
# of failing the entire probe
|
||||
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
||||
|
||||
# Local patch for cpal's CoreAudio backend on macOS:
|
||||
# - Forces IOType::DefaultOutput for all output streams so that macOS never
|
||||
# triggers the TCC microphone-permission prompt. Upstream cpal uses
|
||||
# IOType::HalOutput when the user pins a non-default device, which routes
|
||||
# through AUHAL — that component is flagged as input-capable and triggers
|
||||
# the mic consent dialog on first launch / after each update.
|
||||
# - Tradeoff: output-device selection is a no-op on macOS (stream follows
|
||||
# system default). Surfaced in the Settings UI.
|
||||
cpal = { path = "patches/cpal-0.15.3" }
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{{!--
|
||||
cargo-about template — emits JSON grouped by license. The Node generator
|
||||
(scripts/generate-licenses.mjs) flattens this into a per-crate list and
|
||||
merges it with the npm license data into the final public/licenses.json.
|
||||
--}}
|
||||
{
|
||||
"licenses": [
|
||||
{{#each licenses}}
|
||||
{
|
||||
"id": "{{id}}",
|
||||
"name": "{{name}}",
|
||||
"text": {{json text}},
|
||||
"used_by": [
|
||||
{{#each used_by}}
|
||||
{
|
||||
"name": "{{crate.name}}",
|
||||
"version": "{{crate.version}}",
|
||||
"repository": {{json crate.repository}},
|
||||
"description": {{json crate.description}}
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}{{#unless @last}},{{/unless}}
|
||||
{{/each}}
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
# cargo-about config — controls which licenses are allowed in the dependency
|
||||
# tree and how the licenses listing is generated. Permissive list: anything
|
||||
# the Rust ecosystem commonly publishes under, plus a few exceptions.
|
||||
#
|
||||
# Run via `npm run licenses:generate` (which calls `cargo about generate`).
|
||||
|
||||
accepted = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
# we ship binaries for so the Linux user sees Linux-only deps too, etc.
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
|
||||
# Show transitive deps (we want everything in the binary) but skip dev-deps
|
||||
# (test-only crates aren't in the shipped artifact).
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
@@ -38,7 +38,6 @@
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:webview:allow-set-webview-zoom",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
ebur128 = "0.1"
|
||||
md5 = "0.8"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Baseline: the pre-versioning analysis cache schema.
|
||||
--
|
||||
-- This is the exact shape every existing user DB already carries (created by
|
||||
-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a
|
||||
-- no-op on those DBs and creates the tables on a fresh one, so "migration 1
|
||||
-- applied" means "the schema that shipped before versioned migrations".
|
||||
--
|
||||
-- Server-scoping (server_id) is added additively in 002.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analysis_track (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS waveform_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS loudness_cache (
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
@@ -1,73 +0,0 @@
|
||||
-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped
|
||||
-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each
|
||||
-- table is rebuilt: create the v2 shape, copy every row with server_id = '',
|
||||
-- drop the old table, rename. Existing rows become legacy ('') rows that the
|
||||
-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2).
|
||||
--
|
||||
-- Atomicity: the migration runner wraps this whole file plus the
|
||||
-- schema_migrations marker in one transaction, so any failure or crash rolls
|
||||
-- everything back to the original tables — DROP never runs unless the copy
|
||||
-- before it succeeded. No BEGIN/COMMIT here (that would nest).
|
||||
--
|
||||
-- These three tables have no foreign keys between them or from any other table,
|
||||
-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op
|
||||
-- inside a transaction anyway).
|
||||
|
||||
-- analysis_track
|
||||
CREATE TABLE analysis_track_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
waveform_algo_version INTEGER NOT NULL,
|
||||
loudness_algo_version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb)
|
||||
);
|
||||
INSERT INTO analysis_track_v2
|
||||
(server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at)
|
||||
SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
|
||||
FROM analysis_track;
|
||||
DROP TABLE analysis_track;
|
||||
ALTER TABLE analysis_track_v2 RENAME TO analysis_track;
|
||||
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
|
||||
ON analysis_track(status);
|
||||
|
||||
-- waveform_cache
|
||||
CREATE TABLE waveform_cache_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
bins BLOB NOT NULL,
|
||||
bin_count INTEGER NOT NULL,
|
||||
is_partial INTEGER NOT NULL,
|
||||
known_until_sec REAL NOT NULL,
|
||||
duration_sec REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb)
|
||||
);
|
||||
INSERT INTO waveform_cache_v2
|
||||
(server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at)
|
||||
SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
|
||||
FROM waveform_cache;
|
||||
DROP TABLE waveform_cache;
|
||||
ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache;
|
||||
|
||||
-- loudness_cache
|
||||
CREATE TABLE loudness_cache_v2 (
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
track_id TEXT NOT NULL,
|
||||
md5_16kb TEXT NOT NULL,
|
||||
integrated_lufs REAL NOT NULL,
|
||||
true_peak REAL NOT NULL,
|
||||
recommended_gain_db REAL NOT NULL,
|
||||
target_lufs REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs)
|
||||
);
|
||||
INSERT INTO loudness_cache_v2
|
||||
(server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at)
|
||||
SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
|
||||
FROM loudness_cache;
|
||||
DROP TABLE loudness_cache;
|
||||
ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
mod compute;
|
||||
mod store;
|
||||
|
||||
pub use compute::{
|
||||
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
|
||||
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
|
||||
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
|
||||
};
|
||||
pub use store::{
|
||||
AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey,
|
||||
WaveformEntry,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
//! Per-track analysis timing events for the Performance Probe overlay.
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AnalysisSeedTimings {
|
||||
pub seed_ms: u64,
|
||||
pub bpm_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisTrackPerfPayload {
|
||||
pub track_id: String,
|
||||
pub fetch_ms: u64,
|
||||
pub seed_ms: u64,
|
||||
pub bpm_ms: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
pub fn emit_analysis_track_perf(
|
||||
app: &AppHandle,
|
||||
track_id: &str,
|
||||
fetch_ms: u64,
|
||||
seed_ms: u64,
|
||||
bpm_ms: u64,
|
||||
) {
|
||||
let total_ms = fetch_ms.saturating_add(seed_ms).saturating_add(bpm_ms);
|
||||
if total_ms == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = app.emit(
|
||||
"analysis:track-perf",
|
||||
AnalysisTrackPerfPayload {
|
||||
track_id: track_id.to_string(),
|
||||
fetch_ms,
|
||||
seed_ms,
|
||||
bpm_ms,
|
||||
total_ms,
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
|
||||
use symphonia::core::codecs::registry::CodecRegistry;
|
||||
|
||||
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn make_decoder(
|
||||
params: &AudioCodecParameters,
|
||||
opts: &AudioDecoderOptions,
|
||||
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make_audio_decoder(params, opts)
|
||||
}
|
||||
@@ -1,600 +0,0 @@
|
||||
//! Tauri commands that read/write the analysis cache and steer the backfill
|
||||
//! queue. Thin wrappers around `analysis_cache::*` and `analysis_runtime::*`
|
||||
//! plus the playback-query port (for "is this track currently playing? /
|
||||
//! is a ranged playback already going to seed it?").
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::analysis_cache;
|
||||
use crate::analysis_runtime::{
|
||||
analysis_backfill_queue_stats, analysis_pipeline_queue_stats, enqueue_seed_from_url,
|
||||
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WaveformCachePayload {
|
||||
pub bins: Vec<u8>,
|
||||
pub bin_count: i64,
|
||||
pub is_partial: bool,
|
||||
pub known_until_sec: f64,
|
||||
pub duration_sec: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
|
||||
fn from(v: analysis_cache::WaveformEntry) -> Self {
|
||||
Self {
|
||||
bins: v.bins,
|
||||
bin_count: v.bin_count,
|
||||
is_partial: v.is_partial,
|
||||
known_until_sec: v.known_until_sec,
|
||||
duration_sec: v.duration_sec,
|
||||
updated_at: v.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoudnessCachePayload {
|
||||
pub integrated_lufs: f64,
|
||||
pub true_peak: f64,
|
||||
pub recommended_gain_db: f64,
|
||||
pub target_lufs: f64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisDeleteServerReportDto {
|
||||
pub analysis_tracks: u64,
|
||||
pub waveforms: u64,
|
||||
pub loudness: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisFailedTrackDto {
|
||||
pub track_id: String,
|
||||
pub md5_16kb: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl From<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerReportDto {
|
||||
fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self {
|
||||
Self {
|
||||
analysis_tracks: value.analysis_tracks,
|
||||
waveforms: value.waveforms,
|
||||
loudness: value.loudness,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
|
||||
fn from(value: analysis_cache::FailedTrackEntry) -> Self {
|
||||
Self {
|
||||
track_id: value.track_id,
|
||||
md5_16kb: value.md5_16kb,
|
||||
updated_at: value.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
pub index_key: String,
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id,
|
||||
/// md5_16kb)` key. Converts the `WaveformEntry` into the JSON-serialisable
|
||||
/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can be
|
||||
/// tested with `AnalysisCache::open_in_memory()` and direct upserts.
|
||||
pub fn get_waveform_payload(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
md5_16kb: &str,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let exact = analysis_cache::TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_16kb.to_string(),
|
||||
};
|
||||
Ok(cache
|
||||
.get_waveform(&exact)?
|
||||
.map(WaveformCachePayload::from))
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)`
|
||||
/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`].
|
||||
pub fn get_waveform_payload_for_track(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
Ok(cache
|
||||
.get_latest_waveform_for_track(server_id, track_id)?
|
||||
.map(WaveformCachePayload::from))
|
||||
}
|
||||
|
||||
/// AppHandle-free helper: looks up the latest loudness row for `(server_id,
|
||||
/// track_id)` and recomputes `recommended_gain_db`
|
||||
/// against the optional requested target (clamped to [-30, -8]). When
|
||||
/// `target_lufs` is `None`, the cached row's own target is used.
|
||||
pub fn get_loudness_payload_for_track(
|
||||
cache: &analysis_cache::AnalysisCache,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
target_lufs: Option<f64>,
|
||||
) -> Result<Option<LoudnessCachePayload>, String> {
|
||||
Ok(cache.get_latest_loudness_for_track(server_id, track_id)?.map(|v| {
|
||||
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
|
||||
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
|
||||
v.integrated_lufs,
|
||||
v.true_peak,
|
||||
requested_target,
|
||||
);
|
||||
LoudnessCachePayload {
|
||||
integrated_lufs: v.integrated_lufs,
|
||||
true_peak: v.true_peak,
|
||||
recommended_gain_db,
|
||||
target_lufs: requested_target,
|
||||
updated_at: v.updated_at,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_waveform(
|
||||
track_id: String,
|
||||
md5_16kb: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
let result = get_waveform_payload(cache.inner(), &server_id, &track_id, &md5_16kb);
|
||||
if let Ok(ref payload) = result {
|
||||
match payload {
|
||||
Some(v) => crate::app_deprintln!(
|
||||
"[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}",
|
||||
track_id, md5_16kb, v.bins.len(), v.bin_count, v.updated_at
|
||||
),
|
||||
None => crate::app_deprintln!(
|
||||
"[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}",
|
||||
track_id, md5_16kb
|
||||
),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
let result = get_waveform_payload_for_track(cache.inner(), &server_id, &track_id);
|
||||
if let Ok(ref payload) = result {
|
||||
match payload {
|
||||
Some(v) => crate::app_deprintln!(
|
||||
"[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}",
|
||||
track_id, v.bins.len(), v.bin_count, v.updated_at
|
||||
),
|
||||
None => crate::app_deprintln!("[analysis][waveform] db miss track_id={}", track_id),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_loudness_for_track(
|
||||
track_id: String,
|
||||
target_lufs: Option<f64>,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<LoudnessCachePayload>, String> {
|
||||
let server_id = server_id.unwrap_or_default();
|
||||
get_loudness_payload_for_track(cache.inner(), &server_id, &track_id, target_lufs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_loudness_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_loudness_for_track_id(&server_id.unwrap_or_default(), &track_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_waveform_for_track_id(&server_id.unwrap_or_default(), &track_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_all_waveforms(
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_all_waveforms()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_delete_all_for_server(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<AnalysisDeleteServerReportDto, String> {
|
||||
if server_id.trim().is_empty() {
|
||||
return Err("server_id required".to_string());
|
||||
}
|
||||
let report = cache.delete_all_for_server(&server_id)?;
|
||||
Ok(report.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_failed_track_count(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<i64, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
cache.count_failed_tracks(&server_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_list_failed_tracks(
|
||||
server_id: String,
|
||||
limit: Option<u32>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Vec<AnalysisFailedTrackDto>, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let limit = limit
|
||||
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
.map(|v| v.clamp(1, 5_000));
|
||||
let rows = cache.list_failed_tracks(&server_id, limit)?;
|
||||
Ok(rows.into_iter().map(AnalysisFailedTrackDto::from).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_clear_failed_tracks(
|
||||
server_id: String,
|
||||
track_ids: Option<Vec<String>>,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
let server_id = server_id.trim().to_string();
|
||||
if server_id.is_empty() {
|
||||
return Err("server_id required".to_string());
|
||||
}
|
||||
let track_ids = track_ids
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
cache.clear_failed_tracks(&server_id, &track_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_migrate_server_index_keys(
|
||||
mappings: Vec<AnalysisServerKeyMigrationDto>,
|
||||
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<(), String> {
|
||||
for mapping in mappings {
|
||||
let _ = (mapping.legacy_id, mapping.index_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_enqueue_seed_from_url(
|
||||
track_id: String,
|
||||
url: String,
|
||||
force: Option<bool>,
|
||||
server_id: Option<String>,
|
||||
priority: Option<String>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let explicit = AnalysisBackfillPriority::from_optional_str(priority.as_deref());
|
||||
enqueue_seed_from_url(
|
||||
&app,
|
||||
&track_id,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
explicit,
|
||||
force.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPriorityHintDto {
|
||||
pub server_id: String,
|
||||
pub track_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_set_playback_priority_hints(
|
||||
middle_track_refs: Vec<AnalysisPriorityHintDto>,
|
||||
hints: tauri::State<'_, PlaybackPriorityHints>,
|
||||
) -> Result<(), String> {
|
||||
let pairs = middle_track_refs
|
||||
.into_iter()
|
||||
.map(|r| (r.server_id, r.track_id));
|
||||
hints.set_middle_track_ids(pairs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisBackfillQueueStatsDto {
|
||||
pub queued: usize,
|
||||
pub in_progress_count: usize,
|
||||
pub in_progress_track_id: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
|
||||
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
|
||||
Ok(analysis_pipeline_queue_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
|
||||
let (queued, in_progress_count, in_progress_track_id) =
|
||||
analysis_backfill_queue_stats();
|
||||
Ok(AnalysisBackfillQueueStatsDto {
|
||||
queued,
|
||||
in_progress_count,
|
||||
in_progress_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPrunePendingResult {
|
||||
pub keep_count: usize,
|
||||
pub http_removed: usize,
|
||||
pub cpu_removed_jobs: usize,
|
||||
pub cpu_removed_waiters: usize,
|
||||
}
|
||||
|
||||
/// Prunes pending analysis work for tracks no longer present in the playback queue.
|
||||
///
|
||||
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
|
||||
#[tauri::command]
|
||||
pub fn analysis_prune_pending_to_track_ids(
|
||||
track_ids: Vec<String>,
|
||||
server_id: String,
|
||||
) -> Result<AnalysisPrunePendingResult, String> {
|
||||
let mut normalized: Vec<String> = Vec::with_capacity(track_ids.len());
|
||||
let mut seen = HashSet::new();
|
||||
for raw in track_ids {
|
||||
let tid = raw.trim();
|
||||
if tid.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(tid.to_string()) {
|
||||
normalized.push(tid.to_string());
|
||||
}
|
||||
}
|
||||
let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let server_id = server_id.trim().to_string();
|
||||
let server_filter = if server_id.is_empty() { None } else { Some(server_id.as_str()) };
|
||||
let (http_removed, cpu_removed_jobs, cpu_removed_waiters) =
|
||||
prune_analysis_queues(&keep_track_ids, server_filter)?;
|
||||
|
||||
if http_removed > 0 || cpu_removed_jobs > 0 {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] pruned pending queues keep={} removed_http={} removed_cpu_jobs={} removed_cpu_waiters={}",
|
||||
keep_track_ids.len(),
|
||||
http_removed,
|
||||
cpu_removed_jobs,
|
||||
cpu_removed_waiters
|
||||
);
|
||||
}
|
||||
|
||||
Ok(AnalysisPrunePendingResult {
|
||||
keep_count: keep_track_ids.len(),
|
||||
http_removed,
|
||||
cpu_removed_jobs,
|
||||
cpu_removed_waiters,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::analysis_cache::{
|
||||
AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry,
|
||||
};
|
||||
|
||||
fn key(track_id: &str, md5: &str) -> TrackKey {
|
||||
TrackKey {
|
||||
server_id: "server-a".to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_waveform(cache: &AnalysisCache, track_id: &str, md5: &str, bins: Vec<u8>) {
|
||||
let k = key(track_id, md5);
|
||||
cache.touch_track_status(&k, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(
|
||||
&k,
|
||||
&WaveformEntry {
|
||||
bin_count: (bins.len() / 2) as i64,
|
||||
bins,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 60.0,
|
||||
updated_at: 1_700_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn upsert_loudness(cache: &AnalysisCache, track_id: &str, md5: &str, target_lufs: f64) {
|
||||
let k = key(track_id, md5);
|
||||
cache.touch_track_status(&k, "ready").unwrap();
|
||||
cache
|
||||
.upsert_loudness(
|
||||
&k,
|
||||
&LoudnessEntry {
|
||||
integrated_lufs: -14.0,
|
||||
true_peak: 0.5,
|
||||
recommended_gain_db: 0.0,
|
||||
target_lufs,
|
||||
updated_at: 1_700_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── get_waveform_payload ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_returns_none_for_unknown_key() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let payload = get_waveform_payload(&cache, "server-a", "missing", "deadbeef").unwrap();
|
||||
assert!(payload.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_returns_payload_for_existing_row() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let bins: Vec<u8> = (0..8u8).collect();
|
||||
upsert_waveform(&cache, "abc", "deadbeef", bins.clone());
|
||||
let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef")
|
||||
.unwrap()
|
||||
.expect("payload exists");
|
||||
assert_eq!(payload.bins, bins);
|
||||
assert_eq!(payload.bin_count, 4);
|
||||
assert!(!payload.is_partial);
|
||||
assert_eq!(payload.duration_sec, 60.0);
|
||||
assert_eq!(payload.updated_at, 1_700_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_payload_distinguishes_md5_keys() {
|
||||
// Same track_id, different md5_16kb → independent rows.
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]);
|
||||
upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]);
|
||||
let p1 = get_waveform_payload(&cache, "server-a", "abc", "aaaa").unwrap().unwrap();
|
||||
let p2 = get_waveform_payload(&cache, "server-a", "abc", "bbbb").unwrap().unwrap();
|
||||
assert_ne!(p1.bins, p2.bins);
|
||||
}
|
||||
|
||||
// ── get_waveform_payload_for_track ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_waveform_for_track_finds_row_under_stream_prefix() {
|
||||
// Insert under `stream:abc`, look up with bare `abc` — id-variant
|
||||
// matching is the whole point of get_latest_waveform_for_track.
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]);
|
||||
let payload = get_waveform_payload_for_track(&cache, "server-a", "abc")
|
||||
.unwrap()
|
||||
.expect("bare-id lookup must hit the stream-prefixed row");
|
||||
assert_eq!(payload.bin_count, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_waveform_for_track_returns_none_for_unknown_track() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
assert!(get_waveform_payload_for_track(&cache, "server-a", "phantom").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ── get_loudness_payload_for_track ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_recomputes_gain_against_requested_target() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
|
||||
// Cached row: integrated -14, target -14 → gain 0. Request target -10 →
|
||||
// recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard).
|
||||
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-10.0))
|
||||
.unwrap()
|
||||
.expect("loudness row exists");
|
||||
assert_eq!(payload.target_lufs, -10.0);
|
||||
assert!(
|
||||
payload.recommended_gain_db.is_finite() && payload.recommended_gain_db <= 4.0,
|
||||
"recommended_gain_db must reflect the new target, got {}",
|
||||
payload.recommended_gain_db
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_uses_cached_target_when_request_is_none() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -16.0);
|
||||
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(payload.target_lufs, -16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_clamps_target_into_supported_range() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
|
||||
// Out-of-range target gets clamped to [-30, -8].
|
||||
let too_high = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(0.0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(too_high.target_lufs, -8.0);
|
||||
let too_low = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-100.0))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(too_low.target_lufs, -30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_loudness_for_track_returns_none_for_unknown_track() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
assert!(get_loudness_payload_for_track(&cache, "server-a", "phantom", None)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// ── WaveformCachePayload::from(WaveformEntry) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn waveform_payload_from_entry_preserves_all_fields() {
|
||||
let entry = WaveformEntry {
|
||||
bins: vec![1, 2, 3, 4],
|
||||
bin_count: 2,
|
||||
is_partial: true,
|
||||
known_until_sec: 5.5,
|
||||
duration_sec: 10.0,
|
||||
updated_at: 42,
|
||||
};
|
||||
let payload = WaveformCachePayload::from(entry);
|
||||
assert_eq!(payload.bins, vec![1, 2, 3, 4]);
|
||||
assert_eq!(payload.bin_count, 2);
|
||||
assert!(payload.is_partial);
|
||||
assert_eq!(payload.known_until_sec, 5.5);
|
||||
assert_eq!(payload.duration_sec, 10.0);
|
||||
assert_eq!(payload.updated_at, 42);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! `psysonic-analysis` — loudness/waveform analysis cache and the runtime
|
||||
//! that drives HTTP backfill + CPU-seed work queues.
|
||||
//!
|
||||
//! Submodules mirror the original layout in the top crate:
|
||||
//! - `analysis_cache` — SQLite-backed loudness/waveform store + compute helpers
|
||||
//! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop
|
||||
|
||||
pub mod analysis_cache;
|
||||
pub mod analysis_perf;
|
||||
pub mod analysis_runtime;
|
||||
mod codec;
|
||||
pub mod commands;
|
||||
pub mod track_analysis_plan;
|
||||
pub mod track_enrichment;
|
||||
|
||||
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
|
||||
// the same way they did when they lived in the top crate.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
@@ -1,197 +0,0 @@
|
||||
//! Plan what a track still needs: waveform, LUFS, enrichment (BPM/mood), …
|
||||
//!
|
||||
//! All byte-backed enqueue paths should call [`crate::analysis_runtime::enqueue_track_analysis`],
|
||||
//! which uses this module to decide full CPU seed vs enrichment-only vs no-op.
|
||||
|
||||
use psysonic_core::track_analysis::TrackAnalysisPlan;
|
||||
use psysonic_core::track_enrichment::TrackEnrichmentPort;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::analysis_cache::{AnalysisCache, TrackKey};
|
||||
|
||||
pub fn plan_track_analysis(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
enrichment,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan from the latest cached fingerprint when bytes are not available yet (HTTP backfill gate).
|
||||
pub fn plan_track_analysis_from_cache(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<TrackAnalysisPlan, String> {
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
return Ok(TrackAnalysisPlan {
|
||||
need_waveform: true,
|
||||
need_loudness: true,
|
||||
enrichment: Default::default(),
|
||||
});
|
||||
};
|
||||
let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? else {
|
||||
return Ok(TrackAnalysisPlan {
|
||||
need_waveform: true,
|
||||
need_loudness: true,
|
||||
enrichment: Default::default(),
|
||||
});
|
||||
};
|
||||
Ok(plan_track_analysis(app, server_id, track_id, &md5))
|
||||
}
|
||||
|
||||
pub fn track_analysis_needs_work(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
if let Some(cache) = app.try_state::<AnalysisCache>() {
|
||||
let latest_status = cache.get_latest_status_for_track(server_id, track_id)?;
|
||||
if latest_status
|
||||
.as_ref()
|
||||
.is_some_and(|(status, _)| status == "failed")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let plan = plan_track_analysis_from_cache(app, server_id, track_id)?;
|
||||
if !plan.any() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Legacy reconciliation: some old rows are persisted as `ready` with
|
||||
// waveform present but no loudness (typically unsupported decode path).
|
||||
// Those tracks spin forever in pending without converging. Promote to
|
||||
// terminal `failed` so scheduler/progress can converge.
|
||||
if latest_status
|
||||
.as_ref()
|
||||
.is_some_and(|(status, _)| status == "ready")
|
||||
&& plan.need_loudness
|
||||
&& !plan.need_waveform
|
||||
{
|
||||
if let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? {
|
||||
let key = TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5,
|
||||
};
|
||||
let _ = cache.touch_track_status(&key, "failed");
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
return Ok(plan.any());
|
||||
}
|
||||
Ok(plan_track_analysis_from_cache(app, server_id, track_id)?.any())
|
||||
}
|
||||
|
||||
fn cache_gaps(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
cache_gaps_for_content(
|
||||
app.try_state::<AnalysisCache>().as_deref(),
|
||||
server_id,
|
||||
track_id,
|
||||
content_hash,
|
||||
)
|
||||
}
|
||||
|
||||
fn enrichment_plan(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
if server_id.is_empty() {
|
||||
return Default::default();
|
||||
}
|
||||
app.try_state::<TrackEnrichmentPort>()
|
||||
.map(|port| port.plan(server_id, track_id, content_hash))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn cache_gaps_for_content(
|
||||
cache: Option<&AnalysisCache>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let Some(cache) = cache else {
|
||||
return (true, true);
|
||||
};
|
||||
match cache.content_cache_coverage(server_id, track_id, content_hash) {
|
||||
Ok(coverage) => (!coverage.has_waveform, !coverage.has_loudness),
|
||||
Err(_) => (true, true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::analysis_cache::{LoudnessEntry, TrackKey, WaveformEntry};
|
||||
|
||||
fn seed_waveform_loudness(cache: &AnalysisCache, server_id: &str, track_id: &str, md5: &str) {
|
||||
let key = TrackKey {
|
||||
server_id: server_id.to_string(),
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5.to_string(),
|
||||
};
|
||||
cache.touch_track_status(&key, "ready").unwrap();
|
||||
cache
|
||||
.upsert_waveform(
|
||||
&key,
|
||||
&WaveformEntry {
|
||||
bins: vec![0u8; 1000],
|
||||
bin_count: 500,
|
||||
is_partial: false,
|
||||
known_until_sec: 0.0,
|
||||
duration_sec: 0.0,
|
||||
updated_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.upsert_loudness(
|
||||
&key,
|
||||
&LoudnessEntry {
|
||||
integrated_lufs: -14.0,
|
||||
true_peak: 1.0,
|
||||
recommended_gain_db: 0.0,
|
||||
target_lufs: -14.0,
|
||||
updated_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_gaps_true_when_empty() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||
assert!(wf && ld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_gaps_false_when_fingerprint_present() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "s1", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||
assert!(!wf && !ld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_gaps_finds_stream_prefix_row_for_bare_track_id() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "s1", "stream:t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Client-side track enrichment — oximedia BPM + mood into library facts.
|
||||
|
||||
use oximedia_mir::{mood, tempo, MirConfig};
|
||||
use psysonic_core::track_enrichment::{
|
||||
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort,
|
||||
TrackEnrichmentPlan, TrackEnrichmentRealFact,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
|
||||
use crate::analysis_cache::{
|
||||
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb,
|
||||
};
|
||||
|
||||
pub const ENRICHMENT_WINDOW_SEC: f64 = 60.0;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnrichmentUpdatedPayload {
|
||||
pub track_id: String,
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
fn emit_enrichment_updated<R: Runtime>(app: &AppHandle<R>, server_id: &str, track_id: &str) {
|
||||
let _ = app.emit(
|
||||
"analysis:enrichment-updated",
|
||||
EnrichmentUpdatedPayload {
|
||||
track_id: track_id.to_string(),
|
||||
server_id: server_id.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run_track_enrichment_if_needed<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
notify_ui: bool,
|
||||
) -> TrackEnrichmentOutcome {
|
||||
if server_id.is_empty() {
|
||||
return TrackEnrichmentOutcome::SkippedNoServer;
|
||||
}
|
||||
let Some(port) = app.try_state::<TrackEnrichmentPort>() else {
|
||||
return TrackEnrichmentOutcome::SkippedNoPort;
|
||||
};
|
||||
let content_hash = md5_first_16kb(bytes);
|
||||
let plan = port.plan(server_id, track_id, &content_hash);
|
||||
if !plan.any() {
|
||||
return TrackEnrichmentOutcome::SkippedComplete;
|
||||
}
|
||||
|
||||
match analyze_and_store(&port, server_id, track_id, &content_hash, bytes, plan) {
|
||||
Ok(()) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][enrichment] applied track_id={} server_id={} hash={}",
|
||||
track_id,
|
||||
server_id,
|
||||
content_hash
|
||||
);
|
||||
if notify_ui {
|
||||
emit_enrichment_updated(app, server_id, track_id);
|
||||
}
|
||||
TrackEnrichmentOutcome::Applied
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[analysis][enrichment] failed track_id={} server_id={}: {}",
|
||||
track_id,
|
||||
server_id,
|
||||
e
|
||||
);
|
||||
TrackEnrichmentOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze_and_store(
|
||||
port: &TrackEnrichmentPort,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
bytes: &[u8],
|
||||
plan: TrackEnrichmentPlan,
|
||||
) -> Result<(), String> {
|
||||
let total_duration = audio_duration_from_bytes(bytes).unwrap_or(0.0);
|
||||
let window = analysis_pcm_window(total_duration, ENRICHMENT_WINDOW_SEC);
|
||||
let (mono, sample_rate) =
|
||||
decode_mono_pcm_window(bytes, window.start_sec, window.duration_sec)?;
|
||||
if mono.is_empty() || sample_rate <= 0.0 {
|
||||
return Err("empty PCM window".to_string());
|
||||
}
|
||||
|
||||
let config = MirConfig::default();
|
||||
let mut facts = TrackEnrichmentFacts::default();
|
||||
|
||||
if plan.need_bpm {
|
||||
let detector = tempo::TempoDetector::new(sample_rate, config.min_tempo, config.max_tempo);
|
||||
let tempo = detector.detect(&mono).map_err(|e| format!("tempo: {e}"))?;
|
||||
let bpm = tempo.bpm.round().clamp(20.0, 999.0) as i64;
|
||||
facts.bpm = Some(TrackEnrichmentIntFact {
|
||||
value: bpm,
|
||||
confidence: tempo.confidence,
|
||||
});
|
||||
}
|
||||
|
||||
if plan.need_valence || plan.need_arousal || plan.need_moods {
|
||||
let detector = mood::MoodDetector::new(sample_rate);
|
||||
let mood = detector.detect(&mono).map_err(|e| format!("mood: {e}"))?;
|
||||
let confidence = mood.intensity.clamp(0.0, 1.0);
|
||||
if plan.need_valence {
|
||||
facts.valence = Some(TrackEnrichmentRealFact {
|
||||
value: mood.valence as f64,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
if plan.need_arousal {
|
||||
facts.arousal = Some(TrackEnrichmentRealFact {
|
||||
value: mood.arousal as f64,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
if plan.need_moods && !mood.moods.is_empty() {
|
||||
facts.moods = Some(
|
||||
serde_json::to_string(&mood.moods).map_err(|e| format!("moods json: {e}"))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
port.store(server_id, track_id, content_hash, &facts)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
ringbuf = "0.5"
|
||||
biquad = "0.6"
|
||||
dasp_sample = "0.11.0"
|
||||
md5 = "0.8"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
id3 = "1.17"
|
||||
pitch_shift = "2.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
tokio = { version = "1", features = ["rt", "time", "sync", "macros", "rt-multi-thread", "test-util"] }
|
||||
wiremock = { workspace = true }
|
||||
@@ -1,288 +0,0 @@
|
||||
//! Unified playback → track analysis dispatch.
|
||||
//!
|
||||
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
|
||||
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
|
||||
|
||||
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
|
||||
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
|
||||
use url::Url;
|
||||
use crate::state::ChainedInfo;
|
||||
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||
|
||||
/// Where playback obtained the bytes — used for logging and size caps only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TrackAnalysisOrigin {
|
||||
InMemoryReplay,
|
||||
StreamDownloadComplete,
|
||||
LocalFilePlayback,
|
||||
StreamSpillFile,
|
||||
PrefetchOrCacheFile,
|
||||
GaplessChainReady,
|
||||
GaplessTransition,
|
||||
}
|
||||
|
||||
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
|
||||
match origin {
|
||||
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
||||
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback server scope: explicit IPC value, else pinned engine scope.
|
||||
pub(crate) fn resolve_analysis_server_id(
|
||||
explicit: Option<&str>,
|
||||
engine: Option<&AudioEngine>,
|
||||
) -> String {
|
||||
if let Some(engine) = engine {
|
||||
if let Some(url) = engine
|
||||
.current_playback_url
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
{
|
||||
if let Some(derived) = server_id_from_playback_url(&url) {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
}
|
||||
explicit
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| engine.map(current_playback_server_id_str).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn server_id_from_playback_url(url_raw: &str) -> Option<String> {
|
||||
if url_raw.starts_with("psysonic-local://") {
|
||||
return None;
|
||||
}
|
||||
let parsed = Url::parse(url_raw).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let mut base_path = parsed.path().to_string();
|
||||
if let Some(idx) = base_path.find("/rest") {
|
||||
base_path.truncate(idx);
|
||||
}
|
||||
while base_path.ends_with('/') {
|
||||
base_path.pop();
|
||||
}
|
||||
let mut base = host.to_string();
|
||||
if let Some(port) = parsed.port() {
|
||||
base.push_str(&format!(":{port}"));
|
||||
}
|
||||
if !base_path.is_empty() {
|
||||
base.push_str(&base_path);
|
||||
}
|
||||
Some(base)
|
||||
}
|
||||
|
||||
fn resolve_analysis_priority(
|
||||
app: &AppHandle,
|
||||
engine: Option<&AudioEngine>,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
explicit: Option<AnalysisBackfillPriority>,
|
||||
) -> AnalysisBackfillPriority {
|
||||
if let Some(priority) = explicit {
|
||||
return priority;
|
||||
}
|
||||
if psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|
||||
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
|
||||
{
|
||||
return AnalysisBackfillPriority::High;
|
||||
}
|
||||
psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve `(server_id, priority)` when the caller has live engine state.
|
||||
pub(crate) fn prepare_playback_analysis(
|
||||
app: &AppHandle,
|
||||
engine: &AudioEngine,
|
||||
explicit_server_id: Option<&str>,
|
||||
track_id: &str,
|
||||
priority: Option<AnalysisBackfillPriority>,
|
||||
) -> (String, AnalysisBackfillPriority) {
|
||||
let sid = resolve_analysis_server_id(explicit_server_id, Some(engine));
|
||||
let resolved = resolve_analysis_priority(app, Some(engine), &sid, track_id, priority);
|
||||
(sid, resolved)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_server_id_for_app(
|
||||
app: &AppHandle,
|
||||
explicit: Option<&str>,
|
||||
) -> String {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_analysis_server_id(explicit, engine.as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn analysis_priority_for_app(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
explicit: Option<AnalysisBackfillPriority>,
|
||||
) -> AnalysisBackfillPriority {
|
||||
let engine = app.try_state::<AudioEngine>();
|
||||
resolve_analysis_priority(app, engine.as_deref(), server_id, track_id, explicit)
|
||||
}
|
||||
|
||||
/// Gapless boundary: chained track became audible — run unified analysis if needed.
|
||||
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
|
||||
let track_id = analysis_cache_track_id(
|
||||
info.analysis_track_id.as_deref(),
|
||||
&info.url,
|
||||
);
|
||||
let Some(track_id) = track_id else {
|
||||
return;
|
||||
};
|
||||
let engine = app.state::<AudioEngine>();
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
&engine,
|
||||
info.server_id.as_deref(),
|
||||
&track_id,
|
||||
Some(AnalysisBackfillPriority::High),
|
||||
);
|
||||
let bytes = (*info.raw_bytes).clone();
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::GaplessTransition,
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
|
||||
pub(crate) async fn dispatch_track_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
bytes: Vec<u8>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
) -> Result<(), String> {
|
||||
let track_id = track_id.trim();
|
||||
if track_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let max = max_bytes_for_origin(origin);
|
||||
if bytes.len() > max {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
|
||||
bytes.len(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} priority={priority:?}",
|
||||
if server_id.is_empty() { "''" } else { server_id },
|
||||
bytes.len() as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
|
||||
app,
|
||||
server_id,
|
||||
track_id,
|
||||
&bytes,
|
||||
None,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Non-blocking wrapper with optional play-generation supersede guard.
|
||||
pub(crate) fn spawn_track_analysis_bytes(
|
||||
app: AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
bytes: Vec<u8>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() || bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Some((gen, gen_arc)) = generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
origin,
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_track_analysis_file(
|
||||
app: AppHandle,
|
||||
origin: TrackAnalysisOrigin,
|
||||
server_id: String,
|
||||
track_id: String,
|
||||
file_path: PathBuf,
|
||||
priority: AnalysisBackfillPriority,
|
||||
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||
) {
|
||||
if track_id.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Some((gen, gen_arc)) = &generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != *gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let bytes = match tokio::fs::read(&file_path).await {
|
||||
Ok(b) if !b.is_empty() => b,
|
||||
_ => return,
|
||||
};
|
||||
if let Some((gen, gen_arc)) = generation_guard {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
origin,
|
||||
&server_id,
|
||||
&track_id,
|
||||
bytes,
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!(
|
||||
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//! AutoEQ proxy commands: fetch entry list and FixedBandEQ profiles from
|
||||
//! GitHub via Rust to bypass WebView CORS.
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
|
||||
/// AutoEQ raw-content base URL — the GitHub directory that holds every
|
||||
/// FixedBandEQ profile by `(source, form, name)` (and `rig`-prefixed forms
|
||||
/// for crinacle's measurements).
|
||||
pub(crate) const AUTOEQ_RAW_BASE: &str =
|
||||
"https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
|
||||
|
||||
/// Pure URL builder for [`autoeq_fetch_profile`]. The AutoEQ repo lays out
|
||||
/// FixedBandEQ profiles either as
|
||||
///
|
||||
/// `{base}/{source}/{form}/{name}/{name} FixedBandEQ.txt` (most sources)
|
||||
/// `{base}/{source}/{rig} {form}/{name}/{name} FixedBandEQ.txt` (crinacle — rig-prefixed dir)
|
||||
///
|
||||
/// When `rig` is supplied the function emits the rig-prefixed candidate first
|
||||
/// (so callers try it before the form-only fallback). When `rig` is `None`
|
||||
/// only the form-only path is returned.
|
||||
pub(crate) fn autoeq_profile_url_candidates(
|
||||
base: &str,
|
||||
source: &str,
|
||||
form: &str,
|
||||
name: &str,
|
||||
rig: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let filename = format!("{} FixedBandEQ.txt", name);
|
||||
if let Some(r) = rig {
|
||||
vec![
|
||||
format!("{}/{}/{} {}/{}/{}", base, source, r, form, name, filename),
|
||||
format!("{}/{}/{}/{}/{}", base, source, form, name, filename),
|
||||
]
|
||||
} else {
|
||||
vec![format!("{}/{}/{}/{}/{}", base, source, form, name, filename)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
|
||||
#[tauri::command]
|
||||
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
|
||||
audio_http_client(&state)
|
||||
.get("https://autoeq.app/entries")
|
||||
.send().await.map_err(|e| e.to_string())?
|
||||
.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
|
||||
#[tauri::command]
|
||||
pub async fn autoeq_fetch_profile(
|
||||
name: String,
|
||||
source: String,
|
||||
rig: Option<String>,
|
||||
form: String,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<String, String> {
|
||||
let candidates =
|
||||
autoeq_profile_url_candidates(AUTOEQ_RAW_BASE, &source, &form, &name, rig.as_deref());
|
||||
|
||||
for url in &candidates {
|
||||
let resp = audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if resp.status().is_success() {
|
||||
return resp.text().await.map_err(|e| e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("FixedBandEQ profile not found for '{}'", name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_candidates_returns_form_only_path_when_no_rig_supplied() {
|
||||
let urls = autoeq_profile_url_candidates(
|
||||
"https://example/results",
|
||||
"oratory1990",
|
||||
"over-ear",
|
||||
"Sennheiser HD 600",
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec!["https://example/results/oratory1990/over-ear/Sennheiser HD 600/Sennheiser HD 600 FixedBandEQ.txt".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_candidates_emits_rig_prefixed_candidate_first_when_rig_supplied() {
|
||||
// crinacle's measurements use a rig-prefixed directory like
|
||||
// `crinacle/IEC711 in-ear` instead of plain `crinacle/in-ear`.
|
||||
let urls = autoeq_profile_url_candidates(
|
||||
"https://example/results",
|
||||
"crinacle",
|
||||
"in-ear",
|
||||
"Moondrop Variations",
|
||||
Some("IEC711"),
|
||||
);
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert_eq!(
|
||||
urls[0],
|
||||
"https://example/results/crinacle/IEC711 in-ear/Moondrop Variations/Moondrop Variations FixedBandEQ.txt",
|
||||
"rig-prefixed path tried first"
|
||||
);
|
||||
assert_eq!(
|
||||
urls[1],
|
||||
"https://example/results/crinacle/in-ear/Moondrop Variations/Moondrop Variations FixedBandEQ.txt",
|
||||
"form-only fallback emitted second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_candidates_preserves_spaces_in_headphone_names() {
|
||||
let urls = autoeq_profile_url_candidates(
|
||||
"base",
|
||||
"src",
|
||||
"form",
|
||||
"Audio-Technica ATH-M50x",
|
||||
None,
|
||||
);
|
||||
// Spaces inside the name aren't URL-encoded — reqwest does that on send.
|
||||
assert!(urls[0].contains("Audio-Technica ATH-M50x"));
|
||||
assert!(urls[0].ends_with("Audio-Technica ATH-M50x FixedBandEQ.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_candidates_uses_real_autoeq_base_in_production() {
|
||||
// The const is the production raw-content URL — guard against typos.
|
||||
assert!(AUTOEQ_RAW_BASE.starts_with("https://raw.githubusercontent.com/"));
|
||||
assert!(AUTOEQ_RAW_BASE.contains("/jaakkopasanen/AutoEq"));
|
||||
assert!(AUTOEQ_RAW_BASE.ends_with("/results"));
|
||||
}
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
//! Tauri commands: audio_play / chain_preload / preload + the shared
|
||||
//! spawn_progress_task helper. Transport (pause/resume/stop/seek), device,
|
||||
//! radio, mix-mode and AutoEQ commands live in sibling modules.
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use rodio::Player;
|
||||
use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::build_source;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{
|
||||
build_playback_source_with_probe_fallback, select_play_input,
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
|
||||
PlayInputContext, SinkSwapInputs,
|
||||
};
|
||||
use super::playback_rate::preserve_pitch_will_run;
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
||||
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
|
||||
/// for HTTP streams (`playback_identity` is used as fallback).
|
||||
///
|
||||
/// `server_id`: app id of the server that owns this track (`playbackServerId ??
|
||||
/// activeServerId` on the frontend). Scopes the analysis-cache write key so a
|
||||
/// later server switch can't surface another server's waveform for the same bare
|
||||
/// `track_id`. Empty/absent falls back to the legacy `''` scope.
|
||||
///
|
||||
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
|
||||
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn audio_play(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// ── Ghost-command guard ───────────────────────────────────────────────────
|
||||
// After a gapless auto-advance, the frontend may fire a stale playTrack()
|
||||
// call via IPC. If we're within 500 ms of the last gapless switch AND the
|
||||
// requested URL matches the already-playing chained track, reject it.
|
||||
{
|
||||
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
|
||||
if switch_ms > 0 {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
if now_ms.saturating_sub(switch_ms) < 500 {
|
||||
// Within the guard window — suppress this ghost command.
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel any active preview before starting fresh main playback so the
|
||||
// two sinks don't end up mixed.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
|
||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
||||
// advance. The source is live in the queue — just return and let the
|
||||
// progress task handle the state transition when the previous source ends.
|
||||
//
|
||||
// Never for manual skips: the UI already jumped to this track in JS, but
|
||||
// the current source is still playing until the chain drains. User-initiated
|
||||
// play must clear the chain and start this URL immediately (standard path).
|
||||
if gapless && !manual {
|
||||
let already_chained = state.chained_info.lock().unwrap()
|
||||
.as_ref()
|
||||
.map(|c| same_playback_target(&c.url, &url))
|
||||
.unwrap_or(false);
|
||||
if already_chained {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standard (new-sink) path ─────────────────────────────────────────────
|
||||
// Used for: manual skip, gapless OFF, first play, or gapless when the
|
||||
// proactive chain was not set up in time.
|
||||
|
||||
// Bump generation first so the old progress task stops before we peel
|
||||
// chained_info (avoids a race where it sees current_done + empty chain).
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
// Ranged/legacy HTTP paths reset this to false in `select_play_input`.
|
||||
state.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
|
||||
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
|
||||
// preload cache was already consumed when the chain was built). Otherwise
|
||||
// clear any stale chain metadata.
|
||||
let reuse_chained_bytes: Option<Vec<u8>> = if gapless && manual {
|
||||
let mut ci = state.chained_info.lock().unwrap();
|
||||
if ci.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||
ci.take().map(|info| {
|
||||
Arc::try_unwrap(info.raw_bytes).unwrap_or_else(|a| (*a).clone())
|
||||
})
|
||||
} else {
|
||||
*ci = None;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
None
|
||||
};
|
||||
|
||||
// Stop fading-out sink from previous crossfade.
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
|
||||
old.stop();
|
||||
}
|
||||
|
||||
// Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from
|
||||
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
|
||||
// the previous URL still stored until the sink swap completes.
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
*state.current_analysis_track_id.lock().unwrap() = logical_trim.clone();
|
||||
let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url);
|
||||
// Playback server scope for the analysis-cache write key (empty → legacy '').
|
||||
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
// Pin it so the gain-resolution + replay-gain-update + device-resume reads
|
||||
// scope to this server too (mirrors `current_analysis_track_id`).
|
||||
*state.current_playback_server_id.lock().unwrap() = analysis_server_id.map(str::to_string);
|
||||
|
||||
let format_hint = url_format_hint(&url);
|
||||
|
||||
let play_input = match select_play_input(
|
||||
PlayInputContext {
|
||||
url: &url,
|
||||
gen,
|
||||
duration_hint,
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
format_hint: format_hint.as_deref(),
|
||||
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
|
||||
server_id: analysis_server_id,
|
||||
reuse_chained_bytes,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
).await? {
|
||||
Some(input) => input,
|
||||
None => {
|
||||
crate::app_deprintln!(
|
||||
"[audio] audio_play superseded inside select_play_input: gen={} cur={} track_id={:?}",
|
||||
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[audio] audio_play superseded after select_play_input: gen={} cur={} track_id={:?}",
|
||||
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
|
||||
let (gain_linear, effective_volume) = compute_gain(
|
||||
gain_inputs.norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
|
||||
playback_identity(&url),
|
||||
normalization_engine_name(gain_inputs.norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.cache_loudness_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
gain_inputs.target_lufs,
|
||||
volume,
|
||||
effective_volume
|
||||
);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(gain_inputs.norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs: gain_inputs.target_lufs,
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
// have consumed ~500–800ms from Track A's tail — so its true remaining time
|
||||
// is always less than crossfade_secs_val. Using the measured remaining time
|
||||
// for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and
|
||||
// guarantees Track A reaches 0 exactly when its source exhausts.
|
||||
let actual_fade_secs: f32 = if crossfade_enabled {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let remaining = (cur.duration_secs - cur.position()) as f32;
|
||||
remaining.clamp(0.1, crossfade_secs_val)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Fade-in duration for Track B:
|
||||
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
|
||||
// hard cut → 5 ms micro-fade to suppress DC-offset click
|
||||
let fade_in_dur = if crossfade_enabled {
|
||||
Duration::from_secs_f32(actual_fade_secs)
|
||||
} else {
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
let playback_source = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &url,
|
||||
gen,
|
||||
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
|
||||
server_id: analysis_server_id,
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Suppress the audio:error toast when this play was already superseded
|
||||
// by a newer audio_play (rapid skip): the failure is the inevitable
|
||||
// Ok(0)/EOF from RangedHttpSource after gen-bump, not a real codec
|
||||
// problem. The frontend would otherwise show "Couldn't play track" for
|
||||
// the abandoned URL while a new track is already loading.
|
||||
if state.generation.load(Ordering::SeqCst) == gen {
|
||||
app.emit("audio:error", &e).ok();
|
||||
} else {
|
||||
crate::app_deprintln!(
|
||||
"[audio] suppressed audio:error for superseded play (gen={} cur={}): {}",
|
||||
gen, state.generation.load(Ordering::SeqCst), e
|
||||
);
|
||||
}
|
||||
e
|
||||
})?;
|
||||
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
|
||||
let built = playback_source.built;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
let output_rate = built.output_rate;
|
||||
let output_channels = built.output_channels;
|
||||
|
||||
// Store the actual output rate/channels for position calculation.
|
||||
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
|
||||
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
// toggle was just turned off mid-session), restore the device
|
||||
// default rate so playback is no longer at 88.2/96 kHz etc.
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
|
||||
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(new_handle) => {
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
|
||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||
// At sample rates > 48 kHz the hardware quantum is larger and the first
|
||||
// period demands more decoded frames than at 44.1/48 kHz.
|
||||
// Strategy: pause the sink before appending so rodio's internal mixer
|
||||
// decodes into its ring buffer ahead of the hardware. After a short delay
|
||||
// we resume — the buffer is already full and the hardware gets its frames
|
||||
// without an underrun on the very first period.
|
||||
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
|
||||
// Preserve-pitch phase vocoder runs on a worker thread; pre-fill gives the
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
}
|
||||
|
||||
// Gapless OFF: prepend a short silence so tracks are clearly separated.
|
||||
// Only when this is an auto-advance (near end), not on manual skip.
|
||||
//
|
||||
// Use a frame-aligned `SamplesBuffer` rather than `Zero + take_duration` —
|
||||
// the latter computes its sample count via integer-nanosecond division
|
||||
// (1_000_000_000 / (sr * ch)), which at common rates leaks an odd number
|
||||
// of samples (e.g. 44103 at 44.1 kHz / 2 ch / 500 ms = 22051.5 frames).
|
||||
// The half-frame leak shifts the next source's L/R parity in the device
|
||||
// stream and can manifest as a dead channel for the rest of the track
|
||||
// (reported by users for natural-end-without-gapless transitions only).
|
||||
if !gapless {
|
||||
let cur_pos = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.position()
|
||||
};
|
||||
let cur_dur = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
cur.duration_secs
|
||||
};
|
||||
let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
|
||||
if is_auto_advance {
|
||||
let ch = source.channels();
|
||||
let sr = source.sample_rate();
|
||||
// 500 ms in whole frames, then expand to interleaved samples.
|
||||
let frames = (sr.get() / 2) as usize;
|
||||
let total_samples = frames.saturating_mul(ch.get() as usize);
|
||||
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
|
||||
sink.append(silence);
|
||||
}
|
||||
}
|
||||
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
let prefill_ms = if needs_preserve_prefill {
|
||||
800
|
||||
} else {
|
||||
500
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(prefill_ms)).await;
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // skipped during pre-fill — abort silently
|
||||
}
|
||||
if !defer_playback_start {
|
||||
sink.play();
|
||||
}
|
||||
}
|
||||
|
||||
swap_in_new_sink(&state, SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: built.fadeout_trigger,
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
});
|
||||
|
||||
if defer_playback_start {
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
spawn_legacy_stream_start_when_armed(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
state.samples_played.clone(),
|
||||
state.current.clone(),
|
||||
app.clone(),
|
||||
duration_secs,
|
||||
);
|
||||
} else {
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
}
|
||||
|
||||
// ── Progress + ended detection ────────────────────────────────────────────
|
||||
let analysis_app = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.current.clone(),
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
Some(analysis_app),
|
||||
state.samples_played.clone(),
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
state.playback_rate.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Proactively appends the next track to the current Sink ~30 s before the
|
||||
/// current track ends. Called from JS at the same trigger point as preload.
|
||||
///
|
||||
/// Because this runs well before the track boundary, the IPC round-trip is
|
||||
/// irrelevant — by the time the current track actually ends, the next source
|
||||
/// is already live in the Sink queue and rodio transitions at sample accuracy.
|
||||
///
|
||||
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
|
||||
/// immediately without touching the Sink (pure no-op on the audio path).
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn audio_chain_preload(
|
||||
url: String,
|
||||
volume: f32,
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Idempotent: already chained this track → nothing to do.
|
||||
{
|
||||
let chained = state.chained_info.lock().unwrap();
|
||||
if chained.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Gapless must be enabled and a sink must exist.
|
||||
if !state.gapless_enabled.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let snapshot_gen = state.generation.load(Ordering::SeqCst);
|
||||
|
||||
// Fetch bytes — use preload cache if available, otherwise HTTP.
|
||||
let data: Vec<u8> = {
|
||||
let cached = {
|
||||
let mut preloaded = state.preloaded.lock().unwrap();
|
||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||
preloaded.take().map(|p| p.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(d) = cached {
|
||||
d
|
||||
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let resp = audio_http_client(&state).get(&url).send().await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(()); // silently fail — audio_play will retry
|
||||
}
|
||||
let hint = resp.content_length().unwrap_or(0) as usize;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buf = Vec::with_capacity(hint);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(()); // superseded by manual skip — abort download
|
||||
}
|
||||
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
|
||||
}
|
||||
buf
|
||||
}
|
||||
};
|
||||
|
||||
// Bail if the user skipped to a different track while we were downloading.
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let raw_bytes = Arc::new(data);
|
||||
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||
let (sid, priority) = crate::analysis_dispatch::prepare_playback_analysis(
|
||||
&app,
|
||||
&state,
|
||||
analysis_server_id,
|
||||
&track_id,
|
||||
Some(psysonic_analysis::analysis_runtime::AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
let bytes = (*raw_bytes).clone();
|
||||
crate::analysis_dispatch::spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
|
||||
sid,
|
||||
track_id,
|
||||
bytes,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
|
||||
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
|
||||
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
|
||||
// the still-playing current source). Volume for the chained track is
|
||||
// applied at the gapless transition in `spawn_progress_task`, not here.
|
||||
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
|
||||
let (gain_linear, _effective_volume) = compute_gain(
|
||||
gain_inputs.norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
gain_inputs.effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
|
||||
let done_next = Arc::new(AtomicBool::new(false));
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let built = build_source(
|
||||
(*raw_bytes).clone(),
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_next.clone(),
|
||||
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
|
||||
chain_counter.clone(),
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
).map_err(|e| e.to_string())?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
|
||||
// Final gen check — reject if a manual skip happened during decode.
|
||||
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
// Note: `set_volume` is deliberately NOT called here (see comment above).
|
||||
{
|
||||
let cur = state.current.lock().unwrap();
|
||||
match &cur.sink {
|
||||
Some(sink) => {
|
||||
sink.append(source);
|
||||
}
|
||||
None => return Ok(()), // playback stopped — bail
|
||||
}
|
||||
}
|
||||
|
||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||
url,
|
||||
analysis_track_id: logical_trim,
|
||||
server_id: analysis_server_id.map(str::to_string),
|
||||
raw_bytes,
|
||||
duration_secs,
|
||||
replay_gain_linear: gain_linear,
|
||||
base_volume: volume.clamp(0.0, 1.0),
|
||||
source_done: done_next,
|
||||
sample_counter: chain_counter,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,191 +0,0 @@
|
||||
//! Output device enumeration with suppressed ALSA stderr noise.
|
||||
// `rodio::cpal` is referenced from the included body.
|
||||
|
||||
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
|
||||
}
|
||||
}
|
||||
let _guard = unsafe {
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
f()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[inline]
|
||||
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
f()
|
||||
}
|
||||
|
||||
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
|
||||
/// busy devices are sometimes omitted from `output_devices()` while playback works.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
|
||||
const IFACES: &[&str] = &[
|
||||
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
|
||||
"surround51", "surround71",
|
||||
];
|
||||
let colon = name.find(':')?;
|
||||
let iface = name[..colon].to_ascii_lowercase();
|
||||
if !IFACES.contains(&iface.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
|
||||
let dev = name
|
||||
.split("DEV=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
Some((iface, card, dev))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[inline]
|
||||
pub(crate) fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (
|
||||
linux_alsa_sink_fingerprint(a),
|
||||
linux_alsa_sink_fingerprint(b),
|
||||
) {
|
||||
(Some(fa), Some(fb)) => fa == fb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
|
||||
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
|
||||
available
|
||||
.iter()
|
||||
.any(|d| output_devices_logically_same(d, pinned))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── output_devices_logically_same ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn logically_same_returns_true_for_identical_names() {
|
||||
assert!(output_devices_logically_same("Generic Audio", "Generic Audio"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logically_same_returns_false_for_different_non_alsa_names() {
|
||||
assert!(!output_devices_logically_same(
|
||||
"Built-in Speakers",
|
||||
"External DAC"
|
||||
));
|
||||
}
|
||||
|
||||
// ── output_enumeration_includes_pinned ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn includes_pinned_finds_exact_match() {
|
||||
let avail = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||
assert!(output_enumeration_includes_pinned(&avail, "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_pinned_returns_false_when_absent() {
|
||||
let avail = vec!["A".to_string(), "B".to_string()];
|
||||
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_pinned_returns_false_for_empty_list() {
|
||||
let avail: Vec<String> = vec![];
|
||||
assert!(!output_enumeration_includes_pinned(&avail, "anything"));
|
||||
}
|
||||
|
||||
// ── linux_alsa_sink_fingerprint (Linux-only path) ─────────────────────────
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_extracts_iface_card_dev() {
|
||||
let fp = linux_alsa_sink_fingerprint("hdmi:CARD=NVidia,DEV=3");
|
||||
assert_eq!(fp, Some(("hdmi".to_string(), "NVidia".to_string(), 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_defaults_dev_to_zero_when_missing() {
|
||||
let fp = linux_alsa_sink_fingerprint("plughw:CARD=PCH");
|
||||
assert_eq!(fp, Some(("plughw".to_string(), "PCH".to_string(), 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_returns_none_for_unknown_iface() {
|
||||
// "pulse" is not in the recognised ALSA-iface list — frontend-only sink.
|
||||
assert!(linux_alsa_sink_fingerprint("pulse:something").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_returns_none_when_no_colon() {
|
||||
assert!(linux_alsa_sink_fingerprint("Generic Audio").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn alsa_fingerprint_lowercases_iface_name() {
|
||||
let fp = linux_alsa_sink_fingerprint("HDMI:CARD=card,DEV=0");
|
||||
assert_eq!(fp.unwrap().0, "hdmi", "iface is normalised to lowercase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn logically_same_treats_same_card_dev_as_match_across_alsa_ifaces() {
|
||||
// Same physical sink can appear under "hw:CARD=X,DEV=0" and "plughw:CARD=X,DEV=0".
|
||||
// The fingerprint comparison includes the iface, so these are NOT
|
||||
// logically the same — clarifying the contract here.
|
||||
assert!(!output_devices_logically_same(
|
||||
"hw:CARD=X,DEV=0",
|
||||
"plughw:CARD=X,DEV=0"
|
||||
));
|
||||
// But the SAME iface with the same card/dev is the same sink:
|
||||
assert!(output_devices_logically_same(
|
||||
"hw:CARD=X,DEV=0",
|
||||
"hw:CARD=X,DEV=0"
|
||||
));
|
||||
}
|
||||
|
||||
// ── linux_alsa_sink_fingerprint stub on non-Linux ─────────────────────────
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn alsa_fingerprint_is_none_on_non_linux_for_any_input() {
|
||||
assert!(linux_alsa_sink_fingerprint("hdmi:CARD=X,DEV=0").is_none());
|
||||
assert!(linux_alsa_sink_fingerprint("anything").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Tauri commands for output-device listing + selection. Pulled out of
|
||||
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
|
||||
//! enumeration + reopen path.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// When the saved `selected_device` no longer literally matches any listed
|
||||
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
|
||||
#[tauri::command]
|
||||
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
|
||||
let pinned = state.selected_device.lock().unwrap().clone()?;
|
||||
if pinned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let list = enumerate_output_device_names();
|
||||
if list.iter().any(|d| d == &pinned) {
|
||||
return None;
|
||||
}
|
||||
let canon = list
|
||||
.iter()
|
||||
.find(|d| output_devices_logically_same(d, &pinned))?
|
||||
.clone();
|
||||
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Returns the names of all available audio output devices on the current host.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
///
|
||||
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
audio_list_devices_for_engine(&state)
|
||||
}
|
||||
|
||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||
#[tauri::command]
|
||||
pub fn audio_default_output_device_name() -> Option<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Switch the audio output device. `device_name = null` → follow system default.
|
||||
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
|
||||
#[tauri::command]
|
||||
pub async fn audio_set_device(
|
||||
device_name: Option<String>,
|
||||
state: State<'_, AudioEngine>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||
|
||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
state.stream_reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None).ok_or("device open timed out")?;
|
||||
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
|
||||
// Capture position and drop the active sink atomically so the position
|
||||
// reading is still valid (play_started / paused_at intact before take).
|
||||
let current_time = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let pos = cur.position();
|
||||
if let Some(s) = cur.sink.take() { s.stop(); }
|
||||
pos
|
||||
};
|
||||
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
|
||||
|
||||
// Emit the saved position so the frontend can use seekFallbackVisualTarget
|
||||
// and resume from where the track was, rather than restarting from the beginning.
|
||||
// null is reserved for "Rust already resumed internally" (see reopen_output_stream).
|
||||
app.emit("audio:device-changed", current_time).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
//! Rust-side seamless replay after an output-device switch.
|
||||
//!
|
||||
//! `try_resume_after_device_change` is called from `reopen_output_stream`
|
||||
//! (device_watcher.rs) after the new CPAL stream is ready and the old sink
|
||||
//! has been stopped. It attempts to restart the current track on the new
|
||||
//! device without any frontend round-trip.
|
||||
//!
|
||||
//! Supported source paths (in order of preference):
|
||||
//! - `psysonic-local://` — opened directly from disk via `LocalFileSource`.
|
||||
//! - HTTP, fully cached in RAM — replayed from `stream_completed_cache`.
|
||||
//! - HTTP, spilled to disk — bytes read from `stream_completed_spill`.
|
||||
//!
|
||||
//! Falls back to the frontend (returns `false`) for:
|
||||
//! - paused playback
|
||||
//! - radio / live stream
|
||||
//! - HTTP track whose download was only partial
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::play_input::{
|
||||
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint,
|
||||
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs,
|
||||
};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
/// Snapshot of playback state captured before the blocking stream reopen.
|
||||
pub(crate) struct ResumeSnapshot {
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) current_time_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
pub(crate) is_playing: bool,
|
||||
}
|
||||
|
||||
/// Try to replay the current track on the new device without involving the
|
||||
/// frontend. Returns `true` if playback was successfully restarted.
|
||||
///
|
||||
/// Conditions that cause an immediate `false` (frontend fallback):
|
||||
/// - Paused playback — user can press play on the new device via the cold path.
|
||||
/// - Radio stream — live, non-seekable; frontend handles reconnect.
|
||||
/// - No current URL — nothing was playing.
|
||||
/// - HTTP track whose download was only partial (cache/spill absent) — frontend
|
||||
/// re-fetches from the server via the seekFallbackVisualTarget path.
|
||||
pub(crate) async fn try_resume_after_device_change(
|
||||
app: &tauri::AppHandle,
|
||||
snap: &ResumeSnapshot,
|
||||
) -> bool {
|
||||
// Only resume actively-playing (not paused) tracks.
|
||||
if !snap.is_playing {
|
||||
return false;
|
||||
}
|
||||
let url = match snap.url.as_deref() {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Skip radio — live streams don't have a resume position.
|
||||
if engine.radio_state.lock().unwrap().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build a PlayInput without re-downloading:
|
||||
// - psysonic-local:// → seekable file
|
||||
// - HTTP, fully cached → in-memory bytes (stream_completed_cache)
|
||||
// - HTTP, spilled → bytes read from spill file
|
||||
// - HTTP, partial → return false (frontend will re-fetch)
|
||||
let play_input: PlayInput = if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] cannot open local file: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HTTP track — use completed in-memory cache or spill file.
|
||||
// If the download was only partial, fall back to the frontend path
|
||||
// which will re-fetch from the server.
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard.as_ref().filter(|t| t.url == url).map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard.as_ref().filter(|s| s.url == url).map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] spill read failed: {e}");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
None => return false, // not fully cached yet — frontend will re-fetch
|
||||
}
|
||||
};
|
||||
PlayInput::Bytes(bytes)
|
||||
};
|
||||
|
||||
// Bump generation so the old progress task exits cleanly.
|
||||
let gen = engine.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
engine.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
*engine.chained_info.lock().unwrap() = None;
|
||||
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
return false; // raced with another audio_play
|
||||
}
|
||||
|
||||
let format_hint = url_format_hint(url);
|
||||
let stream_format_suffix: Option<String> = url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
engine.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000;
|
||||
// Resume re-plays the current track → scope its analysis writes to the
|
||||
// pinned playback server (empty → legacy '').
|
||||
let resume_server = crate::helpers::current_playback_server_id_str(&engine);
|
||||
|
||||
let ps: PlaybackSource = match build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
app,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ps) => ps,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] source build failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
return false;
|
||||
}
|
||||
|
||||
engine
|
||||
.current_is_seekable
|
||||
.store(ps.is_seekable, Ordering::SeqCst);
|
||||
engine
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
engine
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let sink = Arc::new(Player::connect_new(
|
||||
engine.stream_handle.lock().unwrap().mixer(),
|
||||
));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
swap_in_new_sink(
|
||||
&engine,
|
||||
SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs: ps.built.duration_secs,
|
||||
volume: snap.base_volume,
|
||||
gain_linear: snap.gain_linear,
|
||||
fadeout_trigger: ps.built.fadeout_trigger,
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
},
|
||||
);
|
||||
|
||||
// Seek to the saved position for seekable sources (local files, ranged HTTP).
|
||||
if ps.is_seekable && snap.current_time_secs > 0.5 {
|
||||
let seek_sink = engine.current.lock().unwrap().sink.as_ref().map(Arc::clone);
|
||||
if let Some(sk) = seek_sink {
|
||||
let target = std::time::Duration::from_secs_f64(snap.current_time_secs.max(0.0));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(sk.try_seek(target).map_err(|e| e.to_string()));
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(700)) {
|
||||
Ok(Ok(())) => {
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.seek_offset = snap.current_time_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
engine.samples_played.store(
|
||||
crate::playback_rate::raw_counter_samples_for_content_position(
|
||||
snap.current_time_secs,
|
||||
engine.current_sample_rate.load(Ordering::Relaxed),
|
||||
engine.current_channels.load(Ordering::Relaxed),
|
||||
&engine.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::app_eprintln!("[device-resume] seek failed: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!("[device-resume] seek timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inform the frontend of the new duration (keeps seekbar range correct).
|
||||
app.emit("audio:playing", ps.built.duration_secs).ok();
|
||||
|
||||
let analysis_app = app.clone();
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
engine.generation.clone(),
|
||||
engine.current.clone(),
|
||||
engine.chained_info.clone(),
|
||||
engine.crossfade_enabled.clone(),
|
||||
engine.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
Some(analysis_app),
|
||||
engine.samples_played.clone(),
|
||||
engine.current_sample_rate.clone(),
|
||||
engine.current_channels.clone(),
|
||||
engine.gapless_switch_at.clone(),
|
||||
engine.current_playback_url.clone(),
|
||||
engine.stream_playback_armed.clone(),
|
||||
engine.playback_rate.clone(),
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[device-resume] internal replay ok — url={url:?} resume_at={:.2}s seekable={}",
|
||||
snap.current_time_secs,
|
||||
ps.is_seekable
|
||||
);
|
||||
true
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::device_resume::{try_resume_after_device_change, ResumeSnapshot};
|
||||
use super::engine::AudioEngine;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use super::dev_io::output_enumeration_includes_pinned;
|
||||
|
||||
/// What to tell the frontend after a successful stream reopen.
|
||||
pub(crate) enum ReopenNotify {
|
||||
/// Normal path — same as `audio_set_device`.
|
||||
DeviceChanged,
|
||||
/// Pinned device unplugged (Windows/macOS only); Rust cleared the pin — clear Settings + restart playback.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
DeviceReset,
|
||||
}
|
||||
|
||||
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
|
||||
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
|
||||
///
|
||||
/// If the interrupted track is a seekable local file or a fully-cached HTTP download
|
||||
/// (in-memory or spill file), the function replays it internally from the saved position —
|
||||
/// no frontend round-trip, no audible restart. On success it emits
|
||||
/// `audio:device-changed` / `audio:device-reset` with a `null` payload so the frontend
|
||||
/// knows Rust already handled playback.
|
||||
/// For radio, partially-buffered HTTP tracks, or paused playback, it falls back to the
|
||||
/// previous behaviour: emit with the captured `current_time_secs` so the frontend calls
|
||||
/// `playTrack`.
|
||||
pub(crate) async fn reopen_output_stream(
|
||||
app: &tauri::AppHandle,
|
||||
device_name: Option<String>,
|
||||
notify: ReopenNotify,
|
||||
) -> bool {
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||
let stream_handle = engine.stream_handle.clone();
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
// Snapshot state we need BEFORE the blocking stream reopen (while the old sink
|
||||
// is still live and position() is still valid).
|
||||
let snapshot = {
|
||||
let cur = current.lock().unwrap();
|
||||
let is_playing = cur.play_started.is_some() && cur.paused_at.is_none();
|
||||
ResumeSnapshot {
|
||||
url: engine.current_playback_url.lock().unwrap().clone(),
|
||||
current_time_secs: cur.position(),
|
||||
duration_secs: cur.duration_secs,
|
||||
base_volume: cur.base_volume,
|
||||
gain_linear: cur.replay_gain_linear,
|
||||
analysis_track_id: engine.current_analysis_track_id.lock().unwrap().clone(),
|
||||
is_playing,
|
||||
}
|
||||
};
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
if reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let Some(handle) = new_handle else {
|
||||
return false;
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
if let Some(s) = fading_out.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
|
||||
// Attempt a Rust-side internal replay (no frontend involvement).
|
||||
// Falls back gracefully to the frontend path if conditions aren't met.
|
||||
let resumed = try_resume_after_device_change(app, &snapshot).await;
|
||||
|
||||
match notify {
|
||||
ReopenNotify::DeviceChanged => {
|
||||
// null → Rust already resumed; frontend skips playTrack
|
||||
// f64 → fallback; frontend calls playTrack + seek
|
||||
if resumed {
|
||||
app.emit("audio:device-changed", Option::<f64>::None).ok();
|
||||
} else {
|
||||
app.emit("audio:device-changed", snapshot.current_time_secs).ok();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
ReopenNotify::DeviceReset => {
|
||||
if resumed {
|
||||
app.emit("audio:device-reset", Option::<f64>::None).ok();
|
||||
} else {
|
||||
app.emit("audio:device-reset", snapshot.current_time_secs).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let selected_device = engine.selected_device.clone();
|
||||
let samples_played = engine.samples_played.clone();
|
||||
let current = engine.current.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut pinned_miss_count: u32 = 0;
|
||||
// Fallback recovery when OS sleep/resume notifications are missed: if playback is
|
||||
// "running" but sample counter is flat for too long, reopen output stream.
|
||||
// To avoid false positives during normal playback, arm this watchdog only
|
||||
// after a suspiciously long poll gap (e.g. process resumed after sleep).
|
||||
let mut last_samples_seen: u64 = 0;
|
||||
let mut stalled_since: Option<Instant> = None;
|
||||
let mut last_stall_recover_at: Option<Instant> = None;
|
||||
let mut last_poll_at = Instant::now();
|
||||
let mut watchdog_armed_until: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let now = Instant::now();
|
||||
let poll_gap = now.saturating_duration_since(last_poll_at);
|
||||
last_poll_at = now;
|
||||
if poll_gap >= Duration::from_secs(15) {
|
||||
let armed_until = now + Duration::from_secs(120);
|
||||
watchdog_armed_until = Some(armed_until);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: watchdog armed for 120s (poll gap {:?}, likely sleep/resume)",
|
||||
poll_gap
|
||||
);
|
||||
}
|
||||
let watchdog_armed = watchdog_armed_until.is_some_and(|until| now < until);
|
||||
|
||||
// ── Fallback stall detector (works even if sleep/resume signal was missed) ──
|
||||
let mut should_recover_stall = false;
|
||||
let mut stall_for = Duration::ZERO;
|
||||
{
|
||||
let samples_now = samples_played.load(Ordering::Relaxed);
|
||||
let cur = current.lock().unwrap();
|
||||
let active = cur
|
||||
.sink
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_paused() && !s.empty());
|
||||
|
||||
if !watchdog_armed {
|
||||
if stalled_since.take().is_some() {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: watchdog disarmed, clearing stall candidate"
|
||||
);
|
||||
}
|
||||
last_samples_seen = samples_now;
|
||||
} else if !active || samples_now != last_samples_seen {
|
||||
if stalled_since.take().is_some() {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stall candidate cleared (active={active}, samples_delta={})",
|
||||
samples_now as i128 - last_samples_seen as i128
|
||||
);
|
||||
}
|
||||
stalled_since = None;
|
||||
last_samples_seen = samples_now;
|
||||
} else {
|
||||
let since = stalled_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() < Duration::from_millis(100) {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stall candidate started (samples={}, active={active})",
|
||||
samples_now
|
||||
);
|
||||
}
|
||||
stall_for = since.elapsed();
|
||||
let cooldown_ok = last_stall_recover_at
|
||||
.map(|t| t.elapsed() >= Duration::from_secs(20))
|
||||
.unwrap_or(true);
|
||||
if stall_for >= Duration::from_secs(8) && cooldown_ok {
|
||||
should_recover_stall = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_recover_stall {
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let samples_now = samples_played.load(Ordering::Relaxed);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: output stalled for {:?} (samples={}) — reopening stream, pinned={:?}",
|
||||
stall_for,
|
||||
samples_now,
|
||||
pinned
|
||||
);
|
||||
if reopen_output_stream(&app, pinned, ReopenNotify::DeviceChanged).await {
|
||||
last_stall_recover_at = Some(Instant::now());
|
||||
stalled_since = None;
|
||||
last_samples_seen = samples_played.load(Ordering::Relaxed);
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stalled-output recovery succeeded"
|
||||
);
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] device-watcher: stalled-output reopen timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
impl Drop for StderrGuard {
|
||||
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
|
||||
}
|
||||
let saved = libc::dup(2);
|
||||
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
|
||||
libc::dup2(devnull, 2);
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let host = rodio::cpal::default_host();
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
|
||||
// whole session — any miss counter eventually tripped audio:device-reset.
|
||||
// Clearing the pin is left to the user (Settings → System Default) or
|
||||
// to a future explicit error signal from the output stream.
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if let Some(ref dev_name) = pinned {
|
||||
if !output_enumeration_includes_pinned(&available, dev_name) {
|
||||
pinned_miss_count += 1;
|
||||
if pinned_miss_count < 3 {
|
||||
continue;
|
||||
}
|
||||
crate::app_eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
|
||||
pinned_miss_count = 0;
|
||||
*selected_device.lock().unwrap() = None;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let reopened = reopen_output_stream(&app, None, ReopenNotify::DeviceReset).await;
|
||||
if !reopened {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out (pinned disconnect)");
|
||||
}
|
||||
|
||||
last_default = current_default;
|
||||
} else {
|
||||
pinned_miss_count = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Case 1: no pinned device, system default changed ──────────────
|
||||
if current_default == last_default {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_default = current_default.clone();
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
|
||||
// Debounce: give the OS time to finish configuring the new device.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
||||
//! Deduped emits for normalization UI and partial loudness analysis.
|
||||
use serde::Serialize;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PartialLoudnessPayload {
|
||||
pub(crate) track_id: Option<String>,
|
||||
pub(crate) gain_db: f32,
|
||||
pub(crate) target_lufs: f32,
|
||||
pub(crate) is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NormalizationStatePayload {
|
||||
pub(crate) engine: String,
|
||||
pub(crate) current_gain_db: Option<f32>,
|
||||
pub(crate) target_lufs: f32,
|
||||
}
|
||||
|
||||
/// Last `audio:normalization-state` emit, kept so we can suppress duplicate
|
||||
/// payloads. The frontend already debounces this event, but on Windows
|
||||
/// (WebView2) the IPC pipe is the bottleneck — every echo we skip here is
|
||||
/// renderer-thread time we don't pay.
|
||||
pub(crate) static LAST_NORM_STATE_EMIT: OnceLock<Mutex<Option<NormalizationStatePayload>>> = OnceLock::new();
|
||||
|
||||
pub(crate) fn norm_state_lock() -> &'static Mutex<Option<NormalizationStatePayload>> {
|
||||
LAST_NORM_STATE_EMIT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub(crate) fn norm_state_changed(prev: &NormalizationStatePayload, next: &NormalizationStatePayload) -> bool {
|
||||
if prev.engine != next.engine { return true; }
|
||||
if (prev.target_lufs - next.target_lufs).abs() >= 0.02 { return true; }
|
||||
match (prev.current_gain_db, next.current_gain_db) {
|
||||
(None, None) => false,
|
||||
(Some(a), Some(b)) => (a - b).abs() >= 0.05,
|
||||
_ => true, // None ↔ Some transition is significant
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_emit_normalization_state(app: &AppHandle, payload: NormalizationStatePayload) {
|
||||
let mut guard = norm_state_lock().lock().unwrap();
|
||||
let should_emit = match guard.as_ref() {
|
||||
Some(prev) => norm_state_changed(prev, &payload),
|
||||
None => true,
|
||||
};
|
||||
if !should_emit { return; }
|
||||
*guard = Some(payload.clone());
|
||||
drop(guard);
|
||||
let _ = app.emit("audio:normalization-state", payload);
|
||||
}
|
||||
|
||||
/// Last `analysis:loudness-partial` gain emitted per track-identity, used to
|
||||
/// suppress emits whose gain hasn't moved meaningfully (≥ 0.1 dB). The partial
|
||||
/// heuristic in `emit_partial_loudness_from_bytes` and the ranged-progress curve
|
||||
/// both produce values that drift by hundredths of a dB even on identical input,
|
||||
/// so the time-based throttle alone is not enough to keep the loop quiet.
|
||||
pub(crate) static LAST_PARTIAL_LOUDNESS_EMIT: OnceLock<Mutex<std::collections::HashMap<String, f32>>> = OnceLock::new();
|
||||
pub(crate) const PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB: f32 = 0.1;
|
||||
|
||||
pub(crate) fn partial_loudness_should_emit(track_key: &str, gain_db: f32) -> bool {
|
||||
let mut guard = LAST_PARTIAL_LOUDNESS_EMIT
|
||||
.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
|
||||
.lock()
|
||||
.unwrap();
|
||||
let prev = guard.get(track_key).copied();
|
||||
if let Some(p) = prev {
|
||||
if (p - gain_db).abs() < PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
guard.insert(track_key.to_string(), gain_db);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn payload(engine: &str, gain: Option<f32>, target: f32) -> NormalizationStatePayload {
|
||||
NormalizationStatePayload {
|
||||
engine: engine.to_string(),
|
||||
current_gain_db: gain,
|
||||
target_lufs: target,
|
||||
}
|
||||
}
|
||||
|
||||
// ── norm_state_changed ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn norm_state_unchanged_for_identical_payloads() {
|
||||
let p = payload("loudness", Some(-3.0), -14.0);
|
||||
assert!(!norm_state_changed(&p, &p.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_engine_differs() {
|
||||
let a = payload("off", Some(0.0), -14.0);
|
||||
let b = payload("loudness", Some(0.0), -14.0);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_ignores_micro_target_lufs_drift_below_two_centibels() {
|
||||
let a = payload("loudness", Some(-3.0), -14.0);
|
||||
let b = payload("loudness", Some(-3.0), -14.01);
|
||||
assert!(!norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_target_lufs_moves_at_least_2_centibels() {
|
||||
let a = payload("loudness", Some(-3.0), -14.0);
|
||||
let b = payload("loudness", Some(-3.0), -13.97);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_ignores_micro_gain_drift_below_5_centibels() {
|
||||
let a = payload("loudness", Some(-3.00), -14.0);
|
||||
let b = payload("loudness", Some(-3.04), -14.0);
|
||||
assert!(!norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_gain_moves_at_least_5_centibels() {
|
||||
let a = payload("loudness", Some(-3.00), -14.0);
|
||||
let b = payload("loudness", Some(-3.06), -14.0);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_changes_when_gain_appears_or_disappears() {
|
||||
let a = payload("loudness", None, -14.0);
|
||||
let b = payload("loudness", Some(-3.0), -14.0);
|
||||
assert!(norm_state_changed(&a, &b));
|
||||
assert!(norm_state_changed(&b, &a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_state_unchanged_when_both_gains_none() {
|
||||
let a = payload("off", None, -14.0);
|
||||
let b = payload("off", None, -14.0);
|
||||
assert!(!norm_state_changed(&a, &b));
|
||||
}
|
||||
|
||||
// ── partial_loudness_should_emit ──────────────────────────────────────────
|
||||
//
|
||||
// Note: this function reads/writes a process-global static map. Tests share
|
||||
// that state, so each test uses a unique track-key to avoid cross-test
|
||||
// pollution. (Don't run tests in parallel that share keys.)
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_emits_on_first_call_for_a_track_key() {
|
||||
let key = "test-emits-first-call";
|
||||
assert!(partial_loudness_should_emit(key, -3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_suppresses_micro_drift_below_threshold() {
|
||||
let key = "test-emits-micro-drift";
|
||||
assert!(partial_loudness_should_emit(key, -3.0));
|
||||
assert!(
|
||||
!partial_loudness_should_emit(key, -3.05),
|
||||
"delta < 0.1 dB is suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_emits_again_when_threshold_is_crossed() {
|
||||
let key = "test-emits-after-threshold";
|
||||
assert!(partial_loudness_should_emit(key, -3.0));
|
||||
assert!(partial_loudness_should_emit(key, -3.5), "delta >= 0.1 dB re-emits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_loudness_treats_each_track_key_independently() {
|
||||
assert!(partial_loudness_should_emit("track-A-independent", -3.0));
|
||||
assert!(
|
||||
partial_loudness_should_emit("track-B-independent", -3.0),
|
||||
"different track keys do not share suppression state"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! `psysonic-audio` — Symphonia decode, rodio output, HTTP radio/streaming,
|
||||
//! gapless, previews. Submodules (`sources`, `decode`, `stream`, `commands`, …)
|
||||
//! preserve the historical single `audio.rs` partitioning.
|
||||
|
||||
// Re-export the logging facade so submodules can keep using
|
||||
// `crate::app_eprintln!()` / `crate::app_deprintln!()`.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
|
||||
pub mod autoeq_commands;
|
||||
mod analysis_dispatch;
|
||||
mod codec;
|
||||
pub mod commands;
|
||||
mod decode;
|
||||
mod dev_io;
|
||||
pub mod device_commands;
|
||||
pub mod mix_commands;
|
||||
mod play_input;
|
||||
pub mod playback_rate;
|
||||
mod preserve_worker;
|
||||
pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
pub mod radio_commands;
|
||||
pub mod transport_commands;
|
||||
mod device_resume;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod power_resume;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
mod state;
|
||||
mod stream;
|
||||
|
||||
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
|
||||
pub use device_watcher::start_device_watcher;
|
||||
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
|
||||
pub use helpers::{
|
||||
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
|
||||
take_stream_completed_spill_for_url,
|
||||
};
|
||||
|
||||
/// Register platform-specific listeners so the output stream is reopened after sleep/resume
|
||||
/// when the device name may be unchanged (Windows WASAPI, Linux PipeWire, …).
|
||||
pub fn register_post_sleep_audio_recovery(app: tauri::AppHandle) {
|
||||
#[cfg(target_os = "windows")]
|
||||
power_notify_win::register(app);
|
||||
#[cfg(target_os = "linux")]
|
||||
power_notify_linux::register(app);
|
||||
// macOS intentionally falls through for now: we only ship native resume hooks
|
||||
// where we have verified regressions (Windows WASAPI, Linux logind/PipeWire).
|
||||
// macOS currently relies on the generic device watcher path.
|
||||
#[cfg(all(
|
||||
not(target_os = "windows"),
|
||||
not(target_os = "linux")
|
||||
))]
|
||||
let _ = app;
|
||||
}
|
||||
|
||||
pub use engine::{analysis_track_id_is_current_playback, ranged_loudness_backfill_should_defer, stop_audio_engine};
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Audio-stage settings commands: volume, replay-gain / loudness normalization,
|
||||
//! 10-band EQ, crossfade, gapless.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
|
||||
let url_for_loudness = if norm_mode == 2 {
|
||||
state.current_playback_url.lock().unwrap().clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let logical_for_loudness = state
|
||||
.current_analysis_track_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
// If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db`
|
||||
// for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`).
|
||||
let server_for_loudness = crate::helpers::current_playback_server_id_str(&state);
|
||||
let cache_loudness = url_for_loudness.as_deref().and_then(|u| {
|
||||
resolve_loudness_gain_from_cache_impl(
|
||||
&app,
|
||||
u,
|
||||
target_lufs,
|
||||
logical_for_loudness.as_deref(),
|
||||
&server_for_loudness,
|
||||
ResolveLoudnessCacheOpts {
|
||||
touch_waveform: false,
|
||||
log_soft_misses: false,
|
||||
},
|
||||
)
|
||||
});
|
||||
let effective_loudness_db = if norm_mode == 2 {
|
||||
match url_for_loudness.as_deref() {
|
||||
Some(_u) => loudness_gain_db_after_resolve(
|
||||
cache_loudness,
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
true,
|
||||
loudness_gain_db,
|
||||
),
|
||||
None => {
|
||||
loudness_gain_db.or(Some(loudness_gain_placeholder_until_cache(
|
||||
target_lufs,
|
||||
pre_analysis_db,
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loudness_gain_db
|
||||
};
|
||||
let (gain_linear, effective) = compute_gain(
|
||||
norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}",
|
||||
normalization_engine_name(norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
loudness_gain_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
|
||||
for (i, &gain) in gains.iter().enumerate() {
|
||||
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
strategy: String,
|
||||
speed: f32,
|
||||
pitch_semitones: f32,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
use crate::playback_rate::{
|
||||
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
|
||||
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_VARISPEED,
|
||||
};
|
||||
|
||||
let clamped_speed = speed.clamp(0.5, 2.0);
|
||||
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
|
||||
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
|
||||
let old_strat = state.playback_rate.load_strategy();
|
||||
let old_speed = state.playback_rate.load_speed();
|
||||
let was_active = is_effect_active(&state.playback_rate);
|
||||
let new_strat = match strategy.as_str() {
|
||||
"preserve_pitch" => STRATEGY_PRESERVE_PITCH,
|
||||
"speed_corrected" => STRATEGY_SPEED_CORRECTED,
|
||||
_ => STRATEGY_VARISPEED,
|
||||
};
|
||||
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
|
||||
|
||||
let restamp_content = if was_active
|
||||
&& enabled == old_enabled
|
||||
&& uses_preserve_dsp(old_strat)
|
||||
&& new_strat == old_strat
|
||||
&& speed_changed
|
||||
{
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
if sample_rate > 0 && channels > 0 {
|
||||
Some(content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state
|
||||
.playback_rate
|
||||
.enabled
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.strategy
|
||||
.store(new_strat, Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.speed
|
||||
.store(clamped_speed.to_bits(), Ordering::Relaxed);
|
||||
state
|
||||
.playback_rate
|
||||
.pitch_semitones
|
||||
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
|
||||
|
||||
if let Some(content_secs) = restamp_content {
|
||||
if is_effect_active(&state.playback_rate) {
|
||||
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let channels = state.current_channels.load(Ordering::Relaxed);
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
content_secs,
|
||||
sample_rate,
|
||||
channels,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let mode = match engine.as_str() {
|
||||
"replaygain" => 1,
|
||||
"loudness" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
state.normalization_engine.store(mode, Ordering::Relaxed);
|
||||
let target = target_lufs.clamp(-30.0, -8.0);
|
||||
state
|
||||
.normalization_target_lufs
|
||||
.store(target.to_bits(), Ordering::Relaxed);
|
||||
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
|
||||
state
|
||||
.loudness_pre_analysis_attenuation_db
|
||||
.store(pre.to_bits(), Ordering::Relaxed);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}",
|
||||
engine,
|
||||
normalization_engine_name(mode),
|
||||
target,
|
||||
pre
|
||||
);
|
||||
maybe_emit_normalization_state(
|
||||
&app,
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(mode).to_string(),
|
||||
// At mode-switch time the effective track gain may not be recalculated yet.
|
||||
// Emit `None` and let audio_play/audio_update_replay_gain publish actual value.
|
||||
current_gain_db: None,
|
||||
target_lufs: target,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,918 +0,0 @@
|
||||
//! Source-selection logic for `audio_play`: given a URL + various caches +
|
||||
//! Subsonic hints, decide whether to play from in-memory bytes, a seekable
|
||||
//! local file, a seekable RangedHttpSource, or a non-seekable streaming reader.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
|
||||
same_playback_target,
|
||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
};
|
||||
use super::stream::{
|
||||
ranged_download_task, track_download_task, AudioStreamReader,
|
||||
LocalFileSource, RangedHttpSource,
|
||||
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
|
||||
};
|
||||
|
||||
/// What `audio_play` will hand to `build_source` / `build_streaming_source`.
|
||||
pub(crate) enum PlayInput {
|
||||
Bytes(Vec<u8>),
|
||||
/// Seekable on-demand source — `RangedHttpSource` for HTTP streams,
|
||||
/// `LocalFileSource` for `psysonic-local://` files. Goes through
|
||||
/// `build_streaming_source` (no iTunSMPB scan, since we don't have the
|
||||
/// bytes in memory; chained-track gapless trim still applies via the
|
||||
/// re-played `Bytes` path on the next start).
|
||||
SeekableMedia {
|
||||
reader: Box<dyn MediaSource>,
|
||||
format_hint: Option<String>,
|
||||
tag: &'static str,
|
||||
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
|
||||
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
|
||||
},
|
||||
Streaming {
|
||||
reader: AudioStreamReader,
|
||||
format_hint: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Inputs `audio_play` has already computed before source selection.
|
||||
pub(super) struct PlayInputContext<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub duration_hint: f64,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub format_hint: Option<&'a str>,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
/// Playback server scope for the analysis-cache write key (empty/`None` →
|
||||
/// legacy `''`). Rides alongside `cache_id_for_tasks` into every seed path.
|
||||
pub server_id: Option<&'a str>,
|
||||
/// `Some(bytes)` when manual-skip onto a pre-chained track reuses bytes
|
||||
/// from the chained-info block.
|
||||
pub reuse_chained_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn spawn_playback_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
ctx: &PlayInputContext<'_>,
|
||||
origin: TrackAnalysisOrigin,
|
||||
bytes: Vec<u8>,
|
||||
) {
|
||||
let Some(track_id) = ctx
|
||||
.cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
origin,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
bytes,
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the play input for `audio_play` honouring (in priority order):
|
||||
/// 1. Reused chained bytes — manual skip onto pre-chained track.
|
||||
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
|
||||
/// 3. Remote HTTP without preload/stream-cache hit — try ranged HTTP, fall
|
||||
/// back to non-seekable AudioStreamReader.
|
||||
/// 4. Preload/stream-cache hit — replay in-memory bytes via `fetch_data`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the operation was superseded by a later
|
||||
/// `audio_play` call (generation bump) — caller should bail out silently.
|
||||
pub(super) async fn select_play_input(
|
||||
ctx: PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
if let Some(d) = ctx.reuse_chained_bytes {
|
||||
if let Some(track_id) = ctx
|
||||
.cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::InMemoryReplay,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
d.clone(),
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
return Ok(Some(PlayInput::Bytes(d)));
|
||||
}
|
||||
|
||||
let stream_cache_hit = {
|
||||
let streamed = state.stream_completed_cache.lock().unwrap();
|
||||
streamed
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, ctx.url))
|
||||
};
|
||||
let preloaded_hit = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.is_some_and(|p| same_playback_target(&p.url, ctx.url))
|
||||
};
|
||||
let is_local = ctx.url.starts_with("psysonic-local://");
|
||||
|
||||
if is_local && !stream_cache_hit && !preloaded_hit {
|
||||
return Ok(Some(open_local_file_input(&ctx, state, app)?));
|
||||
}
|
||||
if !stream_cache_hit && !preloaded_hit && !is_local {
|
||||
return open_ranged_or_streaming_input(&ctx, state, app).await;
|
||||
}
|
||||
|
||||
// Preloaded or stream-cache hit → replay in-memory bytes.
|
||||
let data = match fetch_data(ctx.url, state, ctx.gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Ok(None), // superseded while downloading
|
||||
};
|
||||
spawn_playback_analysis_bytes(
|
||||
app,
|
||||
state,
|
||||
&ctx,
|
||||
TrackAnalysisOrigin::InMemoryReplay,
|
||||
data.clone(),
|
||||
);
|
||||
Ok(Some(PlayInput::Bytes(data)))
|
||||
}
|
||||
|
||||
/// `psysonic-local://<path>` → seekable `LocalFileSource`. Spawns a
|
||||
/// background CPU-seed for the analysis cache when the file is small
|
||||
/// enough (skipped if the cache already has a row for this track).
|
||||
fn open_local_file_input(
|
||||
ctx: &PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlayInput, String> {
|
||||
let path = ctx.url.strip_prefix("psysonic-local://").unwrap();
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let local_hint = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase());
|
||||
crate::app_deprintln!(
|
||||
"[stream] LocalFileSource selected — size={} KB, hint={:?}",
|
||||
len / 1024,
|
||||
local_hint
|
||||
);
|
||||
if let Some(seed_id) = ctx.cache_id_for_tasks {
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, ctx.server_id, seed_id, None);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::LocalFilePlayback,
|
||||
sid,
|
||||
seed_id.to_string(),
|
||||
std::path::PathBuf::from(path),
|
||||
high,
|
||||
Some((ctx.gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
let reader = LocalFileSource { file, len };
|
||||
Ok(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
mp4_probe_gate: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Manual or auto-advance starts that aren't already cached: try ranged HTTP
|
||||
/// (seekable) first, fall back to a non-seekable `AudioStreamReader` if the
|
||||
/// server doesn't advertise byte-range support or a length.
|
||||
async fn open_ranged_or_streaming_input(
|
||||
ctx: &PlayInputContext<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
||||
return Ok(None); // superseded
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let msg = format!("HTTP {status}");
|
||||
app.emit("audio:error", &msg).ok();
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let mut stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(ctx.stream_format_suffix))
|
||||
.or_else(|| ctx.format_hint.map(|s| s.to_string()));
|
||||
|
||||
let supports_range = response.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if stream_hint.is_none() && supports_range {
|
||||
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = audio_http_client(state)
|
||||
.get(ctx.url)
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let stat = pr.status();
|
||||
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| stat == reqwest::StatusCode::OK;
|
||||
if ok {
|
||||
match pr.bytes().await {
|
||||
Ok(bytes) if !bytes.is_empty() => {
|
||||
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||
if stream_hint.is_some() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: format sniff from {} B prefix → hint={:?}",
|
||||
bytes.len(),
|
||||
stream_hint
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
||||
total_usize / 1024,
|
||||
stream_hint
|
||||
);
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
state.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
let playback_armed = state.stream_playback_armed.clone();
|
||||
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||
let tail_prefetch =
|
||||
super::stream::mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||
let mp4_probe_gate = tail_prefetch.then(|| super::stream::RangedMp4ProbeGate {
|
||||
tail_ready: tail_ready.clone(),
|
||||
buf: buf.clone(),
|
||||
downloaded_to: downloaded_to.clone(),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
format_hint: stream_hint.clone(),
|
||||
});
|
||||
let loudness_hold_for_defer = (total_usize <= super::stream::TRACK_STREAM_PROMOTE_MAX_BYTES)
|
||||
.then_some(state.ranged_loudness_seed_hold.clone());
|
||||
tokio::spawn(ranged_download_task(
|
||||
ctx.gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(state),
|
||||
app.clone(),
|
||||
ctx.duration_hint,
|
||||
ctx.url.to_string(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.stream_completed_spill.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
loudness_hold_for_defer,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready,
|
||||
tail_filled_from,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
};
|
||||
return Ok(Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
mp4_probe_gate,
|
||||
}));
|
||||
}
|
||||
|
||||
// Legacy non-seekable streaming reader fallback.
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range, total_size, stream_hint
|
||||
);
|
||||
let buffer_cap = total_size
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
|
||||
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
|
||||
let rb = HeapRb::<u8>::new(buffer_cap);
|
||||
let (prod, cons) = rb.split();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
state.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
let playback_armed = state.stream_playback_armed.clone();
|
||||
tokio::spawn(track_download_task(
|
||||
ctx.gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(state),
|
||||
app.clone(),
|
||||
ctx.url.to_string(),
|
||||
response,
|
||||
prod,
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
playback_armed,
|
||||
));
|
||||
|
||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
let reader = AudioStreamReader {
|
||||
read_timeout_secs: TRACK_READ_TIMEOUT_SECS,
|
||||
cons: Mutex::new(cons),
|
||||
new_cons_rx: Mutex::new(new_cons_rx),
|
||||
deadline: std::time::Instant::now()
|
||||
+ Duration::from_secs(TRACK_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
source_tag: "track-stream",
|
||||
eof_when_empty: Some(done),
|
||||
pos: 0,
|
||||
};
|
||||
Ok(Some(PlayInput::Streaming {
|
||||
reader,
|
||||
format_hint: stream_hint,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
|
||||
/// playback, then reset counters and emit `audio:playing` so the UI does not
|
||||
/// extrapolate ahead of audible output.
|
||||
pub(super) fn spawn_legacy_stream_start_when_armed(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
current: Arc<Mutex<super::engine::AudioCurrent>>,
|
||||
app: AppHandle,
|
||||
duration_secs: f64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if playback_armed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
samples_played.store(0, Ordering::Relaxed);
|
||||
let sink = current.lock().unwrap().sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
{
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = Some(std::time::Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
}
|
||||
sink.play();
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pulled out of the format_hint extraction block in `audio_play` — strip the
|
||||
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
/// don't latch onto random query-param substrings; only accept short
|
||||
/// alphanumeric tails that look like an actual audio extension.
|
||||
pub(crate) fn url_format_hint(url: &str) -> Option<String> {
|
||||
url.split('?').next()
|
||||
.and_then(|path| path.rsplit('.').next())
|
||||
.filter(|ext| {
|
||||
(1..=5).contains(&ext.len())
|
||||
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
&& matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"mp3" | "flac" | "ogg" | "oga" | "opus" | "m4a" | "mp4"
|
||||
| "aac" | "wav" | "wave" | "ape" | "wv" | "webm" | "mka"
|
||||
)
|
||||
})
|
||||
.map(|s| s.to_lowercase())
|
||||
}
|
||||
|
||||
/// Arguments forwarded from `audio_play` into the source-build pipeline.
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
pub server_id: Option<&'a str>,
|
||||
pub url_format_hint: Option<&'a str>,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
use std::time::Instant;
|
||||
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
|
||||
// Calculate total fade samples from the measured actual_fade_secs.
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// Keep old sink alive until the fade completes + small margin,
|
||||
// then drop it. No volume stepping needed — the fade-out runs
|
||||
// at sample level inside the audio thread.
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
|
||||
match input {
|
||||
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
|
||||
format_hint.clone()
|
||||
}
|
||||
PlayInput::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged HTTP probe/decode failed in a way that may succeed after the
|
||||
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
|
||||
fn is_ranged_stream_probe_failure(err: &str) -> bool {
|
||||
err.contains("ranged-stream")
|
||||
&& (err.contains("format probe failed")
|
||||
|| err.contains("moov metadata")
|
||||
|| err.contains("end of stream"))
|
||||
}
|
||||
|
||||
/// Completed ranged download or spill file for `url`, if ready.
|
||||
async fn try_take_completed_stream_bytes(
|
||||
url: &str,
|
||||
state: &State<'_, AudioEngine>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
|
||||
return Some(data);
|
||||
}
|
||||
let spill_path = {
|
||||
let guard = state.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, url))
|
||||
.map(|p| p.path.clone())
|
||||
};
|
||||
if let Some(path) = spill_path {
|
||||
let data = tokio::fs::read(&path).await.ok()?;
|
||||
if !data.is_empty() {
|
||||
return Some(data);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
|
||||
async fn prefer_clean_http_bytes_for_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
ranged_data: Vec<u8>,
|
||||
format_hint: Option<&str>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
|
||||
if is_mp4 {
|
||||
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
|
||||
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|
||||
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
|
||||
);
|
||||
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
|
||||
if super::stream::isobmff_buffer_looks_complete(&fresh) {
|
||||
return Ok(Some(fresh));
|
||||
}
|
||||
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ranged_data))
|
||||
}
|
||||
|
||||
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
|
||||
pub(super) async fn wait_or_fetch_bytes_for_stream_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: using completed download ({} KiB)",
|
||||
data.len() / 1024
|
||||
);
|
||||
return prefer_clean_http_bytes_for_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
data,
|
||||
format_hint,
|
||||
"ranged-cache",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
|
||||
TRACK_READ_TIMEOUT_SECS
|
||||
);
|
||||
fetch_data(url, state, gen, app).await
|
||||
}
|
||||
|
||||
fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
err.contains("format probe failed")
|
||||
|| err.contains("could not open audio stream")
|
||||
|| err.contains("no playable audio track")
|
||||
}
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks,
|
||||
server_id,
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Some(ref h) = effective_hint {
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) if is_ranged_stream_probe_failure(&e) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
|
||||
e
|
||||
);
|
||||
let data = match wait_or_fetch_bytes_for_stream_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
effective_hint.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Err(e),
|
||||
};
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Err("ranged-stream: superseded during full-buffer fallback".into());
|
||||
}
|
||||
let bytes_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(&data),
|
||||
);
|
||||
if bytes_hint.as_ref() != effective_hint.as_ref() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
|
||||
bytes_hint,
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(pe) if is_in_memory_probe_failure(&pe) => {
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&data,
|
||||
bytes_hint.as_deref(),
|
||||
"ranged-cache-probe-fail",
|
||||
);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
|
||||
pe
|
||||
);
|
||||
let fresh = match fetch_data(url, state, gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Err(pe),
|
||||
};
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&fresh,
|
||||
bytes_hint.as_deref(),
|
||||
"http-refetch-after-probe-fail",
|
||||
);
|
||||
}
|
||||
build_source_from_play_input(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(pe) => Err(pe),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
pub(super) async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
//! Global playback speed / pitch strategies (varispeed, speed-corrected, preserve pitch).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::SeekError;
|
||||
use rodio::{ChannelCount, SampleRate, Source};
|
||||
|
||||
use crate::preserve_worker::PreserveOffload;
|
||||
|
||||
pub const STRATEGY_VARISPEED: u32 = 0;
|
||||
pub const STRATEGY_PRESERVE_PITCH: u32 = 1;
|
||||
pub const STRATEGY_SPEED_CORRECTED: u32 = 2;
|
||||
|
||||
pub(crate) const PRESERVE_MAKEUP_GAIN: f32 = 1.35;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlaybackRateAtomics {
|
||||
pub enabled: Arc<AtomicBool>,
|
||||
pub strategy: Arc<AtomicU32>,
|
||||
pub speed: Arc<AtomicU32>,
|
||||
pub pitch_semitones: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl Default for PlaybackRateAtomics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: Arc::new(AtomicBool::new(false)),
|
||||
strategy: Arc::new(AtomicU32::new(STRATEGY_SPEED_CORRECTED)),
|
||||
speed: Arc::new(AtomicU32::new(1.0f32.to_bits())),
|
||||
pitch_semitones: Arc::new(AtomicU32::new(0.0f32.to_bits())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackRateAtomics {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn load_speed(&self) -> f32 {
|
||||
f32::from_bits(self.speed.load(Ordering::Relaxed)).clamp(0.5, 2.0)
|
||||
}
|
||||
|
||||
pub fn load_pitch(&self) -> f32 {
|
||||
f32::from_bits(self.pitch_semitones.load(Ordering::Relaxed)).clamp(-12.0, 12.0)
|
||||
}
|
||||
|
||||
pub fn load_strategy(&self) -> u32 {
|
||||
match self.strategy.load(Ordering::Relaxed) {
|
||||
STRATEGY_PRESERVE_PITCH => STRATEGY_PRESERVE_PITCH,
|
||||
STRATEGY_SPEED_CORRECTED => STRATEGY_SPEED_CORRECTED,
|
||||
_ => STRATEGY_VARISPEED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_preserve_dsp(strategy: u32) -> bool {
|
||||
strategy == STRATEGY_PRESERVE_PITCH || strategy == STRATEGY_SPEED_CORRECTED
|
||||
}
|
||||
|
||||
pub fn effective_pitch(atomics: &PlaybackRateAtomics) -> f32 {
|
||||
if atomics.load_strategy() == STRATEGY_PRESERVE_PITCH {
|
||||
atomics.load_pitch()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
|
||||
if !atomics.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
let speed = atomics.load_speed();
|
||||
match atomics.load_strategy() {
|
||||
STRATEGY_PRESERVE_PITCH => {
|
||||
(speed - 1.0).abs() > 0.001 || atomics.load_pitch().abs() > 0.001
|
||||
}
|
||||
_ => (speed - 1.0).abs() > 0.001,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when preserve-pitch DSP (background worker) should run for this track.
|
||||
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
|
||||
atomics.enabled.load(Ordering::Relaxed)
|
||||
&& uses_preserve_dsp(atomics.load_strategy())
|
||||
&& is_effect_active(atomics)
|
||||
}
|
||||
|
||||
/// Content timeline length for seek bar / duration labels (always the full track).
|
||||
pub fn effective_duration_secs(base_secs: f64, _atomics: &PlaybackRateAtomics) -> f64 {
|
||||
base_secs
|
||||
}
|
||||
|
||||
/// Map counter-derived seconds to timeline position for UI / near-end checks.
|
||||
pub fn effective_position_secs(raw_secs: f64, atomics: &PlaybackRateAtomics) -> f64 {
|
||||
if !is_effect_active(atomics) {
|
||||
return raw_secs;
|
||||
}
|
||||
if atomics.load_strategy() == STRATEGY_VARISPEED {
|
||||
return raw_secs;
|
||||
}
|
||||
// Preserve DSP outputs at the base sample rate; scale to content timeline.
|
||||
raw_secs * atomics.load_speed() as f64
|
||||
}
|
||||
|
||||
/// Sample-counter position mapped to the content timeline (seek bar / labels).
|
||||
pub(crate) fn content_position_from_samples(
|
||||
samples: u64,
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
atomics: &PlaybackRateAtomics,
|
||||
) -> f64 {
|
||||
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
|
||||
effective_position_secs(samples as f64 / divisor, atomics)
|
||||
}
|
||||
|
||||
/// Counter value that matches `content_position_from_samples` after a content-timeline seek.
|
||||
pub(crate) fn raw_counter_samples_for_content_position(
|
||||
content_secs: f64,
|
||||
sample_rate_hz: u32,
|
||||
channels: u32,
|
||||
atomics: &PlaybackRateAtomics,
|
||||
) -> u64 {
|
||||
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
|
||||
let raw_secs = if is_effect_active(atomics)
|
||||
&& atomics.load_strategy() != STRATEGY_VARISPEED
|
||||
{
|
||||
content_secs / atomics.load_speed().max(0.001) as f64
|
||||
} else {
|
||||
content_secs
|
||||
};
|
||||
(raw_secs * divisor).round() as u64
|
||||
}
|
||||
|
||||
pub(crate) fn preserve_out_samples(speed: f32) -> usize {
|
||||
(128.0f32 / speed.clamp(0.5, 2.0)).round() as usize
|
||||
}
|
||||
|
||||
pub struct PlaybackRateSource<S: Source<Item = f32> + Send + 'static> {
|
||||
inner: Option<S>,
|
||||
base_sample_rate: SampleRate,
|
||||
base_channels: ChannelCount,
|
||||
atomics: PlaybackRateAtomics,
|
||||
offload: Option<PreserveOffload>,
|
||||
handback_rx: Option<mpsc::Receiver<S>>,
|
||||
handback_requested: bool,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> PlaybackRateSource<S> {
|
||||
pub fn new(inner: S, atomics: PlaybackRateAtomics) -> Self {
|
||||
let base_sample_rate = inner.sample_rate();
|
||||
let base_channels = inner.channels();
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
base_sample_rate,
|
||||
base_channels,
|
||||
atomics,
|
||||
offload: None,
|
||||
handback_rx: None,
|
||||
handback_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_handback(&mut self) {
|
||||
let Some(rx) = &self.handback_rx else {
|
||||
return;
|
||||
};
|
||||
if let Ok(inner) = rx.try_recv() {
|
||||
self.inner = Some(inner);
|
||||
self.handback_rx = None;
|
||||
self.handback_requested = false;
|
||||
if let Some(offload) = self.offload.take() {
|
||||
offload.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_handback_if_needed(&mut self) {
|
||||
if self.inner.is_some() || self.handback_requested {
|
||||
return;
|
||||
}
|
||||
if let Some(offload) = &self.offload {
|
||||
offload.request_handback();
|
||||
self.handback_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_offload(&mut self) {
|
||||
if self.offload.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(inner) = self.inner.take() {
|
||||
let (handback_tx, handback_rx) = mpsc::sync_channel(1);
|
||||
self.handback_rx = Some(handback_rx);
|
||||
self.offload = Some(PreserveOffload::spawn(
|
||||
inner,
|
||||
self.atomics.clone(),
|
||||
self.base_sample_rate.get(),
|
||||
self.base_channels.get(),
|
||||
handback_tx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn base_sample_rate(&self) -> SampleRate {
|
||||
self.inner
|
||||
.as_ref()
|
||||
.map(Source::sample_rate)
|
||||
.unwrap_or(self.base_sample_rate)
|
||||
}
|
||||
|
||||
fn try_recover_inner_from_offload(&mut self) {
|
||||
if self.inner.is_some() || self.offload.is_none() {
|
||||
return;
|
||||
}
|
||||
self.request_handback_if_needed();
|
||||
self.poll_handback();
|
||||
}
|
||||
|
||||
fn next_from_inner_or_pad(&mut self) -> Option<f32> {
|
||||
self.try_recover_inner_from_offload();
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
return inner.next();
|
||||
}
|
||||
if self
|
||||
.offload
|
||||
.as_ref()
|
||||
.is_some_and(|offload| !offload.is_done())
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> Iterator for PlaybackRateSource<S> {
|
||||
type Item = f32;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if !is_effect_active(&self.atomics) {
|
||||
if let Some(offload) = self.offload.as_mut() {
|
||||
if let Some(s) = offload.pop() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
return self.next_from_inner_or_pad();
|
||||
}
|
||||
|
||||
if uses_preserve_dsp(self.atomics.load_strategy()) {
|
||||
self.ensure_offload();
|
||||
if let Some(s) = self.offload.as_mut().and_then(|o| o.pop()) {
|
||||
return Some(s);
|
||||
}
|
||||
if self
|
||||
.offload
|
||||
.as_ref()
|
||||
.is_some_and(|offload| !offload.is_done())
|
||||
{
|
||||
return Some(0.0);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Varispeed: decoder must stay in `inner` (never in the preserve worker).
|
||||
if self.offload.is_some() {
|
||||
self.try_recover_inner_from_offload();
|
||||
}
|
||||
self.next_from_inner_or_pad()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32> + Send + 'static> Source for PlaybackRateSource<S> {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
self.inner.as_ref()?.current_span_len()
|
||||
}
|
||||
|
||||
fn channels(&self) -> ChannelCount {
|
||||
self.base_channels
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
if is_effect_active(&self.atomics) && self.atomics.load_strategy() == STRATEGY_VARISPEED {
|
||||
let factor = self.atomics.load_speed().max(0.001);
|
||||
SampleRate::new((self.base_sample_rate().get() as f32 * factor).max(1.0) as u32)
|
||||
.unwrap_or(self.base_sample_rate)
|
||||
} else {
|
||||
self.base_sample_rate()
|
||||
}
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.inner.as_ref()?.total_duration()
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
// UI / transport always pass content-timeline seconds (0..full track).
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
inner.try_seek(pos)?;
|
||||
}
|
||||
if let Some(offload) = self.offload.as_mut() {
|
||||
offload.request_seek(pos);
|
||||
offload.drain();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pitch_shift::{Shifter, TOTAL_F32};
|
||||
|
||||
#[test]
|
||||
fn passthrough_when_disabled() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
assert!(!is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_at_unity() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
assert!(!is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_when_speed_not_one() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(is_effect_active(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_duration_is_content_timeline() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
for strat in [
|
||||
STRATEGY_VARISPEED,
|
||||
STRATEGY_SPEED_CORRECTED,
|
||||
STRATEGY_PRESERVE_PITCH,
|
||||
] {
|
||||
a.strategy.store(strat, Ordering::Relaxed);
|
||||
assert!(
|
||||
(effective_duration_secs(200.0, &a) - 200.0).abs() < 0.001,
|
||||
"strategy {strat}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_varispeed_uses_counter() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!((effective_position_secs(20.0, &a) - 20.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_preserve_scales_with_speed() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!((effective_position_secs(10.0, &a) - 20.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_position_inactive_is_raw() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
assert!((effective_position_secs(15.0, &a) - 15.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_counter_samples_roundtrip_content_timeline() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(120.0, 44_100, 2, &a);
|
||||
let back = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
assert!((back - 120.0).abs() < 0.05, "roundtrip at 2x preserve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_counter_samples_roundtrip_varispeed() {
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let samples = raw_counter_samples_for_content_position(90.0, 44_100, 2, &a);
|
||||
let back = content_position_from_samples(samples, 44_100, 2, &a);
|
||||
assert!((back - 90.0).abs() < 0.05, "roundtrip at 2x varispeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_seek_uses_content_timeline() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct SeekSpy {
|
||||
rate: SampleRate,
|
||||
last_seek_secs: Arc<AtomicU64>,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Iterator for SeekSpy {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SeekSpy {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
ChannelCount::new(1).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
self.rate
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
Some(Duration::from_secs(200))
|
||||
}
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
|
||||
self.last_seek_secs
|
||||
.store(pos.as_secs_f64().to_bits(), AtomicOrdering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let last = Arc::new(AtomicU64::new(f64::NAN.to_bits()));
|
||||
let spy = SeekSpy {
|
||||
rate: SampleRate::new(44_100).unwrap(),
|
||||
last_seek_secs: last.clone(),
|
||||
remaining: 44_100,
|
||||
};
|
||||
|
||||
let a = PlaybackRateAtomics::new();
|
||||
a.enabled.store(true, Ordering::Relaxed);
|
||||
a
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let mut src = PlaybackRateSource::new(spy, a);
|
||||
src.try_seek(Duration::from_secs(120)).unwrap();
|
||||
let got = f64::from_bits(last.load(AtomicOrdering::Relaxed));
|
||||
assert!(
|
||||
(got - 120.0).abs() < 0.001,
|
||||
"varispeed seek must not scale content position, got {got}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_out_samples_clamped() {
|
||||
assert_eq!(preserve_out_samples(2.0), 64);
|
||||
assert_eq!(preserve_out_samples(0.5), 256);
|
||||
}
|
||||
|
||||
struct FixedRateSource {
|
||||
rate: u32,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl Iterator for FixedRateSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for FixedRateSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
std::num::NonZero::new(1).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
SampleRate::new(self.rate).unwrap()
|
||||
}
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
Some(Duration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_corrected_uses_preserve_dsp_path() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(uses_preserve_dsp(atomics.load_strategy()));
|
||||
assert!(is_effect_active(&atomics));
|
||||
assert_eq!(effective_pitch(&atomics), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_pitch_respects_manual_pitch() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
|
||||
atomics.pitch_semitones.store(3.0f32.to_bits(), Ordering::Relaxed);
|
||||
assert!(is_effect_active(&atomics));
|
||||
assert_eq!(effective_pitch(&atomics), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_switch_preserve_to_varispeed_does_not_end_early() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let mut src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 44_100,
|
||||
remaining: 50_000,
|
||||
},
|
||||
atomics.clone(),
|
||||
);
|
||||
for _ in 0..5_000 {
|
||||
assert!(src.next().is_some());
|
||||
}
|
||||
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
|
||||
let mut got = 0usize;
|
||||
for _ in 0..2_000 {
|
||||
if src.next().is_some() {
|
||||
got += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got > 100,
|
||||
"varispeed should continue after preserve strategy switch, got {got} samples"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_scales_reported_sample_rate() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
let src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 44_100,
|
||||
remaining: 1,
|
||||
},
|
||||
atomics,
|
||||
);
|
||||
assert_eq!(src.sample_rate().get(), 66_150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varispeed_propagates_through_dyn_source() {
|
||||
use crate::sources::DynSource;
|
||||
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
|
||||
atomics.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
|
||||
let rate_src = PlaybackRateSource::new(
|
||||
FixedRateSource {
|
||||
rate: 48_000,
|
||||
remaining: 1,
|
||||
},
|
||||
atomics,
|
||||
);
|
||||
let dyn_src = DynSource::new(rate_src);
|
||||
assert_eq!(dyn_src.sample_rate().get(), 96_000);
|
||||
}
|
||||
|
||||
fn rms_f32(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_pitch_makeup_keeps_level_reasonable() {
|
||||
let sr = 44_100f32;
|
||||
let mut input = [0.0f32; 128];
|
||||
for (i, s) in input.iter_mut().enumerate() {
|
||||
*s = (i as f32 * 0.12).sin() * 0.75;
|
||||
}
|
||||
let in_rms = rms_f32(&input);
|
||||
let mut shifter: Shifter<Box<[f32; TOTAL_F32]>> =
|
||||
Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
for _ in 0..24 {
|
||||
shifter.shift(&input, 4.0, 128, sr);
|
||||
}
|
||||
let dry = shifter.shift(&input, 4.0, 128, sr);
|
||||
let boosted: Vec<f32> = dry
|
||||
.iter()
|
||||
.map(|&s| (s * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0))
|
||||
.collect();
|
||||
let out_rms = rms_f32(&boosted);
|
||||
assert!(out_rms > in_rms * 0.8, "out_rms={out_rms} in_rms={in_rms}");
|
||||
assert!(out_rms < in_rms * 1.25, "out_rms={out_rms} in_rms={in_rms}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_speed_change_represerves_content_position() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
|
||||
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let samples = raw_counter_samples_for_content_position(30.0, 44_100, 2, &atomics);
|
||||
let content = content_position_from_samples(samples, 44_100, 2, &atomics);
|
||||
assert!((content - 30.0).abs() < 0.05);
|
||||
|
||||
atomics.speed.store(1.8f32.to_bits(), Ordering::Relaxed);
|
||||
let restamped =
|
||||
raw_counter_samples_for_content_position(content, 44_100, 2, &atomics);
|
||||
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
|
||||
assert!((after - 30.0).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Linux: subscribe to logind `PrepareForSleep` on the system bus — `start == false` means resume
|
||||
//! completed (systemd says the boolean is true when going to sleep, false when waking).
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
let res = std::thread::Builder::new()
|
||||
.name("psysonic-logind-sleep".into())
|
||||
.spawn(move || run_listener(app));
|
||||
|
||||
if let Err(e) = res {
|
||||
crate::app_eprintln!("[psysonic] could not spawn logind listener: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_listener(app: AppHandle) {
|
||||
use zbus::blocking::{Connection, MessageIterator};
|
||||
use zbus::message::Type;
|
||||
use zbus::MatchRule;
|
||||
|
||||
let conn = match Connection::system() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] D-Bus system bus unavailable — post-sleep audio recovery disabled: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let rule: zbus::MatchRule = match (|| -> zbus::Result<_> {
|
||||
Ok(MatchRule::builder()
|
||||
.msg_type(Type::Signal)
|
||||
.path("/org/freedesktop/login1")?
|
||||
.interface("org.freedesktop.login1.Manager")?
|
||||
.member("PrepareForSleep")?
|
||||
.build())
|
||||
})() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] MatchRule for logind PrepareForSleep failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut iter = match MessageIterator::for_match_rule(rule, &conn, Some(32)) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind signal subscription failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
crate::app_eprintln!("[psysonic] logind PrepareForSleep listener registered (post-sleep audio recovery)");
|
||||
|
||||
loop {
|
||||
let Some(result) = iter.next() else {
|
||||
break;
|
||||
};
|
||||
let msg = match result {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[psysonic] logind message stream error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let start: bool = match msg.body().deserialize() {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if start {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Windows: `PowerRegisterSuspendResumeNotification` — resume from sleep without a default-device rename.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use windows::Win32::{
|
||||
Foundation::{ERROR_SUCCESS, HANDLE},
|
||||
System::Power::{PowerRegisterSuspendResumeNotification, DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS},
|
||||
UI::WindowsAndMessaging::{
|
||||
DEVICE_NOTIFY_CALLBACK, PBT_APMRESUMEAUTOMATIC, PBT_APMRESUMESUSPEND, PBT_APMRESUMESTANDBY,
|
||||
},
|
||||
};
|
||||
|
||||
use super::power_resume::{debounce_allow_resume_reopen, reopen_audio_after_system_resume};
|
||||
|
||||
unsafe extern "system" fn power_suspend_resume_callback(
|
||||
context: *const c_void,
|
||||
event_type: u32,
|
||||
_setting: *const c_void,
|
||||
) -> u32 {
|
||||
if context.is_null() {
|
||||
return 0;
|
||||
}
|
||||
if !matches!(
|
||||
event_type,
|
||||
PBT_APMRESUMEAUTOMATIC | PBT_APMRESUMESUSPEND | PBT_APMRESUMESTANDBY
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if !debounce_allow_resume_reopen() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let app = unsafe { &*(context as *const AppHandle) }.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
reopen_audio_after_system_resume(&app).await;
|
||||
});
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
pub fn register(app: AppHandle) {
|
||||
// Intentionally leaked for process lifetime: Win32 callback receives this pointer
|
||||
// on each suspend/resume notification and may outlive this function scope.
|
||||
let app_leak = Box::into_raw(Box::new(app));
|
||||
|
||||
let params = Box::new(DEVICE_NOTIFY_SUBSCRIBE_PARAMETERS {
|
||||
Callback: Some(power_suspend_resume_callback),
|
||||
Context: app_leak as *mut c_void,
|
||||
});
|
||||
// Intentionally leaked for process lifetime: the power subsystem keeps the
|
||||
// subscribe-parameters pointer after successful registration.
|
||||
let params_ptr = Box::into_raw(params);
|
||||
|
||||
let mut registration: *mut c_void = std::ptr::null_mut();
|
||||
let err = unsafe {
|
||||
PowerRegisterSuspendResumeNotification(
|
||||
DEVICE_NOTIFY_CALLBACK,
|
||||
HANDLE(params_ptr as *mut _),
|
||||
&mut registration as *mut *mut c_void,
|
||||
)
|
||||
};
|
||||
|
||||
if err != ERROR_SUCCESS {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] PowerRegisterSuspendResumeNotification failed: {:?} — post-sleep audio recovery disabled",
|
||||
err
|
||||
);
|
||||
unsafe {
|
||||
drop(Box::from_raw(params_ptr));
|
||||
drop(Box::from_raw(app_leak));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
crate::app_eprintln!("[psysonic] Windows power suspend/resume notifications registered for audio");
|
||||
// `registration` is an opaque handle returned by Win32 API. It does not own
|
||||
// Rust resources, so dropping the local copy is fine; callback context is
|
||||
// intentionally leaked above for process-lifetime notifications.
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! Reopen CPAL/rodio output after system sleep/resume when the old stream can be silent
|
||||
//! while the reported default device name is unchanged (Windows WASAPI, Linux PipeWire/ALSA, etc.).
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::device_watcher::{reopen_output_stream, ReopenNotify};
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
const DEBOUNCE: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Returns false if this resume should be ignored (coalesce bursts from the OS).
|
||||
pub(crate) fn debounce_allow_resume_reopen() -> bool {
|
||||
let mut g = RESUME_REOPEN_DEBOUNCE.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if let Some(t) = *g {
|
||||
if now.duration_since(t) < DEBOUNCE {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*g = Some(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Delay so the audio stack re-enumerates before we open a new stream.
|
||||
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
let device_name = match app.try_state::<AudioEngine>() {
|
||||
Some(e) => e.selected_device.lock().unwrap().clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio: stream reopen failed or timed out after system resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! Background audio_preload: fetch the next track's bytes ahead of time
|
||||
//! and seed the analysis cache. Distinct from `audio_chain_preload`
|
||||
//! (which constructs the gapless source chain) and `audio_play` (which
|
||||
//! starts playback). All three live in this audio submodule.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||
use super::state::PreloadedTrack;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PreloadEventPayload {
|
||||
url: String,
|
||||
track_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn seed_preload_analysis_bytes(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
url: &str,
|
||||
data: &[u8],
|
||||
analysis_track_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) {
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
if let Err(e) = dispatch_track_analysis_bytes(
|
||||
app,
|
||||
TrackAnalysisOrigin::PrefetchOrCacheFile,
|
||||
&sid,
|
||||
&track_id,
|
||||
data.to_vec(),
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] preload seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_preload_analysis_file(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
url: &str,
|
||||
file_path: PathBuf,
|
||||
analysis_track_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) {
|
||||
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||
return;
|
||||
};
|
||||
let (sid, priority) = prepare_playback_analysis(
|
||||
app,
|
||||
state,
|
||||
server_id,
|
||||
&track_id,
|
||||
Some(AnalysisBackfillPriority::Middle),
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file analysis track_id={} path={}",
|
||||
track_id,
|
||||
file_path.display()
|
||||
);
|
||||
spawn_track_analysis_file(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::LocalFilePlayback,
|
||||
sid,
|
||||
track_id,
|
||||
file_path,
|
||||
priority,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_preload_ready(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||
let _ = app.emit(
|
||||
"audio:preload-ready",
|
||||
PreloadEventPayload {
|
||||
url,
|
||||
track_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||
let _ = app.emit(
|
||||
"audio:preload-cancelled",
|
||||
PreloadEventPayload {
|
||||
url,
|
||||
track_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let track_id_for_events = logical_trim.clone();
|
||||
|
||||
let is_local = url.starts_with("psysonic-local://");
|
||||
|
||||
// Hot/offline cache: playback reads from disk — seed analysis from the file
|
||||
// (512 MiB cap) without copying into the RAM preload slot.
|
||||
if is_local {
|
||||
let path = PathBuf::from(url.strip_prefix("psysonic-local://").unwrap());
|
||||
if !path.is_file() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] audio_preload: local file missing path={}",
|
||||
path.display()
|
||||
);
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
seed_preload_analysis_file(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
path,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
);
|
||||
emit_preload_ready(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remote URL — reuse in-memory bytes when a prior HTTP preload finished.
|
||||
{
|
||||
let cached = {
|
||||
let preloaded = state.preloaded.lock().unwrap();
|
||||
preloaded
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, &url))
|
||||
.map(|p| p.data.clone())
|
||||
};
|
||||
if let Some(data) = cached {
|
||||
if !data.is_empty() {
|
||||
seed_preload_analysis_bytes(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
&data,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// If the user skips during the wait the generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||
|
||||
if !data.is_empty() {
|
||||
seed_preload_analysis_bytes(
|
||||
&app,
|
||||
&state,
|
||||
&url,
|
||||
&data,
|
||||
logical_trim.as_deref(),
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let url_for_emit = url.clone();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
emit_preload_ready(&app, url_for_emit, track_id_for_events);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
//! Background worker for preserve-pitch DSP (phase vocoder is too heavy for cpal callback).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use pitch_shift::{Shifter, TOTAL_F32};
|
||||
use ringbuf::traits::{Consumer, Observer, Producer, Split};
|
||||
use ringbuf::{HeapCons, HeapProd, HeapRb};
|
||||
use rodio::Source;
|
||||
|
||||
use crate::playback_rate::{
|
||||
effective_pitch, is_effect_active, preserve_out_samples, PlaybackRateAtomics, PRESERVE_MAKEUP_GAIN,
|
||||
uses_preserve_dsp,
|
||||
};
|
||||
|
||||
const FRAME_BLOCK: usize = 128;
|
||||
const PRESERVE_OUT_MAX: usize = 1023;
|
||||
const PRESERVE_PARAM_EPS_PITCH: f32 = 0.05;
|
||||
const PRESERVE_PARAM_EPS_SPEED: f32 = 0.001;
|
||||
const RB_MIN_CAPACITY: usize = 44_100 * 2 * 2; // ~2 s stereo @ 44.1 kHz
|
||||
const RB_TARGET_FILL: f32 = 0.6;
|
||||
const RB_FILL_HIGH: f32 = 0.88;
|
||||
const FORWARD_BATCH: usize = 4096;
|
||||
const WORKER_IDLE_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
enum WorkerCmd {
|
||||
Seek(Duration),
|
||||
Handback,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
struct PreserveWorkerEnv {
|
||||
atomics: PlaybackRateAtomics,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
capacity: usize,
|
||||
stop: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
cmd_rx: mpsc::Receiver<WorkerCmd>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreserveOffload {
|
||||
cons: HeapCons<f32>,
|
||||
stop: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
cmd_tx: SyncSender<WorkerCmd>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PreserveOffload {
|
||||
pub(crate) fn spawn<S: Source<Item = f32> + Send + 'static>(
|
||||
inner: S,
|
||||
atomics: PlaybackRateAtomics,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
handback_tx: SyncSender<S>,
|
||||
) -> Self {
|
||||
let cap = ((sample_rate as f32 * channels as f32 * 2.5) as usize).max(RB_MIN_CAPACITY);
|
||||
let rb = HeapRb::<f32>::new(cap);
|
||||
let (prod, cons) = rb.split();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let (cmd_tx, cmd_rx) = mpsc::sync_channel::<WorkerCmd>(8);
|
||||
let stop_worker = stop.clone();
|
||||
let done_worker = done.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("psysonic-preserve-pitch".into())
|
||||
.spawn(move || {
|
||||
worker_main(
|
||||
inner,
|
||||
prod,
|
||||
PreserveWorkerEnv {
|
||||
atomics,
|
||||
sample_rate,
|
||||
channels,
|
||||
capacity: cap,
|
||||
stop: stop_worker,
|
||||
done: done_worker,
|
||||
cmd_rx,
|
||||
},
|
||||
handback_tx,
|
||||
);
|
||||
})
|
||||
.expect("spawn preserve-pitch worker");
|
||||
|
||||
Self {
|
||||
cons,
|
||||
stop,
|
||||
done,
|
||||
cmd_tx,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pop(&mut self) -> Option<f32> {
|
||||
self.cons.try_pop()
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.done.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn request_seek(&self, pos: Duration) {
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Seek(pos));
|
||||
}
|
||||
|
||||
pub(crate) fn request_handback(&self) {
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Handback);
|
||||
}
|
||||
|
||||
pub(crate) fn drain(&mut self) {
|
||||
while self.cons.try_pop().is_some() {}
|
||||
}
|
||||
|
||||
pub(crate) fn join(mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PreserveOffload {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PreserveChannelState {
|
||||
shifter: Shifter<Box<[f32; TOTAL_F32]>>,
|
||||
frame: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PreserveChannelState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
shifter: Shifter::new(Box::new([0.0; TOTAL_F32])),
|
||||
frame: Vec::with_capacity(FRAME_BLOCK),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
self.frame.clear();
|
||||
}
|
||||
|
||||
fn reset_shifter(&mut self) {
|
||||
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
|
||||
}
|
||||
}
|
||||
|
||||
struct PreserveState {
|
||||
channels: Vec<PreserveChannelState>,
|
||||
pending: VecDeque<f32>,
|
||||
channel_idx: usize,
|
||||
last_pitch: f32,
|
||||
last_speed: f32,
|
||||
}
|
||||
|
||||
impl PreserveState {
|
||||
fn for_channels(count: u16) -> Self {
|
||||
let n = count.max(1) as usize;
|
||||
Self {
|
||||
channels: (0..n).map(|_| PreserveChannelState::new()).collect(),
|
||||
pending: VecDeque::new(),
|
||||
channel_idx: 0,
|
||||
last_pitch: f32::NAN,
|
||||
last_speed: f32::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, channels: u16) {
|
||||
let n = channels.max(1) as usize;
|
||||
if self.channels.len() != n {
|
||||
self.channels = (0..n).map(|_| PreserveChannelState::new()).collect();
|
||||
} else {
|
||||
for ch in &mut self.channels {
|
||||
ch.reset();
|
||||
}
|
||||
}
|
||||
self.pending.clear();
|
||||
self.channel_idx = 0;
|
||||
self.last_pitch = f32::NAN;
|
||||
self.last_speed = f32::NAN;
|
||||
}
|
||||
|
||||
fn reset_if_params_changed(&mut self, pitch: f32, speed: f32) {
|
||||
if self.last_pitch.is_nan() {
|
||||
self.last_pitch = pitch;
|
||||
self.last_speed = speed;
|
||||
return;
|
||||
}
|
||||
if (pitch - self.last_pitch).abs() > PRESERVE_PARAM_EPS_PITCH
|
||||
|| (speed - self.last_speed).abs() > PRESERVE_PARAM_EPS_SPEED
|
||||
{
|
||||
for ch in &mut self.channels {
|
||||
ch.reset_shifter();
|
||||
}
|
||||
self.pending.clear();
|
||||
self.last_pitch = pitch;
|
||||
self.last_speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_block(&mut self, speed: f32, pitch: f32, sample_rate: f32) {
|
||||
self.reset_if_params_changed(pitch, speed);
|
||||
let out_n = preserve_out_samples(speed).clamp(1, PRESERVE_OUT_MAX);
|
||||
let ch_count = self.channels.len();
|
||||
let mut outs: Vec<&[f32]> = Vec::with_capacity(ch_count);
|
||||
for ch in &mut self.channels {
|
||||
if ch.frame.len() == FRAME_BLOCK {
|
||||
let out = ch.shifter.shift(&ch.frame, pitch, out_n, sample_rate);
|
||||
outs.push(out);
|
||||
ch.frame.clear();
|
||||
}
|
||||
}
|
||||
if outs.len() != ch_count {
|
||||
return;
|
||||
}
|
||||
for i in 0..out_n {
|
||||
for out_slice in &outs {
|
||||
if let Some(&sample) = out_slice.get(i) {
|
||||
self.pending
|
||||
.push_back((sample * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ring_fill(prod: &HeapProd<f32>, capacity: usize) -> f32 {
|
||||
1.0 - prod.vacant_len() as f32 / capacity as f32
|
||||
}
|
||||
|
||||
fn push_pending(prod: &mut HeapProd<f32>, pending: &mut VecDeque<f32>, stop: &AtomicBool) {
|
||||
while let Some(&s) = pending.front() {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if prod.try_push(s).is_ok() {
|
||||
pending.pop_front();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_passthrough<S: Source<Item = f32>>(
|
||||
inner: &mut S,
|
||||
prod: &mut HeapProd<f32>,
|
||||
capacity: usize,
|
||||
stop: &AtomicBool,
|
||||
) -> bool {
|
||||
let target = (capacity as f32 * RB_TARGET_FILL) as usize;
|
||||
let mut pushed = 0usize;
|
||||
while prod.occupied_len() < target && pushed < FORWARD_BATCH {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let Some(s) = inner.next() else {
|
||||
return false;
|
||||
};
|
||||
if prod.try_push(s).is_err() {
|
||||
break;
|
||||
}
|
||||
pushed += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn worker_main<S: Source<Item = f32> + Send>(
|
||||
mut inner: S,
|
||||
mut prod: HeapProd<f32>,
|
||||
env: PreserveWorkerEnv,
|
||||
handback_tx: SyncSender<S>,
|
||||
) {
|
||||
let PreserveWorkerEnv {
|
||||
atomics,
|
||||
sample_rate,
|
||||
channels,
|
||||
capacity,
|
||||
stop,
|
||||
done,
|
||||
cmd_rx,
|
||||
} = env;
|
||||
let ch_count = channels.max(1) as usize;
|
||||
let mut preserve = PreserveState::for_channels(channels);
|
||||
let sr = sample_rate as f32;
|
||||
|
||||
'run: while !stop.load(Ordering::Acquire) {
|
||||
if let Ok(cmd) = cmd_rx.try_recv() {
|
||||
match cmd {
|
||||
WorkerCmd::Shutdown => break,
|
||||
WorkerCmd::Handback => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
WorkerCmd::Seek(pos) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let use_preserve = atomics.enabled.load(Ordering::Relaxed)
|
||||
&& uses_preserve_dsp(atomics.load_strategy())
|
||||
&& is_effect_active(&atomics);
|
||||
|
||||
if !use_preserve {
|
||||
preserve.reset(channels);
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let fill = ring_fill(&prod, capacity);
|
||||
if fill >= RB_FILL_HIGH {
|
||||
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
|
||||
Ok(WorkerCmd::Shutdown) => break 'run,
|
||||
Ok(WorkerCmd::Handback) => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
Ok(WorkerCmd::Seek(pos)) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
Err(RecvTimeoutError::Disconnected) => break 'run,
|
||||
}
|
||||
}
|
||||
if !forward_passthrough(&mut inner, &mut prod, capacity, &stop) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let fill = ring_fill(&prod, capacity);
|
||||
if fill >= RB_FILL_HIGH {
|
||||
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
|
||||
Ok(WorkerCmd::Shutdown) => break 'run,
|
||||
Ok(WorkerCmd::Handback) => {
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
let _ = handback_tx.send(inner);
|
||||
done.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
Ok(WorkerCmd::Seek(pos)) => {
|
||||
let _ = inner.try_seek(pos);
|
||||
preserve.reset(channels);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
Err(RecvTimeoutError::Disconnected) => break 'run,
|
||||
}
|
||||
}
|
||||
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
|
||||
if !preserve.pending.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match inner.next() {
|
||||
Some(s) => {
|
||||
let ch = preserve.channel_idx;
|
||||
preserve.channels[ch].frame.push(s);
|
||||
preserve.channel_idx = (ch + 1) % ch_count;
|
||||
if preserve
|
||||
.channels
|
||||
.iter()
|
||||
.all(|c| c.frame.len() >= FRAME_BLOCK)
|
||||
{
|
||||
preserve.process_block(
|
||||
atomics.load_speed(),
|
||||
effective_pitch(&atomics),
|
||||
sr,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
push_pending(&mut prod, &mut preserve.pending, &stop);
|
||||
done.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::playback_rate::STRATEGY_PRESERVE_PITCH;
|
||||
use rodio::{ChannelCount, SampleRate};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
struct SineSource {
|
||||
remaining: usize,
|
||||
rate: u32,
|
||||
}
|
||||
|
||||
impl Iterator for SineSource {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
self.remaining -= 1;
|
||||
Some(0.25)
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for SineSource {
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.remaining)
|
||||
}
|
||||
fn channels(&self) -> ChannelCount {
|
||||
std::num::NonZero::new(2).unwrap()
|
||||
}
|
||||
fn sample_rate(&self) -> SampleRate {
|
||||
SampleRate::new(self.rate).unwrap()
|
||||
}
|
||||
fn total_duration(&self) -> Option<StdDuration> {
|
||||
Some(StdDuration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_prefills_ring_before_done() {
|
||||
let atomics = PlaybackRateAtomics::new();
|
||||
atomics.enabled.store(true, Ordering::Relaxed);
|
||||
atomics
|
||||
.strategy
|
||||
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
|
||||
atomics.speed.store(1.25f32.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let src = SineSource {
|
||||
remaining: 44_100 * 2,
|
||||
rate: 44_100,
|
||||
};
|
||||
let (tx, _rx) = mpsc::sync_channel(1);
|
||||
let mut offload = PreserveOffload::spawn(src, atomics, 44_100, 2, tx);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
let mut got = 0usize;
|
||||
for _ in 0..10_000 {
|
||||
if let Some(s) = offload.pop() {
|
||||
got += 1;
|
||||
if got > 500 {
|
||||
break;
|
||||
}
|
||||
let _ = s;
|
||||
} else if offload.is_done() {
|
||||
break;
|
||||
} else {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
assert!(got > 500, "expected prefetched samples, got {got}");
|
||||
}
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
//! Per-generation progress + ended-detection task. Spawned once per
|
||||
//! `audio_play` / `audio_play_radio` invocation, the task ticks at 100 ms,
|
||||
//! emits `audio:progress` (throttled), handles the gapless transition
|
||||
//! when the current source exhausts and a chained successor is queued,
|
||||
//! and finally emits `audio:ended` when no successor exists.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
use super::engine::AudioCurrent;
|
||||
use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM};
|
||||
use super::playback_rate::{effective_duration_secs, effective_position_secs, PlaybackRateAtomics};
|
||||
use super::state::ChainedInfo;
|
||||
|
||||
/// Sink for the three progress events the task emits. Production wraps an
|
||||
/// `AppHandle<R>` (any Tauri runtime) via the blanket impl below; tests pass
|
||||
/// a `MockProgressEmitter` that records every call.
|
||||
///
|
||||
/// Pulled out of `spawn_progress_task` so the timer-driven loop can be
|
||||
/// exercised against a mock emitter under `#[tokio::test(start_paused = true)]`
|
||||
/// without a live Tauri app.
|
||||
pub trait ProgressEmitter: Send + Sync + 'static {
|
||||
fn emit_progress(&self, payload: ProgressPayload);
|
||||
fn emit_track_switched(&self, duration_secs: f64);
|
||||
fn emit_ended(&self);
|
||||
}
|
||||
|
||||
impl<R: Runtime> ProgressEmitter for AppHandle<R> {
|
||||
fn emit_progress(&self, payload: ProgressPayload) {
|
||||
let _ = Emitter::emit(self, "audio:progress", payload);
|
||||
}
|
||||
fn emit_track_switched(&self, duration_secs: f64) {
|
||||
let _ = Emitter::emit(self, "audio:track_switched", duration_secs);
|
||||
}
|
||||
fn emit_ended(&self) {
|
||||
let _ = Emitter::emit(self, "audio:ended", ());
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the per-generation progress + ended-detection task.
|
||||
///
|
||||
/// The task owns a local `done: Arc<AtomicBool>` reference that starts as
|
||||
/// the current track's done flag. When the progress task detects that the
|
||||
/// done flag is set AND `chained_info` has data, it swaps `done` to the
|
||||
/// chained source's flag and transitions state — all without creating a new
|
||||
/// task or changing the generation counter.
|
||||
///
|
||||
/// Key changes from the previous implementation:
|
||||
/// • 100 ms tick (was 500 ms) — halves worst-case event latency
|
||||
/// • Position from atomic sample counter (no wall-clock drift)
|
||||
/// • Immediate `audio:track_switched` event at decoder boundary
|
||||
/// • `audio:ended` only fires when no chained successor exists
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
gen: u64,
|
||||
gen_counter: Arc<AtomicU64>,
|
||||
current_arc: Arc<Mutex<AudioCurrent>>,
|
||||
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled_arc: Arc<AtomicBool>,
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
emitter: E,
|
||||
analysis_app: Option<AppHandle>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate_arc: Arc<AtomicU32>,
|
||||
channels_arc: Arc<AtomicU32>,
|
||||
gapless_switch_at: Arc<AtomicU64>,
|
||||
current_playback_url: Arc<Mutex<Option<String>>>,
|
||||
stream_playback_armed: Arc<AtomicBool>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
) {
|
||||
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on
|
||||
// Linux; mirrors the quantum policy used for stream open/reopen plus a small
|
||||
// scheduler/mixer cushion so the UI doesn't run ahead. Other platforms have
|
||||
// their own latency reporting paths and don't need the compensation here.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 {
|
||||
let rate = sample_rate_hz.max(1.0);
|
||||
let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 };
|
||||
(frames / rate) + 0.012
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn estimated_output_latency_secs(_sample_rate_hz: f64) -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
// Keep near-end detection at 100 ms, but throttle progress IPC to webview.
|
||||
const PROGRESS_EMIT_MIN_MS: u64 = 1500;
|
||||
const PROGRESS_EMIT_MIN_DELTA_SECS: f64 = 0.9;
|
||||
|
||||
// Watchdog ceiling for the duration-hint near-end timer. Without crossfade,
|
||||
// audio:ended fires from the sample-accurate `current_done` signal (see the
|
||||
// exhaustion branch below), so this timer only matters as a fallback for a
|
||||
// source that never signals exhaustion (stalled or malformed decoder). ~8 s
|
||||
// past the point where near-end counting starts — far longer than any
|
||||
// healthy track runs past its (floored) duration hint, so it never clips a
|
||||
// real tail.
|
||||
const END_WATCHDOG_TICKS: u32 = 80;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
// Local done-flag reference; swapped on gapless transition.
|
||||
let mut current_done = initial_done;
|
||||
// Local sample counter; swapped to chained source's counter on transition.
|
||||
let mut samples_played = samples_played;
|
||||
let mut last_progress_emit_at = Instant::now() - Duration::from_millis(PROGRESS_EMIT_MIN_MS);
|
||||
let mut last_progress_emit_pos = -1.0f64;
|
||||
let mut last_progress_emit_paused = false;
|
||||
|
||||
loop {
|
||||
// 100 ms tick keeps near-end detection timely for crossfade/gapless
|
||||
// handoff while frontend still interpolates smoothly via rAF.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Gapless transition detection ─────────────────────────────────
|
||||
// If the current source is exhausted AND we have a chained track
|
||||
// ready, transition seamlessly: swap tracking state, emit
|
||||
// audio:track_switched for the new track, and continue the loop.
|
||||
if current_done.load(Ordering::SeqCst) {
|
||||
// Radio (dur == 0): stream exhausted / connection dropped → stop.
|
||||
let cur_dur = current_arc.lock().unwrap().duration_secs;
|
||||
if cur_dur <= 0.0 {
|
||||
crate::app_eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)");
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
emitter.emit_ended();
|
||||
break;
|
||||
}
|
||||
|
||||
let chained = chained_arc.lock().unwrap().take();
|
||||
if let Some(info) = chained {
|
||||
if let Some(app) = analysis_app.clone() {
|
||||
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
|
||||
}
|
||||
|
||||
// Swap to the chained source's done flag.
|
||||
current_done = info.source_done;
|
||||
|
||||
// Swap to the chained source's sample counter.
|
||||
// The chained CountingSource increments its own Arc,
|
||||
// so we must rebind our local reference to it —
|
||||
// a one-time value copy would go stale immediately.
|
||||
samples_played = info.sample_counter;
|
||||
|
||||
// Update tracking state and apply the chained track's
|
||||
// effective volume. Deferred from `audio_chain_preload`
|
||||
// (which runs ~30 s before the current track ends) to
|
||||
// avoid changing loudness of the still-playing current
|
||||
// track. `Sink::set_volume` affects the whole Sink, so it
|
||||
// must only be called at the boundary, not at preload.
|
||||
{
|
||||
let mut cur = current_arc.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = info.replay_gain_linear;
|
||||
cur.base_volume = info.base_volume;
|
||||
cur.duration_secs = info.duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
if let Some(sink) = &cur.sink {
|
||||
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
}
|
||||
|
||||
*current_playback_url.lock().unwrap() = Some(info.url.clone());
|
||||
|
||||
// Record the gapless switch timestamp for ghost-command guard.
|
||||
let switch_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
gapless_switch_at.store(switch_ts, Ordering::SeqCst);
|
||||
|
||||
// Emit the new track_switched event — this is immediate,
|
||||
// not delayed by 500 ms like the old audio:playing was.
|
||||
emitter.emit_track_switched(info.duration_secs);
|
||||
near_end_ticks = 0;
|
||||
continue;
|
||||
}
|
||||
// Current source exhausted and no chain queued — this is the
|
||||
// real, sample-accurate end of the track. Emit audio:ended now.
|
||||
// The duration_hint-based near-end timer below would otherwise
|
||||
// clip up to ~1 s off the tail: the Subsonic hint is floored to
|
||||
// whole seconds while the decoded audio runs slightly longer.
|
||||
// The timer stays only as the crossfade trigger and as a
|
||||
// watchdog for sources that never signal exhaustion.
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
emitter.emit_ended();
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Position from atomic sample counter ──────────────────────────
|
||||
let rate = sample_rate_arc.load(Ordering::Relaxed) as f64;
|
||||
let ch = channels_arc.load(Ordering::Relaxed) as f64;
|
||||
let samples = samples_played.load(Ordering::Relaxed) as f64;
|
||||
let divisor = (rate * ch).max(1.0);
|
||||
|
||||
// Read playback snapshot under a single lock to minimize contention
|
||||
// with seek/play/pause commands that also touch `current`.
|
||||
let (base_dur, paused_at) = {
|
||||
let cur = current_arc.lock().unwrap();
|
||||
(cur.duration_secs, cur.paused_at)
|
||||
};
|
||||
let dur = effective_duration_secs(base_dur, &playback_rate);
|
||||
let is_paused = paused_at.is_some();
|
||||
|
||||
let pos_raw = if !stream_playback_armed.load(Ordering::Relaxed) {
|
||||
0.0
|
||||
} else if let Some(p) = paused_at {
|
||||
p
|
||||
} else {
|
||||
effective_position_secs(samples / divisor, &playback_rate)
|
||||
.min(dur.max(0.001))
|
||||
};
|
||||
let progress_latency = if is_paused {
|
||||
0.0
|
||||
} else {
|
||||
estimated_output_latency_secs(rate)
|
||||
};
|
||||
let pos = (pos_raw - progress_latency).max(0.0);
|
||||
|
||||
let now = Instant::now();
|
||||
let should_emit_progress = is_paused != last_progress_emit_paused
|
||||
|| now.duration_since(last_progress_emit_at) >= Duration::from_millis(PROGRESS_EMIT_MIN_MS)
|
||||
|| (pos - last_progress_emit_pos).abs() >= PROGRESS_EMIT_MIN_DELTA_SECS;
|
||||
if should_emit_progress {
|
||||
let buffering = !stream_playback_armed.load(Ordering::Relaxed);
|
||||
emitter.emit_progress(ProgressPayload {
|
||||
current_time: pos,
|
||||
duration: dur,
|
||||
buffering,
|
||||
});
|
||||
last_progress_emit_at = now;
|
||||
last_progress_emit_pos = pos;
|
||||
last_progress_emit_paused = is_paused;
|
||||
}
|
||||
|
||||
if is_paused {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
if dur > end_threshold && pos_raw >= dur - end_threshold {
|
||||
near_end_ticks += 1;
|
||||
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
||||
if near_end_ticks >= 10 {
|
||||
// If a gapless chain is pending, the source hasn't
|
||||
// exhausted yet — duration_hint (integer seconds from
|
||||
// Subsonic) is shorter than the actual audio content.
|
||||
// Don't emit audio:ended; let the gapless transition
|
||||
// handle it when current_done fires.
|
||||
let has_chain = chained_arc.lock().unwrap().is_some();
|
||||
if has_chain {
|
||||
continue;
|
||||
}
|
||||
// With crossfade, audio:ended must fire *early* (cf_secs
|
||||
// before the real end, source not yet exhausted) so the
|
||||
// frontend can start the next track and fade between them
|
||||
// — the timer is the intended trigger here. Without
|
||||
// crossfade, the real end is detected sample-accurately
|
||||
// via `current_done` (handled in the exhaustion branch
|
||||
// above), so the timer only acts as a watchdog for a
|
||||
// source that never signals exhaustion — emitting on the
|
||||
// hint alone would clip up to ~1 s off the tail.
|
||||
if cf_enabled || near_end_ticks >= END_WATCHDOG_TICKS {
|
||||
gen_counter.fetch_add(1, Ordering::SeqCst);
|
||||
emitter.emit_ended();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
near_end_ticks = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// In-memory `ProgressEmitter` that records every event for assertion.
|
||||
#[derive(Default)]
|
||||
struct MockEmitter {
|
||||
progress: Mutex<Vec<ProgressPayload>>,
|
||||
track_switched: Mutex<Vec<f64>>,
|
||||
ended: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl MockEmitter {
|
||||
fn progress_count(&self) -> usize {
|
||||
self.progress.lock().unwrap().len()
|
||||
}
|
||||
fn ended_count(&self) -> usize {
|
||||
self.ended.load(Ordering::SeqCst)
|
||||
}
|
||||
fn track_switched_count(&self) -> usize {
|
||||
self.track_switched.lock().unwrap().len()
|
||||
}
|
||||
fn last_progress_time(&self) -> Option<f64> {
|
||||
self.progress
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.map(|p| p.current_time)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressEmitter for Arc<MockEmitter> {
|
||||
fn emit_progress(&self, payload: ProgressPayload) {
|
||||
self.progress.lock().unwrap().push(payload);
|
||||
}
|
||||
fn emit_track_switched(&self, duration_secs: f64) {
|
||||
self.track_switched.lock().unwrap().push(duration_secs);
|
||||
}
|
||||
fn emit_ended(&self) {
|
||||
self.ended.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle of every Arc<…> the spawn function needs, with sane defaults.
|
||||
struct TaskHarness {
|
||||
gen: u64,
|
||||
gen_counter: Arc<AtomicU64>,
|
||||
current: Arc<Mutex<AudioCurrent>>,
|
||||
chained: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled: Arc<AtomicBool>,
|
||||
crossfade_secs: Arc<AtomicU32>,
|
||||
done: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate: Arc<AtomicU32>,
|
||||
channels: Arc<AtomicU32>,
|
||||
gapless_switch_at: Arc<AtomicU64>,
|
||||
playback_url: Arc<Mutex<Option<String>>>,
|
||||
stream_playback_armed: Arc<AtomicBool>,
|
||||
playback_rate: PlaybackRateAtomics,
|
||||
}
|
||||
|
||||
impl TaskHarness {
|
||||
fn new(duration_secs: f64) -> Self {
|
||||
let current = AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 1.0,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
};
|
||||
Self {
|
||||
gen: 1,
|
||||
gen_counter: Arc::new(AtomicU64::new(1)),
|
||||
current: Arc::new(Mutex::new(current)),
|
||||
chained: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
playback_url: Arc::new(Mutex::new(None)),
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
playback_rate: PlaybackRateAtomics::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_with(&self, emitter: Arc<MockEmitter>) {
|
||||
spawn_progress_task(
|
||||
self.gen,
|
||||
self.gen_counter.clone(),
|
||||
self.current.clone(),
|
||||
self.chained.clone(),
|
||||
self.crossfade_enabled.clone(),
|
||||
self.crossfade_secs.clone(),
|
||||
self.done.clone(),
|
||||
emitter,
|
||||
None,
|
||||
self.samples_played.clone(),
|
||||
self.sample_rate.clone(),
|
||||
self.channels.clone(),
|
||||
self.gapless_switch_at.clone(),
|
||||
self.playback_url.clone(),
|
||||
self.stream_playback_armed.clone(),
|
||||
self.playback_rate.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn progress_emits_buffering_while_stream_not_armed() {
|
||||
let h = TaskHarness::new(240.0);
|
||||
h.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
h.samples_played.store(441_000, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
emitter.progress.lock().unwrap().iter().any(|p| p.buffering),
|
||||
"progress payload must flag HTTP stream buffering before armed"
|
||||
);
|
||||
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn legacy_stream_holds_progress_at_zero_until_armed() {
|
||||
let h = TaskHarness::new(240.0);
|
||||
h.stream_playback_armed.store(false, Ordering::SeqCst);
|
||||
h.samples_played.store(441_000, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
emitter.last_progress_time().unwrap_or(0.0) < 0.01,
|
||||
"progress must stay at 0 while legacy stream is buffering"
|
||||
);
|
||||
assert!(
|
||||
emitter.progress.lock().unwrap().iter().any(|p| p.buffering),
|
||||
"progress payload must flag legacy stream buffering"
|
||||
);
|
||||
|
||||
h.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
emitter.last_progress_time().unwrap_or(0.0) > 4.0,
|
||||
"progress should follow samples once armed (got {:?})",
|
||||
emitter.last_progress_time()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn task_breaks_immediately_when_generation_already_changed() {
|
||||
let h = TaskHarness::new(120.0);
|
||||
// Bump the generation BEFORE spawn — the first 100 ms tick will see
|
||||
// the mismatch and exit the loop without emitting anything.
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(emitter.progress_count(), 0);
|
||||
assert_eq!(emitter.ended_count(), 0);
|
||||
assert_eq!(emitter.track_switched_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn radio_with_dur_zero_emits_ended_when_done_flag_flips() {
|
||||
// Radio streams have duration_secs == 0; the "done" flag is the only
|
||||
// exhaustion signal. Loop must emit audio:ended and bump the
|
||||
// generation counter.
|
||||
//
|
||||
// Multi-thread runtime with real time — start_paused under
|
||||
// current_thread doesn't reliably drive the spawned task's loop body
|
||||
// after tokio::time::advance, even with repeated yields. Real 100 ms
|
||||
// sleeps are tolerable because the test only waits one tick.
|
||||
let h = TaskHarness::new(0.0);
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended must fire");
|
||||
assert_eq!(emitter.progress_count(), 0, "no progress emit before exhaustion");
|
||||
assert!(
|
||||
h.gen_counter.load(Ordering::SeqCst) > h.gen,
|
||||
"generation counter must bump so following commands see the new gen"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn task_emits_progress_payload_with_duration_after_first_tick() {
|
||||
let h = TaskHarness::new(120.0);
|
||||
// 5 s of playback at 44.1 kHz × 2 ch.
|
||||
let played = (5.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let first_payload = {
|
||||
let payloads = emitter.progress.lock().unwrap();
|
||||
assert!(!payloads.is_empty(), "first tick must emit progress");
|
||||
payloads[0].clone()
|
||||
};
|
||||
assert_eq!(first_payload.duration, 120.0, "duration_secs propagates verbatim");
|
||||
// current_time is computed from samples_played but possibly trimmed by
|
||||
// platform output latency — accept anything in [0, duration].
|
||||
assert!(first_payload.current_time >= 0.0 && first_payload.current_time <= 120.0);
|
||||
|
||||
// Stop the task so the test runtime can end.
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn done_with_chained_info_swaps_to_chain_and_emits_track_switched() {
|
||||
let h = TaskHarness::new(120.0);
|
||||
// Mark current source exhausted AND queue a chained successor.
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
let chain_url = "psysonic-local:///next/track.flac".to_string();
|
||||
let chained_done = Arc::new(AtomicBool::new(false));
|
||||
let chained_samples = Arc::new(AtomicU64::new(0));
|
||||
*h.chained.lock().unwrap() = Some(ChainedInfo {
|
||||
url: chain_url.clone(),
|
||||
analysis_track_id: None,
|
||||
server_id: None,
|
||||
raw_bytes: Arc::new(Vec::new()),
|
||||
duration_secs: 200.0,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 1.0,
|
||||
source_done: chained_done,
|
||||
sample_counter: chained_samples,
|
||||
});
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
assert_eq!(
|
||||
emitter.track_switched_count(),
|
||||
1,
|
||||
"audio:track_switched must fire on gapless transition"
|
||||
);
|
||||
let switched_dur = emitter.track_switched.lock().unwrap()[0];
|
||||
assert_eq!(switched_dur, 200.0, "duration of the chained track");
|
||||
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"audio:ended must NOT fire when a chain is present"
|
||||
);
|
||||
assert_eq!(
|
||||
*h.playback_url.lock().unwrap(),
|
||||
Some(chain_url),
|
||||
"current_playback_url updated to the chained URL"
|
||||
);
|
||||
assert!(
|
||||
h.gapless_switch_at.load(Ordering::SeqCst) > 0,
|
||||
"gapless_switch_at timestamp recorded for ghost-command guard"
|
||||
);
|
||||
|
||||
// Stop the task.
|
||||
h.gen_counter.store(99, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn done_without_chain_emits_ended_immediately() {
|
||||
// Real track (duration_secs > 0), source exhausted, no chained
|
||||
// successor: audio:ended must fire on the sample-accurate done flag —
|
||||
// not be deferred to (or clipped by) the duration-hint near-end timer.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended must fire on source exhaustion");
|
||||
assert_eq!(emitter.track_switched_count(), 0, "no chain → no track switch");
|
||||
assert!(
|
||||
h.gen_counter.load(Ordering::SeqCst) > h.gen,
|
||||
"generation counter must bump so following commands see the new gen"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn near_end_without_crossfade_waits_for_source_done() {
|
||||
// Position playback past the (floored) duration hint with the source
|
||||
// NOT yet exhausted and crossfade off. The duration-hint timer must
|
||||
// NOT emit audio:ended — doing so would clip the real tail, since the
|
||||
// decoded audio routinely runs slightly longer than the integer hint.
|
||||
let h = TaskHarness::new(120.0);
|
||||
// samples → pos_raw clamps to dur (120 s), well inside `dur - 1`.
|
||||
let played = (120.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
// > 10 ticks: the timer's near-end counter is well past its 1 s mark.
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"audio:ended must NOT fire from the duration-hint timer before the source is done"
|
||||
);
|
||||
|
||||
// Source actually exhausts → audio:ended fires now.
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires once the source is exhausted");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn near_end_with_crossfade_emits_ended_on_timer() {
|
||||
// With crossfade enabled, audio:ended must still fire from the timer
|
||||
// ~cf_secs before the real end (the source is NOT exhausted yet) so the
|
||||
// frontend can start the next track and fade between them.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
// 10 ticks ≈ 1 s to cross the near-end debounce.
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
1,
|
||||
"crossfade still relies on the timer to fire audio:ended early"
|
||||
);
|
||||
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
//! Live internet-radio playback. Distinct from main track playback: no
|
||||
//! gapless chain, no seek, no replay-gain, no preload.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::{HeapCons, HeapRb};
|
||||
use rodio::{Player, Source};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::playback_rate::PlaybackRateAtomics;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::{content_type_to_hint, MASTER_HEADROOM};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::sources::{
|
||||
CountingSource, DynSource, EqSource, EqualPowerFadeIn, NotifyingSource,
|
||||
PriorityBoostSource, TriggeredFadeOut,
|
||||
};
|
||||
use super::stream::{
|
||||
radio_download_task, AudioStreamReader, RadioLiveState, RadioSharedFlags,
|
||||
RADIO_BUF_CAPACITY, RADIO_READ_TIMEOUT_SECS,
|
||||
};
|
||||
|
||||
/// Play a live internet radio stream.
|
||||
///
|
||||
/// Sends `Icy-MetaData: 1` to request inline ICY metadata.
|
||||
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
|
||||
/// and `radio:metadata` whenever the StreamTitle changes.
|
||||
#[tauri::command]
|
||||
pub async fn audio_play_radio(
|
||||
url: String,
|
||||
volume: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
// Cancel any active preview so it doesn't keep playing alongside radio.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Abort any previous radio task before stopping the sink.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
}
|
||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); }
|
||||
|
||||
// ── Open initial HTTP connection ──────────────────────────────────────────
|
||||
let response = audio_http_client(&state)
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let m = format!("radio: connection failed: {e}");
|
||||
app.emit("audio:error", &m).ok();
|
||||
m
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let m = format!("radio: HTTP {}", response.status());
|
||||
app.emit("audio:error", &m).ok();
|
||||
return Err(m);
|
||||
}
|
||||
|
||||
let fmt_hint = content_type_to_hint(
|
||||
response.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
|
||||
// ── Build 4 MB lock-free SPSC ring buffer ─────────────────────────────────
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (prod, cons) = rb.split();
|
||||
|
||||
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
let flags = Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(false),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(new_cons_tx),
|
||||
});
|
||||
|
||||
// ── Spawn download task ───────────────────────────────────────────────────
|
||||
let task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
Some(response),
|
||||
audio_http_client(&state),
|
||||
url.clone(),
|
||||
prod,
|
||||
flags.clone(),
|
||||
app.clone(),
|
||||
));
|
||||
|
||||
*state.radio_state.lock().unwrap() = Some(RadioLiveState {
|
||||
url: url.clone(),
|
||||
gen,
|
||||
task,
|
||||
flags: flags.clone(),
|
||||
});
|
||||
|
||||
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
|
||||
let reader = AudioStreamReader {
|
||||
read_timeout_secs: RADIO_READ_TIMEOUT_SECS,
|
||||
cons: Mutex::new(cons),
|
||||
new_cons_rx: Mutex::new(new_cons_rx),
|
||||
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||
gen_arc: state.generation.clone(),
|
||||
gen,
|
||||
source_tag: "radio",
|
||||
eof_when_empty: None,
|
||||
pos: 0,
|
||||
};
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||
let dyn_src = DynSource::new(decoder);
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(boosted);
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() { old.stop(); }
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = 0.0; // sentinel: live stream
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = 1.0;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
}
|
||||
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
state.current_sample_rate.store(sample_rate.get(), Ordering::Relaxed);
|
||||
state.current_channels.store(channels.get() as u32, Ordering::Relaxed);
|
||||
|
||||
app.emit("audio:playing", 0.0f64).ok();
|
||||
|
||||
state.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.current.clone(),
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
None,
|
||||
state.samples_played.clone(),
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
PlaybackRateAtomics::default(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
//! ICY (Shoutcast/Icecast) inline-metadata state machine.
|
||||
//!
|
||||
//! Streams embed metadata every `metaint` audio bytes:
|
||||
//!
|
||||
//! ┌──────────────────────┬───┬─────────────┐
|
||||
//! │ audio × metaint │ N │ meta × N×16 │ (repeating)
|
||||
//! └──────────────────────┴───┴─────────────┘
|
||||
//!
|
||||
//! N = 0 → no metadata this block. Metadata bytes are stripped so only
|
||||
//! pure audio reaches the ring buffer and Symphonia never sees text bytes.
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub(crate) enum IcyState {
|
||||
/// Forwarding audio bytes; `remaining` counts down to the next boundary.
|
||||
ReadingAudio { remaining: usize },
|
||||
/// Next byte is the metadata length multiplier N.
|
||||
ReadingLengthByte,
|
||||
/// Accumulating N×16 metadata bytes.
|
||||
ReadingMetadata { remaining: usize, buf: Vec<u8> },
|
||||
}
|
||||
|
||||
pub(crate) struct IcyInterceptor {
|
||||
state: IcyState,
|
||||
metaint: usize,
|
||||
}
|
||||
|
||||
impl IcyInterceptor {
|
||||
pub(crate) fn new(metaint: usize) -> Self {
|
||||
Self { metaint, state: IcyState::ReadingAudio { remaining: metaint } }
|
||||
}
|
||||
|
||||
/// Feed a raw HTTP chunk.
|
||||
/// Appends only audio bytes to `audio_out`.
|
||||
/// Returns `Some(IcyMeta)` when a StreamTitle is extracted.
|
||||
pub(crate) fn process(&mut self, input: &[u8], audio_out: &mut Vec<u8>) -> Option<IcyMeta> {
|
||||
let mut extracted: Option<IcyMeta> = None;
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
match &mut self.state {
|
||||
IcyState::ReadingAudio { remaining } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
audio_out.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
self.state = IcyState::ReadingLengthByte;
|
||||
}
|
||||
}
|
||||
IcyState::ReadingLengthByte => {
|
||||
let len_n = input[i] as usize;
|
||||
i += 1;
|
||||
self.state = if len_n == 0 {
|
||||
IcyState::ReadingAudio { remaining: self.metaint }
|
||||
} else {
|
||||
IcyState::ReadingMetadata {
|
||||
remaining: len_n * 16,
|
||||
buf: Vec::with_capacity(len_n * 16),
|
||||
}
|
||||
};
|
||||
}
|
||||
IcyState::ReadingMetadata { remaining, buf } => {
|
||||
let n = (input.len() - i).min(*remaining);
|
||||
buf.extend_from_slice(&input[i..i + n]);
|
||||
i += n;
|
||||
*remaining -= n;
|
||||
if *remaining == 0 {
|
||||
let bytes = std::mem::take(buf);
|
||||
extracted = parse_icy_meta(&bytes);
|
||||
self.state = IcyState::ReadingAudio { remaining: self.metaint };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
extracted
|
||||
}
|
||||
}
|
||||
|
||||
/// ICY metadata parsed from a raw metadata block.
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct IcyMeta {
|
||||
pub title: String,
|
||||
/// `true` when `StreamUrl='0'` — indicates a CDN-injected ad/promo.
|
||||
pub is_ad: bool,
|
||||
}
|
||||
|
||||
/// Extract `StreamTitle` and `StreamUrl` from a raw ICY metadata block.
|
||||
/// Tolerates null padding and non-UTF-8 bytes (lossy conversion).
|
||||
fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
|
||||
let s = String::from_utf8_lossy(raw);
|
||||
let s = s.trim_end_matches('\0');
|
||||
|
||||
const TITLE_TAG: &str = "StreamTitle='";
|
||||
let title_start = s.find(TITLE_TAG)? + TITLE_TAG.len();
|
||||
let title_rest = &s[title_start..];
|
||||
// find (not rfind) — rfind would skip past StreamUrl and corrupt the title
|
||||
let title_end = title_rest.find("';")?;
|
||||
let title = title_rest[..title_end].trim().to_string();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
const URL_TAG: &str = "StreamUrl='";
|
||||
let stream_url = s.find(URL_TAG).map(|pos| {
|
||||
let rest = &s[pos + URL_TAG.len()..];
|
||||
let end = rest.find("';").unwrap_or(rest.len());
|
||||
rest[..end].trim().to_string()
|
||||
}).unwrap_or_default();
|
||||
|
||||
Some(IcyMeta { title, is_ad: stream_url == "0" })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_icy_meta ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_extracts_title_from_canonical_block() {
|
||||
let raw = b"StreamTitle='Pink Floyd - Time';StreamUrl='https://www.example';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert_eq!(m.title, "Pink Floyd - Time");
|
||||
assert!(!m.is_ad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_marks_is_ad_when_stream_url_is_zero() {
|
||||
let raw = b"StreamTitle='Sponsored';StreamUrl='0';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert!(m.is_ad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_title_tag_missing() {
|
||||
assert!(parse_icy_meta(b"StreamUrl='abc';").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_title_unterminated() {
|
||||
// Missing the closing `';` after StreamTitle.
|
||||
assert!(parse_icy_meta(b"StreamTitle='no-end").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_title_is_empty() {
|
||||
assert!(parse_icy_meta(b"StreamTitle='';StreamUrl='x';").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tolerates_trailing_null_padding() {
|
||||
let mut raw = b"StreamTitle='Track';StreamUrl='https://x';".to_vec();
|
||||
raw.extend_from_slice(&[0u8; 32]);
|
||||
let m = parse_icy_meta(&raw).unwrap();
|
||||
assert_eq!(m.title, "Track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tolerates_non_utf8_bytes() {
|
||||
// Latin-1 0xA9 (©) — String::from_utf8_lossy replaces with U+FFFD
|
||||
// and trim() leaves the title intact.
|
||||
let raw = b"StreamTitle='\xA9 Track';StreamUrl='x';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert!(m.title.contains("Track"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_uses_first_title_quote_pair_not_stream_url_pair() {
|
||||
// The body uses `find` not `rfind` so the title stops at its own `';`
|
||||
// even though a later `';` exists for StreamUrl.
|
||||
let raw = b"StreamTitle='Real Title';StreamUrl='Long URL with quotes';";
|
||||
let m = parse_icy_meta(raw).unwrap();
|
||||
assert_eq!(m.title, "Real Title");
|
||||
}
|
||||
|
||||
// ── IcyInterceptor ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn interceptor_passes_audio_through_when_no_metadata_block_yet() {
|
||||
let mut icy = IcyInterceptor::new(8);
|
||||
let mut audio = Vec::new();
|
||||
let result = icy.process(b"abcd", &mut audio);
|
||||
assert_eq!(audio, b"abcd");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_strips_zero_length_metadata_block() {
|
||||
// metaint = 4, then 1 length byte = 0 → no metadata, then more audio.
|
||||
let mut icy = IcyInterceptor::new(4);
|
||||
let mut audio = Vec::new();
|
||||
// Audio (4) + length=0 + audio (4) = 9 bytes input
|
||||
let input: Vec<u8> = b"AAAA\x00BBBB".to_vec();
|
||||
let result = icy.process(&input, &mut audio);
|
||||
assert_eq!(audio, b"AAAABBBB");
|
||||
assert!(result.is_none(), "zero-length metadata block produces no IcyMeta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_strips_metadata_bytes_from_audio_stream() {
|
||||
// metaint = 4, length=1 (×16=16 bytes of metadata).
|
||||
let mut icy = IcyInterceptor::new(4);
|
||||
let mut audio = Vec::new();
|
||||
let mut input = b"AAAA\x01".to_vec();
|
||||
// Pad metadata to exactly 16 bytes with a parsable StreamTitle.
|
||||
let mut meta = b"StreamTitle='X';".to_vec();
|
||||
meta.resize(16, 0);
|
||||
input.extend_from_slice(&meta);
|
||||
input.extend_from_slice(b"BBBB");
|
||||
|
||||
let result = icy.process(&input, &mut audio);
|
||||
assert_eq!(audio, b"AAAABBBB", "metadata bytes do not leak into audio");
|
||||
let meta = result.expect("StreamTitle present");
|
||||
assert_eq!(meta.title, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_handles_input_split_across_multiple_calls() {
|
||||
// Same scenario as above, fed in 1-byte chunks.
|
||||
let mut icy = IcyInterceptor::new(4);
|
||||
let mut audio = Vec::new();
|
||||
let mut full = b"AAAA\x01".to_vec();
|
||||
let mut meta = b"StreamTitle='Y';".to_vec();
|
||||
meta.resize(16, 0);
|
||||
full.extend_from_slice(&meta);
|
||||
full.extend_from_slice(b"BBBB");
|
||||
|
||||
let mut last_meta = None;
|
||||
for byte in &full {
|
||||
if let Some(m) = icy.process(&[*byte], &mut audio) {
|
||||
last_meta = Some(m);
|
||||
}
|
||||
}
|
||||
assert_eq!(audio, b"AAAABBBB");
|
||||
assert_eq!(last_meta.unwrap().title, "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_treats_subsequent_blocks_independently() {
|
||||
// Two metaint cycles, both with parsable metadata. Titles must be
|
||||
// single-character so `StreamTitle='X';` fits in the 16-byte block
|
||||
// (length byte = 1 → 16 bytes of metadata).
|
||||
let mut icy = IcyInterceptor::new(2);
|
||||
let mut audio = Vec::new();
|
||||
// First block: AA + length=1 + 16-byte meta
|
||||
let mut input = b"AA\x01".to_vec();
|
||||
input.extend_from_slice(b"StreamTitle='1';"); // exactly 16 bytes
|
||||
// Second block: BB + length=1 + 16-byte meta
|
||||
input.extend_from_slice(b"BB\x01");
|
||||
input.extend_from_slice(b"StreamTitle='2';"); // exactly 16 bytes
|
||||
// Trailing audio
|
||||
input.extend_from_slice(b"CC");
|
||||
|
||||
let _ = icy.process(&input, &mut audio);
|
||||
assert_eq!(audio, b"AABBCC", "all audio bytes survive across two cycles");
|
||||
|
||||
// Title verification with split input: a single process() returns at
|
||||
// most one IcyMeta, so feed the two metadata blocks in separate calls.
|
||||
let mut icy2 = IcyInterceptor::new(2);
|
||||
let mut audio2 = Vec::new();
|
||||
let split_at = 2 + 1 + 16; // end of first block
|
||||
let mut titles = Vec::new();
|
||||
if let Some(m) = icy2.process(&input[..split_at], &mut audio2) {
|
||||
titles.push(m.title);
|
||||
}
|
||||
if let Some(m) = icy2.process(&input[split_at..], &mut audio2) {
|
||||
titles.push(m.title);
|
||||
}
|
||||
assert_eq!(titles, vec!["1".to_string(), "2".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//! `LocalFileSource` — seekable `MediaSource` backed directly by `std::fs::File`.
|
||||
//!
|
||||
//! Used for `psysonic-local://` URLs (offline library + hot playback cache hits).
|
||||
//! Lets Symphonia read on-demand from disk during the probe (~64 KB) instead of
|
||||
//! the previous behaviour of `tokio::fs::read` blocking until the entire file
|
||||
//! (often 100+ MB for hi-res FLAC) was loaded into RAM. Track-start is instant.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
pub(crate) struct LocalFileSource {
|
||||
pub(crate) file: std::fs::File,
|
||||
pub(crate) len: u64,
|
||||
}
|
||||
|
||||
impl Read for LocalFileSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.file.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for LocalFileSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.file.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for LocalFileSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//! HTTP-backed and file-backed `MediaSource` implementations plus their
|
||||
//! background download tasks.
|
||||
//!
|
||||
//! Submodule layout:
|
||||
//! - `icy` — Shoutcast/Icecast inline-metadata state machine
|
||||
//! - `reader` — `AudioStreamReader` (ringbuf → `std::io::Read` shim)
|
||||
//! - `local_file` — `LocalFileSource` (file-backed, seekable)
|
||||
//! - `ranged_http` — `RangedHttpSource` (seekable HTTP) + `ranged_download_task`
|
||||
//! - `radio` — radio session state + `radio_download_task`
|
||||
//! - `track_stream` — `track_download_task` (one-shot non-ranged HTTP)
|
||||
|
||||
mod icy;
|
||||
mod local_file;
|
||||
mod mp4;
|
||||
mod radio;
|
||||
mod ranged_http;
|
||||
mod reader;
|
||||
mod track_stream;
|
||||
|
||||
pub(crate) use mp4::{
|
||||
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
|
||||
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
|
||||
};
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use reader::AudioStreamReader;
|
||||
pub(crate) use track_stream::track_download_task;
|
||||
|
||||
// ── Shared tuning constants ──────────────────────────────────────────────────
|
||||
|
||||
/// 256 KB on the heap — ≈16 s at 128 kbps, ≈6 s at 320 kbps.
|
||||
/// Small enough that stale audio drains within a few seconds on reconnect;
|
||||
/// large enough to absorb brief network hiccups without stuttering.
|
||||
pub(crate) const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||
/// Minimum ring buffer for on-demand track streaming starts.
|
||||
pub(crate) const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
|
||||
/// Cap ring buffer growth when content-length is known.
|
||||
pub(crate) const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
|
||||
/// Max bytes kept in RAM (`stream_completed_cache`) for fast replay; larger completed
|
||||
/// ranged streams are spilled under app-data `stream-spill/` for hot-cache promote.
|
||||
pub(crate) const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Hot/offline `psysonic-local://` files are read from disk for waveform/LUFS seeding — not the
|
||||
/// same heap pressure as retaining a full HTTP capture. FLAC/DSD tracks often exceed 64 MiB;
|
||||
/// using the stream-promote cap here skipped analysis entirely (empty seekbar).
|
||||
pub(crate) const LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||
/// Consecutive body-stream failures tolerated for track streaming before abort.
|
||||
pub(crate) const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
|
||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||
pub(crate) const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||
/// Live radio: if no audio bytes arrive for this long → EOF.
|
||||
pub(crate) const RADIO_READ_TIMEOUT_SECS: u64 = 15;
|
||||
/// On-demand tracks (`track-stream`, `RangedHttpSource`): allow long gaps while a
|
||||
/// large file is still downloading (format probe may read/seek ahead of the filler).
|
||||
pub(crate) const TRACK_READ_TIMEOUT_SECS: u64 = 120;
|
||||
/// HTTP track paths (`AudioStreamReader`, `RangedHttpSource`): minimum linear
|
||||
/// download before audible playback and seekbar progress (demux probe may read
|
||||
/// far ahead of the play cursor).
|
||||
pub(crate) const TRACK_STREAM_PLAY_START_BYTES: u64 = 384 * 1024;
|
||||
|
||||
/// Arm deferred playback / progress once enough of the file is buffered.
|
||||
pub(crate) fn maybe_arm_stream_playback(downloaded: u64, playback_armed: &std::sync::atomic::AtomicBool) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if !playback_armed.load(Ordering::Relaxed) && downloaded >= TRACK_STREAM_PLAY_START_BYTES {
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
crate::app_deprintln!(
|
||||
"[stream] playback armed after {} KiB buffered",
|
||||
downloaded / 1024
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Held until `RangedHttpSource` has moov metadata for Symphonia probe (tail prefetch
|
||||
/// or fast-start moov in the linear prefix).
|
||||
pub(crate) struct RangedMp4ProbeGate {
|
||||
pub(crate) tail_ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
pub(crate) buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
|
||||
pub(crate) downloaded_to: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub(crate) gen_arc: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
pub(crate) format_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Block until moov is reachable: tail prefetch completed or moov already in the
|
||||
/// downloaded prefix (fast-start). Avoids Symphonia probing moov-at-end M4A before
|
||||
/// the tail range is filled (format probe failed: end of stream).
|
||||
pub(crate) async fn wait_for_ranged_mp4_probe_ready(gate: &RangedMp4ProbeGate) -> Result<(), String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PREFIX_SCAN_MIN: usize = 64 * 1024;
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
|
||||
loop {
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
if gate.tail_ready.load(Ordering::Relaxed) {
|
||||
crate::app_deprintln!("[stream] ranged: moov metadata ready (tail prefetch)");
|
||||
return Ok(());
|
||||
}
|
||||
let dl = gate.downloaded_to.load(Ordering::Relaxed);
|
||||
if dl >= PREFIX_SCAN_MIN {
|
||||
let guard = gate.buf.lock().unwrap();
|
||||
let n = dl.min(guard.len());
|
||||
if !mp4::mp4_needs_tail_prefetch(&guard[..n], gate.format_hint.as_deref()) {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged: moov metadata ready (fast-start, {} KiB prefix)",
|
||||
n / 1024
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(
|
||||
"ranged-stream: timed out waiting for moov metadata (tail prefetch)".into(),
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep interval when ring buffer is empty (prevents CPU spin).
|
||||
pub(crate) const RADIO_YIELD_MS: u64 = 2;
|
||||
@@ -1,243 +0,0 @@
|
||||
//! MP4/M4A layout helpers for HTTP streaming path selection.
|
||||
|
||||
/// True when the Subsonic / sniffed container hint is ISO-BMFF (m4a, mp4, …).
|
||||
pub(crate) fn container_hint_is_mp4(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"m4a" | "m4af" | "mp4" | "m4b" | "mov" | "mp4a" | "isom"
|
||||
)
|
||||
}
|
||||
|
||||
/// Walk top-level atoms in `prefix` and return true when `mdat` appears before `moov`
|
||||
/// (classic non–fast-start layout — Symphonia must read the `moov` near EOF).
|
||||
pub(crate) fn mp4_moov_follows_mdat(prefix: &[u8]) -> bool {
|
||||
let mut pos = 0usize;
|
||||
let mut saw_mdat = false;
|
||||
while pos + 8 <= prefix.len() {
|
||||
let atom_size = match read_mp4_atom_size(prefix, pos) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if atom_size < 8 {
|
||||
break;
|
||||
}
|
||||
let atom_type = &prefix[pos + 4..pos + 8];
|
||||
if atom_type == b"mdat" {
|
||||
saw_mdat = true;
|
||||
}
|
||||
if atom_type == b"moov" {
|
||||
return saw_mdat;
|
||||
}
|
||||
let advance = atom_size.min((prefix.len() - pos) as u64) as usize;
|
||||
if advance < 8 {
|
||||
break;
|
||||
}
|
||||
pos += advance;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True when we should prefetch the file tail before linear fill (moov-at-end).
|
||||
pub(crate) fn mp4_needs_tail_prefetch(prefix: &[u8], hint: Option<&str>) -> bool {
|
||||
if !container_hint_is_mp4(hint) {
|
||||
return false;
|
||||
}
|
||||
if prefix.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if mp4_moov_follows_mdat(prefix) {
|
||||
return true;
|
||||
}
|
||||
// mdat seen but no moov in the scanned prefix — moov is likely at EOF.
|
||||
let mut pos = 0usize;
|
||||
let mut saw_mdat = false;
|
||||
let mut saw_moov = false;
|
||||
while pos + 8 <= prefix.len() {
|
||||
let atom_size = match read_mp4_atom_size(prefix, pos) {
|
||||
Some(s) => s,
|
||||
None => break,
|
||||
};
|
||||
if atom_size < 8 {
|
||||
break;
|
||||
}
|
||||
let atom_type = &prefix[pos + 4..pos + 8];
|
||||
if atom_type == b"mdat" {
|
||||
saw_mdat = true;
|
||||
}
|
||||
if atom_type == b"moov" {
|
||||
saw_moov = true;
|
||||
break;
|
||||
}
|
||||
let advance = atom_size.min((prefix.len() - pos) as u64) as usize;
|
||||
if advance < 8 {
|
||||
break;
|
||||
}
|
||||
pos += advance;
|
||||
}
|
||||
saw_mdat && !saw_moov
|
||||
}
|
||||
|
||||
/// Scan `[scan_start, scan_end)` for a top-level atom fourcc (e.g. `moov`).
|
||||
fn find_atom_fourcc(data: &[u8], atom: &[u8; 4], scan_start: usize, scan_end: usize) -> Option<usize> {
|
||||
let end = scan_end.min(data.len());
|
||||
let start = scan_start.min(end);
|
||||
for i in start..end.saturating_sub(8) {
|
||||
if data[i + 4..i + 8] == *atom {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `moov` in the last 8 MiB (moov-at-end) or anywhere in the first 32 MiB (fast-start).
|
||||
pub(crate) fn mp4_has_moov_atom(data: &[u8]) -> bool {
|
||||
if data.len() < 16 {
|
||||
return false;
|
||||
}
|
||||
const TAIL_SCAN: usize = 8 * 1024 * 1024;
|
||||
const PREFIX_SCAN: usize = 32 * 1024 * 1024;
|
||||
if find_atom_fourcc(data, b"moov", data.len().saturating_sub(TAIL_SCAN), data.len()).is_some() {
|
||||
return true;
|
||||
}
|
||||
find_atom_fourcc(data, b"moov", 0, PREFIX_SCAN.min(data.len())).is_some()
|
||||
}
|
||||
|
||||
/// `ftyp` in the first few KB — minimal sanity check for ISO-BMFF from ranged assembly.
|
||||
pub(crate) fn mp4_has_ftyp_atom(data: &[u8]) -> bool {
|
||||
find_atom_fourcc(data, b"ftyp", 0, data.len().min(8192)).is_some()
|
||||
}
|
||||
|
||||
/// After a full ranged download, the buffer should contain `ftyp` and `moov`.
|
||||
pub(crate) fn isobmff_buffer_looks_complete(data: &[u8]) -> bool {
|
||||
mp4_has_ftyp_atom(data) && mp4_has_moov_atom(data)
|
||||
}
|
||||
|
||||
/// Heuristic: large runs of zero bytes between `ftyp` and `moov` suggest a sparse/holey
|
||||
/// ranged buffer (tail prefetch + incomplete linear fill) rather than real audio.
|
||||
pub(crate) fn mp4_suspect_zero_holes(data: &[u8]) -> bool {
|
||||
if data.len() < 256 * 1024 {
|
||||
return false;
|
||||
}
|
||||
let moov_off = find_atom_fourcc(data, b"moov", data.len().saturating_sub(8 * 1024 * 1024), data.len())
|
||||
.or_else(|| find_atom_fourcc(data, b"moov", 0, data.len().min(32 * 1024 * 1024)));
|
||||
let Some(moov_off) = moov_off else {
|
||||
return false;
|
||||
};
|
||||
let scan_end = moov_off.min(data.len());
|
||||
let scan_start = 64 * 1024; // skip tiny header
|
||||
if scan_end <= scan_start + 64 * 1024 {
|
||||
return false;
|
||||
}
|
||||
const BLOCK: usize = 64 * 1024;
|
||||
let mut zero_blocks = 0usize;
|
||||
let mut total_blocks = 0usize;
|
||||
let mut pos = scan_start;
|
||||
while pos + BLOCK <= scan_end {
|
||||
total_blocks += 1;
|
||||
if data[pos..pos + BLOCK].iter().all(|&b| b == 0) {
|
||||
zero_blocks += 1;
|
||||
}
|
||||
pos += BLOCK;
|
||||
}
|
||||
total_blocks > 4 && zero_blocks * 100 / total_blocks >= 10
|
||||
}
|
||||
|
||||
/// Log why Symphonia may reject a buffer (for support / debugging).
|
||||
pub(crate) fn log_isobmff_buffer_diagnostic(data: &[u8], hint: Option<&str>, label: &str) {
|
||||
let prefix_hex: String = data
|
||||
.iter()
|
||||
.take(16)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let looks_json = data.starts_with(b"{") || data.starts_with(b"[");
|
||||
crate::app_eprintln!(
|
||||
"[stream] ISO-BMFF diagnostic ({label}): hint={:?} bytes={} prefix=[{}] json_like={} ftyp={} moov={} zero_holes={}",
|
||||
hint,
|
||||
data.len(),
|
||||
prefix_hex,
|
||||
looks_json,
|
||||
mp4_has_ftyp_atom(data),
|
||||
mp4_has_moov_atom(data),
|
||||
mp4_suspect_zero_holes(data),
|
||||
);
|
||||
}
|
||||
|
||||
fn read_mp4_atom_size(data: &[u8], pos: usize) -> Option<u64> {
|
||||
if pos + 8 > data.len() {
|
||||
return None;
|
||||
}
|
||||
let size32 = u32::from_be_bytes(data[pos..pos + 4].try_into().ok()?) as u64;
|
||||
if size32 == 1 {
|
||||
if pos + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(u64::from_be_bytes(data[pos + 8..pos + 16].try_into().ok()?))
|
||||
} else {
|
||||
Some(size32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atom(typ: &[u8; 4], payload_len: usize) -> Vec<u8> {
|
||||
let size = (8 + payload_len) as u32;
|
||||
let mut v = Vec::with_capacity(8 + payload_len);
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(typ);
|
||||
v.resize(8 + payload_len, 0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_after_mdat_detected() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 100));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
assert!(mp4_moov_follows_mdat(&buf));
|
||||
assert!(mp4_needs_tail_prefetch(&buf, Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_before_mdat_no_tail_prefetch() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
buf.extend(atom(b"mdat", 100));
|
||||
assert!(!mp4_moov_follows_mdat(&buf));
|
||||
assert!(!mp4_needs_tail_prefetch(&buf, Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_with_m4a_hint_needs_tail_prefetch() {
|
||||
assert!(mp4_needs_tail_prefetch(&[], Some("m4a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_without_mp4_hint_skips_tail_prefetch() {
|
||||
assert!(!mp4_needs_tail_prefetch(&[], Some("mp3")));
|
||||
assert!(!mp4_needs_tail_prefetch(&[], None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isobmff_complete_detects_ftyp_and_moov() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 200));
|
||||
buf.extend(atom(b"moov", 40));
|
||||
assert!(isobmff_buffer_looks_complete(&buf));
|
||||
assert!(!mp4_suspect_zero_holes(&buf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isobmff_incomplete_without_moov() {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend(atom(b"ftyp", 4));
|
||||
buf.extend(atom(b"mdat", 200));
|
||||
assert!(!isobmff_buffer_looks_complete(&buf));
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
//! Live internet-radio session state and the async HTTP download task.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 'outer loop — reconnect on TCP drop (up to MAX_CONSECUTIVE_FAILURES)
|
||||
//! 'inner loop — read HTTP chunks → ICY interceptor → push audio to ring buffer
|
||||
//!
|
||||
//! Hard-pause detection: if push_slice() returns 0 (buffer full) AND sink is
|
||||
//! paused AND that condition persists for `RADIO_HARD_PAUSE_SECS` → disconnect.
|
||||
//! Sets `is_hard_paused = true` so audio_resume knows it must reconnect.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use ringbuf::HeapCons;
|
||||
use ringbuf::HeapProd;
|
||||
use ringbuf::traits::{Observer, Producer};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::icy::IcyInterceptor;
|
||||
use super::{RADIO_BUF_CAPACITY, RADIO_HARD_PAUSE_SECS};
|
||||
|
||||
pub(crate) struct RadioSharedFlags {
|
||||
/// Set by audio_pause; cleared by audio_resume.
|
||||
pub(crate) is_paused: AtomicBool,
|
||||
/// Set by download task on hard disconnect; cleared on resume-reconnect.
|
||||
pub(crate) is_hard_paused: AtomicBool,
|
||||
/// Delivers a fresh HeapCons<u8> to AudioStreamReader on reconnect.
|
||||
pub(crate) new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapCons<u8>>>,
|
||||
}
|
||||
|
||||
/// Live state for the current radio session, stored in AudioEngine.
|
||||
/// Dropping this struct aborts the HTTP download task immediately.
|
||||
pub(crate) struct RadioLiveState {
|
||||
pub url: String,
|
||||
pub gen: u64,
|
||||
pub task: tokio::task::JoinHandle<()>,
|
||||
pub flags: Arc<RadioSharedFlags>,
|
||||
}
|
||||
|
||||
impl Drop for RadioLiveState {
|
||||
fn drop(&mut self) { self.task.abort(); }
|
||||
}
|
||||
|
||||
/// Pure: extract the `icy-metaint` header value from a HeaderMap. Returns
|
||||
/// `None` when the header is absent, non-ASCII, or doesn't parse as `usize`.
|
||||
pub(crate) fn parse_icy_metaint_from_headers(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
) -> Option<usize> {
|
||||
headers
|
||||
.get("icy-metaint")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
/// Pure: should the radio download task disconnect because the consumer has
|
||||
/// been stuck on a full ring buffer for too long while paused?
|
||||
pub(crate) fn should_hard_pause(
|
||||
is_paused: bool,
|
||||
stall_since: Option<std::time::Instant>,
|
||||
now: std::time::Instant,
|
||||
threshold: Duration,
|
||||
) -> bool {
|
||||
is_paused
|
||||
&& stall_since.is_some_and(|since| now.duration_since(since) >= threshold)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn radio_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
mut initial_response: Option<reqwest::Response>,
|
||||
http_client: reqwest::Client,
|
||||
url: String,
|
||||
mut prod: HeapProd<u8>,
|
||||
flags: Arc<RadioSharedFlags>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let mut bytes_total: u64 = 0;
|
||||
// Counts consecutive failures (reset on each successful chunk).
|
||||
// laut.fm and similar CDNs force-reconnect every ~700 KB; this is normal.
|
||||
let mut reconnect_count: u32 = 0;
|
||||
const MAX_CONSECUTIVE_FAILURES: u32 = 5;
|
||||
let mut audio_scratch: Vec<u8> = Vec::with_capacity(65_536);
|
||||
|
||||
'outer: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Obtain response (initial or reconnect) ────────────────────────────
|
||||
let response = match initial_response.take() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
if reconnect_count >= MAX_CONSECUTIVE_FAILURES {
|
||||
crate::app_eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
match http_client
|
||||
.get(&url)
|
||||
.header("Icy-MetaData", "1")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
crate::app_eprintln!("[radio] reconnected ({bytes_total} B so far)");
|
||||
r
|
||||
}
|
||||
Ok(r) => {
|
||||
crate::app_eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
|
||||
break 'outer;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[radio] reconnect error: {e} — giving up");
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse ICY metaint from each response (consistent across reconnects).
|
||||
let metaint = parse_icy_metaint_from_headers(response.headers());
|
||||
let mut icy = metaint.map(IcyInterceptor::new);
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
// Stall timer: tracks how long push_slice() returns 0 while paused.
|
||||
let mut stall_since: Option<std::time::Instant> = None;
|
||||
|
||||
'inner: loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
|
||||
// ── Back-pressure + hard-pause detection ──────────────────────────
|
||||
if prod.is_full() {
|
||||
let now = std::time::Instant::now();
|
||||
let is_paused = flags.is_paused.load(Ordering::Relaxed);
|
||||
if is_paused {
|
||||
stall_since.get_or_insert(now);
|
||||
} else {
|
||||
stall_since = None;
|
||||
}
|
||||
if should_hard_pause(
|
||||
is_paused,
|
||||
stall_since,
|
||||
now,
|
||||
Duration::from_secs(RADIO_HARD_PAUSE_SECS),
|
||||
) {
|
||||
let fill_pct = ((1.0
|
||||
- prod.vacant_len() as f32 / RADIO_BUF_CAPACITY as f32)
|
||||
* 100.0) as u32;
|
||||
crate::app_eprintln!(
|
||||
"[radio] hard pause: {fill_pct}% full, \
|
||||
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
|
||||
);
|
||||
flags.is_hard_paused.store(true, Ordering::Release);
|
||||
return; // Drop HeapProd → TCP connection released.
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
continue 'inner;
|
||||
}
|
||||
stall_since = None;
|
||||
|
||||
// ── Read HTTP chunk ───────────────────────────────────────────────
|
||||
match byte_stream.next().await {
|
||||
Some(Ok(chunk)) => {
|
||||
bytes_total += chunk.len() as u64;
|
||||
// Successful data → reset consecutive-failure counter.
|
||||
reconnect_count = 0;
|
||||
audio_scratch.clear();
|
||||
|
||||
if let Some(ref mut interceptor) = icy {
|
||||
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
|
||||
let label = if meta.is_ad { "[Ad]" } else { "" };
|
||||
crate::app_eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title);
|
||||
let _ = app.emit("radio:metadata", &meta);
|
||||
}
|
||||
} else {
|
||||
audio_scratch.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Push with per-chunk back-pressure: yield 5 ms if full mid-chunk.
|
||||
let mut offset = 0;
|
||||
while offset < audio_scratch.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen { return; }
|
||||
let pushed = prod.push_slice(&audio_scratch[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
offset += pushed;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
reconnect_count += 1;
|
||||
crate::app_eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
None => {
|
||||
reconnect_count += 1;
|
||||
crate::app_eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})");
|
||||
break 'inner;
|
||||
}
|
||||
}
|
||||
} // 'inner
|
||||
|
||||
// Do NOT swap the ring buffer here. The remaining bytes in the buffer
|
||||
// are still valid audio and will drain naturally during reconnect.
|
||||
// Clearing it would cause an immediate underrun/glitch.
|
||||
// The buffer is kept small (RADIO_BUF_CAPACITY) so stale audio drains
|
||||
// within a few seconds rather than minutes.
|
||||
} // 'outer
|
||||
|
||||
crate::app_eprintln!("[radio] download task done ({bytes_total} B total)");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_icy_metaint_from_headers ────────────────────────────────────────
|
||||
|
||||
fn make_headers(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
|
||||
let mut h = reqwest::header::HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
reqwest::header::HeaderValue::from_str(v).unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_parses_valid_integer() {
|
||||
let h = make_headers(&[("icy-metaint", "16384")]);
|
||||
assert_eq!(parse_icy_metaint_from_headers(&h), Some(16384));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_returns_none_when_header_absent() {
|
||||
let h = make_headers(&[]);
|
||||
assert!(parse_icy_metaint_from_headers(&h).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_returns_none_for_non_numeric_value() {
|
||||
let h = make_headers(&[("icy-metaint", "not-a-number")]);
|
||||
assert!(parse_icy_metaint_from_headers(&h).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icy_metaint_returns_none_for_empty_string() {
|
||||
let h = make_headers(&[("icy-metaint", "")]);
|
||||
assert!(parse_icy_metaint_from_headers(&h).is_none());
|
||||
}
|
||||
|
||||
// ── should_hard_pause ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn hard_pause_false_when_not_paused() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled = now - Duration::from_secs(60);
|
||||
// Not paused → never disconnect even after long stalls.
|
||||
assert!(!should_hard_pause(false, Some(stalled), now, Duration::from_secs(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_false_when_no_stall_recorded() {
|
||||
let now = std::time::Instant::now();
|
||||
// No stall recorded → no disconnect even when paused.
|
||||
assert!(!should_hard_pause(true, None, now, Duration::from_secs(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_false_when_stall_below_threshold() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled_recent = now - Duration::from_secs(2);
|
||||
assert!(!should_hard_pause(
|
||||
true,
|
||||
Some(stalled_recent),
|
||||
now,
|
||||
Duration::from_secs(5)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_true_when_paused_and_stall_at_or_past_threshold() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled_long = now - Duration::from_secs(10);
|
||||
assert!(should_hard_pause(
|
||||
true,
|
||||
Some(stalled_long),
|
||||
now,
|
||||
Duration::from_secs(5)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pause_inclusive_at_exact_threshold() {
|
||||
let now = std::time::Instant::now();
|
||||
let stalled_exact = now - Duration::from_secs(5);
|
||||
// `>= threshold` — exactly at threshold counts.
|
||||
assert!(should_hard_pause(
|
||||
true,
|
||||
Some(stalled_exact),
|
||||
now,
|
||||
Duration::from_secs(5)
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
//! `AudioStreamReader` — bridges an SPSC ring buffer (`HeapCons<u8>`) into the
|
||||
//! synchronous `std::io::Read` interface that Symphonia requires.
|
||||
//!
|
||||
//! Designed to run inside `tokio::task::spawn_blocking`.
|
||||
//!
|
||||
//! - Empty buffer: sleeps `RADIO_YIELD_MS` ms, retries. Never busy-spins.
|
||||
//! - Timeout: after `RADIO_READ_TIMEOUT_SECS` with no data → `TimedOut`.
|
||||
//! - Generation: if `gen_arc` != `self.gen` → `Ok(0)` (EOF; new track started).
|
||||
//! - Reconnect: `audio_resume` sends a fresh `HeapCons` via `new_cons_rx`.
|
||||
//! On the next read() we drain the channel (keep latest) and swap.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ringbuf::HeapCons;
|
||||
use ringbuf::traits::{Consumer, Observer};
|
||||
use symphonia::core::io::MediaSource;
|
||||
|
||||
use super::{RADIO_YIELD_MS};
|
||||
|
||||
pub(crate) struct AudioStreamReader {
|
||||
pub(crate) read_timeout_secs: u64,
|
||||
pub(crate) cons: Mutex<HeapCons<u8>>,
|
||||
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
|
||||
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
|
||||
/// No real contention: only the audio thread ever calls read().
|
||||
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapCons<u8>>>,
|
||||
pub(crate) deadline: std::time::Instant,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
/// Diagnostic tag for logs ("radio" or "track-stream").
|
||||
pub(crate) source_tag: &'static str,
|
||||
/// Optional completion marker: when true and the ring buffer is empty,
|
||||
/// return EOF immediately (used by one-shot track streaming).
|
||||
pub(crate) eof_when_empty: Option<Arc<AtomicBool>>,
|
||||
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
||||
pub(crate) pos: u64,
|
||||
}
|
||||
|
||||
impl Read for AudioStreamReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
// EOF guard: new track started.
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
// Drain reconnect channel; keep only the most recently delivered consumer
|
||||
// so a double-tap of resume doesn't leave stale data in place.
|
||||
let mut newest: Option<HeapCons<u8>> = None;
|
||||
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
||||
newest = Some(c);
|
||||
}
|
||||
if let Some(c) = newest {
|
||||
*self.cons.lock().unwrap() = c;
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
||||
}
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
return Ok(0);
|
||||
}
|
||||
let available = self.cons.lock().unwrap().occupied_len();
|
||||
if available > 0 {
|
||||
let n = buf.len().min(available);
|
||||
let read = self.cons.lock().unwrap().pop_slice(&mut buf[..n]);
|
||||
self.pos += read as u64;
|
||||
// Reset deadline: data arrived, so connection is alive.
|
||||
self.deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
||||
return Ok(read);
|
||||
}
|
||||
if self
|
||||
.eof_when_empty
|
||||
.as_ref()
|
||||
.is_some_and(|done| done.load(Ordering::SeqCst))
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
if std::time::Instant::now() >= self.deadline {
|
||||
crate::app_eprintln!(
|
||||
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||
self.source_tag,
|
||||
self.read_timeout_secs
|
||||
);
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("{}: no data received", self.source_tag),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for AudioStreamReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
match pos {
|
||||
SeekFrom::Current(0) => Ok(self.pos),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!("{} stream is not seekable", self.source_tag),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for AudioStreamReader {
|
||||
fn is_seekable(&self) -> bool { false }
|
||||
fn byte_len(&self) -> Option<u64> { None }
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! One-shot HTTP downloader for non-ranged track streaming.
|
||||
//!
|
||||
//! Pushes response chunks into an SPSC ring buffer consumed by `AudioStreamReader`.
|
||||
//! Terminates when:
|
||||
//! - generation changes (track superseded),
|
||||
//! - response stream ends, or
|
||||
//! - response emits an error.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use ringbuf::HeapProd;
|
||||
use ringbuf::traits::Producer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn track_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
mut prod: HeapProd<u8>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_engine: Arc<AtomicU32>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||
server_id: Option<String>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let mut capture: Vec<u8> = Vec::new();
|
||||
let mut capture_over_limit = false;
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
r
|
||||
} else {
|
||||
let mut req = http_client.get(&url);
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming reconnect failed after {} attempts: {}",
|
||||
reconnects, err
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
};
|
||||
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
||||
response.status()
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if downloaded == 0 && !response.status().is_success() {
|
||||
crate::app_eprintln!("[audio] streaming HTTP {}", response.status());
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
crate::app_deprintln!(
|
||||
"[stream] track-stream dl superseded by skip: track_id={:?} gen={}→{}",
|
||||
cache_track_id, gen, gen_arc.load(Ordering::SeqCst)
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming download error after {} reconnects: {}",
|
||||
reconnects, e
|
||||
);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
reconnects += 1;
|
||||
crate::app_eprintln!(
|
||||
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
||||
reconnects,
|
||||
TRACK_STREAM_MAX_RECONNECTS,
|
||||
e
|
||||
);
|
||||
next_response = None;
|
||||
continue 'outer;
|
||||
}
|
||||
};
|
||||
reconnects = 0;
|
||||
let mut offset = 0;
|
||||
while offset < chunk.len() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let pushed = prod.push_slice(&chunk[offset..]);
|
||||
if pushed == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
} else {
|
||||
if !capture_over_limit {
|
||||
if capture.len().saturating_add(pushed) <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
let from = offset;
|
||||
let to = offset + pushed;
|
||||
capture.extend_from_slice(&chunk[from..to]);
|
||||
} else {
|
||||
capture.clear();
|
||||
capture_over_limit = true;
|
||||
}
|
||||
}
|
||||
if !capture_over_limit
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(crate::helpers::PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
if normalization_engine.load(Ordering::Relaxed) == 2 {
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_db = f32::from_bits(
|
||||
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0);
|
||||
crate::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
|
||||
}
|
||||
}
|
||||
offset += pushed;
|
||||
downloaded += pushed as u64;
|
||||
maybe_arm_stream_playback(downloaded, &playback_armed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !capture_over_limit && !capture.is_empty() {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
if let Some(track_id) = cache_track_id {
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)",
|
||||
track_id,
|
||||
capture.len() as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
let sid = crate::analysis_dispatch::resolve_server_id_for_app(
|
||||
&app,
|
||||
server_id.as_deref(),
|
||||
);
|
||||
let priority = crate::analysis_dispatch::analysis_priority_for_app(&app, &sid, &track_id, None);
|
||||
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
|
||||
&app,
|
||||
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
&sid,
|
||||
&track_id,
|
||||
capture.clone(),
|
||||
priority,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
|
||||
}
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: capture,
|
||||
});
|
||||
}
|
||||
playback_armed.store(true, Ordering::SeqCst);
|
||||
done.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
//! Transport-control Tauri commands: pause / resume / stop / seek.
|
||||
//! These don't drive playback startup — they mutate state on an already-running
|
||||
//! sink (or coordinate radio reconnect for cold-resume).
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, TryLockError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ringbuf::traits::Split;
|
||||
use ringbuf::HeapRb;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::playback_rate::{
|
||||
content_position_from_samples, raw_counter_samples_for_content_position,
|
||||
};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if !sink.is_paused() {
|
||||
let pos = content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
)
|
||||
.min(cur.duration_secs.max(0.001));
|
||||
sink.pause();
|
||||
cur.paused_at = Some(pos);
|
||||
cur.play_started = None;
|
||||
}
|
||||
}
|
||||
// Notify the download task so it can start measuring the hard-pause stall timer.
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume playback.
|
||||
///
|
||||
/// **Warm resume** (`is_hard_paused = false`): download task is still running,
|
||||
/// buffer has buffered audio. `sink.play()` suffices.
|
||||
///
|
||||
/// **Cold resume** (`is_hard_paused = true`): TCP was dropped. A fresh 4 MB
|
||||
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
|
||||
/// swaps it in on the next `read()`), and a new download task is spawned.
|
||||
#[tauri::command]
|
||||
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
|
||||
// If a preview is running, cancel it first — otherwise sink.play() on the
|
||||
// main sink would mix on top of the preview sink.
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
|
||||
// Detect radio hard-disconnect.
|
||||
let reconnect_info = {
|
||||
let guard = state.radio_state.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|rs| rs.flags.is_hard_paused.load(Ordering::Acquire))
|
||||
.map(|rs| (rs.url.clone(), rs.gen, rs.flags.clone()))
|
||||
};
|
||||
|
||||
if let Some((url, gen, flags)) = reconnect_info {
|
||||
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
|
||||
let (new_prod, new_cons) = rb.split();
|
||||
|
||||
// Send new consumer to AudioStreamReader (non-blocking; unbounded channel).
|
||||
let ok = flags.new_cons_tx.lock().unwrap().send(new_cons).is_ok();
|
||||
|
||||
if ok {
|
||||
let new_task = tokio::spawn(radio_download_task(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
None, // task performs its own fresh GET
|
||||
audio_http_client(&state),
|
||||
url,
|
||||
new_prod,
|
||||
flags.clone(),
|
||||
app,
|
||||
));
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_mut() {
|
||||
let old = std::mem::replace(&mut rs.task, new_task);
|
||||
old.abort(); // ensure any lingering old task is gone
|
||||
rs.flags.is_hard_paused.store(false, Ordering::Release);
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
// Resume the rodio Sink (works for both warm and cold resume).
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.is_paused() {
|
||||
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
|
||||
sink.play();
|
||||
cur.seek_offset = pos;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
|
||||
rs.flags.is_paused.store(false, Ordering::Release);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.current_playback_url.lock().unwrap() = None;
|
||||
*state.current_analysis_track_id.lock().unwrap() = None;
|
||||
*state.current_playback_server_id.lock().unwrap() = None;
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
// Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the
|
||||
// queue is exhausted; clearing here dropped the full ranged buffer and forced a
|
||||
// re-download on replay. The slot is only consumed on `take`/overwrite for another URL.
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
drop(state.radio_state.lock().unwrap().take());
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
cur.duration_secs = 0.0;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
let state = state.inner();
|
||||
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
|
||||
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
|
||||
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
|
||||
{
|
||||
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
|
||||
if switch_ms > 0 {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
if now_ms.saturating_sub(switch_ms) < 500 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reject seek up-front for non-seekable streaming sources so the frontend's
|
||||
// restart-fallback engages instead of rolling the dice on the format reader
|
||||
// (which can consume the ring buffer to EOF for forward seeks → next song).
|
||||
if !state.current_is_seekable.load(Ordering::SeqCst) {
|
||||
crate::app_deprintln!("[seek] rejected → not-seekable source (legacy stream)");
|
||||
return Err("source is not seekable".into());
|
||||
}
|
||||
crate::app_deprintln!("[seek] target={:.2}s", seconds);
|
||||
|
||||
let lock_current_with_timeout = |timeout_ms: u64| {
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
match state.current.try_lock() {
|
||||
Ok(guard) => break Ok(guard),
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
if Instant::now() >= deadline {
|
||||
break Err("audio seek busy".to_string());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(TryLockError::Poisoned(_)) => {
|
||||
break Err("audio state lock poisoned".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Seeking back invalidates any pending gapless chain.
|
||||
let cur_pos = content_position_from_samples(
|
||||
state.samples_played.load(Ordering::Relaxed),
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
);
|
||||
if seconds < cur_pos - 1.0 {
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
let seek_seconds = seconds.max(0.0);
|
||||
let seek_duration = Duration::from_secs_f64(seek_seconds);
|
||||
let seek_generation = state.generation.load(Ordering::SeqCst);
|
||||
let sink = {
|
||||
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
match cur.sink.as_ref() {
|
||||
Some(sink) => Arc::clone(sink),
|
||||
None => return Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = sink.try_seek(seek_duration).map_err(|e| e.to_string());
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(AUDIO_SEEK_TIMEOUT_MS)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
return Err("audio seek timeout".into());
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err("audio seek worker disconnected".into());
|
||||
}
|
||||
}
|
||||
|
||||
// If playback switched while seek was in flight, skip timestamp updates.
|
||||
if state.generation.load(Ordering::SeqCst) != seek_generation {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
|
||||
if cur.sink.is_none() { return Ok(()); }
|
||||
|
||||
if cur.paused_at.is_some() {
|
||||
cur.paused_at = Some(seek_seconds);
|
||||
} else {
|
||||
cur.seek_offset = seek_seconds;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
seek_seconds,
|
||||
state.current_sample_rate.load(Ordering::Relaxed),
|
||||
state.current_channels.load(Ordering::Relaxed),
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -1,273 +0,0 @@
|
||||
//! Cover disk cache layout — **single place** to change directory naming.
|
||||
//!
|
||||
//! Callers pass `cache_kind` (`album` | `artist`) and `cache_entity_id` (server ids:
|
||||
//! Navidrome `album.id` is often a bare hash/snowflake; `coverArt` may use `al-*`.
|
||||
//! Rarely `mf-*` / `dc-*` on disk when UI enables per-disc art. Path shape:
|
||||
//!
|
||||
//! `{root}/{server_segment}/{kind}/{entity_id}/128.webp`
|
||||
//!
|
||||
//! `server_segment` is derived from the frontend's `serverIndexKeyFromUrl` (host + path,
|
||||
//! no scheme). On Windows that key would otherwise drop a `:` straight into the filesystem
|
||||
//! whenever the user runs Navidrome on a `:port` URL — `CreateDirectory` then rejects the
|
||||
//! whole path with `ERROR_INVALID_NAME`. [`cover_server_dir`] sanitizes the key before it
|
||||
//! hits disk; every caller that wants a server-scoped cover directory goes through it.
|
||||
//!
|
||||
//! Bump [`LAYOUT_STAMP`] when the on-disk format changes (app wipes legacy dirs on startup).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
|
||||
|
||||
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
|
||||
pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
let id = id.trim();
|
||||
id.starts_with("mf-")
|
||||
|| id.starts_with("tr-")
|
||||
|| id.starts_with("pl-")
|
||||
|| id.starts_with("dc-")
|
||||
|| id.starts_with("ra-")
|
||||
}
|
||||
|
||||
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
|
||||
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let trimmed = segment.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
pub fn cover_entity_relative_dir(cache_kind: &str, cache_entity_id: &str) -> PathBuf {
|
||||
let kind = sanitize_path_segment(cache_kind);
|
||||
let entity = sanitize_path_segment(cache_entity_id);
|
||||
PathBuf::from(kind).join(entity)
|
||||
}
|
||||
|
||||
/// Per-server cache root (`{root}/{server_segment}/`). Sanitizes the index key so
|
||||
/// `host:port` and embedded URL paths survive on Windows. Every caller that wants the
|
||||
/// server bucket — list/count/clear/backfill — must go through this helper.
|
||||
pub fn cover_server_dir(root: &Path, server_index_key: &str) -> PathBuf {
|
||||
root.join(sanitize_path_segment(server_index_key))
|
||||
}
|
||||
|
||||
/// Absolute directory for one cover entity (`…/album/al-…/` or `…/artist/ar-…/`).
|
||||
pub fn cover_dir(
|
||||
root: &Path,
|
||||
server_index_key: &str,
|
||||
cache_kind: &str,
|
||||
cache_entity_id: &str,
|
||||
) -> PathBuf {
|
||||
cover_server_dir(root, server_index_key)
|
||||
.join(cover_entity_relative_dir(cache_kind, cache_entity_id))
|
||||
}
|
||||
|
||||
/// Resolved cover identity — keep in sync with TS `src/cover/resolveEntry.ts`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CoverEntry {
|
||||
pub cache_kind: &'static str,
|
||||
pub cache_entity_id: String,
|
||||
pub fetch_cover_art_id: String,
|
||||
}
|
||||
|
||||
/// Album — one disk slot per album; per-disc ids only when `distinct_disc_covers`.
|
||||
pub fn resolve_album_cover(
|
||||
album_id: &str,
|
||||
cover_art_id: Option<&str>,
|
||||
distinct_disc_covers: bool,
|
||||
) -> Option<CoverEntry> {
|
||||
let album = album_id.trim();
|
||||
if album.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fetch = cover_art_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(album);
|
||||
let cache_entity_id = if distinct_disc_covers && fetch != album {
|
||||
fetch.to_string()
|
||||
} else {
|
||||
album.to_string()
|
||||
};
|
||||
Some(CoverEntry {
|
||||
cache_kind: "album",
|
||||
cache_entity_id,
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Segment roots under `{server_index_key}/` (canonical layout).
|
||||
pub const SEGMENT_KINDS: [&str; 2] = ["album", "artist"];
|
||||
|
||||
/// Progress / backfill “done” heuristic — matches `LIBRARY_COVER_CANONICAL_TIER` in the library crate.
|
||||
pub const CANONICAL_PROGRESS_TIER: u32 = 800;
|
||||
|
||||
fn tier_webp_ready(path: &Path) -> bool {
|
||||
path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// True when `{entity_dir}/{CANONICAL_PROGRESS_TIER}.webp` exists and is non-empty.
|
||||
pub fn entity_dir_has_canonical_tier(entity_dir: &Path) -> bool {
|
||||
tier_webp_ready(&entity_dir.join(format!("{CANONICAL_PROGRESS_TIER}.webp")))
|
||||
}
|
||||
|
||||
/// Distinct album/artist entity dirs with canonical tier (segment layout only).
|
||||
pub fn count_entities_with_canonical_tier(server_dir: &Path) -> i64 {
|
||||
let mut n = 0i64;
|
||||
for kind in SEGMENT_KINDS {
|
||||
let kind_dir = server_dir.join(kind);
|
||||
let Ok(entries) = std::fs::read_dir(&kind_dir) else {
|
||||
continue;
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
if ent.path().is_dir() && entity_dir_has_canonical_tier(&ent.path()) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn sum_webp_bytes_rec(dir: &Path) -> u64 {
|
||||
let mut bytes = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return bytes;
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
let p = ent.path();
|
||||
if p.is_dir() {
|
||||
bytes += sum_webp_bytes_rec(&p);
|
||||
} else if p.extension().and_then(|s| s.to_str()) == Some("webp") {
|
||||
if let Ok(meta) = ent.metadata() {
|
||||
bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// All `.webp` bytes under one server bucket + entity count (canonical tier, segment dirs).
|
||||
pub fn server_cover_disk_usage(server_dir: &Path) -> (u64, u64) {
|
||||
(
|
||||
sum_webp_bytes_rec(server_dir),
|
||||
count_entities_with_canonical_tier(server_dir) as u64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sum usage across every server subdirectory under `cover_root`.
|
||||
pub fn cover_root_disk_usage(cover_root: &Path) -> (u64, u64) {
|
||||
let mut bytes = 0u64;
|
||||
let mut count = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(cover_root) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
let fname = ent.file_name();
|
||||
let name = fname.to_string_lossy();
|
||||
if name == ".storage-layout" || !ent.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let (b, c) = server_cover_disk_usage(&ent.path());
|
||||
bytes += b;
|
||||
count += c;
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
/// Artist — one disk slot per artist id.
|
||||
pub fn resolve_artist_cover(artist_id: &str, cover_art_id: Option<&str>) -> Option<CoverEntry> {
|
||||
let artist = artist_id.trim();
|
||||
if artist.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fetch = cover_art_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(artist);
|
||||
Some(CoverEntry {
|
||||
cache_kind: "artist",
|
||||
cache_entity_id: artist.to_string(),
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_paths_use_kind_and_entity_id() {
|
||||
let root = Path::new("/tmp/cover");
|
||||
let dir = cover_dir(root, "srv", "album", "al-1");
|
||||
assert_eq!(dir, root.join("srv").join("album").join("al-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_segment_sanitizes_port_colon_and_url_path() {
|
||||
let root = Path::new("/tmp/cover");
|
||||
// Typical LAN URL key from `serverIndexKeyFromUrl`: `host:port/path`.
|
||||
// The `:` is invalid on Windows; the `/` would otherwise create a
|
||||
// nested directory rather than one bucket per server.
|
||||
let dir = cover_server_dir(root, "192.168.1.10:4533/music");
|
||||
assert_eq!(dir, root.join("192.168.1.10_4533_music"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_dir_passes_server_key_through_sanitizer() {
|
||||
let root = Path::new("/tmp/cover");
|
||||
let dir = cover_dir(root, "host:4533", "album", "al-1");
|
||||
assert_eq!(dir, root.join("host_4533").join("album").join("al-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_and_artist_segments_differ() {
|
||||
let al = cover_entity_relative_dir("album", "al-1");
|
||||
let ar = cover_entity_relative_dir("artist", "ar-1");
|
||||
assert_ne!(al, ar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_disc_mf_entity_gets_own_dir() {
|
||||
let d = cover_entity_relative_dir("album", "mf-disc2_abc");
|
||||
assert_eq!(d, PathBuf::from("album").join("mf-disc2_abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_bare_navidrome_id() {
|
||||
let e = resolve_album_cover("0DurV2S7arIOBQVEknOPWX", Some("al-0Dur_abc"), false).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "0DurV2S7arIOBQVEknOPWX");
|
||||
assert_eq!(e.fetch_cover_art_id, "al-0Dur_abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_per_disc_changes_cache_entity() {
|
||||
let e = resolve_album_cover("al-box", Some("mf-d2"), true).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "mf-d2");
|
||||
}
|
||||
|
||||
fn test_server_dir(label: &str) -> std::path::PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_disk_usage_counts_canonical_only() {
|
||||
let server = test_server_dir("usage");
|
||||
let entity = server.join("album").join("al-1");
|
||||
std::fs::create_dir_all(&entity).unwrap();
|
||||
std::fs::write(entity.join("128.webp"), b"x").unwrap();
|
||||
assert_eq!(count_entities_with_canonical_tier(&server), 0);
|
||||
std::fs::write(entity.join("800.webp"), b"yy").unwrap();
|
||||
assert_eq!(count_entities_with_canonical_tier(&server), 1);
|
||||
let (bytes, count) = server_cover_disk_usage(&server);
|
||||
assert_eq!(count, 1);
|
||||
assert!(bytes >= 3);
|
||||
let _ = std::fs::remove_dir_all(&server);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! `psysonic-core` — workspace-internal shared primitives.
|
||||
//!
|
||||
//! Hosts the runtime logging facade (with `app_eprintln!` / `app_deprintln!`
|
||||
//! macros) and the cross-crate port traits used to break dependency cycles
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod cover_cache_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod track_analysis;
|
||||
pub mod track_enrichment;
|
||||
pub mod user_agent;
|
||||
@@ -1,136 +0,0 @@
|
||||
//! Cross-crate port handles.
|
||||
//!
|
||||
//! Exists to break the one back-edge in the audio↔analysis dependency:
|
||||
//! `psysonic-analysis` needs to ask "is this track currently playing?", but
|
||||
//! must not depend on `psysonic-audio` (which has the real dep on analysis,
|
||||
//! not the other way around).
|
||||
//!
|
||||
//! Implementation note: ports are exposed as **closure handles** rather than
|
||||
//! `Arc<dyn Trait>` — this avoids forcing every existing `State<AudioEngine>`
|
||||
//! callsite to switch to `State<Arc<AudioEngine>>` (which Tauri State requires
|
||||
//! for trait-object registration). The shell crate creates the handle by
|
||||
//! capturing an `AppHandle` and looking up the audio engine at call time.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Read-only queries about the live playback session, used by analysis-side
|
||||
/// code to break the analysis→audio back-edge. The shell crate constructs an
|
||||
/// instance with two closures (each capturing an `AppHandle`) and registers it
|
||||
/// as Tauri State; `psysonic-analysis` looks it up via `try_state::<…>()`.
|
||||
///
|
||||
/// The closures are independent so each can be a no-op / always-false fallback
|
||||
/// without coupling the other.
|
||||
#[derive(Clone)]
|
||||
pub struct PlaybackQueryHandle {
|
||||
is_playing: Arc<dyn Fn(&str) -> bool + Send + Sync + 'static>,
|
||||
should_defer_backfill: Arc<dyn Fn(&str) -> bool + Send + Sync + 'static>,
|
||||
}
|
||||
|
||||
impl PlaybackQueryHandle {
|
||||
pub fn new<P, D>(is_playing: P, should_defer_backfill: D) -> Self
|
||||
where
|
||||
P: Fn(&str) -> bool + Send + Sync + 'static,
|
||||
D: Fn(&str) -> bool + Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
is_playing: Arc::new(is_playing),
|
||||
should_defer_backfill: Arc::new(should_defer_backfill),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if `track_id` is the track currently being decoded/played.
|
||||
pub fn is_track_currently_playing(&self, track_id: &str) -> bool {
|
||||
(self.is_playing)(track_id)
|
||||
}
|
||||
|
||||
/// `true` if a ranged HTTP playback for `track_id` is mid-flight and will
|
||||
/// seed analysis on completion — the backfill enqueue should defer.
|
||||
pub fn ranged_loudness_backfill_should_defer(&self, track_id: &str) -> bool {
|
||||
(self.should_defer_backfill)(track_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge for the analysis→library back-edge (E2 content_hash): when the
|
||||
/// analysis pipeline has the playback-derived `md5_16kb` for a track, it records
|
||||
/// it as `track.content_hash` in the library DB. `psysonic-analysis` must not
|
||||
/// depend on `psysonic-library`, so the shell crate registers a closure that
|
||||
/// captures an `AppHandle` and patches the library; analysis looks this handle
|
||||
/// up via `try_state::<…>()` and fires it after a successful seed.
|
||||
///
|
||||
/// The patch is a no-op when the library has no row for `(server_id, track_id)`
|
||||
/// (index off for that server), so the sink is safe to call unconditionally.
|
||||
type RecordContentHashFn = Arc<dyn Fn(&str, &str, &str) + Send + Sync + 'static>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ContentHashSink {
|
||||
record: RecordContentHashFn,
|
||||
}
|
||||
|
||||
impl ContentHashSink {
|
||||
pub fn new<F>(record: F) -> Self
|
||||
where
|
||||
F: Fn(&str, &str, &str) + Send + Sync + 'static,
|
||||
{
|
||||
Self { record: Arc::new(record) }
|
||||
}
|
||||
|
||||
/// Record `md5_16kb` as the library `content_hash` for `(server_id, track_id)`.
|
||||
/// Best-effort: the registered closure swallows errors and no-ops when the
|
||||
/// library has no matching row.
|
||||
pub fn record_content_hash(&self, server_id: &str, track_id: &str, md5_16kb: &str) {
|
||||
(self.record)(server_id, track_id, md5_16kb)
|
||||
}
|
||||
}
|
||||
|
||||
/// Library→analysis readiness probe (E3 enrichment): given `(server_id,
|
||||
/// track_id, md5_16kb)`, returns `(waveform_ready, loudness_ready)` from the
|
||||
/// analysis cache. `psysonic-library` must not depend on `psysonic-analysis`, so
|
||||
/// the shell crate registers a closure that captures an `AppHandle`, looks up the
|
||||
/// `AnalysisCache`, and probes the exact key with a legacy `''` fallback —
|
||||
/// **read-only, no lazy re-tag**. Library looks this handle up via
|
||||
/// `try_state::<…>()`; absent handle ⇒ `(false, false)`.
|
||||
type QueryReadinessFn = Arc<dyn Fn(&str, &str, &str) -> (bool, bool) + Send + Sync + 'static>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnalysisReadinessQuery {
|
||||
query: QueryReadinessFn,
|
||||
}
|
||||
|
||||
impl AnalysisReadinessQuery {
|
||||
pub fn new<F>(query: F) -> Self
|
||||
where
|
||||
F: Fn(&str, &str, &str) -> (bool, bool) + Send + Sync + 'static,
|
||||
{
|
||||
Self { query: Arc::new(query) }
|
||||
}
|
||||
|
||||
/// `(waveform_ready, loudness_ready)` for `(server_id, track_id, md5_16kb)`.
|
||||
pub fn readiness(&self, server_id: &str, track_id: &str, md5_16kb: &str) -> (bool, bool) {
|
||||
(self.query)(server_id, track_id, md5_16kb)
|
||||
}
|
||||
}
|
||||
|
||||
type NeedsWorkFn = Arc<dyn Fn(&str, &str) -> Result<bool, String> + Send + Sync + 'static>;
|
||||
|
||||
/// Library→analysis plan probe: does `(server_id, track_id)` still need waveform,
|
||||
/// loudness, or enrichment work? Wired in the shell crate so `psysonic-library`
|
||||
/// can batch-scan without depending on `psysonic-analysis`.
|
||||
#[derive(Clone)]
|
||||
pub struct TrackAnalysisNeedsWorkQuery {
|
||||
query: NeedsWorkFn,
|
||||
}
|
||||
|
||||
impl TrackAnalysisNeedsWorkQuery {
|
||||
pub fn new<F>(query: F) -> Self
|
||||
where
|
||||
F: Fn(&str, &str) -> Result<bool, String> + Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
query: Arc::new(query),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_work(&self, server_id: &str, track_id: &str) -> Result<bool, String> {
|
||||
(self.query)(server_id, track_id)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! Unified client-side track analysis plan (waveform / LUFS / enrichment facts).
|
||||
//!
|
||||
//! Planning logic lives in `psysonic-analysis::track_analysis_plan`; this module
|
||||
//! holds the shared outcome type so future analysis modes can extend the plan
|
||||
//! without pulling analysis-cache types into every crate.
|
||||
|
||||
use crate::track_enrichment::TrackEnrichmentPlan;
|
||||
|
||||
/// What still needs to be computed for a track at the current content fingerprint.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TrackAnalysisPlan {
|
||||
/// Waveform bins missing for `(server_id, track_id)` at the current algo version.
|
||||
pub need_waveform: bool,
|
||||
/// Integrated LUFS / true-peak row missing.
|
||||
pub need_loudness: bool,
|
||||
/// Oximedia BPM + mood facts (`track_fact` via library enrichment port).
|
||||
pub enrichment: TrackEnrichmentPlan,
|
||||
}
|
||||
|
||||
impl TrackAnalysisPlan {
|
||||
pub fn any(self) -> bool {
|
||||
self.need_waveform || self.need_loudness || self.enrichment.any()
|
||||
}
|
||||
|
||||
/// Symphonia full-file decode (waveform and/or EBU R128 loudness).
|
||||
pub fn needs_full_cpu_seed(self) -> bool {
|
||||
self.need_waveform || self.need_loudness
|
||||
}
|
||||
|
||||
/// Oximedia 60 s center window only — waveform + loudness already cached.
|
||||
pub fn needs_enrichment_only(self) -> bool {
|
||||
!self.needs_full_cpu_seed() && self.enrichment.any()
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! Shared types for client-side track enrichment (oximedia BPM / mood).
|
||||
|
||||
/// Which analysis facts still need to be computed for the current content hash.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TrackEnrichmentPlan {
|
||||
pub need_bpm: bool,
|
||||
pub need_valence: bool,
|
||||
pub need_arousal: bool,
|
||||
/// Raw oximedia mood scores JSON (`{"calm":0.4,...}`).
|
||||
pub need_moods: bool,
|
||||
}
|
||||
|
||||
impl TrackEnrichmentPlan {
|
||||
pub fn any(self) -> bool {
|
||||
self.need_bpm || self.need_valence || self.need_arousal || self.need_moods
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TrackEnrichmentIntFact {
|
||||
pub value: i64,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TrackEnrichmentRealFact {
|
||||
pub value: f64,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Facts produced by oximedia for persistence via the library port.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TrackEnrichmentFacts {
|
||||
pub bpm: Option<TrackEnrichmentIntFact>,
|
||||
pub valence: Option<TrackEnrichmentRealFact>,
|
||||
pub arousal: Option<TrackEnrichmentRealFact>,
|
||||
/// Oximedia `MoodResult.moods` serialized as JSON object (label → score).
|
||||
pub moods: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrackEnrichmentOutcome {
|
||||
Applied,
|
||||
/// Nothing to compute for the current content hash.
|
||||
SkippedComplete,
|
||||
/// Oximedia analysis or persistence failed; facts were not stored (retry on next seed).
|
||||
Failed,
|
||||
SkippedNoServer,
|
||||
SkippedNoPort,
|
||||
}
|
||||
|
||||
type PlanFn = std::sync::Arc<
|
||||
dyn Fn(&str, &str, &str) -> TrackEnrichmentPlan + Send + Sync + 'static,
|
||||
>;
|
||||
type StoreFn = std::sync::Arc<
|
||||
dyn Fn(&str, &str, &str, &TrackEnrichmentFacts) -> Result<(), String> + Send + Sync + 'static,
|
||||
>;
|
||||
|
||||
/// Library↔analysis port: plan missing facts and store computed results without
|
||||
/// pulling `psysonic-library` into `psysonic-analysis`.
|
||||
#[derive(Clone)]
|
||||
pub struct TrackEnrichmentPort {
|
||||
plan: PlanFn,
|
||||
store: StoreFn,
|
||||
}
|
||||
|
||||
impl TrackEnrichmentPort {
|
||||
pub fn new<P, S>(plan: P, store: S) -> Self
|
||||
where
|
||||
P: Fn(&str, &str, &str) -> TrackEnrichmentPlan + Send + Sync + 'static,
|
||||
S: Fn(&str, &str, &str, &TrackEnrichmentFacts) -> Result<(), String>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
{
|
||||
Self {
|
||||
plan: std::sync::Arc::new(plan),
|
||||
store: std::sync::Arc::new(store),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plan(&self, server_id: &str, track_id: &str, content_hash: &str) -> TrackEnrichmentPlan {
|
||||
(self.plan)(server_id, track_id, content_hash)
|
||||
}
|
||||
|
||||
pub fn store(
|
||||
&self,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
facts: &TrackEnrichmentFacts,
|
||||
) -> Result<(), String> {
|
||||
(self.store)(server_id, track_id, content_hash, facts)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Process-global outbound User-Agent for Rust-side HTTP.
|
||||
//!
|
||||
//! Initialised to `psysonic/<version>` (workspace package version) and then
|
||||
//! overridden from the main WebView's `navigator.userAgent` once the frontend
|
||||
//! reports it during startup. Every Rust HTTP client (`reqwest::Client`,
|
||||
//! handcrafted `header::USER_AGENT`) reads the current value via
|
||||
//! [`subsonic_wire_user_agent`] so a single switch keeps server-side
|
||||
//! request-fingerprints consistent.
|
||||
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
pub fn default_subsonic_wire_user_agent() -> String {
|
||||
format!("psysonic/{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
pub fn runtime_subsonic_wire_user_agent() -> &'static RwLock<String> {
|
||||
static UA: OnceLock<RwLock<String>> = OnceLock::new();
|
||||
UA.get_or_init(|| RwLock::new(default_subsonic_wire_user_agent()))
|
||||
}
|
||||
|
||||
pub fn subsonic_wire_user_agent() -> String {
|
||||
runtime_subsonic_wire_user_agent()
|
||||
.read()
|
||||
.map(|ua| ua.clone())
|
||||
.unwrap_or_else(|_| default_subsonic_wire_user_agent())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_user_agent_starts_with_psysonic_slash() {
|
||||
let ua = default_subsonic_wire_user_agent();
|
||||
assert!(ua.starts_with("psysonic/"), "got {ua:?}");
|
||||
assert!(
|
||||
ua.len() > "psysonic/".len(),
|
||||
"version suffix missing: {ua:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_user_agent_returns_default_until_overridden() {
|
||||
let ua = subsonic_wire_user_agent();
|
||||
assert_eq!(ua, default_subsonic_wire_user_agent());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
discord-rich-presence = "1.1"
|
||||
url = "2"
|
||||
md5 = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "time", "sync", "macros", "rt-multi-thread", "test-util"] }
|
||||
wiremock = { workspace = true }
|
||||
@@ -1,919 +0,0 @@
|
||||
//! Discord Rich Presence integration.
|
||||
//!
|
||||
//! Album artwork is fetched from the iTunes Search API and passed directly to
|
||||
//! Discord via the large_image URL field. This avoids the need to pre-upload
|
||||
//! assets to the Discord Developer Portal.
|
||||
//!
|
||||
//! The commands silently no-op when Discord is not running or the App ID is wrong,
|
||||
//! so the app always starts cleanly regardless of Discord availability.
|
||||
|
||||
use discord_rich_presence::{
|
||||
activity::{Activity, ActivityType, Assets, Timestamps},
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
|
||||
pub struct ArtworkCacheEntry {
|
||||
pub url: String,
|
||||
pub fetched_at: Instant,
|
||||
}
|
||||
|
||||
/// TTL: 1 hour — album artwork doesn't change, but we don't want to cache failures forever.
|
||||
const ARTWORK_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||
|
||||
pub struct DiscordState {
|
||||
pub client: Mutex<Option<DiscordIpcClient>>,
|
||||
/// Cache: "artist|album" -> artwork URL. Arc so it can be shared into spawn_blocking.
|
||||
pub artwork_cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>>,
|
||||
/// HTTP client for iTunes API requests. blocking::Client is Clone (Arc-internally).
|
||||
pub http_client: Client,
|
||||
}
|
||||
|
||||
impl DiscordState {
|
||||
pub fn new() -> Self {
|
||||
DiscordState {
|
||||
client: Mutex::new(None),
|
||||
artwork_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
http_client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscordState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── iTunes Search API ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ItunesResponse {
|
||||
results: Vec<ItunesResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ItunesResult {
|
||||
collectionName: Option<String>,
|
||||
artistName: Option<String>,
|
||||
artworkUrl100: Option<String>,
|
||||
}
|
||||
|
||||
/// Normalize string for comparison: lowercase, trim, collapse whitespace.
|
||||
fn normalize(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Search for album artwork via iTunes Search API.
|
||||
/// Returns a higher-resolution URL (600x600) if found.
|
||||
///
|
||||
/// Takes explicit `client` and `cache` so this can be called from inside
|
||||
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
|
||||
/// iTunes Search API endpoint. Lifted to a constant so [`search_itunes_artwork_with_base`]
|
||||
/// can be redirected at a wiremock instance in tests.
|
||||
const ITUNES_SEARCH_URL: &str = "https://itunes.apple.com/search";
|
||||
|
||||
fn search_itunes_artwork(
|
||||
client: &Client,
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
title: &str,
|
||||
) -> Option<String> {
|
||||
search_itunes_artwork_with_base(client, cache, artist, album, title, ITUNES_SEARCH_URL)
|
||||
}
|
||||
|
||||
/// Test-friendly variant of [`search_itunes_artwork`] that takes the search
|
||||
/// endpoint as a parameter. Production calls always go through the wrapper
|
||||
/// above, which pins the iTunes URL.
|
||||
fn search_itunes_artwork_with_base(
|
||||
client: &Client,
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
title: &str,
|
||||
base_url: &str,
|
||||
) -> Option<String> {
|
||||
let cache_key = format!("{}|{}", artist, album);
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let c = cache.lock().ok()?;
|
||||
if let Some(entry) = c.get(&cache_key) {
|
||||
if entry.fetched_at.elapsed() < ARTWORK_CACHE_TTL {
|
||||
return Some(entry.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let norm_artist = normalize(artist);
|
||||
let norm_album = normalize(album);
|
||||
let norm_title = normalize(title);
|
||||
|
||||
// Strategy 1: exact match search — "artist" "album"
|
||||
let mut url = url::Url::parse(base_url).ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "album")
|
||||
.append_pair("limit", "5");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Strategy 2: relaxed search — artist album (no quotes)
|
||||
let mut url = url::Url::parse(base_url).ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("{} {}", artist, album))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "album")
|
||||
.append_pair("limit", "10");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_album) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Strategy 3: search by track title — artist + title (for singles/rare albums)
|
||||
if !title.is_empty() {
|
||||
let mut url = url::Url::parse(base_url).ok()?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("term", &format!("{} {}", artist, title))
|
||||
.append_pair("media", "music")
|
||||
.append_pair("entity", "song")
|
||||
.append_pair("limit", "10");
|
||||
|
||||
if let Some(result) = search_with_url(client, url, &norm_artist, &norm_title) {
|
||||
cache_and_return(cache, cache_key, &result);
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn search_with_url(
|
||||
client: &Client,
|
||||
url: url::Url,
|
||||
norm_artist: &str,
|
||||
norm_album: &str,
|
||||
) -> Option<String> {
|
||||
let resp = client.get(url).send().ok()?;
|
||||
let body: ItunesResponse = resp.json().ok()?;
|
||||
|
||||
for result in &body.results {
|
||||
let collection = normalize(result.collectionName.as_deref().unwrap_or(""));
|
||||
let result_artist = normalize(result.artistName.as_deref().unwrap_or(""));
|
||||
|
||||
// Flexible matching: check if strings contain each other
|
||||
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
|
||||
let artist_match = norm_artist == result_artist
|
||||
|| norm_artist.contains(&result_artist)
|
||||
|| result_artist.contains(norm_artist)
|
||||
|| words_overlap(norm_artist, &result_artist);
|
||||
|
||||
let album_match = norm_album == collection
|
||||
|| norm_album.contains(&collection)
|
||||
|| collection.contains(norm_album)
|
||||
|| words_overlap(norm_album, &collection);
|
||||
|
||||
if artist_match && album_match {
|
||||
return Some(result.artworkUrl100.as_ref()?.replace("100x100", "600x600"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if two strings share at least 50% of their words.
|
||||
fn words_overlap(a: &str, b: &str) -> bool {
|
||||
let words_a: std::collections::HashSet<_> = a.split_whitespace().collect();
|
||||
let words_b: std::collections::HashSet<_> = b.split_whitespace().collect();
|
||||
|
||||
if words_a.is_empty() || words_b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let common = words_a.intersection(&words_b).count();
|
||||
let min_len = words_a.len().min(words_b.len());
|
||||
|
||||
common >= min_len / 2 + min_len % 2 // At least 50% overlap
|
||||
}
|
||||
|
||||
fn cache_and_return(
|
||||
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
|
||||
key: String,
|
||||
url: &str,
|
||||
) {
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(
|
||||
key,
|
||||
ArtworkCacheEntry {
|
||||
url: url.to_string(),
|
||||
fetched_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
|
||||
///
|
||||
/// In debug builds (i.e. `npx tauri dev`) every step of the IPC handshake is
|
||||
/// logged so the renderer's terminal output shows exactly where the
|
||||
/// connection breaks. Release builds stay completely silent.
|
||||
fn try_connect() -> Option<DiscordIpcClient> {
|
||||
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
|
||||
if let Err(_e) = client.connect() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] connect() failed: {} (Discord desktop running?)", _e);
|
||||
return None;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] IPC connected (app_id={})", DISCORD_APP_ID);
|
||||
Some(client)
|
||||
}
|
||||
|
||||
/// Apply a template string, replacing placeholders with actual values.
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>) -> String {
|
||||
let album_text = album.unwrap_or("");
|
||||
template
|
||||
.replace("{title}", title)
|
||||
.replace("{artist}", artist)
|
||||
.replace("{album}", album_text)
|
||||
}
|
||||
|
||||
/// Bundled output of [`compute_discord_text_fields`].
|
||||
pub(crate) struct DiscordTextFields {
|
||||
pub name: String,
|
||||
pub details: String,
|
||||
pub state: String,
|
||||
pub large_text: String,
|
||||
}
|
||||
|
||||
/// Pure helper: resolve all four configurable Discord text fields, applying
|
||||
/// the supplied templates (or falling back to documented defaults).
|
||||
pub(crate) fn compute_discord_text_fields(
|
||||
title: &str,
|
||||
artist: &str,
|
||||
album: Option<&str>,
|
||||
details_template: Option<&str>,
|
||||
state_template: Option<&str>,
|
||||
large_text_template: Option<&str>,
|
||||
name_template: Option<&str>,
|
||||
) -> DiscordTextFields {
|
||||
let name = apply_template(name_template.unwrap_or("{title}"), title, artist, album);
|
||||
let details = apply_template(
|
||||
details_template.unwrap_or("{artist} - {title}"),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
);
|
||||
let state = apply_template(state_template.unwrap_or("{album}"), title, artist, album);
|
||||
let large_text = apply_template(
|
||||
large_text_template.unwrap_or("{album}"),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
);
|
||||
DiscordTextFields {
|
||||
name,
|
||||
details,
|
||||
state,
|
||||
large_text,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper: compute the Unix-timestamp `start` field that Discord uses
|
||||
/// to show "X minutes elapsed" when `elapsed_secs` is supplied.
|
||||
pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs: i64) -> i64 {
|
||||
now_unix_secs - elapsed_secs.floor() as i64
|
||||
}
|
||||
|
||||
/// Update the Discord Rich Presence activity.
|
||||
///
|
||||
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
|
||||
/// - `elapsed_secs`: seconds already played. `None` when paused — no timestamp is sent so
|
||||
/// Discord stops any running timer.
|
||||
/// - `cover_art_url`: optional direct URL to album artwork.
|
||||
/// - `fetch_itunes_covers`: if true, fetch artwork from the iTunes Search API when no
|
||||
/// `cover_art_url` is provided. If false (default), fall back to the Psysonic app icon
|
||||
/// without making any external request — required for privacy opt-in.
|
||||
/// - `details_template`: template string for the "details" field. Default: "{artist} - {title}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `state_template`: template string for the "state" field. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
/// - `name_template`: template string overriding Discord's default application name in the
|
||||
/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}".
|
||||
/// Empty string falls back to the registered Discord application name.
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn discord_update_presence(
|
||||
state: tauri::State<'_, DiscordState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
is_playing: bool,
|
||||
elapsed_secs: Option<f64>,
|
||||
cover_art_url: Option<String>,
|
||||
fetch_itunes_covers: bool,
|
||||
details_template: Option<String>,
|
||||
state_template: Option<String>,
|
||||
large_text_template: Option<String>,
|
||||
name_template: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
|
||||
// run on the Tokio async executor directly.
|
||||
// Only hit the iTunes API if the user has explicitly opted in.
|
||||
let artwork_url: Option<String> = if let Some(url) = cover_art_url {
|
||||
Some(url)
|
||||
} else if fetch_itunes_covers {
|
||||
if let Some(ref album_name) = album {
|
||||
let http_client = state.http_client.clone();
|
||||
let cache = Arc::clone(&state.artwork_cache);
|
||||
let artist_c = artist.clone();
|
||||
let album_c = album_name.clone();
|
||||
let title_c = title.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
search_itunes_artwork(&http_client, &cache, &artist_c, &album_c, &title_c)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
if guard.is_none() {
|
||||
match try_connect() {
|
||||
Some(client) => *guard = Some(client),
|
||||
None => return Ok(()), // Discord not running — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
let client = guard.as_mut().unwrap();
|
||||
|
||||
let texts = compute_discord_text_fields(
|
||||
&title,
|
||||
&artist,
|
||||
album.as_deref(),
|
||||
details_template.as_deref(),
|
||||
state_template.as_deref(),
|
||||
large_text_template.as_deref(),
|
||||
name_template.as_deref(),
|
||||
);
|
||||
|
||||
let assets = if let Some(ref url) = artwork_url {
|
||||
Assets::new()
|
||||
.large_image(url.as_str())
|
||||
.large_text(&texts.large_text)
|
||||
} else {
|
||||
// Fallback to default Psysonic icon
|
||||
Assets::new()
|
||||
.large_image("psysonic")
|
||||
.large_text(&texts.large_text)
|
||||
};
|
||||
|
||||
// When paused: clear activity completely to avoid any timer issues
|
||||
// When playing: show full activity with timer
|
||||
if !is_playing {
|
||||
if let Err(_e) = client.clear_activity() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] clear_activity (pause) failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only reach here when playing
|
||||
let mut activity = Activity::new().activity_type(ActivityType::Listening);
|
||||
if !texts.name.is_empty() {
|
||||
activity = activity.name(texts.name.as_str());
|
||||
}
|
||||
let activity = activity
|
||||
.details(&texts.details)
|
||||
.state(&texts.state)
|
||||
.assets(assets)
|
||||
.timestamps(if let Some(elapsed) = elapsed_secs {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
Timestamps::new().start(compute_discord_start_timestamp(elapsed, now))
|
||||
} else {
|
||||
Timestamps::new()
|
||||
});
|
||||
|
||||
if let Err(_e) = client.set_activity(activity) {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] set_activity failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!(
|
||||
"[discord] activity sent: \"{}\" / \"{}\"",
|
||||
texts.details,
|
||||
texts.state
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
if let Err(_e) = client.clear_activity() {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] clear_activity failed, dropping client: {}", _e);
|
||||
*guard = None;
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] activity cleared");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// ── normalize ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_lowercases_and_collapses_whitespace() {
|
||||
assert_eq!(normalize(" Pink FLOYD "), "pink floyd");
|
||||
assert_eq!(normalize("The\tBeatles\n"), "the beatles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_returns_empty_for_pure_whitespace() {
|
||||
assert_eq!(normalize(""), "");
|
||||
assert_eq!(normalize(" "), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_preserves_unicode_letters() {
|
||||
assert_eq!(normalize("Sigur Rós"), "sigur rós");
|
||||
assert_eq!(normalize("Mötley Crüe"), "mötley crüe");
|
||||
}
|
||||
|
||||
// ── words_overlap ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn words_overlap_returns_false_for_empty_inputs() {
|
||||
assert!(!words_overlap("", "anything"));
|
||||
assert!(!words_overlap("anything", ""));
|
||||
assert!(!words_overlap("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_overlap_returns_true_for_full_match() {
|
||||
assert!(words_overlap("a b c", "a b c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_overlap_meets_50_percent_threshold() {
|
||||
// "a b" vs "a c" — 1 of 2 words overlap → 50% (just meets ceil-half).
|
||||
assert!(words_overlap("a b", "a c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_overlap_below_threshold_returns_false() {
|
||||
// 1 of 4 words overlap = 25%.
|
||||
assert!(!words_overlap("a b c d", "a x y z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_overlap_handles_asymmetric_lengths() {
|
||||
// "the beatles" (2 words) vs "the beatles greatest hits" (4 words):
|
||||
// 2 common, min_len = 2 → threshold = 1+0 = 1, so true.
|
||||
assert!(words_overlap("the beatles", "the beatles greatest hits"));
|
||||
}
|
||||
|
||||
// ── apply_template ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_template_replaces_all_placeholders() {
|
||||
let out = apply_template(
|
||||
"{artist} - {title} ({album})",
|
||||
"Comfortably Numb",
|
||||
"Pink Floyd",
|
||||
Some("The Wall"),
|
||||
);
|
||||
assert_eq!(out, "Pink Floyd - Comfortably Numb (The Wall)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_template_substitutes_empty_for_missing_album() {
|
||||
let out = apply_template("{album}", "t", "a", None);
|
||||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_template_leaves_unknown_placeholders_untouched() {
|
||||
// Only {title}, {artist}, {album} are supported — {year} stays literal.
|
||||
let out = apply_template("{title} ({year})", "t", "a", None);
|
||||
assert_eq!(out, "t ({year})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_template_repeats_replacement_for_repeated_placeholder() {
|
||||
let out = apply_template("{artist} / {artist}", "t", "AC/DC", None);
|
||||
assert_eq!(out, "AC/DC / AC/DC");
|
||||
}
|
||||
|
||||
// ── compute_discord_text_fields ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_fields_use_documented_defaults_when_templates_are_none() {
|
||||
let f =
|
||||
compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None, None);
|
||||
assert_eq!(f.name, "Song");
|
||||
assert_eq!(f.details, "Artist - Song");
|
||||
assert_eq!(f.state, "Album");
|
||||
assert_eq!(f.large_text, "Album");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_apply_supplied_templates_overriding_defaults() {
|
||||
let f = compute_discord_text_fields(
|
||||
"Song",
|
||||
"Artist",
|
||||
Some("Album"),
|
||||
Some("{title} | {album}"),
|
||||
Some("by {artist}"),
|
||||
Some("{album} ({artist})"),
|
||||
Some("{title} ({artist})"),
|
||||
);
|
||||
assert_eq!(f.name, "Song (Artist)");
|
||||
assert_eq!(f.details, "Song | Album");
|
||||
assert_eq!(f.state, "by Artist");
|
||||
assert_eq!(f.large_text, "Album (Artist)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_substitute_empty_for_missing_album() {
|
||||
let f = compute_discord_text_fields("Song", "Artist", None, None, None, None, None);
|
||||
// {album} placeholder → empty, but the surrounding template stays.
|
||||
assert_eq!(f.name, "Song");
|
||||
assert_eq!(f.details, "Artist - Song");
|
||||
assert_eq!(f.state, "");
|
||||
assert_eq!(f.large_text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_handle_unicode_and_special_characters() {
|
||||
let f = compute_discord_text_fields(
|
||||
"Bohemian Rhapsody",
|
||||
"Queen",
|
||||
Some("A Night at the Opera"),
|
||||
Some("{artist} – {title}"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(f.name, "Bohemian Rhapsody");
|
||||
assert_eq!(f.details, "Queen – Bohemian Rhapsody");
|
||||
}
|
||||
|
||||
// ── compute_discord_start_timestamp ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn start_timestamp_subtracts_floor_of_elapsed() {
|
||||
// elapsed=42.7 → floor=42; start = now - 42
|
||||
assert_eq!(compute_discord_start_timestamp(42.7, 1_700_000_000), 1_699_999_958);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_timestamp_for_zero_elapsed_equals_now() {
|
||||
assert_eq!(compute_discord_start_timestamp(0.0, 1_700_000_000), 1_700_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_timestamp_handles_fractional_seconds_via_floor() {
|
||||
// 0.999 → floor 0 (same as just-started)
|
||||
assert_eq!(compute_discord_start_timestamp(0.999, 1_700_000_000), 1_700_000_000);
|
||||
// 1.0001 → floor 1
|
||||
assert_eq!(compute_discord_start_timestamp(1.0001, 1_700_000_000), 1_699_999_999);
|
||||
}
|
||||
|
||||
// ── cache_and_return ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cache_and_return_inserts_entry_with_url() {
|
||||
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
|
||||
cache_and_return(&cache, "key".to_string(), "https://example/600x600.jpg");
|
||||
let g = cache.lock().unwrap();
|
||||
let entry = g.get("key").expect("entry inserted");
|
||||
assert_eq!(entry.url, "https://example/600x600.jpg");
|
||||
// fetched_at is set to now() — sanity-check it's recent.
|
||||
assert!(entry.fetched_at.elapsed() < std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
// ── search_with_url against wiremock ──────────────────────────────────────
|
||||
|
||||
fn itunes_blocking_client() -> Client {
|
||||
// Mirror the production builder used by DiscordState.
|
||||
Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn search_with_url_returns_600x600_when_artist_and_album_match() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": [
|
||||
{
|
||||
"collectionName": "The Wall",
|
||||
"artistName": "Pink Floyd",
|
||||
"artworkUrl100": "https://is1-ssl.mzstatic.com/100x100bb.jpg"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
|
||||
search_with_url(&itunes_blocking_client(), url, "pink floyd", "the wall")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("https://is1-ssl.mzstatic.com/600x600bb.jpg".to_string()),
|
||||
"100x100 must be replaced with 600x600"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn search_with_url_returns_none_when_no_results_match() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": [
|
||||
{
|
||||
"collectionName": "Some Other Album",
|
||||
"artistName": "Different Artist",
|
||||
"artworkUrl100": "https://x/100x100.jpg"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
|
||||
search_with_url(&itunes_blocking_client(), url, "pink floyd", "the wall")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn search_with_url_returns_none_for_empty_results() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": []
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
|
||||
search_with_url(&itunes_blocking_client(), url, "x", "y")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── search_itunes_artwork_with_base — full strategy ladder + cache ──────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn artwork_with_base_returns_cached_url_without_network() {
|
||||
// No mock — if the function tries to hit the network it'll fail with
|
||||
// a transport error rather than the cached value.
|
||||
let server = MockServer::start().await;
|
||||
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
|
||||
cache.lock().unwrap().insert(
|
||||
"Pink Floyd|The Wall".to_string(),
|
||||
ArtworkCacheEntry {
|
||||
url: "https://cached/600x600.jpg".to_string(),
|
||||
fetched_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
let server_uri = server.uri();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = format!("{server_uri}/search");
|
||||
search_itunes_artwork_with_base(
|
||||
&itunes_blocking_client(),
|
||||
&cache,
|
||||
"Pink Floyd",
|
||||
"The Wall",
|
||||
"Comfortably Numb",
|
||||
&url,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some("https://cached/600x600.jpg".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn artwork_with_base_uses_strategy_1_when_exact_match_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": [
|
||||
{
|
||||
"collectionName": "The Wall",
|
||||
"artistName": "Pink Floyd",
|
||||
"artworkUrl100": "https://itunes/strategy1/100x100.jpg"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = format!("{server_uri}/search");
|
||||
search_itunes_artwork_with_base(
|
||||
&itunes_blocking_client(),
|
||||
&cache,
|
||||
"Pink Floyd",
|
||||
"The Wall",
|
||||
"Comfortably Numb",
|
||||
&url,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("https://itunes/strategy1/600x600.jpg".to_string()),
|
||||
"first matching strategy returns immediately + caches"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn artwork_with_base_returns_none_when_no_strategy_matches() {
|
||||
let server = MockServer::start().await;
|
||||
// Server always returns empty results — every strategy misses.
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": []
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = format!("{server_uri}/search");
|
||||
search_itunes_artwork_with_base(
|
||||
&itunes_blocking_client(),
|
||||
&cache,
|
||||
"Unknown",
|
||||
"Album",
|
||||
"Title",
|
||||
&url,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn artwork_with_base_caches_successful_lookup_for_subsequent_calls() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": [
|
||||
{
|
||||
"collectionName": "Album",
|
||||
"artistName": "Artist",
|
||||
"artworkUrl100": "https://itunes/cached/100x100.jpg"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
let cache_clone = Arc::clone(&cache);
|
||||
let _first = tokio::task::spawn_blocking(move || {
|
||||
let url = format!("{server_uri}/search");
|
||||
search_itunes_artwork_with_base(
|
||||
&itunes_blocking_client(),
|
||||
&cache_clone,
|
||||
"Artist",
|
||||
"Album",
|
||||
"T",
|
||||
&url,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// After first lookup, cache must hold the resolved URL.
|
||||
let entry_url = cache.lock().unwrap().get("Artist|Album").map(|e| e.url.clone());
|
||||
assert_eq!(
|
||||
entry_url,
|
||||
Some("https://itunes/cached/600x600.jpg".to_string()),
|
||||
"successful lookup must populate the artwork cache",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn search_with_url_uses_words_overlap_for_fuzzy_artist_match() {
|
||||
// Server returns "The Beatles" but our normalised query is just "beatles" —
|
||||
// contains() catches it, but this exercises the words_overlap branch by
|
||||
// using artist names where neither contains the other and only word overlap
|
||||
// matches.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"results": [
|
||||
{
|
||||
"collectionName": "Help",
|
||||
"artistName": "The Fab Four Beatles",
|
||||
"artworkUrl100": "https://x/100x100.jpg"
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
|
||||
// "fab beatles" vs "the fab four beatles" — word overlap = 2 of 2,
|
||||
// 50% threshold met, contains() also catches "beatles".
|
||||
search_with_url(&itunes_blocking_client(), url, "fab beatles", "help")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some("https://x/600x600.jpg".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! `psysonic-integration` — outbound bridges to external services that the
|
||||
//! UI surfaces but the WebView can't talk to directly (CORS, custom auth, etc.).
|
||||
//!
|
||||
//! Domains:
|
||||
//! - `discord` — Discord Rich Presence (album artwork via iTunes)
|
||||
//! - `navidrome` — Navidrome's native REST API (admin: users/playlists/covers/queries)
|
||||
//! - `subsonic` — Subsonic REST surface for the library-sync engine
|
||||
//! - `remote` — radio-browser, last.fm, ICY-meta probe, generic CORS proxy
|
||||
//! - `bandsintown` — bandsintown events for an artist
|
||||
|
||||
// Re-export the logging facade so submodules keep using
|
||||
// `crate::app_eprintln!()` and `crate::app_deprintln!()`.
|
||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||
|
||||
pub mod bandsintown;
|
||||
pub mod discord;
|
||||
pub mod navidrome;
|
||||
pub mod remote;
|
||||
pub mod subsonic;
|
||||
@@ -1,279 +0,0 @@
|
||||
//! Auth + retry + HTTP client for Navidrome's native REST API.
|
||||
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/auth/login", server_url))
|
||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
data["token"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
||||
}
|
||||
|
||||
/// Payload returned by Navidrome's `/auth/login`.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct NdLoginResult {
|
||||
pub(super) token: String,
|
||||
#[serde(rename = "userId")]
|
||||
pub(super) user_id: String,
|
||||
#[serde(rename = "isAdmin")]
|
||||
pub(super) is_admin: bool,
|
||||
}
|
||||
|
||||
/// Flatten an error and its `source` chain into a single readable string so
|
||||
/// frontend toasts can show the actual transport cause (connection refused,
|
||||
/// tls handshake fail, cert expired, etc.) instead of reqwest's opaque
|
||||
/// "error sending request for url (…)" wrapper.
|
||||
pub fn nd_err(e: reqwest::Error) -> String {
|
||||
let mut msg = e.to_string();
|
||||
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
|
||||
while let Some(s) = src {
|
||||
msg.push_str(" | ");
|
||||
msg.push_str(&s.to_string());
|
||||
src = s.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Retry a request-building closure on transient transport errors
|
||||
/// (connect/timeout — includes ECONNRESET, TLS handshake EOF, DNS flakes).
|
||||
/// Three attempts with calm backoff: 0 → 300ms → 700ms (total worst case
|
||||
/// ~1s). Retrying too aggressively (5+ attempts, short backoff) can drive
|
||||
/// an already-stressed nginx upstream-probe into "offline" mode, which
|
||||
/// turns a transient glitch into a visible outage. Status-level failures
|
||||
/// (401/403/400 with body) return immediately — we don't retry logic
|
||||
/// errors.
|
||||
pub async fn nd_retry<F, Fut>(mut build_and_send: F) -> Result<reqwest::Response, String>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
{
|
||||
// Reverse-proxies in front of Navidrome (Caddy/nginx + Cloudflare etc.)
|
||||
// sometimes drop a TLS handshake mid-stream when their keep-alive pool
|
||||
// churns. One 500 ms retry isn't always enough — exponential backoff
|
||||
// across 4 attempts gives the upstream pool time to settle without
|
||||
// making the user-visible wait worse for the common single-failure case.
|
||||
const BACKOFFS_MS: [u64; 3] = [300, 800, 1800];
|
||||
let mut last: Option<reqwest::Error> = None;
|
||||
for attempt in 0..=BACKOFFS_MS.len() {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(BACKOFFS_MS[attempt - 1])).await;
|
||||
}
|
||||
match build_and_send().await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if !e.is_connect() && !e.is_timeout() {
|
||||
return Err(nd_err(e));
|
||||
}
|
||||
last = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(nd_err(last.expect("loop ran at least once")))
|
||||
}
|
||||
|
||||
/// Build a reqwest client for Navidrome's native REST endpoints. Plain
|
||||
/// `reqwest::Client::new()` defaults to HTTP/2 over ALPN with no User-Agent,
|
||||
/// which some reverse-proxies (strict nginx rules, Cloudflare Tunnel, CDN
|
||||
/// WAFs) abort mid-TLS-handshake. Pinning HTTP/1.1 and advertising a real
|
||||
/// User-Agent makes the handshake match what browsers do for the Subsonic
|
||||
/// endpoints, so `/auth/*` + `/api/*` go through the same path as `/rest/*`.
|
||||
///
|
||||
/// `pool_max_idle_per_host(0)` disables connection pooling. Keeping stale
|
||||
/// keep-alive connections in the pool caused intermittent "tls handshake
|
||||
/// eof" errors on the second call to an admin endpoint when a server or
|
||||
/// proxy had already closed the TCP connection between calls.
|
||||
pub fn nd_http_client() -> reqwest::Client {
|
||||
// TLS 1.2 only: rustls + nginx with TLS-1.3 session resumption caches
|
||||
// produces intermittent ECONNRESET mid-handshake when the upstream
|
||||
// starts churning keep-alive connections. Pinning TLS 1.2 matches what
|
||||
// the WebKit-side Subsonic calls end up negotiating most of the time
|
||||
// on these setups.
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
.http1_only()
|
||||
.pool_max_idle_per_host(0)
|
||||
.max_tls_version(reqwest::tls::Version::TLS_1_2)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// ── nd_http_client ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nd_http_client_builds_without_panicking() {
|
||||
// Don't try to inspect — just verify the builder + fallback returns a Client.
|
||||
let _client = nd_http_client();
|
||||
}
|
||||
|
||||
// ── nd_err ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nd_err_flattens_into_a_single_string() {
|
||||
// Provoke a transport error by hitting an unbound port — the error chain
|
||||
// typically is "error sending request | tcp connect error | refused".
|
||||
let client = reqwest::Client::new();
|
||||
let err = client
|
||||
.get("http://127.0.0.1:1") // port 1 is reserved, never bound
|
||||
.send()
|
||||
.await
|
||||
.expect_err("connect must fail");
|
||||
let flattened = nd_err(err);
|
||||
// The flattened string contains at least the top message.
|
||||
assert!(!flattened.is_empty());
|
||||
// The chain joiner appears zero or more times depending on the OS — we
|
||||
// just verify the function doesn't panic and returns something readable.
|
||||
}
|
||||
|
||||
// ── nd_retry — uses a synthetic Future, not reqwest, for determinism ──────
|
||||
|
||||
/// Build a reqwest::Error of the connect kind by attempting an immediate
|
||||
/// connect to a known-closed port. Reused by the retry tests so we get
|
||||
/// errors classified as `is_connect()`.
|
||||
async fn synthetic_connect_error() -> reqwest::Error {
|
||||
reqwest::Client::new()
|
||||
.get("http://127.0.0.1:1")
|
||||
.timeout(std::time::Duration::from_millis(50))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("connect must fail")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nd_retry_returns_immediately_when_first_attempt_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/ok"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempts_c = attempts.clone();
|
||||
let url = format!("{}/ok", server.uri());
|
||||
let resp = nd_retry(move || {
|
||||
attempts_c.fetch_add(1, Ordering::SeqCst);
|
||||
let url = url.clone();
|
||||
async move { reqwest::Client::new().get(&url).send().await }
|
||||
})
|
||||
.await
|
||||
.expect("first try should win");
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retries");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nd_retry_does_not_retry_status_level_errors() {
|
||||
// 404 is a status-level error (the future returned Ok(resp) with status 404).
|
||||
// Even though the response is "bad", the body is intact; nd_retry must
|
||||
// return immediately without retrying.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(wm_path("/missing"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempts_c = attempts.clone();
|
||||
let url = format!("{}/missing", server.uri());
|
||||
let resp = nd_retry(move || {
|
||||
attempts_c.fetch_add(1, Ordering::SeqCst);
|
||||
let url = url.clone();
|
||||
async move { reqwest::Client::new().get(&url).send().await }
|
||||
})
|
||||
.await
|
||||
.expect("status errors come back as Ok(resp)");
|
||||
assert_eq!(resp.status(), 404);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1, "404 must not trigger a retry");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nd_retry_returns_err_when_all_attempts_fail() {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempts_c = attempts.clone();
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
nd_retry(move || {
|
||||
attempts_c.fetch_add(1, Ordering::SeqCst);
|
||||
async {
|
||||
let err = synthetic_connect_error().await;
|
||||
Err(err)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("should not exceed 10s — backoffs total ~3s");
|
||||
assert!(result.is_err(), "all attempts failed → Err");
|
||||
// 1 initial + 3 retries (BACKOFFS_MS has 3 entries) = 4 total.
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nd_retry_returns_immediately_on_non_transient_error() {
|
||||
// Builder error (URL parse) is neither connect nor timeout → return immediately.
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempts_c = attempts.clone();
|
||||
let result = nd_retry(move || {
|
||||
attempts_c.fetch_add(1, Ordering::SeqCst);
|
||||
async {
|
||||
// reqwest treats malformed URLs as builder errors, neither
|
||||
// is_connect() nor is_timeout() — so nd_retry must surface
|
||||
// immediately without retrying.
|
||||
reqwest::Client::new().get("not-a-valid-url").send().await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1, "non-transient error must not retry");
|
||||
}
|
||||
|
||||
// ── navidrome_token via wiremock ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navidrome_token_returns_token_from_login_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wm_path("/auth/login"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"token": "abc.def.ghi",
|
||||
"userId": "u1",
|
||||
"isAdmin": true,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let token = navidrome_token(&server.uri(), "user", "pw").await.unwrap();
|
||||
assert_eq!(token, "abc.def.ghi");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navidrome_token_errors_when_response_omits_token() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(wm_path("/auth/login"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"error": "invalid credentials"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = navidrome_token(&server.uri(), "user", "wrong").await.unwrap_err();
|
||||
assert!(err.contains("no token"), "got {err}");
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Image / artwork upload + delete commands. Each command does a one-shot
|
||||
//! login (via `navidrome_token`) and then a multipart POST to the relevant
|
||||
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
|
||||
|
||||
use super::client::navidrome_token;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_playlist_cover(
|
||||
server_url: String,
|
||||
playlist_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_radio_cover(
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_artist_image(
|
||||
server_url: String,
|
||||
artist_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_radio_cover(
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 404/503 = no image existed — treat as success
|
||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
||||
resp.error_for_status().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//! Navidrome native REST API: split into a small client/auth/retry core
|
||||
//! plus per-domain submodules (covers, users, queries, playlists). Each
|
||||
//! Tauri command goes through `nd_http_client()` + `nd_retry()` so flaky
|
||||
//! reverse proxies in front of the server don't surface as user-visible
|
||||
//! transport errors on a single retry-able blip.
|
||||
|
||||
mod client;
|
||||
pub mod covers;
|
||||
pub mod playlists;
|
||||
pub mod probe;
|
||||
pub mod queries;
|
||||
pub mod users;
|
||||
|
||||
pub use client::navidrome_token;
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Playlist CRUD via Navidrome's native REST API. The smart-playlist rules
|
||||
//! payload is forwarded as-is so the frontend can compose any rule the
|
||||
//! Navidrome version supports without backend changes.
|
||||
|
||||
use super::client::{nd_err, nd_http_client, nd_retry};
|
||||
|
||||
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_playlists(
|
||||
server_url: String,
|
||||
token: String,
|
||||
smart: Option<bool>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
let client = nd_http_client();
|
||||
let mut req = client
|
||||
.get(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token));
|
||||
if let Some(s) = smart {
|
||||
req = req.query(&[("smart", s)]);
|
||||
}
|
||||
req.send()
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
resp.json::<serde_json::Value>().await.map_err(nd_err)
|
||||
}
|
||||
|
||||
/// POST `/api/playlist` — create playlist (supports smart rules payload).
|
||||
#[tauri::command]
|
||||
pub async fn nd_create_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.post(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
|
||||
#[tauri::command]
|
||||
pub async fn nd_update_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.put(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
|
||||
#[tauri::command]
|
||||
pub async fn nd_get_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
/// DELETE `/api/playlist/{id}` — delete playlist.
|
||||
#[tauri::command]
|
||||
pub async fn nd_delete_playlist(
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.delete(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Navidrome-side probes for the library-sync capability detection.
|
||||
//!
|
||||
//! Lives next to `client.rs` / `queries.rs` so the existing native-REST
|
||||
//! auth shape (`Authorization: Bearer …`) is reused. PR-3a only needs
|
||||
//! one probe — does the server expose the paginated `/api/song` bulk
|
||||
//! endpoint? — so this stays a free function rather than a client
|
||||
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
|
||||
|
||||
use super::client::{nd_err, nd_http_client};
|
||||
|
||||
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
|
||||
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
|
||||
/// disabled) and 5xx surfaces as `Err`. The body is intentionally not
|
||||
/// inspected — empty libraries still respond with `[]` and a 200.
|
||||
///
|
||||
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
|
||||
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
|
||||
/// is PR-3b's job.
|
||||
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
|
||||
let client = nd_http_client();
|
||||
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("X-ND-Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(nd_err)?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
return Ok(true);
|
||||
}
|
||||
if status.is_client_error() {
|
||||
// 401/403/404 — endpoint genuinely unavailable for this token /
|
||||
// build. Treat as "no native bulk" and fall back to Subsonic.
|
||||
return Ok(false);
|
||||
}
|
||||
Err(format!("HTTP {status}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_bulk_available_returns_true_on_200() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/api/song"))
|
||||
.and(query_param("_start", "0"))
|
||||
.and(query_param("_end", "1"))
|
||||
.and(header("X-ND-Authorization", "Bearer tok-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_bulk_available_returns_false_on_404() {
|
||||
// Server is reachable, auth might be ok, but the endpoint just
|
||||
// doesn't exist (older Navidrome, mod_rewrite mishap, …).
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/api/song"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_bulk_available_returns_false_on_401_auth_failure() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/api/song"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
|
||||
assert!(!ok, "401 reads as `endpoint not available for this caller`");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_bulk_available_surfaces_5xx_as_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/api/song"))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
|
||||
assert!(err.contains("503"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_bulk_available_strips_trailing_slash() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/api/song"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let with_slash = format!("{}/", server.uri());
|
||||
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user