Compare commits

..

1 Commits

Author SHA1 Message Date
Psychotoxical 492f069bf1 feat: Nord themes, Wayland compatibility, and stability fixes (v1.0.6) 2026-03-13 18:59:18 +01:00
2435 changed files with 23132 additions and 306883 deletions
-12
View File
@@ -1,12 +0,0 @@
# Arch Linux's rust package bakes -fuse-ld=lld into the default rustflags.
# ring (pulled in by tauri-plugin-updater) ships C/asm objects that lld cannot
# resolve (ring_core_* symbols). Fix: force cc as linker driver and append
# -fuse-ld=bfd so it overrides the hardcoded -fuse-ld=lld (last flag wins).
# bfd is always available via binutils (part of base-devel on Arch).
#
# NOTE: When building via makepkg (AUR), RUSTFLAGS from /etc/makepkg.conf
# overrides target-specific rustflags here. The PKGBUILD's build() applies
# the same fix by patching the RUSTFLAGS env var directly.
[target.x86_64-unknown-linux-gnu]
linker = "cc"
rustflags = ["-C", "link-arg=-fuse-ld=bfd"]
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: psychotoxic
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-138
View File
@@ -1,138 +0,0 @@
name: Bug report
description: Something is broken or behaves incorrectly
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please fill in as much as you can — the more
precise the report, the faster it can be reproduced and fixed.
**Before opening an issue:** if you're not sure whether the problem is on the Psysonic side
or on your Subsonic-compatible server, please ask in the [Discord](https://discord.gg/AMnDRErm4u)
or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) community first.
- type: textarea
id: summary
attributes:
label: What's wrong?
description: A short description of the problem (one or two sentences).
placeholder: e.g. The volume slider jumps back to 100 % every time I switch tracks.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered, minimal steps that lead to the problem.
placeholder: |
1. Open Psysonic and connect to my Navidrome server.
2. Start playback of any track.
3. Drag the volume slider to ~30 %.
4. Press the next button.
5. Volume slider has snapped back to 100 %.
value: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What should happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: What actually happens?
description: Include any error toast or popup text exactly as shown.
validations:
required: true
- type: input
id: version
attributes:
label: Psysonic version
description: |
Exact version string from `Settings → About` (e.g. `1.45.2` or `1.46.0-dev`).
placeholder: 1.45.2
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- Linux
- macOS
- Windows
- Other (please describe in "Anything else")
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: Install source
description: How did you install Psysonic?
options:
- AUR — psysonic
- AUR — psysonic-bin
- .deb (Linux)
- .rpm (Linux)
- flakes
- .dmg (macOS)
- .msi installer (Windows)
- Built from source
- Other (please describe in "Anything else")
- type: dropdown
id: server
attributes:
label: Subsonic-compatible server
options:
- Navidrome
- Gonic
- Airsonic
- LMS
- Other Subsonic-compatible server
- Not sure / not relevant
- type: input
id: server_version
attributes:
label: Server version
description: If your server has a version string, paste it here. Skip if the bug is clearly UI-side.
placeholder: e.g. Navidrome 0.55.0
- type: textarea
id: logs
attributes:
label: Logs
description: |
How to grab logs:
1. `Settings → Logging → Debug` (enables verbose logging)
2. Reproduce the bug
3. `Settings → Logging → Export logs` and attach the resulting file or paste the relevant section below.
Logs help a lot, especially for playback / streaming / sync bugs.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots / video
description: Drag-and-drop is supported. Optional but appreciated for UI bugs.
- type: textarea
id: anything_else
attributes:
label: Anything else
description: Distro / WM / GPU driver, Tauri-specific quirks, theme, anything that might be relevant.
-16
View File
@@ -1,16 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Usage questions / general help
url: https://discord.gg/AMnDRErm4u
about: |
Have a question, need help connecting to your server, or not sure if a behaviour is
a bug? Please ask in the Discord community first — the issue tracker is reserved for
confirmed bugs and feature requests.
- name: Telegram community
url: https://t.me/+GLBx1_xeH28xYTJi
about: Alternative chat channel for usage questions and general discussion.
- name: AUR packaging issues
url: https://aur.archlinux.org/packages/psysonic
about: |
For problems specific to the AUR `psysonic` or `psysonic-bin` packages (build failures,
dependency mismatches, AUR metadata), please comment on the AUR page first.
@@ -1,43 +0,0 @@
name: Feature request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting an improvement. A short description of the use case is more
valuable than a fully-specified solution — the implementation usually shifts during
review, but the underlying need stays the same.
- type: textarea
id: problem
attributes:
label: What's the use case?
description: |
What are you trying to do that's currently hard or impossible? Describe the workflow
or situation, not the proposed solution.
placeholder: |
e.g. When I'm on a long flight I want to keep listening to my offline-cached tracks,
but if I open Psysonic without an internet connection it shows the connection
screen instead of letting me into the player.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How do you imagine this working? Sketches, UI ideas, command names — anything goes.
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Workarounds you've tried, or other approaches that didn't fit.
- type: textarea
id: anything_else
attributes:
label: Anything else
description: Links to related issues, similar requests, screenshots, etc.
-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"]
-44
View File
@@ -1,44 +0,0 @@
# Hot-path source files for the per-file ≥70 % coverage gate (frontend).
#
# Mirrors `.github/hot-path-files.txt` for the Rust crates. Each entry is a
# workspace-relative path; the gate script (`scripts/check-frontend-hot-path-
# coverage.sh`) reads `coverage/coverage-summary.json` produced by
# `vitest run --coverage` and fails the frontend-tests coverage job when a
# listed file drops below the floor.
#
# Curation rule (mirrors the backend list): a file belongs here when its
# hot-path code dominates the file and ≥70 % is a reasonable floor. Files
# that are partially covered today but contain large untested surface area
# (e.g. `playerStore.ts`, the unfinished half of `previewStore.ts`) live
# OUTSIDE the gate until their tests grow. Add them as Phase 14 coverage
# work lands.
#
# Deferred from the gate, with current coverage shown for reference:
# - src/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
# - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability)
# - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
# ── utils (already at or above threshold) ────────────────────────────
src/utils/cover/coverArtRegisteredSizes.ts
src/utils/server/serverDisplayName.ts
src/utils/server/serverMagicString.ts
src/utils/share/shareLink.ts
src/utils/ui/dynamicColors.ts
src/utils/playback/resolvePlaybackUrl.ts
src/utils/share/copyEntityShareLink.ts
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
src/utils/playback/shuffleArray.ts
src/utils/audio/resolveReplayGainDb.ts
src/utils/playback/songToTrack.ts
src/utils/playback/buildInfiniteQueueCandidates.ts
# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ──
src/app/windowKind.ts
src/app/bootstrap.ts
# ── Phase B.2: mini-player webview tree (2026-05-12) ─────────────────
src/app/MiniPlayerApp.tsx
# ── stores (added as their tests grew past the floor) ────────────────
src/store/previewStore.ts
-42
View File
@@ -1,42 +0,0 @@
# Hot-path source files for the per-file ≥70% coverage gate.
#
# Background: cucadmuh asked for ≥80% per-function coverage on functions in
# typical user-triggered sequences. The original implementation tried to
# parse cargo-llvm-cov per-function region data, but the metric is not
# reliable for async state-machines (async fn body lives in synthetic
# closures, the "main" symbol is just 1-2 regions) or generic functions
# (every monomorphic instantiation is a separate symbol). See
# `scripts/check-hot-path-coverage.sh` header for details.
#
# Switched to file-level coverage: every file listed below must stay
# at ≥70% line coverage. The list is the subset of source files where
# the hot-path code dominates and 70% is a reasonable floor. Files like
# `psysonic-syncfs/src/sync/batch.rs` aren't listed because their hot-
# path functions ARE tested (parse_subsonic_songs / estimate_track_size
# / track_sync_info_from_subsonic_json all 100%) but the same file
# also holds a large amount of untested admin Tauri commands that drag
# the file aggregate down. Those are tracked individually via direct
# unit tests, not via this gate.
#
# Each line is a path relative to the workspace root (so `src-tauri/...`).
# `#` for comments. CI runs cargo-llvm-cov + this gate; PRs that drop
# any listed file below the threshold fail the rust-tests coverage job.
# ── psysonic-syncfs ──────────────────────────────────────────────────
src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs
src-tauri/crates/psysonic-syncfs/src/cache/offline.rs
src-tauri/crates/psysonic-syncfs/src/file_transfer.rs
# ── psysonic-analysis ────────────────────────────────────────────────
src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs
src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs
# ── psysonic-audio ───────────────────────────────────────────────────
src-tauri/crates/psysonic-audio/src/decode.rs
src-tauri/crates/psysonic-audio/src/stream/icy.rs
src-tauri/crates/psysonic-audio/src/progress_task.rs
src-tauri/crates/psysonic-audio/src/ipc.rs
# ── psysonic-integration ─────────────────────────────────────────────
src-tauri/crates/psysonic-integration/src/discord.rs
src-tauri/crates/psysonic-integration/src/navidrome/client.rs
-15
View File
@@ -1,15 +0,0 @@
name: ci-main
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci-ok:
runs-on: ubuntu-latest
steps:
- name: ci sentinel
run: echo "main CI sentinel is green"
-84
View File
@@ -1,84 +0,0 @@
name: frontend-tests
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- '.github/workflows/frontend-tests.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
- 'scripts/check-css-import-graph.mjs'
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- '.github/workflows/frontend-tests.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
- 'scripts/check-css-import-graph.mjs'
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: vitest run
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- name: vitest
run: npm test
typecheck:
name: tsc --noEmit
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- run: npm run prebuild:release-notes
- name: tsc
run: npx tsc --noEmit
coverage:
name: vitest --coverage (baseline + hot-path file gate)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
- name: install jq
run: sudo apt-get update && sudo apt-get install -y jq
- run: npm ci
- run: npm run prebuild:release-notes
- name: vitest run --coverage
run: npx vitest run --coverage
- name: hot-path file coverage gate
run: bash scripts/check-frontend-hot-path-coverage.sh
- uses: actions/upload-artifact@v4
with:
name: frontend-coverage-lcov
path: coverage/lcov.info
if-no-files-found: error
-33
View File
@@ -1,33 +0,0 @@
name: Next Channel
# workflow_dispatch: set "Use workflow from branch" to `main` so reusable-channel-publish.yml
# is the latest (tag guards, etc.). The tree built for artifacts is always ref `next` below.
on:
push:
branches: [next]
# Merging auto PR "chore(nix): refresh lock …" only touches these paths — do not
# re-run the full channel (would loop: publish → verify-nix PR → merge → push → publish).
paths-ignore:
- "flake.lock"
- "nix/**"
workflow_dispatch:
inputs:
source_only:
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs:
publish-next:
uses: ./.github/workflows/reusable-channel-publish.yml
with:
channel: next
# Always build from channel branch; do not tie checkout to workflow_dispatch UI branch.
source_ref: next
target_branch: next
prerelease: true
draft_release: true
verify_nix: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
build_platform_artifacts: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
secrets: inherit
@@ -1,70 +0,0 @@
# Keeps `nix/upstream-sources.json` (`npmDepsHash`) in sync with `package-lock.json`.
# Runs in CI with Nix — contributors do not need Nix locally.
#
# Skips fork PRs (cannot push to the contributor branch); after merge to main,
# next, or release, the push workflow updates the hash on that branch if needed.
name: Sync Nix npmDepsHash
on:
push:
branches: [main, next, release]
paths:
- package-lock.json
- package.json
- nix/upstream-sources.json
pull_request:
paths:
- package-lock.json
- package.json
- nix/upstream-sources.json
workflow_dispatch:
concurrency:
group: nix-npm-hash-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-24.04
permissions:
contents: write
# Fork PRs: token cannot push to the fork — merge to main will run the push workflow instead.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v15
- name: Recompute npmDepsHash from package-lock.json
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "Computed npmDepsHash: $HASH"
TMP="$(mktemp)"
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > "$TMP"
mv "$TMP" nix/upstream-sources.json
- name: Commit and push if changed
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
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 nix/upstream-sources.json
if git diff --cached --quiet; then
echo "npmDepsHash already matches package-lock.json — nothing to commit."
exit 0
fi
git commit -m "chore(nix): sync npmDepsHash with package-lock.json"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
git push origin "HEAD:${PR_HEAD_REF}"
else
git push origin "HEAD:${{ github.ref_name }}"
fi
-107
View File
@@ -1,107 +0,0 @@
name: Promote main to next
on:
workflow_dispatch:
inputs:
source_branch:
description: "Source branch to promote into next (default main). Required ci-ok checks run only when this is main."
required: false
default: main
type: string
source_only:
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs:
validate-main:
if: inputs.source_branch == 'main'
uses: ./.github/workflows/validate-main-green-ci.yml
with:
branch: ${{ inputs.source_branch }}
required_contexts: ci-ok|ci-main / ci-ok
promote:
needs: validate-main
if: ${{ !cancelled() && (needs.validate-main.result == 'success' || needs.validate-main.result == 'skipped') }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
env:
SOURCE_BRANCH: ${{ inputs.source_branch }}
steps:
- name: note when CI gate was skipped
if: inputs.source_branch != 'main'
run: |
echo "Skipping required-check validation: source_branch is '${{ inputs.source_branch }}', not main."
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: next
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: fast-forward next to source branch tip
run: |
set -euo pipefail
git fetch origin "$SOURCE_BRANCH" next
# Reset channel branch to source snapshot, then create a fresh RC bump commit.
git reset --hard "origin/$SOURCE_BRANCH"
- name: bump package version to next RC
run: |
set -euo pipefail
git fetch --tags origin
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
BASE_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
MAX_RC="$(git tag -l "app-v${BASE_VERSION}-rc.*" | sed -E 's/.*-rc\.([0-9]+)$/\1/' | sort -n | tail -n1)"
if [[ -z "$MAX_RC" ]]; then
NEXT_RC=1
else
NEXT_RC=$((MAX_RC + 1))
fi
TARGET_VERSION="${BASE_VERSION}-rc.${NEXT_RC}"
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
echo "package.json already uses $TARGET_VERSION"
exit 0
fi
npm version --no-git-tag-version "$TARGET_VERSION"
- name: sync Tauri version from package.json
run: node scripts/sync-tauri-version-from-package.js
- name: commit RC version bump
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 package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No version bump changes to commit."
exit 0
fi
NEW_VERSION="$(node -p 'require("./package.json").version')"
MSG="chore(release): bump next channel to ${NEW_VERSION}"
if [[ "${{ inputs.source_only }}" == "true" ]]; then
MSG="${MSG} [source-only]"
fi
git commit -m "$MSG"
- name: push next
run: git push --force-with-lease origin HEAD:next
- name: dispatch Next Channel workflow
uses: actions/github-script@v9
env:
SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }}
with:
script: |
const sourceOnly = process.env.SOURCE_ONLY === "true";
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "next.yml",
ref: "main",
inputs: {
source_only: sourceOnly ? "true" : "false",
},
});
core.info(`Dispatched next.yml with source_only=${sourceOnly}`);
@@ -1,79 +0,0 @@
name: Promote next to release
on:
workflow_dispatch:
inputs:
source_only:
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs:
promote:
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: release
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: fast-forward release to next
run: |
set -euo pipefail
git fetch origin next release
# Reset stable channel to next snapshot, then finalize version on top.
git reset --hard origin/next
- name: finalize package version for release
run: |
set -euo pipefail
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
FINAL_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
if [[ "$CURRENT_VERSION" == "$FINAL_VERSION" ]]; then
echo "package.json is already final: $FINAL_VERSION"
exit 0
fi
npm version --no-git-tag-version "$FINAL_VERSION"
- name: sync Tauri version from package.json
run: node scripts/sync-tauri-version-from-package.js
- name: commit final version bump
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 package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No finalization changes to commit."
exit 0
fi
NEW_VERSION="$(node -p 'require("./package.json").version')"
MSG="chore(release): finalize release version ${NEW_VERSION}"
if [[ "${{ inputs.source_only }}" == "true" ]]; then
MSG="${MSG} [source-only]"
fi
git commit -m "$MSG"
- name: push release
run: git push --force-with-lease origin HEAD:release
- name: dispatch Release Channel workflow
uses: actions/github-script@v9
env:
SOURCE_ONLY: ${{ inputs.source_only && 'true' || 'false' }}
with:
script: |
const sourceOnly = process.env.SOURCE_ONLY === "true";
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "release.yml",
ref: "main",
inputs: {
source_only: sourceOnly ? "true" : "false",
},
});
core.info(`Dispatched release.yml with source_only=${sourceOnly}`);
+129 -25
View File
@@ -1,30 +1,134 @@
name: Release Channel
# workflow_dispatch: use workflow from `main` for latest publish logic; checkout ref is always `release`.
name: Release
on:
push:
branches: [release]
# Same nix-refresh loop as next.yml — ignore lock-only merges from verify-nix PRs.
paths-ignore:
- "flake.lock"
- "nix/**"
tags:
- 'v*'
workflow_dispatch:
inputs:
source_only:
description: "Create/refresh tagged release only (skip platform builds and verify-nix)"
required: false
default: false
type: boolean
jobs:
publish-release:
uses: ./.github/workflows/reusable-channel-publish.yml
with:
channel: release
source_ref: release
target_branch: release
prerelease: false
draft_release: true
verify_nix: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
build_platform_artifacts: ${{ !((github.event_name == 'workflow_dispatch' && github.event.inputs.source_only == 'true') || (github.event_name == 'push' && contains(github.event.head_commit.message, '[source-only]'))) }}
secrets: inherit
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: get version
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: create release
id: create-release
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
with:
script: |
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: `app-v${process.env.PACKAGE_VERSION}`,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body: 'See the assets to download this version and install.',
draft: false,
prerelease: false
})
return data.id
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: install npm dependencies
run: npm install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad gstreamer1.0-libav
- name: install linuxdeploy gstreamer plugin
run: |
wget -q "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gstreamer/master/linuxdeploy-plugin-gstreamer.sh" \
-O /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: install npm dependencies
run: npm install
- name: build
run: npm run tauri:build
env:
APPIMAGE_EXTRACT_AND_RUN: 1
- name: patch AppImage AppRun
run: |
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
echo "Patching: $APPIMAGE"
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
-O appimagetool
chmod +x appimagetool
APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool squashfs-root "$APPIMAGE"
rm -rf squashfs-root appimagetool
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.AppImage" -not -name "*.tar.gz" -o -name "*.deb" -o -name "*.rpm" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
@@ -1,489 +0,0 @@
name: Reusable Channel Publish
on:
workflow_call:
inputs:
channel:
description: "Delivery channel name (release/next)"
required: true
type: string
source_ref:
description: "Git ref to build from"
required: true
type: string
target_branch:
description: "Branch that receives nix refresh PRs"
required: true
type: string
prerelease:
description: "Mark GitHub release as prerelease"
required: true
type: boolean
draft_release:
description: "Create release as draft"
required: true
type: boolean
verify_nix:
description: "Run verify-nix job"
required: false
default: true
type: boolean
build_platform_artifacts:
description: "Build and upload macOS/Windows/Linux artifacts + latest.json"
required: false
default: true
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
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
release_tag: ${{ steps.tag.outputs.value }}
source_commit_sha: ${{ steps.source-sha.outputs.value }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.verify_nix && needs.prepare-nix-sources.outputs.source_commit_sha || inputs.source_ref }}
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: get version
id: get-version
run: |
set -euo pipefail
V="$(node -p 'require("./package.json").version')"
# Never publish with a missing/non-semver-ish version — empty becomes tag "app-v" on GitHub and breaks manifests.
if [ -z "$V" ]; then
echo "::error::package.json version is empty"
exit 1
fi
if ! printf '%s' "$V" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::package.json version must start with semver X.Y.Z... (got '$V')"
exit 1
fi
delim="PKGVER_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}_$(openssl rand -hex 8)"
{
echo "version<<$delim"
printf '%s\n' "$V"
echo "$delim"
} >> "$GITHUB_OUTPUT"
- name: compute release tag
id: tag
env:
VERSION: ${{ steps.get-version.outputs.version }}
run: |
set -euo pipefail
if [ -z "${VERSION:-}" ]; then
echo "::error::release tag would be 'app-v' (empty version output) — aborting"
exit 1
fi
TAG="app-v${VERSION}"
if [ "$TAG" = "app-v" ]; then
echo "::error::refuse to create invalid tag 'app-v'"
exit 1
fi
echo "value=$TAG" >> "$GITHUB_OUTPUT"
- name: capture source commit sha
id: source-sha
run: |
set -euo pipefail
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: align existing tag to source ref
env:
TAG: ${{ steps.tag.outputs.value }}
EXPECTED_SHA: ${{ steps.source-sha.outputs.value }}
run: |
set -euo pipefail
git fetch --force --tags origin
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
TAG_SHA="$(git rev-list -n 1 "refs/tags/$TAG")"
if [ "$TAG_SHA" != "$EXPECTED_SHA" ]; then
echo "::warning::Tag '$TAG' points to $TAG_SHA, expected $EXPECTED_SHA from source_ref '${{ inputs.source_ref }}'"
echo "::warning::Re-pointing tag '$TAG' to expected source commit to keep Source code archives aligned with built artifacts."
git tag -f "$TAG" "$EXPECTED_SHA"
git push --force origin "refs/tags/$TAG"
fi
fi
- name: extract changelog
id: changelog
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
BODY=""
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
BODY="$(cat /tmp/changelog-body.md)"
fi
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: extract what's new for release asset
id: whats-new
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
CHANNEL="${{ inputs.channel }}"
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
if [ "$CHANNEL" = "release" ]; then
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
exit 1
fi
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: create or update release
id: create-release
uses: actions/github-script@v9
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
RELEASE_TAG: ${{ steps.tag.outputs.value }}
RELEASE_COMMIT_SHA: ${{ steps.source-sha.outputs.value }}
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
IS_PRERELEASE: ${{ inputs.prerelease }}
IS_DRAFT: ${{ inputs.draft_release }}
with:
script: |
const tag = process.env.RELEASE_TAG;
const body = process.env.CHANGELOG_BODY || "See the assets to download this version and install.";
const prerelease = process.env.IS_PRERELEASE === "true";
const draft = process.env.IS_DRAFT === "true";
const version = process.env.PACKAGE_VERSION;
const targetCommitish = process.env.RELEASE_COMMIT_SHA;
const name = `Psysonic v${version}`;
try {
const { data } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: data.id,
body,
name,
draft,
prerelease,
});
return data.id;
} catch (e) {
if (e.status !== 404) throw e;
}
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
target_commitish: targetCommitish,
name,
body,
draft,
prerelease,
});
return data.id;
- name: upload whats-new.md release asset
if: steps.whats-new.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.tag.outputs.value }}"
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
build-macos-windows:
if: ${{ inputs.build_platform_artifacts }}
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
- platform: "windows-latest"
args: "--bundles nsis"
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.create-release.outputs.source_commit_sha }}
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> "$GITHUB_ENV"
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: re-sign updater bundle + upload .sig (macOS only)
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -e
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
TARGET_ARG='${{ matrix.settings.args }}'
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
TARGET="aarch64-apple-darwin"
ARCH="aarch64"
else
TARGET="x86_64-apple-darwin"
ARCH="x64"
fi
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "::error::Expected tarball missing: $TARBALL"
ls -la "$(dirname "$TARBALL")" || true
exit 1
fi
npx @tauri-apps/cli signer sign "$TARBALL"
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
generate-manifest:
if: ${{ inputs.build_platform_artifacts }}
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.create-release.outputs.source_commit_sha }}
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
gh release upload "$RELEASE_TAG" latest.json --clobber
build-linux:
if: ${{ inputs.build_platform_artifacts }}
needs: create-release
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.create-release.outputs.source_commit_sha }}
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: build
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "$RELEASE_TAG" --clobber
bump-main-to-next-dev:
if: ${{ inputs.channel == 'release' }}
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: main
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: compute next dev version
id: next-dev
env:
RELEASE_VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
set -euo pipefail
NEXT_DEV="$(node -e 'const v=process.env.RELEASE_VERSION; const m=v.match(/^(\d+)\.(\d+)\.(\d+)/); if(!m){throw new Error(`Invalid release version: ${v}`)}; const major=Number(m[1]); const minor=Number(m[2]) + 1; process.stdout.write(`${major}.${minor}.0-dev`)')"
echo "value=$NEXT_DEV" >> "$GITHUB_OUTPUT"
- name: bump package version in main
run: |
set -euo pipefail
TARGET_VERSION="${{ steps.next-dev.outputs.value }}"
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
SHOULD_BUMP="$(node -e '
const parse = (v) => {
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
if (!m) throw new Error(`Invalid semver: ${v}`);
const pre = m[4] ?? "";
const preRank = pre === "" ? 3 : pre.startsWith("rc.") ? 2 : pre === "dev" ? 1 : 0;
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), preRank };
};
const a = parse(process.argv[1]); // current
const b = parse(process.argv[2]); // target
const keys = ["major", "minor", "patch", "preRank"];
for (const k of keys) {
if (b[k] > a[k]) { process.stdout.write("true"); process.exit(0); }
if (b[k] < a[k]) { process.stdout.write("false"); process.exit(0); }
}
process.stdout.write("false");
' "$CURRENT_VERSION" "$TARGET_VERSION")"
if [[ "$SHOULD_BUMP" != "true" ]]; then
echo "main already at ${CURRENT_VERSION} (target ${TARGET_VERSION} is not newer)"
exit 0
fi
npm version --no-git-tag-version "$TARGET_VERSION"
- name: sync Tauri version from package.json
run: node scripts/sync-tauri-version-from-package.js
- name: open PR with dev bump (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 package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No dev version bump required."
exit 0
fi
VERSION="${{ steps.next-dev.outputs.value }}"
SAFE_VERSION="${VERSION//./-}"
SAFE_VERSION="${SAFE_VERSION//\//-}"
BRANCH="chore/version-bump-main-${SAFE_VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(release): bump main to ${VERSION}"
git push origin "$BRANCH"
gh pr create \
--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."
-92
View File
@@ -1,92 +0,0 @@
name: rust-tests
on:
pull_request:
branches: [main]
paths:
- 'src-tauri/**'
- '.github/workflows/rust-tests.yml'
push:
branches: [main]
paths:
- 'src-tauri/**'
- '.github/workflows/rust-tests.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: cargo test --workspace
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libasound2-dev cmake
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: cargo test --workspace
working-directory: src-tauri
run: cargo test --workspace --all-targets
clippy:
name: cargo clippy --workspace
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libasound2-dev cmake
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: cargo clippy --workspace
working-directory: src-tauri
run: cargo clippy --workspace --all-targets -- -D warnings
coverage:
name: cargo llvm-cov (baseline + hot-path file gate)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libasound2-dev cmake jq
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov
- name: generate workspace coverage (lcov + json)
working-directory: src-tauri
run: |
mkdir -p target/llvm-cov
cargo llvm-cov --workspace --lcov --output-path lcov.info
cargo llvm-cov --workspace --json --output-path target/llvm-cov/cov.json
- name: hot-path file coverage gate
run: bash scripts/check-hot-path-coverage.sh
- uses: actions/upload-artifact@v4
with:
name: rust-coverage-lcov
path: src-tauri/lcov.info
if-no-files-found: error
@@ -1,116 +0,0 @@
name: Validate Main Green CI
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to validate"
required: false
default: main
type: string
required_contexts:
description: "Comma-separated required checks; use | for alternatives (e.g. ci-ok|ci-main / ci-ok)"
required: false
default: ci-ok|ci-main / ci-ok
type: string
workflow_call:
inputs:
branch:
required: false
default: main
type: string
required_contexts:
required: false
default: ci-ok|ci-main / ci-ok
type: string
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
checks: read
steps:
- name: validate required checks
uses: actions/github-script@v9
env:
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
REQUIRED_CONTEXTS: ${{ inputs.required_contexts || github.event.inputs.required_contexts || 'ci-ok|ci-main / ci-ok' }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const branch = process.env.TARGET_BRANCH || "main";
const groups = String(process.env.REQUIRED_CONTEXTS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((group) =>
group
.split("|")
.map((s) => s.trim())
.filter(Boolean)
);
const runId = String(context.runId);
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
const sha = branchResp.data.commit.sha;
core.info(`Validating checks for ${branch} @ ${sha}`);
const checksAll = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
const checks = checksAll.filter((c) => !(c.details_url || "").includes(`/actions/runs/${runId}/`));
const newestByName = new Map();
for (const c of checks) {
const key = c.name;
const prev = newestByName.get(key);
if (!prev) {
newestByName.set(key, c);
continue;
}
const prevTime = Date.parse(prev.started_at || prev.completed_at || "") || 0;
const curTime = Date.parse(c.started_at || c.completed_at || "") || 0;
if (curTime >= prevTime) {
newestByName.set(key, c);
}
}
const failures = [];
for (const alternatives of groups) {
let ok = false;
const altNotes = [];
for (const name of alternatives) {
const latest = newestByName.get(name);
if (!latest) {
altNotes.push(`${name}: missing`);
continue;
}
if (latest.status !== "completed") {
altNotes.push(`${name}: status=${latest.status}, conclusion=${latest.conclusion}`);
continue;
}
if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
ok = true;
break;
}
altNotes.push(`${name}: conclusion=${latest.conclusion}`);
}
if (!ok) {
failures.push(`(${alternatives.join(" | ")}): none green — ${altNotes.join("; ")}`);
}
}
if (failures.length > 0) {
core.setFailed(`Required checks for ${branch} are not green:\n${failures.join("\n")}`);
return;
}
const summary = groups
.map((alts) => (alts.length > 1 ? `(${alts.join(" | ")})` : alts[0]))
.join(", ");
core.info(`All required checks for ${branch} are green (${summary}).`);
-38
View File
@@ -7,11 +7,6 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment variables (API keys)
.env
.env.local
.env.*.local
# Node
node_modules
dist
@@ -31,39 +26,6 @@ dist-ssr
# Tauri
src-tauri/target/
src-tauri/gen/
src-tauri/lcov.info
# Frontend test coverage
coverage/
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
src/generated/
# Documentation
CLAUDE.md
# Local commit-instructions for agents (never commit)
to_commit.md
# Claude Code memory (local only)
memory/
# Local scratchpad / notes (not committed)
tmp/
# Third-party clones for local research (not committed)
research/
# Nix build output symlink
result
result-*
# Local incremental cargo (nix develop; not used by flake — see nix/psysonic.nix cleanSource)
.build-local/
# Local Nix helpers (not in repo — optional; use `nix develop` / flake without them)
dev.sh
shell.nix
prod.sh
tsconfig.tsbuildinfo
-5142
View File
File diff suppressed because it is too large Load Diff
-184
View File
@@ -1,184 +0,0 @@
# Contributing to Psysonic
Thanks for your interest in helping the project.
Psysonic is **GPLv3** — see [LICENSE](LICENSE). Forks and modifications are welcome under the license; for attribution expectations when publishing derivative work, see **Forks and Attribution** in the [README](README.md).
## Contents
- [Quick start](#quick-start)
- [Before you write code](#before-you-write-code)
- [Repository layout](#repository-layout)
- [Environment and running the app](#environment-and-running-the-app)
- [Where processes and conventions are documented](#where-processes-and-conventions-are-documented)
- [House rules](#house-rules)
- [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract)
- [CI on pull requests to `main`](#ci-on-pull-requests-to-main)
- [Local checks](#local-checks)
- [Pull request expectations](#pull-request-expectations)
- [Why we are wary of irreversible UI churn](#why-we-are-wary-of-irreversible-ui-churn)
---
## Quick start
```bash
git clone https://github.com/Psychotoxical/psysonic.git
cd psysonic
npm install
npm run tauri:dev # run the desktop app in dev mode
npm test # frontend tests (Vitest)
( cd src-tauri && cargo test --workspace --all-targets ) # backend tests
```
Open pull requests against `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly. The rest of this document covers what reviewers look for, especially around the [Tauri contract](#the-rust--frontend-tauri-contract) and UI changes.
---
## Before you write code
- **Usage questions** ("is this a bug or my setup?") — please use [Discord](https://discord.gg/AMnDRErm4u) or [Telegram](https://t.me/+GLBx1_xeH28xYTJi) first. The issue tracker is intended for confirmed bugs and feature requests (see [issue templates](.github/ISSUE_TEMPLATE/)).
- **AUR packaging problems** — follow the AUR links in [README](README.md); those packages are maintained separately from this repository.
- **Large features or UX overhauls** — consider discussing in chat or opening an issue early so effort aligns with product direction.
- **Changes to the Tauri boundary** — read [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract) before opening a PR; reviewers will ask for a clear justification.
- **Security issues** — please do **not** open a public issue. See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately (Discord or Telegram).
---
## Repository layout
```text
src/ React / TypeScript frontend
src-tauri/ Rust backend (Tauri host process)
public/ Static assets served by Vite
scripts/ CI helpers (coverage gates, install, version sync)
.github/ Workflows, issue templates, hot-path lists
flake.nix Nix development shell + packaging
```
---
## Environment and running the app
See [README](README.md) (**Development**) for the basic flow: from the repository root, `npm install` then `npm run tauri:dev` for development or `npm run tauri:build` for a release build. Use `npm install` while iterating; `npm ci` is what CI runs and is the right command when you want a reproducible install.
For non-Linux contributors, install the native dependencies Tauri requires on your OS — see the upstream [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) (Windows: WebView2 + MSVC build tools; macOS: Xcode Command Line Tools). The Linux package list used in CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml).
If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pinned toolchain and native dependencies. Adding `psysonic.cachix.org` as a substituter (badge in the [README](README.md)) lets you pull prebuilt dev-shell dependencies instead of rebuilding them locally.
---
## Where processes and conventions are documented
| Topic | Location |
|--------|----------|
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
---
## House rules
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
3. **Linting and formatting:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
5. **License:** new code must remain compatible with the project's GPLv3.
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
7. **i18n:** user-visible strings live in `src/locales/*.ts` (one TypeScript module per language) and are wired up in `src/i18n.ts`. English (`en.ts`) is the baseline — always add the key there. Other locales may be left for follow-up translation PRs if you don't speak the language, but keep the object shape consistent so missing keys are obvious.
---
## The Rust ↔ frontend (Tauri) contract
Treat `invoke` handlers, event names, and JSON/payload shapes as a **public API between two codebases**. Prefer **additive** changes (new optional fields, new commands/events) over silent renames or breaking shape changes.
When a breaking change is unavoidable, it should be:
- **narrow** and **documented in the PR**,
- paired with updates on **both sides** of the boundary, and
- paired with updates to any Vitest Tauri mocks that encode the contract.
Drive-by churn here is expensive: it hurts forks, complicates bisects, and forces every contributor to relearn the boundary. If the same outcome can be achieved inside Rust or inside React alone, default to that.
Align early: open an issue or chat thread before sending a PR that renames `invoke` commands, changes event payloads, or reshapes data across the boundary. Reviewers will ask for a clear benefit because every such change ripples through `src-tauri`, `src`, tests, and future contributors' mental model.
---
## CI on pull requests to `main`
PRs must target `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly.
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.): `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
---
## Local checks
Assume the repository root is `psysonic/` (for example after `git clone https://github.com/Psychotoxical/psysonic.git` and `cd psysonic`).
**Frontend** — from the repository root:
```bash
npm ci
npm test
npx tsc --noEmit
npm run test:coverage
bash scripts/check-frontend-hot-path-coverage.sh
```
The last command mirrors the optional hot-path gate used in CI; `jq` must be on `PATH`.
**Rust** — install the Linux packages your distro needs to build Tauri/WebKitGTK (the list used in Ubuntu CI is in [`rust-tests.yml`](.github/workflows/rust-tests.yml) under `apt-get install`), or use `nix develop`. Then:
```bash
cd src-tauri
cargo test --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
```
To reproduce the **coverage + hot-path** job locally you also need:
- `cargo-llvm-cov`
- the `llvm-tools-preview` rustup component
- `jq` on `PATH`
The exact `cargo llvm-cov` invocations and the gate call are taken from the `coverage` job in [`rust-tests.yml`](.github/workflows/rust-tests.yml). After generating `src-tauri/target/llvm-cov/cov.json` as that job does, run the gate from the **repository root**:
```bash
bash scripts/check-hot-path-coverage.sh
```
If you change both frontend and backend, run the relevant blocks above before opening a PR.
---
## Pull request expectations
- **Description:** what changed, who should notice (end users vs developers only), how to verify manually. Link the issue if the PR closes it.
- **Scope:** stay on task; no unrelated reformatting or cleanup in the same PR.
- **UI/UX:** describe the user flow; before/after screenshots help reviewers a lot.
- **i18n:** see [House rules](#house-rules) — add the key to `en.ts` first, keep the shape of other locales consistent.
- **Server compatibility:** the client targets the Subsonic API and is **Navidrome-first**; if a feature depends on server support, say so explicitly.
- **Tauri boundary:** if you touched it, list added/removed/renamed commands and events, describe payload changes, and note how you verified both `src-tauri` and `src` (plus any updated tests/mocks). If you did **not** touch the boundary, saying so helps reviewers scope the review.
- **Persisted settings / on-disk layout:** if you change how configuration or local data is stored, migrated, or located, spell out the impact on **existing installs** (one-time migration, backwards compatibility, or explicit break with rationale).
---
## Why we are wary of irreversible UI churn
Psysonic is a desktop app people use for hours: muscle memory, layout, themes, keyboard workflows, and accessibility settings all matter. Abrupt changes to navigation, information hierarchy, or visual language without a migration path:
- break **habits and power-user flows**;
- complicate **themes and accessibility** (contrast, sizing, custom fonts);
- increase support load and frustration — some users stay on old builds or fork.
We prefer **evolutionary** UI work: discuss large shifts early, ship in steps where possible, use settings or toggles when a breaking visual change is justified, and preserve predictability where users did not ask for an experiment. That is not a ban on fresh design — it is a preference to **not strand users** without a strong reason and a clear adaptation path.
+17 -16
View File
@@ -1,20 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
MIT License
Copyright (C) 2026 Psychotoxical
Copyright (c) 2026 Psychotoxical
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The full license text is available at:
https://www.gnu.org/licenses/gpl-3.0.txt
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-40
View File
@@ -1,40 +0,0 @@
# Notice
Psysonic is licensed under the GNU GPLv3.
Psysonic, including features such as **Orbit** (synchronized shared
listening sessions), was originally designed and implemented by the
Psysonic project maintainers and contributors.
Forks and modified versions are welcome under the GPLv3, but they
must preserve copyright notices, license information, and clear
attribution to the original Psysonic project.
Modified versions must not imply that original Psysonic features were
independently created by the fork, nor imply endorsement by the
Psysonic project.
## Required Attribution for Derivative Works
The following attribution requirements apply to all derivative works
under the additional terms permitted by **GPLv3 §7(b)**. Forks and
redistributions must preserve these notices intact:
1. The unmodified contents of this `NOTICE.md` file must be retained
in the source tree of any fork or derivative work.
2. Derivative works that include or are based on the **Orbit**
feature — its design, protocol, or implementation — must clearly
state in their own documentation and release notes that the Orbit
feature was originally designed and implemented in Psysonic, and
must not present Orbit's design or implementation as independent
original work of the fork.
3. The user-facing name "Orbit" is a Psysonic feature mark and may
not be reused by forks; see `TRADEMARK.md`. The attribution
requirement in (2) applies regardless of what the feature is
renamed to in a fork.
4. The same applies to any other original Psysonic feature that a
fork chooses to retain.
These terms do not restrict the freedoms granted by the GPLv3; they
are attribution-preservation terms explicitly permitted by GPLv3
§7(b) and must be carried forward in further redistribution.
-344
View File
@@ -1,344 +0,0 @@
# Psy Orbit
A "listen together" mode built into Psysonic. One participant hosts the music, others tune in and listen in sync. Guests can suggest tracks; the host decides what lands in the queue.
No external servers, no relays, no accounts on yet another platform — Orbit piggybacks entirely on your existing Navidrome instance. Sessions live in regular playlists (with a small JSON blob in the comment field), the clients poll and write to those playlists, and that's it.
---
## Table of contents
- [For users](#for-users)
- [Starting a session (host)](#starting-a-session-host)
- [Joining (guest)](#joining-guest)
- [Suggesting tracks](#suggesting-tracks)
- [Approvals](#approvals)
- [Shared queue](#shared-queue)
- [Session settings](#session-settings)
- [Participants](#participants)
- [Ending the session](#ending-the-session)
- [Requirements & limits](#requirements--limits)
- [How it works (technical)](#how-it-works-technical)
- [Design goals](#design-goals)
- [Playlists as transport](#playlists-as-transport)
- [The host tick](#the-host-tick)
- [The guest tick](#the-guest-tick)
- [Data flow](#data-flow)
- [State shape](#state-shape)
- [Cleanup](#cleanup)
- [Security & privacy](#security--privacy)
- [Edge cases handled](#edge-cases-handled)
- [Code map](#code-map)
---
## For users
### Starting a session (host)
Click **Psy Orbit** in the top bar → **Create a session**. The start modal opens with:
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
- **Max guests** — cap on concurrent participants (132). You don't count.
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
### Joining (guest)
Two equivalent paths:
1. **Paste anywhere** — copy the invite link the host sent you. Anywhere in Psysonic (not inside a text field), press `Ctrl+V` (`Cmd+V` on macOS). A confirm dialog shows who invited you; click Join.
2. **Launch popover** — click **Psy Orbit** in the top bar → **Join a session** → paste the link into the field → Join.
Either path performs the same preflight: validates the link, checks the session still exists, handles server switches automatically if the link points at another Navidrome you have an account for. If you have **multiple accounts** on the target server, a small picker asks which one to join as.
### Suggesting tracks
Anywhere a song row appears (album, playlist, favorites, artist top-songs, search results, random mix, advanced search):
- **Double-click** a row → adds just that track.
- **Right-click → "Add to Orbit session"** — same effect, via context menu.
- **Single-click** on a row shows a toast hint: "Double-click to add". This is deliberate — a single click normally drops the whole album into your queue, which would spam the shared queue and annoy everyone.
Explicit bulk buttons (**Play All** / **Play Album** / **Play Playlist** / Hero play / Album-card play) ask for a confirmation first inside an active session. On confirm, the tracks are **appended** to the shared queue, never replacing it.
### Approvals
By default, a new session starts with **auto-approve off**. Guest suggestions land in the session's suggestion history but not the actual playback queue — the host decides.
The host sees a prominent **Pending approvals** strip at the top of the queue panel: each pending track with cover, title, artist, and "Suggested by …" line, plus two buttons:
- ✓ Accept — enqueues the track into the host's player queue. Guests see it appear in the shared queue on the next tick.
- ✕ Decline — drops the suggestion. It stays in the suggestion history for audit but won't show up again in the approval strip.
Auto-approve can be toggled on in the session settings for any session where manual approval isn't needed.
### Shared queue
Both hosts and guests see a strip at the top of the queue panel with the session name and a comma-separated list of all participants (host first). Under that:
- **Host** view: regular queue, with any new guest suggestions injected into random positions inside the upcoming range.
- **Guest** view: read-only display of the host's upcoming queue (up to 30 tracks at a time) with submitter attribution — "by alice" for host-chosen tracks is omitted; "suggested by alice" is shown for guest suggestions.
When the guest has in-flight suggestions that haven't been merged yet, they appear in a separate **Waiting for host** section above Up next, with a clock icon. Once the host (auto-)approves and merges, they move into the normal list.
### Session settings
Open via the gear icon in the session bar (host only):
- **Auto-approve suggestions** — on/off. Default off.
- **Automatic reshuffle** — on/off. Periodically FisherYates-shuffles the upcoming queue.
- **Reshuffle every** — 1 / 5 / 10 / 15 / 30 min preset picker. Disabled when auto-reshuffle is off.
- **Shuffle now** — one-shot manual shuffle + bumps the next-auto-shuffle timer.
### Participants
Click the participant count in the session bar. Opens a popover:
- Host row at the top with a crown icon.
- Each connected guest with a user icon, username, and join timestamp.
- Host-only actions per guest: **Remove** (drops them from the session, can re-join via the invite link) vs. **Ban** (permanently blocked for the lifetime of the session). Both confirm before firing.
Guests see the same list but read-only — no action buttons.
### Ending the session
- **Host clicks X** → confirm dialog → session closes for everyone. Server playlists are deleted automatically.
- **Guest clicks X** → confirm dialog → just the guest leaves. Session continues for everyone else.
- **Host goes silent for 5 minutes** (network drop, app crash, laptop lid) → guests auto-leave with a dedicated "Host went silent" modal.
- **App close / force quit** → next app launch sweeps up any orphaned session playlists you own (`__psyorbit_*` with stale heartbeat).
### The help modal
Every screen with the session bar has a `?` icon between settings and X that opens a 9-section walk-through of everything above, with keyboard navigation (arrow keys between sections, Enter to expand).
---
## Requirements & limits
- **Same Navidrome server.** Everyone — host and all guests — must be logged into the same Navidrome instance. Orbit links encode the server URL, and Psysonic auto-switches on paste if you have an account there.
- **Separate accounts.** Each participant needs their own Navidrome user. If a host and guest log in as the same user, their outbox playlists collide and suggestions get lost. This is a hard limit of the current design — Orbit identifies participants by username.
- **Public server address for remote guests.** Guests outside your home network need your server reachable at a public hostname. The start modal warns you if you're currently connected via a LAN address.
- **Host presence matters.** Guests auto-leave after 5 minutes of no host activity. Shorter reconnects (network blips, phone screen off, whatever) are invisible.
- **Session size.** State is bounded to ~4 KB per playlist comment. In practice that's plenty for a session name, participants list, and a suggestion history; there's no hard cap on tracks through the actual playback queue.
---
## How it works (technical)
### Design goals
1. **No external infrastructure.** Everything runs on your Navidrome. No relay, no auth server, no persistent state anywhere you don't already own.
2. **No protocol changes.** Uses Navidrome's existing Subsonic/OpenSubsonic playlist endpoints. If your server can host a normal playlist, it can host an Orbit session.
3. **Degrade gracefully.** A dropped tick doesn't break a session. Network blips are silent. Missing heartbeats expire cleanly. Crashes clean up on the next launch.
4. **Host-authoritative.** The host's player is the ground truth; guests mirror. No distributed consensus, no leader election.
### Playlists as transport
Every session creates two kinds of playlists on the server (names are stable and start with `__psyorbit_`):
| Playlist | Who owns it | What's in it |
|---|---|---|
| `__psyorbit_<sid>` | host | Canonical session state (4 KB JSON blob) in the playlist **comment**. Track list is always empty. |
| `__psyorbit_<sid>_from_<user>__` | each participant | Outbox. Comment holds a heartbeat timestamp; the track list holds pending guest suggestions. |
All playlists are marked `public: true` so every participant can read them via the normal Subsonic endpoints (`getPlaylist.view`, `getPlaylists.view`). Psysonic filters `__psyorbit_*` out of its own UI (Playlists page, pickers, context menu), but the Navidrome web client will show them while a session is active.
### The host tick
Fired every 2.5 s from `useOrbitHost`:
1. **Sweep all outboxes.** List every `__psyorbit_<sid>_from_<user>__` playlist. For each one, read the current tracklist (= new suggestions from that guest) and the heartbeat timestamp from the comment.
2. **Apply snapshots to state.** Rebuild the `participants` array from heartbeat freshness (anyone with a heartbeat < 30 s old is "alive"). Append new suggestions to `state.queue` as `OrbitQueueItem { trackId, addedBy, addedAt }`.
3. **Clear each swept outbox's tracklist** (heartbeat stays). Single-pass consume — a track the host has seen is the host's problem now, not the outbox's.
4. **Merge into player queue** (when auto-approve is on, and the suggestion isn't host-authored or already merged). Each merged track gets sprinkled at a random position in the upcoming range so host picks and guest suggestions interleave.
5. **Maybe shuffle.** If auto-shuffle is on and the interval elapsed, FisherYates-reorder the upcoming play queue + rewrite `state.lastShuffle`.
6. **Snapshot playback.** Write `isPlaying`, `positionMs`, `positionAt` (wall-clock), `currentTrack`, and a 30-item slice of the upcoming play queue (`playQueue`) into the state blob.
7. **Write.** Serialise and push to the session playlist's comment via `updatePlaylist.view`.
Host also writes a heartbeat to its own outbox every 10 s so the participants pipeline treats the host symmetrically.
### The guest tick
Fired from `useOrbitGuest` — fast polling (500 ms) until the first successful sync lands, then steady 2.5 s:
1. **Read the session playlist comment** via `getPlaylist.view`. Parse the OrbitState.
2. **Check for session death:** comment empty → session-ended; `state.ended === true` → session-ended; `state.positionAt` older than 5 min → host-timeout.
3. **Check kick / remove:** if the local username is in `state.kicked` or has a fresh entry in `state.removed`, transition to the appropriate exit modal.
4. **Reconcile pending suggestions.** For every trackId the local client has submitted, check if it's appeared in `state.playQueue` or `state.currentTrack`. If so, drop it from the local "pending" list (the UI hides it automatically).
5. **Auto-sync to host.** Three cases:
- Different track at host → load it locally (`playTrack`), seek to `estimateLivePosition(state, now)`, mirror `isPlaying`. Never touches the local player if the guest has locally diverged (paused on their own).
- Same track, play/pause flipped at host → mirror only if the guest hasn't locally diverged since the last tick.
- First tick after join → mirror unconditionally (initial sync).
6. **Heartbeat tick** (independent, every 10 s): write `{ ts: Date.now() }` into the guest outbox comment.
### Data flow
```
Host (per tick) Navidrome Guest (per tick)
──────────────────────────────────────────────────────────────────────────────────
player.currentTrack
+ position
snapshotPlayerPatch ──► writeOrbitState ─┐
┌─session playlist─┐
│ comment = JSON │ ◄─readOrbitState
└──────────────────┘
parse OrbitState
syncToHost:
• getSong
• playTrack
• seek
• resume/pause
Guest suggests track Y
┌────────────────────────────────────────────────────────────┤
│ │
▼ │
suggestOrbitTrack
┌──guest outbox──┐
│ track list = Y │ ◄─updatePlaylist
└────────────────┘
Host: sweepGuestOutboxes ◄──────────┘
applyOutboxSnapshotsToState
(queue += Y, participants refreshed)
▼ (if auto-approve)
mergeNewSuggestionsIntoQueue
player.enqueueAt ──► playQueue snapshot ──► session playlist ──► Guest reconciles
pending list
```
### State shape
All relevant types in `src/api/orbit.ts`:
```ts
interface OrbitState {
v: 3;
sid: string; // 8 hex chars
host: string; // navidrome username
name: string; // human-readable session name
started: number; // ms since epoch
maxUsers: number;
currentTrack: OrbitQueueItem | null;
isPlaying: boolean;
positionMs: number;
positionAt: number; // wall-clock ms of the last snapshot
queue: OrbitQueueItem[]; // suggestion history
playQueue: OrbitQueueItem[]; // 30-item slice of host's upcoming
playQueueTotal: number;
participants: OrbitParticipant[];
kicked: string[];
removed: { user: string; at: number }[];
lastShuffle: number;
settings: {
autoApprove: boolean;
autoShuffle: boolean;
shuffleIntervalMin: 1 | 5 | 10 | 15 | 30;
};
ended: boolean;
}
interface OrbitQueueItem {
trackId: string;
addedBy: string; // navidrome username
addedAt: number;
}
```
The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState` throws `OrbitStateTooLarge` above the budget so callers can trim optional fields and retry.
### Cleanup
Three layers of defense against orphaned playlists:
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
### Security & privacy
- **Authentication.** Uses Navidrome's own user system. Participants are identified by their username; no additional auth layer.
- **Public playlist visibility.** Session and outbox playlists must be `public: true` so guests can read them. Side effect: they're visible to *any* user on the same Navidrome instance while the session is active. Psysonic's own UI filters them; the Navidrome web client does not.
- **No external servers.** Orbit is strictly peer-to-peer via the Navidrome instance you already trust. No data leaves your server.
- **No message signing.** Since everything is owned by authenticated Navidrome users, we rely on the server's own ACLs. A guest can't modify the host's session playlist (different owner), only their own outbox.
- **Track IDs only.** The state blob references tracks by their Navidrome ID. No filenames, no paths, no stream URLs.
---
## Edge cases handled
- **Host offline < 15 s.** Silent. Guests extrapolate via `estimateLivePosition` (positionMs + elapsed wall-clock).
- **Host offline 15 s 5 min.** Guest UI shows a yellow "Host offline" badge next to the session name. Playback continues locally.
- **Host offline > 5 min.** Guest auto-leaves with a "Host went silent" modal. Cleanup of guest outbox runs on dismissal.
- **Guest pauses locally.** The guest's local pause survives host track changes — the next-track event won't silently un-pause them. "Catch up" brings them back in sync.
- **Guest resume in orbit.** Pressing play (player bar, media keys, MPRIS) in an active session is interpreted as "catch up" — loads the host's current track and seeks to the live position, not "resume the locally frozen track".
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
- **Session playlist deleted** (cleanup race while the guest's local store says it's still active): guest treats as "ended", shows the exit modal.
---
## Code map
### State and types
- `src/api/orbit.ts``OrbitState`, `OrbitQueueItem`, `OrbitSettings`, serialise/parse helpers, `estimateLivePosition`.
- `src/store/orbitStore.ts` — local session state: role, phase, session/playlist ids, `pendingSuggestions`, `mergedSuggestionKeys`, `declinedSuggestionKeys`.
### Lifecycle
- `src/utils/orbit.ts``startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
### Hooks
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
- `src/hooks/useOrbitGuest.ts` — guest state pull + auto-sync + heartbeat + host-timeout detection.
- `src/hooks/useOrbitSongRowBehavior.ts` — shared double-click-to-add behaviour for song lists.
### UI — session bar and popovers
- `src/components/OrbitSessionBar.tsx` — topbar strip with name, counts, shuffle timer, settings/share/help/catch-up/exit buttons.
- `src/components/OrbitSettingsPopover.tsx` — host settings (auto-approve, auto-shuffle, interval, manual shuffle).
- `src/components/OrbitSharePopover.tsx` — host-only invite-link popover with copy button.
- `src/components/OrbitParticipantsPopover.tsx` — participant list with kick/ban (host-only actions).
### UI — modals
- `src/components/OrbitStartModal.tsx` — session creation wizard.
- `src/components/OrbitJoinModal.tsx` — manual invite-link paste + join.
- `src/components/OrbitAccountPicker.tsx` — multi-account disambiguation when joining.
- `src/components/OrbitExitModal.tsx` — session-ended / kicked / removed / host-timeout exit notice.
- `src/components/OrbitHelpModal.tsx` — 9-section help walk-through (keyboard-navigable).
- `src/components/OrbitStartTrigger.tsx` — "Psy Orbit" button in the header + launch popover (create / join / help).
### UI — queue views
- `src/components/OrbitQueueHead.tsx` — shared header strip (session name, participants, host-presence badge).
- `src/components/OrbitGuestQueue.tsx` — guest-side queue view (current track, pending suggestions, upcoming).
- `src/components/HostApprovalQueue.tsx` — host-side approval strip with accept/decline.
### Supporting
- `src/store/confirmModalStore.ts` + `src/components/GlobalConfirmModal.tsx` — promise-based confirm dialog used by the bulk-gate.
- `src/store/helpModalStore.ts` + `src/components/OrbitHelpModal.tsx` — shared help-modal state.
- `src/store/orbitAccountPickerStore.ts` + `src/components/OrbitAccountPicker.tsx` — account picker for multi-account server switch.
-56
View File
@@ -1,56 +0,0 @@
# Privacy Policy
Psysonic is a self-hosted music player. It does not collect telemetry, analytics, or any data on its own. All data stays on your device or travels exclusively between your device and services you explicitly configure.
## Data sent to third-party services
All third-party integrations listed below are **opt-in**. Nothing is sent until you enable the respective feature.
### Your Subsonic / Navidrome server
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
### Music Network (scrobble & enrichment services)
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
To each connected service, Psysonic may send:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
- **Love / Unlove** — when you mark a track as loved, on services that support it
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
### YouLyPlus (Lyrics)
If YouLyPlus mode is selected in Settings → Lyrics, Psysonic sends the track title, artist, album, duration, and ISRC (when available) to a community-operated [lyricsplus](https://github.com/ibratabian17/lyricsplus) backend to fetch word-synced karaoke lyrics. No account is required. Requests are routed through a list of public mirrors; the data they receive is limited to the search query above. This feature is disabled by default.
### NetEase Cloud Music (Lyrics)
If NetEase is enabled as a lyrics source in Settings → Lyrics, Psysonic sends the track artist and title to the NetEase Cloud Music API (via a Rust-side proxy request) to search for synced lyrics. No account is required. This feature is disabled by default.
### Apple Music / iTunes Search API
If "Use Apple Music covers for Discord" is enabled in Settings, Psysonic queries the [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) with the current track's artist and album name to find cover art. No Apple account is required. Apple's own privacy policy applies to these requests.
### Discord Rich Presence
If Discord is running and Rich Presence is not disabled, Psysonic connects to the local Discord client via its IPC socket to display the currently playing track. This data is sent to Discord and subject to [Discord's privacy policy](https://discord.com/privacy). No data is sent if Discord is not installed or not running.
## Data stored locally
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
- Server profiles (URL, username, password)
- Scrobble service credentials (session keys / API tokens)
- Playback preferences, themes, keybindings, and all other settings
- Synced device manifests
## No telemetry
Psysonic contains no crash reporting, analytics, usage tracking, or any form of telemetry.
## Open source
Psysonic is fully open source under the [GNU General Public License v3.0](LICENSE). You can verify exactly what data is sent by reading the source code.
+63 -208
View File
@@ -1,245 +1,100 @@
<div align="center">
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="320"/>
## A modern desktop client for self-hosted music libraries
**Fast. Native. Beautiful. Built for people who actually care about their music collection.**
Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **Airsonic**, **LMS** and other Subsonic-compatible servers, depending on the features supported by your server.
<br>
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=for-the-badge&label=Latest%20Release&color=8b5cf6" alt="Latest Release"></a> <a href="https://github.com/Psychotoxical/psysonic/stargazers"><img src="https://img.shields.io/github/stars/Psychotoxical/psysonic?style=for-the-badge&color=f59e0b" alt="GitHub Stars"></a> <a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-22c55e?style=for-the-badge" alt="License GPLv3"></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-v2-0f172a?style=for-the-badge&logo=tauri" alt="Tauri v2"></a>
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic/1.47.0"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
<br><br>
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese and Hungarian.
More translations are added over time.
**No telemetry • Native performance • Navidrome-first • Community driven**
<img src="public/logo.png" alt="Psysonic Logo" width="200"/>
<h1>Psysonic</h1>
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
<p>
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
</p>
</div>
---
![Psysonic Screenshot](public/screenshot1.png)
Psysonic is a beautiful desktop music player built completely from the ground up for the modern era. Utilizing **Tauri v2** and **React**, it offers a native-feeling, lightweight, and incredibly fast experience with a stunning UI inspired by the [Catppuccin](https://github.com/catppuccin/catppuccin) aesthetic.
---
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
## What is Psysonic?
![Psysonic Screenshot](public/screenshot.png)
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.
## ✨ Features
It is built with **Rust**, **Tauri v2** and **React**, with a strong focus on responsiveness, customization, practical music-library workflows and a user interface that does not require a manual before you can press play.
- 🎨 **Gorgeous UI**: Deeply integrated Catppuccin themes (Mocha & Latte) with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English and German (v1.0.0).
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
- 💾 **Local Caching**: Fast loading times with customizable image caching thresholds.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums.
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux.
Psysonic is **optimized first and foremost for Navidrome**. Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
## 🛡️ Security
---
With the **v1.0.0 Release**, Psysonic has been hardened for public use:
- **Secured Credentials**: Industry-standard security using Tauri's native encrypted store.
- **Hardened Sandbox**: Restricted filesystem permissions to only necessary directories.
- **Native Security**: Disabled global Tauri objects to significantly reduce XSS attack vectors.
# Highlights
## 📥 Installation
## Playback & Queue
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
* Gapless playback
* Crossfade
* ReplayGain support
* LUFS-based Smart Loudness Normalization
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
* Infinite Queue
* Smart Radio sessions
* Fast and responsive playback handling
* Low memory usage compared to heavy web-first clients
- **Windows**: `.exe` or `.msi`
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux**: `.AppImage` or `.deb`
## Audio Tools
## 🚀 Getting Started
* 10-band Equalizer
* Equalizer presets
* AutoEQ headphone correction
* Per-device optimization
* Loudness-aware playback options
1. Download and install Psysonic.
2. Open the app and enter your Subsonic/Navidrome server details (URL, Username, Password).
3. If applicable, you can provide both an external URL and a local LAN IP. Psysonic allows you to quickly toggle between them in the Settings.
4. Enjoy your music!
## Library Management
## 🛠️ Development
* Fast search across large libraries
* Albums, artists, tracks and genres
* Ratings support
* Multi-select bulk actions
* Drag & drop playlist management
* Smart Playlists
* Built for large self-hosted collections
If you want to build Psysonic from source or contribute to the project:
## Lyrics & Discovery
### Prerequisites
- [Node.js](https://nodejs.org/) (v18+)
- [Rust](https://www.rust-lang.org/) (v1.75+)
- OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
* Synced lyrics with seek support
* Lyrics provider support: [YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB and NetEase
* Auto-scrolling sidebar lyrics
* Fullscreen lyric mode
* Last.fm scrobbling
* Similar artists
* Loved tracks and listening stats
## Sharing & Social Listening
* Magic Strings sharing:
* share albums, artists and queues
* Navidrome user management helpers
* fast account sharing
* Orbit shared listening sessions:
* host-controlled synchronized playback
* session invites via link
* guest song suggestions
* real-time queue interaction
## Personalization & Accessibility
* Large theme collection
* Catppuccin and Nord inspired styles
* Glassmorphism effects
* Font customization
* Zoom controls
* Keybind remapping
* Theme Scheduler for automatic day/night switching
* Colorblind-friendly theme options
* Keyboard-friendly navigation
## Power User Extras
* CLI controls
* USB / portable sync
* Backup and restore settings
* In-app auto updater
* LAN / remote auto switching
---
<div align="left">
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
</div>
Orbit brings synchronized shared listening sessions directly into Psysonic.
Start a session, invite others with a link and listen together with host-controlled playback, shared queue interaction and guest song suggestions. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
---
# Platforms
| OS | Support |
| ------- | --------------------------------------------------------------- |
| Windows | Native installer / WinGet |
| macOS | Signed DMG |
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
---
# Install
## Linux
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
> **AppImage runs under X11/XWayland** — it pins `GDK_BACKEND=x11` for a stable WebKitGTK stack. For a native-Wayland launch, use the `.deb`, `.rpm`, AUR, or Nix packages, which follow your session's display server.
## Windows
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
or,
install via Windows Package Manager (WinGet):
```powershell
winget install Psysonic
```
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
## macOS
Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
---
# Development
Contributor expectations (PRs, CI, Tauri boundary, UI): [CONTRIBUTING.md](CONTRIBUTING.md).
### Setup
```bash
# Clone the repository
git clone https://github.com/Psychotoxical/psysonic.git
cd psysonic
# Install node dependencies
npm install
# Run in development mode
npm run tauri:dev
```
Build release:
```bash
# Build for production
npm run tauri:build
```
---
## 🤝 Contributing
# Privacy
Contributions are completely welcome! Whether it is translating the app into a new language, fixing a bug, or proposing a new feature.
Psysonic is built for self-hosted music collections. Your library is yours.
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
* No telemetry
* No spyware nonsense
* No analytics harvesting
* No hidden tracking
## 📄 License
See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVACY.md) for how each opt-in integration handles data.
Distributed under the MIT License. See `LICENSE` for more information.
---
# Reviews
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
---
# Community & Support
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
* [Discord](https://discord.gg/AMnDRErm4u)
* [Telegram](https://t.me/+GLBx1_xeH28xYTJi)
* [GitHub Issues](https://github.com/Psychotoxical/psysonic/issues)
* [Support Psysonic on Ko-fi](https://ko-fi.com/psychotoxic)
---
# License
Psysonic is licensed under the **GNU GPL v3.0**.
---
## Forks and Attribution
Psysonic is free and open-source software under the GPLv3. You are welcome to fork it, modify it and build upon it under the terms of the license.
If you publish a modified or rebranded version, please make it clear that your project is based on Psysonic and preserve proper attribution to the original project.
That is not about preventing forks. Forks are part of open source. It is about being honest with users and contributors about where the work comes from.
Features, design work and implementation ideas developed in Psysonic should not be presented as unrelated original work in downstream projects.
---
<div align="center">
## Own your music. Enjoy the client too.
**Psysonic brings a modern desktop experience to self-hosted music libraries.**
</div>
<p align="center">
<i>Core Components developed with <b>Claude Code</b></i><br/>
<i>UI/UX developed with <b>Gemini 2.0 Flash</b> using <b>Antigravity</b></i>
</p>
-243
View File
@@ -1,243 +0,0 @@
# Release Process (Strict SOP)
This document defines the **only allowed** release workflow for this repository.
All maintainers should follow it exactly.
## 1) Branch roles
- `main`:
- primary development branch
- all regular feature/fix work lands here via PR
- should usually carry a development version (for example `X.Y.Z-dev`)
- `next`:
- release-candidate (RC) stabilization branch
- receives promoted changes from `main`
- receives RC-only fixes during freeze
- `release`:
- stable release branch
- only receives promoted commits from `next`
Direct push to these branches is not part of normal human workflow. Use PRs and promotion workflows.
## 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).
- `main` version format: `X.Y.Z-dev`
- `next` version format: `X.Y.Z-rc.N`
- `release` version format: `X.Y.Z`
Rules:
1. Never edit versions manually in random commits.
2. Version transitions must happen through the defined promotion workflows.
3. Tags must match package version:
- RC: `app-vX.Y.Z-rc.N`
- Stable: `app-vX.Y.Z`
## 3) Standard release flow
### Step A: Prepare in `main`
1. Merge ready PRs into `main`.
2. Confirm CI is green on `main`.
3. Refresh the bundled Open-Source-Licenses data (maintainer-only, run locally):
- Requires `cargo-about` (one-time install: `cargo install cargo-about --features cli`).
- Run directly: `node scripts/generate-licenses.mjs` from the repo root.
- Inspect the diff on `src/data/licenses.json` (size + entry count delta should be plausible).
- Commit on `main` if the file changed. No npm script wrapper exists on purpose — adding one to `package.json` would trigger the `nix-npm-deps-hash-sync` workflow on every push. Contributors consume the committed JSON as-is.
### Step B: Promote to RC (`next`)
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
- resets `next` to `main` snapshot
- auto-bump package version in `next` to next `-rc.N`
- commit and push version bump
3. Push on `next` triggers **Next Channel** workflow:
- build/publish RC artifacts for all platforms
- run Nix verification path
### Step C: Stabilize RC
1. Test RC artifacts.
2. If fixes are needed, follow Section 5 (RC fix policy).
3. Repeat Step B as needed until release candidate is accepted.
### Step D: Promote to stable (`release`)
1. Run workflow: **Promote next to release**.
2. Workflow behavior:
- resets `release` to `next` snapshot
- finalize version from `-rc.N` to `X.Y.Z`
- commit and push finalized version
3. Push on `release` triggers **Release Channel** workflow:
- stable artifact publish
- Nix verification
- opens PR to bump `main` to next minor `-dev`
### Step E: Move `main` forward
1. Merge the auto-generated PR that bumps `main` to next minor dev version.
2. Confirm `main` now uses `X.(Y+1).0-dev`.
3. Update AUR package metadata for the same stable version:
- bump `pkgver` in `packages/aur/PKGBUILD`
- regenerate `packages/aur/.SRCINFO`
- publish/update in AUR remote
## 4) Freeze policy (RC stabilization window)
When RC freeze starts:
- Do **not** run `Promote main to next` automatically or casually.
- Only approved release manager(s) may run promotion workflows.
- `next` accepts only stabilization changes (fixes/docs/chore required for release quality).
- New features remain in `main` and wait for next cycle.
Freeze ends after `next -> release` promotion is complete.
## 5) RC fix policy (strict backport/forward-port rules)
If a bug is discovered during RC stabilization:
1. Create dedicated fix branch from `next`:
- example: `fix/rc-crash-login`
2. Open PR: `fix/rc-crash-login -> next`
3. After merge to `next`, create dedicated backport branch from `main`:
- example: `fix/backport-rc-crash-login-main`
4. Cherry-pick (or re-apply) same fix.
5. Open PR: `fix/backport-rc-crash-login-main -> main`
6. Merge this `main` backport PR before the next `Promote main to next` run.
This is mandatory. RC-only fixes may not stay only in `next`.
Alternative allowed order:
- implement first in `main`, then promote `main -> next`.
But if `main` is ahead with non-release features and promotion is frozen, use the `next-first + mandatory main backport` flow above.
## 6) Post-release critical hotfix policy (default path)
After a stable release `X.Y.Z`, critical fixes must be shipped as a patch release:
- next stable target is always `X.Y.(Z+1)`
- RC tags for hotfix cycle: `app-vX.Y.(Z+1)-rc.N`
- final stable tag: `app-vX.Y.(Z+1)`
Never re-use or overwrite `X.Y.Z` tags/releases.
### Case A: `next` is not yet used for the next minor
This case is uncommon in this repository but allowed.
1. Create hotfix branch from `release`.
2. Implement fix and open PR to `release`.
3. Move patch line through `next` RC flow (`X.Y.(Z+1)-rc.N`).
4. Promote `next -> release` for final `X.Y.(Z+1)`.
5. Backport fix to `main` via dedicated PR (mandatory).
### Case B (default): `next` already tracks next minor
This is the expected real-world case.
Assume:
- `release` is `1.9.0`
- `main`/`next` already moved to `1.10.0-*`
- critical bug requires `1.9.1`
Required steps:
1. Announce **hotfix override window** and freeze normal next-minor RC flow.
2. Create hotfix branch from `release` (`1.9.0` baseline).
3. Implement fix and merge into `release` branch via PR.
4. Temporarily align `next` to the hotfix patch line for RC publication.
5. Publish hotfix RC(s): `1.9.1-rc.N`.
6. Promote `next -> release` to finalize `1.9.1`.
7. Backport/cherry-pick same fix into `main` via dedicated PR (mandatory).
8. Restore `next` back to the normal next-minor line from `main`.
9. Announce end of hotfix override and resume normal RC cycle.
Hard rule: no feature work may be merged into `next` during hotfix override.
## 7) Idempotency and rerun behavior
Manual workflow reruns should be safe:
- rerunning **Promote main to next**:
- no change if no new commits
- version bump occurs only when needed for next RC number
- rerunning **Promote next to release**:
- no change if release already matches next
- no extra version increment beyond `X.Y.Z`
- rerunning release publish:
- main dev bump step should no-op when `main` already has target dev version
Rerun is allowed for recovery, but must be announced in release channel/chat.
## 8) Hard rules and prohibitions
Do:
- use PRs for all code changes
- keep channel promotions deterministic and force-push only through approved promotion workflows
- require green CI before promotions
- document exceptions in PR description
Do not:
- manually retag or overwrite release tags
- manually edit `package.json` version outside defined release flow
- merge feature PRs into `next` during freeze
- skip the `next/release -> main` backport for RC fixes or hotfixes
- force-push `next` or `release` manually outside promotion workflows
## 9) Incident handling
If an incorrect promotion happened:
1. Stop further promotions immediately.
2. Announce incident and current branch SHAs.
3. Create corrective PRs (do not use destructive git history rewrites on protected branches).
4. Re-run affected workflows only after corrective PRs are merged.
## 10) Operator checklist (quick)
Before `main -> next`:
- [ ] `main` CI green
- [ ] freeze status known
- [ ] release manager approval
- [ ] branch rules allow workflow `--force-with-lease` on `next`
Before `next -> release`:
- [ ] RC validation complete
- [ ] all RC fixes merged to `next`
- [ ] corresponding backports to `main` completed or queued with owners
- [ ] branch rules allow workflow `--force-with-lease` on `release`
After stable release:
- [ ] verify stable artifacts exist
- [ ] merge auto PR for next `-dev` bump in `main`
- [ ] publish AUR update (`PKGBUILD` + `.SRCINFO`)
- [ ] announce cycle close
For post-release hotfix:
- [ ] patch target decided: `X.Y.(Z+1)`
- [ ] hotfix override for `next` announced
- [ ] fix merged to release patch line
- [ ] hotfix backport PR to `main` merged
- [ ] `next` restored to normal next-minor line
Nix note:
- `nix-npm-deps-hash-sync.yml` runs on pushes to `main`, `next`, and `release`.
- `verify-nix` in channel publish still performs full lock/hash refresh verification for release artifacts.
- Channel-local nix refresh PRs are advisory and can be overwritten by later reset-based promotions.
- If a nix refresh must survive release cycles, ensure the same change is merged into `main`.
-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.
-66
View File
@@ -1,66 +0,0 @@
# Trademark Notice
The Psysonic source code is licensed under the GNU GPLv3.
The **Psysonic name, logo, wordmark, visual brand identity, and the
names of original Psysonic features are not** — they are excluded
from the GPLv3 grant and remain the property of the Psysonic project
maintainers.
## Covered Marks
- The name **"Psysonic"** (in any capitalization or stylization)
- The Psysonic logo and icon (`public/psysonic-inapp-logo.svg`,
app icons, tray icons, installer artwork)
- The Psysonic wordmark and any derivative branding
- The names of original Psysonic features that function as feature
identity, in particular **"Orbit"**
## What You May Do
- Use the Psysonic name to factually refer to the upstream project
(e.g. *"based on Psysonic"*, *"a fork of Psysonic"*, *"compatible
with Psysonic"*).
- Redistribute unmodified Psysonic builds under the Psysonic name,
in accordance with the GPLv3.
- Link to the official Psysonic repository, website, and community
channels.
## What You May Not Do
- Use the Psysonic name, logo, or branding for a **modified or
rebranded fork**, distribution, or derivative product.
- Name a fork in a way that suggests it *is* Psysonic, an official
release of Psysonic, or endorsed by the Psysonic project (e.g.
"Psysonic Pro", "Psysonic Plus", "Psysonic Next", "MyPsysonic").
- Reuse the Psysonic logo, icon set, or installer artwork in a fork
or derivative product, even with modifications.
- Continue to use the feature name **"Orbit"** in a fork or
derivative. If your fork ships the Orbit feature (or code derived
from it), you must rename the user-facing feature.
- Present original Psysonic features (such as Orbit) as independent
creations of a fork. See `NOTICE.md` for the attribution
requirements that apply to derivative works under GPLv3 §7(b).
- Imply sponsorship, endorsement, or affiliation with the Psysonic
project where none exists.
## Forking Done Right
You are warmly invited to fork the code under the GPLv3.
If you publish a fork or derivative, please:
1. Choose your own distinct project name and branding.
2. Keep the GPLv3 license, copyright notices, and the `NOTICE.md`
file intact (this is required, not optional — see `NOTICE.md`).
3. State clearly that your project is *based on Psysonic* and link
back to the upstream repository.
4. Replace Psysonic-branded assets (logos, icons, installer art,
wordmarks) with your own.
5. If you ship features that originated in Psysonic (such as Orbit),
rename the user-facing feature and credit Psysonic as the
original designer and implementer.
## Questions
If you are unsure whether a planned use of the Psysonic name or
branding is acceptable, please open an issue or contact the
maintainers before publishing.
-189
View File
@@ -1,189 +0,0 @@
# What's New
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
current line before promoting to `next` / `release`. Technical details and PR credits stay in
`CHANGELOG.md`.
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
## [1.49.0]
## Highlights
### AutoDJ — minimum pauses, maximum music
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
### Playlist folders — your playlists, organised
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
### Theme store — spot updates, pick your style
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
### Settings — tidier and easier to scan
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
### Japanese — now in your language
- Psysonic is now available in **Japanese (日本語)** — pick it from the language menu on the **Settings** and **Login** screens.
### Artist artwork — richer artist and fullscreen views
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
### Equalizer — a profile per output device
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
## Fixed
### Playback and audio
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
### Player and playlists
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
### Browse and library
- Albums sorted by artist now list each artist's work AZ by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
### Windows
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
## [1.48.1]
## Fixed
### Playback and audio
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
### Offline, Now Playing, and Navidrome
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
### Themes and integrations
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
### Other
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
## [1.48.0]
## Highlights
### Offline listening
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
### Music Network — scrobble beyond Last.fm
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
- A master switch turns the whole network on or off.
### Theme Store
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
- **Now Playing** follows every theme cleanly, including light palettes.
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
### Fullscreen player
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
### Live — richer now playing on Navidrome 0.62+
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
### Queue — Timeline mode
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
### Settings → Servers
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
### Sidebar — pin Now Playing to the top
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
### Startup
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
## Improved
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
## Fixed
### Playback and audio
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
### Offline, Now Playing, and Navidrome
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
- Mixed-server queues still report to the correct Navidrome server.
### Themes and integrations
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
- Discord Rich Presence shows album art again when covers come from the server.
- Focus rings and dropdown borders follow the active theme consistently.
### Browse and library
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
- **All Albums → Only compilations** returns results for common tagging patterns.
- Album grids show the album artist on compilations instead of a random track artist.
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
- **Home → Most Played** no longer jumps the page when you load more albums.
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
### Other
- **Linux:** the `curl | bash` auto-installer works again.
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
- The active server card under **Settings → Servers** draws a complete border on all sides.
## Under the hood
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

-36
View File
@@ -1,36 +0,0 @@
# Shell completion for `psysonic`
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
## zsh
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
```sh
mkdir -p ~/.zsh/completions
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit
```
If you use a plugin manager, point its `fpath` at this repos `completions/` directory instead.
## bash
Source the script once (e.g. in `~/.bashrc`):
```sh
source /path/to/psysonic/completions/psysonic.bash
```
Use this only under **bash** (including macOSs `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
## Device names after `audio-device set`
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
## Folder ids after `library set`
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
-157
View File
@@ -1,157 +0,0 @@
#compdef psysonic
# Zsh completion for Psysonic CLI (see `psysonic --help`).
_psysonic_audio_device_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_audio_device_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_audio_device_json)" || return
local -a devs
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
(( $#devs )) && compadd -a devs
}
_psysonic_library_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_library_folder_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_library_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_snapshot_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_server_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_snapshot_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_globals() {
compadd -J options -X 'option' -- \
--help --version --info --json --quiet --logs --player completions
}
integer i pidx=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --player ]] && pidx=i
done
if [[ ${words[CURRENT-1]} == --tail ]]; then
_message -e descriptions 'number of lines'
return
fi
if (( pidx == 0 )); then
local has_logs=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --logs ]] && has_logs=1
done
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
compadd help bash zsh
return
fi
if (( has_logs )); then
compadd -- --tail -f --follow
else
_psysonic_globals
fi
return
fi
local -a sub
if (( pidx + 1 <= CURRENT - 1 )); then
sub=( ${words[pidx + 1,CURRENT - 1]} )
else
sub=()
fi
integer n=${#sub[@]}
if (( n == 0 )); then
compadd -J verbs -X 'player command' -- \
next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \
audio-device library server search mix
return
fi
case ${sub[1]} in
audio-device)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd default
_psysonic_audio_device_ids
fi
;;
library)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd all
_psysonic_library_folder_ids
fi
;;
mix)
(( n == 1 )) && compadd append new
;;
server)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
_psysonic_server_ids
fi
;;
search)
if (( n == 1 )); then
compadd track album artist
elif (( n >= 2 )); then
_message -e descriptions 'search query'
fi
;;
repeat)
(( n == 1 )) && compadd off all one
;;
rating)
(( n == 1 )) && compadd 0 1 2 3 4 5
;;
seek)
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
;;
esac
-183
View File
@@ -1,183 +0,0 @@
# bash completion for Psysonic (see `psysonic --help`).
# Install: source /path/to/completions/psysonic.bash
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
#
# Uses no `mapfile` so bash 3.2 (macOS default) works.
#
# compopt is bash-only (programmable completion). Guard so sourcing this file
# under zsh or plain sh does not print "command not found: compopt".
_psysonic_compopt() {
command -v compopt &>/dev/null || return 0
compopt "$@" 2>/dev/null || return 0
}
_psysonic_compreply_from_compgen() {
# $1 = compgen -W word list, $2 = current word
COMPREPLY=()
local line
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W "$1" -- "$2")
}
_psysonic_audio_device_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_library_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_snapshot_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
local prev=""
(( COMP_CWORD > 0 )) && prev="${COMP_WORDS[COMP_CWORD-1]}"
if [[ $prev == --tail ]]; then
_psysonic_compopt -o default
COMPREPLY=()
return
fi
local i pidx=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
done
if (( pidx == 0 )); then
local has_logs=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --logs ]] && has_logs=1
done
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
return
fi
if (( has_logs )); then
_psysonic_compreply_from_compgen '--tail -f --follow' "$cur"
else
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --logs --player completions' "$cur"
fi
return
fi
local -a sub=()
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
sub+=("${COMP_WORDS[i]}")
done
local n=${#sub[@]}
if (( n == 0 )); then
_psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur"
return
fi
case ${sub[0]} in
audio-device)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf d
jf="$(_psysonic_audio_device_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r d; do
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'default' -- "$cur")
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
library)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf id line
jf="$(_psysonic_library_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r id; do
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'all' -- "$cur")
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
mix)
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
;;
server)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf sid
jf="$(_psysonic_snapshot_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r sid; do
[[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid")
done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames
fi
;;
search)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'track album artist' "$cur"
elif (( n >= 2 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
repeat)
(( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur"
;;
rating)
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
;;
seek|volume)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
play)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
esac
}
complete -F _psysonic_complete psysonic
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-169
View File
@@ -1,169 +0,0 @@
{
description = ''
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 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 github:Psychotoxical/psysonic
Development:
nix develop # mkShell (Rust/Node/WebKit deps); same GDK idea as installable (no GDK pin)
nix shell .#devShells.default # same environment without entering subshell semantics
Local cargo output: .build-local/ (gitignored; not copied into flake source tarball)
Release pipeline updates `flake.lock` (nixpkgs pin refresh) and
`nix/upstream-sources.json` (npmDepsHash) on every `v*` tag push
see `.github/workflows/release.yml` (verify-nix job). Package version
is read from `package.json`; nothing in this file needs manual bumping
per release.
'';
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
];
forSystem = f: lib.genAttrs systems f;
mkShellFor =
system:
let
pkgs = nixpkgs.legacyPackages.${system};
gstPlugins = with pkgs.gst_all_1; [
gstreamer
gst-plugins-base
gst-plugins-good
gst-plugins-bad
];
gstPluginPath = pkgs.lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
in
pkgs.mkShell {
packages = with pkgs; [
nodejs_22
rustc
cargo
clippy
cargo-llvm-cov
llvmPackages.llvm
jq
cmake
pkg-config
openssl
gtk3
webkitgtk_4_1
libsoup_3
glib-networking
atk
cairo
gdk-pixbuf
glib
pango
librsvg
alsa-lib
libayatana-appindicator
]
++ gstPlugins;
shellHook = ''
_repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$_repo" ] && [ -f "$_repo/flake.nix" ]; then
export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$_repo/.build-local/cargo-target}"
fi
export LD_LIBRARY_PATH="${pkgs.libayatana-appindicator}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export GST_PLUGIN_PATH="${gstPluginPath}''${GST_PLUGIN_PATH:+:$GST_PLUGIN_PATH}"
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
export LLVM_COV="${pkgs.llvmPackages.llvm}/bin/llvm-cov"
export LLVM_PROFDATA="${pkgs.llvmPackages.llvm}/bin/llvm-profdata"
unset CI
'';
OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include";
};
upstreamMeta = lib.importJSON ./nix/upstream-sources.json;
psysonicFor =
system:
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
src = self;
inherit upstreamMeta;
};
# Same app with GDK_BACKEND pinned to X11 — previous default wrapper behaviour (see nixos-install.md).
psysonicX11LegacyFor =
system:
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
src = self;
inherit upstreamMeta;
forceGdkX11 = true;
};
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;
}
);
apps = forSystem (
system:
let
p = psysonicFor system;
pX11 = psysonicX11LegacyFor system;
in
{
default = {
type = "app";
program = lib.getExe p;
meta = {
inherit (p.meta) description homepage license;
mainProgram = "psysonic";
};
};
psysonic-gdk-session = {
type = "app";
program = lib.getExe p;
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;
mainProgram = "psysonic";
};
};
}
);
};
}
+3 -82
View File
@@ -6,91 +6,12 @@
<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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
</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>
-184
View File
@@ -1,184 +0,0 @@
# Installable Psysonic (Tauri): npm build → cargo tauri build --no-bundle.
# Source: `self` (this repo). Package version is read from package.json.
# `npmDepsHash` in nix/upstream-sources.json is refreshed by the release
# workflow (see .github/workflows/release.yml, verify-nix job).
{
lib,
stdenv,
fetchNpmDeps,
npmHooks,
rustPlatform,
cargo,
rustc,
pkg-config,
cmake,
openssl,
gtk3,
webkitgtk_4_1,
libsoup_3,
glib-networking,
alsa-lib,
libayatana-appindicator,
atk,
cairo,
gdk-pixbuf,
glib,
pango,
librsvg,
cargo-tauri,
nodejs,
makeWrapper,
wrapGAppsHook4,
copyDesktopItems,
makeDesktopItem,
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,
}:
let
version = (lib.importJSON (src + "/package.json")).version;
# WebKit media stack needs discoverable GStreamer plugins (e.g. appsink in gst-plugins-base).
gstPlugins = with gst_all_1; [
gstreamer
gst-plugins-base
gst-plugins-good
gst-plugins-bad
];
gstPluginPath = lib.makeSearchPath "lib/gstreamer-1.0" gstPlugins;
srcClean = lib.cleanSourceWith {
inherit src;
filter =
path: _:
let
f = toString path;
in
!(lib.hasInfix "/node_modules/" f)
&& !(lib.hasInfix "/dist/" f)
&& !(lib.hasInfix "/target/" f)
&& !(lib.hasInfix "/.git/" f)
&& !(lib.hasInfix "/result/" f)
&& !(lib.hasInfix "/.flatpak-builder/" f)
&& !(lib.hasInfix "/.build-local/" f);
};
npmDeps = fetchNpmDeps {
src = srcClean;
hash = upstreamMeta.npmDepsHash;
};
cargoLockFile = src + "/src-tauri/Cargo.lock";
in
stdenv.mkDerivation (finalAttrs: {
pname = "psysonic";
inherit version;
src = srcClean;
inherit npmDeps;
strictDeps = true;
# cmake is only for Rust deps (e.g. libopus); no top-level CMakeLists.txt in repo root
dontUseCmakeConfigure = true;
nativeBuildInputs = [
npmHooks.npmConfigHook
cargo
rustc
rustPlatform.cargoSetupHook
pkg-config
cmake
makeWrapper
wrapGAppsHook4
copyDesktopItems
cargo-tauri
nodejs
];
buildInputs = [
gtk3
webkitgtk_4_1
libsoup_3
glib-networking
openssl
alsa-lib
libayatana-appindicator
atk
cairo
gdk-pixbuf
glib
pango
librsvg
]
++ gstPlugins;
cargoRoot = "src-tauri";
cargoDeps = rustPlatform.importCargoLock {
lockFile = cargoLockFile;
# Local path overrides for `[patch.crates-io]` entries in src-tauri/Cargo.toml.
# Keep in sync with that block — importCargoLock needs the source to match
# the lockfile entries for patched crates (otherwise it tries to fetch from
# crates.io and the hash mismatches).
outputHashes = { };
};
dontUseCargoParallelJobs = true;
env = {
OPENSSL_DIR = "${openssl.dev}";
OPENSSL_LIB_DIR = "${openssl.out}/lib";
OPENSSL_INCLUDE_DIR = "${openssl.dev}/include";
VITE_LASTFM_API_KEY = "";
VITE_LASTFM_API_SECRET = "";
};
# beforeBuildCommand runs npm run build; npmConfigHook supplies offline node_modules
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
(cd src-tauri && cargo tauri build --no-bundle)
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm755 src-tauri/target/release/psysonic -t $out/bin
install -Dm644 src-tauri/icons/128x128.png $out/share/icons/hicolor/128x128/apps/psysonic.png
runHook postInstall
'';
desktopItems = [
(makeDesktopItem {
name = "psysonic";
desktopName = "Psysonic";
comment = "Subsonic-compatible music player";
icon = "psysonic";
exec = "psysonic";
categories = [ "AudioVideo" "Audio" "Player" ];
})
];
postFixup =
let
gdkX11Wrap = lib.optionalString forceGdkX11 ''
--set GDK_BACKEND x11
'';
in
''
wrapProgram $out/bin/psysonic \
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
${gdkX11Wrap}
'';
meta = {
description = "Desktop music player for Subsonic-compatible servers";
homepage = "https://github.com/Psychotoxical/psysonic";
license = lib.licenses.gpl3Only;
mainProgram = "psysonic";
platforms = lib.platforms.linux;
};
})
-3
View File
@@ -1,3 +0,0 @@
{
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
}
-200
View File
@@ -1,200 +0,0 @@
# Installing Psysonic on NixOS (flake)
This guide is for **NixOS** users who want **Psysonic from the upstream Git flake** (`github:Psychotoxical/psysonic`). Supported systems match the flake: **`x86_64-linux`** and **`aarch64-linux`**.
**Stability:** The project is in **very active development**. For **production or everyday use**, prefer **released builds**: pin the flake input to a stable **`app-v*`** tag, or track the **`release`** branch (`?ref=release`). Following **`main`** or **`next`** is better suited to contributors and early testers.
## Prerequisites
**Flakes** enabled (e.g. in `configuration.nix`):
```nix
nix.settings.experimental-features = [ "nix-command" "flakes" ];
```
## Binary cache (Cachix)
The project publishes store paths to a public Cachix cache so you can **substitute** binaries instead of compiling Psysonic locally on every machine.
- **Cache page:** [psysonic.cachix.org](https://psysonic.cachix.org)
- **Substituter URL:** `https://psysonic.cachix.org`
- **Public key** (trust this only if it matches what you expect from the cache owners):
```text
psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA=
```
### NixOS (`configuration.nix` or a flake module)
Add the substituter **and** its signing key under `nix.settings`. Keep `cache.nixos.org` in the list so ordinary `nixpkgs` binaries still resolve:
```nix
{
nix.settings = {
substituters = [
"https://psysonic.cachix.org"
"https://cache.nixos.org/"
];
trusted-public-keys = [
"psysonic.cachix.org-1:M9cQyQ7tgvUWOQ5Pyt8ozlMoPLtOZir6MfRuTH9/VYA="
"cache.nixos.org-1:6NCHdSuAYQQOxGEKTGXLN9WWRXoSBT8GRiSnR6IdfGW="
];
};
}
```
After `nixos-rebuild switch`, builds that hit the cache will download from Cachix. More background: [Cachix — Getting started](https://docs.cachix.org/getting-started).
## Install on NixOS (flake configuration)
Add the repo as an **input**, then reference **`packages.<system>.psysonic`** (or **`default`**, which is the same package).
### Example: top-level `flake.nix` + `nixosConfigurations`
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
psysonic.url = "github:Psychotoxical/psysonic";
};
outputs = { self, nixpkgs, ... }@inputs: let
system = "x86_64-linux";
in {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./configuration.nix
{
environment.systemPackages = [
inputs.psysonic.packages.${system}.psysonic
];
}
];
};
};
}
```
Inside a **module** where you already have `pkgs` and flake `inputs` in scope, a common pattern is:
```nix
environment.systemPackages = with pkgs; [
# …
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
];
```
### Linux wrapper (default vs legacy X11)
The flake exposes **three** Linux attributes (two are the **same derivation**):
| 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-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path).
### Example: legacy X11 wrap
```nix
inputs.psysonic.packages.${system}.psysonic-x11-legacy
```
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
```bash
nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help
```
### Pinning a revision, branch, or tag
- **`main`** (default in the examples above) follows upstream development.
- **Channel branches** (`next`, `release`) exist for pre-release / release automation. For **operational installs**, prefer **`release`** (or an **`app-v*`** tag) over **`next`** or **`main`**; use **`?ref=next`** only if you want pre-release channel builds.
```nix
psysonic.url = "github:Psychotoxical/psysonic?ref=release";
```
- **Tags** (`app-v*`) match published GitHub releases and are the usual choice for a **reproducible** install aligned with a shipped version:
```nix
psysonic.url = "github:Psychotoxical/psysonic?ref=app-v1.44.0"; # example; pick a tag that exists on GitHub
```
Use a `ref` (branch, tag, or commit SHA) that exists on GitHub.
### How `flake.lock` and `nix/upstream-sources.json` stay in sync
CI runs a **verify-nix** job (Nix build, `npmDepsHash` refresh, `flake.lock` refresh, Cachix push) from **`.github/workflows/reusable-channel-publish.yml`**, invoked by:
- **`.github/workflows/next.yml`** (Next channel, branch `next`)
- **`.github/workflows/release.yml`** (Release channel, branch `release`)
So the lock and **`nix/upstream-sources.json`** (`npmDepsHash`) are updated as part of channel publishing, not only from a single legacy “tag-only” path. On **`main`**, **`nix-npm-deps-hash-sync.yml`** can also open PRs when `package-lock.json` changes so the Nix npm hash does not drift.
End users who pin **`main`** should run `nix flake update psysonic` (or equivalent) periodically if they want the latest lock inputs from upstream.
### One-shot run (no system install)
From any machine with flakes:
```bash
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**.
### Apply configuration
- **NixOS flake host**
```bash
sudo nixos-rebuild switch --flake .#my-host
```
- **Home Manager** (if used separately)
```bash
home-manager switch --flake .#my-user@my-host
```
## Home Manager
If you manage packages with [Home Manager](https://github.com/nix-community/home-manager), add the same package to `home.packages`:
```nix
home.packages = [
inputs.psysonic.packages.${pkgs.stdenv.hostPlatform.system}.psysonic
];
```
(Adjust how `inputs` / `pkgs` are passed into your Home Manager module.)
## Development shell (contributors)
From a **flake-enabled** clone of the repo:
- **`nix develop`** — enters the upstream `devShell` (Rust, Node 22, WebKitGTK, GStreamer plugins for the webview, env hooks aligned with `package.json` / Tauri dev).
- **`nix shell .#devShells.default`** — same packages and hooks without `nix develop`s subshell semantics.
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
## 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.
## Troubleshooting (Linux / WebKit)
Some GPU / compositor setups show a black window or broken scrolling under Wayland/EGL. The upstream Help / FAQ documents workarounds (e.g. running under **X11** and compositor-related env vars). Those apply to the Nix-built binary as well as other Linux builds.
## More detail in-repo
- **`flake.nix`** — `packages`, `apps`, `devShells`, supported systems; inline comments for `nix build` / `nix develop` / `nix run`.
- **`nix/psysonic.nix`** — how the app is built from this source tree (`npmDepsHash` from **`nix/upstream-sources.json`**).
- **`.github/workflows/reusable-channel-publish.yml`** — **`verify-nix`** job (prefetch npm deps hash, `nix flake update`, `nix build .#psysonic`, Cachix push, optional lock refresh PR).
- **`.github/workflows/next.yml`** / **`.github/workflows/release.yml`** — channel workflows that call the reusable publish workflow with **`verify_nix: true`**.
- **`.github/workflows/nix-npm-deps-hash-sync.yml`** — keeps **`nix/upstream-sources.json`** aligned with **`package-lock.json`** on **`main`** via PRs.
For the full promotion and release picture (branches, tags, automation), see **`RELEASE_PROCESS.md`**.
+1141 -2408
View File
File diff suppressed because it is too large Load Diff
+26 -59
View File
@@ -1,76 +1,43 @@
{
"name": "psysonic",
"version": "1.49.0-dev",
"version": "1.0.6",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
"dev": "npm run prebuild:release-notes && vite",
"build": "npm run prebuild:release-notes && tsc && vite build",
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"test:watch": "vitest",
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
"tauri:dev": "GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/golos-text": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/lexend": "^5.2.11",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource-variable/unbounded": "^5.2.8",
"@fontsource/opendyslexic": "^5.2.5",
"@tanstack/react-virtual": "^3.13.26",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.16.0",
"i18next": "^26.0.8",
"lucide-react": "^1.17.0",
"axios": "^1.7.7",
"howler": "^2.2.4",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^19.2.5",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.16.0",
"zustand": "^5.0.14"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
"react-router-dom": "^6.26.2",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/md5": "^2.3.6",
"@types/node": "^25.6.0",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.1",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.8"
},
"overrides": {
"undici": "^7.28.0"
"@tauri-apps/cli": "^2",
"@types/howler": "^2.2.12",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.3",
"vite": "^6.0.3"
}
}
-4
View File
@@ -1,4 +0,0 @@
src/
pkg/
*.tar.zst
*.tar.gz
-78
View File
@@ -1,78 +0,0 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.48.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
url="https://github.com/Psychotoxical/psysonic"
license=('GPL-3.0-only')
depends=(
'webkit2gtk-4.1'
'gtk3'
'openssl'
'alsa-lib'
)
makedepends=(
'npm'
'rust'
'cargo'
'clang'
'nasm'
'cmake'
)
source=("$pkgname-$pkgver.tar.gz::https://github.com/Psychotoxical/psysonic/archive/refs/tags/app-v$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "psysonic-app-v$pkgver"
export CARGO_HOME="$srcdir/cargo-home"
export npm_config_cache="$srcdir/npm-cache"
# ring (used by reqwest → rustls-tls) ships C/asm objects whose
# symbols (ring_core_*) lld cannot resolve. On Arch/CachyOS, -fuse-ld=lld
# is hardcoded into rustc itself (not just makepkg.conf RUSTFLAGS), so a
# string substitution is a no-op. Appending -C link-arg=-fuse-ld=bfd works
# because the last -fuse-ld=* flag passed to cc wins.
export RUSTFLAGS="${RUSTFLAGS} -C link-arg=-fuse-ld=bfd"
# CachyOS sets -flto=auto in CFLAGS. ring compiles its C/asm objects via the
# cc crate and picks up CFLAGS, producing fat-LTO objects. bfd cannot resolve
# symbols from fat-LTO objects when linking against non-LTO Rust rlibs, causing
# "undefined reference to ring_core_*" even though the symbols exist in the .a.
# Strip CFLAGS/CXXFLAGS entirely so ring builds plain ELF objects.
unset CFLAGS CXXFLAGS
npm install
npm run tauri:build -- --no-bundle
}
package() {
cd "psysonic-app-v$pkgver"
# 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).
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
#!/bin/sh
exec /usr/lib/psysonic/psysonic "\$@"
EOF
# Desktop entry
install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/psysonic.desktop" <<EOF
[Desktop Entry]
Name=Psysonic
Comment=Desktop music player for Subsonic API-compatible servers
Exec=psysonic
Icon=psysonic
Terminal=false
Type=Application
Categories=AudioVideo;Audio;Music;Player;
EOF
# Icons
install -Dm644 "src-tauri/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/psysonic.png"
install -Dm644 "src-tauri/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/psysonic.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/psysonic.png"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

-41
View File
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="115.549mm"
height="130.30972mm"
viewBox="0 0 115.549 130.30972"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:export-filename="p-small.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"><inkscape:page
x="0"
y="0"
width="115.549"
height="130.30972"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
id="defs1" /><g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(220.53237,27.789086)"><path
style="fill:#ffffff"
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"
id="path1" /></g></svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

-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);
});
});
})();
-52
View File
@@ -1,52 +0,0 @@
# Scripts
## install.sh - Auto-Installer for Debian and RHEL-based Systems
This script automatically downloads and installs the latest Psysonic release from GitHub Releases.
### Supported Distributions
- **Debian/Ubuntu**: Downloads and installs `.deb` package
- **RHEL/Fedora/CentOS**: Downloads and installs `.rpm` package
### Usage
#### Quick Install (Recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh | sudo bash
```
#### Manual Installation
```bash
# Download the script
wget https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts/install.sh
# Make it executable
chmod +x install.sh
# Run with sudo
sudo ./install.sh
```
### What it does
1. Detects your OS type (Debian or RHEL-based)
2. Fetches the latest release from GitHub
3. Downloads the appropriate package (.deb or .rpm)
4. Installs it using your system's package manager
5. Cleans up temporary files
### Requirements
- `curl` - for downloading packages
- `sudo` or root access
- Internet connection
- Supported package manager (apt-get, dnf, or yum)
### Notes
- If Psysonic is already installed, the script will ask if you want to reinstall
- The script automatically handles dependency installation for Debian systems
- After installation, you can launch Psysonic from your application menu or by running `psysonic` in the terminal
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Walk the same root CSS entrypoints as src/main.tsx and ensure every
* relative @import target exists on disk. Catches postcss-import ENOENT
* that Vitest does not surface (CSS is not fully resolved like vite build).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const stylesRoot = path.join(repoRoot, 'src', 'styles');
const ENTRY_RELS = [
'themes/index.css',
'layout/index.css',
'components/index.css',
'tracks/index.css',
];
/** Quoted path in @import '...' or "..." (postcss-import style in this repo). */
const IMPORT_RE = /@import\s+(['"])([^'"]+)\1/g;
function isFilesystemImport(spec) {
if (spec.startsWith('http://') || spec.startsWith('https://') || spec.startsWith('data:')) {
return false;
}
if (spec.startsWith('@')) return false;
return spec.startsWith('./') || spec.startsWith('../');
}
function walk(absPath, visited, missing) {
const key = path.resolve(absPath);
if (visited.has(key)) return;
if (!fs.existsSync(key)) {
missing.add(path.relative(repoRoot, key));
visited.add(key);
return;
}
visited.add(key);
const text = fs.readFileSync(key, 'utf8');
let m;
IMPORT_RE.lastIndex = 0;
while ((m = IMPORT_RE.exec(text)) !== null) {
const spec = m[2];
if (!isFilesystemImport(spec)) continue;
const next = path.resolve(path.dirname(key), spec);
walk(next, visited, missing);
}
}
const visited = new Set();
const missing = new Set();
for (const rel of ENTRY_RELS) {
walk(path.join(stylesRoot, rel), visited, missing);
}
if (missing.size > 0) {
const list = [...missing].sort().join('\n ');
console.error(`check-css-import-graph: missing @import target(s):\n ${list}`);
process.exit(1);
}
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
#
# Hot-path file coverage gate — frontend.
#
# Mirrors `scripts/check-hot-path-coverage.sh` for the Rust workspace. For
# each source file listed in `.github/frontend-hot-path-files.txt`, verifies
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions warning
# annotations for files below the floor and exits 1 when any file is below.
#
# Why files instead of per-function: v8 coverage's per-function data is
# fragile under React Compiler / Vite minification — file-level line
# coverage tracks the underlying intent ("is the hot-path file thoroughly
# tested?") more robustly.
#
# Usage:
# scripts/check-frontend-hot-path-coverage.sh [<summary.json>] [<hot-path-list.txt>]
#
# Defaults:
# summary.json — coverage/coverage-summary.json
# hot-path-list.txt — .github/frontend-hot-path-files.txt
#
# Requires: jq.
set -euo pipefail
export LC_ALL=C
JSON="${1:-coverage/coverage-summary.json}"
HOT_PATH_LIST="${2:-.github/frontend-hot-path-files.txt}"
THRESHOLD=70
if [[ ! -f "$JSON" ]]; then
echo "::error::Coverage summary not found at $JSON. Did you run 'npm run test:coverage' first?"
exit 2
fi
if [[ ! -f "$HOT_PATH_LIST" ]]; then
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
exit 2
fi
# The v8 / Istanbul coverage-summary.json has the form:
# { "total": {...}, "<absolute-path>": { "lines": { "pct": 87.5 }, ... }, ... }
# We want each file path + its line pct as a TSV keyed by basename suffix.
ALL_FILES=$(mktemp)
trap 'rm -f "$ALL_FILES"' EXIT
jq -r 'to_entries[] | select(.key != "total") | [.key, .value.lines.pct] | @tsv' "$JSON" > "$ALL_FILES"
TOTAL=0
BELOW=0
NOT_FOUND=0
echo "── Hot-path file coverage check, frontend (threshold: ≥${THRESHOLD}%) ──"
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line="${raw_line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
TOTAL=$((TOTAL + 1))
# Match suffix — JSON paths are absolute, hot-path list is workspace-relative.
pct=$(awk -F'\t' -v target="$line" '
{
path = $1
gsub(/\\\\/, "/", path)
gsub(/\\/, "/", path)
n = length(path)
tlen = length(target)
if (n >= tlen && substr(path, n - tlen + 1) == target) {
printf "%s\n", $2
exit
}
}
' "$ALL_FILES")
if [[ -z "$pct" ]]; then
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed? or no test imports it yet)"
NOT_FOUND=$((NOT_FOUND + 1))
continue
fi
pct_int=${pct%.*}
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
BELOW=$((BELOW + 1))
else
printf " ok %s %.1f%%\n" "$line" "$pct"
fi
done < "$HOT_PATH_LIST"
echo
echo "── Summary ─────────────────────────────────────────────────────────"
echo "Checked: $TOTAL hot-path file(s)"
echo "Below threshold: $BELOW"
echo "Not found: $NOT_FOUND"
if [[ "$BELOW" -gt 0 ]]; then
exit 1
fi
exit 0
-110
View File
@@ -1,110 +0,0 @@
#!/usr/bin/env bash
#
# Hot-path file coverage gate.
#
# For each source file listed in `.github/hot-path-files.txt`, verifies
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions
# warning annotations for files below the floor and exits 1 when any
# file is below.
#
# Why files instead of per-function: cargo-llvm-cov's per-function
# region data is unreliable for async state-machines (most regions live
# in synthetic closures) and generic functions (every instantiation is
# a separate symbol). File-level line coverage is robustly measured and
# tracks the underlying intent: "is the hot-path file thoroughly tested?".
#
# Usage:
# scripts/check-hot-path-coverage.sh [<coverage.json>] [<hot-path-list.txt>]
#
# Defaults:
# coverage.json — src-tauri/target/llvm-cov/cov.json
# hot-path-list.txt — .github/hot-path-files.txt
#
# Requires: jq (preinstalled on Ubuntu runners; on Windows install via
# `winget install jqlang.jq` or `choco install jq`).
set -euo pipefail
export LC_ALL=C
JSON="${1:-src-tauri/target/llvm-cov/cov.json}"
HOT_PATH_LIST="${2:-.github/hot-path-files.txt}"
THRESHOLD=70
if [[ ! -f "$JSON" ]]; then
echo "::error::Coverage JSON not found at $JSON. Did you run cargo llvm-cov --workspace --json --output-path \"$JSON\" first?"
exit 2
fi
if [[ ! -f "$HOT_PATH_LIST" ]]; then
echo "::error::Hot-path file list not found at $HOT_PATH_LIST"
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq"
exit 2
fi
# Pre-extract every file's line coverage % into a TSV keyed by basename.
# We match by suffix (file path ends with the listed relative path) because
# the JSON stores absolute paths that vary between Windows runners and Linux.
ALL_FILES=$(mktemp)
trap 'rm -f "$ALL_FILES"' EXIT
jq -r '.data[0].files[] | [.filename, .summary.lines.percent] | @tsv' "$JSON" > "$ALL_FILES"
TOTAL=0
BELOW=0
NOT_FOUND=0
echo "── Hot-path file coverage check (threshold: ≥${THRESHOLD}%) ──────────"
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line="${raw_line%%#*}" # strip trailing comment
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line%"${line##*[![:space:]]}"}" # rtrim
[[ -z "$line" ]] && continue
TOTAL=$((TOTAL + 1))
# Match suffix — the JSON stores absolute paths; the hot-path list uses
# workspace-relative paths. Convert both to forward slashes first so the
# endsWith works on Windows-encoded paths too.
pct=$(awk -F'\t' -v target="$line" '
{
path = $1
gsub(/\\\\/, "/", path)
gsub(/\\/, "/", path)
n = length(path)
tlen = length(target)
if (n >= tlen && substr(path, n - tlen + 1) == target) {
printf "%s\n", $2
exit
}
}
' "$ALL_FILES")
if [[ -z "$pct" ]]; then
echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed?)"
NOT_FOUND=$((NOT_FOUND + 1))
continue
fi
# bash arithmetic doesn't do float. Truncate to int for comparison.
pct_int=${pct%.*}
if [[ "$pct_int" -lt "$THRESHOLD" ]]; then
printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD"
BELOW=$((BELOW + 1))
else
printf " ok %s %.1f%%\n" "$line" "$pct"
fi
done < "$HOT_PATH_LIST"
echo
echo "── Summary ─────────────────────────────────────────────────────────"
echo "Checked: $TOTAL hot-path file(s)"
echo "Below threshold: $BELOW"
echo "Not found: $NOT_FOUND"
if [[ "$BELOW" -gt 0 ]]; then
exit 1
fi
exit 0
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env node
/**
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
*
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
function versionCore(version) {
const m = version.trim().match(SEMVER_CORE);
return m ? m[1] : null;
}
function isPlainTriple(header) {
return /^\d+\.\d+\.\d+$/.test(header.trim());
}
function splitBlocks(raw) {
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
}
function headerVersion(block) {
const m = block.match(/^## \[([^\]]+)\]/);
return m ? m[1] : null;
}
function parseBlock(block) {
const lines = block.split('\n');
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
if (!m) return null;
return {
headerVersion: m[1],
date: (m[2] ?? '').trim(),
body: lines.slice(1).join('\n').trim(),
};
}
export function findReleaseSection(raw, appVersion) {
const blocks = splitBlocks(raw);
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = versionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b) => {
const hv = headerVersion(b);
return hv !== null && versionCore(hv) === appCore;
});
if (candidates.length === 0) return null;
const plain = candidates.find((b) => {
const hv = headerVersion(b);
return hv !== null && isPlainTriple(hv);
});
return parseBlock(plain ?? candidates[0]);
}
function main() {
const args = process.argv.slice(2);
const allowEmpty = args.includes('--allow-empty');
const positional = args.filter((a) => a !== '--allow-empty');
const [file, version] = positional;
if (!file || !version) {
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
process.exit(2);
}
const raw = readFileSync(file, 'utf8');
const entry = findReleaseSection(raw, version);
const body = entry?.body?.trim() ?? '';
if (!body) {
if (allowEmpty) process.exit(0);
console.error(`No release section found in ${file} for version ${version}`);
process.exit(1);
}
process.stdout.write(`${body}\n`);
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) main();
-26
View File
@@ -1,26 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { findReleaseSection } from './extract-release-section.mjs';
const FIXTURE = `
## [1.48.0] - 2026-06-10
## Highlights
- One
## [1.47.0]
- Old
`;
describe('findReleaseSection', () => {
it('matches base line for -rc versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
assert.equal(entry.headerVersion, '1.48.0');
assert.match(entry.body, /Highlights/);
});
it('matches base line for -dev versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
assert.equal(entry.headerVersion, '1.48.0');
});
});
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env node
// Generates src/data/licenses.json — the data backing Settings → System → Open Source Licenses.
//
// Maintainer-only operation: run before each release to refresh the bundled
// dependency list. Requires `cargo-about` installed globally
// (`cargo install cargo-about --features cli`). The npm-side license data is
// read via npx without persisting a devDependency, so package.json stays clean
// and the nix-npm-deps-hash-sync workflow is not triggered.
//
// Output lives in src/ (not public/) so Vite bundles it as a lazy JS chunk
// — no runtime fetch, the data is fixed into the build artifact.
//
// Output format (per entry):
// {
// name: string,
// version: string,
// source: 'npm' | 'cargo',
// licenses: string[], // SPDX ids, may be multi (dual-licensed)
// repository?: string,
// homepage?: string,
// description?: string,
// publisher?: string,
// licenseText?: string, // full license text, may be empty if not found
// }
//
// Usage: node scripts/generate-licenses.mjs
import { execSync, spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const TAURI_DIR = join(REPO_ROOT, 'src-tauri');
const OUT_DIR = join(REPO_ROOT, 'src', 'data');
const OUT_PATH = join(OUT_DIR, 'licenses.json');
function die(msg) {
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
process.exit(1);
}
function step(msg) {
console.log(`\x1b[36m→\x1b[0m ${msg}`);
}
function ok(msg) {
console.log(`\x1b[32m✓\x1b[0m ${msg}`);
}
// ── Cargo side via cargo-about ──────────────────────────────────────────────
function checkCargoAbout() {
try {
execSync('cargo about --version', { stdio: 'ignore' });
} catch {
die(
'cargo-about not found. Install with:\n' +
' cargo install cargo-about --features cli',
);
}
}
function generateCargoLicenses() {
step('Running cargo-about (this resolves the full dependency graph and may take ~30s)…');
const result = spawnSync(
'cargo',
[
'about',
'generate',
'about.hbs',
'--config', 'about.toml',
'--manifest-path', 'Cargo.toml',
'--all-features',
],
{ cwd: TAURI_DIR, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 },
);
if (result.status !== 0) {
console.error(result.stderr);
die('cargo-about failed.');
}
const parsed = JSON.parse(result.stdout);
if (!parsed?.licenses) die('cargo-about output missing `licenses` key.');
// Flatten: pivot from license-grouped to crate-grouped.
const byCrate = new Map();
for (const lic of parsed.licenses) {
for (const u of lic.used_by ?? []) {
const key = `${u.name}@${u.version}`;
if (!byCrate.has(key)) {
byCrate.set(key, {
name: u.name,
version: u.version,
source: 'cargo',
licenses: [],
repository: u.repository || undefined,
description: u.description || undefined,
licenseText: '',
});
}
const entry = byCrate.get(key);
if (!entry.licenses.includes(lic.id)) entry.licenses.push(lic.id);
// Append text — a crate may have multiple licenses (dual). Keep them all,
// separated by a clear divider.
if (lic.text) {
if (entry.licenseText) {
entry.licenseText += `\n\n--- ${lic.id} ---\n\n${lic.text}`;
} else {
entry.licenseText = lic.text;
}
}
}
}
ok(`cargo: ${byCrate.size} crates`);
return [...byCrate.values()];
}
// ── npm side via license-checker-rseidelsohn (npx) ──────────────────────────
function readLicenseFile(path) {
if (!path || !existsSync(path)) return '';
try {
return readFileSync(path, 'utf-8');
} catch {
return '';
}
}
// Try to find a notice/copyright file alongside the license file.
function findExtraLicenseTexts(packagePath) {
if (!packagePath || !existsSync(packagePath)) return '';
let extra = '';
try {
const entries = readdirSync(packagePath);
for (const name of entries) {
const upper = name.toUpperCase();
if (
upper === 'NOTICE' ||
upper === 'NOTICE.MD' ||
upper === 'NOTICE.TXT' ||
upper === 'COPYRIGHT' ||
upper === 'COPYRIGHT.MD' ||
upper === 'COPYRIGHT.TXT'
) {
const full = join(packagePath, name);
if (statSync(full).isFile()) {
const txt = readFileSync(full, 'utf-8').trim();
if (txt) extra += `\n\n--- ${name} ---\n\n${txt}`;
}
}
}
} catch {
/* ignore */
}
return extra;
}
function generateNpmLicenses() {
step('Running license-checker-rseidelsohn via npx (this enumerates node_modules)…');
const result = spawnSync(
'npx',
[
'--yes',
'license-checker-rseidelsohn@latest',
'--json',
'--production',
'--excludePrivatePackages',
],
{ cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 },
);
if (result.status !== 0) {
console.error(result.stderr);
die('license-checker-rseidelsohn failed.');
}
const data = JSON.parse(result.stdout);
const out = [];
for (const [key, info] of Object.entries(data)) {
// key looks like `package-name@1.2.3` or `@scope/name@1.2.3`.
const at = key.lastIndexOf('@');
if (at <= 0) continue;
const name = key.slice(0, at);
const version = key.slice(at + 1);
const licenses = Array.isArray(info.licenses)
? info.licenses
: typeof info.licenses === 'string'
? [info.licenses]
: [];
const text =
readLicenseFile(info.licenseFile) +
findExtraLicenseTexts(info.path);
out.push({
name,
version,
source: 'npm',
licenses: licenses.map(String),
repository: info.repository || undefined,
homepage: info.url || undefined,
publisher: info.publisher || undefined,
description: undefined, // license-checker doesn't surface descriptions
licenseText: text.trim(),
});
}
ok(`npm: ${out.length} packages`);
return out;
}
// ── Self ────────────────────────────────────────────────────────────────────
function readSelfPackageJson() {
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
return {
name: pkg.name ?? 'psysonic',
version: pkg.version ?? '0.0.0',
};
}
// ── Main ────────────────────────────────────────────────────────────────────
function main() {
step(`Generating ${OUT_PATH.replace(REPO_ROOT + '/', '')}`);
mkdirSync(OUT_DIR, { recursive: true });
checkCargoAbout();
const cargoEntries = generateCargoLicenses();
const npmEntries = generateNpmLicenses();
const entries = [...cargoEntries, ...npmEntries];
// Stable sort: name, then version. Case-insensitive on name.
entries.sort((a, b) => {
const n = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
if (n !== 0) return n;
return a.version.localeCompare(b.version);
});
const self = readSelfPackageJson();
const stats = {
npm: npmEntries.length,
cargo: cargoEntries.length,
total: entries.length,
withFullText: entries.filter((e) => e.licenseText && e.licenseText.length > 0).length,
};
const payload = {
generatedAt: new Date().toISOString(),
project: self,
stats,
entries,
};
writeFileSync(OUT_PATH, JSON.stringify(payload, null, 0) + '\n');
const sizeKb = (statSync(OUT_PATH).size / 1024).toFixed(1);
ok(`Wrote ${OUT_PATH} (${stats.total} entries, ${sizeKb} KB)`);
ok(` ${stats.cargo} cargo + ${stats.npm} npm; ${stats.withFullText} with full license text`);
}
main();
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env node
/**
* Build src/generated/releaseNotesBundle.ts for production bundles.
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findReleaseSection } from './extract-release-section.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const version = pkg.version;
const whatsNewPath = join(root, 'WHATS_NEW.md');
const changelogPath = join(root, 'CHANGELOG.md');
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
const changelogFull = readFileSync(changelogPath, 'utf8');
function sliceForVersion(full, fileLabel) {
const entry = findReleaseSection(full, version);
if (!entry?.body) {
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
return '';
}
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
}
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
const outDir = join(root, 'src/generated');
mkdirSync(outDir, { recursive: true });
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
`;
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env node
// Generates latest.json for the Tauri updater from a GitHub release.
// Reads .sig files uploaded by tauri-action, assembles the manifest, writes latest.json.
//
// macOS-only for now — Windows + Linux are added once their signing pipelines
// (Certum cert for Windows, native package managers for Linux) are wired up.
//
// Required env vars: VERSION, GITHUB_TOKEN
// Usage: node scripts/generate-update-manifest.js
const { execSync } = require('child_process');
const fs = require('fs');
const VERSION = process.env.VERSION;
const REPO = 'Psychotoxical/psysonic';
const TAG = `app-v${VERSION}`;
if (!VERSION) {
console.error('VERSION env var required');
process.exit(1);
}
// Platform → update bundle filename (produced by tauri-action with updater plugin)
const PLATFORM_FILES = {
'darwin-aarch64': 'Psysonic_aarch64.app.tar.gz',
'darwin-x86_64': 'Psysonic_x64.app.tar.gz',
};
const platforms = {};
// A real minisign .sig file is multi-line and ~200+ chars.
// A public key (RWTxxx... single line, ~56 chars) must never appear here.
function validateSignature(sig, platform, sigFile) {
if (/^RWT[A-Za-z0-9+/]{10,}={0,2}$/.test(sig)) {
throw new Error(
`${platform}: .sig file "${sigFile}" contains a PUBLIC KEY instead of a signature.\n` +
` Got: ${sig}\n` +
` TAURI_SIGNING_PRIVATE_KEY must be the private key, not the public one.`
);
}
if (sig.length < 80) {
throw new Error(
`${platform}: .sig file "${sigFile}" looks too short (${sig.length} chars) to be a valid signature.`
);
}
}
for (const [platform, filename] of Object.entries(PLATFORM_FILES)) {
const sigFile = `${filename}.sig`;
try {
execSync(
`gh release download "${TAG}" --repo "${REPO}" -p "${sigFile}" --clobber`,
{ stdio: 'pipe' }
);
const signature = fs.readFileSync(sigFile, 'utf8').trim();
validateSignature(signature, platform, sigFile);
const url = `https://github.com/${REPO}/releases/download/${TAG}/${filename}`;
platforms[platform] = { signature, url };
console.log(`${platform}`);
} catch (e) {
console.warn(`⚠ Skipping ${platform}: ${e.message}`);
}
}
if (Object.keys(platforms).length === 0) {
console.error('No platforms found — aborting manifest generation');
process.exit(1);
}
let notes = '';
try {
const raw = execSync(
`gh release view "${TAG}" --repo "${REPO}" --json body`,
{ stdio: 'pipe' }
).toString();
notes = JSON.parse(raw).body ?? '';
} catch {
console.warn('Could not fetch release notes');
}
const manifest = {
version: VERSION,
notes,
pub_date: new Date().toISOString(),
platforms,
};
fs.writeFileSync('latest.json', JSON.stringify(manifest, null, 2));
console.log(`\nWrote latest.json for v${VERSION} with platforms: ${Object.keys(platforms).join(', ')}`);
-182
View File
@@ -1,182 +0,0 @@
#!/bin/bash
set -e
# Psysonic Auto-Installer
# Automatically detects your OS and installs the latest release from GitHub
REPO="Psychotoxical/psysonic"
APP_NAME="psysonic"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Log helpers write to stderr so functions that return values via stdout
# (e.g. get_download_url) stay clean when called in command substitution.
info() {
echo -e "${BLUE}[INFO]${NC} $1" >&2
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1" >&2
}
error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
exit 1
}
# Check if running as root
check_root() {
if [ "$EUID" -ne 0 ]; then
error "Please run this script as root (use sudo)"
fi
}
# Detect package manager and OS type
detect_os() {
if command -v apt &> /dev/null; then
OS_TYPE="debian"
PACKAGE_MANAGER="apt"
info "Detected Debian/Ubuntu-based system (apt)"
elif command -v dnf &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="dnf"
info "Detected RHEL/Fedora-based system (dnf)"
elif command -v yum &> /dev/null; then
OS_TYPE="rhel"
PACKAGE_MANAGER="yum"
info "Detected RHEL/CentOS-based system (yum)"
else
error "Unsupported package manager. This installer supports Debian/Ubuntu and RHEL/Fedora/CentOS systems."
fi
}
# Get the latest release download URL for the specific package type
get_download_url() {
local api_url="https://api.github.com/repos/${REPO}/releases/latest"
info "Fetching latest release information..."
local release_info
release_info=$(curl -s "$api_url")
if echo "$release_info" | grep -q "message.*Not Found"; then
error "Could not fetch release information. Please check your internet connection."
fi
local tag_name
tag_name=$(echo "$release_info" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$tag_name" ]; then
error "Could not determine latest release version."
fi
info "Latest version: $tag_name"
local download_url=""
if [ "$OS_TYPE" = "debian" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.deb"' | head -1 | cut -d'"' -f4)
elif [ "$OS_TYPE" = "rhel" ]; then
download_url=$(echo "$release_info" | grep -o '"browser_download_url": *"[^"]*\.rpm"' | head -1 | cut -d'"' -f4)
fi
if [ -z "$download_url" ]; then
error "Could not find download URL for $OS_TYPE package."
fi
echo "$download_url"
}
# Install the package
install_package() {
local download_url="$1"
local temp_dir
temp_dir=$(mktemp -d)
local package_file="$temp_dir/${APP_NAME}_latest"
info "Downloading package..."
if [ "$OS_TYPE" = "debian" ]; then
package_file="${package_file}.deb"
curl --fail --globoff -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file" || {
warn "Trying to fix broken dependencies..."
$PACKAGE_MANAGER install -f -y
}
elif [ "$OS_TYPE" = "rhel" ]; then
package_file="${package_file}.rpm"
curl --fail --globoff -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file"
fi
# Cleanup
rm -rf "$temp_dir"
}
# Check if app is already installed
check_installed() {
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
warn "${APP_NAME} appears to be already installed."
# Under `curl ... | bash`, stdin is the script stream itself, so
# read the answer from the controlling terminal instead. Probe by
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
# without a controlling terminal; only open() reports the failure.
if { : < /dev/tty; } 2>/dev/null; then
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
echo
else
warn "No terminal available for prompt; skipping reinstall."
exit 0
fi
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Installation cancelled."
exit 0
fi
fi
}
# Main installation flow
main() {
echo -e "${GREEN}"
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ Psysonic Auto-Installer ║"
echo "║ Install the latest release from GitHub Releases ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
check_root
detect_os
check_installed
local download_url
download_url=$(get_download_url)
if [ -z "$download_url" ]; then
error "Failed to get download URL."
fi
info "Download URL: $download_url"
install_package "$download_url"
echo ""
success "Psysonic has been installed successfully!"
echo -e "${BLUE}You can launch it from your application menu or by running:${NC} psysonic"
echo ""
}
# Run main function
main
@@ -1,49 +0,0 @@
#!/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".
* Used after npm version in promote workflows so bundle names match release semver.
*/
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const version = require(path.join(root, 'package.json')).version;
if (!version || typeof version !== 'string') {
console.error('package.json version missing');
process.exit(1);
}
const cargoPath = path.join(root, 'src-tauri', 'Cargo.toml');
let cargo = fs.readFileSync(cargoPath, 'utf8');
if (!/^version = "[^"]*"$/m.test(cargo)) {
console.error('Cargo.toml: expected a package-level line: version = "..."');
process.exit(1);
}
cargo = cargo.replace(/^version = "[^"]*"$/m, `version = "${version}"`);
fs.writeFileSync(cargoPath, cargo);
console.log(`Cargo.toml -> ${version}`);
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
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}`);
}
+699 -3471
View File
File diff suppressed because it is too large Load Diff
+6 -86
View File
@@ -1,29 +1,13 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.49.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
wiremock = "0.6"
mockall = "0.13"
proptest = "1"
[package]
name = "psysonic"
version.workspace = true
version = "1.0.6"
description = "Psysonic Desktop Music Player"
authors = []
license.workspace = true
license = ""
repository = ""
default-run = "psysonic"
edition.workspace = true
rust-version.workspace = true
edition = "2021"
rust-version = "1.77.2"
[lib]
name = "psysonic_lib"
@@ -37,76 +21,12 @@ path = "src/main.rs"
tauri-build = { version = "2", features = [] }
[dependencies]
psysonic-core = { path = "crates/psysonic-core" }
psysonic-analysis = { path = "crates/psysonic-analysis" }
psysonic-audio = { path = "crates/psysonic-audio" }
psysonic-library = { path = "crates/psysonic-library" }
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
psysonic-integration = { path = "crates/psysonic-integration" }
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
md5 = "0.8"
tokio = { version = "1", features = ["rt", "time", "sync"] }
biquad = "0.6"
ringbuf = "0.5"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "1.1"
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"] }
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"] }
# 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 = [
"Win32_Foundation",
"Win32_Graphics",
"Win32_Graphics_Gdi",
"Win32_System_Com",
"Win32_System_Power",
"Win32_System_Threading",
"Win32_UI_Controls",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Sandbox disabled: app is unsigned/ad-hoc signed, so an active sandbox
would restrict network access more than without it. macOS users bypass
Gatekeeper via: xattr -cr /Applications/Psysonic.app -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Allow outbound TCP connections to the Navidrome server (reqwest in audio.rs).
Respected by ad-hoc signatures on some macOS versions. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
-14
View File
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
Navidrome servers running without HTTPS can load media in WKWebView.
Without this, macOS App Transport Security blocks HTTP audio src. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
-26
View File
@@ -1,26 +0,0 @@
{{!--
cargo-about template emits JSON grouped by license. The Node generator
(scripts/generate-licenses.mjs) flattens this into a per-crate list and
merges it with the npm license data into the final public/licenses.json.
--}}
{
"licenses": [
{{#each licenses}}
{
"id": "{{id}}",
"name": "{{name}}",
"text": {{json text}},
"used_by": [
{{#each used_by}}
{
"name": "{{crate.name}}",
"version": "{{crate.version}}",
"repository": {{json crate.repository}},
"description": {{json crate.description}}
}{{#unless @last}},{{/unless}}
{{/each}}
]
}{{#unless @last}},{{/unless}}
{{/each}}
]
}
-43
View File
@@ -1,43 +0,0 @@
# cargo-about config — controls which licenses are allowed in the dependency
# tree and how the licenses listing is generated. Permissive list: anything
# the Rust ecosystem commonly publishes under, plus a few exceptions.
#
# Run via `npm run licenses:generate` (which calls `cargo about generate`).
accepted = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"MPL-2.0",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"0BSD",
"CC0-1.0",
"Unlicense",
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
# we ship binaries for so the Linux user sees Linux-only deps too, etc.
targets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
]
# Show transitive deps (we want everything in the binary) but skip dev-deps
# (test-only crates aren't in the shipped artifact).
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
workarounds = ["ring"]
+4 -20
View File
@@ -3,11 +3,12 @@
"identifier": "default",
"description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main", "mini"],
"windows": ["main"],
"permissions": [
"core:default",
"shell:default",
{ "identifier": "shell:allow-open", "allow": [{ "url": "https://**" }] },
"shell:allow-open",
"notification:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:default",
@@ -17,30 +18,13 @@
"store:allow-save",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:default",
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:allow-app-write-recursive",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"window-state:allow-save-window-state",
"window-state:allow-restore-state",
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen",
"core:window:allow-start-dragging",
"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"
"core:window:allow-show"
]
}
@@ -1,26 +0,0 @@
[package]
name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
futures-util = "0.3"
ebur128 = "0.1"
md5 = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
@@ -1,44 +0,0 @@
-- Baseline: the pre-versioning analysis cache schema.
--
-- This is the exact shape every existing user DB already carries (created by
-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a
-- no-op on those DBs and creates the tables on a fresh one, so "migration 1
-- applied" means "the schema that shipped before versioned migrations".
--
-- Server-scoping (server_id) is added additively in 002.
CREATE TABLE IF NOT EXISTS analysis_track (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS waveform_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS loudness_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb, target_lufs)
);
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
@@ -1,73 +0,0 @@
-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped
-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each
-- table is rebuilt: create the v2 shape, copy every row with server_id = '',
-- drop the old table, rename. Existing rows become legacy ('') rows that the
-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2).
--
-- Atomicity: the migration runner wraps this whole file plus the
-- schema_migrations marker in one transaction, so any failure or crash rolls
-- everything back to the original tables — DROP never runs unless the copy
-- before it succeeded. No BEGIN/COMMIT here (that would nest).
--
-- These three tables have no foreign keys between them or from any other table,
-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op
-- inside a transaction anyway).
-- analysis_track
CREATE TABLE analysis_track_v2 (
server_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, md5_16kb)
);
INSERT INTO analysis_track_v2
(server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at)
SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
FROM analysis_track;
DROP TABLE analysis_track;
ALTER TABLE analysis_track_v2 RENAME TO analysis_track;
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
-- waveform_cache
CREATE TABLE waveform_cache_v2 (
server_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, md5_16kb)
);
INSERT INTO waveform_cache_v2
(server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at)
SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
FROM waveform_cache;
DROP TABLE waveform_cache;
ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache;
-- loudness_cache
CREATE TABLE loudness_cache_v2 (
server_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs)
);
INSERT INTO loudness_cache_v2
(server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at)
SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
FROM loudness_cache;
DROP TABLE loudness_cache;
ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache;
File diff suppressed because it is too large Load Diff
@@ -1,12 +0,0 @@
mod compute;
mod store;
pub use compute::{
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
};
pub use store::{
AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey,
WaveformEntry,
};
File diff suppressed because it is too large Load Diff
@@ -1,42 +0,0 @@
//! Per-track analysis timing events for the Performance Probe overlay.
use tauri::{AppHandle, Emitter};
#[derive(Debug, Clone, Copy, Default)]
pub struct AnalysisSeedTimings {
pub seed_ms: u64,
pub bpm_ms: u64,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisTrackPerfPayload {
pub track_id: String,
pub fetch_ms: u64,
pub seed_ms: u64,
pub bpm_ms: u64,
pub total_ms: u64,
}
pub fn emit_analysis_track_perf(
app: &AppHandle,
track_id: &str,
fetch_ms: u64,
seed_ms: u64,
bpm_ms: u64,
) {
let total_ms = fetch_ms.saturating_add(seed_ms).saturating_add(bpm_ms);
if total_ms == 0 {
return;
}
let _ = app.emit(
"analysis:track-perf",
AnalysisTrackPerfPayload {
track_id: track_id.to_string(),
fetch_ms,
seed_ms,
bpm_ms,
total_ms,
},
);
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
use std::sync::OnceLock;
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn make_decoder(
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
@@ -1,600 +0,0 @@
//! Tauri commands that read/write the analysis cache and steer the backfill
//! queue. Thin wrappers around `analysis_cache::*` and `analysis_runtime::*`
//! plus the playback-query port (for "is this track currently playing? /
//! is a ranged playback already going to seed it?").
use std::collections::HashSet;
use crate::analysis_cache;
use crate::analysis_runtime::{
analysis_backfill_queue_stats, analysis_pipeline_queue_stats, enqueue_seed_from_url,
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
};
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WaveformCachePayload {
pub bins: Vec<u8>,
pub bin_count: i64,
pub is_partial: bool,
pub known_until_sec: f64,
pub duration_sec: f64,
pub updated_at: i64,
}
impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
fn from(v: analysis_cache::WaveformEntry) -> Self {
Self {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoudnessCachePayload {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisDeleteServerReportDto {
pub analysis_tracks: u64,
pub waveforms: u64,
pub loudness: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisFailedTrackDto {
pub track_id: String,
pub md5_16kb: String,
pub updated_at: i64,
}
impl From<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerReportDto {
fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self {
Self {
analysis_tracks: value.analysis_tracks,
waveforms: value.waveforms,
loudness: value.loudness,
}
}
}
impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
fn from(value: analysis_cache::FailedTrackEntry) -> Self {
Self {
track_id: value.track_id,
md5_16kb: value.md5_16kb,
updated_at: value.updated_at,
}
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisServerKeyMigrationDto {
pub legacy_id: String,
pub index_key: String,
}
/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id,
/// md5_16kb)` key. Converts the `WaveformEntry` into the JSON-serialisable
/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can be
/// tested with `AnalysisCache::open_in_memory()` and direct upserts.
pub fn get_waveform_payload(
cache: &analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
md5_16kb: &str,
) -> Result<Option<WaveformCachePayload>, String> {
let exact = analysis_cache::TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_16kb.to_string(),
};
Ok(cache
.get_waveform(&exact)?
.map(WaveformCachePayload::from))
}
/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)`
/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`].
pub fn get_waveform_payload_for_track(
cache: &analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
) -> Result<Option<WaveformCachePayload>, String> {
Ok(cache
.get_latest_waveform_for_track(server_id, track_id)?
.map(WaveformCachePayload::from))
}
/// AppHandle-free helper: looks up the latest loudness row for `(server_id,
/// track_id)` and recomputes `recommended_gain_db`
/// against the optional requested target (clamped to [-30, -8]). When
/// `target_lufs` is `None`, the cached row's own target is used.
pub fn get_loudness_payload_for_track(
cache: &analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
target_lufs: Option<f64>,
) -> Result<Option<LoudnessCachePayload>, String> {
Ok(cache.get_latest_loudness_for_track(server_id, track_id)?.map(|v| {
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
v.integrated_lufs,
v.true_peak,
requested_target,
);
LoudnessCachePayload {
integrated_lufs: v.integrated_lufs,
true_peak: v.true_peak,
recommended_gain_db,
target_lufs: requested_target,
updated_at: v.updated_at,
}
}))
}
#[tauri::command]
pub fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let server_id = server_id.unwrap_or_default();
let result = get_waveform_payload(cache.inner(), &server_id, &track_id, &md5_16kb);
if let Ok(ref payload) = result {
match payload {
Some(v) => crate::app_deprintln!(
"[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}",
track_id, md5_16kb, v.bins.len(), v.bin_count, v.updated_at
),
None => crate::app_deprintln!(
"[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}",
track_id, md5_16kb
),
}
}
result
}
#[tauri::command]
pub fn analysis_get_waveform_for_track(
track_id: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let server_id = server_id.unwrap_or_default();
let result = get_waveform_payload_for_track(cache.inner(), &server_id, &track_id);
if let Ok(ref payload) = result {
match payload {
Some(v) => crate::app_deprintln!(
"[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}",
track_id, v.bins.len(), v.bin_count, v.updated_at
),
None => crate::app_deprintln!("[analysis][waveform] db miss track_id={}", track_id),
}
}
result
}
#[tauri::command]
pub fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<LoudnessCachePayload>, String> {
let server_id = server_id.unwrap_or_default();
get_loudness_payload_for_track(cache.inner(), &server_id, &track_id, target_lufs)
}
#[tauri::command]
pub fn analysis_delete_loudness_for_track(
track_id: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_loudness_for_track_id(&server_id.unwrap_or_default(), &track_id)
}
#[tauri::command]
pub fn analysis_delete_waveform_for_track(
track_id: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_waveform_for_track_id(&server_id.unwrap_or_default(), &track_id)
}
#[tauri::command]
pub fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_all_waveforms()
}
#[tauri::command]
pub fn analysis_delete_all_for_server(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<AnalysisDeleteServerReportDto, String> {
if server_id.trim().is_empty() {
return Err("server_id required".to_string());
}
let report = cache.delete_all_for_server(&server_id)?;
Ok(report.into())
}
#[tauri::command]
pub fn analysis_get_failed_track_count(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<i64, String> {
let server_id = server_id.trim().to_string();
if server_id.is_empty() {
return Ok(0);
}
cache.count_failed_tracks(&server_id)
}
#[tauri::command]
pub fn analysis_list_failed_tracks(
server_id: String,
limit: Option<u32>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Vec<AnalysisFailedTrackDto>, String> {
let server_id = server_id.trim().to_string();
if server_id.is_empty() {
return Ok(Vec::new());
}
let limit = limit
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
.map(|v| v.clamp(1, 5_000));
let rows = cache.list_failed_tracks(&server_id, limit)?;
Ok(rows.into_iter().map(AnalysisFailedTrackDto::from).collect())
}
#[tauri::command]
pub fn analysis_clear_failed_tracks(
server_id: String,
track_ids: Option<Vec<String>>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
let server_id = server_id.trim().to_string();
if server_id.is_empty() {
return Err("server_id required".to_string());
}
let track_ids = track_ids
.unwrap_or_default()
.into_iter()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect::<Vec<_>>();
cache.clear_failed_tracks(&server_id, &track_ids)
}
#[tauri::command]
pub fn analysis_migrate_server_index_keys(
mappings: Vec<AnalysisServerKeyMigrationDto>,
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<(), String> {
for mapping in mappings {
let _ = (mapping.legacy_id, mapping.index_key);
}
Ok(())
}
#[tauri::command]
pub fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
force: Option<bool>,
server_id: Option<String>,
priority: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let explicit = AnalysisBackfillPriority::from_optional_str(priority.as_deref());
enqueue_seed_from_url(
&app,
&track_id,
&url,
server_id.as_deref(),
explicit,
force.unwrap_or(false),
)
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPriorityHintDto {
pub server_id: String,
pub track_id: String,
}
#[tauri::command]
pub fn analysis_set_playback_priority_hints(
middle_track_refs: Vec<AnalysisPriorityHintDto>,
hints: tauri::State<'_, PlaybackPriorityHints>,
) -> Result<(), String> {
let pairs = middle_track_refs
.into_iter()
.map(|r| (r.server_id, r.track_id));
hints.set_middle_track_ids(pairs);
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisBackfillQueueStatsDto {
pub queued: usize,
pub in_progress_count: usize,
pub in_progress_track_id: Option<String>,
}
#[tauri::command]
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
Ok(())
}
#[tauri::command]
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
Ok(analysis_pipeline_queue_stats())
}
#[tauri::command]
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
let (queued, in_progress_count, in_progress_track_id) =
analysis_backfill_queue_stats();
Ok(AnalysisBackfillQueueStatsDto {
queued,
in_progress_count,
in_progress_track_id,
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPrunePendingResult {
pub keep_count: usize,
pub http_removed: usize,
pub cpu_removed_jobs: usize,
pub cpu_removed_waiters: usize,
}
/// Prunes pending analysis work for tracks no longer present in the playback queue.
///
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
#[tauri::command]
pub fn analysis_prune_pending_to_track_ids(
track_ids: Vec<String>,
server_id: String,
) -> Result<AnalysisPrunePendingResult, String> {
let mut normalized: Vec<String> = Vec::with_capacity(track_ids.len());
let mut seen = HashSet::new();
for raw in track_ids {
let tid = raw.trim();
if tid.is_empty() {
continue;
}
if seen.insert(tid.to_string()) {
normalized.push(tid.to_string());
}
}
let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect();
let server_id = server_id.trim().to_string();
let server_filter = if server_id.is_empty() { None } else { Some(server_id.as_str()) };
let (http_removed, cpu_removed_jobs, cpu_removed_waiters) =
prune_analysis_queues(&keep_track_ids, server_filter)?;
if http_removed > 0 || cpu_removed_jobs > 0 {
crate::app_deprintln!(
"[analysis] pruned pending queues keep={} removed_http={} removed_cpu_jobs={} removed_cpu_waiters={}",
keep_track_ids.len(),
http_removed,
cpu_removed_jobs,
cpu_removed_waiters
);
}
Ok(AnalysisPrunePendingResult {
keep_count: keep_track_ids.len(),
http_removed,
cpu_removed_jobs,
cpu_removed_waiters,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis_cache::{
AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry,
};
fn key(track_id: &str, md5: &str) -> TrackKey {
TrackKey {
server_id: "server-a".to_string(),
track_id: track_id.to_string(),
md5_16kb: md5.to_string(),
}
}
fn upsert_waveform(cache: &AnalysisCache, track_id: &str, md5: &str, bins: Vec<u8>) {
let k = key(track_id, md5);
cache.touch_track_status(&k, "ready").unwrap();
cache
.upsert_waveform(
&k,
&WaveformEntry {
bin_count: (bins.len() / 2) as i64,
bins,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 60.0,
updated_at: 1_700_000_000,
},
)
.unwrap();
}
fn upsert_loudness(cache: &AnalysisCache, track_id: &str, md5: &str, target_lufs: f64) {
let k = key(track_id, md5);
cache.touch_track_status(&k, "ready").unwrap();
cache
.upsert_loudness(
&k,
&LoudnessEntry {
integrated_lufs: -14.0,
true_peak: 0.5,
recommended_gain_db: 0.0,
target_lufs,
updated_at: 1_700_000_000,
},
)
.unwrap();
}
// ── get_waveform_payload ──────────────────────────────────────────────────
#[test]
fn get_waveform_payload_returns_none_for_unknown_key() {
let cache = AnalysisCache::open_in_memory();
let payload = get_waveform_payload(&cache, "server-a", "missing", "deadbeef").unwrap();
assert!(payload.is_none());
}
#[test]
fn get_waveform_payload_returns_payload_for_existing_row() {
let cache = AnalysisCache::open_in_memory();
let bins: Vec<u8> = (0..8u8).collect();
upsert_waveform(&cache, "abc", "deadbeef", bins.clone());
let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef")
.unwrap()
.expect("payload exists");
assert_eq!(payload.bins, bins);
assert_eq!(payload.bin_count, 4);
assert!(!payload.is_partial);
assert_eq!(payload.duration_sec, 60.0);
assert_eq!(payload.updated_at, 1_700_000_000);
}
#[test]
fn get_waveform_payload_distinguishes_md5_keys() {
// Same track_id, different md5_16kb → independent rows.
let cache = AnalysisCache::open_in_memory();
upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]);
upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]);
let p1 = get_waveform_payload(&cache, "server-a", "abc", "aaaa").unwrap().unwrap();
let p2 = get_waveform_payload(&cache, "server-a", "abc", "bbbb").unwrap().unwrap();
assert_ne!(p1.bins, p2.bins);
}
// ── get_waveform_payload_for_track ────────────────────────────────────────
#[test]
fn get_waveform_for_track_finds_row_under_stream_prefix() {
// Insert under `stream:abc`, look up with bare `abc` — id-variant
// matching is the whole point of get_latest_waveform_for_track.
let cache = AnalysisCache::open_in_memory();
upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]);
let payload = get_waveform_payload_for_track(&cache, "server-a", "abc")
.unwrap()
.expect("bare-id lookup must hit the stream-prefixed row");
assert_eq!(payload.bin_count, 4);
}
#[test]
fn get_waveform_for_track_returns_none_for_unknown_track() {
let cache = AnalysisCache::open_in_memory();
assert!(get_waveform_payload_for_track(&cache, "server-a", "phantom").unwrap().is_none());
}
// ── get_loudness_payload_for_track ────────────────────────────────────────
#[test]
fn get_loudness_for_track_recomputes_gain_against_requested_target() {
let cache = AnalysisCache::open_in_memory();
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
// Cached row: integrated -14, target -14 → gain 0. Request target -10 →
// recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard).
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-10.0))
.unwrap()
.expect("loudness row exists");
assert_eq!(payload.target_lufs, -10.0);
assert!(
payload.recommended_gain_db.is_finite() && payload.recommended_gain_db <= 4.0,
"recommended_gain_db must reflect the new target, got {}",
payload.recommended_gain_db
);
}
#[test]
fn get_loudness_for_track_uses_cached_target_when_request_is_none() {
let cache = AnalysisCache::open_in_memory();
upsert_loudness(&cache, "abc", "deadbeef", -16.0);
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", None)
.unwrap()
.unwrap();
assert_eq!(payload.target_lufs, -16.0);
}
#[test]
fn get_loudness_for_track_clamps_target_into_supported_range() {
let cache = AnalysisCache::open_in_memory();
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
// Out-of-range target gets clamped to [-30, -8].
let too_high = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(0.0))
.unwrap()
.unwrap();
assert_eq!(too_high.target_lufs, -8.0);
let too_low = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-100.0))
.unwrap()
.unwrap();
assert_eq!(too_low.target_lufs, -30.0);
}
#[test]
fn get_loudness_for_track_returns_none_for_unknown_track() {
let cache = AnalysisCache::open_in_memory();
assert!(get_loudness_payload_for_track(&cache, "server-a", "phantom", None)
.unwrap()
.is_none());
}
// ── WaveformCachePayload::from(WaveformEntry) ─────────────────────────────
#[test]
fn waveform_payload_from_entry_preserves_all_fields() {
let entry = WaveformEntry {
bins: vec![1, 2, 3, 4],
bin_count: 2,
is_partial: true,
known_until_sec: 5.5,
duration_sec: 10.0,
updated_at: 42,
};
let payload = WaveformCachePayload::from(entry);
assert_eq!(payload.bins, vec![1, 2, 3, 4]);
assert_eq!(payload.bin_count, 2);
assert!(payload.is_partial);
assert_eq!(payload.known_until_sec, 5.5);
assert_eq!(payload.duration_sec, 10.0);
assert_eq!(payload.updated_at, 42);
}
}
@@ -1,18 +0,0 @@
//! `psysonic-analysis` — loudness/waveform analysis cache and the runtime
//! that drives HTTP backfill + CPU-seed work queues.
//!
//! Submodules mirror the original layout in the top crate:
//! - `analysis_cache` — SQLite-backed loudness/waveform store + compute helpers
//! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop
pub mod analysis_cache;
pub mod analysis_perf;
pub mod analysis_runtime;
mod codec;
pub mod commands;
pub mod track_analysis_plan;
pub mod track_enrichment;
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
// the same way they did when they lived in the top crate.
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
@@ -1,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)
}
@@ -1,50 +0,0 @@
[package]
name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-analysis = { path = "../psysonic-analysis" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
ringbuf = "0.5"
biquad = "0.6"
dasp_sample = "0.11.0"
md5 = "0.8"
url = "2"
thread-priority = "3"
lofty = "0.24"
id3 = "1.17"
pitch_shift = "2.1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Threading",
"Win32_System_Power",
"Win32_UI_WindowsAndMessaging",
] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
tokio = { version = "1", features = ["rt", "time", "sync", "macros", "rt-multi-thread", "test-util"] }
wiremock = { workspace = true }
@@ -1,288 +0,0 @@
//! Unified playback → track analysis dispatch.
//!
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
use url::Url;
use crate::state::ChainedInfo;
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
/// Where playback obtained the bytes — used for logging and size caps only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TrackAnalysisOrigin {
InMemoryReplay,
StreamDownloadComplete,
LocalFilePlayback,
StreamSpillFile,
PrefetchOrCacheFile,
GaplessChainReady,
GaplessTransition,
}
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
match origin {
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
}
}
/// Playback server scope: explicit IPC value, else pinned engine scope.
pub(crate) fn resolve_analysis_server_id(
explicit: Option<&str>,
engine: Option<&AudioEngine>,
) -> String {
if let Some(engine) = engine {
if let Some(url) = engine
.current_playback_url
.lock()
.ok()
.and_then(|g| (*g).clone())
{
if let Some(derived) = server_id_from_playback_url(&url) {
return derived;
}
}
}
explicit
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| engine.map(current_playback_server_id_str).unwrap_or_default())
}
fn server_id_from_playback_url(url_raw: &str) -> Option<String> {
if url_raw.starts_with("psysonic-local://") {
return None;
}
let parsed = Url::parse(url_raw).ok()?;
let host = parsed.host_str()?;
let mut base_path = parsed.path().to_string();
if let Some(idx) = base_path.find("/rest") {
base_path.truncate(idx);
}
while base_path.ends_with('/') {
base_path.pop();
}
let mut base = host.to_string();
if let Some(port) = parsed.port() {
base.push_str(&format!(":{port}"));
}
if !base_path.is_empty() {
base.push_str(&base_path);
}
Some(base)
}
fn resolve_analysis_priority(
app: &AppHandle,
engine: Option<&AudioEngine>,
server_id: &str,
track_id: &str,
explicit: Option<AnalysisBackfillPriority>,
) -> AnalysisBackfillPriority {
if let Some(priority) = explicit {
return priority;
}
if psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
{
return AnalysisBackfillPriority::High;
}
psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority(
app,
server_id,
track_id,
None,
)
}
/// Resolve `(server_id, priority)` when the caller has live engine state.
pub(crate) fn prepare_playback_analysis(
app: &AppHandle,
engine: &AudioEngine,
explicit_server_id: Option<&str>,
track_id: &str,
priority: Option<AnalysisBackfillPriority>,
) -> (String, AnalysisBackfillPriority) {
let sid = resolve_analysis_server_id(explicit_server_id, Some(engine));
let resolved = resolve_analysis_priority(app, Some(engine), &sid, track_id, priority);
(sid, resolved)
}
pub(crate) fn resolve_server_id_for_app(
app: &AppHandle,
explicit: Option<&str>,
) -> String {
let engine = app.try_state::<AudioEngine>();
resolve_analysis_server_id(explicit, engine.as_deref())
}
pub(crate) fn analysis_priority_for_app(
app: &AppHandle,
server_id: &str,
track_id: &str,
explicit: Option<AnalysisBackfillPriority>,
) -> AnalysisBackfillPriority {
let engine = app.try_state::<AudioEngine>();
resolve_analysis_priority(app, engine.as_deref(), server_id, track_id, explicit)
}
/// Gapless boundary: chained track became audible — run unified analysis if needed.
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
let track_id = analysis_cache_track_id(
info.analysis_track_id.as_deref(),
&info.url,
);
let Some(track_id) = track_id else {
return;
};
let engine = app.state::<AudioEngine>();
let (sid, priority) = prepare_playback_analysis(
app,
&engine,
info.server_id.as_deref(),
&track_id,
Some(AnalysisBackfillPriority::High),
);
let bytes = (*info.raw_bytes).clone();
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::GaplessTransition,
sid,
track_id,
bytes,
priority,
None,
);
}
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
pub(crate) async fn dispatch_track_analysis_bytes(
app: &AppHandle,
origin: TrackAnalysisOrigin,
server_id: &str,
track_id: &str,
bytes: Vec<u8>,
priority: AnalysisBackfillPriority,
) -> Result<(), String> {
let track_id = track_id.trim();
if track_id.is_empty() {
return Ok(());
}
if bytes.is_empty() {
return Ok(());
}
let max = max_bytes_for_origin(origin);
if bytes.len() > max {
crate::app_deprintln!(
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
bytes.len(),
);
return Ok(());
}
crate::app_deprintln!(
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} priority={priority:?}",
if server_id.is_empty() { "''" } else { server_id },
bytes.len() as f64 / (1024.0 * 1024.0),
);
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
app,
server_id,
track_id,
&bytes,
None,
priority,
)
.await
.map(|_| ())
}
/// Non-blocking wrapper with optional play-generation supersede guard.
pub(crate) fn spawn_track_analysis_bytes(
app: AppHandle,
origin: TrackAnalysisOrigin,
server_id: String,
track_id: String,
bytes: Vec<u8>,
priority: AnalysisBackfillPriority,
generation_guard: Option<(u64, Arc<AtomicU64>)>,
) {
if track_id.trim().is_empty() || bytes.is_empty() {
return;
}
tokio::spawn(async move {
if let Some((gen, gen_arc)) = generation_guard {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
}
if let Err(e) = dispatch_track_analysis_bytes(
&app,
origin,
&server_id,
&track_id,
bytes,
priority,
)
.await
{
crate::app_eprintln!(
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
);
}
});
}
pub(crate) fn spawn_track_analysis_file(
app: AppHandle,
origin: TrackAnalysisOrigin,
server_id: String,
track_id: String,
file_path: PathBuf,
priority: AnalysisBackfillPriority,
generation_guard: Option<(u64, Arc<AtomicU64>)>,
) {
if track_id.trim().is_empty() {
return;
}
tokio::spawn(async move {
if let Some((gen, gen_arc)) = &generation_guard {
if gen_arc.load(Ordering::SeqCst) != *gen {
return;
}
}
let bytes = match tokio::fs::read(&file_path).await {
Ok(b) if !b.is_empty() => b,
_ => return,
};
if let Some((gen, gen_arc)) = generation_guard {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
}
if let Err(e) = dispatch_track_analysis_bytes(
&app,
origin,
&server_id,
&track_id,
bytes,
priority,
)
.await
{
crate::app_eprintln!(
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
);
}
});
}
@@ -1,136 +0,0 @@
//! AutoEQ proxy commands: fetch entry list and FixedBandEQ profiles from
//! GitHub via Rust to bypass WebView CORS.
use tauri::State;
use super::engine::{audio_http_client, AudioEngine};
/// AutoEQ raw-content base URL — the GitHub directory that holds every
/// FixedBandEQ profile by `(source, form, name)` (and `rig`-prefixed forms
/// for crinacle's measurements).
pub(crate) const AUTOEQ_RAW_BASE: &str =
"https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
/// Pure URL builder for [`autoeq_fetch_profile`]. The AutoEQ repo lays out
/// FixedBandEQ profiles either as
///
/// `{base}/{source}/{form}/{name}/{name} FixedBandEQ.txt` (most sources)
/// `{base}/{source}/{rig} {form}/{name}/{name} FixedBandEQ.txt` (crinacle — rig-prefixed dir)
///
/// When `rig` is supplied the function emits the rig-prefixed candidate first
/// (so callers try it before the form-only fallback). When `rig` is `None`
/// only the form-only path is returned.
pub(crate) fn autoeq_profile_url_candidates(
base: &str,
source: &str,
form: &str,
name: &str,
rig: Option<&str>,
) -> Vec<String> {
let filename = format!("{} FixedBandEQ.txt", name);
if let Some(r) = rig {
vec![
format!("{}/{}/{} {}/{}/{}", base, source, r, form, name, filename),
format!("{}/{}/{}/{}/{}", base, source, form, name, filename),
]
} else {
vec![format!("{}/{}/{}/{}/{}", base, source, form, name, filename)]
}
}
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
#[tauri::command]
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
audio_http_client(&state)
.get("https://autoeq.app/entries")
.send().await.map_err(|e| e.to_string())?
.text().await.map_err(|e| e.to_string())
}
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
#[tauri::command]
pub async fn autoeq_fetch_profile(
name: String,
source: String,
rig: Option<String>,
form: String,
state: State<'_, AudioEngine>,
) -> Result<String, String> {
let candidates =
autoeq_profile_url_candidates(AUTOEQ_RAW_BASE, &source, &form, &name, rig.as_deref());
for url in &candidates {
let resp = audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
if resp.status().is_success() {
return resp.text().await.map_err(|e| e.to_string());
}
}
Err(format!("FixedBandEQ profile not found for '{}'", name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_candidates_returns_form_only_path_when_no_rig_supplied() {
let urls = autoeq_profile_url_candidates(
"https://example/results",
"oratory1990",
"over-ear",
"Sennheiser HD 600",
None,
);
assert_eq!(
urls,
vec!["https://example/results/oratory1990/over-ear/Sennheiser HD 600/Sennheiser HD 600 FixedBandEQ.txt".to_string()]
);
}
#[test]
fn url_candidates_emits_rig_prefixed_candidate_first_when_rig_supplied() {
// crinacle's measurements use a rig-prefixed directory like
// `crinacle/IEC711 in-ear` instead of plain `crinacle/in-ear`.
let urls = autoeq_profile_url_candidates(
"https://example/results",
"crinacle",
"in-ear",
"Moondrop Variations",
Some("IEC711"),
);
assert_eq!(urls.len(), 2);
assert_eq!(
urls[0],
"https://example/results/crinacle/IEC711 in-ear/Moondrop Variations/Moondrop Variations FixedBandEQ.txt",
"rig-prefixed path tried first"
);
assert_eq!(
urls[1],
"https://example/results/crinacle/in-ear/Moondrop Variations/Moondrop Variations FixedBandEQ.txt",
"form-only fallback emitted second"
);
}
#[test]
fn url_candidates_preserves_spaces_in_headphone_names() {
let urls = autoeq_profile_url_candidates(
"base",
"src",
"form",
"Audio-Technica ATH-M50x",
None,
);
// Spaces inside the name aren't URL-encoded — reqwest does that on send.
assert!(urls[0].contains("Audio-Technica ATH-M50x"));
assert!(urls[0].ends_with("Audio-Technica ATH-M50x FixedBandEQ.txt"));
}
#[test]
fn url_candidates_uses_real_autoeq_base_in_production() {
// The const is the production raw-content URL — guard against typos.
assert!(AUTOEQ_RAW_BASE.starts_with("https://raw.githubusercontent.com/"));
assert!(AUTOEQ_RAW_BASE.contains("/jaakkopasanen/AutoEq"));
assert!(AUTOEQ_RAW_BASE.ends_with("/results"));
}
}
@@ -1,22 +0,0 @@
//! 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;
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 try_make_radio_decoder(
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
@@ -1,750 +0,0 @@
//! Tauri commands: audio_play / chain_preload / preload + the shared
//! spawn_progress_task helper. Transport (pause/resume/stop/seek), device,
//! radio, mix-mode and AutoEQ commands live in sibling modules.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use rodio::Player;
use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::build_source;
use super::engine::AudioEngine;
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
use super::sink_swap::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
SinkSwapInputs,
};
use super::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task;
use super::state::{ChainedInfo, PreloadedTrack};
// ─── Commands ─────────────────────────────────────────────────────────────────
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
/// for HTTP streams (`playback_identity` is used as fallback).
///
/// `server_id`: app id of the server that owns this track (`playbackServerId ??
/// activeServerId` on the frontend). Scopes the analysis-cache write key so a
/// later server switch can't surface another server's waveform for the same bare
/// `track_id`. Empty/absent falls back to the legacy `''` scope.
///
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn audio_play(
url: String,
volume: f32,
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
analysis_track_id: Option<String>,
server_id: Option<String>,
stream_format_suffix: Option<String>,
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
// `false` so older/external `audio_play` callers that omit it still work.
start_paused: Option<bool>,
// Silence-aware crossfade (B-head): begin playback past the next track's
// leading silence. Optional + defaults to `0` so existing callers are
// unaffected; only applied when the freshly built source is seekable.
start_secs: Option<f64>,
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
// transition instead of the global `crossfade_secs`. `None` → use the global
// setting (today's behaviour); always still clamped to the measured remaining.
crossfade_secs_override: Option<f32>,
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
// already fades out in the recording, so let it ride at full engine gain
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
// B's fade (today's behaviour). Always clamped to A's measured remaining.
outgoing_fade_secs_override: Option<f32>,
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
// while a track is playing. Optional; only honoured when `manual` is true.
manual_autodj_blend: Option<bool>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let start_paused = start_paused.unwrap_or(false);
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
// After a gapless auto-advance, the frontend may fire a stale playTrack()
// call via IPC. If we're within 500 ms of the last gapless switch AND the
// requested URL matches the already-playing chained track, reject it.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
// Within the guard window — suppress this ghost command.
return Ok(());
}
}
}
// Cancel any active preview before starting fresh main playback so the
// two sinks don't end up mixed.
preview_clear_for_new_main_playback(&state, &app);
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in
// advance. The source is live in the queue — just return and let the
// progress task handle the state transition when the previous source ends.
//
// Never for manual skips: the UI already jumped to this track in JS, but
// the current source is still playing until the chain drains. User-initiated
// play must clear the chain and start this URL immediately (standard path).
if gapless && !manual {
let already_chained = state.chained_info.lock().unwrap()
.as_ref()
.map(|c| same_playback_target(&c.url, &url))
.unwrap_or(false);
if already_chained {
return Ok(());
}
}
// ── Standard (new-sink) path ─────────────────────────────────────────────
// Used for: manual skip, gapless OFF, first play, or gapless when the
// proactive chain was not set up in time.
// Bump generation first so the old progress task stops before we peel
// chained_info (avoids a race where it sees current_done + empty chain).
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Ranged/legacy HTTP paths reset this to false in `select_play_input`.
state.stream_playback_armed.store(true, Ordering::SeqCst);
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
// preload cache was already consumed when the chain was built). Otherwise
// clear any stale chain metadata.
let reuse_chained_bytes: Option<Vec<u8>> = if gapless && manual {
let mut ci = state.chained_info.lock().unwrap();
if ci.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
ci.take().map(|info| {
Arc::try_unwrap(info.raw_bytes).unwrap_or_else(|a| (*a).clone())
})
} else {
*ci = None;
None
}
} else {
*state.chained_info.lock().unwrap() = None;
None
};
// Stop fading-out sink from previous crossfade.
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
old.stop();
}
// Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
// the previous URL still stored until the sink swap completes.
*state.current_playback_url.lock().unwrap() = Some(url.clone());
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
*state.current_analysis_track_id.lock().unwrap() = logical_trim.clone();
let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url);
// Playback server scope for the analysis-cache write key (empty → legacy '').
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
// Pin it so the gain-resolution + replay-gain-update + device-resume reads
// scope to this server too (mirrors `current_analysis_track_id`).
*state.current_playback_server_id.lock().unwrap() = analysis_server_id.map(str::to_string);
let format_hint = url_format_hint(&url);
let play_input = match select_play_input(
PlayInputContext {
url: &url,
gen,
duration_hint,
stream_format_suffix: stream_format_suffix.as_deref(),
format_hint: format_hint.as_deref(),
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
server_id: analysis_server_id,
reuse_chained_bytes,
},
&state,
&app,
).await? {
Some(input) => input,
None => {
crate::app_deprintln!(
"[audio] audio_play superseded inside select_play_input: gen={} cur={} track_id={:?}",
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
);
return Ok(());
}
};
if state.generation.load(Ordering::SeqCst) != gen {
crate::app_deprintln!(
"[audio] audio_play superseded after select_play_input: gen={} cur={} track_id={:?}",
gen, state.generation.load(Ordering::SeqCst), cache_id_for_tasks
);
return Ok(());
}
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
let (gain_linear, effective_volume) = compute_gain(
gain_inputs.norm_mode,
replay_gain_db,
replay_gain_peak,
gain_inputs.effective_loudness_db,
pre_gain_db,
fallback_db,
volume,
);
let current_gain_db = loudness_ui_current_gain_db(gain_linear);
crate::app_deprintln!(
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
playback_identity(&url),
normalization_engine_name(gain_inputs.norm_mode),
replay_gain_db,
replay_gain_peak,
gain_inputs.cache_loudness_db,
gain_linear,
current_gain_db,
gain_inputs.target_lufs,
volume,
effective_volume
);
maybe_emit_normalization_state(
&app,
NormalizationStatePayload {
engine: normalization_engine_name(gain_inputs.norm_mode).to_string(),
current_gain_db,
target_lufs: gain_inputs.target_lufs,
},
);
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
let crossfade_enabled =
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
// Per-transition override (dynamic crossfade) caps the fade for this swap;
// otherwise fall back to the global crossfade length. Both clamped the same.
let crossfade_secs_val = crossfade_secs_override
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
.clamp(0.5, 12.0);
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
// have consumed ~500800ms from Track A's tail — so its true remaining time
// is always less than crossfade_secs_val. Using the measured remaining time
// for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and
// guarantees Track A reaches 0 exactly when its source exhausts.
let actual_fade_secs: f32 = if crossfade_enabled {
let cur = state.current.lock().unwrap();
let remaining = (cur.duration_secs - cur.position()) as f32;
remaining.clamp(0.1, crossfade_secs_val)
} else {
0.0
};
// Fade-in duration for Track B:
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
// hard cut → 5 ms micro-fade to suppress DC-offset click
let fade_in_dur = if crossfade_enabled {
Duration::from_secs_f32(actual_fade_secs)
} else {
Duration::from_millis(5)
};
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
// override means A already fades out in the recording, so we leave it at
// full engine gain (scenario A). Never longer than A's remaining audio.
let outgoing_fade_secs: f32 = if crossfade_enabled {
match outgoing_fade_secs_override {
Some(v) => v.max(0.0).min(actual_fade_secs),
None => actual_fade_secs,
}
} else {
0.0
};
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let playback_source = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &url,
gen,
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
server_id: analysis_server_id,
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
},
&state,
&app,
)
.await
.map_err(|e| {
// Suppress the audio:error toast when this play was already superseded
// by a newer audio_play (rapid skip): the failure is the inevitable
// Ok(0)/EOF from RangedHttpSource after gen-bump, not a real codec
// problem. The frontend would otherwise show "Couldn't play track" for
// the abandoned URL while a new track is already loading.
if state.generation.load(Ordering::SeqCst) == gen {
app.emit("audio:error", &e).ok();
} else {
crate::app_deprintln!(
"[audio] suppressed audio:error for superseded play (gen={} cur={}): {}",
gen, state.generation.load(Ordering::SeqCst), e
);
}
e
})?;
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
let source_seekable = playback_source.is_seekable;
let built = playback_source.built;
let mut source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
// Store the actual output rate/channels for position calculation.
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
// ── Stream rate management ────────────────────────────────────────────────
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
// toggle was just turned off mid-session), restore the device
// default rate so playback is no longer at 88.2/96 kHz etc.
// If already at the device default — skip entirely (no IPC, no
// PipeWire reconfigure, no scheduler cost).
{
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let target_rate = if hi_res_enabled {
output_rate // native file rate
} else {
state.device_default_rate // restore device default
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let dev = state.selected_device.lock().unwrap().clone();
match super::engine::open_output_stream_blocking(
&state,
target_rate,
hi_res_enabled,
dev,
) {
Ok(_) => {
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
crate::app_eprintln!(
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
);
}
}
}
// Re-check gen: a rapid skip during the settle sleep would have bumped it.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
}
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
// At sample rates > 48 kHz the hardware quantum is larger and the first
// period demands more decoded frames than at 44.1/48 kHz.
// Strategy: pause the sink before appending so rodio's internal mixer
// decodes into its ring buffer ahead of the hardware. After a short delay
// we resume — the buffer is already full and the hardware gets its frames
// without an underrun on the very first period.
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
// Preserve-pitch phase vocoder runs on a worker thread; pre-fill gives the
// ring buffer time to build headroom before the cpal callback drains it.
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
let needs_prefill =
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start {
sink.pause();
}
// Gapless OFF: prepend a short silence so tracks are clearly separated.
// Only when this is an auto-advance (near end), not on manual skip.
//
// Use a frame-aligned `SamplesBuffer` rather than `Zero + take_duration` —
// the latter computes its sample count via integer-nanosecond division
// (1_000_000_000 / (sr * ch)), which at common rates leaks an odd number
// of samples (e.g. 44103 at 44.1 kHz / 2 ch / 500 ms = 22051.5 frames).
// The half-frame leak shifts the next source's L/R parity in the device
// stream and can manifest as a dead channel for the rest of the track
// (reported by users for natural-end-without-gapless transitions only).
if !gapless {
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
};
let cur_dur = {
let cur = state.current.lock().unwrap();
cur.duration_secs
};
let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
if is_auto_advance {
let ch = source.channels();
let sr = source.sample_rate();
// 500 ms in whole frames, then expand to interleaved samples.
let frames = (sr.get() / 2) as usize;
let total_samples = frames.saturating_mul(ch.get() as usize);
let silence = rodio::buffer::SamplesBuffer::new(ch, sr, vec![0f32; total_samples]);
sink.append(silence);
}
}
// Silence-aware crossfade (B-head): skip the next track's leading silence by
// seeking the freshly built source before it is appended. The outermost
// `CountingSource` stores the sample counter on a successful seek; we still
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
// so the seekbar and the crossfade-remaining math are content-relative.
let did_start_seek = if start_secs > 0.05 && source_seekable {
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
} else {
false
};
sink.append(source);
if needs_prefill {
let prefill_ms = if needs_preserve_prefill {
800
} else {
500
};
tokio::time::sleep(Duration::from_millis(prefill_ms)).await;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
if !defer_playback_start && !start_paused {
sink.play();
}
}
swap_in_new_sink(&state, SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: built.fadeout_trigger,
fadeout_samples: built.fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
});
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
// sample counter to the content offset so position reporting is correct.
if did_start_seek {
{
let mut cur = state.current.lock().unwrap();
cur.seek_offset = start_secs;
}
state.samples_played.store(
raw_counter_samples_for_content_position(
start_secs,
output_rate,
output_channels as u32,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
if defer_playback_start {
if !start_paused {
let mut cur = state.current.lock().unwrap();
cur.play_started = None;
cur.paused_at = Some(0.0);
}
spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
gen,
gen_arc: state.generation.clone(),
playback_armed: state.stream_playback_armed.clone(),
samples_played: state.samples_played.clone(),
current: state.current.clone(),
app: app.clone(),
duration_secs,
hold_paused: start_paused,
});
} else if !start_paused {
app.emit("audio:playing", duration_secs).ok();
}
// ── Progress + ended detection ────────────────────────────────────────────
let analysis_app = app.clone();
spawn_progress_task(
gen,
state.generation.clone(),
state.current.clone(),
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
state.autodj_suppress_autocrossfade.clone(),
done_flag,
app,
Some(analysis_app),
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
state.stream_playback_armed.clone(),
state.playback_rate.clone(),
);
Ok(())
}
/// Proactively appends the next track to the current Sink ~30 s before the
/// current track ends. Called from JS at the same trigger point as preload.
///
/// Because this runs well before the track boundary, the IPC round-trip is
/// irrelevant — by the time the current track actually ends, the next source
/// is already live in the Sink queue and rodio transitions at sample accuracy.
///
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
/// immediately without touching the Sink (pure no-op on the audio path).
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn audio_chain_preload(
url: String,
volume: f32,
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
hi_res_enabled: bool,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Idempotent: already chained this track → nothing to do.
{
let chained = state.chained_info.lock().unwrap();
if chained.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
return Ok(());
}
}
// Gapless must be enabled and a sink must exist.
if !state.gapless_enabled.load(Ordering::Relaxed) {
return Ok(());
}
let snapshot_gen = state.generation.load(Ordering::SeqCst);
// Fetch bytes — use preload cache if available, otherwise HTTP.
let data: Vec<u8> = {
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
preloaded.take().map(|p| p.data)
} else {
None
}
};
if let Some(d) = cached {
d
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
}
let hint = resp.content_length().unwrap_or(0) as usize;
let mut stream = resp.bytes_stream();
let mut buf = Vec::with_capacity(hint);
while let Some(chunk) = stream.next().await {
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(()); // superseded by manual skip — abort download
}
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
}
buf
}
};
// Bail if the user skipped to a different track while we were downloading.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(());
}
let raw_bytes = Arc::new(data);
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
let (sid, priority) = crate::analysis_dispatch::prepare_playback_analysis(
&app,
&state,
analysis_server_id,
&track_id,
Some(psysonic_analysis::analysis_runtime::AnalysisBackfillPriority::Middle),
);
let bytes = (*raw_bytes).clone();
crate::analysis_dispatch::spawn_track_analysis_bytes(
app.clone(),
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
sid,
track_id,
bytes,
priority,
None,
);
}
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
// the still-playing current source). Volume for the chained track is
// applied at the gapless transition in `spawn_progress_task`, not here.
let gain_inputs = resolve_track_gain_inputs(&state, &app, &url, logical_trim.as_deref(), loudness_gain_db);
let (gain_linear, _effective_volume) = compute_gain(
gain_inputs.norm_mode,
replay_gain_db,
replay_gain_peak,
gain_inputs.effective_loudness_db,
pre_gain_db,
fallback_db,
volume,
);
let done_next = Arc::new(AtomicBool::new(false));
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
// Always 0 — no application-level resampling (same as audio_play).
let target_rate: u32 = 0;
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
let built = build_source(
(*raw_bytes).clone(),
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
format_hint.as_deref(),
hi_res_enabled,
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
// Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(());
}
// In hi-res mode: if the next track's native rate differs from the current
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
// with a stream re-open. Store raw bytes to avoid re-downloading.
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
// chain proceeds and rodio resamples internally — no bail needed.
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
crate::app_eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
// Append to the existing Sink. The audio hardware stream never stalls.
// Note: `set_volume` is deliberately NOT called here (see comment above).
{
let cur = state.current.lock().unwrap();
match &cur.sink {
Some(sink) => {
sink.append(source);
}
None => return Ok(()), // playback stopped — bail
}
}
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
url,
analysis_track_id: logical_trim,
server_id: analysis_server_id.map(str::to_string),
raw_bytes,
duration_secs,
replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0),
source_done: done_next,
sample_counter: chain_counter,
});
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -1,191 +0,0 @@
//! Output device enumeration with suppressed ALSA stderr noise.
// `rodio::cpal` is referenced from the included body.
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
#[cfg(unix)]
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) {
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
}
}
let _guard = unsafe {
let saved = libc::dup(2);
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
f()
}
#[cfg(not(unix))]
#[inline]
pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
f()
}
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default()
})
}
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
/// busy devices are sometimes omitted from `output_devices()` while playback works.
#[cfg(target_os = "linux")]
pub(crate) fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
const IFACES: &[&str] = &[
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
"surround51", "surround71",
];
let colon = name.find(':')?;
let iface = name[..colon].to_ascii_lowercase();
if !IFACES.contains(&iface.as_str()) {
return None;
}
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
let dev = name
.split("DEV=")
.nth(1)
.and_then(|s| s.split(',').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
Some((iface, card, dev))
}
#[cfg(not(target_os = "linux"))]
#[inline]
pub(crate) fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
None
}
pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
if a == b {
return true;
}
match (
linux_alsa_sink_fingerprint(a),
linux_alsa_sink_fingerprint(b),
) {
(Some(fa), Some(fb)) => fa == fb,
_ => false,
}
}
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
available
.iter()
.any(|d| output_devices_logically_same(d, pinned))
}
#[cfg(test)]
mod tests {
use super::*;
// ── output_devices_logically_same ─────────────────────────────────────────
#[test]
fn logically_same_returns_true_for_identical_names() {
assert!(output_devices_logically_same("Generic Audio", "Generic Audio"));
}
#[test]
fn logically_same_returns_false_for_different_non_alsa_names() {
assert!(!output_devices_logically_same(
"Built-in Speakers",
"External DAC"
));
}
// ── output_enumeration_includes_pinned ────────────────────────────────────
#[test]
fn includes_pinned_finds_exact_match() {
let avail = vec!["A".to_string(), "B".to_string(), "C".to_string()];
assert!(output_enumeration_includes_pinned(&avail, "B"));
}
#[test]
fn includes_pinned_returns_false_when_absent() {
let avail = vec!["A".to_string(), "B".to_string()];
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
}
#[test]
fn includes_pinned_returns_false_for_empty_list() {
let avail: Vec<String> = vec![];
assert!(!output_enumeration_includes_pinned(&avail, "anything"));
}
// ── linux_alsa_sink_fingerprint (Linux-only path) ─────────────────────────
#[test]
#[cfg(target_os = "linux")]
fn alsa_fingerprint_extracts_iface_card_dev() {
let fp = linux_alsa_sink_fingerprint("hdmi:CARD=NVidia,DEV=3");
assert_eq!(fp, Some(("hdmi".to_string(), "NVidia".to_string(), 3)));
}
#[test]
#[cfg(target_os = "linux")]
fn alsa_fingerprint_defaults_dev_to_zero_when_missing() {
let fp = linux_alsa_sink_fingerprint("plughw:CARD=PCH");
assert_eq!(fp, Some(("plughw".to_string(), "PCH".to_string(), 0)));
}
#[test]
#[cfg(target_os = "linux")]
fn alsa_fingerprint_returns_none_for_unknown_iface() {
// "pulse" is not in the recognised ALSA-iface list — frontend-only sink.
assert!(linux_alsa_sink_fingerprint("pulse:something").is_none());
}
#[test]
#[cfg(target_os = "linux")]
fn alsa_fingerprint_returns_none_when_no_colon() {
assert!(linux_alsa_sink_fingerprint("Generic Audio").is_none());
}
#[test]
#[cfg(target_os = "linux")]
fn alsa_fingerprint_lowercases_iface_name() {
let fp = linux_alsa_sink_fingerprint("HDMI:CARD=card,DEV=0");
assert_eq!(fp.unwrap().0, "hdmi", "iface is normalised to lowercase");
}
#[test]
#[cfg(target_os = "linux")]
fn logically_same_treats_same_card_dev_as_match_across_alsa_ifaces() {
// Same physical sink can appear under "hw:CARD=X,DEV=0" and "plughw:CARD=X,DEV=0".
// The fingerprint comparison includes the iface, so these are NOT
// logically the same — clarifying the contract here.
assert!(!output_devices_logically_same(
"hw:CARD=X,DEV=0",
"plughw:CARD=X,DEV=0"
));
// But the SAME iface with the same card/dev is the same sink:
assert!(output_devices_logically_same(
"hw:CARD=X,DEV=0",
"hw:CARD=X,DEV=0"
));
}
// ── linux_alsa_sink_fingerprint stub on non-Linux ─────────────────────────
#[test]
#[cfg(not(target_os = "linux"))]
fn alsa_fingerprint_is_none_on_non_linux_for_any_input() {
assert!(linux_alsa_sink_fingerprint("hdmi:CARD=X,DEV=0").is_none());
assert!(linux_alsa_sink_fingerprint("anything").is_none());
}
}
@@ -1,102 +0,0 @@
//! Tauri commands for output-device listing + selection. Pulled out of
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
//! enumeration + reopen path.
use std::sync::atomic::Ordering;
use tauri::{Emitter, State};
use super::dev_io::{
enumerate_output_device_names, output_devices_logically_same,
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
};
use super::engine::AudioEngine;
/// When the saved `selected_device` no longer literally matches any listed
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
#[tauri::command]
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
let pinned = state.selected_device.lock().unwrap().clone()?;
if pinned.is_empty() {
return None;
}
let list = enumerate_output_device_names();
if list.iter().any(|d| d == &pinned) {
return None;
}
let canon = list
.iter()
.find(|d| output_devices_logically_same(d, &pinned))?
.clone();
*state.selected_device.lock().unwrap() = Some(canon.clone());
Some(canon)
}
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
let mut list = enumerate_output_device_names();
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
list.push(name.clone());
}
}
list
}
/// Returns the names of all available audio output devices on the current host.
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
///
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
audio_list_devices_for_engine(&state)
}
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
#[tauri::command]
pub fn audio_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
})
}
/// Switch the audio output device. `device_name = null` → follow system default.
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
#[tauri::command]
pub async fn audio_set_device(
device_name: Option<String>,
state: State<'_, AudioEngine>,
app: tauri::AppHandle,
) -> Result<(), String> {
*state.selected_device.lock().unwrap() = device_name.clone();
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
let open_rate = if rate > 0 {
rate
} else {
state.device_default_rate
};
super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
.map_err(|_| "device open timed out".to_string())?;
// Capture position and drop the active sink atomically so the position
// reading is still valid (play_started / paused_at intact before take).
let current_time = {
let mut cur = state.current.lock().unwrap();
let pos = cur.position();
if let Some(s) = cur.sink.take() { s.stop(); }
pos
};
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
// Emit the saved position so the frontend can use seekFallbackVisualTarget
// and resume from where the track was, rather than restarting from the beginning.
// null is reserved for "Rust already resumed internally" (see reopen_output_stream).
app.emit("audio:device-changed", current_time).map_err(|e| e.to_string())?;
Ok(())
}
@@ -1,284 +0,0 @@
//! Rust-side seamless replay after an output-device switch.
//!
//! `try_resume_after_device_change` is called from `reopen_output_stream`
//! (device_watcher.rs) after the new CPAL stream is ready and the old sink
//! has been stopped. It attempts to restart the current track on the new
//! device without any frontend round-trip.
//!
//! Supported source paths (in order of preference):
//! - `psysonic-local://` — opened directly from disk via `LocalFileSource`.
//! - HTTP, fully cached in RAM — replayed from `stream_completed_cache`.
//! - HTTP, spilled to disk — bytes read from `stream_completed_spill`.
//!
//! Falls back to the frontend (returns `false`) for:
//! - paused playback
//! - radio / live stream
//! - HTTP track whose download was only partial
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use rodio::Player;
use tauri::Emitter;
use tauri::Manager;
use super::engine::AudioEngine;
use super::play_input::{url_format_hint, PlayInput};
use super::source_build::{
build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource,
};
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
use super::progress_task::spawn_progress_task;
use super::stream::LocalFileSource;
/// Snapshot of playback state captured before the blocking stream reopen.
pub(crate) struct ResumeSnapshot {
pub(crate) url: Option<String>,
pub(crate) current_time_secs: f64,
pub(crate) duration_secs: f64,
pub(crate) base_volume: f32,
pub(crate) gain_linear: f32,
pub(crate) analysis_track_id: Option<String>,
pub(crate) is_playing: bool,
}
/// Try to replay the current track on the new device without involving the
/// frontend. Returns `true` if playback was successfully restarted.
///
/// Conditions that cause an immediate `false` (frontend fallback):
/// - Paused playback — user can press play on the new device via the cold path.
/// - Radio stream — live, non-seekable; frontend handles reconnect.
/// - No current URL — nothing was playing.
/// - HTTP track whose download was only partial (cache/spill absent) — frontend
/// re-fetches from the server via the seekFallbackVisualTarget path.
pub(crate) async fn try_resume_after_device_change(
app: &tauri::AppHandle,
snap: &ResumeSnapshot,
) -> bool {
// Only resume actively-playing (not paused) tracks.
if !snap.is_playing {
return false;
}
let url = match snap.url.as_deref() {
Some(u) if !u.is_empty() => u,
_ => return false,
};
let Some(engine) = app.try_state::<AudioEngine>() else {
return false;
};
// Skip radio — live streams don't have a resume position.
if engine.radio_state.lock().unwrap().is_some() {
return false;
}
// Build a PlayInput without re-downloading:
// - psysonic-local:// → seekable file
// - HTTP, fully cached → in-memory bytes (stream_completed_cache)
// - HTTP, spilled → bytes read from spill file
// - HTTP, partial → return false (frontend will re-fetch)
let play_input: PlayInput = if url.starts_with("psysonic-local://") {
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
match std::fs::File::open(path) {
Ok(file) => {
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
PlayInput::SeekableMedia {
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[device-resume]",
random_access: true,
mp4_probe_gate: None,
}
}
Err(e) => {
crate::app_eprintln!("[device-resume] cannot open local file: {e}");
return false;
}
}
} else {
// HTTP track — use completed in-memory cache or spill file.
// If the download was only partial, fall back to the frontend path
// which will re-fetch from the server.
let ram_bytes = {
let guard = engine.stream_completed_cache.lock().unwrap();
guard.as_ref().filter(|t| t.url == url).map(|t| t.data.clone())
};
let bytes = if let Some(b) = ram_bytes {
b
} else {
let spill_path = {
let guard = engine.stream_completed_spill.lock().unwrap();
guard.as_ref().filter(|s| s.url == url).map(|s| s.path.clone())
};
match spill_path {
Some(p) => match std::fs::read(&p) {
Ok(b) => b,
Err(e) => {
crate::app_eprintln!("[device-resume] spill read failed: {e}");
return false;
}
},
None => return false, // not fully cached yet — frontend will re-fetch
}
};
PlayInput::Bytes(bytes)
};
// Bump generation so the old progress task exits cleanly.
let gen = engine.generation.fetch_add(1, Ordering::SeqCst) + 1;
engine.stream_playback_armed.store(true, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
if engine.generation.load(Ordering::SeqCst) != gen {
return false; // raced with another audio_play
}
let format_hint = url_format_hint(url);
let stream_format_suffix: Option<String> = url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let done_flag = Arc::new(AtomicBool::new(false));
engine.samples_played.store(0, Ordering::Relaxed);
let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000;
// Resume re-plays the current track → scope its analysis writes to the
// pinned playback server (empty → legacy '').
let resume_server = crate::helpers::current_playback_server_id_str(&engine);
let ps: PlaybackSource = match build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url,
gen,
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
server_id: Some(resume_server.as_str()),
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur: std::time::Duration::from_millis(5),
hi_res_enabled,
duration_hint: snap.duration_secs,
},
&engine,
app,
)
.await
{
Ok(ps) => ps,
Err(e) => {
crate::app_eprintln!("[device-resume] source build failed: {e}");
return false;
}
};
if engine.generation.load(Ordering::SeqCst) != gen {
return false;
}
engine
.current_is_seekable
.store(ps.is_seekable, Ordering::SeqCst);
engine
.current_sample_rate
.store(ps.built.output_rate, Ordering::Relaxed);
engine
.current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed);
let stream = match super::engine::ensure_output_stream_open(&engine) {
Ok(s) => s,
Err(e) => {
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
return false;
}
};
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
swap_in_new_sink(
&engine,
SinkSwapInputs {
sink,
duration_secs: ps.built.duration_secs,
volume: snap.base_volume,
gain_linear: snap.gain_linear,
fadeout_trigger: ps.built.fadeout_trigger,
fadeout_samples: ps.built.fadeout_samples,
crossfade_enabled: false,
actual_fade_secs: 0.0,
outgoing_fade_secs: 0.0,
start_paused: false,
},
);
// Seek to the saved position for seekable sources (local files, ranged HTTP).
if ps.is_seekable && snap.current_time_secs > 0.5 {
let seek_sink = engine.current.lock().unwrap().sink.as_ref().map(Arc::clone);
if let Some(sk) = seek_sink {
let target = std::time::Duration::from_secs_f64(snap.current_time_secs.max(0.0));
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
std::thread::spawn(move || {
let _ = tx.send(sk.try_seek(target).map_err(|e| e.to_string()));
});
match rx.recv_timeout(std::time::Duration::from_millis(700)) {
Ok(Ok(())) => {
let mut cur = engine.current.lock().unwrap();
cur.seek_offset = snap.current_time_secs;
cur.play_started = Some(Instant::now());
engine.samples_played.store(
crate::playback_rate::raw_counter_samples_for_content_position(
snap.current_time_secs,
engine.current_sample_rate.load(Ordering::Relaxed),
engine.current_channels.load(Ordering::Relaxed),
&engine.playback_rate,
),
Ordering::Relaxed,
);
}
Ok(Err(e)) => {
crate::app_eprintln!("[device-resume] seek failed: {e}");
}
Err(_) => {
crate::app_eprintln!("[device-resume] seek timed out");
}
}
}
}
// Inform the frontend of the new duration (keeps seekbar range correct).
app.emit("audio:playing", ps.built.duration_secs).ok();
let analysis_app = app.clone();
spawn_progress_task(
gen,
engine.generation.clone(),
engine.current.clone(),
engine.chained_info.clone(),
engine.crossfade_enabled.clone(),
engine.crossfade_secs.clone(),
engine.autodj_suppress_autocrossfade.clone(),
done_flag,
app.clone(),
Some(analysis_app),
engine.samples_played.clone(),
engine.current_sample_rate.clone(),
engine.current_channels.clone(),
engine.gapless_switch_at.clone(),
engine.current_playback_url.clone(),
engine.stream_playback_armed.clone(),
engine.playback_rate.clone(),
);
crate::app_deprintln!(
"[device-resume] internal replay ok — url={url:?} resume_at={:.2}s seekable={}",
snap.current_time_secs,
ps.is_seekable
);
true
}
@@ -1,341 +0,0 @@
//! Poll default output device and pinned-device presence; reopen stream when needed.
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use tauri::Emitter;
use tauri::Manager;
use super::device_resume::{try_resume_after_device_change, ResumeSnapshot};
use super::engine::AudioEngine;
#[cfg(not(target_os = "linux"))]
use super::dev_io::output_enumeration_includes_pinned;
/// What to tell the frontend after a successful stream reopen.
pub(crate) enum ReopenNotify {
/// Normal path — same as `audio_set_device`.
DeviceChanged,
/// Pinned device unplugged (Windows/macOS only); Rust cleared the pin — clear Settings + restart playback.
#[cfg(not(target_os = "linux"))]
DeviceReset,
}
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
///
/// If the interrupted track is a seekable local file or a fully-cached HTTP download
/// (in-memory or spill file), the function replays it internally from the saved position —
/// no frontend round-trip, no audible restart. On success it emits
/// `audio:device-changed` / `audio:device-reset` with a `null` payload so the frontend
/// knows Rust already handled playback.
/// For radio, partially-buffered HTTP tracks, or paused playback, it falls back to the
/// previous behaviour: emit with the captured `current_time_secs` so the frontend calls
/// `playTrack`.
pub(crate) async fn reopen_output_stream(
app: &tauri::AppHandle,
device_name: Option<String>,
notify: ReopenNotify,
) -> bool {
let Some(engine) = app.try_state::<AudioEngine>() else {
return false;
};
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
// Snapshot state we need BEFORE the blocking stream reopen (while the old sink
// is still live and position() is still valid).
let snapshot = {
let cur = current.lock().unwrap();
let is_playing = cur.play_started.is_some() && cur.paused_at.is_none();
ResumeSnapshot {
url: engine.current_playback_url.lock().unwrap().clone(),
current_time_secs: cur.position(),
duration_secs: cur.duration_secs,
base_volume: cur.base_volume,
gain_linear: cur.replay_gain_linear,
analysis_track_id: engine.current_analysis_track_id.lock().unwrap().clone(),
is_playing,
}
};
let app_for_open = app.clone();
let device_name_for_open = device_name.clone();
let opened = tauri::async_runtime::spawn_blocking(move || {
let engine = app_for_open.state::<AudioEngine>();
super::engine::open_output_stream_blocking(
&engine,
open_rate,
false,
device_name_for_open,
)
.is_ok()
})
.await
.unwrap_or(false);
if !opened {
return false;
}
// When we're not actively playing (paused/stopped), bump the generation
// before stopping the old sink so the still-running progress task sees the
// mismatch and bails out instead of emitting a spurious `audio:ended` —
// which would otherwise trigger a frontend restart of paused playback
// (#1094). The active-playback path bumps inside
// `try_resume_after_device_change`, so only guard the non-playing case here.
if !snapshot.is_playing {
engine.generation.fetch_add(1, Ordering::SeqCst);
}
if let Some(s) = current.lock().unwrap().sink.take() {
s.stop();
}
if let Some(s) = fading_out.lock().unwrap().take() {
s.stop();
}
// Attempt a Rust-side internal replay (no frontend involvement).
// Falls back gracefully to the frontend path if conditions aren't met.
let resumed = try_resume_after_device_change(app, &snapshot).await;
match notify {
ReopenNotify::DeviceChanged => {
// null → Rust already resumed; frontend skips playTrack
// f64 → fallback; frontend calls playTrack + seek
if resumed {
app.emit("audio:device-changed", Option::<f64>::None).ok();
} else {
app.emit("audio:device-changed", snapshot.current_time_secs).ok();
}
}
#[cfg(not(target_os = "linux"))]
ReopenNotify::DeviceReset => {
if resumed {
app.emit("audio:device-reset", Option::<f64>::None).ok();
} else {
app.emit("audio:device-reset", snapshot.current_time_secs).ok();
}
}
}
true
}
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let selected_device = engine.selected_device.clone();
let samples_played = engine.samples_played.clone();
let current = engine.current.clone();
tauri::async_runtime::spawn(async move {
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
}).await.unwrap_or(None);
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
#[cfg(not(target_os = "linux"))]
let mut pinned_miss_count: u32 = 0;
// Fallback recovery when OS sleep/resume notifications are missed: if playback is
// "running" but sample counter is flat for too long, reopen output stream.
// To avoid false positives during normal playback, arm this watchdog only
// after a suspiciously long poll gap (e.g. process resumed after sleep).
let mut last_samples_seen: u64 = 0;
let mut stalled_since: Option<Instant> = None;
let mut last_stall_recover_at: Option<Instant> = None;
let mut last_poll_at = Instant::now();
let mut watchdog_armed_until: Option<Instant> = None;
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
let now = Instant::now();
let poll_gap = now.saturating_duration_since(last_poll_at);
last_poll_at = now;
if poll_gap >= Duration::from_secs(15) {
let armed_until = now + Duration::from_secs(120);
watchdog_armed_until = Some(armed_until);
crate::app_eprintln!(
"[psysonic] device-watcher: watchdog armed for 120s (poll gap {:?}, likely sleep/resume)",
poll_gap
);
}
let watchdog_armed = watchdog_armed_until.is_some_and(|until| now < until);
// ── Fallback stall detector (works even if sleep/resume signal was missed) ──
let mut should_recover_stall = false;
let mut stall_for = Duration::ZERO;
{
let samples_now = samples_played.load(Ordering::Relaxed);
let cur = current.lock().unwrap();
let active = cur
.sink
.as_ref()
.is_some_and(|s| !s.is_paused() && !s.empty());
if !watchdog_armed {
if stalled_since.take().is_some() {
crate::app_eprintln!(
"[psysonic] device-watcher: watchdog disarmed, clearing stall candidate"
);
}
last_samples_seen = samples_now;
} else if !active || samples_now != last_samples_seen {
if stalled_since.take().is_some() {
crate::app_eprintln!(
"[psysonic] device-watcher: stall candidate cleared (active={active}, samples_delta={})",
samples_now as i128 - last_samples_seen as i128
);
}
stalled_since = None;
last_samples_seen = samples_now;
} else {
let since = stalled_since.get_or_insert_with(Instant::now);
if since.elapsed() < Duration::from_millis(100) {
crate::app_eprintln!(
"[psysonic] device-watcher: stall candidate started (samples={}, active={active})",
samples_now
);
}
stall_for = since.elapsed();
let cooldown_ok = last_stall_recover_at
.map(|t| t.elapsed() >= Duration::from_secs(20))
.unwrap_or(true);
if stall_for >= Duration::from_secs(8) && cooldown_ok {
should_recover_stall = true;
}
}
}
if should_recover_stall {
let pinned = selected_device.lock().unwrap().clone();
let samples_now = samples_played.load(Ordering::Relaxed);
crate::app_eprintln!(
"[psysonic] device-watcher: output stalled for {:?} (samples={}) — reopening stream, pinned={:?}",
stall_for,
samples_now,
pinned
);
if reopen_output_stream(&app, pinned, ReopenNotify::DeviceChanged).await {
last_stall_recover_at = Some(Instant::now());
stalled_since = None;
last_samples_seen = samples_played.load(Ordering::Relaxed);
crate::app_eprintln!(
"[psysonic] device-watcher: stalled-output recovery succeeded"
);
} else {
crate::app_eprintln!(
"[psysonic] device-watcher: stalled-output reopen timed out"
);
}
}
// The full `output_devices()` + per-device `description()` scan is the
// CoreAudio HAL call that contends with the audio render thread and
// produces a brief dropout once per poll interval (issue #996: stutter
// every ~3s, cadence tracking the poll exactly). It is only needed to
// detect a *pinned* output device disappearing. With no pin — system
// default, the common case — only the current default is needed, a
// single cheap query, so the full enumeration is skipped entirely.
let pinned = selected_device.lock().unwrap().clone();
let need_full_enum = pinned.is_some();
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
#[cfg(unix)]
let _guard = unsafe {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
}
let saved = libc::dup(2);
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
let host = rodio::cpal::default_host();
let default = host
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let available: Vec<String> = if need_full_enum {
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
(default, available)
}).await.unwrap_or((None, vec![]));
// Empty list (only when we actually enumerated for a pinned device)
// almost always means a transient enumeration failure, not that every
// output device vanished. Treating it as "pinned missing" caused false
// audio:device-reset (UI jumped back to system default) when switching
// to external USB / class-compliant interfaces.
if need_full_enum && available.is_empty() {
continue;
}
#[cfg(target_os = "linux")]
if pinned.is_some() {
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
// whole session — any miss counter eventually tripped audio:device-reset.
// Clearing the pin is left to the user (Settings → System Default) or
// to a future explicit error signal from the output stream.
continue;
}
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
#[cfg(not(target_os = "linux"))]
if let Some(ref dev_name) = pinned {
if !output_enumeration_includes_pinned(&available, dev_name) {
pinned_miss_count += 1;
if pinned_miss_count < 3 {
continue;
}
crate::app_eprintln!("[psysonic] device-watcher: pinned device '{dev_name}' disconnected, falling back to system default");
pinned_miss_count = 0;
*selected_device.lock().unwrap() = None;
tokio::time::sleep(Duration::from_millis(500)).await;
let reopened = reopen_output_stream(&app, None, ReopenNotify::DeviceReset).await;
if !reopened {
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out (pinned disconnect)");
}
last_default = current_default;
} else {
pinned_miss_count = 0;
}
continue;
}
// ── Case 1: no pinned device, system default changed ──────────────
if current_default == last_default {
continue;
}
last_default = current_default.clone();
let Some(_new_name) = current_default else { continue };
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
}
}
});
}
@@ -1,653 +0,0 @@
//! `AudioEngine` / `AudioCurrent`, stream thread, and HTTP client refresh.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
/// Reply channel handed back to the audio-stream thread once an open finishes.
pub type StreamOpenReply =
std::sync::mpsc::SyncSender<(Arc<rodio::MixerDeviceSink>, u32)>;
/// Requests handled on the dedicated audio-stream thread (open / idle release).
pub enum StreamThreadMsg {
Open {
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
reply: StreamOpenReply,
},
Release {
reply: std::sync::mpsc::SyncSender<()>,
},
}
pub struct AudioEngine {
pub stream_handle: Arc<std::sync::Mutex<Option<Arc<rodio::MixerDeviceSink>>>>,
/// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>,
/// The rate the device was opened at on cold start — used to restore the
/// stream when Hi-Res is toggled off while a hi-res rate is active.
pub device_default_rate: u32,
/// Open or release the CPAL output stream on the audio-stream thread.
pub stream_thread_tx: std::sync::mpsc::SyncSender<StreamThreadMsg>,
/// User-selected output device name (None = follow system default).
pub selected_device: Arc<Mutex<Option<String>>>,
pub current: Arc<Mutex<AudioCurrent>>,
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
pub generation: Arc<AtomicU64>,
pub http_client: Arc<RwLock<reqwest::Client>>,
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.
pub(crate) stream_completed_cache: Arc<Mutex<Option<PreloadedTrack>>>,
/// On-disk spill for completed ranged streams above `TRACK_STREAM_PROMOTE_MAX_BYTES`.
pub(crate) stream_completed_spill: Arc<Mutex<Option<StreamCompletedSpill>>>,
/// True when the currently playing source supports seeking (in-memory bytes
/// or `RangedHttpSource`); false for the legacy non-seekable streaming
/// fallback (`AudioStreamReader`). `audio_seek` rejects with a "not
/// seekable" error when false so the frontend restart-fallback can engage.
pub(crate) current_is_seekable: Arc<AtomicBool>,
/// HTTP stream paths (`RangedHttpSource`, legacy `AudioStreamReader`): false
/// until `TRACK_STREAM_PLAY_START_BYTES` are buffered (or download ends).
/// Bytes / local file / radio keep true.
pub(crate) stream_playback_armed: Arc<AtomicBool>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
/// AutoDJ: when true, the progress task does NOT fire its autonomous
/// `crossfade_secs`-before-end `audio:ended` timer — the JS A-tail logic
/// drives every advance (gated on the next track being playable). Prevents
/// the engine from starting a still-buffering next track and fading over it
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
/// outgoing sink; block normalization/volume ramps until the handoff swap.
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
pub gapless_enabled: Arc<AtomicBool>,
/// 0=off, 1=replaygain, 2=loudness (future runtime loudness engine).
pub normalization_engine: Arc<AtomicU32>,
/// Target loudness in LUFS for loudness engine (future use).
pub normalization_target_lufs: Arc<AtomicU32>,
/// Extra attenuation (dB) when no loudness DB row exists at decode bind; also seeds streaming heuristics (Settings).
pub loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
/// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires.
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
/// Atomic sample counter — incremented by CountingSource in the audio thread.
/// Progress task reads this for drift-free position tracking.
pub samples_played: Arc<AtomicU64>,
/// Sample rate of the currently playing source (for samples → seconds).
pub current_sample_rate: Arc<AtomicU32>,
/// Channel count of the currently playing source.
pub current_channels: Arc<AtomicU32>,
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
pub gapless_switch_at: Arc<AtomicU64>,
/// Active radio session state. None for regular (non-radio) tracks.
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
pub(crate) radio_state: Mutex<Option<crate::stream::RadioLiveState>>,
/// URL last committed to `AudioCurrent` — used so `audio_update_replay_gain` can
/// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null`
/// (otherwise `compute_gain` would treat that as unity gain and playback "jumps").
pub(crate) current_playback_url: Arc<Mutex<Option<String>>>,
/// 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
/// decode (ALSA underruns). The ranged task clears this on exit; `gen` avoids a
/// late drop clearing a newer play of the same track.
pub(crate) ranged_loudness_seed_hold: Arc<Mutex<Option<(String, u64)>>>,
/// Secondary sink dedicated to track previews. Runs on the same `OutputStream`
/// as the main sink (rodio mixes both internally) so we don't open a second
/// device handle — important on ALSA-exclusive hardware.
pub(crate) preview_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// Cancel token for the active preview. Bumped on every `audio_preview_play`
/// and `audio_preview_stop` so that orphan timer/progress tasks bail out.
pub(crate) preview_gen: Arc<AtomicU64>,
/// True when `audio_preview_play` paused the main sink and should resume it
/// on preview end. False if the main sink was already paused (or empty).
pub(crate) preview_main_resume: Arc<AtomicBool>,
/// Subsonic song id of the currently playing preview. Echoed back in
/// `audio:preview-end` so the frontend can clear UI state for that row.
pub(crate) preview_song_id: Arc<Mutex<Option<String>>>,
}
pub struct AudioCurrent {
pub sink: Option<Arc<Player>>,
pub duration_secs: f64,
pub seek_offset: f64,
pub play_started: Option<Instant>,
pub paused_at: Option<f64>,
pub replay_gain_linear: f32,
pub base_volume: f32,
/// Crossfade: trigger for sample-level fade-out of the current source.
pub fadeout_trigger: Option<Arc<AtomicBool>>,
/// Crossfade: total fade samples (set before triggering).
pub fadeout_samples: Option<Arc<AtomicU64>>,
}
impl AudioCurrent {
pub fn position(&self) -> f64 {
if let Some(p) = self.paused_at {
return p;
}
if let Some(t) = self.play_started {
let elapsed = t.elapsed().as_secs_f64();
(self.seek_offset + elapsed).min(self.duration_secs.max(0.001))
} else {
self.seek_offset
}
}
}
/// Open an output device at `desired_rate` Hz (0 = device default).
///
/// `device_name`: exact name from `audio_list_devices`. `None` → system default.
/// Falls back to the system default if the named device is not found.
///
/// Resolution order:
/// 1. Exact rate match in the device's supported config ranges.
/// 2. Highest available rate (for hardware that doesn't support the source rate).
/// 3. Device default.
/// 4. System default (last resort).
///
/// Rodio prints a stderr line on every intentional stream drop. Keep that only
/// when runtime logging is in **debug** mode; normal/off silence the noise.
fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc<rodio::MixerDeviceSink> {
if !crate::logging::should_log_debug() {
handle.log_on_drop(false);
}
Arc::new(handle)
}
/// Returns `(stream_handle, actual_sample_rate)`.
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
// Suppress ALSA stderr noise while enumerating devices on Unix.
#[cfg(unix)]
let _guard = unsafe {
struct StderrGuard(i32);
impl Drop for StderrGuard {
fn drop(&mut self) { unsafe { libc::dup2(self.0, 2); libc::close(self.0); } }
}
let saved = libc::dup(2);
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
libc::dup2(devnull, 2);
libc::close(devnull);
StderrGuard(saved)
};
let host = rodio::cpal::default_host();
// Resolve the target device: explicit name first, then (on Linux) prefer
// a "pipewire" or "pulse" ALSA alias before falling back to cpal's system
// default. On PipeWire-based distros the raw ALSA `default` alias can
// route to a null sink at app-start (issue #234 on Debian 13): the stream
// opens cleanly, progress ticks run, no audio reaches the user. The
// named-alias path goes through pipewire-alsa's real sink and just works.
// On systems where neither alias exists (pure ALSA, macOS, Windows),
// `find_by_name` returns None and we drop through to `default_output_device`
// unchanged — no regression.
let find_by_name = |name: &str| -> Option<_> {
host.output_devices().ok()?.find(|d| {
d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(name)
})
};
let device = device_name
.and_then(find_by_name)
.or_else(|| {
#[cfg(target_os = "linux")]
{ find_by_name("pipewire").or_else(|| find_by_name("pulse")) }
#[cfg(not(target_os = "linux"))]
{ None }
})
.or_else(|| host.default_output_device());
if let Some(device) = device {
if desired_rate > 0 {
if let Ok(supported) = device.supported_output_configs() {
let configs: Vec<_> = supported.collect();
// 1. Exact rate match — prefer more channels (stereo > mono).
let exact = configs.iter()
.filter(|c| {
c.min_sample_rate() <= desired_rate
&& desired_rate <= c.max_sample_rate()
})
.max_by_key(|c| c.channels());
if exact.is_some() {
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
{
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
return (finalize_mixer_device_sink(handle), desired_rate);
}
}
// 2. No exact match — use the highest supported rate.
let best = configs.iter()
.max_by_key(|c| c.max_sample_rate());
if let Some(cfg) = best {
let rate = cfg.max_sample_rate();
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone())
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
{
crate::app_eprintln!(
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
rate, desired_rate
);
return (finalize_mixer_device_sink(handle), rate);
}
}
}
}
// 3. Device default.
if let Ok(handle) = rodio::DeviceSinkBuilder::from_device(device.clone()).and_then(|b| b.open_stream()) {
let rate = device
.default_output_config()
.map(|c| c.sample_rate())
.unwrap_or(44100);
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
return (finalize_mixer_device_sink(handle), rate);
}
}
// 4. Last resort: system default.
crate::app_eprintln!("[psysonic] audio stream falling back to system default");
let handle = rodio::DeviceSinkBuilder::open_default_sink()
.expect("cannot open any audio output device");
let rate = rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44100);
(finalize_mixer_device_sink(handle), rate)
}
fn probe_device_default_rate() -> u32 {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44_100)
}
/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`.
pub(crate) fn open_output_stream_blocking(
engine: &AudioEngine,
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
let rate = if desired_rate > 0 {
desired_rate
} else {
engine.device_default_rate
};
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Open {
desired_rate: rate,
is_hi_res,
device_name,
reply: reply_tx,
})
.map_err(|e| e.to_string())?;
let (handle, actual_rate) = reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream open timed out".to_string())?;
engine
.stream_sample_rate
.store(actual_rate, std::sync::atomic::Ordering::Relaxed);
*engine.stream_handle.lock().unwrap() = Some(handle.clone());
Ok(handle)
}
/// Ensure a live output stream exists; lazy-opens on first playback.
pub(crate) fn ensure_output_stream_open(
engine: &AudioEngine,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
if let Some(handle) = engine.stream_handle.lock().unwrap().clone() {
return Ok(handle);
}
let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed);
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let device = engine.selected_device.lock().unwrap().clone();
open_output_stream_blocking(engine, open_rate, false, device)
}
pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Release { reply: reply_tx })
.map_err(|e| e.to_string())?;
reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream release timed out".to_string())?;
Ok(())
}
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// macOS: request a smaller CoreAudio buffer to reduce output latency.
#[cfg(target_os = "macos")]
{
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
std::env::set_var("COREAUDIO_BUFFER_SIZE", "512");
}
}
// Channel: main thread ←→ audio-stream thread (lazy open + idle release).
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0);
let (stream_thread_tx, stream_thread_rx) =
std::sync::mpsc::sync_channel::<StreamThreadMsg>(4);
let device_default_rate = probe_device_default_rate();
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
.spawn(move || {
// Set PipeWire / PulseAudio latency hints before the first open.
#[cfg(target_os = "linux")]
{
// Match cpal ALSA ~200 ms headroom: larger quantum reduces underruns when
// the decoder thread catches up after seek or competes with other work.
if std::env::var("PIPEWIRE_LATENCY").is_err() {
std::env::set_var("PIPEWIRE_LATENCY", "8192/48000");
}
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
std::env::set_var("PULSE_LATENCY_MSEC", "170");
}
}
// Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
let mut _stream: Option<Arc<rodio::MixerDeviceSink>> = None;
ready_tx.send(()).ok();
while let Ok(msg) = stream_thread_rx.recv() {
match msg {
StreamThreadMsg::Release { reply } => {
_stream = None;
let _ = reply.send(());
}
StreamThreadMsg::Open {
desired_rate,
is_hi_res,
device_name,
reply,
} => {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max,
)
.ok();
}
_stream = None;
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
#[cfg(target_os = "linux")]
if desired_rate > 0 {
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
let latency_ms =
(frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, actual_rate) =
open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = Some(new_stream);
let _ = reply.send((new_handle, actual_rate));
}
}
}
})
.expect("spawn audio stream thread");
ready_rx.recv().expect("audio stream thread ready");
let engine = AudioEngine {
stream_handle: Arc::new(std::sync::Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate,
stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
duration_secs: 0.0,
seek_offset: 0.0,
play_started: None,
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: Arc::new(RwLock::new(
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_default(),
)),
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)),
current_is_seekable: Arc::new(AtomicBool::new(true)),
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())),
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new((-4.5f32).to_bits())),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(0)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
radio_state: Mutex::new(None),
current_playback_url: Arc::new(Mutex::new(None)),
current_analysis_track_id: Arc::new(Mutex::new(None)),
current_playback_server_id: Arc::new(Mutex::new(None)),
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
preview_sink: Arc::new(Mutex::new(None)),
preview_gen: Arc::new(AtomicU64::new(0)),
preview_main_resume: Arc::new(AtomicBool::new(false)),
preview_song_id: Arc::new(Mutex::new(None)),
};
(engine, thread)
}
/// `analysis_enqueue_seed_from_url` should bail while this track's ranged HTTP buffer
/// is still filling — playback will seed on completion with the same bytes.
pub fn ranged_loudness_backfill_should_defer(engine: &AudioEngine, track_id: &str) -> bool {
let tid = track_id.trim();
if tid.is_empty() {
return false;
}
let Ok(g) = engine.ranged_loudness_seed_hold.lock() else {
return false;
};
matches!(&*g, Some((t, _)) if t.as_str() == tid)
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
pub fn stop_audio_engine(app: &tauri::AppHandle) {
use std::sync::atomic::Ordering;
use tauri::Manager;
let engine = app.state::<AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
/// Subsonic id pinned for the playing source (`audio_play`). Used to prioritize
/// HTTP loudness backfill for the track the user is listening to.
pub fn analysis_track_id_is_current_playback(engine: &AudioEngine, track_id: &str) -> bool {
let needle = track_id.trim();
if needle.is_empty() {
return false;
}
let Ok(guard) = engine.current_analysis_track_id.lock() else {
return false;
};
let Some(cur) = guard.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
return false;
};
cur == needle
}
pub(crate) fn audio_http_client(state: &AudioEngine) -> reqwest::Client {
state
.http_client
.read()
.map(|c| c.clone())
.unwrap_or_default()
}
pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.use_rustls_tls()
.user_agent(ua)
.build()
.unwrap_or_default();
if let Ok(mut slot) = state.http_client.write() {
*slot = client;
}
}
pub(crate) fn apply_playback_request_headers(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, url, req);
}
if let Some(ctx) = reg.get_for_server_url(url) {
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
req
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
#[derive(Clone, Default)]
pub(crate) struct PlaybackHttpHeaders {
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
server_id: Option<String>,
}
impl PlaybackHttpHeaders {
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
Self {
registry: app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s)),
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
}
}
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
apply_playback_request_headers(
self.registry.as_deref(),
self.server_id.as_deref(),
url,
req,
)
}
}
pub(crate) fn scoped_http_get(
state: &AudioEngine,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
apply_playback_request_headers(
registry,
server_id,
url,
audio_http_client(state).get(url),
)
}
/// Resolve registry + server id for playback/preload HTTP GETs.
pub(crate) fn playback_scoped_get(
state: &AudioEngine,
app: &tauri::AppHandle,
url: &str,
server_id: Option<&str>,
) -> reqwest::RequestBuilder {
let registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let sid = server_id
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
scoped_http_get(
state,
registry.as_deref(),
sid.as_deref(),
url,
)
}

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