Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot] 68bbb46c4e chore(nix): refresh lock + npmDepsHash for v1.46.0 (#776)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 14:11:27 +02:00
github-actions[bot] 70c5e06b65 chore(release): finalize release version 1.46.0 2026-05-18 11:54:41 +00:00
Frank Stellmacher 17db557239 Update CHANGELOG.md 2026-05-18 13:49:38 +02:00
github-actions[bot] e07b0f6882 chore(release): bump next channel to 1.46.0-rc.5 2026-05-18 11:02:02 +00:00
1472 changed files with 33938 additions and 116123 deletions
-46
View File
@@ -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"]
+7 -2
View File
@@ -3,8 +3,13 @@
# 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.
# `vitest run --coverage` and warns when a listed file drops below the floor.
#
# Soft today (warnings only — the workflow carries `continue-on-error: true`).
# Flip to a hard PR-blocker by removing `continue-on-error` from the
# `frontend-tests` workflow at the start of M4 in the pre-refactor testing
# plan (2026-05-11), once Phase 13 characterization tests have proven the
# gate stable on a handful of real PRs.
#
# 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
+2 -1
View File
@@ -20,7 +20,8 @@
#
# 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.
# any listed file below the threshold get a warning annotation today
# and (after watching it run cleanly) eventually a hard fail.
# ── psysonic-syncfs ──────────────────────────────────────────────────
src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs
+12 -7
View File
@@ -38,9 +38,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
node-version: '20'
cache: 'npm'
- run: npm ci
- name: vitest
@@ -51,9 +51,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
node-version: '20'
cache: 'npm'
- run: npm ci
- name: tsc
@@ -61,19 +61,24 @@ jobs:
coverage:
name: vitest --coverage (baseline + hot-path file gate)
# Two-layer gate: the script exits 1 when any listed file drops below the
# threshold (warning annotations show in the PR checks panel), but
# `continue-on-error: true` keeps it from BLOCKING merges. Drop the flag
# to flip the gate hard once we've watched a few PRs run cleanly.
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
node-version: '20'
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
- name: hot-path file coverage soft gate
run: bash scripts/check-frontend-hot-path-coverage.sh
- uses: actions/upload-artifact@v4
with:
+1 -1
View File
@@ -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
+68 -69
View File
@@ -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."
+7 -1
View File
@@ -59,7 +59,13 @@ jobs:
coverage:
name: cargo llvm-cov (baseline + hot-path file gate)
# Layered: the gate script exits 1 when any hot-path file drops below
# threshold (see scripts/check-hot-path-coverage.sh) — the failure is
# visible in the PR's checks panel. `continue-on-error: true` keeps it
# from BLOCKING merges. Drop continue-on-error to flip the gate to a
# PR-blocker once we've watched a few PRs run cleanly.
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- uses: actions/checkout@v5
- name: install Linux build dependencies
@@ -83,7 +89,7 @@ jobs:
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
- name: hot-path function coverage soft gate
run: bash scripts/check-hot-path-coverage.sh
- uses: actions/upload-artifact@v4
with:
-1
View File
@@ -63,4 +63,3 @@ result-*
dev.sh
shell.nix
prod.sh
tsconfig.tsbuildinfo
+7 -1272
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -41,7 +41,7 @@ Open pull requests against `main`. `next` and `release` are maintainer-driven pr
- **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).
- **Security issues** — please do **not** open a public issue. Reach a maintainer privately via Discord or Telegram first; we'll coordinate disclosure from there.
---
@@ -117,7 +117,7 @@ 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.
Hot-path coverage gates are currently **soft** (warnings only — the workflow carries `continue-on-error: true`). They will be flipped to required when the floors stabilise; 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 the current state of each list.
---
+3 -4
View File
@@ -32,6 +32,9 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
@@ -150,8 +153,6 @@ curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts
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).
@@ -190,8 +191,6 @@ Psysonic is built for self-hosted music collections. Your library is yours.
* 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.
---
# Community & Support
+1 -1
View File
@@ -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`
-28
View File
@@ -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).
-65
View File
@@ -1,65 +0,0 @@
# Privacy & Telemetry
Privacy is not an afterthought in Psysonic. It has been part of the projects 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.
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
+17 -34
View File
@@ -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)
@@ -89,6 +87,9 @@
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 +106,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 +140,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";
};
};
-82
View File
@@ -6,91 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<script src="/startup-splash-preflight.js"></script>
<style>
html, body {
margin: 0;
min-height: 100%;
background: var(--startup-splash-bg, #1e1e2e);
}
#app-startup-splash {
--splash-bg: var(--startup-splash-bg, #1e1e2e);
--splash-text: var(--startup-splash-text, #cdd6f4);
--splash-muted: var(--startup-splash-muted, #a6adc8);
--splash-accent: var(--startup-splash-accent, #cba6f7);
--splash-track: var(--startup-splash-track, #313244);
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.75rem;
background: var(--splash-bg);
color: var(--splash-text);
font-family: system-ui, -apple-system, sans-serif;
opacity: 1;
transition: opacity 0.28s ease;
}
#app-startup-splash.app-startup-splash--hide {
opacity: 0;
pointer-events: none;
}
#app-startup-splash .app-startup-splash__logo {
height: 72px;
width: auto;
opacity: 0.95;
}
#app-startup-splash .app-startup-splash__label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--splash-muted);
}
#app-startup-splash .app-startup-splash__bar {
width: min(240px, 72vw);
height: 4px;
border-radius: 999px;
background: var(--splash-track);
overflow: hidden;
}
#app-startup-splash .app-startup-splash__bar-fill {
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--splash-accent);
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
}
@keyframes app-startup-splash-indeterminate {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
</style>
</head>
<body>
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--splash-logo-start)" />
<stop offset="100%" stop-color="var(--splash-logo-end)" />
</linearGradient>
</defs>
<g transform="translate(220.53237,27.789086)">
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
</g>
</svg>
<div class="app-startup-splash__bar" aria-hidden="true">
<div class="app-startup-splash__bar-fill"></div>
</div>
<span class="app-startup-splash__label">Loading</span>
</div>
<div id="root"></div>
<script src="/startup-splash-reveal.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+10 -6
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
"npmDepsHash": "sha256-4q+d3lfR8S6abAbw0Z//pvJCHwO3AqR+lNOTWdSZp4Q="
}
+10 -10
View File
@@ -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.
+518 -565
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.46.0",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
@@ -30,7 +30,7 @@
"@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.24",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
@@ -42,31 +42,31 @@
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.16.0",
"i18next": "^26.0.8",
"lucide-react": "^1.17.0",
"lucide-react": "^1.14.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^19.2.5",
"react-dom": "^19.2.6",
"react-dom": "^19.2.5",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.16.0",
"zustand": "^5.0.14"
"react-router-dom": "^7.15.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"@tauri-apps/cli": "^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",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"esbuild": "^0.28.0",
"jsdom": "^29.1.1",
"jsdom": "^26.1.0",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.8"
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.46.0
pkgver=1.45.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
-161
View File
@@ -1,161 +0,0 @@
/**
* Synchronous startup splash theme (before the Vite bundle loads).
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
*/
(function startupSplashPreflight() {
var THEME_KEY = 'psysonic_theme';
var INSTALLED_KEY = 'psysonic_installed_themes';
var DEFAULT = {
bg: '#1e1e2e',
text: '#cdd6f4',
muted: '#a6adc8',
accent: '#cba6f7',
track: '#313244',
logoStart: '#cba6f7',
logoEnd: '#89b4fa',
};
var BUILTIN = {
mocha: DEFAULT,
latte: {
bg: '#eff1f5',
text: '#4c4f69',
muted: '#6c6f85',
accent: '#8839ef',
track: '#ccd0da',
logoStart: '#8839ef',
logoEnd: '#1e66f5',
},
'kanagawa-wave': {
bg: '#1F1F28',
text: '#DCD7BA',
muted: '#727169',
accent: '#7E9CD8',
track: '#2A2A37',
logoStart: '#7E9CD8',
logoEnd: '#957FB8',
},
'stark-hud': {
bg: '#0b0f15',
text: '#e0f7fa',
muted: '#7da5aa',
accent: '#00f2ff',
track: '#141b24',
logoStart: '#00f2ff',
logoEnd: '#7df9ff',
},
'vision-dark': {
bg: '#0d0b12',
text: '#f2eef8',
muted: '#a6a2b8',
accent: '#ffd700',
track: '#16131e',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
'vision-navy': {
bg: '#0a1628',
text: '#e8eef8',
muted: '#9caac2',
accent: '#ffd700',
track: '#12213a',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
};
function readCssVar(css, name) {
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
var value = match && match[1] ? match[1].trim() : '';
return value || null;
}
function resolveScheduledTheme(state) {
if (!state.enableThemeScheduler) return state.theme;
var now = new Date();
var nowMins = now.getHours() * 60 + now.getMinutes();
var dayParts = state.timeDayStart.split(':').map(Number);
var nightParts = state.timeNightStart.split(':').map(Number);
var dayMins = dayParts[0] * 60 + dayParts[1];
var nightMins = nightParts[0] * 60 + nightParts[1];
var isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
function readThemeState() {
try {
var raw = localStorage.getItem(THEME_KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
var s = parsed && parsed.state;
if (!s) return null;
return {
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme || 'mocha'),
themeDay: String(s.themeDay || 'latte'),
themeNight: String(s.themeNight || 'mocha'),
timeDayStart: String(s.timeDayStart || '07:00'),
timeNightStart: String(s.timeNightStart || '19:00'),
};
} catch (_err) {
return null;
}
}
function readInstalledThemes() {
try {
var raw = localStorage.getItem(INSTALLED_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
var themes = parsed && parsed.state && parsed.state.themes;
return Array.isArray(themes) ? themes : [];
} catch (_err) {
return [];
}
}
function paletteForTheme(themeId, installedThemes) {
if (BUILTIN[themeId]) return BUILTIN[themeId];
for (var i = 0; i < installedThemes.length; i += 1) {
var theme = installedThemes[i];
if (!theme || theme.id !== themeId || !theme.css) continue;
var bg = readCssVar(theme.css, '--bg-app');
var accent = readCssVar(theme.css, '--accent');
if (!bg || !accent) break;
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|| readCssVar(theme.css, '--accent-2')
|| accent;
return {
bg: bg,
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
accent: accent,
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
logoStart: logoStart,
logoEnd: logoEnd,
};
}
return DEFAULT;
}
function applyPalette(themeId, palette) {
var root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.style.setProperty('--startup-splash-bg', palette.bg);
root.style.setProperty('--startup-splash-text', palette.text);
root.style.setProperty('--startup-splash-muted', palette.muted);
root.style.setProperty('--startup-splash-accent', palette.accent);
root.style.setProperty('--startup-splash-track', palette.track);
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
root.style.background = palette.bg;
if (document.body) document.body.style.background = palette.bg;
}
var persisted = readThemeState();
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
var palette = paletteForTheme(themeId, readInstalledThemes());
applyPalette(themeId, palette);
})();
-28
View File
@@ -1,28 +0,0 @@
/**
* Show the native window after the inline startup splash has painted.
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
*/
(function startupSplashReveal() {
var MAX_ATTEMPTS = 60;
function tryShowMainWindow() {
var internals = window.__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') return false;
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
return true;
}
function reveal(attempt) {
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
reveal(0);
});
});
})();
+4 -2
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
#
# Hot-path file coverage gate — frontend.
# Hot-path file coverage gate — frontend, soft mode.
#
# 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.
# annotations for files below the floor; exits 1 when any file is below, but
# the wrapping CI job carries `continue-on-error: true` so it doesn't block
# merges yet (drop that flag once we've watched a few PRs run cleanly).
#
# Why files instead of per-function: v8 coverage's per-function data is
# fragile under React Compiler / Vite minification — file-level line
+11 -3
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
#
# Hot-path file coverage gate.
# Hot-path file coverage gate — soft mode.
#
# 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.
# warning annotations for files below the floor; never sets a non-zero
# exit code (soft gate).
#
# Why files instead of per-function: cargo-llvm-cov's per-function
# region data is unreliable for async state-machines (most regions live
@@ -104,6 +104,14 @@ echo "Checked: $TOTAL hot-path file(s)"
echo "Below threshold: $BELOW"
echo "Not found: $NOT_FOUND"
# Two-layer gate:
# - This script exits 1 when any hot-path file regresses below the
# threshold. That gives an unambiguous CI signal in the workflow log.
# - The `coverage` job in `.github/workflows/rust-tests.yml` carries
# `continue-on-error: true`, so the failing exit is visible in the
# PR's checks panel but does NOT block merges yet.
# - Flip to a hard PR-blocker by removing `continue-on-error` from the
# workflow once we've watched a few PRs run cleanly.
if [[ "$BELOW" -gt 0 ]]; then
exit 1
fi
+1 -20
View File
@@ -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}`);
}
+159 -823
View File
File diff suppressed because it is too large Load Diff
+18 -26
View File
@@ -3,10 +3,9 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0-dev"
version = "1.46.0"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
rust-version = "1.89"
[workspace.dependencies]
tempfile = "3"
@@ -19,7 +18,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license.workspace = true
license = ""
repository = ""
default-run = "psysonic"
edition.workspace = true
@@ -40,10 +39,9 @@ tauri-build = { version = "2", features = [] }
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"
@@ -52,8 +50,8 @@ 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"] }
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
md5 = "0.8"
@@ -68,27 +66,20 @@ discord-rich-presence = "1.1"
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"] }
sysinfo = { version = "0.38", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.9"
rusqlite = { version = "0.39", 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.15", default-features = false, features = ["blocking-api", "async-io"] }
# Match wry/tauris 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 = [
@@ -103,10 +94,11 @@ windows = { version = "0.62", features = [
"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" }
+1 -2
View File
@@ -21,8 +21,6 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -40,4 +38,5 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
-1
View File
@@ -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"
]
@@ -3,7 +3,6 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -17,10 +16,5 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "r
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"] }
rusqlite = { version = "0.39", features = ["bundled"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
@@ -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;
@@ -2,18 +2,14 @@ use std::io::Cursor;
use std::time::Instant;
use ebur128::{EbuR128, Mode as Ebur128Mode};
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType};
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::units::Time;
use tauri::{Manager, Runtime};
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
use crate::analysis_perf::AnalysisSeedTimings;
use crate::codec::make_decoder;
use symphonia::core::probe::Hint;
use tauri::Manager;
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
@@ -42,120 +38,54 @@ pub enum SeedFromBytesOutcome {
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
pub fn seed_from_bytes_execute<R: Runtime>(
app: &tauri::AppHandle<R>,
server_id: &str,
pub fn seed_from_bytes_execute(
app: &tauri::AppHandle,
track_id: &str,
bytes: &[u8],
format_hint: Option<&str>,
notify_ui: bool,
) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> {
let seed_started = Instant::now();
) -> Result<SeedFromBytesOutcome, String> {
let Some(cache) = app.try_state::<AnalysisCache>() else {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
track_id,
bytes.len()
);
return Ok((
SeedFromBytesOutcome::SkippedNoAnalysisCache,
AnalysisSeedTimings::default(),
));
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
};
let (outcome, md5_16kb) =
seed_from_bytes_into_cache(&cache, server_id, track_id, bytes, format_hint)?;
let seed_ms = seed_started.elapsed().as_millis() as u64;
// E2 bridge (analysis → library content_hash): once the playback-derived
// md5_16kb is known — whether freshly written or already cached — record it
// as `track.content_hash` via the registered sink. Decoupled from
// psysonic-library through the psysonic-core port; a no-op when the library
// has no row for this (server_id, track_id). Skipped when no server is known.
if !server_id.is_empty()
&& matches!(
outcome,
SeedFromBytesOutcome::Upserted | SeedFromBytesOutcome::SkippedWaveformCacheHit
)
{
if let Some(sink) = app.try_state::<psysonic_core::ports::ContentHashSink>() {
sink.record_content_hash(server_id, track_id, &md5_16kb);
}
}
let bpm_ms = if !server_id.is_empty() {
let bpm_started = Instant::now();
let enrichment_outcome = crate::track_enrichment::run_track_enrichment_if_needed(
app,
server_id,
track_id,
bytes,
notify_ui,
);
if matches!(enrichment_outcome, TrackEnrichmentOutcome::Failed) {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_16kb.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
if matches!(outcome, SeedFromBytesOutcome::Upserted) {
if let Ok(coverage) = cache.content_cache_coverage(server_id, track_id, &md5_16kb) {
if !coverage.has_loudness {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_16kb.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
}
}
bpm_started.elapsed().as_millis() as u64
} else {
0
};
Ok((
outcome,
AnalysisSeedTimings { seed_ms, bpm_ms },
))
seed_from_bytes_into_cache(&cache, track_id, bytes)
}
/// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache
/// directly, runs the same Symphonia → waveform → EBU R128 pipeline, and
/// upserts the rows. Called from `seed_from_bytes_execute` in production and
/// from tests against an in-memory cache.
/// Returns the outcome plus the computed `md5_16kb` (the content fingerprint),
/// so the AppHandle-aware caller can bridge it to the library `content_hash`
/// (E2) without re-reading the bytes.
pub fn seed_from_bytes_into_cache(
cache: &AnalysisCache,
server_id: &str,
track_id: &str,
bytes: &[u8],
format_hint: Option<&str>,
) -> Result<(SeedFromBytesOutcome, String), String> {
) -> Result<SeedFromBytesOutcome, String> {
let started = Instant::now();
// Write under the playback server's scope.
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_first_16kb(bytes),
};
let coverage = cache.content_cache_coverage(server_id, track_id, &key.md5_16kb)?;
if coverage.complete() {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} elapsed_ms={}",
track_id,
key.md5_16kb,
started.elapsed().as_millis()
);
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
}
if coverage.has_waveform && !coverage.has_loudness {
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
if let Some(existing) = cache.get_waveform(&key)? {
if !existing.bins.is_empty() {
if cache.loudness_row_exists_for_key(&key)? {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
);
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
}
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
}
}
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
crate::app_deprintln!(
@@ -171,8 +101,7 @@ pub fn seed_from_bytes_into_cache(
let build = (|| -> Result<(bool, usize), String> {
cache.touch_track_status(&key, "queued")?;
let (wf_bins, loudness_opt, used_pcm_decode) =
match analyze_loudness_and_waveform(bytes, -16.0, 500, format_hint) {
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
(
bins,
@@ -205,7 +134,6 @@ pub fn seed_from_bytes_into_cache(
}
cache.touch_track_status(&key, "ready")?;
let _ = cache.checkpoint_wal("analysis.seed");
Ok((used_pcm_decode, bins_len))
})();
@@ -226,7 +154,6 @@ pub fn seed_from_bytes_into_cache(
);
}
Err(e) => {
let _ = cache.touch_track_status(&key, "failed");
crate::app_deprintln!(
"[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}",
track_id,
@@ -237,12 +164,12 @@ pub fn seed_from_bytes_into_cache(
}
match build {
Ok(_) => Ok((SeedFromBytesOutcome::Upserted, key.md5_16kb.clone())),
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
Err(e) => Err(e),
}
}
pub fn md5_first_16kb(bytes: &[u8]) -> String {
fn md5_first_16kb(bytes: &[u8]) -> String {
let n = bytes.len().min(16 * 1024);
format!("{:x}", md5::compute(&bytes[..n]))
}
@@ -279,16 +206,15 @@ fn analyze_loudness_and_waveform(
bytes: &[u8],
target_lufs: f64,
bin_count: usize,
format_hint: Option<&str>,
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
if bytes.is_empty() || bin_count == 0 {
return None;
}
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes, format_hint)?;
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
if decoded_frames == 0 {
return None;
}
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs), format_hint)?;
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
let (i, t, r, tgt) = scanned.loudness?;
Some((i, t, r, tgt, scanned.bins))
}
@@ -298,60 +224,34 @@ fn analyze_loudness_and_waveform(
/// when the container reports total track length.
struct DecodeSession {
format: Box<dyn FormatReader>,
decoder: Box<dyn AudioDecoder>,
decoder: Box<dyn Decoder>,
track_id: u32,
timeline_hint: Option<u64>,
}
fn format_hint_from_bytes(bytes: &[u8]) -> Option<String> {
if bytes.len() < 4 {
return None;
}
if bytes[0..4] == *b"OggS" {
return Some("ogg".into());
}
if bytes.len() >= 4 && bytes[0..4] == *b"fLaC" {
return Some("flac".into());
}
if bytes.len() >= 12 && bytes[0..4] == *b"RIFF" && bytes[8..12] == *b"WAVE" {
return Some("wav".into());
}
let scan = bytes.len().min(4096).saturating_sub(4);
for i in 0..=scan {
if bytes[i..i + 4] == *b"ftyp" {
return Some("m4a".into());
}
}
None
}
fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option<DecodeSession> {
fn open_decode_session(bytes: &[u8]) -> Option<DecodeSession> {
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let sniffed = format_hint_from_bytes(bytes);
let mut hint = Hint::new();
if let Some(ext) = format_hint.or(sniffed.as_deref()) {
hint.with_extension(ext);
}
let format = symphonia::default::get_probe()
.probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
// Prefer an audio track that reports both sample rate and channels; fall back to
// the first audio track with a known codec (skips e.g. MJPEG cover-art tracks).
let format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| {
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some() && a.channels.is_some())
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.first_track_known_codec(TrackType::Audio))?;
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let timeline_hint = track.num_frames.filter(|&n| n > 0);
let audio_params = track.codec_params.as_ref()?.audio()?.clone();
let decoder = match make_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false)) {
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
let codec_params = track.codec_params.clone();
let decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
@@ -365,15 +265,14 @@ fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option<Decode
/// `codec_params.n_frames` when the container reports total track length — used
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
/// when the buffer grows.
fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -> Option<(u64, Option<u64>)> {
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
let DecodeSession { mut format, mut decoder, track_id, timeline_hint } =
open_decode_session(bytes, format_hint)?;
open_decode_session(bytes)?;
let mut total: u64 = 0;
let mut loop_i: u32 = 0;
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -382,12 +281,14 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let n = samples_buf.len();
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let n = samples.samples().len();
if n < n_ch || !n.is_multiple_of(n_ch) {
continue;
}
@@ -429,9 +330,8 @@ fn decode_scan_pcm(
decoded_frames: u64,
timeline_hint: Option<u64>,
loudness_target_lufs: Option<f64>,
format_hint: Option<&str>,
) -> Option<PcmScanResult> {
let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes, format_hint)?;
let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes)?;
let mut bin_max = vec![0.0f32; bin_count];
let mut bin_sum = vec![0.0f32; bin_count];
@@ -458,9 +358,8 @@ fn decode_scan_pcm(
}
let bin_grid_frames = decoded_frames.max(1);
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -470,14 +369,15 @@ fn decode_scan_pcm(
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
if loudness_target_lufs.is_some() && ebu.is_none() {
let ch = decoded.spec().channels().count() as u32;
let sr = decoded.spec().rate();
let ch = spec.channels.count() as u32;
let sr = spec.rate;
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
@@ -495,8 +395,9 @@ fn decode_scan_pcm(
}
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
@@ -528,7 +429,7 @@ fn decode_scan_pcm(
if loudness_target_lufs.is_some() {
if let Some(e) = ebu.as_mut() {
match e.add_frames_f32(&samples_buf) {
match e.add_frames_f32(samples.samples()) {
Ok(_) => fed_any_frames = true,
Err(err) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
@@ -602,171 +503,6 @@ fn decode_scan_pcm(
Some(PcmScanResult { bins, loudness })
}
/// PCM window for short MIR-style analysis (typically 60 s from track center).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PcmAnalysisWindow {
pub start_sec: f64,
pub duration_sec: f64,
}
/// Pick a centered analysis window, or the full track when shorter than `window_sec`.
pub fn analysis_pcm_window(total_duration_sec: f64, window_sec: f64) -> PcmAnalysisWindow {
let total = total_duration_sec.max(0.0);
let window = window_sec.max(0.1);
if total <= window || !total.is_finite() {
return PcmAnalysisWindow {
start_sec: 0.0,
duration_sec: if total > 0.0 { total } else { window },
};
}
let start = ((total - window) / 2.0).max(0.0);
PcmAnalysisWindow {
start_sec: start,
duration_sec: window,
}
}
/// Best-effort container duration from codec metadata (seconds).
pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
let session = open_decode_session(bytes, None)?;
let sample_rate = session
.format
.default_track(TrackType::Audio)
.or_else(|| session.format.tracks().first())
.and_then(|t| t.codec_params.as_ref())
.and_then(|c| c.audio())
.and_then(|a| a.sample_rate)
.filter(|&sr| sr > 0)?;
let frames = session.timeline_hint?;
Some(frames as f64 / sample_rate as f64)
}
/// Decode mono PCM for a time window. Seeks when `start_sec > 0`.
pub fn decode_mono_pcm_window(
bytes: &[u8],
start_sec: f64,
window_sec: f64,
) -> Result<(Vec<f32>, f32), String> {
if bytes.is_empty() {
return Err("empty audio buffer".to_string());
}
let DecodeSession {
mut format,
mut decoder,
track_id,
..
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
if start_sec.is_finite() && start_sec > 0.0 {
let time = Time::try_from_secs_f64(start_sec.max(0.0))
.ok_or_else(|| "pcm window: invalid seek time".to_string())?;
format
.seek(
SeekMode::Accurate,
SeekTo::Time {
time,
track_id: Some(track_id),
},
)
.map_err(|e| format!("pcm window seek failed: {e}"))?;
}
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, Some(window_sec))
}
/// Decode audio bytes to mono f32 PCM, optionally capped at `max_seconds`.
pub fn decode_mono_pcm_limited(
bytes: &[u8],
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
if bytes.is_empty() {
return Err("empty audio buffer".to_string());
}
let DecodeSession {
mut format,
mut decoder,
track_id,
..
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, max_seconds)
}
fn decode_mono_pcm_from_session(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn AudioDecoder>,
track_id: u32,
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
let mut mono = Vec::new();
let mut sample_rate = 0_f32;
let mut max_frames: Option<u64> = None;
let mut loop_i: u32 = 0;
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
if sample_rate <= 0.0 {
sample_rate = decoded.spec().rate() as f32;
if sample_rate <= 0.0 {
return Err("invalid sample rate".to_string());
}
max_frames = max_seconds.and_then(|sec| {
if sec.is_finite() && sec > 0.0 {
Some((sec * sample_rate as f64).max(1.0) as u64)
} else {
None
}
});
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
let frames = slice.len() / n_ch;
for f in 0..frames {
if let Some(limit) = max_frames {
if mono.len() as u64 >= limit {
break;
}
}
let base = f * n_ch;
let mut acc = 0.0_f32;
for c in 0..n_ch {
acc += slice[base + c];
}
mono.push(acc / (n_ch as f32));
}
if max_frames.is_some_and(|limit| mono.len() as u64 >= limit) {
break;
}
loop_i = loop_i.wrapping_add(1);
if loop_i.is_multiple_of(128) {
std::thread::yield_now();
}
}
if mono.is_empty() {
return Err("no PCM frames decoded".to_string());
}
Ok((mono, sample_rate))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -799,20 +535,6 @@ mod tests {
assert_eq!(huge_down, -24.0);
}
#[test]
fn analysis_pcm_window_uses_center_for_long_tracks() {
let w = analysis_pcm_window(180.0, 60.0);
assert!((w.start_sec - 60.0).abs() < 1e-9);
assert!((w.duration_sec - 60.0).abs() < 1e-9);
}
#[test]
fn analysis_pcm_window_uses_full_track_when_short() {
let w = analysis_pcm_window(45.0, 60.0);
assert_eq!(w.start_sec, 0.0);
assert!((w.duration_sec - 45.0).abs() < 1e-9);
}
// ── md5_first_16kb ────────────────────────────────────────────────────────
#[test]
@@ -959,7 +681,7 @@ mod tests {
#[test]
fn count_mono_frames_returns_decoded_length_for_synthetic_wav() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav, None)
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav)
.expect("WAV decode must succeed");
// 1 second × 44.1 kHz mono = 44 100 frames; allow ±1 packet tolerance.
assert!(
@@ -970,18 +692,18 @@ mod tests {
#[test]
fn count_mono_frames_returns_none_for_garbage_bytes() {
assert!(count_mono_frames_from_audio_bytes(b"not an audio file", None).is_none());
assert!(count_mono_frames_from_audio_bytes(b"not an audio file").is_none());
}
#[test]
fn count_mono_frames_returns_none_for_empty_bytes() {
assert!(count_mono_frames_from_audio_bytes(&[], None).is_none());
assert!(count_mono_frames_from_audio_bytes(&[]).is_none());
}
#[test]
fn analyze_loudness_and_waveform_returns_loudness_for_synthetic_sine() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
let result = analyze_loudness_and_waveform(&wav, -14.0, 100, None)
let result = analyze_loudness_and_waveform(&wav, -14.0, 100)
.expect("WAV decode must succeed");
let (integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins) = result;
assert_eq!(bins.len(), 200, "bins layout is peak_u8 + mean_u8 = 2 * bin_count");
@@ -1006,26 +728,24 @@ mod tests {
#[test]
fn analyze_loudness_returns_none_for_zero_bin_count() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100);
assert!(analyze_loudness_and_waveform(&wav, -14.0, 0, None).is_none());
assert!(analyze_loudness_and_waveform(&wav, -14.0, 0).is_none());
}
#[test]
fn analyze_loudness_returns_none_for_empty_bytes() {
assert!(analyze_loudness_and_waveform(&[], -14.0, 100, None).is_none());
assert!(analyze_loudness_and_waveform(&[], -14.0, 100).is_none());
}
#[test]
fn seed_from_bytes_into_cache_upserts_waveform_and_loudness_for_wav() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
let (outcome, md5) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track", &wav, None).unwrap();
let outcome = seed_from_bytes_into_cache(&cache, "wav-track", &wav).unwrap();
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint");
// Both a waveform AND a loudness row must exist after a successful
// PCM decode + EBU R128 analysis.
let key = TrackKey {
server_id: "server-a".to_string(),
track_id: "wav-track".to_string(),
md5_16kb: md5_first_16kb(&wav),
};
@@ -1035,34 +755,13 @@ mod tests {
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
}
#[test]
fn seed_from_bytes_into_cache_writes_under_the_given_server_scope() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
seed_from_bytes_into_cache(&cache, "server-x", "scoped-track", &wav, None).unwrap();
let md5 = md5_first_16kb(&wav);
let scoped = TrackKey {
server_id: "server-x".to_string(),
track_id: "scoped-track".to_string(),
md5_16kb: md5.clone(),
};
assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope");
let other = TrackKey {
server_id: "server-y".to_string(),
track_id: "scoped-track".to_string(),
md5_16kb: md5,
};
assert!(cache.get_waveform(&other).unwrap().is_none(), "row stays under the exact server");
}
#[test]
fn seed_from_bytes_into_cache_returns_skipped_on_second_call() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (first, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav, None).unwrap();
let first = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap();
assert_eq!(first, SeedFromBytesOutcome::Upserted);
let (second, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav, None).unwrap();
let second = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap();
assert_eq!(
second,
SeedFromBytesOutcome::SkippedWaveformCacheHit,
@@ -1076,11 +775,10 @@ mod tests {
// Garbage bytes — Symphonia probe fails, the pipeline falls back to
// `derive_waveform_bins` (no loudness row gets cached).
let bytes = vec![0xAAu8; 8 * 1024];
let (outcome, _) = seed_from_bytes_into_cache(&cache, "server-a", "garbage", &bytes, None).unwrap();
let outcome = seed_from_bytes_into_cache(&cache, "garbage", &bytes).unwrap();
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
let key = TrackKey {
server_id: "server-a".to_string(),
track_id: "garbage".to_string(),
md5_16kb: md5_first_16kb(&bytes),
};
@@ -1091,204 +789,4 @@ mod tests {
"byte-envelope fallback must not cache loudness"
);
}
#[test]
fn audio_duration_from_bytes_reports_duration_for_wav() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
let duration = audio_duration_from_bytes(&wav).expect("duration must be available");
assert!(
(1.8..=2.2).contains(&duration),
"expected ~2s duration, got {duration}"
);
}
#[test]
fn audio_duration_from_bytes_returns_none_for_garbage() {
assert!(audio_duration_from_bytes(b"not audio").is_none());
}
#[test]
fn decode_mono_pcm_limited_decodes_and_respects_limit() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(48_000, 2.0), 48_000);
let (full_pcm, sr_full) = decode_mono_pcm_limited(&wav, None).expect("full decode");
assert_eq!(sr_full, 48_000.0);
assert!(
full_pcm.len() >= 95_000,
"2 seconds at 48kHz should decode close to 96k samples"
);
let (limited_pcm, sr_limited) =
decode_mono_pcm_limited(&wav, Some(0.25)).expect("limited decode");
assert_eq!(sr_limited, 48_000.0);
assert!(
(11_500..=12_500).contains(&limited_pcm.len()),
"0.25 seconds at 48kHz should decode ~12k samples, got {}",
limited_pcm.len()
);
assert!(limited_pcm.len() < full_pcm.len());
}
#[test]
fn decode_mono_pcm_limited_rejects_empty_buffer() {
let err = decode_mono_pcm_limited(&[], Some(1.0)).unwrap_err();
assert!(err.contains("empty audio buffer"));
}
#[test]
fn decode_mono_pcm_window_decodes_center_slice() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
let (window_pcm, sr) = decode_mono_pcm_window(&wav, 0.75, 0.5).expect("window decode");
assert_eq!(sr, 44_100.0);
assert!(
(20_000..=24_000).contains(&window_pcm.len()),
"0.5 seconds at 44.1kHz should decode ~22k samples, got {}",
window_pcm.len()
);
}
#[test]
fn decode_mono_pcm_window_rejects_invalid_bytes() {
let err = decode_mono_pcm_window(b"not-audio", 0.0, 1.0).unwrap_err();
assert!(
err.contains("failed to open audio decode session"),
"unexpected error: {err}"
);
}
#[test]
fn decode_scan_pcm_supports_waveform_only_mode_without_loudness() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting");
let scanned = decode_scan_pcm(&wav, 64, frames, hint, None, None).expect("scan must succeed");
assert_eq!(scanned.bins.len(), 128);
assert!(scanned.loudness.is_none());
}
#[test]
fn decode_scan_pcm_with_loudness_target_returns_loudness_tuple() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting");
let scanned = decode_scan_pcm(&wav, 64, frames, hint, Some(-14.0), None).expect("scan must succeed");
assert_eq!(scanned.bins.len(), 128);
let (integrated_lufs, true_peak, recommended_gain_db, target_lufs) =
scanned.loudness.expect("loudness tuple must be present");
assert!(integrated_lufs.is_finite());
assert!(true_peak.is_finite());
assert!((-24.0..=24.0).contains(&recommended_gain_db));
assert_eq!(target_lufs, -14.0);
}
#[test]
fn decode_scan_pcm_returns_none_for_non_audio_input() {
assert!(decode_scan_pcm(b"nope", 32, 10, None, Some(-14.0), None).is_none());
}
#[test]
fn seed_from_bytes_reanalyzes_when_waveform_exists_without_loudness() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let md5 = md5_first_16kb(&wav);
let key = TrackKey {
server_id: "server-a".to_string(),
track_id: "track-reseed".to_string(),
md5_16kb: md5,
};
cache.touch_track_status(&key, "ready").unwrap();
cache
.upsert_waveform(
&key,
&WaveformEntry {
bins: vec![8u8; 1000],
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: now_unix_ts(),
},
)
.unwrap();
assert!(!cache.loudness_row_exists_for_key(&key).unwrap());
let (outcome, _) =
seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav, None).unwrap();
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
}
#[test]
fn analysis_pcm_window_handles_negative_and_non_finite_durations() {
let neg = analysis_pcm_window(-42.0, 60.0);
assert_eq!(neg.start_sec, 0.0);
assert_eq!(neg.duration_sec, 60.0);
let inf = analysis_pcm_window(f64::INFINITY, 60.0);
assert_eq!(inf.start_sec, 0.0);
assert!(!inf.duration_sec.is_finite());
}
#[test]
fn decode_mono_pcm_window_rejects_empty_buffer() {
let err = decode_mono_pcm_window(&[], 0.0, 1.0).unwrap_err();
assert!(err.contains("empty audio buffer"));
}
#[test]
fn decode_mono_pcm_limited_rejects_invalid_bytes() {
let err = decode_mono_pcm_limited(b"not-audio", Some(0.5)).unwrap_err();
assert!(err.contains("failed to open audio decode session"));
}
#[test]
fn decode_mono_pcm_limited_ignores_non_positive_or_non_finite_cap() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (full_a, _) = decode_mono_pcm_limited(&wav, None).unwrap();
let (full_b, _) = decode_mono_pcm_limited(&wav, Some(0.0)).unwrap();
let (full_c, _) = decode_mono_pcm_limited(&wav, Some(f64::NAN)).unwrap();
assert_eq!(full_a.len(), full_b.len());
assert_eq!(full_a.len(), full_c.len());
}
#[test]
fn decode_scan_pcm_returns_none_when_no_frames_decoded() {
let wav = build_mono_pcm16_wav(&[], 44_100);
assert!(analyze_loudness_and_waveform(&wav, -14.0, 64, None).is_none());
}
#[test]
fn decode_scan_pcm_ignores_oversized_timeline_hint() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting");
let scanned = decode_scan_pcm(&wav, 64, frames, Some(frames * 10), None, None).unwrap();
assert_eq!(scanned.bins.len(), 128);
}
#[test]
fn seed_from_bytes_execute_returns_no_cache_without_registered_state() {
let app = tauri::test::mock_app();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.25), 44_100);
let handle = app.handle().clone();
let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav, None, true)
.expect("seed execute should return a graceful skip");
assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache);
assert_eq!(timings.seed_ms, 0);
assert_eq!(timings.bpm_ms, 0);
}
#[test]
fn seed_from_bytes_execute_runs_with_registered_cache() {
let app = tauri::test::mock_app();
app.manage(AnalysisCache::open_in_memory());
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100);
let handle = app.handle().clone();
let (first, timings_first) =
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, None, true).unwrap();
assert_eq!(first, SeedFromBytesOutcome::Upserted);
assert!(timings_first.seed_ms <= 30_000);
let (second, timings_second) =
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, None, true).unwrap();
assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit);
assert!(timings_second.seed_ms <= 30_000);
}
}
@@ -2,11 +2,7 @@ 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,
recommended_gain_for_target, seed_from_bytes_execute, seed_from_bytes_into_cache,
SeedFromBytesOutcome,
};
pub use store::{AnalysisCache, 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)
}
@@ -5,10 +5,14 @@
use std::collections::HashSet;
use tauri::Manager;
use psysonic_core::ports::PlaybackQueryHandle;
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,
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
AnalysisBackfillEnqueueKind,
};
#[derive(serde::Serialize)]
@@ -45,92 +49,43 @@ pub struct LoudnessCachePayload {
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.
/// AppHandle-free helper: looks up a waveform by exact `(track_id, md5_16kb)`
/// key and 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(),
let key = analysis_cache::TrackKey {
track_id: track_id.to_string(),
md5_16kb: md5_16kb.to_string(),
};
Ok(cache
.get_waveform(&exact)?
.map(WaveformCachePayload::from))
Ok(cache.get_waveform(&key)?.map(WaveformCachePayload::from))
}
/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)`
/// AppHandle-free helper: looks up the latest waveform for `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)?
.get_latest_waveform_for_track(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.
/// AppHandle-free helper: looks up the latest loudness row for `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| {
Ok(cache.get_latest_loudness_for_track(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,
@@ -151,11 +106,9 @@ pub fn get_loudness_payload_for_track(
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);
let result = get_waveform_payload(cache.inner(), &track_id, &md5_16kb);
if let Ok(ref payload) = result {
match payload {
Some(v) => crate::app_deprintln!(
@@ -174,11 +127,9 @@ pub fn analysis_get_waveform(
#[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);
let result = get_waveform_payload_for_track(cache.inner(), &track_id);
if let Ok(ref payload) = result {
match payload {
Some(v) => crate::app_deprintln!(
@@ -195,29 +146,25 @@ pub fn analysis_get_waveform_for_track(
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)
get_loudness_payload_for_track(cache.inner(), &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)
cache.delete_loudness_for_track_id(&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)
cache.delete_waveform_for_track_id(&track_id)
}
#[tauri::command]
@@ -227,146 +174,70 @@ pub fn analysis_delete_all_waveforms(
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);
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
let force = force.unwrap_or(false);
if !force {
if let Some(playback) = app.try_state::<PlaybackQueryHandle>() {
if playback.ranged_loudness_backfill_should_defer(&track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
}
}
}
if !force {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
}
}
}
let tid_log = track_id.clone();
let high_priority = analysis_backfill_is_current_track(&app, &track_id);
let shared = analysis_backfill_shared(&app);
let kind = {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
st.enqueue(track_id, url, high_priority)
};
match kind {
AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill enqueued: track_id={} position={}",
tid_log,
if high_priority { "front" } else { "back" }
);
}
AnalysisBackfillEnqueueKind::ReorderedFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill bumped to front (current track) track_id={}",
tid_log
);
}
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
}
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 {
@@ -382,7 +253,6 @@ pub struct AnalysisPrunePendingResult {
#[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();
@@ -397,10 +267,8 @@ pub fn analysis_prune_pending_to_track_ids(
}
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)?;
prune_analysis_queues(&keep_track_ids)?;
if http_removed > 0 || cpu_removed_jobs > 0 {
crate::app_deprintln!(
@@ -429,7 +297,6 @@ mod tests {
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(),
}
@@ -475,7 +342,7 @@ mod tests {
#[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();
let payload = get_waveform_payload(&cache, "missing", "deadbeef").unwrap();
assert!(payload.is_none());
}
@@ -484,7 +351,7 @@ mod tests {
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")
let payload = get_waveform_payload(&cache, "abc", "deadbeef")
.unwrap()
.expect("payload exists");
assert_eq!(payload.bins, bins);
@@ -500,8 +367,8 @@ mod tests {
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();
let p1 = get_waveform_payload(&cache, "abc", "aaaa").unwrap().unwrap();
let p2 = get_waveform_payload(&cache, "abc", "bbbb").unwrap().unwrap();
assert_ne!(p1.bins, p2.bins);
}
@@ -513,7 +380,7 @@ mod tests {
// 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")
let payload = get_waveform_payload_for_track(&cache, "abc")
.unwrap()
.expect("bare-id lookup must hit the stream-prefixed row");
assert_eq!(payload.bin_count, 4);
@@ -522,7 +389,7 @@ mod tests {
#[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());
assert!(get_waveform_payload_for_track(&cache, "phantom").unwrap().is_none());
}
// ── get_loudness_payload_for_track ────────────────────────────────────────
@@ -533,7 +400,7 @@ mod tests {
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))
let payload = get_loudness_payload_for_track(&cache, "abc", Some(-10.0))
.unwrap()
.expect("loudness row exists");
assert_eq!(payload.target_lufs, -10.0);
@@ -548,7 +415,7 @@ mod tests {
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)
let payload = get_loudness_payload_for_track(&cache, "abc", None)
.unwrap()
.unwrap();
assert_eq!(payload.target_lufs, -16.0);
@@ -559,11 +426,11 @@ mod tests {
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))
let too_high = get_loudness_payload_for_track(&cache, "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))
let too_low = get_loudness_payload_for_track(&cache, "abc", Some(-100.0))
.unwrap()
.unwrap();
assert_eq!(too_low.target_lufs, -30.0);
@@ -572,7 +439,7 @@ mod tests {
#[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)
assert!(get_loudness_payload_for_track(&cache, "phantom", None)
.unwrap()
.is_none());
}
@@ -6,12 +6,8 @@
//! - `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.
@@ -1,285 +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 {
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
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 cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
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 enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
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");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -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)
}
+5 -7
View File
@@ -3,7 +3,6 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -16,9 +15,9 @@ 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"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
ringbuf = "0.5"
biquad = "0.6"
dasp_sample = "0.11.0"
@@ -26,14 +25,13 @@ md5 = "0.8"
url = "2"
thread-priority = "3"
lofty = "0.24"
id3 = "1.17"
pitch_shift = "2.1"
id3 = "1.16.4"
[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"] }
zbus = { version = "5.15", default-features = false, features = ["blocking-api", "async-io"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
@@ -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}"
);
}
});
}
+6 -7
View File
@@ -1,22 +1,21 @@
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
use std::sync::OnceLock;
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
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.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn try_make_radio_decoder(
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
}
@@ -19,7 +19,6 @@ use super::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};
@@ -30,11 +29,6 @@ use super::state::{ChainedInfo, PreloadedTrack};
/// 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]
@@ -51,7 +45,6 @@ pub async fn audio_play(
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>,
@@ -141,11 +134,6 @@ pub async fn audio_play(
.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);
@@ -157,7 +145,6 @@ pub async fn audio_play(
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,
@@ -252,7 +239,6 @@ pub async fn audio_play(
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(),
@@ -348,11 +334,7 @@ pub async fn audio_play(
// 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 needs_prefill = hi_res_enabled && output_rate > 48_000;
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start {
sink.pause();
@@ -392,12 +374,12 @@ pub async fn audio_play(
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;
// 500 ms lets rodio decode several seconds of hi-res audio into its
// internal buffer while the sink is paused. The hardware sees no gap
// because the output is held — it only starts draining after sink.play().
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
// absorbing scheduler jitter and PipeWire graph wake-up latency.
tokio::time::sleep(Duration::from_millis(500)).await;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
@@ -437,7 +419,6 @@ pub async fn audio_play(
}
// ── Progress + ended detection ────────────────────────────────────────────
let analysis_app = app.clone();
spawn_progress_task(
gen,
state.generation.clone(),
@@ -447,14 +428,12 @@ pub async fn audio_play(
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(())
@@ -482,7 +461,6 @@ pub async fn audio_chain_preload(
fallback_db: f32,
hi_res_enabled: bool,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -545,27 +523,6 @@ pub async fn audio_chain_preload(
.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
@@ -598,7 +555,6 @@ pub async fn audio_chain_preload(
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(),
@@ -647,8 +603,6 @@ pub async fn audio_chain_preload(
*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,
+112 -346
View File
@@ -1,24 +1,22 @@
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
use std::io::{Cursor, Read, Seek};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::UniformSourceIterator;
use rodio::Source;
use symphonia::core::{
audio::{AudioSpec, GenericAudioBufferRef},
codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions},
formats::probe::Hint,
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
common::Limit,
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
units::{Time, Timestamp},
probe::Hint,
units::{self, Time},
};
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
use super::playback_rate::{PlaybackRateAtomics, PlaybackRateSource};
use super::sources::*;
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
@@ -53,46 +51,6 @@ impl MediaSource for SizedCursorSource {
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── ProbeSeekGate — temporarily hide seekability during probing ──────────────
//
// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…)
// whenever the source reports `is_seekable() == true` and a known `byte_len()`.
// That scan seeks to the end of the stream. For a progressive ranged-HTTP source
// this forces a download all the way to EOF before the first sample can play
// (FLAC/MP3/OGG regressed to "won't start until fully downloaded").
//
// These formats are demuxed sequentially from the start, and their seek paths
// re-check `is_seekable()` dynamically, so we can advertise the source as
// non-seekable for the duration of the probe (skipping the trailing scan) and
// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is
// excluded because its demuxer captures seekability at construction and relies
// on seeking to locate `moov` (its tail is prefetched separately instead).
struct ProbeSeekGate {
inner: Box<dyn MediaSource>,
seekable: Arc<AtomicBool>,
}
impl Read for ProbeSeekGate {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for ProbeSeekGate {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for ProbeSeekGate {
fn is_seekable(&self) -> bool {
self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable()
}
fn byte_len(&self) -> Option<u64> {
self.inner.byte_len()
}
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
@@ -106,19 +64,19 @@ impl MediaSource for ProbeSeekGate {
/// playback is genuinely lossless.
pub(crate) fn log_codec_resolution(
tag: &str,
params: &AudioCodecParameters,
params: &symphonia::core::codecs::CodecParameters,
container_hint: Option<&str>,
) {
let codec_name = symphonia::default::get_codecs()
.get_audio_decoder(params.codec)
.map(|d| d.codec.info.short_name)
.get_codec(params.codec)
.map(|d| d.short_name)
.unwrap_or("?");
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
let bits = params.bits_per_sample
.or(params.bits_per_coded_sample)
.map(|b| format!("{}-bit", b))
.unwrap_or_else(|| "?-bit".into());
let ch = params.channels.as_ref()
let ch = params.channels
.map(|c| format!("{}ch", c.count()))
.unwrap_or_else(|| "?ch".into());
let lossless = codec_name.starts_with("pcm")
@@ -140,20 +98,14 @@ const DECODE_MAX_RETRIES: usize = 3;
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
/// playable track (VLC-style frame dropping).
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose
/// download stalls (e.g. right after a server switch) can otherwise block the
/// probe — and therefore playback start — indefinitely. On timeout we abort with
/// an error so the player can recover/retry instead of hanging until a restart.
const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
pub(crate) struct SizedDecoder {
decoder: Box<dyn AudioDecoder>,
decoder: Box<dyn symphonia::core::codecs::Decoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
/// Interleaved f32 samples of the currently decoded packet.
buffer: Vec<f32>,
spec: AudioSpec,
buffer: SampleBuffer<f32>,
spec: SignalSpec,
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
/// successfully decoded frame. Used to detect fully undecodable streams.
consecutive_decode_errors: usize,
@@ -166,38 +118,36 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mut hint = Hint::new();
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions::default();
let format_opts = FormatOptions {
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
// present in older iTunes-purchased M4A files.
enable_gapless: false,
..Default::default()
};
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
let meta_opts =
MetadataOptions::default().limit_visual_bytes(Limit::Maximum(8 * 1024 * 1024));
let meta_opts = symphonia::core::meta::MetadataOptions {
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
..Default::default()
};
let mut format = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &meta_opts)
.map_err(|e| {
let hint_str = format_hint.unwrap_or("unknown");
// Always print the raw Symphonia error to the terminal for diagnosis.
@@ -209,49 +159,30 @@ impl SizedDecoder {
}
})?;
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
let track = probed.format
.tracks()
.iter()
// Explicitly select only audio tracks: must have an audio codec and a
// Explicitly select only audio tracks: must have a valid codec and a
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
// files embed as a secondary video track.
.find(|t| {
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some())
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
})
.ok_or_else(|| {
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", format.tracks().len());
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
"no playable audio track found in file".to_string()
})?;
let track_id = track.id;
// Encoder-delay-aware total duration (timebase units → Time).
let total_duration = track
.time_base
.zip(track.num_frames)
.and_then(|(base, frames)| {
Timestamp::try_from(frames).ok().and_then(|ts| base.calc_time(ts))
});
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| "selected track has no audio codec parameters".to_string())?
.clone();
log_codec_resolution("bytes", &track.codec_params, format_hint);
log_codec_resolution("bytes", &audio_params, format_hint);
// Gapless trimming is performed by `build_source` (iTunSMPB), so disable
// the decoder's built-in trimming to avoid double-trimming.
let mut decoder = psysonic_codec_registry()
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| {
crate::app_eprintln!("[psysonic] codec init failed: {e}");
if e.to_string().to_lowercase().contains("unsupported") {
@@ -261,15 +192,15 @@ impl SizedDecoder {
}
})?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(Some(p)) => p,
// Clean EOF before any decodable packet.
Ok(None) => break decoder.last_decoded(),
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
@@ -278,8 +209,8 @@ impl SizedDecoder {
return Err(format!("could not read audio data: {e}"));
}
};
if packet.track_id != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id, track_id);
if packet.track_id() != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
continue;
}
match decoder.decode(&packet) {
@@ -298,8 +229,8 @@ impl SizedDecoder {
}
};
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
Ok(SizedDecoder {
decoder,
@@ -320,107 +251,34 @@ impl SizedDecoder {
format_hint: Option<&str>,
source_tag: &str,
) -> Result<Self, String> {
// For non-MP4 progressive streams, hide seekability during the probe so
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
// and block until the whole file is downloaded). Re-enabled right after.
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
// prefetched separately).
let stream_len = media.byte_len();
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
};
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
// read() call frequency into the ring buffer, easing I/O spikes.
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
let format_opts = FormatOptions::default();
let meta_opts = MetadataOptions::default();
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
crate::app_deprintln!(
"[stream] {source_tag}: probe start (hint={}, stream_len={})",
format_hint.unwrap_or("?"),
stream_len.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
);
let probe_start = std::time::Instant::now();
// Run the probe on a dedicated thread guarded by a timeout. If a ranged
// source stalls (download never reaches the bytes Symphonia needs), the
// probe blocks forever; without this guard playback start would hang until
// the user restarts the player. On timeout we abandon the worker thread
// (it unblocks once the underlying read errors/returns) and surface an
// error so the caller can retry.
let hint_ext = format_hint.map(|s| s.to_string());
let tag_owned = source_tag.to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("symphonia-probe".into())
.spawn(move || {
let mut hint = Hint::new();
if let Some(ext) = &hint_ext {
hint.with_extension(ext);
}
let result = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| format!("{tag_owned}: format probe failed: {e}"));
// Receiver is gone if we already timed out — ignore the send error.
let _ = tx.send(result);
})
.map_err(|e| format!("{source_tag}: failed to spawn probe thread: {e}"))?;
let mut format = match rx.recv_timeout(STREAM_PROBE_TIMEOUT) {
Ok(Ok(format)) => format,
Ok(Err(e)) => return Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
crate::app_eprintln!(
"[stream] {source_tag}: probe timed out after {STREAM_PROBE_TIMEOUT:?} \
(stream stalled?) aborting so the player can retry"
);
return Err(format!(
"{source_tag}: format probe timed out after {STREAM_PROBE_TIMEOUT:?}"
));
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err(format!("{source_tag}: probe thread ended unexpectedly"));
}
};
crate::app_deprintln!(
"[stream] {source_tag}: probe done in {} ms",
probe_start.elapsed().as_millis()
);
// Trailing-metadata scan is done; restore real seekability for scrubbing.
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format.tracks().iter()
.find(|t| t.codec_params.as_ref().and_then(|c| c.audio()).is_some())
let track = probed.format.tracks().iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
let track_id = track.id;
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| format!("{source_tag}: track has no audio codec parameters"))?
.clone();
log_codec_resolution(source_tag, &audio_params, format_hint);
log_codec_resolution(source_tag, &track.codec_params, format_hint);
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = try_make_radio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
let mut format = probed.format;
let mut errors = 0usize;
let decoded = loop {
let packet = match format.next_packet() {
Ok(Some(p)) => p,
Ok(None) => break decoder.last_decoded(),
Ok(p) => p,
Err(_) => break decoder.last_decoded(),
};
if packet.track_id != track_id { continue; }
if packet.track_id() != track_id { continue; }
match decoder.decode(&packet) {
Ok(d) => break d,
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
@@ -433,15 +291,16 @@ impl SizedDecoder {
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
}
};
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
}
#[inline]
fn make_buffer(decoded: &GenericAudioBufferRef<'_>) -> Vec<f32> {
let mut buffer = Vec::new();
decoded.copy_to_vec_interleaved(&mut buffer);
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
@@ -451,43 +310,29 @@ impl SizedDecoder {
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
// Number of frames between where the demuxer landed and the requested ts.
let mut samples_to_pass: u64 = seek_res
.required_ts
.get()
.saturating_sub(seek_res.actual_ts.get())
.max(0) as u64;
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
let packet = loop {
let candidate = match self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?
{
Some(p) => p,
// EOF while refining — nothing more to skip.
None => return Ok(()),
};
if candidate.dur.get() > samples_to_pass {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur.get();
samples_to_pass -= candidate.dur();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = match self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?
{
Some(p) => p,
None => break,
};
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels().count();
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
Ok(())
}
}
@@ -503,12 +348,12 @@ impl Iterator for SizedDecoder {
// drop the frame and advance to the next packet. IO errors and a
// clean end-of-stream both terminate the iterator normally.
loop {
let packet = self.format.next_packet().ok()??;
let packet = self.format.next_packet().ok()?;
match self.decoder.decode(&packet) {
Ok(decoded) => {
self.consecutive_decode_errors = 0;
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = 0;
break;
}
@@ -539,7 +384,7 @@ impl Iterator for SizedDecoder {
}
}
let sample = *self.buffer.get(self.current_frame_offset)?;
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
@@ -548,24 +393,25 @@ impl Iterator for SizedDecoder {
impl Source for SizedDecoder {
#[inline]
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.len())
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZeroU16::new(self.spec.channels().count() as u16)
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
.unwrap_or(std::num::NonZeroU16::MIN)
}
#[inline]
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZeroU32::new(self.spec.rate()).unwrap_or(std::num::NonZeroU32::MIN)
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration
.map(|t| Duration::from_secs_f64(t.as_secs_f64().max(0.0)))
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
@@ -573,19 +419,20 @@ impl Source for SizedDecoder {
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let target_secs = if seek_beyond_end {
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
// Step back a tiny bit — some demuxers can't seek to the exact end.
let total = self
.total_duration
.map(|t| t.as_secs_f64())
.unwrap_or_else(|| pos.as_secs_f64());
(total - 0.0001).max(0.0)
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
} else {
pos.as_secs_f64()
pos.as_secs_f64().into()
};
let time = Time::try_from_secs_f64(target_secs).unwrap_or(Time::ZERO);
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
@@ -699,7 +546,6 @@ pub(crate) fn build_source(
eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>,
playback_rate: PlaybackRateAtomics,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
@@ -770,9 +616,7 @@ pub(crate) fn build_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
let rate_dyn = DynSource::new(rate_src);
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -781,7 +625,7 @@ pub(crate) fn build_source(
Ok(BuiltSource {
source: boosted,
duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
duration_secs: effective_dur,
output_rate,
output_channels: channels.get(),
fadeout_trigger,
@@ -799,7 +643,6 @@ pub(crate) fn build_streaming_source(
eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>,
playback_rate: PlaybackRateAtomics,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
@@ -839,9 +682,7 @@ pub(crate) fn build_streaming_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
let rate_dyn = DynSource::new(rate_src);
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -853,7 +694,7 @@ pub(crate) fn build_streaming_source(
Ok(BuiltSource {
source: boosted,
duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
duration_secs: effective_dur,
output_rate,
output_channels: channels.get(),
fadeout_trigger,
@@ -993,8 +834,8 @@ mod tests {
fn sized_decoder_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
assert_eq!(decoder.spec.rate, 44_100);
assert_eq!(decoder.spec.channels.count(), 1);
}
#[test]
@@ -1009,84 +850,21 @@ mod tests {
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
}
// ── new_streaming + ProbeSeekGate ────────────────────────────────────────
fn seekable_source(bytes: Vec<u8>) -> Box<dyn MediaSource> {
let len = bytes.len() as u64;
Box::new(SizedCursorSource { inner: Cursor::new(bytes), len })
}
#[test]
fn new_streaming_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
assert!(decoder.total_duration.is_none());
}
#[test]
fn new_streaming_returns_err_for_garbage_input() {
let result = SizedDecoder::new_streaming(
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
);
assert!(result.is_err());
}
#[test]
fn probe_seek_gate_toggles_seekability() {
let wav = synthetic_wav_bytes(0.1);
let len = wav.len() as u64;
let flag = Arc::new(AtomicBool::new(false));
let gate = ProbeSeekGate {
inner: seekable_source(wav),
seekable: flag.clone(),
};
// Hidden during probe …
assert!(!gate.is_seekable());
// … restored afterwards.
flag.store(true, Ordering::Relaxed);
assert!(gate.is_seekable());
// byte_len always passes through to the inner source.
assert_eq!(gate.byte_len(), Some(len));
}
#[test]
fn probe_seek_gate_read_and_seek_pass_through() {
let bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let mut gate = ProbeSeekGate {
inner: seekable_source(bytes),
seekable: Arc::new(AtomicBool::new(true)),
};
let mut buf = [0u8; 4];
let n = gate.read(&mut buf).expect("read");
assert_eq!(n, 4);
assert_eq!(&buf, &[1, 2, 3, 4]);
let pos = gate.seek(std::io::SeekFrom::Start(6)).expect("seek");
assert_eq!(pos, 6);
let n = gate.read(&mut buf).expect("read after seek");
assert_eq!(&buf[..n], &[7, 8]);
}
// ── log_codec_resolution ─────────────────────────────────────────────────
#[test]
fn log_codec_resolution_does_not_panic_for_valid_params() {
let mut params = AudioCodecParameters::new();
params.codec = symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE;
let mut params = symphonia::core::codecs::CodecParameters::new();
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
params.sample_rate = Some(44_100);
params.bits_per_sample = Some(16);
params.channels = Some(symphonia::core::audio::Channels::Discrete(1));
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
log_codec_resolution("test-tag", &params, Some("wav"));
}
#[test]
fn log_codec_resolution_handles_unknown_codec_gracefully() {
let params = AudioCodecParameters::new();
let params = symphonia::core::codecs::CodecParameters::new();
log_codec_resolution("unknown", &params, None);
}
}
@@ -1137,29 +915,21 @@ mod build_source_tests {
}
type EqGains = Arc<[AtomicU32; 10]>;
type SourceArgs = (
EqGains,
Arc<AtomicBool>,
Arc<AtomicU32>,
PlaybackRateAtomics,
Arc<AtomicBool>,
Arc<AtomicU64>,
);
type SourceArgs = (EqGains, Arc<AtomicBool>, Arc<AtomicU32>, Arc<AtomicBool>, Arc<AtomicU64>);
fn default_source_args() -> SourceArgs {
let eq_gains: Arc<[AtomicU32; 10]> =
Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits())));
let eq_enabled = Arc::new(AtomicBool::new(false));
let eq_pre_gain = Arc::new(AtomicU32::new(0f32.to_bits()));
let playback_rate = PlaybackRateAtomics::new();
let done_flag = Arc::new(AtomicBool::new(false));
let sample_counter = Arc::new(AtomicU64::new(0));
(eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter)
(eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter)
}
#[test]
fn build_source_succeeds_for_synthetic_wav() {
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let wav = synthetic_wav_bytes_local(0.4);
let built = build_source(
wav,
@@ -1167,7 +937,6 @@ mod build_source_tests {
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::ZERO,
sample_counter,
@@ -1183,14 +952,13 @@ mod build_source_tests {
#[test]
fn build_source_returns_err_for_garbage_bytes() {
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let result = build_source(
vec![0u8; 32],
0.0,
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::ZERO,
sample_counter,
@@ -1203,7 +971,7 @@ mod build_source_tests {
#[test]
fn build_streaming_source_succeeds_for_synthetic_wav() {
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let wav = synthetic_wav_bytes_local(0.4);
let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap();
let built = build_streaming_source(
@@ -1212,7 +980,6 @@ mod build_source_tests {
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::ZERO,
sample_counter,
@@ -1226,7 +993,7 @@ mod build_source_tests {
#[test]
fn build_source_with_target_rate_resamples() {
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let wav = synthetic_wav_bytes_local(0.3);
let built = build_source(
wav,
@@ -1234,7 +1001,6 @@ mod build_source_tests {
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::from_millis(5),
sample_counter,
@@ -143,9 +143,6 @@ pub(crate) async fn try_resume_after_device_change(
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,
@@ -153,7 +150,6 @@ pub(crate) async fn try_resume_after_device_change(
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(),
@@ -222,15 +218,6 @@ pub(crate) async fn try_resume_after_device_change(
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}");
@@ -245,7 +232,6 @@ pub(crate) async fn try_resume_after_device_change(
// 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(),
@@ -255,14 +241,12 @@ pub(crate) async fn try_resume_after_device_change(
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!(
@@ -224,18 +224,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
}
}
// The full `output_devices()` + per-device `description()` scan is the
// CoreAudio HAL call that contends with the audio render thread and
// produces a brief dropout once per poll interval (issue #996: stutter
// every ~3s, cadence tracking the poll exactly). It is only needed to
// detect a *pinned* output device disappearing. With no pin — system
// default, the common case — only the current default is needed, a
// single cheap query, so the full enumeration is skipped entirely.
let pinned = selected_device.lock().unwrap().clone();
let need_full_enum = pinned.is_some();
// 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(move || {
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
#[cfg(unix)]
let _guard = unsafe {
@@ -253,28 +244,26 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let default = host
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let available: Vec<String> = if need_full_enum {
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
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 (only when we actually enumerated for a pinned device)
// almost always means a transient enumeration failure, not that every
// output device vanished. Treating it as "pinned missing" caused false
// audio:device-reset (UI jumped back to system default) when switching
// to external USB / class-compliant interfaces.
if need_full_enum && available.is_empty() {
// 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.
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::{AppHandle, Manager};
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -31,7 +32,6 @@ pub struct AudioEngine {
pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>,
pub eq_pre_gain: Arc<AtomicU32>,
pub playback_rate: crate::playback_rate::PlaybackRateAtomics,
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
/// Last fully downloaded manual-stream track bytes (same playback identity),
/// used to recover seek/replay without waiting for network again.
@@ -82,11 +82,6 @@ pub struct AudioEngine {
/// Subsonic song id last passed from JS with `audio_play` (trimmed). Used
/// for loudness/waveform cache when the URL is `psysonic-local://…`.
pub(crate) current_analysis_track_id: Arc<Mutex<Option<String>>>,
/// App server id (`playbackServerId ?? activeServerId`) of the current
/// playback, pinned by `audio_play`. Scopes analysis-cache reads (loudness
/// gain, replay-gain updates, device resume) to the right server so a switch
/// can't surface another server's blob for the same bare `track_id`.
pub(crate) current_playback_server_id: Arc<Mutex<Option<String>>>,
/// While a `RangedHttpSource` download task is filling the buffer for this
/// `(track_id, play_generation)`, skip `analysis_enqueue_seed_from_url` for the
/// same id — otherwise a parallel full GET + Symphonia competes with playback
@@ -366,7 +361,6 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
playback_rate: crate::playback_rate::PlaybackRateAtomics::new(),
preloaded: Arc::new(Mutex::new(None)),
stream_completed_cache: Arc::new(Mutex::new(None)),
stream_completed_spill: Arc::new(Mutex::new(None)),
@@ -387,7 +381,6 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
radio_state: Mutex::new(None),
current_playback_url: Arc::new(Mutex::new(None)),
current_analysis_track_id: Arc::new(Mutex::new(None)),
current_playback_server_id: Arc::new(Mutex::new(None)),
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
preview_sink: Arc::new(Mutex::new(None)),
preview_gen: Arc::new(AtomicU64::new(0)),
@@ -458,3 +451,7 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool {
app.try_state::<AudioEngine>()
.is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id))
}
+112 -28
View File
@@ -326,14 +326,12 @@ pub(crate) fn resolve_loudness_gain_from_cache(
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
server_id: &str,
) -> Option<f32> {
resolve_loudness_gain_from_cache_impl(
app,
url,
target_lufs,
logical_track_id,
server_id,
ResolveLoudnessCacheOpts::default(),
)
}
@@ -343,7 +341,6 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
server_id: &str,
opts: ResolveLoudnessCacheOpts,
) -> Option<f32> {
// Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`)
@@ -366,7 +363,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
}
return None;
};
resolve_loudness_gain_with_cache(cache.inner(), server_id, &track_id, target_lufs, opts)
resolve_loudness_gain_with_cache(cache.inner(), &track_id, target_lufs, opts)
}
/// AppHandle-free core of [`resolve_loudness_gain_from_cache_impl`]. Looks up
@@ -379,16 +376,15 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
/// connection's row cache is warm for the next IPC tick.
pub(crate) fn resolve_loudness_gain_with_cache(
cache: &psysonic_analysis::analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
target_lufs: f32,
opts: ResolveLoudnessCacheOpts,
) -> Option<f32> {
if opts.touch_waveform {
// Bind / preload: verify waveform context exists alongside loudness lookup.
let _ = cache.get_latest_waveform_for_track(server_id, track_id);
let _ = cache.get_latest_waveform_for_track(track_id);
}
match cache.get_latest_loudness_for_track(server_id, track_id) {
match cache.get_latest_loudness_for_track(track_id) {
Ok(Some(row)) if row.integrated_lufs.is_finite() => {
let recommended = psysonic_analysis::analysis_cache::recommended_gain_for_target(
row.integrated_lufs,
@@ -497,17 +493,6 @@ pub(crate) struct TrackGainInputs {
/// Read engine state + resolve the loudness cache for a track that's about to
/// start playing. JS-supplied `loudness_gain_db` is **not** consulted at bind
/// time (only post-cache via `audio_update_replay_gain`).
/// Current playback server scope (`current_playback_server_id`, empty when
/// unset) for scoping analysis-cache reads on the gain-resolution path.
pub(crate) fn current_playback_server_id_str(state: &AudioEngine) -> String {
state
.current_playback_server_id
.lock()
.ok()
.and_then(|g| (*g).clone())
.unwrap_or_default()
}
pub(crate) fn resolve_track_gain_inputs(
state: &AudioEngine,
app: &AppHandle,
@@ -518,9 +503,7 @@ pub(crate) fn resolve_track_gain_inputs(
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let pre_analysis_db = loudness_pre_analysis_db_for_engine(state);
let server_id = current_playback_server_id_str(state);
let cache_loudness_db =
resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id, &server_id);
let cache_loudness_db = resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id);
let effective_loudness_db = if norm_mode == 2 {
loudness_gain_db_after_resolve(
cache_loudness_db,
@@ -747,6 +730,113 @@ pub(crate) async fn fetch_data(
Ok(Some(data))
}
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
app: &AppHandle,
cache_track_id: Option<&str>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
bytes: &[u8],
) {
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
if bytes.is_empty() || bytes.len() > crate::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
return;
}
let track_id = track_id.to_string();
let bytes = bytes.to_vec();
let app = app.clone();
let gen_arc = gen_arc.clone();
crate::app_deprintln!(
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await {
crate::app_eprintln!(
"[analysis] in-memory play path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap).
pub(crate) fn spawn_analysis_seed_from_spill_file(
app: &AppHandle,
track_id: &str,
spill_path: std::path::PathBuf,
gen: u64,
gen_arc: &Arc<AtomicU64>,
) {
let track_id = track_id.trim().to_string();
if track_id.is_empty() {
return;
}
let app = app.clone();
let gen_arc = gen_arc.clone();
let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES;
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
let bytes = match tokio::fs::read(&spill_path).await {
Ok(b) if b.is_empty() => return,
Ok(b) if b.len() > max_bytes => {
crate::app_deprintln!(
"[stream] spill analysis skip track_id={} bytes={} max={}",
track_id,
b.len(),
max_bytes
);
return;
}
Ok(b) => b,
Err(e) => {
crate::app_eprintln!(
"[stream] spill analysis read failed track_id={}: {}",
track_id,
e
);
return;
}
};
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
crate::app_deprintln!(
"[stream] spill path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(
app,
track_id.clone(),
bytes,
high,
)
.await
{
crate::app_eprintln!(
"[analysis] spill path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
@@ -1317,7 +1407,6 @@ mod tests {
fn upsert_loudness_row(cache: &AnalysisCache, track_id: &str, integrated: f64, target: f64) {
let k = TrackKey {
server_id: String::new(),
track_id: track_id.to_string(),
md5_16kb: "deadbeef".to_string(),
};
@@ -1341,7 +1430,6 @@ mod tests {
let cache = AnalysisCache::open_in_memory();
let g = resolve_loudness_gain_with_cache(
&cache,
"",
"no-such-track",
-14.0,
ResolveLoudnessCacheOpts::default(),
@@ -1356,7 +1444,6 @@ mod tests {
upsert_loudness_row(&cache, "abc", -23.0, -14.0);
let g = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-14.0,
ResolveLoudnessCacheOpts::default(),
@@ -1381,7 +1468,6 @@ mod tests {
upsert_loudness_row(&cache, "stream:abc", -16.0, -14.0);
let g = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-14.0,
ResolveLoudnessCacheOpts::default(),
@@ -1395,7 +1481,6 @@ mod tests {
upsert_loudness_row(&cache, "abc", -20.0, -14.0);
let g_quiet = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-20.0,
ResolveLoudnessCacheOpts::default(),
@@ -1403,7 +1488,6 @@ mod tests {
.unwrap();
let g_loud = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-10.0,
ResolveLoudnessCacheOpts::default(),
@@ -1424,7 +1508,7 @@ mod tests {
touch_waveform: false,
log_soft_misses: false,
};
let g = resolve_loudness_gain_with_cache(&cache, "", "abc", -14.0, opts);
let g = resolve_loudness_gain_with_cache(&cache, "abc", -14.0, opts);
assert!(g.is_some());
}
}
@@ -7,7 +7,6 @@
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
pub mod autoeq_commands;
mod analysis_dispatch;
mod codec;
pub mod commands;
mod decode;
@@ -15,8 +14,6 @@ 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;
@@ -50,14 +50,12 @@ pub fn audio_update_replay_gain(
.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,
@@ -143,89 +141,6 @@ 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,
@@ -9,23 +9,19 @@ use std::time::Duration;
use ringbuf::traits::Split;
use ringbuf::{HeapCons, HeapRb};
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, 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,
spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
STREAM_FORMAT_SNIFF_PROBE_BYTES,
};
use super::stream::{
ranged_download_task, track_download_task, AudioStreamReader,
LocalFileSource, RangedHttpSource,
LocalFileSource, RangedHttpSource, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
};
@@ -58,41 +54,11 @@ pub(super) struct PlayInputContext<'a> {
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.
@@ -108,23 +74,13 @@ pub(super) async fn select_play_input(
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())),
);
}
spawn_analysis_seed_from_in_memory_bytes(
app,
ctx.cache_id_for_tasks,
ctx.gen,
&state.generation,
&d,
);
return Ok(Some(PlayInput::Bytes(d)));
}
@@ -154,12 +110,12 @@ pub(super) async fn select_play_input(
Some(d) => d,
None => return Ok(None), // superseded while downloading
};
spawn_playback_analysis_bytes(
spawn_analysis_seed_from_in_memory_bytes(
app,
state,
&ctx,
TrackAnalysisOrigin::InMemoryReplay,
data.clone(),
ctx.cache_id_for_tasks,
ctx.gen,
&state.generation,
&data,
);
Ok(Some(PlayInput::Bytes(data)))
}
@@ -185,17 +141,53 @@ fn open_local_file_input(
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 skip_cpu_seed = app
.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
.map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false))
.unwrap_or(false);
if !skip_cpu_seed {
let path_owned = std::path::PathBuf::from(path);
let app_seed = app.clone();
let gen_seed = ctx.gen;
let gen_arc_seed = state.generation.clone();
let seed_id = seed_id.to_string();
tokio::spawn(async move {
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
let data = match tokio::fs::read(&path_owned).await {
Ok(d) => d,
Err(_) => return,
};
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
crate::app_deprintln!(
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
seed_id,
data.len(),
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
);
return;
}
crate::app_deprintln!(
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
seed_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
seed_id,
e
);
}
});
}
}
let reader = LocalFileSource { file, len };
Ok(PlayInput::SeekableMedia {
@@ -324,7 +316,6 @@ async fn open_ranged_or_streaming_input(
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(),
@@ -378,7 +369,6 @@ async fn open_ranged_or_streaming_input(
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,
));
@@ -469,7 +459,6 @@ 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>,
@@ -697,7 +686,6 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
url,
gen,
cache_id_for_tasks,
server_id,
url_format_hint,
stream_format_suffix,
done_flag,
@@ -761,22 +749,13 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
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())),
);
}
spawn_analysis_seed_from_in_memory_bytes(
app,
cache_id_for_tasks,
gen,
&state.generation,
&data,
);
match build_source_from_play_input(
PlayInput::Bytes(data.clone()),
state,
@@ -853,7 +832,6 @@ pub(super) async fn build_source_from_play_input(
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(),
@@ -884,7 +862,6 @@ pub(super) async fn build_source_from_play_input(
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(),
@@ -905,7 +882,6 @@ pub(super) async fn build_source_from_play_input(
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(),
@@ -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);
}
}
@@ -3,215 +3,65 @@
//! (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;
}
let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
let _ = app.emit("audio:preload-ready", url.clone());
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> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
crate::app_deprintln!(
"[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis",
track_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
}
}
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);
let _ = app.emit("audio:preload-ready", url_for_emit);
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}");
}
}
+44 -298
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::Player;
@@ -9,17 +9,8 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::helpers::MASTER_HEADROOM;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -99,246 +90,26 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// `format=` query param on Subsonic stream URLs (transcode targets).
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -363,24 +134,48 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -476,55 +271,6 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -12,7 +12,6 @@ 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
@@ -63,14 +62,12 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
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
@@ -135,10 +132,6 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
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;
@@ -203,11 +196,10 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
// 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 (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) {
@@ -215,8 +207,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
} else if let Some(p) = paused_at {
p
} else {
effective_position_secs(samples / divisor, &playback_rate)
.min(dur.max(0.001))
(samples / divisor).min(dur.max(0.001))
};
let progress_latency = if is_paused {
0.0
@@ -342,7 +333,6 @@ mod tests {
gapless_switch_at: Arc<AtomicU64>,
playback_url: Arc<Mutex<Option<String>>>,
stream_playback_armed: Arc<AtomicBool>,
playback_rate: PlaybackRateAtomics,
}
impl TaskHarness {
@@ -372,7 +362,6 @@ mod tests {
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(),
}
}
@@ -386,14 +375,12 @@ mod tests {
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(),
);
}
}
@@ -524,8 +511,6 @@ mod tests {
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,
@@ -11,7 +11,6 @@ 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;
@@ -185,14 +184,12 @@ pub async fn audio_play_radio(
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(())
+11 -10
View File
@@ -14,6 +14,7 @@ const EQ_CHECK_INTERVAL: usize = 1024;
pub(crate) struct EqSource<S: Source<Item = f32>> {
inner: S,
sample_rate: rodio::SampleRate,
channels: rodio::ChannelCount,
gains: Arc<[AtomicU32; 10]>,
enabled: Arc<AtomicBool>,
@@ -46,7 +47,7 @@ impl<S: Source<Item = f32>> EqSource<S> {
})
});
Self {
inner, channels, gains, enabled, pre_gain,
inner, sample_rate, channels, gains, enabled, pre_gain,
filters,
current_gains: [0.0; 10],
sample_counter: 0,
@@ -56,15 +57,14 @@ impl<S: Source<Item = f32>> EqSource<S> {
#[allow(clippy::needless_range_loop)]
fn refresh_if_needed(&mut self) {
let sample_rate = self.inner.sample_rate();
for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
if (gain_db - self.current_gains[band]).abs() > 0.01 {
self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db),
(sample_rate.get() as f32).hz(),
(self.sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
) {
@@ -109,20 +109,19 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
impl<S: Source<Item = f32>> Source for EqSource<S> {
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
#[allow(clippy::needless_range_loop)]
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
let sample_rate = self.inner.sample_rate();
// Reset biquad filter state to avoid glitches after seek.
for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db),
(sample_rate.get() as f32).hz(),
(self.sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
) {
@@ -145,12 +144,14 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
pub(crate) struct DynSource {
inner: Box<dyn Source<Item = f32> + Send>,
channels: rodio::ChannelCount,
sample_rate: rodio::SampleRate,
}
impl DynSource {
pub(crate) fn new(src: impl Source<Item = f32> + Send + 'static) -> Self {
let channels = src.channels();
Self { inner: Box::new(src), channels }
let sample_rate = src.sample_rate();
Self { inner: Box::new(src), channels, sample_rate }
}
}
@@ -162,7 +163,7 @@ impl Iterator for DynSource {
impl Source for DynSource {
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.inner.try_seek(pos)
@@ -18,10 +18,6 @@ pub(crate) struct StreamCompletedSpill {
pub(crate) struct ChainedInfo {
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
pub(crate) url: String,
/// Subsonic track id for analysis dispatch (from `audio_chain_preload`).
pub(crate) analysis_track_id: Option<String>,
/// Playback server scope for analysis writes.
pub(crate) server_id: Option<String>,
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
/// them instead of re-downloading after dropping the Sink queue.
pub(crate) raw_bytes: Arc<Vec<u8>>,
@@ -26,11 +26,9 @@ use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
TRACK_STREAM_PROMOTE_MAX_BYTES,
};
use crate::analysis_dispatch::{
dispatch_track_analysis_bytes, analysis_priority_for_app, resolve_server_id_for_app,
spawn_track_analysis_file, TrackAnalysisOrigin,
use crate::helpers::{
install_stream_completed_spill, spawn_analysis_seed_from_spill_file, write_stream_spill_file,
};
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
use crate::state::StreamCompletedSpill;
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
@@ -462,8 +460,6 @@ pub(crate) async fn ranged_download_task(
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>,
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
// track; `None` for large files where ranged skips seed (needs backfill).
loudness_seed_hold: Option<LoudnessSeedHold>,
@@ -646,19 +642,9 @@ pub(crate) async fn ranged_download_task(
);
}
if let Some(track_id) = cache_track_id {
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
if let Err(e) = dispatch_track_analysis_bytes(
&app,
TrackAnalysisOrigin::StreamDownloadComplete,
&sid,
&track_id,
data.clone(),
priority,
)
.await
{
crate::app_eprintln!("[analysis] ranged seed failed for {track_id}: {e}");
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
@@ -690,16 +676,12 @@ pub(crate) async fn ranged_download_task(
return;
}
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::StreamSpillFile,
sid,
track_id,
spawn_analysis_seed_from_spill_file(
&app,
&track_id,
path,
priority,
Some((gen, gen_arc.clone())),
gen,
&gen_arc,
);
}
Err(e) => {
@@ -35,8 +35,6 @@ pub(crate) async fn track_download_task(
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;
@@ -167,22 +165,11 @@ pub(crate) async fn track_download_task(
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
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) =
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await
{
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
@@ -11,9 +11,6 @@ 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};
@@ -22,15 +19,9 @@ 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));
let pos = cur.position();
sink.pause();
cur.paused_at = Some(pos);
cur.paused_at = Some(pos);
cur.play_started = None;
}
}
@@ -117,7 +108,6 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
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
@@ -134,7 +124,6 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
#[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.
@@ -179,12 +168,10 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
};
// 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,
);
let cur_pos = {
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
cur.position()
};
if seconds < cur_pos - 1.0 {
*state.chained_info.lock().unwrap() = None;
}
@@ -231,14 +218,5 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
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(())
}
@@ -3,7 +3,6 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -1,301 +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-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
let cleaned: String = trimmed
.chars()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
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 sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
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);
}
}
@@ -4,11 +4,6 @@
//! 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 log_sanitize;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
pub mod track_enrichment;
pub mod user_agent;
@@ -1,378 +0,0 @@
//! Redact secrets and partially mask remote server hostnames before log lines
//! are stored or exported (PsyLab / Settings log export).
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
"access_token", "refresh_token", "auth",
];
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
}
/// Never panic on the logging hot path — fall back to the raw line if needed.
pub fn sanitize_log_line_infallible(line: &str) -> String {
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
}
fn redact_bearer_tokens(line: &str) -> String {
let marker = "Bearer ";
let mut s = line.to_string();
let mut search_from = 0;
while let Some(rel) = s[search_from..].find(marker) {
let idx = search_from + rel;
let start = idx + marker.len();
let end = s[start..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
.map(|i| start + i)
.unwrap_or(s.len());
if end > start {
s.replace_range(start..end, "REDACTED");
}
search_from = start + "REDACTED".len();
}
s
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
for sep in [':', '='] {
let needle = format!("{key}{sep}");
let lower = out.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find(&needle) {
let idx = search_from + rel;
let val_start = idx + needle.len();
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
}
}
out
}
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
return true;
}
if ch == ')' || ch == ']' || ch == ',' {
if let Some(next) = s[byte_off..].chars().nth(1) {
return next.is_whitespace() || next == '"' || next == '\'';
}
}
false
}
fn redact_urls_in_text(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut cursor = 0;
while cursor < line.len() {
let slice = &line[cursor..];
let rel = match (slice.find("http://"), slice.find("https://")) {
(Some(h), Some(s)) => Some(h.min(s)),
(Some(h), None) => Some(h),
(None, Some(s)) => Some(s),
(None, None) => None,
};
let Some(rel) = rel else {
out.push_str(slice);
break;
};
out.push_str(&slice[..rel]);
let url_start = cursor + rel;
let url_slice = &line[url_start..];
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
let mut url_end = scheme_len;
for (off, ch) in url_slice[scheme_len..].char_indices() {
let abs = scheme_len + off;
if url_char_ends_url(ch, url_slice, abs) {
break;
}
url_end = abs + ch.len_utf8();
}
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
cursor = url_start + url_end;
}
out
}
fn redact_url(raw: &str) -> String {
let (url, suffix) = split_trailing_punct(raw);
let mut out = String::new();
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
out.push_str(&url[..scheme_end]);
let mut rest = &url[scheme_end..];
if let Some(at) = rest.rfind('@') {
// Drop userinfo entirely.
out.push_str("***@");
rest = &rest[at + 1..];
}
let (hostport, path) = split_host_path(rest);
let (host, port) = split_host_port(&hostport);
let masked_host = mask_hostname(&host);
out.push_str(&masked_host);
if let Some(p) = port {
out.push(':');
out.push_str(&p);
}
if let Some((path_only, query)) = path.split_once('?') {
out.push_str(path_only);
out.push('?');
out.push_str(&redact_query_string(query));
} else {
out.push_str(&path);
}
format!("{out}{suffix}")
}
fn split_trailing_punct(raw: &str) -> (&str, &str) {
let mut end = raw.len();
while end > 0 {
let ch = raw.as_bytes()[end - 1] as char;
if ch == ')' || ch == ']' || ch == ',' {
end -= 1;
continue;
}
break;
}
(&raw[..end], &raw[end..])
}
fn split_host_path(rest: &str) -> (String, String) {
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
let hostport = &rest[..=end];
return (hostport.to_string(), rest[end + 1..].to_string());
}
}
if let Some(slash) = rest.find('/') {
(rest[..slash].to_string(), rest[slash..].to_string())
} else {
(rest.to_string(), String::new())
}
}
fn split_host_port(hostport: &str) -> (String, Option<String>) {
if hostport.starts_with('[') {
if let Some(end) = hostport.find("]:") {
return (
hostport[..=end].to_string(),
Some(hostport[end + 2..].to_string()),
);
}
return (hostport.to_string(), None);
}
if let Some((h, p)) = hostport.rsplit_once(':') {
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
return (h.to_string(), Some(p.to_string()));
}
}
(hostport.to_string(), None)
}
fn redact_query_string(query: &str) -> String {
query
.split('&')
.map(|pair| {
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
if is_sensitive_query_key(k) {
format!("{k}=REDACTED")
} else {
pair.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_query_key(key: &str) -> bool {
let k = key.trim().to_ascii_lowercase();
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
}
fn is_lan_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
let Ok(a) = parts[0].parse::<u8>() else { return false };
let Ok(b) = parts[1].parse::<u8>() else { return false };
a == 127
|| a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
}
fn is_lan_ipv6(host: &str) -> bool {
let h = host.to_ascii_lowercase();
if h == "::1" {
return true;
}
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
return true;
}
if h.starts_with("fc") || h.starts_with("fd") {
return true;
}
if let Some(rest) = h.strip_prefix("::ffff:") {
if rest.contains('.') {
return is_lan_ipv4(rest);
}
if let Some((a, b)) = rest.split_once(':') {
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
let ip = format!(
"{}.{}.{}.{}",
(v1 >> 8) & 0xff,
v1 & 0xff,
(v2 >> 8) & 0xff,
v2 & 0xff
);
return is_lan_ipv4(&ip);
}
}
}
false
}
fn is_lan_host(host: &str) -> bool {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
let lower = stripped.to_ascii_lowercase();
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
return true;
}
if stripped.contains(':') {
return is_lan_ipv6(stripped);
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return is_lan_ipv4(stripped);
}
false
}
fn mask_label_prefix(label: &str) -> String {
let mut chars = label.chars();
let c1 = chars.next();
let c2 = chars.next();
match (c1, c2) {
(None, _) => "*".to_string(),
(Some(a), None) => a.to_string(),
(Some(a), Some(b)) => {
let rest = label.chars().count().saturating_sub(2);
let stars = rest.clamp(1, 4);
format!("{a}{b}{}", "*".repeat(stars))
}
}
}
fn mask_public_ipv4(ip: &str) -> String {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return "***".to_string();
}
format!("{}.*.*.{}", parts[0], parts[3])
}
fn mask_hostname(host: &str) -> String {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
if is_lan_host(stripped) {
return host.to_string();
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return mask_public_ipv4(stripped);
}
if stripped.contains(':') {
return "[ipv6-redacted]".to_string();
}
let parts: Vec<&str> = stripped.split('.').collect();
if parts.is_empty() {
return "***".to_string();
}
let masked_first = mask_label_prefix(parts[0]);
if parts.len() == 1 {
masked_first
} else {
format!("{}.{}", masked_first, parts[1..].join("."))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_subsonic_wire_auth_params() {
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
let out = sanitize_log_line(line);
assert!(out.contains("t=REDACTED"));
assert!(out.contains("s=REDACTED"));
assert!(out.contains("p=REDACTED"));
assert!(!out.contains("abc"));
}
#[test]
fn masks_remote_hostname_keeps_lan_ip() {
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
assert!(remote.contains("my****.example.com"));
assert!(!remote.contains("my-server.example.com"));
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
assert!(lan.contains("192.168.1.42"));
}
#[test]
fn redacts_bearer_and_password_kv() {
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
let out = sanitize_log_line(line);
assert!(out.contains("Bearer REDACTED"));
assert!(!out.contains("eyJhbGci"));
assert!(out.contains("password=REDACTED"));
assert!(!out.contains("sekrit"));
}
#[test]
fn strips_url_userinfo() {
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
let out = sanitize_log_line(line);
assert!(out.contains("***@10.0.0.5"));
assert!(!out.contains("user:pass"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
let out = sanitize_log_line(line);
assert!(out.contains('—'));
assert!(out.contains("RangedHttpSource"));
}
}
+9 -72
View File
@@ -8,7 +8,7 @@
use std::collections::VecDeque;
use std::io::Write;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
@@ -21,31 +21,8 @@ pub enum LoggingMode {
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
const LOG_BUFFER_MAX_LINES: usize = 20_000;
/// Monotonic sequence assigned to each appended line; lets the UI tail
/// incrementally (request only lines newer than the last seq it has seen).
static LOG_SEQ: AtomicU64 = AtomicU64::new(0);
/// A single buffered log line plus its monotonic sequence number.
#[derive(Clone, Debug)]
pub struct LogLine {
pub seq: u64,
pub text: String,
}
/// Result of an incremental tail request.
#[derive(Clone, Debug, Default)]
pub struct LogTail {
pub lines: Vec<LogLine>,
/// Sequence to pass back on the next request (highest seq known, even if no
/// new lines were returned).
pub last_seq: u64,
/// True when the caller's `after_seq` predates the retained window, i.e. some
/// lines were dropped from the ring buffer before they could be delivered.
pub dropped: bool,
}
fn log_buffer() -> &'static Mutex<VecDeque<LogLine>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<LogLine>>> = OnceLock::new();
fn log_buffer() -> &'static Mutex<VecDeque<String>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
}
@@ -75,15 +52,6 @@ pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> {
Ok(())
}
/// Current logging mode as a stable lowercase string for the UI.
pub fn current_mode_str() -> &'static str {
match current_mode() {
LoggingMode::Off => "off",
LoggingMode::Normal => "normal",
LoggingMode::Debug => "debug",
}
}
fn current_mode() -> LoggingMode {
match LOGGING_MODE.load(Ordering::Acquire) {
0 => LoggingMode::Off,
@@ -101,15 +69,12 @@ pub fn should_log_debug() -> bool {
}
pub fn append_log_line(line: String) {
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
{
let mut buf = log_buffer().lock().unwrap();
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
}
buf.push_back(LogLine { seq, text: line.clone() });
let mut buf = log_buffer().lock().unwrap();
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
}
buf.push_back(line.clone());
drop(buf);
let path = cli_log_channel_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
@@ -119,41 +84,13 @@ pub fn append_log_line(line: String) {
}
}
/// Return retained log lines with `seq > after_seq`, capped to `max` (most
/// recent kept). Pass `after_seq = None` to fetch the latest `max` lines.
pub fn tail_logs(after_seq: Option<u64>, max: usize) -> LogTail {
let max = max.clamp(1, LOG_BUFFER_MAX_LINES);
let buf = log_buffer().lock().unwrap();
let last_seq = buf.back().map(|l| l.seq).unwrap_or(0);
let earliest_seq = buf.front().map(|l| l.seq).unwrap_or(0);
let after = after_seq.unwrap_or(0);
// A gap occurred if the caller already saw `after` lines but the buffer no
// longer holds the line right after it (it scrolled out of the window).
let dropped = after_seq.is_some()
&& after > 0
&& earliest_seq > 0
&& after + 1 < earliest_seq;
let mut lines: Vec<LogLine> = buf
.iter()
.filter(|l| l.seq > after)
.cloned()
.collect();
if lines.len() > max {
lines.drain(0..lines.len() - max);
}
LogTail { lines, last_seq, dropped }
}
pub fn export_logs_to_file(path: &str) -> Result<usize, String> {
let snapshot = {
let buf = log_buffer().lock().unwrap();
if buf.is_empty() {
String::new()
} else {
let mut s = buf.iter().map(|l| l.text.clone()).collect::<Vec<_>>().join("\n");
let mut s = buf.iter().cloned().collect::<Vec<_>>().join("\n");
s.push('\n');
s
}
@@ -1,364 +0,0 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -49,88 +49,3 @@ impl PlaybackQueryHandle {
(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)
}
}
@@ -3,7 +3,6 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -13,7 +12,7 @@ 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"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
discord-rich-presence = "1.1"
url = "2"
@@ -262,13 +262,12 @@ fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>
/// 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
/// Pure helper: resolve all three configurable Discord text fields, applying
/// the supplied templates (or falling back to documented defaults).
pub(crate) fn compute_discord_text_fields(
title: &str,
@@ -277,9 +276,7 @@ pub(crate) fn compute_discord_text_fields(
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,
@@ -294,7 +291,6 @@ pub(crate) fn compute_discord_text_fields(
album,
);
DiscordTextFields {
name,
details,
state,
large_text,
@@ -322,10 +318,6 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs:
/// 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(
@@ -340,7 +332,6 @@ pub async fn discord_update_presence(
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.
@@ -386,7 +377,6 @@ pub async fn discord_update_presence(
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 {
@@ -412,11 +402,8 @@ pub async fn discord_update_presence(
}
// 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
let activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&texts.details)
.state(&texts.state)
.assets(assets)
@@ -558,9 +545,7 @@ mod tests {
#[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");
let f = compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None);
assert_eq!(f.details, "Artist - Song");
assert_eq!(f.state, "Album");
assert_eq!(f.large_text, "Album");
@@ -575,9 +560,7 @@ mod tests {
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)");
@@ -585,9 +568,8 @@ mod tests {
#[test]
fn text_fields_substitute_empty_for_missing_album() {
let f = compute_discord_text_fields("Song", "Artist", None, None, None, None, None);
let f = compute_discord_text_fields("Song", "Artist", 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, "");
@@ -602,9 +584,7 @@ mod tests {
Some("{artist} {title}"),
None,
None,
None,
);
assert_eq!(f.name, "Bohemian Rhapsody");
assert_eq!(f.details, "Queen Bohemian Rhapsody");
}
@@ -4,7 +4,6 @@
//! 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
@@ -16,4 +15,3 @@ pub mod bandsintown;
pub mod discord;
pub mod navidrome;
pub mod remote;
pub mod subsonic;
@@ -7,8 +7,5 @@
mod client;
pub mod covers;
pub mod playlists;
pub mod probe;
pub mod queries;
pub mod users;
pub use client::navidrome_token;
@@ -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());
}
}
@@ -4,15 +4,14 @@
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
/// song list. Pure async helper used by the library-side N1 ingest
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
/// variant below for existing frontend callers.
pub async fn nd_list_songs_internal(
server_url: &str,
token: &str,
sort: &str,
order: &str,
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list.
/// Available to any authenticated user (no admin required). Returns raw JSON array.
#[tauri::command]
pub async fn nd_list_songs(
server_url: String,
token: String,
sort: String,
order: String,
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
@@ -23,7 +22,7 @@ pub async fn nd_list_songs_internal(
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}"))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
}).await?;
if !resp.status().is_success() {
@@ -32,20 +31,6 @@ pub async fn nd_list_songs_internal(
resp.json::<serde_json::Value>().await.map_err(nd_err)
}
/// Tauri-visible variant — owned-String arguments to keep the IPC
/// surface unchanged for existing call sites in the WebView.
#[tauri::command]
pub async fn nd_list_songs(
server_url: String,
token: String,
sort: String,
order: String,
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
/// query to a single library — `library_id` is the same scope key the Navidrome
/// web UI sends, and it matches the Subsonic `musicFolderId` we store per server.
@@ -1,91 +0,0 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
/// Subsonic credentials in the legacy salted-md5 shape (spec v1.13+):
/// the client sends `u` + `t = md5(password || salt)` + `s = salt`,
/// never the plaintext password. The salt is per-request to defeat
/// replay, but doesn't need to be cryptographically random — Subsonic's
/// API treats it as an opaque uniqueness nonce.
#[derive(Debug, Clone)]
pub struct SubsonicCredentials {
pub username: String,
pub token: String,
pub salt: String,
}
impl SubsonicCredentials {
/// Derive a credentials triple from a plaintext password. Generates a
/// fresh salt and computes `md5(password || salt)`.
pub fn from_password(username: impl Into<String>, password: &str) -> Self {
let salt = fresh_salt();
let token = md5_hex(&format!("{password}{salt}"));
Self { username: username.into(), token, salt }
}
/// Use a caller-supplied salt + token. Intended for tests and for
/// callers that already cache the derivation result.
pub fn with_static(username: impl Into<String>, token: impl Into<String>, salt: impl Into<String>) -> Self {
Self { username: username.into(), token: token.into(), salt: salt.into() }
}
}
/// Per-process monotonically-advancing nonce mixed into the salt so the
/// hot loop of `from_password` calls doesn't repeat itself even at the
/// same nanosecond.
static SALT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn fresh_salt() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let counter = SALT_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id() as u64;
format!("{:016x}{:08x}", nanos ^ pid.rotate_left(13), counter)
}
fn md5_hex(input: &str) -> String {
let digest = md5::compute(input.as_bytes());
format!("{digest:x}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn from_password_computes_md5_token() {
// Force the salt by going through with_static for a deterministic check.
let salt = "abc123";
let password = "sesame";
let expected = md5_hex(&format!("{password}{salt}"));
let creds = SubsonicCredentials::with_static("user", &expected, salt);
assert_eq!(creds.token, expected);
assert_eq!(creds.token.len(), 32, "md5 hex must be 32 chars");
}
#[test]
fn fresh_salt_is_unique_across_rapid_calls() {
let mut seen: HashSet<String> = HashSet::new();
for _ in 0..1000 {
assert!(seen.insert(fresh_salt()), "fresh_salt repeated");
}
}
#[test]
fn from_password_produces_different_salts_per_call() {
let a = SubsonicCredentials::from_password("u", "pw");
let b = SubsonicCredentials::from_password("u", "pw");
assert_ne!(a.salt, b.salt);
assert_ne!(a.token, b.token, "different salt → different token");
}
#[test]
fn md5_hex_matches_known_vector() {
// md5("") = d41d8cd98f00b204e9800998ecf8427e
assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e");
// md5("abc") = 900150983cd24fb0d6963f7d28e17f72
assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
use std::fmt;
/// Errors surfaced by `SubsonicClient`. Designed for the sync engine
/// (PR-3): `NotFound` exists as a first-class variant so the tombstone
/// reconciler can match Subsonic error code 70 without parsing strings.
///
/// Spec §2.6 — code 70 = "The requested data was not found".
#[derive(Debug)]
pub enum SubsonicError {
/// Transport failure (DNS, TCP, TLS, body read). Wraps the flattened
/// reqwest error chain so toasts can surface the real cause.
Transport(String),
/// Server replied with a non-2xx HTTP status before the JSON envelope
/// was inspectable.
HttpStatus(reqwest::StatusCode),
/// Subsonic-level failure (`status = "failed"` in the envelope).
Api { code: i32, message: String },
/// Convenience for the common error code 70. Equivalent to
/// `Api { code: 70, .. }` and produced by the same parser.
NotFound,
/// Response body wasn't the expected JSON shape.
Decode(String),
}
impl fmt::Display for SubsonicError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SubsonicError::Transport(m) => write!(f, "subsonic transport: {m}"),
SubsonicError::HttpStatus(s) => write!(f, "subsonic http status: {s}"),
SubsonicError::Api { code, message } => {
write!(f, "subsonic api error {code}: {message}")
}
SubsonicError::NotFound => write!(f, "subsonic: not found (code 70)"),
SubsonicError::Decode(m) => write!(f, "subsonic decode: {m}"),
}
}
}
impl std::error::Error for SubsonicError {}
/// Flatten a `reqwest::Error` source chain into one readable string —
/// mirrors `psysonic-integration::navidrome::nd_err` so the two clients
/// surface comparable diagnostic text.
pub(crate) fn flatten_reqwest_error(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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_includes_error_code_for_api_variant() {
let e = SubsonicError::Api { code: 40, message: "Wrong username or password".into() };
let s = e.to_string();
assert!(s.contains("40"));
assert!(s.contains("Wrong username"));
}
#[test]
fn not_found_renders_with_code_70_for_log_search() {
let s = SubsonicError::NotFound.to_string();
assert!(s.contains("70"), "got {s}");
}
}
@@ -1,20 +0,0 @@
//! Subsonic REST client — read-only endpoints the library-sync engine
//! consumes (phase B per spec §10). See `client::SubsonicClient` for the
//! entry point.
pub mod auth;
pub mod client;
pub mod error;
pub mod stream_url;
pub mod types;
pub use auth::SubsonicCredentials;
pub use client::{
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
};
pub use stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError;
pub use types::{
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo,
Song,
};
@@ -1,61 +0,0 @@
//! Subsonic `stream.view` URLs for native library analysis backfill.
use url::Url;
use super::auth::SubsonicCredentials;
use super::client::{SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID};
/// `{origin}/rest` — mirrors frontend `restBaseFromUrl`.
pub fn rest_base_from_url(server_url: &str) -> String {
let trimmed = server_url.trim().trim_end_matches('/');
let base = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
format!("{base}/rest")
}
/// Authenticated `stream.view` URL for a library track id.
pub fn build_stream_view_url(
server_url: &str,
username: &str,
password: &str,
track_id: &str,
) -> String {
let creds = SubsonicCredentials::from_password(username, password);
let base = rest_base_from_url(server_url);
let mut url =
Url::parse(&format!("{base}/stream.view")).unwrap_or_else(|_| Url::parse("http://invalid/rest/stream.view").unwrap());
{
let mut q = url.query_pairs_mut();
q.append_pair("id", track_id);
q.append_pair("u", &creds.username);
q.append_pair("t", &creds.token);
q.append_pair("s", &creds.salt);
q.append_pair("v", SUBSONIC_API_VERSION);
q.append_pair("c", SUBSONIC_CLIENT_ID);
q.append_pair("f", "json");
}
url.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_url_contains_track_and_auth_params() {
let url = build_stream_view_url(
"https://music.example",
"alice",
"secret",
"tr-42",
);
assert!(url.contains("stream.view"));
assert!(url.contains("id=tr-42"));
assert!(url.contains("u=alice"));
assert!(url.contains("&t="));
assert!(url.contains("&s="));
}
}
@@ -1,403 +0,0 @@
//! Response structs for the Subsonic REST API surface PR-2 needs.
//!
//! Only the hot fields the sync engine reads are typed; everything else
//! survives via the raw JSON the client also returns (PR-3 wires the
//! `raw_json` column on `track`). Unknown fields are simply ignored on
//! deserialize — additive OpenSubsonic extensions never break parsing.
use serde::{Deserialize, Serialize};
/// Deserialize a field Navidrome/OpenSubsonic may return either as a plain
/// string or as a JSON array. OpenSubsonic types `isrc` as `string[]`;
/// Navidrome ships `isrc: []` / `["USRC…"]`, which breaks a plain
/// `Option<String>`. Take the first usable value; the full set survives
/// verbatim in `track.raw_json` (ADR-7).
fn de_string_or_seq<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
Some(serde_json::Value::String(s)) => Some(s),
Some(serde_json::Value::Array(arr)) => first_tag_value(&arr),
_ => None,
})
}
/// Navidrome often ships library ids as JSON numbers; Subsonic uses strings.
fn de_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
Some(serde_json::Value::String(s)) => Some(s),
Some(serde_json::Value::Number(n)) => Some(n.to_string()),
_ => None,
})
}
/// First usable value in a multi-valued array: a string element, or an
/// object element's `name` (the OpenSubsonic `[{ "name": … }]` shape).
fn first_tag_value(arr: &[serde_json::Value]) -> Option<String> {
arr.iter().find_map(|el| match el {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(map) => map
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
_ => None,
})
}
/// Envelope-level metadata returned by `#ping` (and present on every
/// other response too). Read by the capability probe to detect the
/// server family (Navidrome vs generic Subsonic) and the OpenSubsonic
/// flag. Filled in from the `subsonic-response` object itself, not
/// from a body key — these fields sit at the same level as `status`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServerInfo {
/// Server software family — Navidrome reports `"navidrome"`, generic
/// Subsonic implementations report their own label. `None` when the
/// server omits the field (older Subsonic).
pub server_type: Option<String>,
/// Server build version, e.g. `"0.55.2"` on Navidrome.
pub server_version: Option<String>,
/// Subsonic API protocol level the server advertises.
pub api_version: Option<String>,
/// `true` when the server advertises OpenSubsonic extensions
/// (`isrc`, `played`, `bpm`, contributor arrays, …).
pub open_subsonic: bool,
}
/// `#getScanStatus` (since 1.15.0). `lastScan` is an ISO-8601 string on
/// Navidrome (`responses.go` `ScanStatus.LastScan`); other servers may
/// omit it during an active scan.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ScanStatus {
pub scanning: bool,
#[serde(default)]
pub count: Option<i64>,
#[serde(rename = "folderCount", default)]
pub folder_count: Option<i64>,
#[serde(rename = "lastScan", default)]
pub last_scan: Option<String>,
}
/// `#getIndexes` (file-structure browse) and `#getArtists` (ID3 browse)
/// share the same shape on the wire: a top-level `lastModified` watermark
/// plus a list of letter buckets.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ArtistIndex {
/// `lastModified` is ms since epoch (spec §2.2 — response metadata,
/// not a request param).
#[serde(rename = "lastModified", default)]
pub last_modified_ms: Option<i64>,
#[serde(rename = "ignoredArticles", default)]
pub ignored_articles: Option<String>,
#[serde(default)]
pub index: Vec<IndexBucket>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct IndexBucket {
pub name: String,
#[serde(default)]
pub artist: Vec<ArtistRef>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ArtistRef {
pub id: String,
pub name: String,
#[serde(rename = "albumCount", default)]
pub album_count: Option<i64>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
}
/// `#getAlbumList2` — page of album summaries (no song list).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct AlbumSummary {
pub id: String,
pub name: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(rename = "artistId", default)]
pub artist_id: Option<String>,
#[serde(rename = "songCount", default)]
pub song_count: Option<i64>,
#[serde(default)]
pub duration: Option<i64>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub genre: Option<String>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
#[serde(default)]
pub starred: Option<String>,
}
/// `#getAlbum` — album + its full song list.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct Album {
pub id: String,
pub name: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(rename = "artistId", default)]
pub artist_id: Option<String>,
#[serde(rename = "songCount", default)]
pub song_count: Option<i64>,
#[serde(default)]
pub duration: Option<i64>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub genre: Option<String>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
#[serde(default)]
pub song: Vec<Song>,
}
/// `#search3` — three parallel lists, any of which may be empty.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
pub struct SearchResult {
#[serde(default)]
pub artist: Vec<ArtistRef>,
#[serde(default)]
pub album: Vec<AlbumSummary>,
#[serde(default)]
pub song: Vec<Song>,
}
/// `#getSong` / nested in `#getAlbum`. Only the hot columns from
/// spec §5.1 are typed; everything else (OpenSubsonic extensions, contributor
/// arrays, …) is ignored at this layer and recovered from `raw_json` later.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct Song {
pub id: String,
pub title: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(rename = "artistId", default)]
pub artist_id: Option<String>,
#[serde(default)]
pub album: Option<String>,
#[serde(rename = "albumId", default)]
pub album_id: Option<String>,
#[serde(rename = "albumArtist", default)]
pub album_artist: Option<String>,
/// Subsonic reports `duration` in whole seconds.
#[serde(default)]
pub duration: Option<i64>,
#[serde(rename = "track", default)]
pub track_number: Option<i64>,
#[serde(rename = "discNumber", default)]
pub disc_number: Option<i64>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub genre: Option<String>,
#[serde(default)]
pub suffix: Option<String>,
#[serde(rename = "bitRate", default)]
pub bit_rate: Option<i64>,
/// Server reports `size` in bytes.
#[serde(default)]
pub size: Option<i64>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
#[serde(default)]
pub starred: Option<String>,
#[serde(rename = "userRating", default)]
pub user_rating: Option<i64>,
#[serde(rename = "playCount", default)]
pub play_count: Option<i64>,
#[serde(default)]
pub played: Option<String>,
/// Server-side relative path (Navidrome populates; some servers don't).
#[serde(default)]
pub path: Option<String>,
/// `libraryId` (Navidrome native) or `musicFolderId` (Subsonic generic).
/// We accept both keys — Navidrome uses `libraryId` on OpenSubsonic
/// responses, generic Subsonic stays on `musicFolderId`.
#[serde(
default,
alias = "libraryId",
alias = "musicFolderId",
deserialize_with = "de_string_or_number"
)]
pub library_id: Option<String>,
// OpenSubsonic types `isrc` as `string[]` — Navidrome returns
// `isrc: []` / `["USRC…"]`, which breaks a plain `Option<String>`.
#[serde(default, deserialize_with = "de_string_or_seq")]
pub isrc: Option<String>,
/// MusicBrainz recording id. Subsonic / OpenSubsonic uses the
/// `musicBrainzId` JSON key; the schema column is `mbid_recording`
/// (spec §5.1). The alias keeps both spellings deserializable so
/// future API revisions don't break ingest.
#[serde(default, alias = "musicBrainzId", alias = "mbid_recording")]
pub mbid_recording: Option<String>,
#[serde(default)]
pub bpm: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn song_deserialize_accepts_minimal_navidrome_payload() {
let payload = r#"{
"id": "tr_1",
"title": "Hello",
"artist": "World",
"duration": 240,
"track": 3,
"year": 2024,
"suffix": "flac",
"bitRate": 1000,
"size": 32000000,
"coverArt": "cv_1",
"libraryId": "1",
"isrc": "USRC17607839"
}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.id, "tr_1");
assert_eq!(song.title, "Hello");
assert_eq!(song.duration, Some(240));
assert_eq!(song.track_number, Some(3));
assert_eq!(song.library_id.as_deref(), Some("1"));
assert_eq!(song.isrc.as_deref(), Some("USRC17607839"));
}
#[test]
fn song_alias_falls_back_to_music_folder_id() {
// Generic Subsonic still ships `musicFolderId`, not Navidrome's
// `libraryId` — make sure we don't lose it.
let payload = r#"{"id":"a","title":"t","musicFolderId":"7"}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.library_id.as_deref(), Some("7"));
}
#[test]
fn song_deserialize_library_id_from_number() {
let payload = r#"{"id":"a","title":"t","libraryId":3}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.library_id.as_deref(), Some("3"));
}
#[test]
fn song_picks_up_music_brainz_id_from_either_alias() {
// OpenSubsonic shape — `musicBrainzId`.
let from_subsonic: Song = serde_json::from_str(
r#"{"id":"a","title":"t","musicBrainzId":"abc-123"}"#,
)
.unwrap();
assert_eq!(from_subsonic.mbid_recording.as_deref(), Some("abc-123"));
// Schema-column shape — direct `mbid_recording`. Lets callers
// round-trip a row through `serde_json` without renaming.
let from_schema: Song = serde_json::from_str(
r#"{"id":"a","title":"t","mbid_recording":"xyz-789"}"#,
)
.unwrap();
assert_eq!(from_schema.mbid_recording.as_deref(), Some("xyz-789"));
}
#[test]
fn song_ignores_unknown_open_subsonic_fields() {
// OpenSubsonic ships extras like `played`, `replayGain`, `artists`
// (contributor list), etc. We don't type them; they must not error.
let payload = r#"{
"id": "tr_1",
"title": "Hello",
"replayGain": { "trackGain": -1.2, "albumGain": -0.8 },
"artists": [{ "id": "ar_1", "name": "W" }],
"contributors": []
}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.id, "tr_1");
assert!(song.artist.is_none());
}
#[test]
fn album_with_songs_round_trips() {
let payload = r#"{
"id": "al_1",
"name": "Test Album",
"artist": "Artist",
"artistId": "ar_1",
"songCount": 2,
"song": [
{"id": "tr_1", "title": "One", "track": 1},
{"id": "tr_2", "title": "Two", "track": 2}
]
}"#;
let album: Album = serde_json::from_str(payload).unwrap();
assert_eq!(album.name, "Test Album");
assert_eq!(album.song.len(), 2);
assert_eq!(album.song[1].title, "Two");
}
#[test]
fn song_isrc_accepts_opensubsonic_string_array() {
// OpenSubsonic `isrc` is `string[]`. Navidrome ships `isrc: []`
// (the album !Brincamos! repro) or a populated array — both must
// decode, plus the legacy single-string form.
let empty: Song = serde_json::from_str(r#"{"id":"a","title":"t","isrc":[]}"#).unwrap();
assert!(empty.isrc.is_none());
let arr: Song =
serde_json::from_str(r#"{"id":"a","title":"t","isrc":["USRC17607839"]}"#).unwrap();
assert_eq!(arr.isrc.as_deref(), Some("USRC17607839"));
let legacy: Song =
serde_json::from_str(r#"{"id":"a","title":"t","isrc":"USRC17607839"}"#).unwrap();
assert_eq!(legacy.isrc.as_deref(), Some("USRC17607839"));
}
#[test]
fn artist_index_parses_last_modified_watermark() {
let payload = r#"{
"lastModified": 1716840000000,
"ignoredArticles": "The El La",
"index": [
{"name": "A", "artist": [
{"id": "ar_1", "name": "Anna"},
{"id": "ar_2", "name": "Alex", "albumCount": 3}
]},
{"name": "B", "artist": []}
]
}"#;
let ai: ArtistIndex = serde_json::from_str(payload).unwrap();
assert_eq!(ai.last_modified_ms, Some(1716840000000));
assert_eq!(ai.index.len(), 2);
assert_eq!(ai.index[0].artist.len(), 2);
assert_eq!(ai.index[0].artist[1].album_count, Some(3));
}
#[test]
fn search_result_defaults_empty_lists() {
// search3 with no hits returns just `{"searchResult3": {}}`.
let sr: SearchResult = serde_json::from_str("{}").unwrap();
assert!(sr.artist.is_empty());
assert!(sr.album.is_empty());
assert!(sr.song.is_empty());
}
#[test]
fn scan_status_parses_navidrome_shape() {
let payload = r#"{
"scanning": false,
"count": 12345,
"folderCount": 100,
"lastScan": "2024-06-01T12:00:00Z"
}"#;
let s: ScanStatus = serde_json::from_str(payload).unwrap();
assert!(!s.scanning);
assert_eq!(s.count, Some(12345));
assert_eq!(s.last_scan.as_deref(), Some("2024-06-01T12:00:00Z"));
}
}
@@ -1,22 +0,0 @@
[package]
name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-integration = { path = "../psysonic-integration" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
wiremock = { workspace = true }
@@ -1,295 +0,0 @@
-- psysonic-library v1 schema — see implementation-spec.ru.md §5.1–§5.3
-- Tables are ordered so FK targets exist before their referrers.
-- Migration-runner bookkeeping (§5.7). Defined here so the schema file is
-- self-describing — `LibraryStore::run_migrations` also creates this table
-- defensively before applying migrations, which keeps both paths idempotent.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE TABLE canonical_track (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE canonical_identity (
canonical_id TEXT NOT NULL,
kind TEXT NOT NULL,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
PRIMARY KEY (kind, value),
FOREIGN KEY (canonical_id) REFERENCES canonical_track(id)
);
CREATE TABLE sync_state (
server_id TEXT NOT NULL,
library_scope TEXT NOT NULL DEFAULT '',
normalized_base_url TEXT NOT NULL DEFAULT '',
server_fingerprint_ok INTEGER,
fingerprint_checked_at INTEGER,
capability_flags INTEGER NOT NULL DEFAULT 0,
last_full_sync_at INTEGER,
last_delta_sync_at INTEGER,
server_last_scan_iso TEXT,
indexes_last_modified_ms INTEGER,
artists_last_modified_ms INTEGER,
server_track_count INTEGER,
local_track_count INTEGER,
artist_count INTEGER,
library_tier TEXT NOT NULL DEFAULT 'unknown',
poll_stats_json TEXT NOT NULL DEFAULT '{}',
next_poll_at INTEGER,
initial_sync_cursor_json TEXT NOT NULL DEFAULT '{}',
sync_phase TEXT NOT NULL DEFAULT 'idle',
last_error TEXT,
n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, library_scope)
);
CREATE TABLE artist (
server_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
album_count INTEGER,
synced_at INTEGER NOT NULL,
raw_json TEXT,
PRIMARY KEY (server_id, id)
);
CREATE TABLE album (
server_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
artist TEXT,
artist_id TEXT,
song_count INTEGER,
duration_sec INTEGER,
year INTEGER,
genre TEXT,
cover_art_id TEXT,
starred_at INTEGER,
synced_at INTEGER NOT NULL,
raw_json TEXT,
PRIMARY KEY (server_id, id)
);
CREATE TABLE track (
server_id TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL,
title_sort TEXT,
artist TEXT,
artist_id TEXT,
album TEXT NOT NULL DEFAULT '',
album_id TEXT,
album_artist TEXT,
duration_sec INTEGER NOT NULL DEFAULT 0,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
suffix TEXT,
bit_rate INTEGER,
size_bytes INTEGER,
cover_art_id TEXT,
starred_at INTEGER,
user_rating INTEGER,
play_count INTEGER,
played_at INTEGER,
server_path TEXT,
library_id TEXT,
isrc TEXT,
mbid_recording TEXT,
bpm INTEGER,
replay_gain_track_db REAL,
replay_gain_album_db REAL,
content_hash TEXT,
server_updated_at INTEGER,
server_created_at INTEGER,
resync_gen INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
synced_at INTEGER NOT NULL,
raw_json TEXT NOT NULL,
PRIMARY KEY (server_id, id)
);
CREATE VIRTUAL TABLE track_fts USING fts5(
title, artist, album, album_artist, genre,
content='track', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER track_ai AFTER INSERT ON track BEGIN
INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre)
VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre);
END;
CREATE TRIGGER track_ad AFTER DELETE ON track BEGIN
INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre)
VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre);
END;
CREATE TRIGGER track_au AFTER UPDATE ON track BEGIN
INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre)
VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre);
INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre)
VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre);
END;
CREATE TABLE track_extension (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
kind TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
payload BLOB NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, kind),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id)
);
-- NO FK to track: survives library purge when user keeps the cached file (§5.14).
CREATE TABLE track_offline (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
local_path TEXT NOT NULL,
file_size_bytes INTEGER,
suffix TEXT,
content_hash TEXT NOT NULL DEFAULT '',
server_path TEXT,
cached_at INTEGER NOT NULL,
last_verified_at INTEGER,
PRIMARY KEY (server_id, track_id)
);
CREATE TABLE track_id_history (
server_id TEXT NOT NULL,
old_id TEXT NOT NULL,
new_id TEXT NOT NULL,
content_hash TEXT,
server_path TEXT,
remapped_at INTEGER NOT NULL,
PRIMARY KEY (server_id, old_id)
);
CREATE TABLE track_fact (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
fact_kind TEXT NOT NULL,
value_real REAL,
value_int INTEGER,
value_text TEXT,
unit TEXT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
source_detail TEXT,
confidence REAL NOT NULL DEFAULT 1.0,
content_hash TEXT,
fetched_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (server_id, track_id, fact_kind, source_kind, source_id),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id)
);
CREATE TABLE track_artifact (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
artifact_kind TEXT NOT NULL,
format TEXT NOT NULL,
language TEXT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
content_text TEXT,
content_blob BLOB,
content_bytes INTEGER NOT NULL DEFAULT 0,
not_found INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
fetched_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (server_id, track_id, artifact_kind, source_kind, source_id, format),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id)
);
CREATE TABLE track_canonical_link (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
canonical_id TEXT NOT NULL,
match_method TEXT NOT NULL,
confidence REAL NOT NULL,
linked_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
FOREIGN KEY (canonical_id) REFERENCES canonical_track(id)
);
CREATE TABLE canonical_enrichment_link (
canonical_id TEXT NOT NULL,
enrichment_kind TEXT NOT NULL,
owner_server_id TEXT NOT NULL,
owner_track_id TEXT NOT NULL,
share_policy TEXT NOT NULL DEFAULT 'isrc_match',
linked_at INTEGER NOT NULL,
PRIMARY KEY (canonical_id, enrichment_kind, owner_server_id, owner_track_id),
FOREIGN KEY (canonical_id) REFERENCES canonical_track(id)
);
CREATE TABLE play_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_sec REAL NOT NULL,
position_max_sec REAL NOT NULL,
completion TEXT NOT NULL,
end_reason TEXT NOT NULL,
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
CHECK (completion IN ('partial', 'full'))
);
CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0;
CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0;
CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0;
CREATE INDEX idx_track_starred ON track(server_id, starred_at) WHERE deleted = 0 AND starred_at IS NOT NULL;
CREATE INDEX idx_track_library ON track(server_id, library_id) WHERE deleted = 0;
CREATE INDEX idx_track_bpm ON track(server_id, bpm) WHERE deleted = 0 AND bpm IS NOT NULL;
CREATE INDEX idx_track_isrc ON track(isrc) WHERE deleted = 0 AND isrc IS NOT NULL;
CREATE INDEX idx_track_fact_lookup ON track_fact(server_id, track_id, fact_kind);
CREATE INDEX idx_track_artifact_lookup ON track_artifact(server_id, track_id, artifact_kind);
CREATE INDEX idx_track_offline_hash ON track_offline(server_id, content_hash);
CREATE INDEX idx_track_id_history_new ON track_id_history(server_id, new_id);
CREATE INDEX idx_canonical_identity_lookup ON canonical_identity(kind, value);
CREATE INDEX idx_track_remap_path
ON track(server_id, server_path)
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
CREATE INDEX idx_track_remap_hash
ON track(server_id, content_hash)
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
CREATE INDEX idx_track_title
ON track(server_id, title COLLATE NOCASE)
WHERE deleted = 0;
CREATE INDEX idx_track_genre
ON track(server_id, genre COLLATE NOCASE)
WHERE deleted = 0 AND genre IS NOT NULL;
CREATE INDEX idx_track_year
ON track(server_id, year)
WHERE deleted = 0 AND year IS NOT NULL;
CREATE INDEX idx_play_session_server_time
ON play_session(server_id, started_at_ms DESC);
CREATE INDEX idx_play_session_track
ON play_session(server_id, track_id, started_at_ms DESC);
CREATE INDEX idx_play_session_started
ON play_session(started_at_ms DESC);
CREATE INDEX idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
@@ -1,6 +0,0 @@
-- psysonic-library schema v2 — large-library ingest policy (R7-15).
-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a
-- deep offset on a large catalog, the strategy selector stops choosing N1 for
-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive
-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall.
ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0;
@@ -1,10 +0,0 @@
-- Remap detection (§6.9) and unstable-id servers: without these indexes
-- each upsert in a 500-row batch can scan the whole track table.
CREATE INDEX IF NOT EXISTS idx_track_remap_path
ON track(server_id, server_path)
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
CREATE INDEX IF NOT EXISTS idx_track_remap_hash
ON track(server_id, content_hash)
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
@@ -1,4 +0,0 @@
-- Browse / sort-by-title without sorting the full server slice on every page.
CREATE INDEX IF NOT EXISTS idx_track_title
ON track(server_id, title COLLATE NOCASE)
WHERE deleted = 0;
@@ -1,8 +0,0 @@
-- Advanced search filters on genre and year (partial indexes — only non-null rows).
CREATE INDEX IF NOT EXISTS idx_track_genre
ON track(server_id, genre COLLATE NOCASE)
WHERE deleted = 0 AND genre IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_track_year
ON track(server_id, year)
WHERE deleted = 0 AND year IS NOT NULL;
@@ -1,22 +0,0 @@
-- Player listening history — see workdocs player-stats spec §3.1
CREATE TABLE play_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_sec REAL NOT NULL,
position_max_sec REAL NOT NULL,
completion TEXT NOT NULL,
end_reason TEXT NOT NULL,
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
CHECK (completion IN ('partial', 'full'))
);
CREATE INDEX idx_play_session_server_time
ON play_session(server_id, started_at_ms DESC);
CREATE INDEX idx_play_session_track
ON play_session(server_id, track_id, started_at_ms DESC);
CREATE INDEX idx_play_session_started
ON play_session(started_at_ms DESC);
@@ -1,4 +0,0 @@
-- Full-resync orphan sweep (mark-and-sweep via generation stamp).
-- Rows ingested during a resync pass carry the active `resync_gen`; after
-- IS-6 succeeds, live rows with a stale generation are soft-deleted.
ALTER TABLE track ADD COLUMN resync_gen INTEGER NOT NULL DEFAULT 0;
@@ -1,4 +0,0 @@
-- Atomic mood tags for Advanced Search (EXISTS on track_fact).
CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
@@ -1,3 +0,0 @@
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
DELETE FROM track_fact
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');

Some files were not shown because too many files have changed in this diff Show More