mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e6006408d | |||
| e770656a74 | |||
| 103c84895e | |||
| 75b56cdb41 | |||
| cdb22fba8e | |||
| 0ea783b395 |
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
// Architecture / layering guard for the feature-folder structure (PR #1225, group A1).
|
||||
//
|
||||
// Encodes the layering contract the restructure established:
|
||||
//
|
||||
// lib (feature-free infra: api clients, format, i18n, util, media, server, navigation)
|
||||
// ▲
|
||||
// store / ui (cross-cutting global stores; domain-agnostic primitives) — may import lib
|
||||
// ▲
|
||||
// cover / music-network (top-level domains — peers; may import lib, store, ui)
|
||||
// ▲
|
||||
// features/<x> (may import lib, store, ui, cover, music-network, other features via barrel only)
|
||||
// ▲
|
||||
// app (shell + bridges — may import anything)
|
||||
//
|
||||
// A lower layer may NOT import a higher one. Cross-feature access goes through the
|
||||
// `@/features/<x>` barrel only, never a deep path. No import cycles anywhere.
|
||||
//
|
||||
// Ratchet: current known violations (residual legacy dirs + documented inversions) are
|
||||
// captured in `.dependency-cruiser-known-violations.json` and ignored via `--ignore-known`
|
||||
// (see `npm run dep:check`). Any NEW violation fails CI. As the drain (group E) removes an
|
||||
// exception, regenerate the baseline so the count ratchets toward zero.
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
name: 'no-circular',
|
||||
severity: 'error',
|
||||
comment: 'No import cycles anywhere under src/ — they make the module graph impossible to reason about.',
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
{
|
||||
name: 'lib-is-the-floor',
|
||||
severity: 'error',
|
||||
comment:
|
||||
'lib/** is feature-free infra and must not import a higher layer ' +
|
||||
'(features, store, ui, app, cover, music-network).',
|
||||
from: { path: '^src/lib/' },
|
||||
to: { path: '^src/(features|store|ui|app|cover|music-network)/' },
|
||||
},
|
||||
{
|
||||
name: 'no-core-to-feature',
|
||||
severity: 'error',
|
||||
comment:
|
||||
'store/** and ui/** are cross-cutting core and must not import features or the app shell ' +
|
||||
'(the inversions the seams removed — keep them out).',
|
||||
from: { path: '^src/(store|ui)/' },
|
||||
to: { path: '^src/(features|app)/' },
|
||||
},
|
||||
{
|
||||
name: 'no-deep-cross-feature',
|
||||
severity: 'error',
|
||||
comment:
|
||||
'A feature must reach another feature only through its `@/features/<x>` barrel (index), ' +
|
||||
'never a deep path. Same-feature deep imports are fine.',
|
||||
from: { path: '^src/features/([^/]+)/' },
|
||||
to: {
|
||||
path: '^src/features/([^/]+)/[^/]+',
|
||||
pathNot: [
|
||||
'^src/features/$1/', // same feature — allowed
|
||||
'^src/features/[^/]+/index\\.(ts|tsx)$', // the barrel — allowed
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
options: {
|
||||
doNotFollow: { path: 'node_modules' },
|
||||
// Type-only edges are tolerated by the iron rule (erased at runtime), but the ratchet
|
||||
// records whatever exists today regardless of kind; new edges of any kind fail.
|
||||
tsPreCompilationDeps: true,
|
||||
tsConfig: { fileName: 'tsconfig.json' },
|
||||
enhancedResolveOptions: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
||||
mainFields: ['module', 'main', 'types', 'typings'],
|
||||
},
|
||||
// The layering contract is about the production module graph. Tests, test helpers,
|
||||
// ambient declarations and non-src trees are not part of it.
|
||||
exclude: {
|
||||
path: [
|
||||
'\\.test\\.(ts|tsx)$',
|
||||
'^src/test/',
|
||||
'\\.d\\.ts$',
|
||||
'^src/vite-env',
|
||||
],
|
||||
},
|
||||
reporterOptions: {
|
||||
text: { highlightFocused: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -13,29 +13,25 @@
|
||||
# OUTSIDE the gate until their tests grow. Add them as Phase 1–4 coverage
|
||||
# work lands.
|
||||
#
|
||||
# Paths follow the feature-folder layout (`src/features/**`, `src/lib/**`,
|
||||
# `src/cover/**`) after the frontend restructure; the files below moved out
|
||||
# of the old `src/utils/**` tree but their contents are unchanged.
|
||||
#
|
||||
# Deferred from the gate, with current coverage shown for reference:
|
||||
# - src/features/playback/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
|
||||
# - 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/lib/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
# - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
|
||||
# ── extracted helpers (already at or above threshold) ────────────────
|
||||
src/cover/coverArtRegisteredSizes.ts
|
||||
src/lib/server/serverDisplayName.ts
|
||||
src/lib/server/serverMagicString.ts
|
||||
src/lib/share/shareLink.ts
|
||||
src/lib/dom/dynamicColors.ts
|
||||
src/features/playback/utils/playback/resolvePlaybackUrl.ts
|
||||
src/lib/share/copyEntityShareLink.ts
|
||||
# ── 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/lib/util/shuffleArray.ts
|
||||
src/features/playback/utils/audio/resolveReplayGainDb.ts
|
||||
src/lib/media/songToTrack.ts
|
||||
src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts
|
||||
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
|
||||
@@ -45,4 +41,4 @@ src/app/bootstrap.ts
|
||||
src/app/MiniPlayerApp.tsx
|
||||
|
||||
# ── stores (added as their tests grew past the floor) ────────────────
|
||||
src/features/playback/store/previewStore.ts
|
||||
src/store/previewStore.ts
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
|
||||
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
|
||||
*/
|
||||
|
||||
const FRONTEND_PATH_RE =
|
||||
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.dependency-cruiser\.cjs$|\.dependency-cruiser-known-violations\.json$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
|
||||
|
||||
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
|
||||
|
||||
const FRONTEND_JOBS = [
|
||||
'vitest run',
|
||||
'tsc --noEmit',
|
||||
'vitest --coverage (baseline + hot-path file gate)',
|
||||
'eslint',
|
||||
'dependency-cruiser',
|
||||
];
|
||||
|
||||
const RUST_JOBS = [
|
||||
'cargo test --workspace',
|
||||
'cargo clippy --workspace',
|
||||
'cargo llvm-cov (baseline + hot-path file gate)',
|
||||
];
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
const TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||
|
||||
/**
|
||||
* GitHub API hiccups (5xx, secondary rate limits, dropped connections) must
|
||||
* not fail the gate — the answer is to poll again, not to go red while the
|
||||
* real jobs are green. 4xx config errors (401/404 …) still throw.
|
||||
*/
|
||||
export function isTransientApiError(err) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 0;
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function withTransientRetry(label, fn, core, attempts = 5, delayMs = POLL_MS) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err) || attempt >= attempts) {
|
||||
throw err;
|
||||
}
|
||||
// err.message can be a whole HTML error page — log only the status.
|
||||
core.info(
|
||||
`${label}: transient API error (status=${err?.status ?? 'network'}), retry ${attempt}/${attempts - 1} in ${delayMs / 1000}s`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pathTriggersFrontend(file) {
|
||||
return FRONTEND_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function pathTriggersRust(file) {
|
||||
return RUST_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function requiredJobNames(changedFiles) {
|
||||
const names = [];
|
||||
if (changedFiles.some(pathTriggersFrontend)) {
|
||||
names.push(...FRONTEND_JOBS);
|
||||
}
|
||||
if (changedFiles.some(pathTriggersRust)) {
|
||||
names.push(...RUST_JOBS);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function newestChecksByName(checks, excludeRunId) {
|
||||
const newest = new Map();
|
||||
for (const check of checks) {
|
||||
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
|
||||
continue;
|
||||
}
|
||||
const key = check.name;
|
||||
const prev = newest.get(key);
|
||||
if (!prev) {
|
||||
newest.set(key, check);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
|
||||
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newest.set(key, check);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function evaluateRequiredJobs(required, newestByName) {
|
||||
const pending = [];
|
||||
const failures = [];
|
||||
|
||||
for (const name of required) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
pending.push(`${name}: not started`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== 'completed') {
|
||||
pending.push(`${name}: status=${latest.status}`);
|
||||
continue;
|
||||
}
|
||||
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
|
||||
failures.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
|
||||
}
|
||||
|
||||
export async function listChangedFiles(github, context) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
if (context.eventName === 'push') {
|
||||
const before = context.payload.before;
|
||||
const after = context.sha;
|
||||
if (!before || /^0+$/.test(before)) {
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
const compare = await github.rest.repos.compareCommits({
|
||||
owner,
|
||||
repo,
|
||||
base: before,
|
||||
head: after,
|
||||
});
|
||||
return compare.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (pr?.number) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function resolveTargetSha(context) {
|
||||
if (context.eventName === 'pull_request') {
|
||||
return context.payload.pull_request.head.sha;
|
||||
}
|
||||
if (context.eventName === 'workflow_run') {
|
||||
return context.payload.workflow_run.head_sha;
|
||||
}
|
||||
return context.sha;
|
||||
}
|
||||
|
||||
export async function runCiOkAggregate(github, context, core) {
|
||||
const { owner, repo } = context.repo;
|
||||
const sha = resolveTargetSha(context);
|
||||
const excludeRunId = String(context.runId);
|
||||
const changedFiles = await withTransientRetry(
|
||||
'listChangedFiles',
|
||||
() => listChangedFiles(github, context),
|
||||
core,
|
||||
);
|
||||
const required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
if (required.length === 0) {
|
||||
core.info('No path-filtered test workflows apply — ci-ok passes.');
|
||||
return;
|
||||
}
|
||||
core.info(`Waiting for required job checks: ${required.join(', ')}`);
|
||||
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
let checksAll;
|
||||
try {
|
||||
checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Same as an inconclusive poll: wait out the hiccup, the 90-minute
|
||||
// deadline stays the backstop.
|
||||
core.info(`checks.listForRef: transient API error (status=${err?.status ?? 'network'}) — retrying next poll`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
continue;
|
||||
}
|
||||
const newestByName = newestChecksByName(checksAll, excludeRunId);
|
||||
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
core.info('All required checks are green.');
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
}
|
||||
|
||||
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
isTransientApiError,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
withTransientRetry,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
|
||||
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
|
||||
assert.equal(pathTriggersFrontend('README.md'), false);
|
||||
});
|
||||
|
||||
test('pathTriggersRust matches rust workflow paths', () => {
|
||||
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
|
||||
assert.equal(pathTriggersRust('src/App.tsx'), false);
|
||||
});
|
||||
|
||||
test('requiredJobNames unions frontend and rust jobs', () => {
|
||||
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
|
||||
assert.ok(names.includes('eslint'));
|
||||
assert.ok(names.includes('cargo test --workspace'));
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs fails on red conclusions', () => {
|
||||
const newest = newestChecksByName([
|
||||
{
|
||||
name: 'eslint',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
},
|
||||
]);
|
||||
const result = evaluateRequiredJobs(['eslint'], newest);
|
||||
assert.equal(result.done, false);
|
||||
assert.equal(result.failures.length, 1);
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
const checks = ['eslint', 'vitest run'].map((name) => ({
|
||||
name,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
}));
|
||||
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
|
||||
assert.equal(result.done, true);
|
||||
});
|
||||
|
||||
test('isTransientApiError treats 5xx, 429 and network errors as transient', () => {
|
||||
assert.equal(isTransientApiError({ status: 503 }), true);
|
||||
assert.equal(isTransientApiError({ status: 500 }), true);
|
||||
assert.equal(isTransientApiError({ status: 429 }), true);
|
||||
assert.equal(isTransientApiError(new Error('socket hang up')), true);
|
||||
assert.equal(isTransientApiError({ status: 404 }), false);
|
||||
assert.equal(isTransientApiError({ status: 401 }), false);
|
||||
});
|
||||
|
||||
const silentCore = { info: () => {} };
|
||||
|
||||
test('withTransientRetry retries transient errors and returns the late success', async () => {
|
||||
let calls = 0;
|
||||
const result = await withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('withTransientRetry rethrows non-transient errors immediately', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
),
|
||||
/not found/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('withTransientRetry gives up after the attempt budget', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
3,
|
||||
1,
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
@@ -1,30 +1,15 @@
|
||||
name: ci-main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: [frontend-tests, rust-tests, eslint]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
ci-ok:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
|
||||
- name: aggregate required checks
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
|
||||
await runCiOkAggregate(github, context, core);
|
||||
- name: ci sentinel
|
||||
run: echo "main CI sentinel is green"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
name: eslint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.dependency-cruiser.cjs'
|
||||
- '.dependency-cruiser-known-violations.json'
|
||||
- '.github/workflows/eslint.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'
|
||||
- 'eslint.config.mjs'
|
||||
- '.dependency-cruiser.cjs'
|
||||
- '.dependency-cruiser-known-violations.json'
|
||||
- '.github/workflows/eslint.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:
|
||||
eslint:
|
||||
name: eslint
|
||||
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: eslint
|
||||
run: npm run lint
|
||||
|
||||
dependency-cruiser:
|
||||
name: dependency-cruiser
|
||||
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: layering + cycle guard
|
||||
run: npm run dep:check
|
||||
@@ -10,7 +10,6 @@ on:
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
@@ -24,7 +23,6 @@ on:
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Derive winget version from app-v* tag
|
||||
id: ver
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Submit to WinGet Community Repository
|
||||
uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Psychotoxical.Psysonic
|
||||
version: ${{ steps.ver.outputs.value }}
|
||||
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
+2
-6
@@ -37,12 +37,8 @@ src-tauri/lcov.info
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs).
|
||||
# Ignore the contents (not the dir) so the committed tauri-specta FE↔BE contract
|
||||
# snapshot below can be re-included — a `!` exception cannot escape an ignored dir.
|
||||
src/generated/*
|
||||
# ...except the committed tauri-specta contract snapshot, so CI diffs catch drift.
|
||||
!src/generated/bindings.ts
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
|
||||
src/generated/
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
+40
-535
@@ -9,362 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
>
|
||||
|
||||
|
||||
## [1.50.0]
|
||||
|
||||
## Added
|
||||
|
||||
### Square corners — sharp-edged cards and covers
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1215](https://github.com/Psychotoxical/psysonic/pull/1215)**
|
||||
|
||||
* New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** overrides the active theme to render cards and cover art with square, non-rounded corners. Covers album, playlist, artist and song cards, detail-page cover art, the Now Playing / Radio and fullscreen views, the cover lightbox, the queue cover, and the mini player. Off by default; buttons, inputs and dialogs keep the theme's corners.
|
||||
|
||||
### Discord community banner
|
||||
|
||||
**By [@ImAsra](https://github.com/ImAsra), PR [#1222](https://github.com/Psychotoxical/psysonic/pull/1222)**
|
||||
|
||||
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently. The icon renders at a consistent size on every platform, including Windows.
|
||||
|
||||
### Bulgarian translation
|
||||
|
||||
**By [@akirichev](https://github.com/akirichev), PR [#1228](https://github.com/Psychotoxical/psysonic/pull/1228)**
|
||||
|
||||
* Full Bulgarian (Български) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
### Artists browse — album vs track credit mode
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1232](https://github.com/Psychotoxical/psysonic/pull/1232)**
|
||||
|
||||
* Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes performers from the local artist index (featured/guest credits). Star filter works in both modes; the choice persists across app restarts like **Show artist images**.
|
||||
* Letter bucket filter (`A`–`Z`, `#`, `OTHER`) runs in local SQL instead of scanning catalog chunks client-side, so late-alphabet picks load promptly on large libraries.
|
||||
* Artist name search no longer depends on query letter case for Cyrillic (and other non-ASCII) names when the local library index is enabled.
|
||||
|
||||
### CLI — relative volume and quieter scripting output
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1238](https://github.com/Psychotoxical/psysonic/pull/1238)**
|
||||
|
||||
* `psysonic --player volume +5` / `volume -10` adjust the current level by that many percent; `volume 80` still sets an absolute level (use `-q` before `--player` when the delta is negative so it is not parsed as a flag).
|
||||
* CLI invocations no longer print WebKit/NVIDIA workaround notes on stderr; on Linux, remote `--player` forwarding runs before WebKit startup so helper processes exit with less noise.
|
||||
|
||||
### Theme Store — per-theme changelogs and pinned updates
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1240](https://github.com/Psychotoxical/psysonic/pull/1240)**
|
||||
|
||||
* Each theme card now has an expandable **What's new** with per-version release notes, so you can see what a theme update changed — including non-visual fixes. Provided by theme authors; themes without notes just don't show the section.
|
||||
* Installed themes with an available update now appear at the top of the store list instead of wherever the sort placed them, so you don't have to hunt for them.
|
||||
|
||||
### Multi-library filter — browse and search across selected libraries
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1241](https://github.com/Psychotoxical/psysonic/pull/1241)**
|
||||
|
||||
* The sidebar library picker now supports **multi-select with priority ordering**: browse, search, genre and album/artist detail views aggregate across the chosen libraries and de-duplicate shared items by priority. Built for large libraries — scoped SQL uses the hot `library_id` column with covering indexes and FTS-first matching.
|
||||
* Identity matching that powers cross-library de-duplication now normalises names per shipped locale (folds German ß, Norwegian æ, French œ, Romanian ș/ț, and Cyrillic ё/й); CJK titles are matched as-is.
|
||||
* The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
|
||||
|
||||
### Theme contributors credited in Settings
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1248](https://github.com/Psychotoxical/psysonic/pull/1248)**
|
||||
|
||||
* **Settings → System → Contributors** now lists community theme authors in a **Themes** sub-section alongside the **App** contributors, pulled from the theme store so it stays current as new themes are published.
|
||||
* The theme card **What's new** now shows just the latest version's notes instead of the full version history.
|
||||
* Theme author names refresh quietly from the store in the background instead of staying stale for up to 12 hours.
|
||||
|
||||
### Fullscreen player — Minimal and Immersive styles
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1249](https://github.com/Psychotoxical/psysonic/pull/1249)**
|
||||
|
||||
* **Settings → Appearance → Fullscreen player style** lets you choose **Minimal** (the current view) or **Immersive** — the earlier fullscreen player, with the artist photo/backdrop, a cover-derived accent colour, and rail or Apple-style scrolling lyrics.
|
||||
* In Immersive, **Show artist photo** and **Photo dimming** are configurable; Apple-style lyrics show the artist image as a dimmed full-screen backdrop.
|
||||
|
||||
### Italian translation
|
||||
|
||||
**By [@daquino94](https://github.com/daquino94), PR [#1250](https://github.com/Psychotoxical/psysonic/pull/1250)**
|
||||
|
||||
* Full Italian (Italiano) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
### Fullscreen player — Prism style
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1251](https://github.com/Psychotoxical/psysonic/pull/1251)**
|
||||
|
||||
* A third **Fullscreen player style**, **Prism** — a full-bleed artist backdrop with a floating glass lyrics panel on the right and a single glass control bar at the bottom (transport, a centred now-playing pill with an integrated progress line, and utilities). The cover-derived accent colour drives the progress fill and the active lyric line, and upcoming lyric lines fade out with a progressive blur.
|
||||
|
||||
### Lyrics — word-by-word highlighting straight from your server
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1265](https://github.com/Psychotoxical/psysonic/pull/1265)**
|
||||
|
||||
* The **Server** lyrics source now highlights lyrics word by word, so karaoke sync no longer depends on the third-party YouLyPlus backend. Requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC); anything else keeps highlighting line by line.
|
||||
* **Settings → Lyrics → Lyrics Sources** spells out those requirements, and the block now follows the standard settings sub-card layout.
|
||||
* Embedded Enhanced LRC no longer prints raw word timing codes (`<00:12.34>`) in the lyric text — those codes drive word-by-word highlighting instead.
|
||||
* FLAC, Ogg Vorbis, Opus and Speex files that store synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again, with that tag taking priority over the plain `LYRICS` tag.
|
||||
|
||||
### Start minimized to tray
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1271](https://github.com/Psychotoxical/psysonic/pull/1271)**
|
||||
|
||||
* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon.
|
||||
* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately.
|
||||
* Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling WMs such as Hyprland — instead of leaving the sidebar or Mainstage invisible or blank until a restart.
|
||||
|
||||
### Navidrome public share links — open and play without logging in
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1275](https://github.com/Psychotoxical/psysonic/pull/1275)**
|
||||
|
||||
* Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list in a modal, then play the full queue with no server account — direct stream and cover URLs are resolved anonymously from the share page.
|
||||
* Share playback uses a dedicated scope so an idle server play-queue pull cannot replace the share queue while you are also logged into Navidrome. Share sessions are not restored after an app restart — the server play queue applies as usual.
|
||||
* While a share queue is active, **Save Playlist** is hidden in the queue toolbar (share tracks cannot be saved to the server); **Load Playlist** stays available. The queue **Share** button copies the original Navidrome `/share/{id}` page URL.
|
||||
|
||||
### Track lists — optional album cover thumbnails
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1280](https://github.com/Psychotoxical/psysonic/pull/1280)**
|
||||
|
||||
* Browse and queue track rows can show the track's **album** cover (per-disc art when the album has distinct disc covers). Covers load through the standard cover cache pipeline — library resolve, viewport ensure, Rust resize to disk tiers — not a separate warm path.
|
||||
* **Settings → Appearance** adds separate toggles for queue vs browse tracklists. Favorites, playlist, and album-detail track grids gain a flex-resize handle on the title column when covers are shown.
|
||||
* Album detail pages skip per-row cover thumbs when the album art is already shown above the list — no duplicate image on every line.
|
||||
|
||||
### Discord — server cover art source, without the credential leak
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1299](https://github.com/Psychotoxical/psysonic/pull/1299)**
|
||||
|
||||
* **Settings → Integrations → Discord → Cover art source** gets a **Server** option, alongside **None** and **Apple Music**. It resolves artwork through the standard Subsonic `getAlbumInfo2` endpoint's public image link — never an authenticated cover URL that could expose your login credentials (reported by lavioso on Discord). Needs a publicly reachable server; anyone viewing your Discord profile can see that server's public address, but nothing else.
|
||||
|
||||
### Playlist cards — play and queue from the right-click menu
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1307](https://github.com/Psychotoxical/psysonic/pull/1307)**
|
||||
|
||||
* Right-clicking a playlist card now offers **Play next** and **Add to queue** alongside **Play now**, matching the album card. All three honour offline mode and the active multi-library filter.
|
||||
|
||||
### Playlists browse — scoped header search
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1308](https://github.com/Psychotoxical/psysonic/pull/1308)**
|
||||
|
||||
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
|
||||
|
||||
### Artist page — add the whole discography to the queue
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
|
||||
|
||||
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Frontend restructure — feature-folder architecture and hardening
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), with additional architecture by [@cucadmuh](https://github.com/cucadmuh), PR [#1225](https://github.com/Psychotoxical/psysonic/pull/1225)**
|
||||
|
||||
* Reorganised the frontend into a feature-folder architecture with a CI-enforced layering guard, added unit + behavior-scenario + boot-smoke test coverage, and introduced a compile-time frontend/backend IPC contract via tauri-specta. Internal only — no change to how the app looks or behaves.
|
||||
|
||||
### Typed-IPC contract — completed the tauri-specta cutover
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), with additional architecture by [@cucadmuh](https://github.com/cucadmuh), PR [#1230](https://github.com/Psychotoxical/psysonic/pull/1230)**
|
||||
|
||||
* Completed the frontend/backend typed-IPC contract: the frontend now calls the generated tauri-specta command surface, with CI guards keeping the bindings fresh and every command registered in the handler. Internal only — no change to how the app looks or behaves.
|
||||
|
||||
### Equalizer — per-device profiles follow the active system default
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1274](https://github.com/Psychotoxical/psysonic/pull/1274)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
|
||||
|
||||
* With **Remember EQ per device** enabled and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes externally (Windows sound settings, Stream Deck, etc.), instead of using one shared profile for all system-default outputs.
|
||||
* On Linux/PipeWire, the active default is resolved from WirePlumber (`wpctl`) first — including Hyprpanel, pavucontrol, and `wpctl set-default` — not cpal, which can keep a stale card name even after the default sink changes. When PipeWire has already moved the playback stream to the new default, the device watcher skips a redundant stream reopen (avoids a post-switch stutter).
|
||||
* **Windows:** release builds no longer freeze on the loading splash; audio output devices on Windows and macOS use stable backend IDs with clearer labels, duplicate friendly names are disambiguated, device-change detection works again, and legacy pinned device / per-device EQ keys stored as plain names are matched after upgrade.
|
||||
|
||||
### Player bar — build your own
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1287](https://github.com/Psychotoxical/psysonic/pull/1287)**
|
||||
|
||||
* **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). The right-hand buttons — star rating, favorite, love, playback speed, equalizer, mini player — can be **dragged into any order** you like.
|
||||
* The section is no longer behind **Advanced**.
|
||||
|
||||
### Shuffle
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1288](https://github.com/Psychotoxical/psysonic/pull/1288)**
|
||||
|
||||
* A **shuffle toggle** in the player bar, next to the transport controls. While on, the queue is shuffled from the current track onwards — the playing track stays put — and turning it off restores the original order. It survives a restart, and the shuffled order is what your other devices and Orbit guests see, so playback stays in step everywhere. Hide the button under **Settings → Personalisation → Player bar** if you don't want it.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Per-track covers when playing from a playlist
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1218](https://github.com/Psychotoxical/psysonic/pull/1218)**, reported by The Cup Slammer on Discord
|
||||
|
||||
* Playing a song from a playlist could show the song's own cover art in Now Playing instead of its album cover, while playing the same song from its album page showed the album cover. Now Playing now consistently uses the album cover, matching album-page playback. Albums with genuine per-disc artwork are unaffected.
|
||||
|
||||
### Playback — ReplayGain prefetch, gapless playbar sync, and library peak index
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1231](https://github.com/Psychotoxical/psysonic/pull/1231)**
|
||||
|
||||
* ReplayGain applies when stream or queue metadata resolves late — index-first prefetch before bind, reactive sync when resolver tags land, and live refresh from the library index after sync when tags differ on the playing track.
|
||||
* Gapless auto-advance no longer leaves the playbar on the previous track; missed `audio:track_switched` is reconciled from engine position with seek guards so backward seek is not treated as a gapless switch.
|
||||
* Local library index stores `replayGainPeak` (migration 015) so anti-clipping peak is available on index-first paths without an extra network round-trip.
|
||||
|
||||
### Connection — ignore spurious offline hint on desktop
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by mikmik on the Psysonic Discord, PR [#1234](https://github.com/Psychotoxical/psysonic/pull/1234)**
|
||||
|
||||
* Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` stuck at `false` while the server is actually reachable — the app now confirms with a real server probe instead of trusting that hint, so browse and playback keep working. Web builds are unchanged.
|
||||
* Pending favorite/rating sync now flushes when the server actually becomes reachable again, rather than relying on a browser `online` event that may never fire on desktop.
|
||||
|
||||
### Playlists — add more than ~341 tracks; faster large-playlist edits
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1235](https://github.com/Psychotoxical/psysonic/pull/1235)**
|
||||
|
||||
* Adding tracks to a playlist no longer fails past ~341 songs — writes are sent to the server in batches instead of one oversized request, so playlists of any size build correctly.
|
||||
* Adding and merging into large playlists is faster: playlist membership is cached in memory for de-duplication instead of re-fetching the whole playlist on every add.
|
||||
|
||||
### Queue — rows no longer stuck showing "…"
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1236](https://github.com/Psychotoxical/psysonic/pull/1236)**
|
||||
|
||||
* Queue rows that were far from the currently playing track (e.g. after starting a large playlist from the middle, or scrolling the queue) no longer stay stuck on a "…" placeholder — the queue now loads track details for whatever you scroll to, in the desktop queue panel, the mobile queue drawer, and the fullscreen "up next" overlay.
|
||||
|
||||
### Offline browse — on-disk-only Artists, Albums, Tracks, and Genres
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1243](https://github.com/Psychotoxical/psysonic/pull/1243)**
|
||||
|
||||
* When browsing offline, Artists, Albums, Tracks, and Genres now list only content with on-disk bytes — library pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
|
||||
* Sidebar and shell gates react when hot-cache rows appear; browse pages reload after hot-cache growth and library sync without leaving the page.
|
||||
* Album vs track artist credit mode, starred artists, genre filters, and Tracks discovery rails respect the on-disk scope; album artist grouping follows indexed `album_artist` parity.
|
||||
|
||||
### All Albums — year filter keyboard entry
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1244](https://github.com/Psychotoxical/psysonic/pull/1244)**
|
||||
|
||||
* The year filter on All Albums no longer clamps on every keystroke while typing a four-digit year — drafts commit on blur, Enter, or outside click; incomplete input reverts to the last applied value. Wheel and spinner controls are unchanged.
|
||||
|
||||
### Album detail — favorite heart and album-level stars
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1247](https://github.com/Psychotoxical/psysonic/pull/1247)**
|
||||
|
||||
* Starring an album on the detail page now fills the heart immediately and keeps it filled after reload or returning from Favorites — the local index stores album favorites in `album.starred_at` instead of inferring from track stars.
|
||||
* When only a track is starred, the album heart stays empty unless the album itself is in Favorites; unfavorite no longer requires a double click on the detail page.
|
||||
* Album user rating on detail reconciles from the server in the background; multi-library browse and Favorites filters use album-level stars consistently.
|
||||
|
||||
### Library — renamed artists no longer linger as ghosts after resync
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by Seraphim on the Psysonic Discord, PR [#1253](https://github.com/Psychotoxical/psysonic/pull/1253)**
|
||||
|
||||
* Renaming an artist on the server no longer leaves a stale entry in the local Artists list that opened to "Artist not found" — a sync now prunes artist rows the latest server listing no longer confirms that also have no remaining tracks. Cleanup runs on both full and delta syncs (only after a confirmed artist listing, so a transient empty response can't drop valid entries), plus a one-time pass at startup that clears ghosts already accumulated in existing libraries.
|
||||
* Newly added or renamed entries now show up right after a resync instead of only after an app restart: the Artists and Albums pages refresh their cached catalog when a library sync finishes.
|
||||
|
||||
### Library — album artist links no longer dead-end at "Artist not found"
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by tummydummy, PR [#1254](https://github.com/Psychotoxical/psysonic/pull/1254)**
|
||||
|
||||
* Clicking the artist beneath an album (most visibly in **Random Albums**) no longer shows "Artist not found" when the server's `getArtist` doesn't recognise that album-artist id — the artist page now falls back to the local library index, which shares the id the card was built from. Artist pages also stay reachable on a brief network hiccup when the library is indexed.
|
||||
|
||||
### Library — album tiles no longer miss cover art in Random Albums
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by tummydummy, PR [#1254](https://github.com/Psychotoxical/psysonic/pull/1254)**
|
||||
|
||||
* Album tiles for rows that synced without a cover id (surfacing most in **Random Albums**) no longer show a blank cover while the detail page has one — local browse now falls back to the album's first track cover id, so tile and detail resolve the same artwork. The same fallback applies to the detail header's library cover resolution, so the two stay consistent instead of flickering between art and placeholder.
|
||||
|
||||
### Library — multi-library dedup sidecar no longer accumulates dead identity keys
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1255](https://github.com/Psychotoxical/psysonic/pull/1255)**
|
||||
|
||||
* The precomputed `library-cluster.db` identity keys used for cross-library dedup are now pruned on rebuild when their track no longer exists (removed, or dropped when a server mints a fresh id on rename). Previously the rebuild only refreshed live tracks and never deleted stale rows, so the sidecar grew with library churn until it was recreated wholesale (server switch / restore / import). The rows were inert (reads only ever join live tracks), so dedup and browse results are unchanged — this just stops the sidecar from bloating.
|
||||
|
||||
### Library — renamed album artist links now heal on resync
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1256](https://github.com/Psychotoxical/psysonic/pull/1256)**
|
||||
|
||||
* When an artist is renamed on the server (minting a new artist id), the album's stored artist link no longer stays stuck on the old id and dead-ending at "Artist not found". Album metadata now follows the server's `getAlbum` for the artist reference, so a resync updates it instead of keeping the pre-rename id indefinitely. Complements the earlier ghost-row prune (#1253) and the local-index fallback (#1254), which did not clear the stale reference itself.
|
||||
|
||||
### Sync — large play queues no longer revert after pausing
|
||||
|
||||
**By [@norperz](https://github.com/norperz), PR [#1262](https://github.com/Psychotoxical/psysonic/pull/1262)**
|
||||
|
||||
* Pausing a large queue behind a reverse proxy (e.g. Nginx) could snap the player back to an earlier track — the save was one long URL that hit the HTTP 414 limit, failed silently, and idle auto-pull restored the stale server queue.
|
||||
* Servers advertising the OpenSubsonic `formPost` extension (Navidrome) now save via POST; others retry once as POST on 414. A failed save no longer lets auto-pull overwrite playback — it resumes only after a successful save.
|
||||
|
||||
### Servers — connecting to servers behind a header gate
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1273](https://github.com/Psychotoxical/psysonic/pull/1273)**
|
||||
|
||||
* Adding a server that needs a custom HTTP header (Cloudflare Access, Pangolin service tokens) failed with "Connection failed" even though streaming and covers would have worked. Root cause: the connection test ran in the WebView, which sends a header-less CORS preflight the gate rejects before the real request. The test now runs natively for header-carrying servers, so the header rides on the request and the server connects.
|
||||
* A failed "Add server" used to close the form and leave only a tiny status dot, so it was unclear what went wrong. The form now stays open on failure and shows the reason — the server's own message (e.g. "Wrong username or password"), an HTTP status like `HTTP 403 Forbidden`, or a transport error — and empty server address / username are caught up front with a clear message.
|
||||
* Even after a gated server connected, browse and detail views that run in the WebView — Main stage, New Releases, Random Albums, Statistics, search, genres, and more — stayed empty, because those `axios` requests still tripped the gate's CORS preflight. Every Subsonic REST call that would carry a gate header now runs natively (the same reqwest path streaming and covers use), so the whole app works behind a header gate, not just playback.
|
||||
* Playback itself and background prefetch still returned `403` on a gated server: the audio/preload path attached the gate header only when its playback server id happened to match the header registry key, and silently sent nothing otherwise. Header lookup now falls back to matching the request URL against the server's own endpoints (the same fallback covers and Navidrome browse already used), so streaming, prefetch and artist-info fetches carry the header reliably. Endpoint matching only ever hits a configured gated server and still honours the *apply to LAN/public* rule, so non-gated servers are untouched.
|
||||
* Under the extra native-proxy traffic a gated server now generates, browse calls opened a brand-new connection pool per request, which starved connections and made fast endpoints time out. Proxied requests now reuse a pooled HTTP client, keeping keep-alive across the burst of browse calls.
|
||||
* On app startup the per-server gate headers were registered with the native layer only after a successful reachability probe and bind — so if that probe was slow or the server looked briefly offline, the registry stayed empty and every native request (streaming, covers, prefetch, artist art) 403'd behind the gate even though the header was configured. Headers are now registered up front, before any probe or bind, so the native layer always has them.
|
||||
* Covers that hit the gate during the brief window before headers were registered got a `403`, which was treated as "cover missing" and written a 30-minute do-not-retry marker — so on a gated server most covers stayed blank long after the gate started answering. A gate-style `403`/`401` on cover art is now treated as a recoverable hiccup (retried) rather than a permanent miss, and reconnecting a gated server clears those stale markers and re-runs the cover fill so the artwork loads.
|
||||
* For a server with both a LAN and a public address, whichever answered first after launch stuck for the whole session: if the app started off the LAN it pinned to the public address and never switched back to LAN once you got home (the public address kept answering, so the LAN address was never re-checked). The reachability tick now re-checks the LAN address first with a single attempt, so returning to the LAN upgrades the connection back to local automatically, while staying remote costs just that one probe rather than the full retry wait. The LAN/public badge also refreshes immediately when you switch the active server, instead of staying on the previous server's classification until the next poll.
|
||||
|
||||
### Windows — MSI bundle on dev and RC versions
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1278](https://github.com/Psychotoxical/psysonic/pull/1278)**
|
||||
|
||||
* Windows `.msi` builds no longer fail on channel versions like `1.50.0-dev` — WiX requires a numeric fourth version field, so the bundler maps `-dev` / `-rc.N` to numeric semver in `bundle.windows.wix.version` while Settings → About still shows the real package version.
|
||||
* Release builds no longer warn that the album feature barrel defeats a lazy import in the new-albums easter egg (direct import of the export helper).
|
||||
|
||||
### Internet Radio — equalizer presets now apply to radio playback
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1284](https://github.com/Psychotoxical/psysonic/pull/1284)**
|
||||
|
||||
* Internet Radio playback stayed on HTML5 after v1.32, but EQ changes only reached the Rust engine used for library tracks — toggling EQ or switching presets had no effect on a live station. Radio now routes through a Web Audio 10-band graph on the same `<audio>` element when EQ is enabled; preset and slider changes update filters in place without restarting the stream.
|
||||
|
||||
### Music Network — connect errors now name their cause
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1285](https://github.com/Psychotoxical/psysonic/pull/1285)**
|
||||
|
||||
* Connecting a scrobble service could fail with only "Network error — check your connection or URL", which covers everything from a DNS failure to a blocked host, an interrupted TLS handshake or a rejected request. The underlying error is now shown alongside it, so a failing connect can be told apart from a reachability problem on your machine or network.
|
||||
|
||||
### Windows — Subsonic client id no longer `psysonic/undefined`
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1290](https://github.com/Psychotoxical/psysonic/pull/1290)**
|
||||
|
||||
* Windows release builds could send `psysonic/undefined` as the Subsonic client id (visible in **Who is listening?**) when `package.json` version was read during a circular authStore boot-chunk init — prebuild now emits a leaf `SUBSONIC_CLIENT_ID` literal and the boot-chunk guard rejects unresolved client-id templates.
|
||||
|
||||
### Albums — "Artist / Year" sorting and albums with featured guests
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1292](https://github.com/Psychotoxical/psysonic/pull/1292)**
|
||||
|
||||
* Sorting albums by artist ordered them by the track artist while showing the album artist. On a release with featured guests the two differ, so it was filed under a name that isn't on screen — the album dropped out of its artist's run of years, sometimes behind a different artist entirely. Album sorting now follows the artist the row actually shows.
|
||||
|
||||
### Playlist and radio custom covers blank
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by VirtualWolf, PR [#1295](https://github.com/Psychotoxical/psysonic/pull/1295)**
|
||||
|
||||
* Custom playlist and internet radio covers uploaded in Navidrome stayed blank in Psysonic (cards and detail headers) while album and track art worked. The cover resolver rewrote Navidrome's `pl-*` and `ra-*` getCoverArt ids into invalid `al-pl-*_0` / `al-ra-*_0` forms; fetch-only prefixes are now preserved in TS and Rust.
|
||||
|
||||
### Themes — album rails no longer cut off card shadows
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1300](https://github.com/Psychotoxical/psysonic/pull/1300)**
|
||||
|
||||
* Horizontal album rails clipped an outer card shadow at the edges, which only themes that use a real drop shadow ran into. Working around it meant overriding the rail's `overflow`, and that disabled the rail's `<` / `>` scroll arrows. Rails now reserve room for the shadow inside the rail itself, so the arrows keep working; a theme that needs more room can raise `--rail-shadow-room` instead of touching `overflow`.
|
||||
|
||||
### Accessibility — modal dialogs announce their title
|
||||
|
||||
**By [@AliMahmoudDev](https://github.com/AliMahmoudDev), PR [#1301](https://github.com/Psychotoxical/psysonic/pull/1301)**
|
||||
|
||||
* Modal dialogs carried no accessible name, so a screen reader announced them without saying which dialog had opened. The dialog is now linked to its title, and each instance gets its own id so several open dialogs cannot be confused for one another.
|
||||
|
||||
### Themes — smooth UI with many themes installed
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1315](https://github.com/Psychotoxical/psysonic/pull/1315)**
|
||||
|
||||
* With a large number of community themes installed, every hover or playback-state change made the browser re-evaluate the CSS of every installed theme, which could slow the UI to a crawl. Only the active theme (plus the scheduler's day and night picks) participates now; the others stay dormant until applied — switching themes is unaffected.
|
||||
|
||||
### Settings — cover art toggles translated
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1319](https://github.com/Psychotoxical/psysonic/pull/1319)**
|
||||
|
||||
* The queue cover-art setting and the track-list setting's title showed English text in every language except Russian — both are translated in all languages now, and the German description states more precisely which pages show the thumbnails.
|
||||
|
||||
### Square corners — player bar and list thumbnails included
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by JU3RG on the Psysonic Discord, PR [#1320](https://github.com/Psychotoxical/psysonic/pull/1320)**
|
||||
|
||||
* The Square Corners toggle left the player bar cover and the small cover thumbnails in list rows rounded (queue, playlists, favorites, search, Random Mix). They now go square with everything else; the floating player bar's circular cover stays round by design.
|
||||
|
||||
### Duplicate server session on Navidrome
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by TheHomeGuy on the Psysonic Discord, PR [#1322](https://github.com/Psychotoxical/psysonic/pull/1322)**
|
||||
|
||||
* Native requests carried a separate User-Agent from the in-app view, so the server listed the app as two logged-in players at once. They now share one identity and show as a single session.
|
||||
|
||||
|
||||
## [1.49.0] - 2026-06-29
|
||||
## [1.49.0]
|
||||
|
||||
## Added
|
||||
|
||||
@@ -405,23 +50,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
|
||||
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
|
||||
|
||||
### Japanese and Hungarian translations
|
||||
### Japanese translation
|
||||
|
||||
**By [@Soli0222](https://github.com/Soli0222), PR [#1134](https://github.com/Psychotoxical/psysonic/pull/1134)**
|
||||
|
||||
* Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
|
||||
|
||||
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
||||
|
||||
### Artist artwork from fanart.tv
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137) and PR [#1193](https://github.com/Psychotoxical/psysonic/pull/1193)**
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137)**
|
||||
|
||||
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
|
||||
* The **mainstage hero** on the home screen now shows the album artist's backdrop too, matching the fullscreen player and artist page.
|
||||
* Choose, per place (mainstage hero, artist page, fullscreen player), which images to use as the background and in what order — drag to reorder or switch a source off, under the same setting. The hero also preloads the upcoming backdrops so they appear without a long blank.
|
||||
|
||||
### Remember the equalizer per audio output device
|
||||
|
||||
@@ -429,6 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
|
||||
|
||||
### Hungarian translation
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
|
||||
|
||||
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
|
||||
|
||||
### Custom HTTP headers for gated servers
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
|
||||
@@ -450,51 +95,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* The theme scheduler can now switch your day/night theme pair based on your operating system's light/dark setting, in addition to the existing time-of-day schedule. Pick the trigger with a new Time of Day / System Theme switch; in system mode the two pickers read as Light and Dark theme. On Linux setups where the OS does not signal the change live, a hint notes it applies after restarting the app.
|
||||
|
||||
### Hi-Res transition blend rate
|
||||
### AutoDJ — waveform edge-mix blend with min/max length
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1171](https://github.com/Psychotoxical/psysonic/pull/1171)**
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1167](https://github.com/Psychotoxical/psysonic/pull/1167)**, algorithm by [@peri4ko](https://github.com/peri4ko)
|
||||
|
||||
* **Settings → Audio → Native Hi-Res** gains a blend-rate picker (44.1 / 88.2 / 96 kHz, default 44.1 kHz) for transitions when adjacent tracks have different sample rates, with a note that resampling uses extra CPU and memory.
|
||||
* **Crossfade / AutoDJ:** both sides resample to the chosen rate; the output stream reopens when needed and the outgoing track rebuilds from cache so mixed 88.2 ↔ 44.1 kHz transitions no longer tear mid-fade.
|
||||
* **Gapless:** the next track chains at the blend rate and the current track realigns when the stream Hz differs, instead of falling back to a hard cut.
|
||||
|
||||
### AutoDJ — configurable overlap cap
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1173](https://github.com/Psychotoxical/psysonic/pull/1173)**
|
||||
|
||||
* **Settings → Audio → Track transitions → AutoDJ:** choose **Auto** (content-driven overlap, up to 12 s) or **Limit** (slider 2–30 s, default 15 s when enabled) to cap how long AutoDJ may overlap tracks.
|
||||
* The cap applies to end-of-track planning, JS auto-advance, smooth skip, and Orbit transition sync; the audio engine accepts dynamic overlap overrides up to 30 s.
|
||||
|
||||
### Polish translation
|
||||
|
||||
**By [@Rextens](https://github.com/Rextens), PR [#1185](https://github.com/Psychotoxical/psysonic/pull/1185)**
|
||||
|
||||
* Full Polish (Polski) UI translation — selectable from the language picker on the Settings and Login screens.
|
||||
|
||||
### Multiple genres in album details
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1186](https://github.com/Psychotoxical/psysonic/pull/1186)**, suggested by [@Thraka](https://github.com/Thraka)
|
||||
|
||||
* Album details now surface every genre a release spans instead of just the first one: the main genre shows inline with a **+N** chip that opens the full, clickable list, each genre linking to its genre page.
|
||||
* Genres combine album and track tags (matching the genre browser) and read from the local library index when it is ready, so they also work offline.
|
||||
|
||||
### Compact buttons — switch action and toolbar buttons to icon-only
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1189](https://github.com/Psychotoxical/psysonic/pull/1189)**
|
||||
|
||||
* New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets.
|
||||
|
||||
### Playlists — sort by date added
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1191](https://github.com/Psychotoxical/psysonic/pull/1191)**, suggested by SinFist
|
||||
|
||||
* Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist filter toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
|
||||
|
||||
### WinGet update command in the update dialog (Windows)
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1202](https://github.com/Psychotoxical/psysonic/pull/1202)**
|
||||
|
||||
* The Windows update dialog now also shows the WinGet command (`winget upgrade Psysonic`) next to the installer download, so you can update whichever way you installed.
|
||||
* AutoDJ now blends tracks using the **shape of the audio at each edge** — it measures how each track ends and how the next one begins, then crossfades over exactly that musical region with matched gain curves instead of a generic equal-power fade. Transitions follow real fade-outs and intros more closely; smooth skip uses the same model from your current position.
|
||||
* Blend curves use **equal-power interpolation** between waveform-derived gain endpoints (constant perceived loudness during the overlap, like classic crossfade), and **hard loud↔loud** pairs get at least a ~2 s overlap so stabs and abrupt endings still feel blended.
|
||||
* New **Transition length** controls under Settings → Audio → Track transitions: optional **Min** and **Max** bounds (each with an **Auto** setting) cap how short or long an AutoDJ blend can be. Auto leaves the length entirely up to the audio.
|
||||
* Classic Crossfade and Gapless are unchanged.
|
||||
|
||||
|
||||
## Changed
|
||||
@@ -510,22 +118,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Settings → Personalisation** gains a **Queue Settings** category that brings the queue display mode, the queue toolbar customizer, and the **Preserve "Play Next" order** toggle (moved here from Audio) together in one place.
|
||||
* On macOS, the **Audio Output Device** category is now hidden rather than showing a notice — playback there always follows the system output device.
|
||||
|
||||
### Russian locale — missing strings and phrasing cleanup
|
||||
|
||||
**By [@kilyabin](https://github.com/kilyabin), PR [#1181](https://github.com/Psychotoxical/psysonic/pull/1181)**
|
||||
|
||||
* Fifty strings that still fell back to English in the Russian UI are now translated — macOS in-place updater, device sync file migration, fullscreen lyrics, and statistics share-image export.
|
||||
* User-facing descriptions in Russian and English no longer mention WebKitGTK or Fisher–Yates internals; several Russian labels and section titles read more naturally (settings casing, smart playlists, track transitions, and home rails).
|
||||
|
||||
### macOS — themed window title bar
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1199](https://github.com/Psychotoxical/psysonic/pull/1199)**, suggested by [@bcorporaal](https://github.com/bcorporaal)
|
||||
|
||||
* On macOS the window's title bar now follows the active theme instead of the grey system bar; the native macOS window buttons stay in place, floating over the themed bar.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playlists header buttons clipped at narrow widths
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
|
||||
|
||||
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
|
||||
|
||||
### Seeking in streamed Opus/Ogg tracks
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1110](https://github.com/Psychotoxical/psysonic/pull/1110)**
|
||||
@@ -569,24 +170,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Niri is now recognized as a tiling window manager (`NIRI_SOCKET`, `XDG_CURRENT_DESKTOP=niri`), so it gets the same custom title bar, window decorations, and mini-player behavior as Hyprland and Sway instead of being treated like a floating desktop.
|
||||
|
||||
### Play queue sync — follow-up fixes
|
||||
### Play queue idle pull overwrote local edits
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
|
||||
|
||||
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
|
||||
|
||||
### Paused client did not push edited queue on Play
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
|
||||
|
||||
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
|
||||
|
||||
### Connection indicator flapping on flaky links
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1135](https://github.com/Psychotoxical/psysonic/pull/1135)**
|
||||
|
||||
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
|
||||
|
||||
### Yellow sync LED during normal playback
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
|
||||
|
||||
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
|
||||
|
||||
### Update notification — clearer popup on Linux
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
|
||||
|
||||
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
||||
|
||||
### Favorites — bulk add to playlist and play/enqueue selected
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
|
||||
@@ -595,18 +208,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
|
||||
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
|
||||
|
||||
### Update notification — clearer popup on Linux
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
|
||||
|
||||
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
||||
|
||||
### Artists letter index — Navidrome ignored articles and library index
|
||||
### Artists letter index — Navidrome ignored articles
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**, closes [#1144](https://github.com/Psychotoxical/psysonic/issues/1144)
|
||||
|
||||
* On the **Artists** page (and **Composers**), the A–Z filter now groups names like Navidrome: leading articles such as **The** are skipped before picking the letter — **The Beatles** lands under **B**, not **T**. The bucket follows the server's own `ignoredArticles` list when the local index knows it.
|
||||
* The local library index stores `name_sort` and the server's `ignoredArticles` from `getArtists`, sorts browse SQL by the sort key (now indexed), and repairs stale keys once on upgrade.
|
||||
|
||||
### Library index — safer open, swap and recovery
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**
|
||||
|
||||
* The local library database now opens, swaps and restores through one pipeline, so a swapped or restored file always picks up pending migrations and one-time repairs instead of serving a stale schema.
|
||||
* A panic or a poisoned lock in one query no longer wedges the whole library index — connections recover and report the error instead, and the new sort-key migration applies idempotently so a half-applied upgrade self-heals on the next launch.
|
||||
|
||||
@@ -624,12 +236,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
|
||||
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
|
||||
|
||||
### Playlists header buttons clipped at narrow widths
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
|
||||
|
||||
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
|
||||
|
||||
### Orbit — session reliability fixes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
|
||||
@@ -642,115 +248,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
|
||||
* Pasted invites are rejected unless they point at a normal http/https server address.
|
||||
|
||||
### macOS dock icon larger than native apps
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1169](https://github.com/Psychotoxical/psysonic/pull/1169)**, closes [#1166](https://github.com/Psychotoxical/psysonic/issues/1166)
|
||||
|
||||
* On macOS the dock icon was rendered edge-to-edge and looked larger than other apps; it is now padded to Apple's icon grid so it matches native sizing.
|
||||
|
||||
### Artist header showing the plain image instead of the external background
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1172](https://github.com/Psychotoxical/psysonic/pull/1172)**
|
||||
|
||||
* On the artist page, when an artist had an external background image (from fanart.tv) but no banner, the header showed the plain Navidrome artist image instead of the background — even though the fullscreen player used the background correctly. The header now falls back banner → background → Navidrome image as intended. The background also sits a little higher so band members' heads aren't cropped on wide screens.
|
||||
|
||||
### Context menu "Play Now" and resize behaviour
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1174](https://github.com/Psychotoxical/psysonic/pull/1174)**, reported by [@peri4ko](https://github.com/peri4ko)
|
||||
|
||||
* On the Playlists page, right-clicking a playlist and choosing "Play Now" only opened the playlist instead of playing it. It now starts playback.
|
||||
* Resizing the window while a context menu was open could leave the menu stranded and drifting off-screen. The context menu now closes when the window is resized.
|
||||
|
||||
### Genres page kept empty genres after tag changes
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1176](https://github.com/Psychotoxical/psysonic/pull/1176)**, closes [#1162](https://github.com/Psychotoxical/psysonic/issues/1162)
|
||||
|
||||
* After retagging a track and resyncing the library, genres with no remaining albums could still appear on the Genres page until restart. The local genre catalog now counts only live indexed tracks, filters zero-count genres, and the Genres page refreshes when library sync finishes.
|
||||
|
||||
### AutoDJ — last track in the queue was cut short
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
|
||||
|
||||
* With AutoDJ active and no next track to blend into, the engine could still fire the crossfade end timer and trim the final song. The last track now plays through to real source exhaustion.
|
||||
|
||||
### Play queue sync — idle pull rewound after the queue finished
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
|
||||
|
||||
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
|
||||
|
||||
### Sidebar — offline nav gating after manual reconnect Retry
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1190](https://github.com/Psychotoxical/psysonic/pull/1190)**, closes [#1160](https://github.com/Psychotoxical/psysonic/issues/1160)
|
||||
|
||||
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
|
||||
|
||||
### Timeline play history disappeared on album/playlist play
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1204](https://github.com/Psychotoxical/psysonic/pull/1204)**, closes [#1096](https://github.com/Psychotoxical/psysonic/issues/1096)
|
||||
|
||||
* Timeline mode now keeps a session play-history strip (plus cold bootstrap of the last 50 plays from statistics) when Play album/playlist replaces the queue; canonical queue sync is unchanged.
|
||||
* The current track stays pinned to the top of the list; clicking a history row inserts after the playing track instead of replacing the queue, and replayed tracks remain in the history strip.
|
||||
* History rows from other servers resolve album/cover metadata per server so Now Playing artwork loads when replaying cross-server plays.
|
||||
* Cross-server queue switches now send `playbackReport` **stopped** to the previous server so its Who is listening entry clears promptly.
|
||||
|
||||
### Album and artist covers — full resolution restored
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1205](https://github.com/Psychotoxical/psysonic/pull/1205)**
|
||||
|
||||
* Album and artist covers — and the full-size view when you click a cover — could appear small and low-quality even though the source image was large, depending on how you reached the album. Root cause: the cache built its larger sizes from a smaller already-saved size instead of the full-resolution download, so they were stored downscaled. Covers are now built from the full-resolution image, and the full-size view opens at full resolution. The cover cache refreshes once on update. Reported by users on Discord.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
### WinGet — automated manifest updates on release
|
||||
|
||||
**By [@ImAsra](https://github.com/ImAsra), PR [#1077](https://github.com/Psychotoxical/psysonic/pull/1077)**
|
||||
|
||||
* New GitHub Actions workflow publishes Windows installer updates to `microsoft/winget-pkgs` on each release — scans the `_x64-setup.exe` asset, computes SHA-256, and opens the upstream PR via `winget-releaser`.
|
||||
|
||||
### ESLint setup and a strict lint pass over the frontend
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1165](https://github.com/Psychotoxical/psysonic/pull/1165)**
|
||||
|
||||
* Added an ESLint config and `npm run lint`, and brought `src/` to zero errors and warnings under the strict React-hooks ruleset. Developer-only — no user-facing behaviour change.
|
||||
|
||||
### CI — ESLint gate and path-aware ci-ok merge check
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1170](https://github.com/Psychotoxical/psysonic/pull/1170)**
|
||||
|
||||
* Strict `npm run lint` runs in CI on frontend path filters via a dedicated workflow parallel to the existing frontend test jobs.
|
||||
* The `ci-ok` check waits for every applicable test and lint job on a PR (frontend and/or Rust, depending on changed paths) and blocks merge when any required job failed or did not finish in time.
|
||||
|
||||
### Settings — consistent design for the Audio sub-sections
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1175](https://github.com/Psychotoxical/psysonic/pull/1175)**
|
||||
|
||||
* The AutoDJ overlap-cap and the Native Hi-Res blend-rate options in Settings → Audio now sit in the same bordered sub-card the Normalization options use, and the Hi-Res section no longer shows a double border.
|
||||
|
||||
### App no longer blanks on an unexpected error
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1194](https://github.com/Psychotoxical/psysonic/pull/1194)**
|
||||
|
||||
* If a screen hit an unexpected rendering error, the whole window could go blank with no way back. The app now shows a small recoverable error card (Try again / Reload app) instead, and playback keeps going.
|
||||
|
||||
### Windows update notice waits out WinGet moderation
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1200](https://github.com/Psychotoxical/psysonic/pull/1200)**
|
||||
|
||||
* On Windows, the "update available" notice now waits until a release is a couple of days old, so it no longer points to a version that WinGet has not finished publishing yet. macOS and Linux are unaffected.
|
||||
|
||||
### Playlist no longer reloads when you press Play
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1201](https://github.com/Psychotoxical/psysonic/pull/1201)**
|
||||
|
||||
* Pressing Play, Shuffle or Add to queue on a playlist no longer reloads the whole page with a spinner — it just starts playback. Editing the playlist (adding or removing songs) still refreshes the list as before.
|
||||
|
||||
### Sidebar items jumped back when reordered
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1206](https://github.com/Psychotoxical/psysonic/pull/1206)**, reported by [@tummydummy](https://github.com/tummydummy)
|
||||
|
||||
* In Settings → Personalization → Sidebar, dragging an item to a new position could snap it back or land it one place off, depending on which items were hidden. Reordering now tracks each item directly, so it stays exactly where you release it — both in the customizer and when long-pressing items in the sidebar itself.
|
||||
|
||||
## [1.48.1] - 2026-06-15
|
||||
|
||||
## Fixed
|
||||
|
||||
+3
-28
@@ -73,7 +73,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
|
||||
| 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), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| 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/) |
|
||||
@@ -84,12 +84,11 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
|
||||
|
||||
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:** ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
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.
|
||||
- **Adding a new language (not just keys):** the Rust cluster-key normalizer (`src-tauri/crates/psysonic-library/src/identity/norm.rs`) folds diacritics/ligatures per shipped locale so library items match across servers. When a locale introduces a script or letters it does not yet cover, extend its decomposition table for that language and **bump `NORM_VERSION`** (existing `library-cluster.db` keys rebuild automatically). CJK locales are intentionally left verbatim. The module header carries the same checklist.
|
||||
|
||||
---
|
||||
|
||||
@@ -109,36 +108,15 @@ Align early: open an issue or chat thread before sending a PR that renames `invo
|
||||
|
||||
---
|
||||
|
||||
## Frontend architecture and layering
|
||||
|
||||
The frontend uses a feature-folder architecture (introduced in #1225) with a layering contract that CI enforces through **`npm run dep:check`** (dependency-cruiser). The rule is simple: **a lower layer must never import a higher one.**
|
||||
|
||||
```
|
||||
lib → store / ui → cover / music-network → features/<x> → app
|
||||
```
|
||||
|
||||
- **`lib/**` is the floor** — feature-free infrastructure (API clients, formatting, i18n, util, media, server, navigation). It must **not** import from `store`, `ui`, `features`, `cover`, `music-network`, or `app`. If a `lib` helper needs auth/server state, pass it in as an argument or keep the helper in the layer that owns that state (`store` or `features`) — do not reach into a store from `lib`.
|
||||
- **`store` / `ui`** may import `lib` only.
|
||||
- **`cover` / `music-network`** are top-level domains and may import `lib`, `store`, `ui`.
|
||||
- **`features/<x>`** may import `lib`, `store`, `ui`, `cover`, `music-network`, and other features — but cross-feature access goes **only** through the `@/features/<x>` barrel, never a deep path.
|
||||
- **`app`** (shell + bridges) may import anything.
|
||||
- **No import cycles** anywhere under `src/`.
|
||||
|
||||
The authoritative rules and rationale live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs) (its header documents the layers and the known-violation ratchet). Any **new** layering or cycle violation fails the `dependency-cruiser` job and blocks `ci-ok`, so run **`npm run dep:check`** locally before opening a frontend PR. The known-violations baseline in `.dependency-cruiser-known-violations.json` only ratchets **down** — don't regenerate it to silence a new violation; fix the import instead.
|
||||
|
||||
---
|
||||
|
||||
## 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, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **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.
|
||||
|
||||
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -151,10 +129,7 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run dep:check
|
||||
npm test
|
||||
npm run prebuild:release-notes
|
||||
npx tsc --noEmit
|
||||
npm run test:coverage
|
||||
bash scripts/check-frontend-hot-path-coverage.sh
|
||||
|
||||
@@ -14,11 +14,11 @@ Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **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"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></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, Hungarian and Polish.
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese and Hungarian.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
@@ -38,142 +38,95 @@ Psysonic is a desktop music client for self-hosted music libraries. It is design
|
||||
|
||||
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.
|
||||
|
||||
Psysonic is **optimized first and foremost for Navidrome**, and it leans into that on purpose: instead of being one more generic Subsonic client, it is **the Navidrome-first desktop client that does things no other client does.** Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
# ⭐ Key Features
|
||||
# Highlights
|
||||
|
||||
These are the things that set Psysonic apart. To our knowledge, no comparable self-hosted desktop client ships them.
|
||||
|
||||
## 🪐 Orbit — Shared Listening
|
||||
|
||||
**Listen together, in sync, over your own server.**
|
||||
|
||||
Orbit brings real-time synchronized group listening into Psysonic. Start a session, invite people with a link, and everyone hears the same thing at the same time — with host-controlled playback, a shared queue and guest song suggestions.
|
||||
|
||||
The clever part: Orbit rides entirely on **your own Navidrome**. There is no external relay, no third-party service and no extra accounts. The session lives on your server, where it belongs. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
|
||||
|
||||
<div align="left">
|
||||
<img src="public/orbit.png" alt="Orbit shared listening" width="520"/>
|
||||
</div>
|
||||
|
||||
## ⚡ Local Library — Instant, and Almost Offline
|
||||
|
||||
**A local index of your whole collection, so the app stays fast no matter what your connection does.**
|
||||
|
||||
Psysonic keeps a local library of your collection's metadata right on your machine. Because the app already knows your tracks inside out, browsing, searching and starting playback are instant — even a 500 MB FLAC starts the moment you hit play, because nothing has to be fetched or parsed first.
|
||||
|
||||
It also means the connection to your server stops being a bottleneck. Even on a slow, flaky or distant link, Psysonic stays responsive and **behaves almost like an offline player**, whatever the network is doing.
|
||||
|
||||
And it's the foundation everything else is built on: the local library is what makes on-device analysis, smart audio and snappy navigation possible in the first place.
|
||||
|
||||
## 🧠 On-Device Audio Analysis
|
||||
|
||||
**One of the most powerful things Psysonic does — entirely on your own machine.**
|
||||
|
||||
Built on top of the local library, Psysonic analyzes your tracks locally — **loudness, waveform and tempo** — with no cloud service and no required server-side plugin. That analysis is what powers content-aware AutoDJ transitions, LUFS-based loudness normalization and playback-speed control.
|
||||
|
||||
This is a deep, genuinely useful layer that most clients simply don't have, and because it runs locally, it works exactly the same whether you're fully online or barely connected.
|
||||
|
||||
## 🎧 AutoDJ — Content-Aware Crossfade
|
||||
|
||||
**A DJ that listens to the music, not a stopwatch.**
|
||||
|
||||
Most players do fixed-time crossfade: blend the last N seconds into the next N seconds, dead air and all. AutoDJ uses Psysonic's own audio analysis to **trim the silence at the edges of a track and blend out of the actual music** — for transitions that sound deliberate instead of mechanical. It is a standalone playback mode with smooth skip/interrupt handling and a configurable overlap.
|
||||
|
||||
## 🔗 Navidrome-Native, Deeply
|
||||
|
||||
**Not a generic Subsonic client wearing a Navidrome hat.**
|
||||
|
||||
Psysonic binds Navidrome's native capabilities directly: server-side smart-playlist create/edit, playback reporting and OpenSubsonic capability probing. Most clients in this space stay Subsonic-generic. Psysonic goes deeper, so Navidrome users get the features their server can actually deliver.
|
||||
|
||||
## 🎨 Community Theme Store
|
||||
|
||||
**A real marketplace for themes — installable and schedulable.**
|
||||
|
||||
Beyond a big set of built-in themes, Psysonic has a first-party theme registry: browse community themes, install them in-app, and let the **Theme Scheduler** switch looks automatically between day and night.
|
||||
|
||||
---
|
||||
|
||||
> ### Built to be trusted
|
||||
>
|
||||
> We take an enterprise-grade approach to development — continuously improving our automated testing and maintaining strict contracts between the backend and the frontend. Releases are cut from green CI, not vibes.
|
||||
|
||||
---
|
||||
|
||||
# ✨ More Highlights
|
||||
|
||||
Features that go well beyond the basics. Not all of these are unique to Psysonic, but few clients bring this many together.
|
||||
|
||||
## Audio & Loudness
|
||||
## Playback & Queue
|
||||
|
||||
* Gapless playback
|
||||
* Crossfade
|
||||
* ReplayGain support
|
||||
* LUFS-based Smart Loudness Normalization
|
||||
* ReplayGain support and loudness-aware playback
|
||||
* 10-band Equalizer with presets
|
||||
* [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
|
||||
|
||||
## Audio Tools
|
||||
|
||||
* 10-band Equalizer
|
||||
* Equalizer presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device EQ and output optimization
|
||||
* Adjustable playback speed
|
||||
* Per-device optimization
|
||||
* Loudness-aware playback options
|
||||
|
||||
## Lyrics & Listening
|
||||
## Library Management
|
||||
|
||||
* Synced lyrics with seek support, from multiple providers ([YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB, NetEase)
|
||||
* Auto-scrolling sidebar lyrics and a fullscreen lyric mode
|
||||
* Last.fm scrobbling, similar artists, loved tracks and listening stats
|
||||
* Smart Radio sessions and an Infinite Queue
|
||||
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support for sonic-similarity discovery (requires an AudioMuse-AI server)
|
||||
|
||||
## Artwork & Visuals
|
||||
|
||||
* Optional external artist imagery via **fanart.tv** — opt-in, shown on the artist page, fullscreen player and home hero (Navidrome stays the canonical cover-art source)
|
||||
* Cover art surfaced across the app, OS media controls and Discord Rich Presence
|
||||
|
||||
## Library & Playlists
|
||||
|
||||
* Smart Playlists
|
||||
* Drag & drop playlist management
|
||||
* 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
|
||||
|
||||
## Sharing
|
||||
## Lyrics & Discovery
|
||||
|
||||
* Magic Strings sharing for albums, artists and queues
|
||||
* Navidrome user-management helpers for fast account sharing
|
||||
* 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
|
||||
|
||||
## Offline, Sync & Deployment
|
||||
## Sharing & Social Listening
|
||||
|
||||
* Offline playback and downloads
|
||||
* USB / portable sync
|
||||
* LAN / remote auto-switching
|
||||
* Custom HTTP headers for reverse-proxy-gated servers (e.g. Cloudflare Access, Pangolin)
|
||||
* Backup and restore settings
|
||||
* In-app auto updater
|
||||
* 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
|
||||
|
||||
* Font customization and zoom controls
|
||||
* 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
|
||||
## Power User Extras
|
||||
|
||||
* CLI controls
|
||||
* USB / portable sync
|
||||
* Backup and restore settings
|
||||
* In-app auto updater
|
||||
* LAN / remote auto switching
|
||||
|
||||
---
|
||||
|
||||
# ✅ The Basics, Done Right
|
||||
<div align="left">
|
||||
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
|
||||
</div>
|
||||
|
||||
The things you simply expect from a serious music player — and Psysonic does them well.
|
||||
Orbit brings synchronized shared listening sessions directly into Psysonic.
|
||||
|
||||
* Gapless playback and crossfade
|
||||
* Fast search across large libraries
|
||||
* Browse albums, artists, tracks and genres
|
||||
* Ratings
|
||||
* Queue management
|
||||
* Keyboard navigation
|
||||
* Media key support
|
||||
* Low memory usage and native performance compared to heavy web-first clients
|
||||
* Built for large self-hosted collections
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ Version is authoritative in `package.json` and `package-lock.json`. Promotion wo
|
||||
- `next` version format: `X.Y.Z-rc.N`
|
||||
- `release` version format: `X.Y.Z`
|
||||
|
||||
WiX/MSI bundle version: alphabetic pre-releases (`-dev`, `-rc.N`) map to monotonic `major.minor.patch.build` in `bundle.windows.wix.version` via `scripts/sync-wix-bundle-version.mjs` — dev `.1`, rc `.10000+N`, stable `.65534` (in-place MSI upgrade across promotion). About/updater still show the real package version. NSIS accepts full semver without this mapping.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Never edit versions manually in random commits.
|
||||
|
||||
@@ -36,7 +36,6 @@ Some Psysonic features can communicate with external services, such as:
|
||||
- Last.fm
|
||||
- Bandsintown
|
||||
- Discord Rich Presence
|
||||
- Fanart.tv
|
||||
|
||||
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
|
||||
|
||||
|
||||
+17
-185
@@ -8,230 +8,62 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
|
||||
## [1.50.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Multi-library filter — browse across your libraries
|
||||
|
||||
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
|
||||
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
|
||||
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
|
||||
|
||||
### Navidrome public share links — listen without logging in
|
||||
|
||||
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
|
||||
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
|
||||
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
|
||||
|
||||
### Fullscreen player — Minimal, Immersive, and Prism
|
||||
|
||||
- **Settings → Appearance → Fullscreen player style** now offers three looks: **Minimal** (the current sharp view), **Immersive** (artist photo/backdrop with rail or Apple-style scrolling lyrics), and **Prism** (full-bleed artist backdrop, glass lyrics panel, and a single glass control bar).
|
||||
- In Immersive, **Show artist photo** and **Photo dimming** are configurable; Prism drives progress and the active lyric line from the cover-derived accent colour.
|
||||
|
||||
### Lyrics that follow the singer, word by word
|
||||
|
||||
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
|
||||
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
|
||||
|
||||
### Player bar — build your own, plus shuffle
|
||||
|
||||
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
|
||||
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
|
||||
|
||||
### Track lists — optional album cover thumbnails
|
||||
|
||||
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
|
||||
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
|
||||
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
|
||||
|
||||
### Discord — Server cover art without exposing your login
|
||||
|
||||
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
|
||||
|
||||
### Artists browse — album artists or track credits
|
||||
|
||||
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
|
||||
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
|
||||
|
||||
### Theme Store — what's new on each theme
|
||||
|
||||
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
|
||||
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
|
||||
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
|
||||
|
||||
### Italian and Bulgarian — now in your language
|
||||
|
||||
- Psysonic is now available in **Italian (Italiano)** and **Bulgarian (Български)** — pick either from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Start minimized to tray
|
||||
|
||||
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
|
||||
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
|
||||
|
||||
### Square corners — a sharper, boxier look
|
||||
|
||||
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
|
||||
|
||||
## Improved
|
||||
|
||||
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
|
||||
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
|
||||
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
|
||||
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` at `false` while the server is reachable — the app confirms with a real server probe instead.
|
||||
- When browsing offline, Artists, Albums, Tracks, and Genres list only content with on-disk bytes — pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
|
||||
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
|
||||
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
|
||||
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
|
||||
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
|
||||
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
|
||||
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
|
||||
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
|
||||
|
||||
### Other
|
||||
|
||||
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
|
||||
- Modal dialogs now announce their title to screen readers when they open.
|
||||
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Play queue sync — pick up where you left off on another device
|
||||
|
||||
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
|
||||
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
|
||||
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
|
||||
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
|
||||
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
|
||||
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 2–30 s) under **Settings → Audio → Track transitions**.
|
||||
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
|
||||
- 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.
|
||||
|
||||
### 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, Hungarian, and Polish — now in your language
|
||||
|
||||
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### 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.
|
||||
|
||||
### Hi-Res playback — smoother transitions between sample rates
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
|
||||
- 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**.
|
||||
|
||||
### Artist artwork — richer home, artist, and fullscreen views
|
||||
### Japanese — now in your language
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
|
||||
- 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, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
|
||||
|
||||
### Orbit — everyone hears transitions the host chose
|
||||
|
||||
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
|
||||
- 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.
|
||||
|
||||
### Themes — follow your system's light and dark mode
|
||||
|
||||
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
|
||||
|
||||
### Servers behind a reverse proxy — custom HTTP headers
|
||||
|
||||
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
|
||||
|
||||
### Album details — every genre, not just the first
|
||||
|
||||
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
|
||||
|
||||
### Compact buttons — switch to icon-only controls
|
||||
|
||||
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
|
||||
|
||||
### Playlists — sort by date added
|
||||
|
||||
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
|
||||
|
||||
## Improved
|
||||
|
||||
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
|
||||
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
|
||||
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
|
||||
- The **Artists** A–Z index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
|
||||
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
|
||||
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
|
||||
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
|
||||
|
||||
### Other
|
||||
### Browse and library
|
||||
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
|
||||
### Windows
|
||||
|
||||
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
|
||||
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
|
||||
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
@@ -149,7 +149,7 @@ case ${sub[1]} in
|
||||
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
|
||||
;;
|
||||
volume)
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100, or ±N for relative change'
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
|
||||
@@ -165,17 +165,12 @@ _psysonic_complete() {
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek)
|
||||
seek|volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
COMPREPLY=()
|
||||
fi
|
||||
;;
|
||||
volume)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compreply_from_compgen '+5 +10 -5 -10 50' "$cur"
|
||||
fi
|
||||
;;
|
||||
play)
|
||||
if (( n == 1 )); then
|
||||
_psysonic_compopt -o default
|
||||
|
||||
+1
-4
@@ -7,10 +7,7 @@ import tseslint from 'typescript-eslint';
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
|
||||
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
|
||||
// `src/generated` holds machine-generated output (release-notes bundle,
|
||||
// tauri-specta `bindings.ts`) — not linted. `bindings.ts` is still type-checked
|
||||
// by tsc (the FE↔BE contract); its generated runtime helper uses `any`.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts', 'src/generated'] },
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1784356753,
|
||||
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-ORdnzlm65b2HeaUrvZehq0jGDxbdchpCzRDPD0EFVh4="
|
||||
"npmDepsHash": "sha256-g18089KGJ4RdCag14vNLKpa048rSm1Z5XzEMVUBJKac="
|
||||
}
|
||||
|
||||
Generated
+5
-437
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.50.0",
|
||||
"version": "1.49.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.50.0",
|
||||
"version": "1.49.0-dev",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -57,7 +57,6 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"dependency-cruiser": "^18.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -2687,39 +2686,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx-walk": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz",
|
||||
"integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn-loose": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz",
|
||||
"integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2968,39 +2934,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/charenc": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||
@@ -3010,26 +2943,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -3042,16 +2955,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -3178,54 +3081,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-18.0.0.tgz",
|
||||
"integrity": "sha512-51Q7wbHoP3/GZrENpINnvKE/xfBsj41NVKarqu5Fff/kmqB/bg0GpiD5bOBwj0+CVunxPGzO80uTdYoy/d4Rsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "8.17.0",
|
||||
"acorn-jsx": "5.3.2",
|
||||
"acorn-jsx-walk": "2.0.0",
|
||||
"acorn-loose": "8.5.2",
|
||||
"acorn-walk": "8.3.5",
|
||||
"commander": "15.0.0",
|
||||
"enhanced-resolve": "5.24.1",
|
||||
"ignore": "7.0.5",
|
||||
"interpret": "3.1.1",
|
||||
"is-installed-globally": "1.0.0",
|
||||
"json5": "2.2.3",
|
||||
"picomatch": "4.0.4",
|
||||
"prompts": "2.4.2",
|
||||
"rechoir": "0.8.0",
|
||||
"safe-regex": "2.1.1",
|
||||
"semver": "7.8.5",
|
||||
"tsconfig-paths-webpack-plugin": "4.2.0",
|
||||
"watskeburt": "6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"depcruise": "bin/dependency-cruise.mjs",
|
||||
"depcruise-baseline": "bin/depcruise-baseline.mjs",
|
||||
"depcruise-fmt": "bin/depcruise-fmt.mjs",
|
||||
"depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs",
|
||||
"dependency-cruise": "bin/dependency-cruise.mjs",
|
||||
"dependency-cruiser": "bin/dependency-cruise.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22||^24||>=26"
|
||||
}
|
||||
},
|
||||
"node_modules/dependency-cruiser/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -3275,20 +3130,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.24.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz",
|
||||
"integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
@@ -3834,22 +3675,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-directory": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz",
|
||||
"integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ini": "4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
||||
@@ -3875,13 +3700,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -4035,48 +3853,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz",
|
||||
"integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
|
||||
"integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -4100,36 +3882,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-installed-globally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz",
|
||||
"integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"global-directory": "^4.0.1",
|
||||
"is-path-inside": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
|
||||
"integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
@@ -4288,16 +4040,6 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -4731,16 +4473,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4886,13 +4618,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -4975,20 +4700,6 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^3.0.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -5102,19 +4813,6 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve": "^1.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -5129,16 +4827,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/regexp-tree": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"regexp-tree": "bin/regexp-tree"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -5149,28 +4837,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
@@ -5205,16 +4871,6 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
|
||||
"integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regexp-tree": "~0.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -5235,9 +4891,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -5283,13 +4939,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -5314,16 +4963,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -5350,19 +4989,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -5370,20 +4996,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -5487,37 +5099,6 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json5": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths-webpack-plugin": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz",
|
||||
"integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"enhanced-resolve": "^5.7.0",
|
||||
"tapable": "^2.2.1",
|
||||
"tsconfig-paths": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -5834,19 +5415,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watskeburt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-6.0.0.tgz",
|
||||
"integrity": "sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"watskeburt": "dist/run-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13||^24||>=26"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
|
||||
+4
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.50.0",
|
||||
"version": "1.49.0-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
@@ -9,15 +9,11 @@
|
||||
"build": "npm run prebuild:release-notes && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
|
||||
"tauri:build": "npm run prebuild:release-notes && tauri build",
|
||||
"lint": "eslint -c eslint.config.mjs src",
|
||||
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
|
||||
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
|
||||
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
|
||||
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
|
||||
"build:verify": "npm run build && npm run check:boot-chunks",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
|
||||
"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"
|
||||
},
|
||||
@@ -71,7 +67,6 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"dependency-cruiser": "^18.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.49.0
|
||||
pkgver=1.48.1
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -155,25 +155,7 @@
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
function readStartMinimizedToTray() {
|
||||
try {
|
||||
var raw = localStorage.getItem('psysonic-auth');
|
||||
if (!raw) return false;
|
||||
var parsed = JSON.parse(raw);
|
||||
var state = parsed && parsed.state;
|
||||
if (!state || !state.startMinimizedToTray) return false;
|
||||
return state.showTrayIcon !== false;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
|
||||
var palette = paletteForTheme(themeId, readInstalledThemes());
|
||||
applyPalette(themeId, palette);
|
||||
var trayHandled = false;
|
||||
try {
|
||||
trayHandled = sessionStorage.getItem('psy-startup-tray-handled') === '1';
|
||||
} catch (_err) {}
|
||||
window.__psyStartMinimizedToTray = readStartMinimizedToTray() && !trayHandled;
|
||||
})();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Show the native window after the inline startup splash has painted.
|
||||
* When starting minimized to tray, hide the main window as early as possible
|
||||
* (visible:false may still map briefly on some Linux WMs before this script).
|
||||
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
|
||||
*/
|
||||
(function startupSplashReveal() {
|
||||
@@ -14,22 +12,7 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryHideMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|hide', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reveal(attempt) {
|
||||
if (window.__psyStartMinimizedToTray) {
|
||||
if (tryHideMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
reveal(attempt + 1);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
if (tryShowMainWindow()) return;
|
||||
if (attempt >= MAX_ATTEMPTS) return;
|
||||
window.setTimeout(function () {
|
||||
@@ -37,18 +20,6 @@
|
||||
}, 50);
|
||||
}
|
||||
|
||||
if (window.__psyStartMinimizedToTray) {
|
||||
// Mark this synchronously, before React mounts. This deliberately does
|
||||
// not set the CSS animation-pause attribute: entrance animations may
|
||||
// still mount while the native window is hidden.
|
||||
window.__psyHidden = true;
|
||||
try {
|
||||
sessionStorage.setItem('psy-startup-tray-handled', '1');
|
||||
} catch (_err) {}
|
||||
reveal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
reveal(0);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build guard: boot-adjacent Vite chunks must not bundle lucide-react.
|
||||
*
|
||||
* When a store/utils barrel accidentally re-exports UI, Rollup may pull
|
||||
* createLucideIcon into authStore/offline chunks and hit TDZ init-order bugs
|
||||
* in production (Windows WebView2: "X is not a function" on splash).
|
||||
*
|
||||
* Run after `npm run build` — no Tauri compile needed.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DIST = join(new URL('..', import.meta.url).pathname, 'dist/assets');
|
||||
|
||||
/** Chunk filename prefixes that must stay lucide-free. */
|
||||
const BOOT_CHUNK_PREFIXES = ['authStore-', 'offline-'];
|
||||
|
||||
/** Minified bundles still embed lucide module paths or icon factory calls. */
|
||||
const LUCIDE_SIGNALS = [
|
||||
'lucide-react',
|
||||
'createLucideIcon',
|
||||
// Common preset/offline icons pulled through bad barrels (minified arg strings):
|
||||
'("globe"',
|
||||
'("settings"',
|
||||
'("wifi-off"',
|
||||
'("download"',
|
||||
];
|
||||
|
||||
/** Subsonic client id must be a compile-time literal, not a cyclic package.json import. */
|
||||
const CLIENT_ID_SIGNALS = [
|
||||
'psysonic/undefined',
|
||||
'psysonic/${',
|
||||
];
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(DIST).filter(f => f.endsWith('.js'));
|
||||
} catch {
|
||||
console.error('check-boot-chunk-lucide: dist/assets not found — run `npm run build` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!BOOT_CHUNK_PREFIXES.some(p => file.startsWith(p))) continue;
|
||||
const text = readFileSync(join(DIST, file), 'utf8');
|
||||
for (const signal of LUCIDE_SIGNALS) {
|
||||
if (text.includes(signal)) {
|
||||
violations.push({ file, signal });
|
||||
}
|
||||
}
|
||||
for (const signal of CLIENT_ID_SIGNALS) {
|
||||
if (text.includes(signal)) {
|
||||
violations.push({ file, signal: `client-id: ${signal}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Lucide leaked into boot-critical chunks:\n');
|
||||
for (const { file, signal } of violations) {
|
||||
console.error(` • dist/assets/${file} — matched "${signal}"`);
|
||||
}
|
||||
console.error(
|
||||
'\nFix: split UI from the feature root barrel; import UI from @/features/<x>/ui only in components.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('check-boot-chunk-lucide: ok');
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Guard: boot-critical feature barrels must not re-export UI modules.
|
||||
*
|
||||
* Re-exporting components/hooks that pull lucide-react through a barrel while
|
||||
* the same barrel (or its stores) is imported from boot paths creates production
|
||||
* init-order failures (`createLucideIcon is not a function` in minified chunks).
|
||||
*
|
||||
* Not every feature barrel is checked — album/artist/etc. export UI for lazy
|
||||
* routes and are safe. Only barrels that stores/utils on the boot path import
|
||||
* from are listed in BOOT_CRITICAL_BARRELS.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
|
||||
/** Barrels whose non-UI surface is imported before or during first paint. */
|
||||
const BOOT_CRITICAL_BARRELS = [
|
||||
{ file: join(ROOT, 'src/features/offline/index.ts'), label: 'src/features/offline/index.ts' },
|
||||
{ file: join(ROOT, 'src/music-network/index.ts'), label: 'src/music-network/index.ts' },
|
||||
];
|
||||
|
||||
const FORBIDDEN_EXPORT_PATTERNS = [
|
||||
/from\s+['"]\.\/components\//,
|
||||
/from\s+['"]\.\/ui\//,
|
||||
/export\s+\*\s+from\s+['"]\.\/components\//,
|
||||
/export\s+\*\s+from\s+['"]\.\/ui\//,
|
||||
/export\s+\{[^}]*\}\s+from\s+['"]\.\/components\//,
|
||||
/export\s+\{[^}]*\}\s+from\s+['"]\.\/ui\//,
|
||||
];
|
||||
|
||||
/** @param {string} file @param {string} label */
|
||||
function checkBarrel(file, label) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const hits = [];
|
||||
for (const re of FORBIDDEN_EXPORT_PATTERNS) {
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
||||
if (re.test(line)) hits.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (hits.length === 0) return [];
|
||||
return hits.map(h => `${label}: ${h}`);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const { file, label } of BOOT_CRITICAL_BARRELS) {
|
||||
try {
|
||||
statSync(file);
|
||||
errors.push(...checkBarrel(file, label));
|
||||
} catch {
|
||||
errors.push(`${label}: missing barrel file`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Boot-critical barrel UI re-export violations:\n');
|
||||
for (const e of errors) console.error(` • ${e}`);
|
||||
console.error(
|
||||
'\nFix: remove UI exports from the root barrel; import from @/features/<x>/ui or deep paths.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('check-feature-barrel-ui: ok');
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compare two environment dumps from dump-environment.sh
|
||||
# Usage: ./scripts/compare-environment.sh FILE_A FILE_B
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 FILE_A FILE_B" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_A="$1"
|
||||
FILE_B="$2"
|
||||
|
||||
for f in "$FILE_A" "$FILE_B"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "Missing file: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
parse_dump() {
|
||||
local file="$1"
|
||||
awk -F= '
|
||||
/^\[/ {
|
||||
section = substr($0, 2, length($0) - 2)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*$/ { next }
|
||||
/^#/ { next }
|
||||
{
|
||||
key = $1
|
||||
$1 = ""
|
||||
sub(/^=/, "", $0)
|
||||
full = (section == "" ? key : section "." key)
|
||||
print full "\t" $0
|
||||
}
|
||||
' "$file" | sort -u
|
||||
}
|
||||
|
||||
TMP_A="$(mktemp)"
|
||||
TMP_B="$(mktemp)"
|
||||
TMP_JOIN="$(mktemp)"
|
||||
trap 'rm -f "$TMP_A" "$TMP_B" "$TMP_JOIN"' EXIT
|
||||
|
||||
parse_dump "$FILE_A" >"$TMP_A"
|
||||
parse_dump "$FILE_B" >"$TMP_B"
|
||||
|
||||
join -t $'\t' -a 1 -a 2 -e '—' -o '0,1.2,2.2' "$TMP_A" "$TMP_B" >"$TMP_JOIN"
|
||||
|
||||
ONLY_A=0
|
||||
ONLY_B=0
|
||||
DIFF=0
|
||||
SAME=0
|
||||
|
||||
echo "Comparing:"
|
||||
echo " A: $FILE_A"
|
||||
echo " B: $FILE_B"
|
||||
echo
|
||||
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ -z "$key" ]] && continue
|
||||
if [[ "$val_a" == "—" ]]; then
|
||||
ONLY_B=$((ONLY_B + 1))
|
||||
printf ' + %-40s B=%s\n' "$key" "$val_b"
|
||||
elif [[ "$val_b" == "—" ]]; then
|
||||
ONLY_A=$((ONLY_A + 1))
|
||||
printf ' - %-40s A=%s\n' "$key" "$val_a"
|
||||
elif [[ "$val_a" != "$val_b" ]]; then
|
||||
DIFF=$((DIFF + 1))
|
||||
printf ' ≠ %-40s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
else
|
||||
SAME=$((SAME + 1))
|
||||
fi
|
||||
done <"$TMP_JOIN"
|
||||
|
||||
echo
|
||||
echo "Summary: same=$SAME different=$DIFF only_in_A=$ONLY_A only_in_B=$ONLY_B"
|
||||
|
||||
# High-signal keys for the common "works on one NixOS box" case.
|
||||
echo
|
||||
echo "High-signal differences (if any):"
|
||||
HIGH_SIGNAL=0
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ "$val_a" == "$val_b" ]] && continue
|
||||
case "$key" in
|
||||
meta.flake_lock_sha256|meta.nixpkgs_locked_rev|meta.npm_lock_sha256|meta.git_rev|\
|
||||
installed_app.psysonic_realpath|installed_app.psysonic_closure_paths|installed_app.psysonic_drv|\
|
||||
runtime_env.HTTP_PROXY|runtime_env.HTTPS_PROXY|runtime_env.http_proxy|runtime_env.https_proxy|\
|
||||
runtime_env.NO_PROXY|runtime_env.no_proxy|runtime_env.SSL_CERT_FILE|runtime_env.NIX_SSL_CERT_FILE|\
|
||||
toolchain_nix_develop.node_realpath|toolchain_nix_develop.rustc_realpath|\
|
||||
host.nixos_version|host.uname|\
|
||||
app_config_paths.data_dir|app_config_paths.localstorage_db_path|\
|
||||
app_preferences.language|app_servers.active_server_id|app_servers.server_count|\
|
||||
app_servers.server.*.url|app_servers.server.*.alternateUrl|\
|
||||
app_servers.server.*.customHeaders_count|app_servers.server.*.customHeadersApplyTo|\
|
||||
app_servers.server.*.password_sha256|app_servers.server.*.customHeaders.*.name|\
|
||||
app_servers.server.*.customHeaders.*.value_sha256|\
|
||||
app_network_probe.probe.*)
|
||||
HIGH_SIGNAL=$((HIGH_SIGNAL + 1))
|
||||
printf ' ! %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
;;
|
||||
esac
|
||||
done <"$TMP_JOIN"
|
||||
|
||||
if [[ "$HIGH_SIGNAL" -eq 0 && "$DIFF" -eq 0 && "$ONLY_A" -eq 0 && "$ONLY_B" -eq 0 ]]; then
|
||||
echo " (none — environments match on recorded keys)"
|
||||
elif [[ "$HIGH_SIGNAL" -eq 0 ]]; then
|
||||
echo " (no high-signal keys differ; see full list above — may be npm patch-level deps only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Server / network config differences:"
|
||||
SERVER_DIFF=0
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ "$val_a" == "$val_b" ]] && continue
|
||||
case "$key" in
|
||||
app_servers.*|app_network_probe.*|app_config_paths.*|app_preferences.*)
|
||||
SERVER_DIFF=$((SERVER_DIFF + 1))
|
||||
printf ' • %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
;;
|
||||
esac
|
||||
done <"$TMP_JOIN"
|
||||
if [[ "$SERVER_DIFF" -eq 0 ]]; then
|
||||
echo " (none — server URLs, headers, and curl probes match)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Reminder: server offline is usually URL/network/headers, not Node patch versions."
|
||||
echo "Next: curl the Navidrome URL from both hosts; compare Settings → Servers side by side."
|
||||
|
||||
if [[ "$DIFF" -gt 0 || "$ONLY_A" -gt 0 || "$ONLY_B" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dump Psysonic-related toolchain, Nix closure hints, app config, and env for cross-machine diff.
|
||||
# Re-enters `nix develop` automatically when flake.nix is present (no manual dev shell needed).
|
||||
# Usage: ./scripts/dump-environment.sh [-o FILE]
|
||||
# Compare: ./scripts/compare-environment.sh a.txt b.txt
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/dump-environment.sh"
|
||||
|
||||
# Bootstrap: run the rest inside the flake dev shell so node/jq match the project.
|
||||
if [[ -z "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]] && [[ -f "$REPO_ROOT/flake.nix" ]]; then
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
echo "$0: flake.nix found but nix is not on PATH — install Nix or run from a NixOS profile with flakes." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PSYSONIC_ENV_DUMP_IN_NIX=1
|
||||
exec env REPO_ROOT="$REPO_ROOT" nix develop --command bash "$SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "$0: node not found after nix develop — check flake.nix devShell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_FILE=""
|
||||
while getopts 'o:h' opt; do
|
||||
case "$opt" in
|
||||
o) OUTPUT_FILE="$OPTARG" ;;
|
||||
h)
|
||||
echo "Usage: $0 [-o FILE]" >&2
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [-o FILE]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
emit() {
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
printf '%s\n' "$*" >>"$OUTPUT_FILE"
|
||||
else
|
||||
printf '%s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
kv() {
|
||||
local key="$1"
|
||||
local value="${2-}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
emit "${key}=${value}"
|
||||
}
|
||||
|
||||
section() {
|
||||
emit ""
|
||||
emit "[$1]"
|
||||
}
|
||||
|
||||
run_optional() {
|
||||
"$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
: >"$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
section meta
|
||||
kv generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
kv in_nix_dev_shell "${PSYSONIC_ENV_DUMP_IN_NIX:-no}"
|
||||
kv hostname "$(hostname 2>/dev/null || echo unknown)"
|
||||
kv repo_root "$REPO_ROOT"
|
||||
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
kv git_rev "$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
kv git_branch "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||
kv git_dirty "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||
else
|
||||
kv git_rev "n/a"
|
||||
kv git_branch "n/a"
|
||||
kv git_dirty "n/a"
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/package.json" ]]; then
|
||||
kv package_json_version "$(node -p "require('./package.json').version" 2>/dev/null || sed -n 's/.*\"version\": \"\\([^\"]*\\)\".*/\\1/p' "$REPO_ROOT/package.json" | head -1)"
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/flake.lock" ]]; then
|
||||
kv flake_lock_sha256 "$(sha256sum "$REPO_ROOT/flake.lock" | awk '{print $1}')"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
kv nixpkgs_locked_rev "$(jq -r '.nodes.nixpkgs.locked.rev // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
|
||||
kv nixpkgs_locked_narHash "$(jq -r '.nodes.nixpkgs.locked.narHash // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/package-lock.json" ]]; then
|
||||
kv npm_lockfile_version "$(jq -r '.lockfileVersion // "unknown"' "$REPO_ROOT/package-lock.json" 2>/dev/null || echo unknown)"
|
||||
kv npm_lock_sha256 "$(sha256sum "$REPO_ROOT/package-lock.json" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
section host
|
||||
kv uname "$(uname -a 2>/dev/null || echo unknown)"
|
||||
if [[ -r /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
kv os_id "${ID:-unknown}"
|
||||
kv os_version "${VERSION_ID:-unknown}"
|
||||
kv os_pretty "${PRETTY_NAME:-unknown}"
|
||||
fi
|
||||
if command -v nixos-version >/dev/null 2>&1; then
|
||||
kv nixos_version "$(nixos-version 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
|
||||
section nix
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
kv nix_version "$(nix --version 2>/dev/null | head -1)"
|
||||
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
|
||||
else
|
||||
kv nix_version "not_installed"
|
||||
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
|
||||
fi
|
||||
|
||||
dump_toolchain_block() {
|
||||
local label="$1"
|
||||
shift
|
||||
section "$label"
|
||||
for tool in node npm rustc cargo clippy jq cmake pkg-config; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
case "$tool" in
|
||||
node) kv node_version "$("$tool" -v 2>/dev/null)" ;;
|
||||
npm) kv npm_version "$("$tool" -v 2>/dev/null)" ;;
|
||||
rustc) kv rustc_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
cargo) kv cargo_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
clippy) kv clippy_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
jq) kv jq_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
cmake) kv cmake_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
pkg-config) kv pkg_config_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
esac
|
||||
kv "${tool}_path" "$(command -v "$tool")"
|
||||
if [[ -L "$(command -v "$tool")" ]] || [[ -e "$(command -v "$tool")" ]]; then
|
||||
kv "${tool}_realpath" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
else
|
||||
kv "${tool}_version" "missing"
|
||||
fi
|
||||
done
|
||||
kv CARGO_TARGET_DIR "${CARGO_TARGET_DIR:-unset}"
|
||||
kv LD_LIBRARY_PATH "${LD_LIBRARY_PATH:-unset}"
|
||||
kv GST_PLUGIN_PATH "${GST_PLUGIN_PATH:-unset}"
|
||||
kv GIO_EXTRA_MODULES "${GIO_EXTRA_MODULES:-unset}"
|
||||
}
|
||||
|
||||
if [[ -n "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]]; then
|
||||
dump_toolchain_block toolchain_nix_develop
|
||||
elif command -v nix >/dev/null 2>&1 && [[ -f "$REPO_ROOT/flake.nix" ]]; then
|
||||
# No bootstrap (should not happen when flake exists) — capture devShell separately.
|
||||
NIX_DEV_DUMP="$(nix develop --command bash -lc '
|
||||
set +e
|
||||
cd "$REPO_ROOT" || exit 0
|
||||
for tool in node npm rustc cargo clippy jq; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
case "$tool" in
|
||||
node) printf "node_version=%s\n" "$("$tool" -v)" ;;
|
||||
npm) printf "npm_version=%s\n" "$("$tool" -v)" ;;
|
||||
rustc) printf "rustc_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
cargo) printf "cargo_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
clippy) printf "clippy_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
jq) printf "jq_version=%s\n" "$("$tool" --version | head -1)" ;;
|
||||
esac
|
||||
printf "%s_path=%s\n" "$tool" "$(command -v "$tool")"
|
||||
printf "%s_realpath=%s\n" "$tool" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
|
||||
else
|
||||
printf "%s_version=missing\n" "$tool"
|
||||
fi
|
||||
done
|
||||
printf "CARGO_TARGET_DIR=%s\n" "${CARGO_TARGET_DIR:-unset}"
|
||||
printf "LD_LIBRARY_PATH=%s\n" "${LD_LIBRARY_PATH:-unset}"
|
||||
printf "GST_PLUGIN_PATH=%s\n" "${GST_PLUGIN_PATH:-unset}"
|
||||
printf "GIO_EXTRA_MODULES=%s\n" "${GIO_EXTRA_MODULES:-unset}"
|
||||
' REPO_ROOT="$REPO_ROOT" 2>/dev/null | tr -d '\r' || true)"
|
||||
if [[ -n "$NIX_DEV_DUMP" ]]; then
|
||||
section toolchain_nix_develop
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" && "$line" == *=* ]] && emit "$line"
|
||||
done <<<"$NIX_DEV_DUMP"
|
||||
else
|
||||
section toolchain_nix_develop
|
||||
kv status "nix develop failed or unavailable"
|
||||
fi
|
||||
else
|
||||
dump_toolchain_block toolchain_ambient
|
||||
fi
|
||||
|
||||
section runtime_env
|
||||
for var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy GDK_BACKEND PSYSONIC_SKIP_WAYLAND_FONT_TUNING PSYSONIC_ALLOW_NATIVE_GDK SSL_CERT_FILE SSL_CERT_DIR NIX_SSL_CERT_FILE; do
|
||||
kv "$var" "${!var-unset}"
|
||||
done
|
||||
kv navigator_online "n/a (browser-only)"
|
||||
|
||||
section installed_app
|
||||
if command -v psysonic >/dev/null 2>&1; then
|
||||
PSY_PATH="$(command -v psysonic)"
|
||||
kv psysonic_path "$PSY_PATH"
|
||||
kv psysonic_realpath "$(readlink -f "$PSY_PATH" 2>/dev/null || echo unknown)"
|
||||
run_optional kv psysonic_version "$(psysonic --version 2>/dev/null | head -1)"
|
||||
if command -v nix-store >/dev/null 2>&1; then
|
||||
kv psysonic_closure_paths "$(nix-store -qR "$PSY_PATH" 2>/dev/null | wc -l | tr -d ' ')"
|
||||
kv psysonic_drv "$(nix-store -q --deriver "$PSY_PATH" 2>/dev/null | sed 's/\.drv$//' | xargs -r basename 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
else
|
||||
kv psysonic_path "not_in_path"
|
||||
fi
|
||||
|
||||
section npm_dependencies
|
||||
if [[ -f "$REPO_ROOT/package-lock.json" ]] && command -v node >/dev/null 2>&1; then
|
||||
REPO_ROOT="$REPO_ROOT" node <<'NODE' 2>/dev/null | while IFS= read -r line; do emit "$line"; done || true
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lockPath = path.join(process.env.REPO_ROOT || '.', 'package-lock.json');
|
||||
let lock;
|
||||
try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { process.exit(0); }
|
||||
const root = lock.packages?.['']?.dependencies || {};
|
||||
const deps = Object.keys(root).sort();
|
||||
for (const name of deps) {
|
||||
const entry = lock.packages?.[`node_modules/${name}`] || lock.packages?.[name];
|
||||
const version = entry?.version || 'unknown';
|
||||
console.log(`dep.${name}=${version}`);
|
||||
}
|
||||
NODE
|
||||
else
|
||||
kv status "node or package-lock.json unavailable"
|
||||
fi
|
||||
|
||||
run_app_config_dump() {
|
||||
local extractor="$REPO_ROOT/scripts/lib/extract-app-config.mjs"
|
||||
[[ -f "$extractor" ]] || return 0
|
||||
if node "$extractor" --repo-root "$REPO_ROOT" >>"${OUTPUT_FILE:-/dev/stdout}" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
section app_config
|
||||
kv status "extract-app-config failed (is Psysonic installed / has it been run once?)"
|
||||
fi
|
||||
}
|
||||
|
||||
run_app_config_dump
|
||||
|
||||
section network_probe_hint
|
||||
kv note_1 "App server profiles and curl probes are in app_servers / app_network_probe sections above."
|
||||
kv note_2 "Passwords and custom header values are redacted; compare password_sha256 and value_sha256 only."
|
||||
kv note_3 "Compare two dumps with: scripts/compare-environment.sh a.txt b.txt"
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
echo "Wrote $OUTPUT_FILE" >&2
|
||||
fi
|
||||
@@ -43,15 +43,3 @@ 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})`);
|
||||
|
||||
// Leaf module for boot-critical client id — must not import package.json at runtime
|
||||
// in the authStore chunk (circular init → psysonic/undefined on Windows WebView2).
|
||||
const appVersionTs = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const APP_VERSION = ${JSON.stringify(version)};
|
||||
|
||||
/** Subsonic REST \`c\` param and OpenSubsonic client id (\`psysonic/<version>\`). */
|
||||
export const SUBSONIC_CLIENT_ID = ${JSON.stringify(`psysonic/${version}`)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'appVersion.ts'), appVersionTs, 'utf8');
|
||||
console.log(`wrote src/generated/appVersion.ts (${version})`);
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Read Psysonic app config from WebKit localStorage + XDG dirs.
|
||||
* Emits key=value lines (passwords/secrets redacted; header values hashed).
|
||||
*
|
||||
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--app-id' && argv[i + 1]) {
|
||||
out.appId = argv[++i];
|
||||
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
|
||||
out.repoRoot = argv[++i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function emitSection(name) {
|
||||
process.stdout.write(`\n[${name}]\n`);
|
||||
}
|
||||
|
||||
function emit(key, value) {
|
||||
const v = String(value ?? '').replace(/\n/g, '\\n');
|
||||
process.stdout.write(`${key}=${v}\n`);
|
||||
}
|
||||
|
||||
function readAppIdFromRepo(repoRoot) {
|
||||
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
|
||||
if (!fs.existsSync(conf)) return '';
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
|
||||
return typeof j.identifier === 'string' ? j.identifier : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function xdgDataHome() {
|
||||
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
}
|
||||
|
||||
function xdgConfigHome() {
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
function decodeWebKitValue(raw) {
|
||||
if (raw == null) return null;
|
||||
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
|
||||
if (buf) {
|
||||
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
||||
return new TextDecoder('utf-16le').decode(buf.subarray(2));
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
||||
return new TextDecoder('utf-16be').decode(buf.subarray(2));
|
||||
}
|
||||
// WebKit often stores UTF-16LE without BOM
|
||||
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
|
||||
return new TextDecoder('utf-16le').decode(buf);
|
||||
}
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
if (typeof raw === 'string') return raw;
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function readLocalStorageRaw(dbPath, storageKey) {
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
|
||||
db.close();
|
||||
if (!row?.value) return null;
|
||||
return decodeWebKitValue(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalStorageKey(dbPath, storageKey) {
|
||||
const text = readLocalStorageRaw(dbPath, storageKey);
|
||||
if (text == null) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function pickLocalStorageFile(dataDir) {
|
||||
const dir = path.join(dataDir, 'localstorage');
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
|
||||
.map(f => path.join(dir, f));
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Prefer packaged app origin over vite dev (1420) when both exist.
|
||||
const ranked = files.sort((a, b) => {
|
||||
const score = p => {
|
||||
const base = path.basename(p);
|
||||
if (base.includes('tauri_localhost')) return 0;
|
||||
if (base.includes('1420')) return 2;
|
||||
return 1;
|
||||
};
|
||||
return score(a) - score(b);
|
||||
});
|
||||
|
||||
for (const file of ranked) {
|
||||
const auth = readLocalStorageKey(file, 'psysonic-auth');
|
||||
if (auth?.state?.servers?.length) return file;
|
||||
}
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
function probeHttpReachability(rawUrl) {
|
||||
if (!rawUrl) return 'empty';
|
||||
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
|
||||
const r = spawnSync(
|
||||
'curl',
|
||||
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
|
||||
if (r.status !== 0) return `curl_exit_${r.status}`;
|
||||
return `http_${r.stdout.trim() || '000'}`;
|
||||
}
|
||||
|
||||
function dumpNetworkProbes(servers) {
|
||||
emitSection('app_network_probe');
|
||||
const seen = new Set();
|
||||
servers.forEach((srv, i) => {
|
||||
for (const [kind, raw] of [
|
||||
['url', srv.url],
|
||||
['alternateUrl', srv.alternateUrl],
|
||||
]) {
|
||||
if (!raw || seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
|
||||
emit(`probe.${i}.${kind}_target`, raw);
|
||||
}
|
||||
});
|
||||
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
|
||||
}
|
||||
|
||||
function dumpServerProfiles(state) {
|
||||
const servers = state?.servers ?? [];
|
||||
emit('active_server_id', state?.activeServerId ?? '');
|
||||
emit('server_count', servers.length);
|
||||
servers.forEach((srv, i) => {
|
||||
const p = `server.${i}`;
|
||||
emit(`${p}.id`, srv.id ?? '');
|
||||
emit(`${p}.name`, srv.name ?? '');
|
||||
emit(`${p}.url`, srv.url ?? '');
|
||||
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
|
||||
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
|
||||
emit(`${p}.username`, srv.username ?? '');
|
||||
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
|
||||
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
|
||||
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
|
||||
const headers = srv.customHeaders ?? [];
|
||||
emit(`${p}.customHeaders_count`, headers.length);
|
||||
headers.forEach((h, hi) => {
|
||||
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
|
||||
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
|
||||
});
|
||||
});
|
||||
dumpNetworkProbes(servers);
|
||||
}
|
||||
|
||||
function fileMeta(label, filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
const st = fs.statSync(filePath);
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, filePath);
|
||||
emit(`${label}_size`, st.size);
|
||||
emit(`${label}_mtime`, st.mtime.toISOString());
|
||||
}
|
||||
|
||||
function listDir(label, dirPath, max = 12) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, dirPath);
|
||||
const entries = fs.readdirSync(dirPath).sort();
|
||||
emit(`${label}_entry_count`, entries.length);
|
||||
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
|
||||
}
|
||||
|
||||
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
|
||||
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
|
||||
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
|
||||
|
||||
const dataDir = path.join(xdgDataHome(), appId);
|
||||
const configDir = path.join(xdgConfigHome(), appId);
|
||||
|
||||
emitSection('app_config_paths');
|
||||
emit('app_id', appId);
|
||||
emit('data_dir', dataDir);
|
||||
emit('config_dir', configDir);
|
||||
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
|
||||
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
|
||||
|
||||
const lsFile = pickLocalStorageFile(dataDir);
|
||||
fileMeta('localstorage_db', lsFile);
|
||||
|
||||
emitSection('app_preferences');
|
||||
if (lsFile) {
|
||||
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
|
||||
emit('language', lang ?? 'unknown');
|
||||
|
||||
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
|
||||
if (authWrap?.state) {
|
||||
emitSection('app_servers');
|
||||
dumpServerProfiles(authWrap.state);
|
||||
} else {
|
||||
emit('app_servers_status', 'psysonic-auth not found or empty');
|
||||
}
|
||||
} else {
|
||||
emit('app_servers_status', 'no localstorage database found');
|
||||
}
|
||||
|
||||
emitSection('app_config_files');
|
||||
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
|
||||
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
|
||||
}
|
||||
|
||||
emitSection('app_data_artifacts');
|
||||
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
|
||||
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
|
||||
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
|
||||
@@ -47,8 +47,3 @@ if (updatedLock !== lock) {
|
||||
} else {
|
||||
console.log(`Cargo.lock workspace crates already at ${version}`);
|
||||
}
|
||||
|
||||
require('child_process').execSync('node scripts/sync-wix-bundle-version.mjs', {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Integration tests for promotion/version sync: npm version → sync-tauri → sync-wix.
|
||||
* Simulates tauri.conf.json state after the full pipeline (no file I/O).
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
wixMappedBuildNumber,
|
||||
wixVersionOverrideForPackageVersion,
|
||||
} from './wix-bundle-version.mjs';
|
||||
|
||||
/** Mirrors sync-tauri + sync-wix effects on tauri.conf.json. */
|
||||
function confAfterVersionSync(packageVersion, priorConf) {
|
||||
const conf = structuredClone(priorConf);
|
||||
conf.version = packageVersion;
|
||||
conf.bundle ??= {};
|
||||
conf.bundle.windows ??= {};
|
||||
const override = wixVersionOverrideForPackageVersion(packageVersion);
|
||||
conf.bundle.windows.wix = { ...(conf.bundle.windows.wix ?? {}), version: override };
|
||||
return conf;
|
||||
}
|
||||
|
||||
const baseConf = {
|
||||
version: '1.49.0-dev',
|
||||
bundle: {
|
||||
windows: {
|
||||
nsis: { installMode: 'currentUser' },
|
||||
wix: { version: '1.49.0.1' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('version promotion pipeline (sync-tauri + sync-wix)', () => {
|
||||
it('main → next: dev to first RC increases WiX build', () => {
|
||||
const conf = confAfterVersionSync('1.50.0-rc.1', baseConf);
|
||||
assert.equal(conf.version, '1.50.0-rc.1');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10001');
|
||||
assert.ok(
|
||||
wixMappedBuildNumber('1.50.0-rc.1') > wixMappedBuildNumber('1.50.0-dev'),
|
||||
);
|
||||
});
|
||||
|
||||
it('next RC bump: rc.1 to rc.2', () => {
|
||||
const from = confAfterVersionSync('1.50.0-rc.1', baseConf);
|
||||
const conf = confAfterVersionSync('1.50.0-rc.2', from);
|
||||
assert.equal(conf.version, '1.50.0-rc.2');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10002');
|
||||
});
|
||||
|
||||
it('next → release: RC to stable increases WiX build', () => {
|
||||
const from = confAfterVersionSync('1.50.0-rc.3', baseConf);
|
||||
const conf = confAfterVersionSync('1.50.0', from);
|
||||
assert.equal(conf.version, '1.50.0');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.50.0.65534');
|
||||
assert.ok(wixMappedBuildNumber('1.50.0') > wixMappedBuildNumber('1.50.0-rc.3'));
|
||||
});
|
||||
|
||||
it('post-release: next minor dev resets build band on new line', () => {
|
||||
const from = confAfterVersionSync('1.50.0', baseConf);
|
||||
const conf = confAfterVersionSync('1.51.0-dev', from);
|
||||
assert.equal(conf.version, '1.51.0-dev');
|
||||
assert.equal(conf.bundle.windows.wix.version, '1.51.0.1');
|
||||
});
|
||||
|
||||
it('stable release sets highest build in line', () => {
|
||||
const conf = confAfterVersionSync('2.0.0', {
|
||||
version: '2.0.0-rc.1',
|
||||
bundle: { windows: { wix: { version: '2.0.0.10001' } } },
|
||||
});
|
||||
assert.equal(conf.version, '2.0.0');
|
||||
assert.equal(conf.bundle.windows.wix.version, '2.0.0.65534');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync-tauri-version-from-package.js invokes sync-wix', () => {
|
||||
it('calls sync-wix-bundle-version.mjs after updating conf.version', () => {
|
||||
const source = readFileSync(new URL('./sync-tauri-version-from-package.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /sync-wix-bundle-version\.mjs/);
|
||||
const wixCall = source.indexOf('sync-wix-bundle-version');
|
||||
const versionWrite = source.indexOf('conf.version = version');
|
||||
assert.ok(wixCall > versionWrite, 'sync-wix must run after conf.version is set');
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Write bundle.windows.wix.version in tauri.conf.json from package.json.
|
||||
* Keeps the top-level app version unchanged (About, updater metadata, filenames).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { wixVersionOverrideForPackageVersion } from './wix-bundle-version.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
if (!version || typeof version !== 'string') {
|
||||
console.error('package.json version missing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
|
||||
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
|
||||
conf.bundle ??= {};
|
||||
conf.bundle.windows ??= {};
|
||||
conf.bundle.windows.wix ??= {};
|
||||
|
||||
const wixVersion = wixVersionOverrideForPackageVersion(version);
|
||||
conf.bundle.windows.wix.version = wixVersion;
|
||||
console.log(`tauri.conf wix.version -> ${wixVersion} (package ${version})`);
|
||||
|
||||
fs.writeFileSync(confPath, `${JSON.stringify(conf, null, 2)}\n`);
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Map package.json semver to a monotonic WiX/MSI ProductVersion for Tauri.
|
||||
*
|
||||
* `bundle.windows.wix.version` must be `major.minor.patch.build` (four integers
|
||||
* ≤ 65535). Alphabetic pre-releases cannot be used directly. NSIS accepts full
|
||||
* semver without this mapping.
|
||||
*
|
||||
* Build bands (monotonic within X.Y.Z so in-place MSI upgrades work across
|
||||
* dev → rc → stable):
|
||||
* dev → .1
|
||||
* rc.N → .10000 + N
|
||||
* stable → .65534
|
||||
*
|
||||
* Display / About still use the real package.json version.
|
||||
*/
|
||||
|
||||
/** @type {const} */
|
||||
export const WIX_BUILD = {
|
||||
DEV: 1,
|
||||
RC_BASE: 10_000,
|
||||
STABLE: 65_534,
|
||||
};
|
||||
|
||||
const MAX_WIX_FIELD = 65_535;
|
||||
|
||||
/** @param {number} build */
|
||||
function assertBuildField(build, label) {
|
||||
if (!Number.isInteger(build) || build < 0 || build > MAX_WIX_FIELD) {
|
||||
throw new Error(`WiX build field out of range for ${label}: ${build}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WiX dot version for bundle.windows.wix.version (always four parts for channels
|
||||
* we ship).
|
||||
* @param {string} version
|
||||
*/
|
||||
export function wixVersionOverrideForPackageVersion(version) {
|
||||
const trimmed = version.trim();
|
||||
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(\d+))?$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver for WiX mapping: ${trimmed}`);
|
||||
}
|
||||
|
||||
const major = match[1];
|
||||
const minor = match[2];
|
||||
const patch = match[3];
|
||||
const pre = match[4];
|
||||
const buildPart = match[5];
|
||||
const base = `${major}.${minor}.${patch}`;
|
||||
|
||||
if (buildPart !== undefined) {
|
||||
throw new Error(
|
||||
`Version "${trimmed}" has +build metadata — map manually or drop build for WiX`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pre === undefined) {
|
||||
return `${base}.${WIX_BUILD.STABLE}`;
|
||||
}
|
||||
|
||||
if (pre === 'dev') {
|
||||
return `${base}.${WIX_BUILD.DEV}`;
|
||||
}
|
||||
|
||||
const rc = pre.match(/^rc\.(\d+)$/);
|
||||
if (rc) {
|
||||
const n = Number(rc[1]);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(`WiX rc index must be ≥ 1 (got rc.${rc[1]})`);
|
||||
}
|
||||
const build = WIX_BUILD.RC_BASE + n;
|
||||
assertBuildField(build, `rc.${n}`);
|
||||
return `${base}.${build}`;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(pre)) {
|
||||
const n = Number(pre);
|
||||
const build = WIX_BUILD.RC_BASE + n;
|
||||
assertBuildField(build, `pre ${pre}`);
|
||||
return `${base}.${build}`;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Version "${trimmed}" has non-numeric pre-release "${pre}" — MSI/WiX cannot bundle it. ` +
|
||||
'Use NSIS (`--bundles nsis`) or extend wix-bundle-version.mjs.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Numeric build field from mapped WiX version (for monotonicity tests). */
|
||||
export function wixMappedBuildNumber(packageVersion) {
|
||||
const wix = wixVersionOverrideForPackageVersion(packageVersion);
|
||||
const build = Number(wix.split('.')[3]);
|
||||
assertBuildField(build, packageVersion);
|
||||
return build;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
WIX_BUILD,
|
||||
wixMappedBuildNumber,
|
||||
wixVersionOverrideForPackageVersion,
|
||||
} from './wix-bundle-version.mjs';
|
||||
|
||||
describe('wixVersionOverrideForPackageVersion', () => {
|
||||
it('maps -dev to lowest build band', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-dev'), '1.50.0.1');
|
||||
});
|
||||
|
||||
it('maps -rc.N to RC base + N', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-rc.3'), '1.50.0.10003');
|
||||
});
|
||||
|
||||
it('maps stable to highest build band', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0'), '1.50.0.65534');
|
||||
});
|
||||
|
||||
it('maps numeric pre-release to RC band', () => {
|
||||
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-42'), '1.50.0.10042');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monotonic promotion builds within X.Y.Z', () => {
|
||||
it('dev < rc.1 < rc.2 < stable', () => {
|
||||
const chain = ['1.50.0-dev', '1.50.0-rc.1', '1.50.0-rc.2', '1.50.0'];
|
||||
let prev = -1;
|
||||
for (const v of chain) {
|
||||
const build = wixMappedBuildNumber(v);
|
||||
assert.ok(build > prev, `${v} build ${build} must exceed ${prev}`);
|
||||
prev = build;
|
||||
}
|
||||
assert.equal(prev, WIX_BUILD.STABLE);
|
||||
});
|
||||
});
|
||||
Generated
+7
-105
@@ -2,12 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Inflector"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -4120,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4146,8 +4140,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"sysinfo",
|
||||
@@ -4162,7 +4154,6 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"thread-priority",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -4176,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4187,7 +4178,6 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"tauri",
|
||||
@@ -4196,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4213,7 +4203,6 @@ dependencies = [
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"tauri",
|
||||
@@ -4227,19 +4216,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"specta",
|
||||
"tauri",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4248,7 +4236,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -4257,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4265,7 +4252,6 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"wiremock",
|
||||
@@ -4273,7 +4259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
@@ -4286,7 +4272,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-plugin-process",
|
||||
@@ -5396,57 +5381,6 @@ dependencies = [
|
||||
"zvariant 3.15.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta"
|
||||
version = "2.0.0-rc.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"rustc_version",
|
||||
"specta-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-macros"
|
||||
version = "2.0.0-rc.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-serde"
|
||||
version = "0.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029"
|
||||
dependencies = [
|
||||
"specta",
|
||||
"specta-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-typescript"
|
||||
version = "0.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0"
|
||||
dependencies = [
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "specta-util"
|
||||
version = "0.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502"
|
||||
dependencies = [
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.12.0"
|
||||
@@ -5887,7 +5821,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"serialize-to-javascript",
|
||||
"specta",
|
||||
"swift-rs",
|
||||
"tauri-build",
|
||||
"tauri-macros",
|
||||
@@ -6200,37 +6133,6 @@ dependencies = [
|
||||
"wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-specta"
|
||||
version = "2.0.0-rc.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-serde",
|
||||
"specta-typescript",
|
||||
"specta-util",
|
||||
"tauri",
|
||||
"tauri-specta-macros",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-specta-macros"
|
||||
version = "2.0.0-rc.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.2"
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.50.0"
|
||||
version = "1.49.0-dev"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -44,9 +44,6 @@ 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"] }
|
||||
specta = "=2.0.0-rc.25"
|
||||
specta-typescript = "=0.0.12"
|
||||
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
fn main() {
|
||||
// Windows/MSVC test binaries only: bind to Common-Controls v6.
|
||||
//
|
||||
// The library test harness (`--lib` unittests) links the wry/tao windowing
|
||||
// runtime and statically imports `TaskDialogIndirect` from comctl32, which
|
||||
// exists only in Common-Controls v6 (WinSxS). The real app binary gets that
|
||||
// manifest from `tauri_build`, but the separate test executable does not, so
|
||||
// it aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND (0xC0000139). Add the
|
||||
// dependency to test targets only — never the app binary (which already has a
|
||||
// manifest via tauri_build) nor non-Windows/CI builds.
|
||||
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if is_windows_msvc {
|
||||
println!(
|
||||
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
|
||||
);
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ psysonic-core = { path = "../psysonic-core" }
|
||||
tauri = { version = "2" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
fn main() {
|
||||
// Windows/MSVC test binaries only: bind to Common-Controls v6.
|
||||
//
|
||||
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
|
||||
// crate's *test* executables, which statically import `TaskDialogIndirect`
|
||||
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
|
||||
// bare System32 comctl32.dll is v5.82 and does not export it, so an
|
||||
// unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
|
||||
// (0xC0000139) before any test runs. The app binary avoids this through its
|
||||
// embedded manifest — mirror that here, scoped to test targets only (never
|
||||
// the app, the rlib, or non-Windows/CI builds).
|
||||
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if is_windows_msvc {
|
||||
println!(
|
||||
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ impl AnalysisTierCounts {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPipelineQueueStatsDto {
|
||||
pub pipeline_workers: u32,
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::analysis_runtime::{
|
||||
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WaveformCachePayload {
|
||||
pub bins: Vec<u8>,
|
||||
@@ -35,7 +35,7 @@ impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoudnessCachePayload {
|
||||
pub integrated_lufs: f64,
|
||||
@@ -45,7 +45,7 @@ pub struct LoudnessCachePayload {
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisDeleteServerReportDto {
|
||||
pub analysis_tracks: u64,
|
||||
@@ -53,7 +53,7 @@ pub struct AnalysisDeleteServerReportDto {
|
||||
pub loudness: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisFailedTrackDto {
|
||||
pub track_id: String,
|
||||
@@ -81,7 +81,7 @@ impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, specta::Type)]
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
@@ -148,7 +148,6 @@ pub fn get_loudness_payload_for_track(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_waveform(
|
||||
track_id: String,
|
||||
md5_16kb: String,
|
||||
@@ -173,7 +172,6 @@ pub fn analysis_get_waveform(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
@@ -194,7 +192,6 @@ pub fn analysis_get_waveform_for_track(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_loudness_for_track(
|
||||
track_id: String,
|
||||
target_lufs: Option<f64>,
|
||||
@@ -206,7 +203,6 @@ pub fn analysis_get_loudness_for_track(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_delete_loudness_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
@@ -216,7 +212,6 @@ pub fn analysis_delete_loudness_for_track(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_delete_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
@@ -226,7 +221,6 @@ pub fn analysis_delete_waveform_for_track(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_delete_all_waveforms(
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
@@ -234,7 +228,6 @@ pub fn analysis_delete_all_waveforms(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_delete_all_for_server(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
@@ -247,7 +240,6 @@ pub fn analysis_delete_all_for_server(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_failed_track_count(
|
||||
server_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
@@ -260,7 +252,6 @@ pub fn analysis_get_failed_track_count(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_list_failed_tracks(
|
||||
server_id: String,
|
||||
limit: Option<u32>,
|
||||
@@ -278,7 +269,6 @@ pub fn analysis_list_failed_tracks(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_clear_failed_tracks(
|
||||
server_id: String,
|
||||
track_ids: Option<Vec<String>>,
|
||||
@@ -298,7 +288,6 @@ pub fn analysis_clear_failed_tracks(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_migrate_server_index_keys(
|
||||
mappings: Vec<AnalysisServerKeyMigrationDto>,
|
||||
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
@@ -310,7 +299,6 @@ pub fn analysis_migrate_server_index_keys(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_enqueue_seed_from_url(
|
||||
track_id: String,
|
||||
url: String,
|
||||
@@ -330,7 +318,7 @@ pub fn analysis_enqueue_seed_from_url(
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPriorityHintDto {
|
||||
pub server_id: String,
|
||||
@@ -338,7 +326,6 @@ pub struct AnalysisPriorityHintDto {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_set_playback_priority_hints(
|
||||
middle_track_refs: Vec<AnalysisPriorityHintDto>,
|
||||
hints: tauri::State<'_, PlaybackPriorityHints>,
|
||||
@@ -350,7 +337,7 @@ pub fn analysis_set_playback_priority_hints(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisBackfillQueueStatsDto {
|
||||
pub queued: usize,
|
||||
@@ -359,20 +346,17 @@ pub struct AnalysisBackfillQueueStatsDto {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
|
||||
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
|
||||
Ok(analysis_pipeline_queue_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
|
||||
let (queued, in_progress_count, in_progress_track_id) =
|
||||
analysis_backfill_queue_stats();
|
||||
@@ -383,7 +367,7 @@ pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsD
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPrunePendingResult {
|
||||
pub keep_count: usize,
|
||||
@@ -396,7 +380,6 @@ pub struct AnalysisPrunePendingResult {
|
||||
///
|
||||
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_prune_pending_to_track_ids(
|
||||
track_ids: Vec<String>,
|
||||
server_id: String,
|
||||
|
||||
@@ -11,7 +11,6 @@ psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-analysis = { path = "../psysonic-analysis" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
fn main() {
|
||||
// Windows/MSVC test binaries only: bind to Common-Controls v6.
|
||||
//
|
||||
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
|
||||
// crate's *test* executables, which statically import `TaskDialogIndirect`
|
||||
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
|
||||
// bare System32 comctl32.dll is v5.82 and does not export it, so an
|
||||
// unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
|
||||
// (0xC0000139) before any test runs. The app binary avoids this through its
|
||||
// embedded manifest — mirror that here, scoped to test targets only (never
|
||||
// the app, the rlib, or non-Windows/CI builds).
|
||||
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if is_windows_msvc {
|
||||
println!(
|
||||
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ pub(crate) fn autoeq_profile_url_candidates(
|
||||
|
||||
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
|
||||
audio_http_client(&state)
|
||||
.get("https://autoeq.app/entries")
|
||||
@@ -50,7 +49,6 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, Str
|
||||
|
||||
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn autoeq_fetch_profile(
|
||||
name: String,
|
||||
source: String,
|
||||
|
||||
@@ -13,7 +13,6 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use super::decode::build_source;
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
|
||||
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};
|
||||
@@ -26,6 +25,27 @@ use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
|
||||
/// AutoDJ edge-mix linear blend parameters (additive `audio_play` arg). Sent by
|
||||
/// the frontend only in AutoDJ mode when an edge-mix plan is available; absent
|
||||
/// ⇒ the classic equal-power sin/cos crossfade path is used unchanged.
|
||||
///
|
||||
/// Field names are the locked contract (snake_case) from the implementation
|
||||
/// spec §16.5 — the frontend object literal uses these exact keys.
|
||||
#[derive(Debug, Clone, Copy, serde::Deserialize)]
|
||||
pub struct AutodjLinearMixParams {
|
||||
/// Desired mix overlap length (clamped 0.5..12 s, then to A's remaining).
|
||||
pub mix_secs: f32,
|
||||
/// Outgoing A gain at mix start — always 1.0 (carried for completeness).
|
||||
#[allow(dead_code)]
|
||||
pub outgoing_gain_start: f32,
|
||||
/// Outgoing A gain at mix end = 1 − linear_A(0); held after the fade.
|
||||
pub outgoing_gain_end: f32,
|
||||
/// Incoming B gain at mix start = 1 − linear_B(0).
|
||||
pub incoming_gain_start: f32,
|
||||
/// Incoming B gain at mix end — always 1.0.
|
||||
pub incoming_gain_end: f32,
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
||||
@@ -40,10 +60,6 @@ use super::state::{ChainedInfo, PreloadedTrack};
|
||||
/// `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]
|
||||
// NOTE: excluded from tauri-specta collect_commands! — specta's SpectaFn is only
|
||||
// implemented up to 10 args and this has 24. Typing it needs the args bundled into
|
||||
// a struct (a behaviour/contract change), tracked for the D4 flip; stays on
|
||||
// generate_handler! for now.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn audio_play(
|
||||
url: String,
|
||||
@@ -56,7 +72,6 @@ pub async fn audio_play(
|
||||
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)
|
||||
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
@@ -81,6 +96,9 @@ pub async fn audio_play(
|
||||
// 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>,
|
||||
// AutoDJ edge-mix: linear blend plan (mix length + gain endpoints). Present
|
||||
// only in AutoDJ mode with a valid plan; absent ⇒ equal-power sin/cos path.
|
||||
autodj_linear_mix: Option<AutodjLinearMixParams>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -251,12 +269,9 @@ pub async fn audio_play(
|
||||
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 = if let Some(override_secs) = crossfade_secs_override {
|
||||
override_secs.clamp(0.5, 30.0)
|
||||
} else {
|
||||
f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed))
|
||||
.clamp(0.5, 12.0)
|
||||
};
|
||||
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
|
||||
@@ -272,20 +287,46 @@ pub async fn audio_play(
|
||||
0.0
|
||||
};
|
||||
|
||||
// AutoDJ edge-mix: when a linear blend plan is present (and crossfade is
|
||||
// active for this swap) it replaces the equal-power fade shape on BOTH sides
|
||||
// — B rises linearly from `incoming_gain_start`, A falls linearly to
|
||||
// `outgoing_gain_end` and holds. `actual_mix_secs` mirrors `actual_fade_secs`:
|
||||
// the planned mix length clamped to A's measured remaining audio.
|
||||
let autodj_mix = autodj_linear_mix.filter(|_| crossfade_enabled);
|
||||
let actual_mix_secs: f32 = match autodj_mix {
|
||||
Some(m) => {
|
||||
let mix = m.mix_secs.clamp(0.5, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
let remaining = (cur.duration_secs - cur.position()) as f32;
|
||||
remaining.min(mix).clamp(0.1, mix)
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let autodj_in: Option<(f32, f32)> = autodj_mix
|
||||
.map(|m| (m.incoming_gain_start.clamp(0.0, 1.0), m.incoming_gain_end.clamp(0.0, 1.0)));
|
||||
let outgoing_linear_end_gain: Option<f32> =
|
||||
autodj_mix.map(|m| m.outgoing_gain_end.clamp(0.0, 1.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 {
|
||||
// edge-mix → linear lerp over the planned mix length (A's remaining)
|
||||
// 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 autodj_mix.is_some() {
|
||||
Duration::from_secs_f32(actual_mix_secs)
|
||||
} else 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 {
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Edge-mix uses the
|
||||
// planned mix length with a linear fade-to-hold; otherwise defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade) — 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 autodj_mix.is_some() {
|
||||
actual_mix_secs
|
||||
} else if crossfade_enabled {
|
||||
match outgoing_fade_secs_override {
|
||||
Some(v) => v.max(0.0).min(actual_fade_secs),
|
||||
None => actual_fade_secs,
|
||||
@@ -294,13 +335,6 @@ pub async fn audio_play(
|
||||
0.0
|
||||
};
|
||||
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(
|
||||
hi_res_enabled,
|
||||
crossfade_enabled || (gapless && !manual),
|
||||
hi_res_crossfade_resample_hz,
|
||||
);
|
||||
let resample_target_hz = blend_rate.unwrap_or(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.
|
||||
@@ -317,8 +351,8 @@ pub async fn audio_play(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
},
|
||||
&state,
|
||||
&app,
|
||||
@@ -356,26 +390,6 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let outgoing_blend: Option<OutgoingBlendSnapshot> =
|
||||
if let Some(blend) = blend_rate {
|
||||
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
|
||||
hi_res_blend::capture_outgoing_blend_snapshot(
|
||||
&state,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if outgoing_blend.is_some() {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
}
|
||||
|
||||
// ── 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
|
||||
@@ -384,12 +398,11 @@ pub async fn audio_play(
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let target_rate = if let Some(blend) = blend_rate {
|
||||
blend
|
||||
} else if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
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
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
@@ -421,23 +434,6 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
|
||||
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
|
||||
&app,
|
||||
&state,
|
||||
snap,
|
||||
blend,
|
||||
gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
}
|
||||
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);
|
||||
@@ -454,7 +450,7 @@ pub async fn audio_play(
|
||||
// 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 && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_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();
|
||||
@@ -526,9 +522,12 @@ pub async fn audio_play(
|
||||
gain_linear,
|
||||
fadeout_trigger: built.fadeout_trigger,
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
fadeout_linear: built.fadeout_linear,
|
||||
fadeout_end_gain: built.fadeout_end_gain,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
outgoing_linear_end_gain,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
@@ -606,9 +605,6 @@ pub async fn audio_play(
|
||||
/// 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]
|
||||
// NOTE: excluded from tauri-specta collect_commands! — 13 args exceed specta's
|
||||
// 10-arg SpectaFn limit; needs arg-bundling for the D4 flip. Stays on
|
||||
// generate_handler! for now.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn audio_chain_preload(
|
||||
url: String,
|
||||
@@ -620,7 +616,6 @@ pub async fn audio_chain_preload(
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
hi_res_crossfade_resample_hz: Option<u32>,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
@@ -734,9 +729,8 @@ pub async fn audio_chain_preload(
|
||||
// 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 unless hi-res gapless blend resampling is active.
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
|
||||
let target_rate: u32 = blend_rate.unwrap_or(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());
|
||||
@@ -753,6 +747,7 @@ pub async fn audio_chain_preload(
|
||||
target_rate,
|
||||
format_hint.as_deref(),
|
||||
hi_res_enabled,
|
||||
None, // gapless chain never uses the AutoDJ linear edge-mix
|
||||
).map_err(|e| e.to_string())?;
|
||||
let source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
@@ -762,70 +757,23 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Hi-res gapless: resample the chained track to the blend rate and realign
|
||||
// the output stream when its Hz differs from the current track.
|
||||
// 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 let Some(br) = blend_rate {
|
||||
if stream_rate > 0 && stream_rate != br {
|
||||
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
|
||||
if hi_res_enabled && br > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
|
||||
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
|
||||
&app,
|
||||
&state,
|
||||
&snap,
|
||||
br,
|
||||
snapshot_gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend skipped: current track not cached for realign"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
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(());
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -702,8 +702,15 @@ pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
|
||||
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
|
||||
}
|
||||
|
||||
// The incoming fade-in is type-erased through `DynSource` so the same stack type
|
||||
// covers both the equal-power (classic crossfade) and linear (AutoDJ edge-mix)
|
||||
// fade-in envelopes selected at build time.
|
||||
pub(crate) type BuiltSourceStack =
|
||||
PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>;
|
||||
PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<DynSource>>>>;
|
||||
|
||||
/// Incoming linear fade-in endpoints for the AutoDJ edge-mix (track B):
|
||||
/// `(incoming_gain_start, incoming_gain_end)`. `None` ⇒ classic equal-power fade.
|
||||
pub(crate) type AutodjFadeIn = Option<(f32, f32)>;
|
||||
|
||||
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
|
||||
pub(crate) struct BuiltSource {
|
||||
@@ -715,6 +722,30 @@ pub(crate) struct BuiltSource {
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
/// Total samples for the fade-out (set before triggering).
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
/// AutoDJ edge-mix: set at handoff for a linear fade-to-hold (else cos→0).
|
||||
pub(crate) fadeout_linear: Arc<AtomicBool>,
|
||||
/// AutoDJ edge-mix: outgoing end gain (f32 bits) the linear fade holds at.
|
||||
pub(crate) fadeout_end_gain: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// Build the fade stage shared by both builders: type-erase the chosen fade-in
|
||||
/// envelope, then wrap in the (generalised) triggered fade-out.
|
||||
fn build_fade_stage(
|
||||
eq_src: EqSource<DynSource>,
|
||||
fade_in_dur: Duration,
|
||||
autodj_in: AutodjFadeIn,
|
||||
fadeout_trigger: Arc<AtomicBool>,
|
||||
fadeout_samples: Arc<AtomicU64>,
|
||||
fadeout_linear: Arc<AtomicBool>,
|
||||
fadeout_end_gain: Arc<AtomicU32>,
|
||||
) -> TriggeredFadeOut<DynSource> {
|
||||
let fade_in: DynSource = match autodj_in {
|
||||
Some((start, end)) => {
|
||||
DynSource::new(LinearGainEnvelopeIn::new(eq_src, fade_in_dur, start, end))
|
||||
}
|
||||
None => DynSource::new(EqualPowerFadeIn::new(eq_src, fade_in_dur)),
|
||||
};
|
||||
TriggeredFadeOut::new(fade_in, fadeout_trigger, fadeout_samples, fadeout_linear, fadeout_end_gain)
|
||||
}
|
||||
|
||||
/// Build a fully-prepared playback source:
|
||||
@@ -742,6 +773,7 @@ pub(crate) fn build_source(
|
||||
target_rate: u32,
|
||||
format_hint: Option<&str>,
|
||||
hi_res: bool,
|
||||
autodj_in: AutodjFadeIn,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let gapless = parse_gapless_info(&data);
|
||||
|
||||
@@ -805,12 +837,21 @@ pub(crate) fn build_source(
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
let fadeout_linear = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_end_gain = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
|
||||
let rate_dyn = DynSource::new(rate_src);
|
||||
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let fade_out = build_fade_stage(
|
||||
eq_src,
|
||||
fade_in_dur,
|
||||
autodj_in,
|
||||
fadeout_trigger.clone(),
|
||||
fadeout_samples.clone(),
|
||||
fadeout_linear.clone(),
|
||||
fadeout_end_gain.clone(),
|
||||
);
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = CountingSource::new(notifying, sample_counter);
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
@@ -822,6 +863,8 @@ pub(crate) fn build_source(
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
fadeout_linear,
|
||||
fadeout_end_gain,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -841,6 +884,7 @@ pub(crate) fn build_streaming_source(
|
||||
sample_counter: Arc<AtomicU64>,
|
||||
target_rate: u32,
|
||||
count_gate: Option<Arc<AtomicBool>>,
|
||||
autodj_in: AutodjFadeIn,
|
||||
) -> Result<BuiltSource, String> {
|
||||
let sample_rate = decoder.sample_rate();
|
||||
let channels = decoder.channels();
|
||||
@@ -874,12 +918,21 @@ pub(crate) fn build_streaming_source(
|
||||
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
let fadeout_linear = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_end_gain = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
|
||||
let rate_dyn = DynSource::new(rate_src);
|
||||
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let fade_out = build_fade_stage(
|
||||
eq_src,
|
||||
fade_in_dur,
|
||||
autodj_in,
|
||||
fadeout_trigger.clone(),
|
||||
fadeout_samples.clone(),
|
||||
fadeout_linear.clone(),
|
||||
fadeout_end_gain.clone(),
|
||||
);
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||
let counting = match count_gate {
|
||||
Some(gate) => CountingSource::new_gated(notifying, sample_counter, gate),
|
||||
@@ -894,6 +947,8 @@ pub(crate) fn build_streaming_source(
|
||||
output_channels: channels.get(),
|
||||
fadeout_trigger,
|
||||
fadeout_samples,
|
||||
fadeout_linear,
|
||||
fadeout_end_gain,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1212,6 +1267,7 @@ mod build_source_tests {
|
||||
0,
|
||||
Some("wav"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("build_source must succeed for a valid WAV");
|
||||
assert_eq!(built.output_channels, 1);
|
||||
@@ -1235,6 +1291,7 @@ mod build_source_tests {
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -1256,6 +1313,7 @@ mod build_source_tests {
|
||||
sample_counter,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("build_streaming_source must succeed for a valid WAV decoder");
|
||||
assert_eq!(built.output_channels, 1);
|
||||
@@ -1279,6 +1337,7 @@ mod build_source_tests {
|
||||
48_000,
|
||||
Some("wav"),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("resampled build_source must succeed");
|
||||
assert_eq!(built.output_rate, 48_000);
|
||||
|
||||
@@ -27,540 +27,18 @@ pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
}
|
||||
|
||||
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
||||
enumerate_output_device_entries()
|
||||
.into_iter()
|
||||
.map(|e| e.key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stable key + human label for the settings dropdown.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OutputDeviceEntry {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub(crate) fn enumerate_output_device_entries() -> Vec<OutputDeviceEntry> {
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
let mut out = with_suppressed_alsa_stderr(|| {
|
||||
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| {
|
||||
let key = output_device_stable_key(&d);
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(OutputDeviceEntry {
|
||||
label: output_device_display_label(&d),
|
||||
key,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
dedupe_output_device_entries(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn dedupe_output_device_entries(entries: &mut Vec<OutputDeviceEntry>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
entries.retain(|e| seen.insert(e.key.clone()));
|
||||
}
|
||||
|
||||
/// Stable per-device key for Settings / EQ maps. Linux keeps ALSA-style description
|
||||
/// names; Windows/macOS use cpal [`DeviceId`] so same-named endpoints stay distinct
|
||||
/// and default-device changes are observable by the watcher.
|
||||
pub(crate) fn output_device_stable_key(device: &impl rodio::cpal::traits::DeviceTrait) -> String {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
if let Ok(id) = device.id() {
|
||||
return id.to_string();
|
||||
}
|
||||
}
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|d| d.name().to_string())
|
||||
.unwrap_or_else(|| device.id().map(|i| i.to_string()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Human-readable label for the settings dropdown (not the stored key).
|
||||
pub(crate) fn output_device_display_label(
|
||||
device: &impl rodio::cpal::traits::DeviceTrait,
|
||||
) -> String {
|
||||
match device.description() {
|
||||
Ok(desc) => format_output_device_label(&desc),
|
||||
Err(_) => output_device_stable_key(device),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_output_device_label(desc: &rodio::cpal::DeviceDescription) -> String {
|
||||
use rodio::cpal::{DeviceType, InterfaceType};
|
||||
let name = desc.name();
|
||||
let mut parts: Vec<String> = vec![name.to_string()];
|
||||
if let Some(mfr) = desc.manufacturer() {
|
||||
if mfr != name && !name.contains(mfr) {
|
||||
parts.push(mfr.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(driver) = desc.driver() {
|
||||
if driver != name && !parts.iter().any(|p| p.contains(driver)) {
|
||||
parts.push(driver.to_string());
|
||||
}
|
||||
}
|
||||
if parts.len() == 1 {
|
||||
let iface = desc.interface_type();
|
||||
if iface != InterfaceType::Unknown && iface != InterfaceType::BuiltIn {
|
||||
parts.push(iface.to_string());
|
||||
} else {
|
||||
let dtype = desc.device_type();
|
||||
if dtype != DeviceType::Unknown && dtype != DeviceType::Speaker {
|
||||
parts.push(dtype.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
/// Best-effort label when a legacy plain-name pin is kept off the current list.
|
||||
pub(crate) fn legacy_output_device_display_label(key: &str) -> String {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
if let Ok(id) = key.parse::<rodio::cpal::DeviceId>() {
|
||||
if let Some(device) = rodio::cpal::default_host().device_by_id(&id) {
|
||||
return output_device_display_label(&device);
|
||||
}
|
||||
}
|
||||
}
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
/// Upgrade a pre–DeviceId persisted pin to the current stable key when unambiguous.
|
||||
pub(crate) fn resolve_legacy_pinned_key(
|
||||
pinned: &str,
|
||||
entries: &[OutputDeviceEntry],
|
||||
) -> Option<String> {
|
||||
if entries.iter().any(|e| e.key == pinned) {
|
||||
return Some(pinned.to_string());
|
||||
}
|
||||
let logic_matches: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| output_devices_logically_same(&e.key, pinned))
|
||||
.collect();
|
||||
if logic_matches.len() == 1 {
|
||||
return Some(logic_matches[0].key.clone());
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let label_matches: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| e.label == pinned || e.label.starts_with(&format!("{pinned} · ")))
|
||||
.collect();
|
||||
if label_matches.len() == 1 {
|
||||
return Some(label_matches[0].key.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a stored device key to a cpal device (DeviceId on Windows/macOS, name on Linux).
|
||||
pub(crate) fn resolve_output_device(
|
||||
device_key: &str,
|
||||
) -> Option<rodio::cpal::Device> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use std::str::FromStr;
|
||||
let host = rodio::cpal::default_host();
|
||||
if let Ok(id) = rodio::cpal::DeviceId::from_str(device_key) {
|
||||
if let Some(device) = host.device_by_id(&id) {
|
||||
return Some(device);
|
||||
}
|
||||
}
|
||||
host.output_devices().ok()?.find(|d| {
|
||||
output_device_stable_key(d) == device_key
|
||||
|| d.description()
|
||||
.ok()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.as_deref()
|
||||
== Some(device_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// cpal/rodio aliases for "follow the OS default" — not a stable per-device key.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn is_generic_default_output_alias(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"default"
|
||||
| "Default Audio Device"
|
||||
| "PipeWire Sound Server"
|
||||
| "Default ALSA Output (currently PipeWire Media Server)"
|
||||
)
|
||||
}
|
||||
|
||||
fn raw_cpal_default_output_device_key() -> Option<String> {
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.map(|d| output_device_stable_key(&d))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pick_listed_device_name(candidate: &str, list: &[String]) -> Option<String> {
|
||||
list.iter()
|
||||
.find(|d| d.as_str() == candidate || output_devices_logically_same(d, candidate))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn equivalent_list_entries(name: &str, list: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = list
|
||||
.iter()
|
||||
.filter(|d| d.as_str() == name || output_devices_logically_same(d, name))
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Some(picked) = pick_listed_device_name(name, list) {
|
||||
if !out.iter().any(|d| d == &picked) {
|
||||
out.push(picked);
|
||||
}
|
||||
}
|
||||
if out.is_empty() && !name.is_empty() {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when two device keys refer to the same sink (exact, ALSA logical, or via list canon).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn output_device_keys_equivalent(a: &str, b: &str, list: &[String]) -> bool {
|
||||
if a == b || output_devices_logically_same(a, b) {
|
||||
return true;
|
||||
}
|
||||
if comma_and_alsa_device_equivalent(a, b) {
|
||||
return true;
|
||||
}
|
||||
let ea = equivalent_list_entries(a, list);
|
||||
let eb = equivalent_list_entries(b, list);
|
||||
ea.iter()
|
||||
.any(|x| eb.iter().any(|y| x == y || output_devices_logically_same(x, y)))
|
||||
}
|
||||
|
||||
/// Match wpctl/cpal `"CARD, PCM"` labels to ALSA `iface:CARD=…` picker ids.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn comma_and_alsa_device_equivalent(a: &str, b: &str) -> bool {
|
||||
let (comma, alsa) = if linux_alsa_sink_fingerprint(a).is_some() {
|
||||
(b, a)
|
||||
} else if linux_alsa_sink_fingerprint(b).is_some() {
|
||||
(a, b)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if comma.contains(':') {
|
||||
return false;
|
||||
}
|
||||
let mut parts = comma.splitn(2, ',');
|
||||
let Some(comma_card) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
let comma_card = comma_card.trim();
|
||||
let comma_pcm = parts.next().map(|s| s.trim()).unwrap_or("");
|
||||
if comma_pcm.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some((_, alsa_card, _)) = linux_alsa_sink_fingerprint(alsa) else {
|
||||
return false;
|
||||
};
|
||||
let pcm = comma_pcm.to_ascii_lowercase();
|
||||
let alsa_lower = alsa.to_ascii_lowercase();
|
||||
let cc = comma_card.to_ascii_lowercase();
|
||||
let ac = alsa_card.to_ascii_lowercase();
|
||||
let card_ok = cc.contains(&ac) || ac.contains(&cc);
|
||||
if !card_ok {
|
||||
return false;
|
||||
}
|
||||
if alsa_lower.starts_with("hdmi:") {
|
||||
return !pcm.contains("analog");
|
||||
}
|
||||
if pcm.contains("analog") {
|
||||
return alsa_lower.starts_with("hw:") || alsa_lower.starts_with("plughw:");
|
||||
}
|
||||
alsa_lower.contains(&pcm) || pcm.contains(&alsa_lower)
|
||||
}
|
||||
|
||||
/// Build the cpal-style `"CARD, PCM name"` label PipeWire exposes for ALSA sinks.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn cpal_name_from_pipewire_alsa(card: &str, alsa_name: &str) -> String {
|
||||
format!("{card}, {alsa_name}")
|
||||
}
|
||||
|
||||
/// Read `node.driver-id` from `wpctl inspect` output (PipeWire stream → sink link).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn parse_wpctl_inspect_driver_id(inspect: &str) -> Option<u32> {
|
||||
for line in inspect.lines() {
|
||||
let line = line.trim().trim_start_matches('*').trim();
|
||||
if let Some(v) = line.strip_prefix("node.driver-id = ") {
|
||||
return v.trim_matches('"').parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect PipeWire ALSA `[psysonic]` stream node ids that have at least one
|
||||
/// active playback link in `wpctl status` (ignores stale / idle nodes).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn parse_wpctl_status_psysonic_stream_ids(status: &str) -> Vec<u32> {
|
||||
let mut in_audio_streams = false;
|
||||
let mut ids = Vec::new();
|
||||
let mut current_id: Option<u32> = None;
|
||||
for line in status.lines() {
|
||||
if line.contains("Streams:") && line.contains('─') {
|
||||
in_audio_streams = true;
|
||||
continue;
|
||||
}
|
||||
if !in_audio_streams {
|
||||
continue;
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("Video") || trimmed.starts_with("Settings") {
|
||||
break;
|
||||
}
|
||||
if trimmed.contains("PipeWire ALSA [psysonic]") && !trimmed.contains("(deleted)") {
|
||||
current_id = trimmed
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|s| s.trim().parse().ok());
|
||||
continue;
|
||||
}
|
||||
if trimmed.contains('>')
|
||||
&& (trimmed.contains("[active]") || trimmed.contains("[init]"))
|
||||
{
|
||||
if let Some(id) = current_id {
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
} else if trimmed.contains('.') {
|
||||
let prefix = trimmed.split('.').next().unwrap_or("").trim();
|
||||
if prefix.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('>') {
|
||||
current_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_wpctl_inspect_driver_id(node_id: u32) -> Option<u32> {
|
||||
use std::process::Command;
|
||||
let inspect = Command::new("wpctl")
|
||||
.args(["inspect", &node_id.to_string()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
|
||||
parse_wpctl_inspect_driver_id(&inspect)
|
||||
}
|
||||
|
||||
/// True when a live psysonic PipeWire stream is already routed to the default sink.
|
||||
/// Hyprpanel / WirePlumber often migrate streams on `set-default` before our poll
|
||||
/// sees the change — reopening CPAL in that case only causes an audible glitch.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
|
||||
use std::process::Command;
|
||||
let Some(default_id) = linux_wpctl_default_sink_id() else {
|
||||
return false;
|
||||
};
|
||||
let Some(status) = Command::new("wpctl")
|
||||
.args(["status"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let stream_ids = parse_wpctl_status_psysonic_stream_ids(&status);
|
||||
stream_ids.iter().any(|&id| linux_wpctl_inspect_driver_id(id) == Some(default_id))
|
||||
}
|
||||
|
||||
/// Parse `wpctl list audio sinks` and return the id of the default sink (trailing `*`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn parse_wpctl_list_default_sink_id(listing: &str) -> Option<u32> {
|
||||
for line in listing.lines() {
|
||||
let line = line.trim_end();
|
||||
if !line.ends_with('*') {
|
||||
continue;
|
||||
}
|
||||
let id_str = line.split('\t').next()?.trim();
|
||||
return id_str.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse `wpctl status` and return the id of the default sink (line marked with `*`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn parse_wpctl_default_sink_id(status: &str) -> Option<u32> {
|
||||
let mut in_sinks = false;
|
||||
for line in status.lines() {
|
||||
if line.contains("Sinks:") {
|
||||
in_sinks = true;
|
||||
continue;
|
||||
}
|
||||
if !in_sinks {
|
||||
continue;
|
||||
}
|
||||
if line.contains("Sources:") {
|
||||
break;
|
||||
}
|
||||
if !line.contains('*') {
|
||||
continue;
|
||||
}
|
||||
let after_star = line.split('*').nth(1)?.trim();
|
||||
let id_str = after_star.split('.').next()?.trim();
|
||||
return id_str.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read `api.alsa.card.name` + `alsa.name` from `wpctl inspect` output.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn parse_wpctl_inspect_alsa_names(inspect: &str) -> Option<(String, String)> {
|
||||
let mut card: Option<String> = None;
|
||||
let mut pcm: Option<String> = None;
|
||||
for line in inspect.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(v) = line.strip_prefix("api.alsa.card.name = ") {
|
||||
card = Some(v.trim_matches('"').to_string());
|
||||
} else if card.is_none() {
|
||||
if let Some(v) = line.strip_prefix("alsa.card_name = ") {
|
||||
card = Some(v.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
if let Some(v) = line.strip_prefix("alsa.name = ") {
|
||||
pcm = Some(v.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
match (card, pcm) {
|
||||
(Some(c), Some(n)) if !c.is_empty() && !n.is_empty() => Some((c, n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_wpctl_default_sink_id() -> Option<u32> {
|
||||
use std::process::Command;
|
||||
let listing = Command::new("wpctl")
|
||||
.args(["list", "audio", "sinks"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
|
||||
if let Some(ref text) = listing {
|
||||
if let Some(id) = parse_wpctl_list_default_sink_id(text) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
let status = Command::new("wpctl")
|
||||
.args(["status"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
|
||||
parse_wpctl_default_sink_id(&status)
|
||||
}
|
||||
|
||||
/// Read `node.description` from `wpctl inspect` (Bluetooth and other non-ALSA sinks).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn parse_wpctl_inspect_node_description(inspect: &str) -> Option<String> {
|
||||
for line in inspect.lines() {
|
||||
let line = line.trim().trim_start_matches('*').trim();
|
||||
if let Some(v) = line.strip_prefix("node.description = ") {
|
||||
let desc = v.trim_matches('"').to_string();
|
||||
if !desc.is_empty() {
|
||||
return Some(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_resolve_default_via_pipewire(list: &[String]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let sink_id = linux_wpctl_default_sink_id()?;
|
||||
let inspect = Command::new("wpctl")
|
||||
.args(["inspect", &sink_id.to_string()])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
|
||||
let candidate = if let Some((card, pcm)) = parse_wpctl_inspect_alsa_names(&inspect) {
|
||||
cpal_name_from_pipewire_alsa(&card, &pcm)
|
||||
} else {
|
||||
parse_wpctl_inspect_node_description(&inspect)?
|
||||
};
|
||||
pick_listed_device_name(&candidate, list).or(Some(candidate))
|
||||
}
|
||||
|
||||
/// Resolve the active default output to a device key that matches `audio_list_devices`
|
||||
/// when possible. On Linux/PipeWire, cpal's default is often a generic alias or a
|
||||
/// stale card name that does not track WirePlumber default changes (Hyprpanel,
|
||||
/// pavucontrol, `wpctl set-default`, etc.) — prefer `wpctl` when available.
|
||||
pub fn effective_default_output_device_name() -> Option<String> {
|
||||
resolve_effective_default_output_device_name(true)
|
||||
}
|
||||
|
||||
/// Same as [`effective_default_output_device_name`] but skips the full
|
||||
/// `output_devices()` scan — for the device-watcher poll path (#996).
|
||||
pub(crate) fn effective_default_output_device_name_for_poll() -> Option<String> {
|
||||
resolve_effective_default_output_device_name(false)
|
||||
}
|
||||
|
||||
fn resolve_effective_default_output_device_name(enumerate_devices: bool) -> Option<String> {
|
||||
// Windows/macOS: single cpal default query (pre-#1274). Full `output_devices()`
|
||||
// enumeration contends with WASAPI/CoreAudio and is only needed for Linux/PipeWire
|
||||
// default resolution + ALSA logical key matching.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = enumerate_devices;
|
||||
return raw_cpal_default_output_device_key();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let list = if enumerate_devices {
|
||||
enumerate_output_device_names()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(resolved) = linux_resolve_default_via_pipewire(&list) {
|
||||
return Some(resolved);
|
||||
}
|
||||
if !enumerate_devices {
|
||||
// wpctl unavailable — last-resort cpal (skip generic/stale placeholder names).
|
||||
if linux_wpctl_default_sink_id().is_none() {
|
||||
if let Some(raw) = raw_cpal_default_output_device_key() {
|
||||
if !is_generic_default_output_alias(&raw) {
|
||||
return Some(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let raw = raw_cpal_default_output_device_key();
|
||||
if let Some(ref name) = raw {
|
||||
if !is_generic_default_output_alias(name) {
|
||||
return pick_listed_device_name(name, &list).or_else(|| Some(name.clone()));
|
||||
}
|
||||
}
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
@@ -594,20 +72,6 @@ pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
if let (Ok(ida), Ok(idb)) = (
|
||||
a.parse::<rodio::cpal::DeviceId>(),
|
||||
b.parse::<rodio::cpal::DeviceId>(),
|
||||
) {
|
||||
return ida.1 == idb.1;
|
||||
}
|
||||
if legacy_description_key_matches_device_id(a, b)
|
||||
|| legacy_description_key_matches_device_id(b, a)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match (
|
||||
linux_alsa_sink_fingerprint(a),
|
||||
linux_alsa_sink_fingerprint(b),
|
||||
@@ -617,32 +81,7 @@ pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre–DeviceId persisted pins (description names) vs cpal `DeviceId` enumeration keys.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn legacy_description_key_matches_device_id(legacy: &str, device_id_key: &str) -> bool {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use std::str::FromStr;
|
||||
if legacy.parse::<rodio::cpal::DeviceId>().is_ok() {
|
||||
return false;
|
||||
}
|
||||
let Ok(id) = rodio::cpal::DeviceId::from_str(device_id_key) else {
|
||||
return legacy == device_id_key;
|
||||
};
|
||||
let Some(device) = rodio::cpal::default_host().device_by_id(&id) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(desc) = device.description() else {
|
||||
return false;
|
||||
};
|
||||
if desc.name() == legacy {
|
||||
return true;
|
||||
}
|
||||
let label = output_device_display_label(&device);
|
||||
label == legacy || label.starts_with(&format!("{legacy} · "))
|
||||
}
|
||||
|
||||
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
|
||||
available
|
||||
.iter()
|
||||
@@ -671,21 +110,18 @@ mod tests {
|
||||
// ── output_enumeration_includes_pinned ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
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]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn includes_pinned_returns_false_when_absent() {
|
||||
let avail = vec!["A".to_string(), "B".to_string()];
|
||||
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn includes_pinned_returns_false_for_empty_list() {
|
||||
let avail: Vec<String> = vec![];
|
||||
assert!(!output_enumeration_includes_pinned(&avail, "anything"));
|
||||
@@ -752,165 +188,4 @@ mod tests {
|
||||
assert!(linux_alsa_sink_fingerprint("hdmi:CARD=X,DEV=0").is_none());
|
||||
assert!(linux_alsa_sink_fingerprint("anything").is_none());
|
||||
}
|
||||
|
||||
// ── generic default alias / PipeWire wpctl parsing ────────────────────────
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn generic_default_alias_detects_cpal_pipewire_placeholders() {
|
||||
assert!(is_generic_default_output_alias("Default Audio Device"));
|
||||
assert!(is_generic_default_output_alias("PipeWire Sound Server"));
|
||||
assert!(!is_generic_default_output_alias("HDA NVidia, Gigabyte M32U"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_status_psysonic_stream_ids_accepts_init_links_when_paused() {
|
||||
let status = r#"
|
||||
Audio
|
||||
└─ Streams:
|
||||
84. PipeWire ALSA [psysonic]
|
||||
90. output_FL > ALC897 Analog:playback_FL [init]
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![84]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_status_psysonic_stream_ids_ignores_streams_without_links() {
|
||||
let status = r#"
|
||||
Audio
|
||||
└─ Streams:
|
||||
84. PipeWire ALSA [psysonic]
|
||||
87. PipeWire ALSA [psysonic]
|
||||
106. output_FL > HDMI:playback_FL [active]
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![87]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_status_psysonic_stream_ids_finds_active_streams() {
|
||||
let status = r#"
|
||||
Audio
|
||||
└─ Streams:
|
||||
84. PipeWire ALSA [psysonic]
|
||||
90. output_FL > ALC897 Analog:playback_FL [active]
|
||||
119. PipeWire ALSA [psysonic (deleted)]
|
||||
Video
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_wpctl_status_psysonic_stream_ids(status),
|
||||
vec![84]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_inspect_driver_id_reads_node_driver() {
|
||||
let inspect = r#"
|
||||
* node.driver-id = "58"
|
||||
node.name = "alsa_playback.psysonic"
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_inspect_driver_id(inspect), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_list_default_sink_id_finds_starred_sink() {
|
||||
let listing = "56\talsa_output.pci-hdmi\taudio/sink\t\n58\talsa_output.pci-analog\taudio/sink\t*";
|
||||
assert_eq!(parse_wpctl_list_default_sink_id(listing), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_default_sink_id_finds_starred_sink() {
|
||||
let status = r#"
|
||||
Audio
|
||||
├─ Devices:
|
||||
├─ Sinks:
|
||||
│ 56. HDMI out
|
||||
│ * 58. Analog out
|
||||
├─ Sources:
|
||||
"#;
|
||||
assert_eq!(parse_wpctl_default_sink_id(status), Some(58));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_inspect_alsa_names_reads_card_and_pcm() {
|
||||
let inspect = r#"
|
||||
api.alsa.card.name = "HD-Audio Generic"
|
||||
alsa.name = "ALC897 Analog"
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_wpctl_inspect_alsa_names(inspect),
|
||||
Some(("HD-Audio Generic".into(), "ALC897 Analog".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
cpal_name_from_pipewire_alsa("HD-Audio Generic", "ALC897 Analog"),
|
||||
"HD-Audio Generic, ALC897 Analog"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_wpctl_inspect_node_description_reads_bluetooth_sink() {
|
||||
let inspect = r#"
|
||||
* node.description = "BlueZ Audio Device"
|
||||
node.name = "bluez_output.xxx"
|
||||
"#;
|
||||
assert_eq!(
|
||||
parse_wpctl_inspect_node_description(inspect),
|
||||
Some("BlueZ Audio Device".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn output_device_keys_equivalent_links_hdmi_comma_and_alsa_id() {
|
||||
assert!(output_device_keys_equivalent(
|
||||
"HDA NVidia, Gigabyte M32U",
|
||||
"hdmi:CARD=NVidia,DEV=3",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn output_device_keys_equivalent_distinguishes_analog_and_hdmi() {
|
||||
assert!(!output_device_keys_equivalent(
|
||||
"HD-Audio Generic, ALC897 Analog",
|
||||
"hdmi:CARD=HD-Audio Generic,DEV=3",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pick_listed_device_name_prefers_enumerated_entry() {
|
||||
let list = vec![
|
||||
"Default Audio Device".to_string(),
|
||||
"HDA NVidia, Gigabyte M32U".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_listed_device_name("HDA NVidia, Gigabyte M32U", &list),
|
||||
Some("HDA NVidia, Gigabyte M32U".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pick_listed_device_name_matches_linux_alsa_logical_alias() {
|
||||
let list = vec!["hdmi:CARD=NVidia,DEV=3".to_string()];
|
||||
assert_eq!(
|
||||
pick_listed_device_name("hw:CARD=NVidia,DEV=3", &list),
|
||||
None,
|
||||
"different ALSA ifaces are not logically the same"
|
||||
);
|
||||
assert_eq!(
|
||||
pick_listed_device_name("hdmi:CARD=NVidia,DEV=3", &list),
|
||||
Some("hdmi:CARD=NVidia,DEV=3".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,135 +7,67 @@ use std::sync::atomic::Ordering;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_entries, legacy_output_device_display_label,
|
||||
output_devices_logically_same, resolve_legacy_pinned_key, OutputDeviceEntry,
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::dev_io::output_device_keys_equivalent;
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// One row in the audio output device picker (`key` is persisted; `label` is display-only).
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
pub struct AudioOutputDeviceEntry {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl From<OutputDeviceEntry> for AudioOutputDeviceEntry {
|
||||
fn from(e: OutputDeviceEntry) -> Self {
|
||||
Self {
|
||||
key: e.key,
|
||||
label: e.label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_output_device_entries(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
|
||||
let mut list: Vec<AudioOutputDeviceEntry> = enumerate_output_device_entries()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
||||
if !name.is_empty()
|
||||
&& !list
|
||||
.iter()
|
||||
.any(|e| output_devices_logically_same(&e.key, name))
|
||||
{
|
||||
list.push(AudioOutputDeviceEntry {
|
||||
key: name.clone(),
|
||||
label: legacy_output_device_display_label(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// 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]
|
||||
#[specta::specta]
|
||||
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 entries = list_output_device_entries(&state);
|
||||
if entries.iter().any(|e| e.key == pinned) {
|
||||
let list = enumerate_output_device_names();
|
||||
if list.iter().any(|d| d == &pinned) {
|
||||
return None;
|
||||
}
|
||||
if let Some(upgraded) = resolve_legacy_pinned_key(&pinned, &enumerate_output_device_entries()) {
|
||||
if upgraded != pinned {
|
||||
*state.selected_device.lock().unwrap() = Some(upgraded.clone());
|
||||
return Some(upgraded);
|
||||
}
|
||||
}
|
||||
let canon = entries
|
||||
let canon = list
|
||||
.iter()
|
||||
.find(|e| output_devices_logically_same(&e.key, &pinned))?
|
||||
.key
|
||||
.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<AudioOutputDeviceEntry> {
|
||||
list_output_device_entries(engine)
|
||||
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 keys of all available audio output devices on the current host.
|
||||
/// 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 is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// 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]
|
||||
#[specta::specta]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<AudioOutputDeviceEntry> {
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub fn audio_default_output_device_name() -> Option<String> {
|
||||
super::dev_io::effective_default_output_device_name()
|
||||
}
|
||||
|
||||
/// Lightweight default query for EQ poll — skips full `output_devices()` scan (#996).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_default_output_device_name_for_poll() -> Option<String> {
|
||||
super::dev_io::effective_default_output_device_name_for_poll()
|
||||
}
|
||||
|
||||
/// Find a stored per-device EQ key that denotes the same sink as `candidate`
|
||||
/// (exact or Linux ALSA logical match).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_match_stored_output_device_key(
|
||||
candidate: String,
|
||||
stored_keys: Vec<String>,
|
||||
) -> Option<String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
return stored_keys
|
||||
.into_iter()
|
||||
.find(|k| output_devices_logically_same(k, &candidate));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let list = super::dev_io::enumerate_output_device_names();
|
||||
stored_keys
|
||||
.into_iter()
|
||||
.find(|k| output_device_keys_equivalent(k, &candidate, &list))
|
||||
}
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub async fn audio_set_device(
|
||||
device_name: Option<String>,
|
||||
state: State<'_, AudioEngine>,
|
||||
|
||||
@@ -161,8 +161,8 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
resample_target_hz: 0,
|
||||
duration_hint: snap.duration_secs,
|
||||
autodj_in: None,
|
||||
},
|
||||
&engine,
|
||||
app,
|
||||
@@ -211,9 +211,12 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
gain_linear: snap.gain_linear,
|
||||
fadeout_trigger: ps.built.fadeout_trigger,
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
fadeout_linear: ps.built.fadeout_linear,
|
||||
fadeout_end_gain: ps.built.fadeout_end_gain,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
outgoing_fade_secs: 0.0,
|
||||
outgoing_linear_end_gain: None,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,7 +132,10 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
|
||||
super::dev_io::effective_default_output_device_name_for_poll()
|
||||
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.
|
||||
@@ -243,6 +246,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
|
||||
// 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);
|
||||
@@ -255,9 +259,17 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let default = super::dev_io::effective_default_output_device_name_for_poll();
|
||||
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 {
|
||||
super::dev_io::enumerate_output_device_names()
|
||||
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()
|
||||
};
|
||||
@@ -314,57 +326,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(new_name) = current_default else {
|
||||
// Transient wpctl/cpal miss — keep last known default.
|
||||
continue;
|
||||
};
|
||||
last_default = current_default.clone();
|
||||
|
||||
if last_default.is_none() {
|
||||
last_default = Some(new_name.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Linux/PipeWire: cpal default labels can drift while the physical sink
|
||||
// is unchanged — compare via ALSA logical keys before reopening.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref prev) = last_default {
|
||||
let prev_name = prev.clone();
|
||||
let new_name_for_eq = new_name.clone();
|
||||
let same_sink = tauri::async_runtime::spawn_blocking(move || {
|
||||
let list = super::dev_io::enumerate_output_device_names();
|
||||
super::dev_io::output_device_keys_equivalent(
|
||||
&prev_name,
|
||||
&new_name_for_eq,
|
||||
&list,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if same_sink {
|
||||
last_default = Some(new_name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
last_default = Some(new_name.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;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let stream_on_default = tauri::async_runtime::spawn_blocking(|| {
|
||||
super::dev_io::linux_psysonic_stream_routes_to_default_sink()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if stream_on_default {
|
||||
// PipeWire already moved playback — notify frontend (EQ sync) only.
|
||||
app.emit("audio:device-changed", Option::<f64>::None).ok();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
|
||||
}
|
||||
|
||||
@@ -141,6 +141,11 @@ pub struct AudioCurrent {
|
||||
pub fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
/// Crossfade: total fade samples (set before triggering).
|
||||
pub fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
/// AutoDJ edge-mix: set true at handoff to fade this source linearly to
|
||||
/// `fadeout_end_gain` then hold (instead of the equal-power cos → 0).
|
||||
pub fadeout_linear: Option<Arc<AtomicBool>>,
|
||||
/// AutoDJ edge-mix: outgoing end gain (f32 bits) the linear fade holds at.
|
||||
pub fadeout_end_gain: Option<Arc<AtomicU32>>,
|
||||
}
|
||||
|
||||
impl AudioCurrent {
|
||||
@@ -206,23 +211,21 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
// 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_key = |key: &str| -> Option<_> {
|
||||
super::dev_io::resolve_output_device(key).or_else(|| {
|
||||
host.output_devices().ok()?.find(|d| {
|
||||
d.description()
|
||||
.ok()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.as_deref()
|
||||
== Some(key)
|
||||
})
|
||||
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_key)
|
||||
.and_then(find_by_name)
|
||||
.or_else(|| {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ find_by_key("pipewire").or_else(|| find_by_key("pulse")) }
|
||||
{ find_by_name("pipewire").or_else(|| find_by_name("pulse")) }
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ None }
|
||||
})
|
||||
@@ -466,6 +469,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
fadeout_linear: None,
|
||||
fadeout_end_gain: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(
|
||||
@@ -580,7 +585,15 @@ pub(crate) fn apply_playback_request_headers(
|
||||
url: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
psysonic_core::server_http::apply_optional_registry_headers(registry, server_id, url, req)
|
||||
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.
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
|
||||
//! AutoDJ, or gapless must cross a sample-rate boundary.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::playback_rate::raw_counter_samples_for_content_position;
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
const BLEND_44100: u32 = 44_100;
|
||||
const BLEND_88200: u32 = 88_200;
|
||||
const BLEND_96000: u32 = 96_000;
|
||||
|
||||
/// User-selected blend rate for hi-res transitions; `None` when inactive.
|
||||
pub(crate) fn blend_rate_hz(
|
||||
hi_res_enabled: bool,
|
||||
transition_blend_active: bool,
|
||||
hz: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
if !hi_res_enabled || !transition_blend_active {
|
||||
return None;
|
||||
}
|
||||
let raw = hz.unwrap_or(BLEND_44100);
|
||||
match raw {
|
||||
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
|
||||
_ => Some(BLEND_44100),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OutgoingBlendSnapshot {
|
||||
pub(crate) url: String,
|
||||
pub(crate) position_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Capture the currently playing track before a hi-res blend stream reopen.
|
||||
pub(crate) fn capture_outgoing_blend_snapshot(
|
||||
state: &AudioEngine,
|
||||
outgoing_fade_secs: f32,
|
||||
actual_fade_secs: f32,
|
||||
) -> Option<OutgoingBlendSnapshot> {
|
||||
let url = state.current_playback_url.lock().unwrap().clone()?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let playing = cur.sink.is_some() && cur.paused_at.is_none();
|
||||
(
|
||||
cur.position(),
|
||||
cur.duration_secs,
|
||||
cur.base_volume,
|
||||
cur.replay_gain_linear,
|
||||
playing,
|
||||
)
|
||||
};
|
||||
if !playing {
|
||||
return None;
|
||||
}
|
||||
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
|
||||
Some(OutgoingBlendSnapshot {
|
||||
url,
|
||||
position_secs,
|
||||
duration_secs,
|
||||
base_volume,
|
||||
gain_linear,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
analysis_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the live main sink so a stream reopen does not leave dangling players.
|
||||
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() {
|
||||
old.stop();
|
||||
}
|
||||
cur.fadeout_trigger = None;
|
||||
cur.fadeout_samples = None;
|
||||
}
|
||||
|
||||
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
|
||||
if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
return Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[hi-res-blend]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
});
|
||||
}
|
||||
|
||||
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())
|
||||
};
|
||||
let path = spill_path?;
|
||||
std::fs::read(&path).ok()?
|
||||
};
|
||||
Some(PlayInput::Bytes(bytes))
|
||||
}
|
||||
|
||||
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
|
||||
pub(crate) async fn spawn_outgoing_blend_resample(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.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: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
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()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
let fade_secs = snap.outgoing_fade_secs;
|
||||
if fade_secs > 0.0 {
|
||||
let rate = blend_rate;
|
||||
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
ps.built
|
||||
.fadeout_samples
|
||||
.store(fade_total.max(1), Ordering::SeqCst);
|
||||
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
sink.play();
|
||||
*state.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
|
||||
snap.position_secs,
|
||||
fade_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
|
||||
/// chain realign) so the next source can append to the same sink.
|
||||
pub(crate) async fn rebuild_current_track_at_blend_rate(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] current track not cached for gapless realign: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.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: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
state
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
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);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
sink.play();
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = ps.built.duration_secs;
|
||||
cur.seek_offset = snap.position_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = snap.gain_linear;
|
||||
cur.base_volume = snap.base_volume;
|
||||
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
|
||||
cur.fadeout_samples = Some(ps.built.fadeout_samples);
|
||||
}
|
||||
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
snap.position_secs,
|
||||
ps.built.output_rate,
|
||||
ps.built.output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
|
||||
snap.position_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_rate_inactive_without_hi_res_or_transition() {
|
||||
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
|
||||
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_rate_sanitizes_hz() {
|
||||
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod hi_res_blend;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
|
||||
@@ -11,7 +11,6 @@ use super::helpers::*;
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
@@ -23,7 +22,6 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
@@ -134,7 +132,6 @@ pub fn audio_update_replay_gain(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
|
||||
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
|
||||
@@ -144,14 +141,12 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
||||
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
||||
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
@@ -159,7 +154,6 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
/// Duck the current sink over `fade_secs` without exhausting its source (which
|
||||
/// would spuriously emit `audio:ended` before the interrupt handoff).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
|
||||
let fade_secs = fade_secs.clamp(0.1, 12.0);
|
||||
let cur = state.current.lock().unwrap();
|
||||
@@ -179,7 +173,6 @@ pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>)
|
||||
/// (only when the next track is actually playable). When `false`, the engine's
|
||||
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state
|
||||
.autodj_suppress_autocrossfade
|
||||
@@ -187,7 +180,6 @@ pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
strategy: String,
|
||||
@@ -271,7 +263,6 @@ pub fn audio_set_playback_rate(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
|
||||
@@ -114,7 +114,6 @@ fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
|
||||
@@ -336,7 +336,6 @@ async fn open_preview_decoder(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
|
||||
pub async fn audio_preview_play(
|
||||
id: String,
|
||||
@@ -533,7 +532,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
}
|
||||
@@ -544,7 +542,6 @@ pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
/// auto-resume main playback the moment the preview ends and the user perceives
|
||||
/// the click as having no effect.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, false);
|
||||
}
|
||||
@@ -555,7 +552,6 @@ pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>)
|
||||
/// start, so the engine just clamps and applies the master headroom. No-op
|
||||
/// when no preview is active.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_preview_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
if let Some(sink) = state.preview_sink.lock().unwrap().as_ref() {
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
|
||||
@@ -364,6 +364,8 @@ mod tests {
|
||||
base_volume: 1.0,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
fadeout_linear: None,
|
||||
fadeout_end_gain: None,
|
||||
};
|
||||
Self {
|
||||
gen: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Live internet-radio playback. Distinct from main track playback: no
|
||||
//! gapless chain, no seek, no replay-gain, no preload.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -31,7 +31,6 @@ use super::stream::{
|
||||
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
|
||||
/// and `radio:metadata` whenever the StreamTitle changes.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn audio_play_radio(
|
||||
url: String,
|
||||
volume: f32,
|
||||
@@ -137,6 +136,9 @@ pub async fn audio_play_radio(
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||
// Radio never uses the AutoDJ linear edge-mix → fixed non-linear defaults.
|
||||
let fadeout_linear = Arc::new(AtomicBool::new(false));
|
||||
let fadeout_end_gain = Arc::new(AtomicU32::new(0));
|
||||
state.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
|
||||
@@ -144,7 +146,8 @@ pub async fn audio_play_radio(
|
||||
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(), state.eq_pre_gain.clone());
|
||||
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone(),
|
||||
fadeout_linear.clone(), fadeout_end_gain.clone());
|
||||
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
|
||||
let counting = CountingSource::new(notifying, state.samples_played.clone());
|
||||
let boosted = PriorityBoostSource::new(counting);
|
||||
@@ -168,6 +171,8 @@ pub async fn audio_play_radio(
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(fadeout_trigger);
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
cur.fadeout_linear = Some(fadeout_linear);
|
||||
cur.fadeout_end_gain = Some(fadeout_end_gain);
|
||||
}
|
||||
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! download task arms playback. Split out of `play_input.rs` so source
|
||||
//! selection and source building stay focused on their own concerns.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -88,21 +88,32 @@ pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
/// New track's linear-mix control Arcs (stored so the *next* swap can drive
|
||||
/// this track's outgoing fade); inert until a later handoff sets them.
|
||||
pub(crate) fadeout_linear: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_end_gain: Arc<AtomicU32>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
|
||||
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
/// AutoDJ edge-mix: when `Some(end_gain)`, fade outgoing A *linearly* to
|
||||
/// `end_gain` then hold (instead of the equal-power cos → 0). `None` = cos.
|
||||
pub(crate) outgoing_linear_end_gain: Option<f32>,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
old_fadeout_linear: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_end_gain: Option<Arc<AtomicU32>>,
|
||||
linear_end_gain: Option<f32>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
@@ -117,6 +128,15 @@ fn handoff_old_sink_fade_out(
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
// AutoDJ edge-mix: configure the outgoing source's linear fade-to-hold before
|
||||
// arming the trigger, so its first fade sample reads the right mode + gain.
|
||||
if let (Some(end_gain), Some(linear), Some(bits)) =
|
||||
(linear_end_gain, old_fadeout_linear, old_fadeout_end_gain)
|
||||
{
|
||||
bits.store(end_gain.clamp(0.0, 1.0).to_bits(), Ordering::SeqCst);
|
||||
linear.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
@@ -145,17 +165,22 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
fadeout_linear: new_fadeout_linear,
|
||||
fadeout_end_gain: new_fadeout_end_gain,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
outgoing_linear_end_gain,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples, old_fadeout_linear, old_fadeout_end_gain) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
let old_fo_linear = cur.fadeout_linear.take();
|
||||
let old_fo_end_gain = cur.fadeout_end_gain.take();
|
||||
cur.sink = Some(sink.clone());
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
@@ -171,18 +196,25 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
cur.fadeout_linear = Some(new_fadeout_linear);
|
||||
cur.fadeout_end_gain = Some(new_fadeout_end_gain);
|
||||
(old, old_fo_trigger, old_fo_samples, old_fo_linear, old_fo_end_gain)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
// AutoDJ edge-mix: `outgoing_linear_end_gain` drives a linear
|
||||
// fade-to-hold instead of the equal-power cos → 0.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
old_fadeout_linear,
|
||||
old_fadeout_end_gain,
|
||||
outgoing_linear_end_gain,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ use tauri::{AppHandle, State};
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::decode::{build_source, build_streaming_source, AutodjFadeIn, BuiltSource, SizedDecoder};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
|
||||
use super::play_input::PlayInput;
|
||||
@@ -33,18 +33,9 @@ pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
|
||||
pub resample_target_hz: u32,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
|
||||
struct PlaybackSourceShape {
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
resample_target_hz: u32,
|
||||
duration_hint: f64,
|
||||
/// AutoDJ edge-mix incoming linear fade-in endpoints (None = equal-power).
|
||||
pub autodj_in: AutodjFadeIn,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
@@ -194,8 +185,8 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
@@ -208,15 +199,16 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
let shape = PlaybackSourceShape {
|
||||
done_flag: done_flag.clone(),
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
};
|
||||
|
||||
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
|
||||
autodj_in,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
@@ -273,7 +265,11 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
&shape,
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -305,13 +301,11 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
&PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
autodj_in,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -325,36 +319,36 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
shape: &PlaybackSourceShape,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
autodj_in: AutodjFadeIn,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = shape;
|
||||
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
|
||||
let target_rate: u32 = *resample_target_hz;
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
*duration_hint,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
*hi_res_enabled,
|
||||
hi_res_enabled,
|
||||
autodj_in,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
@@ -376,16 +370,17 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
*duration_hint,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
autodj_in,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
@@ -402,16 +397,17 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
*duration_hint,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
autodj_in,
|
||||
)
|
||||
}
|
||||
}?;
|
||||
|
||||
@@ -239,6 +239,77 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── LinearGainEnvelopeIn — AutoDJ edge-mix incoming equal-power fade-in ─────
|
||||
//
|
||||
// Incoming track B on the AutoDJ edge-mix path: gain rises via equal-power
|
||||
// power-lerp from `start_gain` (= 1 − linear_B(0), may be > 0 when B starts loud)
|
||||
// to `end_gain` (always 1.0) across the mix window, then holds `end_gain`:
|
||||
// g(t) = sqrt(start²·(1−t) + end²·t)
|
||||
// For a symmetric 0→1 fade this matches sin(t·π/2); for non-zero endpoints it
|
||||
// generalises scenario A/B while keeping g_A²+g_B² ≈ 1 when paired with the
|
||||
// outgoing power-lerp on Track A.
|
||||
|
||||
pub(crate) struct LinearGainEnvelopeIn<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
sample_count: u64,
|
||||
fade_samples: u64,
|
||||
start_gain: f32,
|
||||
end_gain: f32,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> LinearGainEnvelopeIn<S> {
|
||||
pub(crate) fn new(inner: S, fade_dur: Duration, start_gain: f32, end_gain: f32) -> Self {
|
||||
let sample_rate = inner.sample_rate();
|
||||
let channels = inner.channels().get() as u64;
|
||||
let fade_samples = if fade_dur.is_zero() {
|
||||
0
|
||||
} else {
|
||||
(fade_dur.as_secs_f64() * sample_rate.get() as f64 * channels as f64) as u64
|
||||
};
|
||||
Self {
|
||||
inner,
|
||||
sample_count: 0,
|
||||
fade_samples,
|
||||
start_gain: start_gain.clamp(0.0, 1.0),
|
||||
end_gain: end_gain.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Iterator for LinearGainEnvelopeIn<S> {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
let sample = self.inner.next()?;
|
||||
let gain = if self.fade_samples == 0 || self.sample_count >= self.fade_samples {
|
||||
self.end_gain
|
||||
} else {
|
||||
let t = self.sample_count as f32 / self.fade_samples as f32;
|
||||
(self.start_gain.powi(2) * (1.0 - t) + self.end_gain.powi(2) * t).sqrt()
|
||||
};
|
||||
self.sample_count += 1;
|
||||
Some((sample * gain).clamp(-1.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> Source for LinearGainEnvelopeIn<S> {
|
||||
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
|
||||
fn channels(&self) -> rodio::ChannelCount { self.inner.channels() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
if self.sample_count == 0 {
|
||||
// Initial start-offset seek (B-head: skip leading silence). Keep the
|
||||
// envelope so B still rises in from its trimmed start.
|
||||
} else if pos.as_millis() < 100 {
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
// Mid-playback seek elsewhere → jump to the held end gain.
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─
|
||||
//
|
||||
// Every track source is wrapped with this. It passes through at unity gain
|
||||
@@ -255,20 +326,40 @@ pub(crate) struct TriggeredFadeOut<S: Source<Item = f32>> {
|
||||
inner: S,
|
||||
trigger: Arc<AtomicBool>,
|
||||
fade_total_samples: Arc<AtomicU64>,
|
||||
// AutoDJ edge-mix: when `linear` is set at trigger time, fade via equal-power
|
||||
// power-lerp from 1.0 to `end_gain` over the fade window, then **hold**
|
||||
// `end_gain` until the inner source exhausts (generalised scenario A — the
|
||||
// recording carries the outgoing track the rest of the way at a fixed engine
|
||||
// gain). When `linear` is false the classic equal-power `cos(t·π/2) → 0 →
|
||||
// None` path runs.
|
||||
linear: Arc<AtomicBool>,
|
||||
end_gain_bits: Arc<AtomicU32>,
|
||||
fade_progress: u64,
|
||||
fading: bool,
|
||||
cached_total: u64,
|
||||
cached_linear: bool,
|
||||
cached_end_gain: f32,
|
||||
}
|
||||
|
||||
impl<S: Source<Item = f32>> TriggeredFadeOut<S> {
|
||||
pub(crate) fn new(inner: S, trigger: Arc<AtomicBool>, fade_total_samples: Arc<AtomicU64>) -> Self {
|
||||
pub(crate) fn new(
|
||||
inner: S,
|
||||
trigger: Arc<AtomicBool>,
|
||||
fade_total_samples: Arc<AtomicU64>,
|
||||
linear: Arc<AtomicBool>,
|
||||
end_gain_bits: Arc<AtomicU32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
trigger,
|
||||
fade_total_samples,
|
||||
linear,
|
||||
end_gain_bits,
|
||||
fade_progress: 0,
|
||||
fading: false,
|
||||
cached_total: 0,
|
||||
cached_linear: false,
|
||||
cached_end_gain: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,17 +371,29 @@ impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
|
||||
if !self.fading && self.trigger.load(Ordering::Relaxed) {
|
||||
self.fading = true;
|
||||
self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1);
|
||||
self.cached_linear = self.linear.load(Ordering::Relaxed);
|
||||
self.cached_end_gain = f32::from_bits(self.end_gain_bits.load(Ordering::Relaxed)).clamp(0.0, 1.0);
|
||||
self.fade_progress = 0;
|
||||
}
|
||||
|
||||
if self.fading {
|
||||
if self.fade_progress >= self.cached_total {
|
||||
// Linear edge-mix with a non-zero end gain: hold that gain so the
|
||||
// outgoing recording keeps playing under the incoming track.
|
||||
if self.cached_linear && self.cached_end_gain > 0.001 {
|
||||
let sample = self.inner.next()?;
|
||||
return Some((sample * self.cached_end_gain).clamp(-1.0, 1.0));
|
||||
}
|
||||
// Fade complete — exhaust the source.
|
||||
return None;
|
||||
}
|
||||
let sample = self.inner.next()?;
|
||||
let t = self.fade_progress as f32 / self.cached_total as f32;
|
||||
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
|
||||
let gain = if self.cached_linear {
|
||||
(1.0 * (1.0 - t) + self.cached_end_gain.powi(2) * t).sqrt()
|
||||
} else {
|
||||
(t * std::f32::consts::FRAC_PI_2).cos()
|
||||
};
|
||||
self.fade_progress += 1;
|
||||
Some((sample * gain).clamp(-1.0, 1.0))
|
||||
} else {
|
||||
@@ -558,3 +661,101 @@ mod counting_source_tests {
|
||||
assert_eq!(counter.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod edge_mix_tests {
|
||||
use super::*;
|
||||
|
||||
/// Constant 1.0 source: mono, 4 Hz → a 1-second fade spans exactly 4 samples.
|
||||
struct Ones(usize);
|
||||
impl Iterator for Ones {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
if self.0 == 0 {
|
||||
None
|
||||
} else {
|
||||
self.0 -= 1;
|
||||
Some(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Source for Ones {
|
||||
fn current_span_len(&self) -> Option<usize> { Some(1) }
|
||||
fn channels(&self) -> rodio::ChannelCount { std::num::NonZero::new(1).unwrap() }
|
||||
fn sample_rate(&self) -> rodio::SampleRate { std::num::NonZero::new(4).unwrap() }
|
||||
fn total_duration(&self) -> Option<Duration> { None }
|
||||
}
|
||||
|
||||
fn end_gain_arc(g: f32) -> Arc<AtomicU32> {
|
||||
Arc::new(AtomicU32::new(g.to_bits()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fade_in_lerps_then_holds_end_gain() {
|
||||
let out: Vec<f32> =
|
||||
LinearGainEnvelopeIn::new(Ones(8), Duration::from_secs(1), 0.25, 1.0).collect();
|
||||
assert!((out[0] - 0.25).abs() < 1e-4); // p=0 → start_gain
|
||||
// p=0.5 → sqrt(0.25²·0.5 + 1²·0.5)
|
||||
assert!((out[2] - 0.7289).abs() < 1e-3);
|
||||
assert!((out[4] - 1.0).abs() < 1e-4); // after fade → end_gain
|
||||
assert!((out[7] - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_linear_fade_out_holds_nonzero_end_gain() {
|
||||
let src = TriggeredFadeOut::new(
|
||||
Ones(8),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicU64::new(4)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
end_gain_arc(0.5),
|
||||
);
|
||||
let out: Vec<f32> = src.collect();
|
||||
assert!((out[0] - 1.0).abs() < 1e-4); // p=0 → outgoing_gain_start (1.0)
|
||||
// p=0.5 → sqrt(1·0.5 + 0.5²·0.5)
|
||||
assert!((out[2] - 0.7906).abs() < 1e-3);
|
||||
assert!((out[4] - 0.5).abs() < 1e-4); // held at end_gain
|
||||
assert!((out[7] - 0.5).abs() < 1e-4);
|
||||
assert_eq!(out.len(), 8); // not exhausted — A keeps playing under B
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_linear_fade_out_exhausts_when_end_gain_zero() {
|
||||
let src = TriggeredFadeOut::new(
|
||||
Ones(8),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicU64::new(4)),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
end_gain_arc(0.0),
|
||||
);
|
||||
let out: Vec<f32> = src.collect();
|
||||
assert_eq!(out.len(), 4); // fades 1→0 then returns None
|
||||
assert!((out[0] - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_cos_fade_out_unchanged_when_not_linear() {
|
||||
let src = TriggeredFadeOut::new(
|
||||
Ones(8),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(AtomicU64::new(4)),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
end_gain_arc(0.0),
|
||||
);
|
||||
let out: Vec<f32> = src.collect();
|
||||
assert_eq!(out.len(), 4); // cos → 0 then None
|
||||
assert!((out[0] - 1.0).abs() < 1e-4); // cos(0) = 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_mix_power_lerp_is_constant_power_for_symmetric_crossfade() {
|
||||
// g_A: 1→0, g_B: 0→1 via power-lerp → g_A² + g_B² = 1 at every sample.
|
||||
let fade_samples = 4u64;
|
||||
for i in 0..fade_samples {
|
||||
let t = i as f32 / fade_samples as f32;
|
||||
let g_a = (1.0 * (1.0 - t)).sqrt();
|
||||
let g_b = (0.0 * (1.0 - t) + 1.0 * t).sqrt();
|
||||
assert!((g_a.powi(2) + g_b.powi(2) - 1.0).abs() < 1e-4, "i={i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,8 @@ mod tests {
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
fadeout_linear: None,
|
||||
fadeout_end_gain: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
|
||||
|
||||
@@ -18,7 +18,6 @@ use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
@@ -50,7 +49,6 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
|
||||
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
|
||||
/// swaps it in on the next `read()`), and a new download task is spawned.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
|
||||
// If a preview is running, cancel it first — otherwise sink.play() on the
|
||||
// main sink would mix on top of the preview sink.
|
||||
@@ -114,7 +112,6 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
preview_clear_for_new_main_playback(&state, &app);
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
@@ -138,7 +135,6 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
|
||||
let state = state.inner();
|
||||
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
|
||||
|
||||
@@ -8,7 +8,6 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2" }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
|
||||
url = "2"
|
||||
|
||||
@@ -17,10 +17,9 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
|
||||
|
||||
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
|
||||
/// Prefixes mirror Navidrome `model.Kind` (`pl`, `ra`, `dc`, `mf`, …) — not bare album hashes.
|
||||
pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
let id = id.trim();
|
||||
id.starts_with("mf-")
|
||||
@@ -111,20 +110,6 @@ pub fn resolve_album_cover(
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(album);
|
||||
// Navidrome track-only libraries: keep consensus `mf-*` fetch (library picks the
|
||||
// first track per album) while the disk slot stays album-scoped.
|
||||
if !distinct_disc_covers && fetch.starts_with("mf-") && fetch != album {
|
||||
return Some(CoverEntry {
|
||||
cache_kind: "album",
|
||||
cache_entity_id: album.to_string(),
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
});
|
||||
}
|
||||
let fetch_id = if !distinct_disc_covers && fetch == album && !is_fetch_only_cover_id(fetch) {
|
||||
format!("al-{album}_0")
|
||||
} else {
|
||||
fetch.to_string()
|
||||
};
|
||||
let cache_entity_id = if distinct_disc_covers && fetch != album {
|
||||
fetch.to_string()
|
||||
} else {
|
||||
@@ -133,7 +118,7 @@ pub fn resolve_album_cover(
|
||||
Some(CoverEntry {
|
||||
cache_kind: "album",
|
||||
cache_entity_id,
|
||||
fetch_cover_art_id: fetch_id,
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,41 +271,6 @@ mod tests {
|
||||
assert_eq!(e.cache_entity_id, "mf-d2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_keeps_mf_fetch_on_album_bucket() {
|
||||
let e = resolve_album_cover("al-box", Some("mf-track"), false).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "al-box");
|
||||
assert_eq!(e.fetch_cover_art_id, "mf-track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_navidrome_bare_id() {
|
||||
let e = resolve_album_cover("2lsdR1ogDKiFcAD6Pcvk4f", None, false).unwrap();
|
||||
assert_eq!(e.fetch_cover_art_id, "al-2lsdR1ogDKiFcAD6Pcvk4f_0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_playlist_cover_keeps_pl_prefix() {
|
||||
let e = resolve_album_cover("pl-abc123", Some("pl-abc123"), false).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "pl-abc123");
|
||||
assert_eq!(e.fetch_cover_art_id, "pl-abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_playlist_cover_keeps_navidrome_pl_suffix() {
|
||||
let id = "pl-18690de0-151b-4d86-81cb-f418a907315a_0";
|
||||
let e = resolve_album_cover(id, Some(id), false).unwrap();
|
||||
assert_eq!(e.cache_entity_id, id);
|
||||
assert_eq!(e.fetch_cover_art_id, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_radio_cover_keeps_ra_prefix() {
|
||||
let e = resolve_album_cover("ra-rd-1_0", Some("ra-rd-1_0"), false).unwrap();
|
||||
assert_eq!(e.cache_entity_id, "ra-rd-1_0");
|
||||
assert_eq!(e.fetch_cover_art_id, "ra-rd-1_0");
|
||||
}
|
||||
|
||||
fn test_server_dir(label: &str) -> std::path::PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
|
||||
@@ -293,10 +293,7 @@ fn is_lan_ipv6(host: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Public: reused by other crates (e.g. `psysonic-integration`'s Discord
|
||||
/// publish gate) wherever "is this host LAN/loopback, not safe to expose
|
||||
/// externally" needs the same answer this log-redaction module already uses.
|
||||
pub fn is_lan_host(host: &str) -> bool {
|
||||
fn is_lan_host(host: &str) -> bool {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
let lower = stripped.to_ascii_lowercase();
|
||||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Write;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
@@ -19,8 +19,6 @@ pub enum LoggingMode {
|
||||
}
|
||||
|
||||
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
|
||||
static PSYLAB_ALBUMS_BROWSE_TRACE: AtomicBool = AtomicBool::new(false);
|
||||
static PSYLAB_ARTISTS_BROWSE_TRACE: AtomicBool = AtomicBool::new(false);
|
||||
const LOG_BUFFER_MAX_LINES: usize = 20_000;
|
||||
|
||||
/// Monotonic sequence assigned to each appended line; lets the UI tail
|
||||
@@ -102,24 +100,6 @@ pub fn should_log_debug() -> bool {
|
||||
matches!(current_mode(), LoggingMode::Debug)
|
||||
}
|
||||
|
||||
/// PsyLab → Toggles → Albums → Browse perf trace (frontend syncs via IPC).
|
||||
pub fn set_psylab_albums_browse_trace(enabled: bool) {
|
||||
PSYLAB_ALBUMS_BROWSE_TRACE.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn should_log_albums_browse_trace() -> bool {
|
||||
should_log_debug() && PSYLAB_ALBUMS_BROWSE_TRACE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// PsyLab → Toggles → Artists → Browse perf trace (frontend syncs via IPC).
|
||||
pub fn set_psylab_artists_browse_trace(enabled: bool) {
|
||||
PSYLAB_ARTISTS_BROWSE_TRACE.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn should_log_artists_browse_trace() -> bool {
|
||||
should_log_debug() && PSYLAB_ARTISTS_BROWSE_TRACE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn append_log_line(line: String) {
|
||||
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
|
||||
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
@@ -8,14 +8,14 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EndpointKind {
|
||||
Local,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CustomHeadersApplyTo {
|
||||
Local,
|
||||
@@ -24,19 +24,19 @@ pub enum CustomHeadersApplyTo {
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ServerHttpEndpointWire {
|
||||
pub url: String,
|
||||
pub kind: EndpointKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CustomHeaderEntryWire {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ServerHttpContextSyncWire {
|
||||
#[serde(rename = "serverId")]
|
||||
pub server_id: String,
|
||||
@@ -271,34 +271,32 @@ impl ServerHttpRegistry {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a context by `server_ref` first, then fall back to matching the
|
||||
/// request URL against the registered endpoints. The URL fallback is what
|
||||
/// keeps gated servers working when a caller passes a stale/foreign ref
|
||||
/// (e.g. the audio engine's playback server id vs. the index key): endpoint
|
||||
/// matching only ever hits a registered gated server, and `apply_to` is
|
||||
/// still enforced downstream, so non-gated servers are never touched.
|
||||
pub fn resolve_context(
|
||||
pub fn apply_for_http_url(
|
||||
&self,
|
||||
server_ref: Option<&str>,
|
||||
server_ref: &str,
|
||||
full_http_url: &str,
|
||||
) -> Option<Arc<ServerHttpContext>> {
|
||||
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||
if let Some(ctx) = self.get_for_server_ref(sid) {
|
||||
return Some(ctx);
|
||||
}
|
||||
}
|
||||
self.get_for_server_url(full_http_url)
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
let Some(ctx) = self.get_for_server_ref(server_ref) else {
|
||||
return builder;
|
||||
};
|
||||
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
|
||||
}
|
||||
|
||||
pub fn apply_for_base_url(
|
||||
&self,
|
||||
server_ref: &str,
|
||||
request_base_url: &str,
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
let Some(ctx) = self.get_for_server_ref(server_ref) else {
|
||||
return builder;
|
||||
};
|
||||
apply_server_headers(builder, &ctx, request_base_url)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single entry point for attaching a gated server's custom headers to any
|
||||
/// native request. Resolves the context by `server_ref` first, then falls back
|
||||
/// to matching the request URL against a registered gated endpoint; a non-gated
|
||||
/// server (no match) leaves the builder untouched. Every raw-download call site
|
||||
/// (streaming, cover art, analysis prefetch, Navidrome auth, offline transfer)
|
||||
/// and `SubsonicClient::with_registry` funnel through this / `resolve_context`,
|
||||
/// so gate-header behaviour lives in exactly one place.
|
||||
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
|
||||
pub fn apply_optional_registry_headers(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
@@ -306,7 +304,10 @@ pub fn apply_optional_registry_headers(
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
if let Some(reg) = registry {
|
||||
if let Some(ctx) = reg.resolve_context(server_ref, full_http_url) {
|
||||
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||
return reg.apply_for_http_url(sid, full_http_url, builder);
|
||||
}
|
||||
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
|
||||
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
|
||||
}
|
||||
}
|
||||
@@ -342,40 +343,6 @@ mod tests {
|
||||
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_context_falls_back_to_url_when_ref_is_stale() {
|
||||
// Registry keyed by index key with an app-id alias; endpoint is the gate.
|
||||
let reg = ServerHttpRegistry::new();
|
||||
reg.sync(ServerHttpContextSyncWire {
|
||||
server_id: "127.0.0.1".into(),
|
||||
app_server_id: "uuid-1".into(),
|
||||
endpoints: vec![ServerHttpEndpointWire {
|
||||
url: "http://127.0.0.1:8899".into(),
|
||||
kind: EndpointKind::Local,
|
||||
}],
|
||||
custom_headers: vec![CustomHeaderEntryWire {
|
||||
name: "X-Gate".into(),
|
||||
value: "tok".into(),
|
||||
}],
|
||||
custom_headers_apply_to: Some(CustomHeadersApplyTo::Both),
|
||||
});
|
||||
|
||||
let stream_url = "http://127.0.0.1:8899/rest/stream.view?id=42&u=x&t=y";
|
||||
|
||||
// The audio engine passes a playback server id that is neither the index
|
||||
// key nor the app-id alias — it must still resolve via the request URL.
|
||||
let ctx = reg
|
||||
.resolve_context(Some("some-stale-playback-id"), stream_url)
|
||||
.expect("stale ref must fall back to URL endpoint match");
|
||||
let headers = headers_for_request_base_url(&ctx, "http://127.0.0.1:8899");
|
||||
assert_eq!(headers.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("tok")));
|
||||
|
||||
// A non-gated server URL never resolves — foreign servers stay untouched.
|
||||
assert!(reg
|
||||
.resolve_context(Some("some-stale-playback-id"), "https://other.example/rest/stream.view?id=1")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_resolves_app_id_alias() {
|
||||
let reg = ServerHttpRegistry::new();
|
||||
|
||||
@@ -10,7 +10,6 @@ publish = false
|
||||
psysonic-core = { path = "../psysonic-core" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "time", "sync"] }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
|
||||
pub const BANDSINTOWN_APP_ID: &str = "js_app_id";
|
||||
|
||||
#[derive(serde::Serialize, Default, specta::Type)]
|
||||
#[derive(serde::Serialize, Default)]
|
||||
pub struct BandsintownEvent {
|
||||
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
|
||||
venue_name: String,
|
||||
@@ -20,7 +20,6 @@ pub struct BandsintownEvent {
|
||||
/// Returns an empty list on any failure (404, network, parse) — the UI
|
||||
/// just hides the section in that case.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
|
||||
let trimmed = artist_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
||||
@@ -19,42 +19,6 @@ use std::time::Instant;
|
||||
|
||||
const DISCORD_APP_ID: &str = "1489544859718258779";
|
||||
|
||||
/// Query-param keys that carry a replayable auth secret. Checked
|
||||
/// case-insensitively; Subsonic's own keys (`u`/`t`/`s`) are lower-case but
|
||||
/// the defensive variants guard against other backends / auth schemes.
|
||||
const CREDENTIAL_PARAM_KEYS: &[&str] = &["u", "t", "s", "p", "apikey", "jwt", "token", "auth"];
|
||||
|
||||
/// Backstop gate: true when `url` is safe to publish to Discord as a
|
||||
/// `large_image`. Discord's external image proxy re-exposes the source URL
|
||||
/// to anyone viewing the presence, so this must reject anything credentialed
|
||||
/// or LAN-scoped before it ever reaches `Assets::large_image` — regardless of
|
||||
/// which frontend code path produced the URL (mirrors the sanitizer in
|
||||
/// `src/cover/integrations/discord.ts`, but this is the layer a frontend
|
||||
/// regression cannot bypass). The LAN/loopback check reuses
|
||||
/// `psysonic_core::log_sanitize::is_lan_host`, the same host classification
|
||||
/// already relied on for local-log redaction, rather than a second
|
||||
/// hand-written copy.
|
||||
fn is_publishable_image_url(url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return false;
|
||||
}
|
||||
if psysonic_core::log_sanitize::is_lan_host(parsed.host_str().unwrap_or("")) {
|
||||
return false;
|
||||
}
|
||||
for (key, _) in parsed.query_pairs() {
|
||||
if CREDENTIAL_PARAM_KEYS.contains(&key.to_lowercase().as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
|
||||
pub struct ArtworkCacheEntry {
|
||||
pub url: String,
|
||||
@@ -362,7 +326,6 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs:
|
||||
/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}".
|
||||
/// Empty string falls back to the registered Discord application name.
|
||||
/// Supported placeholders: {title}, {artist}, {album}
|
||||
// NOT specta-collected: >10 total params exceed specta's SpectaFn arg cap. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn discord_update_presence(
|
||||
@@ -404,17 +367,6 @@ pub async fn discord_update_presence(
|
||||
None
|
||||
};
|
||||
|
||||
// Backstop: reject any URL that isn't safe to publish, no matter which
|
||||
// path above produced it. Falls back to the app icon on rejection.
|
||||
let artwork_url = artwork_url.filter(|url| {
|
||||
let ok = is_publishable_image_url(url);
|
||||
if !ok {
|
||||
#[cfg(debug_assertions)]
|
||||
crate::app_eprintln!("[discord] rejected non-publishable artwork_url");
|
||||
}
|
||||
ok
|
||||
});
|
||||
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
|
||||
// (Re)connect lazily — handles the case where Discord starts after the app.
|
||||
@@ -496,7 +448,6 @@ pub async fn discord_update_presence(
|
||||
|
||||
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
|
||||
let mut guard = state.client.lock().unwrap();
|
||||
if let Some(client) = guard.as_mut() {
|
||||
@@ -657,68 +608,6 @@ mod tests {
|
||||
assert_eq!(f.details, "Queen – Bohemian Rhapsody");
|
||||
}
|
||||
|
||||
// ── is_publishable_image_url ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn publishable_url_accepts_public_share_image_link() {
|
||||
assert!(is_publishable_image_url(
|
||||
"https://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEifQ.abc?size=600"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_accepts_itunes_artwork_link() {
|
||||
assert!(is_publishable_image_url(
|
||||
"https://is1-ssl.mzstatic.com/image/thumb/Music/600x600bb.jpg"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_credentialed_subsonic_cover_url() {
|
||||
assert!(!is_publishable_image_url(
|
||||
"https://music.example.com/rest/getCoverArt.view?id=al-1&u=alice&t=deadbeef&s=abc123"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_credentialed_url_regardless_of_key_case() {
|
||||
assert!(!is_publishable_image_url(
|
||||
"https://music.example.com/rest/getCoverArt.view?id=al-1&U=alice&T=deadbeef&S=abc123"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_non_https_scheme() {
|
||||
assert!(!is_publishable_image_url(
|
||||
"http://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_embedded_userinfo() {
|
||||
assert!(!is_publishable_image_url(
|
||||
"https://alice:secret@music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_malformed_url() {
|
||||
assert!(!is_publishable_image_url("not a url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_lan_host() {
|
||||
assert!(!is_publishable_image_url(
|
||||
"https://192.168.1.5/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishable_url_rejects_loopback_and_local_hosts() {
|
||||
assert!(!is_publishable_image_url("https://localhost/share/img/abc"));
|
||||
assert!(!is_publishable_image_url("https://music.local/share/img/abc"));
|
||||
}
|
||||
|
||||
// ── compute_discord_start_timestamp ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Auth + retry + HTTP client for Navidrome's native REST API.
|
||||
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
|
||||
|
||||
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
|
||||
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
|
||||
|
||||
/// Authenticate with Navidrome's own REST API and return a Bearer token.
|
||||
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
|
||||
@@ -17,14 +17,14 @@ pub async fn navidrome_token_with_registry(
|
||||
let client = reqwest::Client::new();
|
||||
let base = server_url.trim_end_matches('/');
|
||||
let login_url = format!("{base}/auth/login");
|
||||
let req = apply_optional_registry_headers(
|
||||
registry,
|
||||
None,
|
||||
&login_url,
|
||||
client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({ "username": username, "password": password })),
|
||||
);
|
||||
let mut req = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({ "username": username, "password": password }));
|
||||
if let Some(reg) = registry {
|
||||
if let Some(ctx) = reg.get_for_server_url(server_url) {
|
||||
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
|
||||
}
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| e.to_string())?;
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
data["token"]
|
||||
@@ -44,7 +44,7 @@ pub fn nd_apply_request(
|
||||
}
|
||||
|
||||
/// Payload returned by Navidrome's `/auth/login`.
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct NdLoginResult {
|
||||
pub(super) token: String,
|
||||
#[serde(rename = "userId")]
|
||||
@@ -123,10 +123,7 @@ pub fn nd_http_client() -> reqwest::Client {
|
||||
// the WebKit-side Subsonic calls end up negotiating most of the time
|
||||
// on these setups.
|
||||
reqwest::Client::builder()
|
||||
// Shared wire UA (the main WebView's User-Agent once the frontend reports
|
||||
// it at startup) so Navidrome logs these native calls under the same
|
||||
// client as the WebView instead of a second `[Psysonic]` session.
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
.http1_only()
|
||||
.pool_max_idle_per_host(0)
|
||||
.max_tls_version(reqwest::tls::Version::TLS_1_2)
|
||||
|
||||
@@ -10,7 +10,6 @@ use tauri::State;
|
||||
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_playlist_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -46,7 +45,6 @@ pub async fn upload_playlist_cover(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -82,7 +80,6 @@ pub async fn upload_radio_cover(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_artist_image(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -118,7 +115,6 @@ pub async fn upload_artist_image(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
|
||||
@@ -10,7 +10,6 @@ use tauri::State;
|
||||
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
|
||||
|
||||
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_playlists(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -47,7 +46,6 @@ pub async fn nd_list_playlists(
|
||||
}
|
||||
|
||||
/// POST `/api/playlist` — create playlist (supports smart rules payload).
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_create_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -86,7 +84,6 @@ pub async fn nd_create_playlist(
|
||||
}
|
||||
|
||||
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_update_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -126,7 +123,6 @@ pub async fn nd_update_playlist(
|
||||
}
|
||||
|
||||
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_get_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -164,7 +160,6 @@ pub async fn nd_get_playlist(
|
||||
|
||||
/// DELETE `/api/playlist/{id}` — delete playlist.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn nd_delete_playlist(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
|
||||
@@ -54,7 +54,6 @@ pub async fn nd_list_songs_internal(
|
||||
|
||||
/// Tauri-visible variant — owned-String arguments to keep the IPC
|
||||
/// surface unchanged for existing call sites in the WebView.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_songs(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -99,7 +98,6 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
|
||||
/// — paginated list of artists that have at least one credit in the given role.
|
||||
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
|
||||
/// authenticated user. Returns raw JSON array.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_list_artists_by_role(
|
||||
@@ -160,7 +158,6 @@ pub async fn nd_list_artists_by_role(
|
||||
/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
|
||||
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
|
||||
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_list_albums_by_artist_role(
|
||||
@@ -219,7 +216,6 @@ pub async fn nd_list_albums_by_artist_role(
|
||||
}
|
||||
|
||||
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_libraries(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -253,7 +249,6 @@ pub async fn nd_list_libraries(
|
||||
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
|
||||
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn nd_set_user_libraries(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -304,7 +299,6 @@ pub async fn nd_set_user_libraries(
|
||||
/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit
|
||||
/// it for non-admin users on some configurations.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn nd_get_song_path(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
|
||||
@@ -11,7 +11,6 @@ use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginR
|
||||
|
||||
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn navidrome_login(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -51,7 +50,6 @@ pub async fn navidrome_login(
|
||||
}
|
||||
|
||||
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn nd_list_users(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -85,7 +83,6 @@ pub async fn nd_list_users(
|
||||
}
|
||||
|
||||
/// POST `/api/user` — create a user.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_create_user(
|
||||
@@ -136,7 +133,6 @@ pub async fn nd_create_user(
|
||||
}
|
||||
|
||||
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn nd_update_user(
|
||||
@@ -192,7 +188,6 @@ pub async fn nd_update_user(
|
||||
|
||||
/// DELETE `/api/user/{id}`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn nd_delete_user(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
|
||||
@@ -3,7 +3,6 @@ use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
pub const RADIO_PAGE_SIZE: u32 = 25;
|
||||
|
||||
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
@@ -27,7 +26,6 @@ pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serd
|
||||
}
|
||||
|
||||
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
@@ -48,7 +46,6 @@ pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value
|
||||
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
|
||||
/// Returns (bytes, content_type).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(subsonic_wire_user_agent())
|
||||
@@ -78,7 +75,6 @@ pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
||||
|
||||
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
|
||||
/// Returns the response body as a UTF-8 string for parsing on the JS side.
|
||||
// NOT specta-collected: raw-JSON passthrough (FE JSON.parses the string) — kept hand-written on generate_handler! with the scrobbler/fetch family.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_json_url(url: String) -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -99,7 +95,7 @@ pub async fn fetch_json_url(url: String) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// ICY metadata response returned to the frontend.
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct IcyMetadata {
|
||||
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
|
||||
stream_title: Option<String>,
|
||||
@@ -177,7 +173,6 @@ pub async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option
|
||||
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct
|
||||
/// stream URL before the ICY request is made.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
@@ -282,7 +277,6 @@ pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||
/// Returns the original URL unchanged if it is not a recognised playlist format
|
||||
/// or if the playlist cannot be fetched/parsed.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resolve_stream_url(url: String) -> String {
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
@@ -315,7 +309,6 @@ fn provider_http_client() -> Result<reqwest::Client, String> {
|
||||
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
|
||||
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
|
||||
/// true a GET request is made, otherwise a form POST.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn audioscrobbler_request(
|
||||
base_url: String,
|
||||
@@ -378,7 +371,6 @@ pub async fn audioscrobbler_request(
|
||||
///
|
||||
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
|
||||
/// is present the request is a POST with that body; otherwise a GET.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_request(
|
||||
base_url: String,
|
||||
@@ -418,7 +410,6 @@ pub async fn listenbrainz_request(
|
||||
///
|
||||
/// `path` is appended to `base_url`. When `json_body` is present the request is a
|
||||
/// POST with that body; otherwise a GET with `query` pairs.
|
||||
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn maloja_request(
|
||||
base_url: String,
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde::Deserialize;
|
||||
|
||||
use super::auth::SubsonicCredentials;
|
||||
use super::error::{flatten_reqwest_error, SubsonicError};
|
||||
use super::types::{Album, AlbumSummary, ArtistIndex, MusicFolder, ScanStatus, SearchResult, ServerInfo, Song};
|
||||
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
|
||||
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
|
||||
|
||||
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
|
||||
@@ -187,15 +187,6 @@ impl SubsonicClient {
|
||||
self.fetch("getArtists", ¶ms, "artists").await
|
||||
}
|
||||
|
||||
/// B2 — `getMusicFolders()`. Returns the server's music libraries /
|
||||
/// folders. Used by the library-tagging pass to scope `getAlbumList2`
|
||||
/// without re-ingesting tracks.
|
||||
pub async fn get_music_folders(&self) -> Result<Vec<MusicFolder>, SubsonicError> {
|
||||
let wrapped: MusicFoldersWrapper =
|
||||
self.fetch("getMusicFolders", &[], "musicFolders").await?;
|
||||
Ok(wrapped.music_folder)
|
||||
}
|
||||
|
||||
/// B3a — `getAlbumList2(type, size, offset, musicFolderId?)`. Returns
|
||||
/// just the album summaries; the caller follows up with `get_album`
|
||||
/// per id to enumerate songs.
|
||||
@@ -352,47 +343,6 @@ impl SubsonicClient {
|
||||
.await
|
||||
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
|
||||
}
|
||||
|
||||
/// Raw WebView-transport bridge: the caller (TypeScript) has already built
|
||||
/// the *full* query — auth params (`u`/`t`/`s`/`v`/`c`/`f`) plus the
|
||||
/// endpoint's own args — so this only attaches gate headers + UA and hands
|
||||
/// the untouched response body back for the frontend to parse. It lets
|
||||
/// gated servers (Cloudflare Access, Pangolin, …) reach every Subsonic
|
||||
/// endpoint the WebView would otherwise call over `axios`, where a
|
||||
/// non-safelisted header trips a CORS preflight the gate rejects.
|
||||
///
|
||||
/// `endpoint` is the REST path segment *including* `.view`
|
||||
/// (e.g. `getAlbumList2.view`). `post_form` sends the params as an
|
||||
/// `application/x-www-form-urlencoded` body (OpenSubsonic `formPost`, for
|
||||
/// large multi-`id` calls) instead of a query string.
|
||||
pub async fn send_raw(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
params: &[(String, String)],
|
||||
post_form: bool,
|
||||
) -> Result<String, SubsonicError> {
|
||||
let url = format!("{}/rest/{endpoint}", self.base_url);
|
||||
let mut req = if post_form {
|
||||
self.http.post(&url).form(params)
|
||||
} else {
|
||||
self.http.get(&url).query(params)
|
||||
};
|
||||
if let Some(ctx) = &self.http_context {
|
||||
req = apply_server_headers(req, ctx, &self.base_url);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SubsonicError::HttpStatus(resp.status()));
|
||||
}
|
||||
resp.text()
|
||||
.await
|
||||
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -401,22 +351,9 @@ struct AlbumListWrapper {
|
||||
album: Vec<AlbumSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MusicFoldersWrapper {
|
||||
#[serde(
|
||||
rename = "musicFolder",
|
||||
default,
|
||||
deserialize_with = "crate::subsonic::types::de_music_folder_one_or_many"
|
||||
)]
|
||||
music_folder: Vec<MusicFolder>,
|
||||
}
|
||||
|
||||
fn default_http_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
// Shared wire UA (aligned with the main WebView at startup) so native
|
||||
// Subsonic calls share the WebView's client identity on the server
|
||||
// instead of registering a separate `[Psysonic]` session.
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
@@ -705,126 +642,6 @@ mod tests {
|
||||
test_client(&server.uri()).ping().await.expect("ping must succeed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ping_sends_custom_gate_header_via_http_context() {
|
||||
// Backs the connect-probe fix (#1216): a per-server gate header
|
||||
// (Cloudflare Access / Pangolin) must ride on the ping itself. The mock
|
||||
// only answers when the header is present, so a passing ping proves the
|
||||
// header was sent on the native request (no WebView CORS preflight).
|
||||
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
|
||||
use wiremock::matchers::header;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/ping.view"))
|
||||
.and(header("CF-Access-Client-Secret", "gate-secret"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": { "status": "ok", "version": "1.16.1" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ctx = ServerHttpContext {
|
||||
endpoints: vec![(server.uri(), EndpointKind::Public)],
|
||||
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
|
||||
apply_to: CustomHeadersApplyTo::Public,
|
||||
};
|
||||
test_client(&server.uri())
|
||||
.with_http_context(ctx)
|
||||
.ping()
|
||||
.await
|
||||
.expect("ping must carry the gate header to the mounted matcher");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ping_without_context_misses_gate_matcher() {
|
||||
// Same gated mock, but no header context: the request must NOT match, so
|
||||
// the probe fails — confirming the header (not something else) is what
|
||||
// unlocks the gated endpoint.
|
||||
use wiremock::matchers::header;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/ping.view"))
|
||||
.and(header("CF-Access-Client-Secret", "gate-secret"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": { "status": "ok" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = test_client(&server.uri()).ping().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SubsonicError::HttpStatus(_)),
|
||||
"gated endpoint without the header should not match the mock (got {err:?})"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn send_raw_get_forwards_query_and_gate_header_and_returns_body() {
|
||||
// WebView-transport bridge: the frontend passes the full query (auth +
|
||||
// endpoint args) and the gate header rides via the http context. The
|
||||
// mock only answers when both the caller's `type` param and the gate
|
||||
// header are present, and the untouched JSON body is returned verbatim.
|
||||
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
|
||||
use wiremock::matchers::header;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getAlbumList2.view"))
|
||||
.and(query_param("type", "newest"))
|
||||
.and(query_param("u", "user"))
|
||||
.and(header("CF-Access-Client-Secret", "gate-secret"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": { "status": "ok", "albumList2": { "album": [] } }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ctx = ServerHttpContext {
|
||||
endpoints: vec![(server.uri(), EndpointKind::Public)],
|
||||
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
|
||||
apply_to: CustomHeadersApplyTo::Public,
|
||||
};
|
||||
let params = vec![
|
||||
("u".to_string(), "user".to_string()),
|
||||
("type".to_string(), "newest".to_string()),
|
||||
];
|
||||
let body = test_client(&server.uri())
|
||||
.with_http_context(ctx)
|
||||
.send_raw("getAlbumList2.view", ¶ms, false)
|
||||
.await
|
||||
.expect("gated raw GET must reach the mounted matcher");
|
||||
assert!(body.contains("albumList2"), "raw body returned verbatim: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn send_raw_post_form_sends_params_in_body() {
|
||||
// OpenSubsonic `formPost` path for large multi-`id` calls: params ride in
|
||||
// the urlencoded body, not the query string.
|
||||
use wiremock::matchers::body_string_contains;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("POST"))
|
||||
.and(wm_path("/rest/savePlayQueue.view"))
|
||||
.and(body_string_contains("id=track-1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": { "status": "ok" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let params = vec![
|
||||
("u".to_string(), "user".to_string()),
|
||||
("id".to_string(), "track-1".to_string()),
|
||||
];
|
||||
let body = test_client(&server.uri())
|
||||
.send_raw("savePlayQueue.view", ¶ms, true)
|
||||
.await
|
||||
.expect("form-post raw request must match the body matcher");
|
||||
assert!(body.contains("\"status\": \"ok\"") || body.contains("\"status\":\"ok\""));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ping_surfaces_wrong_credentials_as_code_40() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -997,54 +814,6 @@ mod tests {
|
||||
assert_eq!(albums[1].id, "al_2");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_music_folders_handles_array_and_numeric_ids() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getMusicFolders.view"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"musicFolders": {
|
||||
"musicFolder": [
|
||||
{ "id": 1, "name": "Music Library" },
|
||||
{ "id": "2", "name": "Podcasts" }
|
||||
]
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let folders = test_client(&server.uri()).get_music_folders().await.unwrap();
|
||||
assert_eq!(folders.len(), 2);
|
||||
assert_eq!(folders[0].id, "1");
|
||||
assert_eq!(folders[0].name, "Music Library");
|
||||
assert_eq!(folders[1].id, "2");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_music_folders_handles_single_object() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(wm_method("GET"))
|
||||
.and(wm_path("/rest/getMusicFolders.view"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"musicFolders": {
|
||||
"musicFolder": { "id": 3, "name": "Only" }
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let folders = test_client(&server.uri()).get_music_folders().await.unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].id, "3");
|
||||
assert_eq!(folders[0].name, "Only");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_album_includes_song_list() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -16,6 +16,6 @@ pub use client::{
|
||||
pub use stream_url::{build_stream_view_url, rest_base_from_url};
|
||||
pub use error::SubsonicError;
|
||||
pub use types::{
|
||||
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, MusicFolder, ScanStatus,
|
||||
SearchResult, ServerInfo, Song,
|
||||
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo,
|
||||
Song,
|
||||
};
|
||||
|
||||
@@ -37,41 +37,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// Required id field — Subsonic/Navidrome may send string or number.
|
||||
pub(crate) fn de_id_string_or_number<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"expected string or number for id, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `musicFolder` may be a single object or an array on the wire.
|
||||
pub(crate) fn de_music_folder_one_or_many<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Vec<MusicFolder>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
match value {
|
||||
None => Ok(Vec::new()),
|
||||
Some(serde_json::Value::Array(arr)) => arr
|
||||
.into_iter()
|
||||
.map(|v| serde_json::from_value(v).map_err(serde::de::Error::custom))
|
||||
.collect(),
|
||||
Some(obj) => serde_json::from_value(obj)
|
||||
.map(|one: MusicFolder| vec![one])
|
||||
.map_err(serde::de::Error::custom),
|
||||
}
|
||||
}
|
||||
|
||||
/// First usable value in a multi-valued array: a string element, or an
|
||||
/// object element's `name` (the OpenSubsonic `[{ "name": … }]` shape).
|
||||
fn first_tag_value(arr: &[serde_json::Value]) -> Option<String> {
|
||||
@@ -151,15 +116,6 @@ pub struct ArtistRef {
|
||||
pub cover_art: Option<String>,
|
||||
}
|
||||
|
||||
/// `#getMusicFolders` — top-level music libraries / folders on the server.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct MusicFolder {
|
||||
#[serde(deserialize_with = "de_id_string_or_number")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// `#getAlbumList2` — page of album summaries (no song list).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct AlbumSummary {
|
||||
|
||||
@@ -11,7 +11,6 @@ psysonic-core = { path = "../psysonic-core" }
|
||||
psysonic-integration = { path = "../psysonic-integration" }
|
||||
|
||||
tauri = { version = "2" }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak).
|
||||
ALTER TABLE track ADD COLUMN replay_gain_peak REAL;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Layer-1 scoped browse indexes: (server_id, library_id, …) for sargable IN filters.
|
||||
CREATE INDEX IF NOT EXISTS idx_track_library_album
|
||||
ON track(server_id, library_id, album_id)
|
||||
WHERE deleted = 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_library_artist
|
||||
ON track(server_id, library_id, artist_id)
|
||||
WHERE deleted = 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_library_title
|
||||
ON track(server_id, library_id, title COLLATE NOCASE)
|
||||
WHERE deleted = 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_library_genre
|
||||
ON track(server_id, library_id, genre)
|
||||
WHERE deleted = 0;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Per-server library tagging pass state (post-sync album-membership tagging).
|
||||
CREATE TABLE IF NOT EXISTS library_tag_state (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
folders_hash TEXT NOT NULL,
|
||||
last_untagged_count INTEGER NOT NULL DEFAULT 0,
|
||||
completed_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Speeds up the orphan-artist prune's freshness check: the prune keeps the
|
||||
-- freshest getArtists pass per server (MAX(synced_at)) and deletes stale rows
|
||||
-- below it. Without this index that lookup scans every artist row for the
|
||||
-- server on each sync; the composite index turns it into a seek + range.
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_synced ON artist(server_id, synced_at);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,28 +49,15 @@ pub fn various_artists_label(s: &str) -> bool {
|
||||
s.trim().to_ascii_lowercase().contains("various artists")
|
||||
}
|
||||
|
||||
/// SQL mirror of [`pick_album_group_artist`] over arbitrary column *expressions*
|
||||
/// rather than a table alias — the album browse groups by album and therefore has
|
||||
/// to feed aggregates (`MAX(t.artist)`, `MAX(t.album_artist)`), while the
|
||||
/// multi-library dedup path feeds projected columns (`artist`, `album_artist`).
|
||||
/// Single source of the rule; keep in sync with [`pick_album_group_artist`].
|
||||
pub fn sql_display_artist_from(track_artist: &str, album_artist: &str) -> String {
|
||||
format!(
|
||||
"CASE WHEN trim(coalesce({aa}, '')) != '' \
|
||||
THEN trim({aa}) \
|
||||
ELSE NULLIF(trim(coalesce({ta}, '')), '') END",
|
||||
aa = album_artist,
|
||||
ta = track_artist,
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL mirror of [`pick_album_group_artist`] for track-grouped browse subqueries
|
||||
/// (`la`). Used where `ORDER BY` / `COALESCE(a.artist, …)` must stay in SQL;
|
||||
/// keep both implementations in sync.
|
||||
pub fn sql_track_group_display_artist(alias: &str) -> String {
|
||||
sql_display_artist_from(
|
||||
&format!("{alias}.artist"),
|
||||
&format!("{alias}.album_artist"),
|
||||
format!(
|
||||
"CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \
|
||||
THEN trim({a}.album_artist) \
|
||||
ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END",
|
||||
a = alias
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,60 +161,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same parity, but for the **aggregate** form the grouped album browse sorts on.
|
||||
///
|
||||
/// `map_album_from_tracks` builds a row's display artist as
|
||||
/// `pick_album_group_artist(MAX(artist), MAX(album_artist))`, so the sort key
|
||||
/// must be `sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)")` over
|
||||
/// the same aggregates — anything else sorts the album under a name the row does
|
||||
/// not show (#1217). The multi-row groups here are the point: a single row cannot
|
||||
/// tell an aggregate apart from a bare column.
|
||||
#[test]
|
||||
fn sql_display_artist_from_aggregates_matches_pick_album_group_artist() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute("CREATE TABLE t (artist TEXT, album_artist TEXT)", [])
|
||||
.unwrap();
|
||||
let sql = format!(
|
||||
"SELECT {} FROM t",
|
||||
sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)"),
|
||||
);
|
||||
|
||||
// Each case is one album's worth of tracks — album_artist deliberately sparse.
|
||||
let groups: [&[(&str, Option<&str>)]; 5] = [
|
||||
// Featured guest on one track only; the album artist is what shows.
|
||||
&[("Alpha", Some("Alpha")), ("Alpha feat. Zulu", None)],
|
||||
// Album artist on the *second* track — MAX still has to find it.
|
||||
&[("Alpha feat. Zulu", None), ("Alpha", Some("Alpha"))],
|
||||
// No album artist anywhere: falls back to the track credit.
|
||||
&[("Alpha", None), ("Alpha feat. Zulu", None)],
|
||||
// Blank strings must not count as an album artist.
|
||||
&[("Alice", Some(" ")), ("Alice feat. Bob", Some(""))],
|
||||
// Compilation: every track carries the same album artist.
|
||||
&[("Alice", Some("Various Artists")), ("Bob", Some("Various Artists"))],
|
||||
];
|
||||
|
||||
for rows in groups {
|
||||
conn.execute("DELETE FROM t", []).unwrap();
|
||||
for (artist, album_artist) in rows {
|
||||
conn.execute(
|
||||
"INSERT INTO t (artist, album_artist) VALUES (?1, ?2)",
|
||||
rusqlite::params![artist, album_artist],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let sql_out: Option<String> = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
|
||||
|
||||
// The Rust side of the same decision, over the same aggregates.
|
||||
let max_artist = rows.iter().map(|(a, _)| *a).max().map(str::to_string);
|
||||
let max_album_artist = rows
|
||||
.iter()
|
||||
.filter_map(|(_, aa)| *aa)
|
||||
.max()
|
||||
.map(str::to_string);
|
||||
let rust_out = pick_album_group_artist(max_artist, max_album_artist);
|
||||
|
||||
assert_eq!(sql_out, rust_out, "rows={rows:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const DEFAULT_BATCH: u32 = 20;
|
||||
const MAX_BATCH: u32 = 50;
|
||||
const PROGRESS_SCAN_CHUNK: usize = 1000;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAnalysisBackfillBatchDto {
|
||||
pub track_ids: Vec<String>,
|
||||
@@ -21,7 +21,7 @@ pub struct LibraryAnalysisBackfillBatchDto {
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAnalysisProgressDto {
|
||||
pub total_tracks: i64,
|
||||
|
||||
@@ -5,34 +5,15 @@ use crate::dto::{
|
||||
LibraryTrackDto,
|
||||
};
|
||||
use crate::lossless_formats::track_is_lossless_sql;
|
||||
use crate::search::{
|
||||
aliased_track_columns, combined_scope_library_ids, library_scope_in_sql,
|
||||
library_scope_sargable_equals_sql,
|
||||
};
|
||||
use crate::search::{aliased_track_columns, library_scope_equals_sql};
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Push a sargable `library_id` filter (single or multi scope) matching the
|
||||
/// migrated browse/search paths. Empty scope means all libraries.
|
||||
fn push_library_scope_filter(
|
||||
where_clauses: &mut Vec<String>,
|
||||
params: &mut Vec<SqlValue>,
|
||||
scope_ids: &[String],
|
||||
) {
|
||||
match scope_ids.len() {
|
||||
0 => {}
|
||||
1 => {
|
||||
where_clauses.push(library_scope_sargable_equals_sql("t"));
|
||||
params.push(SqlValue::Text(scope_ids[0].clone()));
|
||||
}
|
||||
n => {
|
||||
where_clauses.push(library_scope_in_sql("t", n));
|
||||
for id in scope_ids {
|
||||
params.push(SqlValue::Text(id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
pub fn get_artist_lossless_browse(
|
||||
@@ -55,9 +36,11 @@ pub fn get_artist_lossless_browse(
|
||||
SqlValue::Text(req.artist_id.clone()),
|
||||
];
|
||||
|
||||
let scope_ids =
|
||||
combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref());
|
||||
push_library_scope_filter(&mut track_where, &mut track_params, &scope_ids);
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
track_where.push(clause);
|
||||
track_params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
let track_where_sql = track_where.join(" AND ");
|
||||
let track_cols = aliased_track_columns("t");
|
||||
@@ -91,7 +74,11 @@ pub fn get_artist_lossless_browse(
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(req.artist_id.clone()),
|
||||
];
|
||||
push_library_scope_filter(&mut album_where, &mut album_params, &scope_ids);
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
album_where.push(clause);
|
||||
album_params.push(SqlValue::Text(scope));
|
||||
}
|
||||
let album_where_sql = album_where.join(" AND ");
|
||||
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
@@ -226,7 +213,6 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -255,7 +241,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
artist_id: "ar1".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -9,16 +9,10 @@ pub fn strip_leading_articles(name: &str, ignored_articles: &str) -> String {
|
||||
for article in ignored_articles.split(' ').filter(|s| !s.is_empty()) {
|
||||
let prefix = format!("{} ", article);
|
||||
// `prefix` is ASCII; use `get` so we never slice inside a multibyte rune
|
||||
// (e.g. probing "The " / "El " on CJK names must not panic).
|
||||
let Some(head) = trimmed.get(..prefix.len()) else {
|
||||
continue;
|
||||
};
|
||||
if head.eq_ignore_ascii_case(&prefix) {
|
||||
return trimmed
|
||||
.get(prefix.len()..)
|
||||
.map(str::trim_start)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// (e.g. "Elə…" must not panic when probing the "El " article).
|
||||
let head = trimmed.get(0..prefix.len());
|
||||
if head.is_some_and(|h| h.eq_ignore_ascii_case(&prefix)) {
|
||||
return trimmed[prefix.len()..].trim_start().to_string();
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
@@ -75,13 +69,4 @@ mod tests {
|
||||
let key = sort_key_for_display_name("Eləmir", DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, "eləmir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_panic_on_cjk_multi_artist_credit_string() {
|
||||
// Discord report (Asra): sync panicked on FromSoftware OST composer list
|
||||
// when probing the 4-byte "The " article prefix against 北村友香…
|
||||
let name = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉";
|
||||
let key = sort_key_for_display_name(name, DEFAULT_IGNORED_ARTICLES);
|
||||
assert_eq!(key, name.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
//! Album browse helpers: favorites reconcile and catalog year bounds.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::{Map, Value};
|
||||
use rusqlite::params;
|
||||
use tauri::State;
|
||||
|
||||
use crate::dto::CatalogYearBoundsDto;
|
||||
use crate::dto::GenreAlbumCountDto;
|
||||
use crate::dto::LibraryAlbumDto;
|
||||
use crate::runtime::LibraryRuntime;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use crate::sync::mapping::format_iso_ms_z;
|
||||
use crate::search::{
|
||||
library_scope_in_sql, library_scope_sargable_equals_sql, normalized_library_scopes,
|
||||
push_library_scope_binds,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StarredAlbumReconcileItem {
|
||||
pub id: String,
|
||||
@@ -25,7 +19,6 @@ pub struct StarredAlbumReconcileItem {
|
||||
/// Align `album.starred_at` with server favorites: UPDATE existing rows only
|
||||
/// (no INSERT / stub rows). Clears local stars absent from `starred_albums`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_reconcile_album_stars(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -34,125 +27,6 @@ pub fn library_reconcile_album_stars(
|
||||
reconcile_album_stars(&runtime, &server_id, &starred_albums)
|
||||
}
|
||||
|
||||
/// Read album-level favorite timestamp (`album.starred_at`), not track stars.
|
||||
pub(crate) fn read_album_starred_at(
|
||||
conn: &rusqlite::Connection,
|
||||
server_id: &str,
|
||||
album_id: &str,
|
||||
) -> rusqlite::Result<Option<i64>> {
|
||||
conn.query_row(
|
||||
"SELECT starred_at FROM album WHERE server_id = ?1 AND id = ?2",
|
||||
params![server_id, album_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|row| row.flatten())
|
||||
}
|
||||
|
||||
/// Replace track-aggregated stars with `album.starred_at` per row (multi-server safe).
|
||||
pub(crate) fn overlay_album_starred_at_rows(
|
||||
conn: &rusqlite::Connection,
|
||||
albums: &mut [LibraryAlbumDto],
|
||||
) {
|
||||
for album in albums.iter_mut() {
|
||||
album.starred_at =
|
||||
read_album_starred_at(conn, &album.server_id, &album.id).unwrap_or(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Album browse/detail: `starred_at` reflects album favorites only (`album.starred_at`).
|
||||
pub(crate) fn overlay_album_level_starred_at(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
albums: &mut [LibraryAlbumDto],
|
||||
) -> Result<(), String> {
|
||||
if albums.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
for album in albums.iter_mut() {
|
||||
album.starred_at =
|
||||
read_album_starred_at(conn, server_id, &album.id).unwrap_or(None);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Patch-on-use for album favorites — mirrors `apply_track_patch` (UPDATE only).
|
||||
pub(crate) fn apply_album_patch(
|
||||
runtime: &LibraryRuntime,
|
||||
server_id: &str,
|
||||
album_id: &str,
|
||||
patch: &Value,
|
||||
) -> Result<(), String> {
|
||||
let starred_at = patch.get("starredAt").map(|v| v.as_i64());
|
||||
runtime
|
||||
.store
|
||||
.with_conn("browse.patch_album", |conn| {
|
||||
if let Some(v) = starred_at {
|
||||
conn.execute(
|
||||
"UPDATE album SET starred_at = ?3 \
|
||||
WHERE server_id = ?1 AND id = ?2",
|
||||
params![server_id, album_id, v],
|
||||
)?;
|
||||
sync_album_raw_json_starred(conn, server_id, album_id, v)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn sync_album_raw_json_starred(
|
||||
conn: &rusqlite::Connection,
|
||||
server_id: &str,
|
||||
album_id: &str,
|
||||
starred_at: Option<i64>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let raw_str: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT raw_json FROM album WHERE server_id = ?1 AND id = ?2",
|
||||
params![server_id, album_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
let mut raw = raw_str
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.unwrap_or_else(|| Value::Object(Map::new()));
|
||||
let Value::Object(ref mut map) = raw else {
|
||||
return Ok(());
|
||||
};
|
||||
match starred_at {
|
||||
None => {
|
||||
map.remove("starred");
|
||||
}
|
||||
Some(ms) => {
|
||||
if let Some(iso) = format_iso_ms_z(ms) {
|
||||
map.insert("starred".into(), Value::String(iso));
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE album SET raw_json = ?3 WHERE server_id = ?1 AND id = ?2",
|
||||
params![server_id, album_id, raw.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NOT specta-collected: serde_json::Value patch arg (same as library_patch_track).
|
||||
#[tauri::command]
|
||||
pub fn library_patch_album(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
album_id: String,
|
||||
patch: Value,
|
||||
) -> Result<(), String> {
|
||||
apply_album_patch(&runtime, &server_id, &album_id, &patch)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_album_stars(
|
||||
runtime: &LibraryRuntime,
|
||||
server_id: &str,
|
||||
@@ -225,68 +99,48 @@ pub(crate) fn catalog_year_bounds_for_server(
|
||||
|
||||
/// Min/max album years from the local track catalog (for Albums browse filter spinners).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_catalog_year_bounds(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
) -> Result<CatalogYearBoundsDto, String> {
|
||||
let trace = psysonic_core::logging::should_log_albums_browse_trace();
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = catalog_year_bounds_for_server(&runtime.store, &server_id);
|
||||
if trace {
|
||||
let step_ms = t0.elapsed().as_millis();
|
||||
let (min_year, max_year) = result
|
||||
.as_ref()
|
||||
.map(|b| (b.min_year, b.max_year))
|
||||
.unwrap_or((None, None));
|
||||
crate::app_deprintln!(
|
||||
"[frontend][albums-browse] {}",
|
||||
serde_json::json!({
|
||||
"step": "rust_catalog_year_bounds",
|
||||
"elapsedMs": 0,
|
||||
"details": {
|
||||
"stepMs": step_ms,
|
||||
"serverId": server_id,
|
||||
"minYear": min_year,
|
||||
"maxYear": max_year,
|
||||
"ok": result.is_ok(),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
result
|
||||
catalog_year_bounds_for_server(&runtime.store, &server_id)
|
||||
}
|
||||
|
||||
pub(crate) fn genre_album_counts_for_server(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
library_scopes: &[String],
|
||||
library_scope: Option<&str>,
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
let scopes = normalized_library_scopes(library_scopes);
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
let mut sql = String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track t \
|
||||
INNER JOIN track_genre tg \
|
||||
ON tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
WHERE t.server_id = ?1 \
|
||||
AND t.deleted = 0 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
);
|
||||
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
|
||||
let mut sql = if scoped {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
} else {
|
||||
String::from(
|
||||
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
|
||||
COUNT(DISTINCT tg.track_id) AS song_count \
|
||||
FROM track_genre tg \
|
||||
WHERE tg.server_id = ?1 \
|
||||
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
|
||||
)
|
||||
};
|
||||
let mut params: Vec<rusqlite::types::Value> =
|
||||
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
||||
if scopes.len() == 1 {
|
||||
sql.push_str(&format!(" AND {}", library_scope_sargable_equals_sql("t")));
|
||||
push_library_scope_binds(&mut params, &scopes);
|
||||
} else if scopes.len() > 1 {
|
||||
sql.push_str(&format!(" AND {}", library_scope_in_sql("t", scopes.len())));
|
||||
push_library_scope_binds(&mut params, &scopes);
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
|
||||
params.push(rusqlite::types::Value::Text(scope.to_string()));
|
||||
}
|
||||
sql.push_str(
|
||||
" GROUP BY tg.genre COLLATE NOCASE \
|
||||
HAVING album_count > 0 \
|
||||
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
@@ -306,43 +160,16 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
|
||||
/// Distinct album counts per track genre — same grouping as genre album browse.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_genre_album_counts(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
library_scope: Option<String>,
|
||||
library_scopes: Option<Vec<String>>,
|
||||
) -> Result<Vec<GenreAlbumCountDto>, String> {
|
||||
let trace = psysonic_core::logging::should_log_albums_browse_trace();
|
||||
let scopes = if let Some(scopes) = library_scopes {
|
||||
normalized_library_scopes(&scopes)
|
||||
} else if let Some(scope) = library_scope.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
vec![scope.to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let trace_scopes = scopes.clone();
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = genre_album_counts_for_server(&runtime.store, &server_id, &scopes);
|
||||
if trace {
|
||||
let step_ms = t0.elapsed().as_millis();
|
||||
let genre_count = result.as_ref().map(|rows| rows.len()).unwrap_or(0);
|
||||
crate::app_deprintln!(
|
||||
"[frontend][albums-browse] {}",
|
||||
serde_json::json!({
|
||||
"step": "rust_genre_album_counts",
|
||||
"elapsedMs": 0,
|
||||
"details": {
|
||||
"stepMs": step_ms,
|
||||
"serverId": server_id,
|
||||
"libraryScopes": trace_scopes,
|
||||
"genreCount": genre_count,
|
||||
"ok": result.is_ok(),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
result
|
||||
genre_album_counts_for_server(
|
||||
&runtime.store,
|
||||
&server_id,
|
||||
library_scope.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -354,10 +181,9 @@ mod tests {
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::{
|
||||
apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_server,
|
||||
overlay_album_level_starred_at, reconcile_album_stars, StarredAlbumReconcileItem,
|
||||
catalog_year_bounds_for_server, genre_album_counts_for_server, reconcile_album_stars,
|
||||
StarredAlbumReconcileItem,
|
||||
};
|
||||
use crate::dto::LibraryAlbumDto;
|
||||
|
||||
fn make_row(server: &str, id: &str, album_id: &str, track: i64) -> crate::repos::TrackRow {
|
||||
crate::repos::TrackRow {
|
||||
@@ -390,7 +216,6 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -404,131 +229,6 @@ mod tests {
|
||||
LibraryRuntime::new(store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_album_patch_sets_and_clears_starred_at() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
||||
VALUES ('s1', 'al1', 'Album', NULL, 1, '{}')",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let rt = runtime(store.clone());
|
||||
apply_album_patch(&rt, "s1", "al1", &serde_json::json!({ "starredAt": 1700 })).unwrap();
|
||||
let starred: Option<i64> = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(starred, Some(1700));
|
||||
|
||||
apply_album_patch(&rt, "s1", "al1", &serde_json::json!({ "starredAt": null })).unwrap();
|
||||
let cleared: Option<i64> = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT starred_at FROM album WHERE server_id = 's1' AND id = 'al1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(cleared, None);
|
||||
let raw: String = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT raw_json FROM album WHERE server_id = 's1' AND id = 'al1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!raw.contains("starred"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_album_patch_clears_stale_starred_in_raw_json() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
||||
VALUES ('s1', 'al1', 'Album', 100, 1, \
|
||||
'{\"id\":\"al1\",\"starred\":\"2024-01-01T00:00:00Z\"}')",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let rt = runtime(store.clone());
|
||||
apply_album_patch(&rt, "s1", "al1", &serde_json::json!({ "starredAt": null })).unwrap();
|
||||
let raw: String = store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT raw_json FROM album WHERE server_id = 's1' AND id = 'al1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert!(parsed.get("starred").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_album_level_starred_at_ignores_track_stars() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[make_row("s1", "tr_1", "al1", 1)])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET starred_at = 999 WHERE server_id = 's1' AND id = 'tr_1'",
|
||||
[],
|
||||
)?;
|
||||
c.execute(
|
||||
"INSERT INTO album (server_id, id, name, starred_at, synced_at, raw_json) \
|
||||
VALUES ('s1', 'al1', 'Album', NULL, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mut albums = vec![LibraryAlbumDto {
|
||||
server_id: "s1".into(),
|
||||
id: "al1".into(),
|
||||
name: "Album".into(),
|
||||
artist: None,
|
||||
artist_id: None,
|
||||
song_count: Some(1),
|
||||
duration_sec: Some(200),
|
||||
year: None,
|
||||
genre: None,
|
||||
cover_art_id: None,
|
||||
starred_at: Some(999),
|
||||
synced_at: 1,
|
||||
raw_json: serde_json::Value::Null,
|
||||
}];
|
||||
overlay_album_level_starred_at(&store, "s1", &mut albums).unwrap();
|
||||
assert_eq!(albums[0].starred_at, None);
|
||||
|
||||
apply_album_patch(
|
||||
&runtime(store.clone()),
|
||||
"s1",
|
||||
"al1",
|
||||
&serde_json::json!({ "starredAt": 1700 }),
|
||||
)
|
||||
.unwrap();
|
||||
overlay_album_level_starred_at(&store, "s1", &mut albums).unwrap();
|
||||
assert_eq!(albums[0].starred_at, Some(1700));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_album_stars_clears_stale_and_sets_existing_rows() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
@@ -620,7 +320,7 @@ mod tests {
|
||||
.upsert_batch(&rock_one)
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
assert_eq!(counts.len(), 2);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 2);
|
||||
@@ -643,7 +343,7 @@ mod tests {
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[String::from("lib1")]).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
@@ -655,87 +355,21 @@ mod tests {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut scoped = make_row("s1", "r1", "al_a", 1);
|
||||
scoped.genre = Some("Rock".into());
|
||||
scoped.library_id = Some("lib1".into());
|
||||
scoped.library_id = None;
|
||||
scoped.raw_json = r#"{"libraryId":"lib1"}"#.into();
|
||||
let mut other = make_row("s1", "r2", "al_b", 1);
|
||||
other.genre = Some("Rock".into());
|
||||
other.library_id = Some("lib2".into());
|
||||
other.library_id = None;
|
||||
other.raw_json = r#"{"libraryId":"lib2"}"#.into();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[String::from("lib1")]).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_multi_library_scope_in_one_query() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut lib1 = make_row("s1", "r1", "al_a", 1);
|
||||
lib1.genre = Some("Rock".into());
|
||||
lib1.library_id = Some("lib1".into());
|
||||
let mut lib2 = make_row("s1", "r2", "al_b", 1);
|
||||
lib2.genre = Some("Pop".into());
|
||||
lib2.library_id = Some("lib2".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[lib1, lib2])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(
|
||||
&store,
|
||||
"s1",
|
||||
&[String::from("lib1"), String::from("lib2")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(counts.len(), 2);
|
||||
// Equal album_count → ORDER BY tg.genre COLLATE NOCASE ASC: "Pop" before "Rock".
|
||||
assert_eq!(counts[0].value, "Pop");
|
||||
assert_eq!(counts[1].value, "Rock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_drop_genre_after_track_retag() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut track = make_row("s1", "t1", "al1", 1);
|
||||
track.genre = Some("ruspop".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track.clone()])
|
||||
.unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "ruspop");
|
||||
|
||||
track.genre = Some("Pop".into());
|
||||
TrackRepository::new(&store).upsert_batch(&[track]).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Pop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genre_album_counts_ignore_orphan_track_genre_rows() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
let mut live = make_row("s1", "live", "al1", 1);
|
||||
live.genre = Some("Rock".into());
|
||||
let mut stale = make_row("s1", "gone", "al_stale", 1);
|
||||
stale.genre = Some("ruspop".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[live, stale])
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn("test", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE track SET deleted = 1 WHERE server_id = 's1' AND id = 'gone'",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_album_stars_clears_all_when_server_list_empty() {
|
||||
let store = Arc::new(LibraryStore::open_in_memory());
|
||||
|
||||
@@ -22,19 +22,15 @@ use crate::cross_server;
|
||||
use crate::dto::{
|
||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse,
|
||||
LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest,
|
||||
LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest,
|
||||
LibraryTrackDto,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
||||
};
|
||||
use crate::live_search;
|
||||
use crate::payload::LibrarySyncProgressPayload;
|
||||
use crate::repos::{PlaySessionRepository, SyncStateRepository, TrackRepository};
|
||||
use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession};
|
||||
use crate::scope_merge;
|
||||
use crate::search::search_tracks;
|
||||
use crate::store::LibraryStore;
|
||||
use crate::sync::bandwidth::PlaybackHint;
|
||||
@@ -43,7 +39,6 @@ use crate::sync::capability::{probe_and_persist, CapabilityFlags, NavidromeProbe
|
||||
use crate::sync::delta::DeltaSyncRunner;
|
||||
use crate::sync::error::SyncError;
|
||||
use crate::sync::initial::InitialSyncRunner;
|
||||
use crate::sync::library_tag::run_tag_pass_best_effort;
|
||||
use crate::sync::progress::{ChannelProgress, Progress, ProgressEvent};
|
||||
use crate::sync::tombstone::should_auto_reconcile;
|
||||
|
||||
@@ -62,7 +57,7 @@ where
|
||||
const TRACKS_BATCH_LIMIT: usize = 100;
|
||||
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
@@ -71,7 +66,6 @@ pub struct LibraryServerKeyMigrationDto {
|
||||
|
||||
/// Resolve cover disk + fetch ids from the local library (`album` | `artist` | `track`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_resolve_cover_entry(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -93,7 +87,6 @@ pub fn library_resolve_cover_entry(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_analysis_backfill_batch(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -113,7 +106,6 @@ pub fn library_analysis_backfill_batch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_analysis_progress(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -164,7 +156,6 @@ pub fn library_analysis_progress(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_count_live_tracks(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -178,7 +169,6 @@ pub fn library_count_live_tracks(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_status(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -312,7 +302,6 @@ fn resolve_local_track_count(
|
||||
}
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_search(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -321,18 +310,15 @@ pub async fn library_search(
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
library_scope: Option<String>,
|
||||
library_scopes: Option<Vec<String>>,
|
||||
) -> Result<LibraryTracksEnvelope, String> {
|
||||
let scopes = effective_library_scopes(library_scope.as_deref(), library_scopes.as_deref());
|
||||
let _ = library_scope; // PR-5a accepts the arg for forward-compat; filter is wired in §5.13
|
||||
let limit = limit.unwrap_or(100).clamp(1, 500);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let hits = search_tracks(
|
||||
&runtime.store,
|
||||
&server_id,
|
||||
&query,
|
||||
limit as i64 + offset as i64,
|
||||
&scopes,
|
||||
)?;
|
||||
// `search_tracks` returns lean `TrackHit` rows for FTS; PR-5a
|
||||
// re-fetches the full `TrackRow` per hit so the DTO carries every
|
||||
// hot column. Acceptable for `limit ≤ 100`; PR-5d wires a single-
|
||||
// statement SQL builder via the FilterRegistry.
|
||||
let hits = search_tracks(&runtime.store, &server_id, &query, limit as i64 + offset as i64)?;
|
||||
let mut paged: Vec<TrackRefDto> = hits
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
@@ -349,7 +335,6 @@ pub async fn library_search(
|
||||
Ok(LibraryTracksEnvelope { tracks, total })
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_get_track(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -389,7 +374,6 @@ pub async fn library_get_track(
|
||||
Ok(Some(dto))
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_get_tracks_batch(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -405,7 +389,6 @@ pub async fn library_get_tracks_batch(
|
||||
hydrate_refs(&runtime, &refs)
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_get_tracks_by_album(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -418,7 +401,6 @@ pub async fn library_get_tracks_by_album(
|
||||
|
||||
/// Upsert Subsonic API song payloads into the library index so pin/download can
|
||||
/// build `media/library/…` paths before a full sync has ingested the rows.
|
||||
// NOT specta-collected: takes a serde_json::Value arg — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub fn library_upsert_songs_from_api(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -452,7 +434,6 @@ pub fn library_upsert_songs_from_api(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -475,7 +456,6 @@ pub async fn library_get_artifact(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_facts(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -492,7 +472,6 @@ pub async fn library_get_facts(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_offline_path(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -522,90 +501,15 @@ pub async fn library_get_offline_path(
|
||||
// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_advanced_search(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryAdvancedSearchRequest,
|
||||
) -> Result<LibraryAdvancedSearchResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let trace_album_browse = psysonic_core::logging::should_log_albums_browse_trace()
|
||||
&& request.entity_types.len() == 1
|
||||
&& request.entity_types[0] == crate::filter::EntityKind::Album
|
||||
&& request
|
||||
.query
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.is_none();
|
||||
let trace_artists_browse = psysonic_core::logging::should_log_artists_browse_trace()
|
||||
&& request.entity_types.len() == 1
|
||||
&& request.entity_types[0] == crate::filter::EntityKind::Artist
|
||||
&& request
|
||||
.query
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.is_none();
|
||||
let trace_offset = request.offset;
|
||||
let trace_limit = request.limit;
|
||||
let trace_filter_count = request.filters.len();
|
||||
let trace_scope_count = request
|
||||
.library_scopes
|
||||
.as_ref()
|
||||
.map(|scopes| scopes.len())
|
||||
.unwrap_or(if request.library_scope.is_some() { 1 } else { 0 });
|
||||
library_spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = advanced_search::run_advanced_search(&store, &request);
|
||||
if trace_album_browse {
|
||||
let step_ms = t0.elapsed().as_millis();
|
||||
let album_count = result.as_ref().map(|r| r.albums.len()).unwrap_or(0);
|
||||
crate::app_deprintln!(
|
||||
"[frontend][albums-browse] {}",
|
||||
serde_json::json!({
|
||||
"step": "rust_advanced_search",
|
||||
"elapsedMs": 0,
|
||||
"details": {
|
||||
"stepMs": step_ms,
|
||||
"albums": album_count,
|
||||
"offset": trace_offset,
|
||||
"limit": trace_limit,
|
||||
"filterCount": trace_filter_count,
|
||||
"scopeCount": trace_scope_count,
|
||||
"ok": result.is_ok(),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
if trace_artists_browse {
|
||||
let step_ms = t0.elapsed().as_millis();
|
||||
let artist_count = result.as_ref().map(|r| r.artists.len()).unwrap_or(0);
|
||||
crate::app_deprintln!(
|
||||
"[frontend][artists-browse] {}",
|
||||
serde_json::json!({
|
||||
"step": "rust_advanced_search",
|
||||
"elapsedMs": 0,
|
||||
"details": {
|
||||
"stepMs": step_ms,
|
||||
"artists": artist_count,
|
||||
"offset": trace_offset,
|
||||
"limit": trace_limit,
|
||||
"filterCount": trace_filter_count,
|
||||
"scopeCount": trace_scope_count,
|
||||
"creditMode": request.artist_credit_mode,
|
||||
"letterBucket": request.artist_letter_bucket,
|
||||
"ok": result.is_ok(),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_list_lossless_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -615,46 +519,17 @@ pub async fn library_list_lossless_albums(
|
||||
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_list_albums_by_genre(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: crate::dto::LibraryGenreAlbumsRequest,
|
||||
) -> Result<crate::dto::LibraryGenreAlbumsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let trace = psysonic_core::logging::should_log_albums_browse_trace();
|
||||
let trace_genre = request.genre.clone();
|
||||
let trace_offset = request.offset;
|
||||
let trace_limit = request.limit;
|
||||
library_spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = crate::genre_album_browse::list_albums_by_genre(&store, &request);
|
||||
if trace {
|
||||
let step_ms = t0.elapsed().as_millis();
|
||||
let album_count = result.as_ref().map(|r| r.albums.len()).unwrap_or(0);
|
||||
crate::app_deprintln!(
|
||||
"[frontend][albums-browse] {}",
|
||||
serde_json::json!({
|
||||
"step": "rust_list_albums_by_genre",
|
||||
"elapsedMs": 0,
|
||||
"details": {
|
||||
"stepMs": step_ms,
|
||||
"albums": album_count,
|
||||
"genre": trace_genre,
|
||||
"offset": trace_offset,
|
||||
"limit": trace_limit,
|
||||
"ok": result.is_ok(),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
result
|
||||
})
|
||||
library_spawn_blocking(move || crate::genre_album_browse::list_albums_by_genre(&store, &request))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_genre_tags_inspect(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
|
||||
@@ -662,7 +537,6 @@ pub fn library_genre_tags_inspect(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_genre_tags_run(
|
||||
app: tauri::AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -672,71 +546,6 @@ pub async fn library_genre_tags_run(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rebuild precomputed cluster identity keys (`library-cluster.db` attach).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_cluster_rebuild(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: Option<String>,
|
||||
) -> Result<u64, String> {
|
||||
let server_id = server_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
crate::identity::rebuild_cluster_keys(&runtime.store, server_id)
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_scope_list_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryScopeListRequest,
|
||||
) -> Result<Vec<crate::dto::LibraryAlbumDto>, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || scope_merge::list_albums(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_scope_list_artists(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryScopeListRequest,
|
||||
) -> Result<Vec<crate::dto::LibraryArtistDto>, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || scope_merge::list_artists(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_scope_search_tracks(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryScopeSearchRequest,
|
||||
) -> Result<Vec<LibraryTrackDto>, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || scope_merge::search_tracks(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_scope_album_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryScopeAlbumDetailRequest,
|
||||
) -> Result<LibraryScopeAlbumDetailResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || scope_merge::album_detail(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_scope_artist_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryScopeArtistDetailRequest,
|
||||
) -> Result<LibraryScopeArtistDetailResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
library_spawn_blocking(move || scope_merge::artist_detail(&store, &request)).await
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_get_artist_lossless_browse(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -745,7 +554,6 @@ pub async fn library_get_artist_lossless_browse(
|
||||
crate::artist_lossless_browse::get_artist_lossless_browse(&runtime.store, &request)
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_live_search(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -768,7 +576,6 @@ pub async fn library_live_search(
|
||||
&request.server_id,
|
||||
&request.query,
|
||||
request.library_scope.as_deref(),
|
||||
request.library_scopes.as_deref(),
|
||||
request.artist_limit.unwrap_or(5),
|
||||
request.album_limit.unwrap_or(5),
|
||||
request.song_limit.unwrap_or(10),
|
||||
@@ -782,7 +589,6 @@ pub async fn library_live_search(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub async fn library_search_cross_server(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -791,28 +597,11 @@ pub async fn library_search_cross_server(
|
||||
servers: Option<Vec<String>>,
|
||||
) -> Result<LibraryCrossServerSearchResponse, String> {
|
||||
let limit = limit.unwrap_or(100);
|
||||
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref(), None)
|
||||
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref())
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Ordered multi-scope wins; else single `library_scope`; empty = all libraries.
|
||||
fn effective_library_scopes(
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: Option<&[String]>,
|
||||
) -> Vec<String> {
|
||||
if let Some(list) = library_scopes {
|
||||
return crate::search::normalized_library_scopes(list);
|
||||
}
|
||||
crate::search::normalized_library_scopes(
|
||||
&library_scope
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| vec![s.to_string()])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn hydrate_refs(
|
||||
runtime: &LibraryRuntime,
|
||||
refs: &[TrackRefDto],
|
||||
@@ -888,7 +677,6 @@ async fn navidrome_token_with_retry(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_sync_bind_session(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
@@ -962,7 +750,6 @@ fn subsonic_base_url_from(runtime: &LibraryRuntime, server_id: &str) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_sync_clear_session(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -972,7 +759,6 @@ pub fn library_sync_clear_session(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_set_playback_hint(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
hint: String,
|
||||
@@ -988,7 +774,6 @@ pub fn library_set_playback_hint(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result<String, String> {
|
||||
Ok(match runtime.current_playback_hint() {
|
||||
PlaybackHint::Idle => "idle".to_string(),
|
||||
@@ -998,7 +783,6 @@ pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result<S
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_sync_start(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -1135,19 +919,7 @@ async fn library_sync_start_inner(
|
||||
if let Some(creds) = navidrome_creds.clone() {
|
||||
runner = runner.with_navidrome_credentials(creds);
|
||||
}
|
||||
let run = sync_outcome_to_result(runner.run().await);
|
||||
if run.is_ok() {
|
||||
run_tag_pass_best_effort(
|
||||
&store,
|
||||
&subsonic,
|
||||
&session_clone.server_id,
|
||||
Some(Arc::clone(&cancel_for_task)),
|
||||
Arc::clone(&progress),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
run
|
||||
sync_outcome_to_result(runner.run().await)
|
||||
} else {
|
||||
// Delta — Mode A manual integrity uses the DeltaMismatch
|
||||
// budget for tombstones when the local/server count gap
|
||||
@@ -1175,19 +947,7 @@ async fn library_sync_start_inner(
|
||||
if let Some(creds) = navidrome_creds.clone() {
|
||||
runner = runner.with_navidrome_credentials(creds);
|
||||
}
|
||||
let run = sync_outcome_to_result(runner.run().await);
|
||||
if run.is_ok() {
|
||||
run_tag_pass_best_effort(
|
||||
&store,
|
||||
&subsonic,
|
||||
&session_clone.server_id,
|
||||
Some(Arc::clone(&cancel_for_task)),
|
||||
Arc::clone(&progress),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
run
|
||||
sync_outcome_to_result(runner.run().await)
|
||||
};
|
||||
|
||||
// Closing the mpsc sender by dropping `progress` so the
|
||||
@@ -1257,7 +1017,6 @@ async fn library_sync_start_inner(
|
||||
/// Mode A user-initiated full reconcile bypasses the threshold
|
||||
/// check.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_sync_verify_integrity(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -1276,7 +1035,6 @@ pub async fn library_sync_verify_integrity(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_sync_cancel(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
job_id: Option<String>,
|
||||
@@ -1322,7 +1080,6 @@ pub fn patch_content_hash(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// NOT specta-collected: takes a serde_json::Value arg — specta rc.25 can't export it. Stays hand-written on generate_handler!.
|
||||
#[tauri::command]
|
||||
pub fn library_patch_track(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -1403,7 +1160,6 @@ pub(crate) fn apply_track_patch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_put_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1420,7 +1176,6 @@ pub fn library_put_artifact(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_put_fact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1433,7 +1188,6 @@ pub fn library_put_fact(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_record_play_session(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
input: PlaySessionInputDto,
|
||||
@@ -1442,7 +1196,6 @@ pub fn library_record_play_session(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_year_summary(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
year: i32,
|
||||
@@ -1451,7 +1204,6 @@ pub fn library_get_player_stats_year_summary(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_heatmap(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
year: i32,
|
||||
@@ -1460,7 +1212,6 @@ pub fn library_get_player_stats_heatmap(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_day_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
date_iso: String,
|
||||
@@ -1469,7 +1220,6 @@ pub fn library_get_player_stats_day_detail(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_year_bounds(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
) -> Result<PlaySessionYearBoundsDto, String> {
|
||||
@@ -1477,7 +1227,6 @@ pub fn library_get_player_stats_year_bounds(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_recent_days(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
limit: Option<u32>,
|
||||
@@ -1486,18 +1235,6 @@ pub fn library_get_player_stats_recent_days(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_recent_play_sessions(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
limit: Option<u32>,
|
||||
since_ms: Option<i64>,
|
||||
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
|
||||
PlaySessionRepository::new(&runtime.store)
|
||||
.recent_plays(limit.unwrap_or(50), since_ms)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_purge_server(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1612,7 +1349,6 @@ pub fn library_purge_server(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_migrate_server_index_keys(
|
||||
_runtime: State<'_, LibraryRuntime>,
|
||||
mappings: Vec<LibraryServerKeyMigrationDto>,
|
||||
@@ -1624,7 +1360,6 @@ pub fn library_migrate_server_index_keys(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_delete_server_data(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1747,7 +1482,6 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: Some(format!("hash-{id}")),
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -23,7 +23,7 @@ const MAX_BATCH: u32 = 256;
|
||||
const SCAN_PAGE: i64 = 256;
|
||||
const MAX_SCAN_PAGES: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverBackfillItem {
|
||||
pub cache_kind: String,
|
||||
@@ -31,7 +31,7 @@ pub struct CoverBackfillItem {
|
||||
pub fetch_cover_art_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverBackfillBatchDto {
|
||||
pub items: Vec<CoverBackfillItem>,
|
||||
@@ -41,7 +41,7 @@ pub struct LibraryCoverBackfillBatchDto {
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverProgressDto {
|
||||
pub total_distinct: i64,
|
||||
@@ -606,10 +606,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(batch.cover_ids, vec!["0DurV2S7arIOBQVEknOPWX".to_string()]);
|
||||
assert_eq!(batch.items[0].cache_kind, "album");
|
||||
assert_eq!(
|
||||
batch.items[0].fetch_cover_art_id,
|
||||
"al-0DurV2S7arIOBQVEknOPWX_0"
|
||||
);
|
||||
assert_eq!(batch.items[0].fetch_cover_art_id, "0DurV2S7arIOBQVEknOPWX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@ use rusqlite::OptionalExtension;
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverEntryDto {
|
||||
pub cache_kind: String,
|
||||
@@ -107,50 +107,13 @@ pub fn resolve_album_cover_entry(
|
||||
)
|
||||
.optional()
|
||||
})? {
|
||||
None => {
|
||||
return track_only_album_backfill_entry(store, library_server_id, album_id);
|
||||
}
|
||||
None => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
// Album rows synced without a cover id (created from a starred/tag/browse path
|
||||
// rather than getAlbum) would otherwise resolve to the bare album id, which
|
||||
// fails on servers that only serve art under a track/media cover id. Fall back
|
||||
// to the album's first track cover so the detail header and browse tiles agree.
|
||||
let cover_art_id = match cover_art_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
Some(_) => cover_art_id,
|
||||
None => album_track_cover_art_id(store, library_server_id, album_id)?,
|
||||
};
|
||||
let distinct = album_has_distinct_disc_covers(store, library_server_id, album_id)?;
|
||||
Ok(resolve_album_cover(album_id, cover_art_id.as_deref(), distinct).map(Into::into))
|
||||
}
|
||||
|
||||
/// First non-empty track cover id for an album — used to fill an `album` row that
|
||||
/// synced without a `cover_art_id`. Mirrors the `ALBUM_COLUMNS` COALESCE fallback
|
||||
/// in `advanced_search.rs` so browse tiles and cover resolution agree.
|
||||
fn album_track_cover_art_id(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
album_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT t.cover_art_id FROM track t
|
||||
WHERE t.server_id = ?1 AND t.album_id = ?2 AND t.deleted = 0
|
||||
AND NULLIF(TRIM(t.cover_art_id), '') IS NOT NULL
|
||||
ORDER BY t.id ASC
|
||||
LIMIT 1",
|
||||
rusqlite::params![library_server_id, album_id],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(Option::flatten)
|
||||
})
|
||||
}
|
||||
|
||||
/// Album id appears only on `track` rows (no `album` table row) — mirror catalog `fetch_id`.
|
||||
fn track_only_album_backfill_entry(
|
||||
store: &LibraryStore,
|
||||
@@ -411,51 +374,6 @@ mod tests {
|
||||
assert_eq!(e.fetch_cover_art_id, "al-ca78bec6_60fc987f");
|
||||
}
|
||||
|
||||
// #1252: album row without cover id — use first track mf when present.
|
||||
#[test]
|
||||
fn resolve_album_falls_back_to_track_mf_when_row_cover_null() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_album(&store, "srv", "al-nocover", None);
|
||||
seed_track(&store, "srv", "tr1", "al-nocover", 1, Some("mf-cover"));
|
||||
let e = resolve_album_cover_entry(&store, "srv", "al-nocover")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(e.cache_entity_id, "al-nocover");
|
||||
assert_eq!(e.fetch_cover_art_id, "mf-cover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_without_album_row_uses_track_only_backfill() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(
|
||||
&store,
|
||||
"srv",
|
||||
"tr1",
|
||||
"2lsdR1ogDKiFcAD6Pcvk4f",
|
||||
1,
|
||||
Some("mf-fis8alFzjMGlcncxrvmpUV_67afa52a"),
|
||||
);
|
||||
let e = resolve_album_cover_entry(&store, "srv", "2lsdR1ogDKiFcAD6Pcvk4f")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(e.cache_entity_id, "2lsdR1ogDKiFcAD6Pcvk4f");
|
||||
assert_eq!(
|
||||
e.fetch_cover_art_id,
|
||||
"mf-fis8alFzjMGlcncxrvmpUV_67afa52a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_album_keeps_row_cover_over_track_cover() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_album(&store, "srv", "al-rowcover", Some("al-rowcover_art"));
|
||||
seed_track(&store, "srv", "tr1", "al-rowcover", 1, Some("mf-cover"));
|
||||
let e = resolve_album_cover_entry(&store, "srv", "al-rowcover")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(e.fetch_cover_art_id, "al-rowcover_art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_track_defaults_to_album_bucket() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -3,16 +3,13 @@
|
||||
//! (`fuzzy`): per-server `title LIKE` matches the exact FTS pass missed.
|
||||
//! UI wiring stays PR-7.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
|
||||
use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto};
|
||||
use crate::repos;
|
||||
use crate::search::{
|
||||
aliased_track_columns, fts_query, library_scope_in_sql, like_contains,
|
||||
normalized_library_scopes, push_library_scope_binds, PAGE_LIMIT_MAX,
|
||||
};
|
||||
use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
/// §5.9 caps the fuzzy fallback at "top 20 per server" so a `LIKE %…%` scan
|
||||
@@ -27,7 +24,6 @@ pub fn run_cross_server_search(
|
||||
query: &str,
|
||||
limit: u32,
|
||||
servers: Option<&[String]>,
|
||||
library_scopes: Option<&[String]>,
|
||||
) -> Result<LibraryCrossServerSearchResponse, String> {
|
||||
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
|
||||
let Some(fts) = fts_query(query) else {
|
||||
@@ -44,12 +40,38 @@ pub fn run_cross_server_search(
|
||||
return Ok(LibraryCrossServerSearchResponse::default());
|
||||
}
|
||||
|
||||
let scopes = library_scopes
|
||||
.map(normalized_library_scopes)
|
||||
.unwrap_or_default();
|
||||
let placeholders = vec!["?"; targets.len()].join(", ");
|
||||
let cols = aliased_track_columns("t");
|
||||
let sql = format!(
|
||||
"SELECT {cols}, l.canonical_id \
|
||||
FROM track_fts f \
|
||||
JOIN track t ON t.rowid = f.rowid \
|
||||
LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \
|
||||
WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({placeholders}) \
|
||||
ORDER BY bm25(track_fts) LIMIT ?"
|
||||
);
|
||||
|
||||
let rowids = collect_cross_server_fts_rowids(store, &fts, &targets, &scopes, limit as i64)?;
|
||||
let rows = fetch_cross_server_hits(store, &rowids, &scopes)?;
|
||||
let mut params: Vec<SqlValue> = Vec::with_capacity(targets.len() + 2);
|
||||
params.push(SqlValue::Text(fts));
|
||||
for s in &targets {
|
||||
params.push(SqlValue::Text(s.clone()));
|
||||
}
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
|
||||
let canonical_idx = repos::track_columns().split(',').count();
|
||||
let rows: Vec<(LibraryTrackDto, Option<String>)> = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
// Bind the collected `Result` before unwrapping so the `MappedRows`
|
||||
// borrow of `stmt` ends inside the block (rusqlite borrow quirk).
|
||||
let collected: rusqlite::Result<Vec<(LibraryTrackDto, Option<String>)>> = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
|
||||
let canonical: Option<String> = r.get(canonical_idx)?;
|
||||
Ok((track, canonical))
|
||||
})?
|
||||
.collect();
|
||||
collected
|
||||
})?;
|
||||
|
||||
// Dedup the exact hits by canonical id (§5.5B step 2). Rows with no
|
||||
// canonical link are always kept — the link table is sparse for tracks
|
||||
@@ -70,15 +92,7 @@ pub fn run_cross_server_search(
|
||||
.iter()
|
||||
.map(|t| (t.server_id.clone(), t.id.clone()))
|
||||
.collect();
|
||||
let fuzzy = fuzzy_matches(
|
||||
store,
|
||||
&targets,
|
||||
query.trim(),
|
||||
&mut seen,
|
||||
&hit_keys,
|
||||
limit as usize,
|
||||
&scopes,
|
||||
)?;
|
||||
let fuzzy = fuzzy_matches(store, &targets, query.trim(), &mut seen, &hit_keys, limit as usize)?;
|
||||
|
||||
Ok(LibraryCrossServerSearchResponse {
|
||||
hits,
|
||||
@@ -87,88 +101,6 @@ pub fn run_cross_server_search(
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_cross_server_fts_rowids(
|
||||
store: &LibraryStore,
|
||||
fts: &str,
|
||||
targets: &[String],
|
||||
library_scopes: &[String],
|
||||
limit: i64,
|
||||
) -> Result<Vec<i64>, String> {
|
||||
let server_placeholders = vec!["?"; targets.len()].join(", ");
|
||||
let mut scope_sql = String::new();
|
||||
if !library_scopes.is_empty() {
|
||||
scope_sql = format!(" AND {}", library_scope_in_sql("c", library_scopes.len()));
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT f.rowid FROM track_fts f \
|
||||
WHERE track_fts MATCH ? \
|
||||
AND EXISTS (\
|
||||
SELECT 1 FROM track c \
|
||||
WHERE c.rowid = f.rowid \
|
||||
AND c.deleted = 0 \
|
||||
AND c.server_id IN ({server_placeholders}){scope_sql}\
|
||||
) \
|
||||
ORDER BY bm25(track_fts) LIMIT ?",
|
||||
);
|
||||
store.with_read_conn(|conn| {
|
||||
let mut bind: Vec<SqlValue> = vec![SqlValue::Text(fts.to_string())];
|
||||
for s in targets {
|
||||
bind.push(SqlValue::Text(s.clone()));
|
||||
}
|
||||
push_library_scope_binds(&mut bind, library_scopes);
|
||||
bind.push(SqlValue::Integer(limit));
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let collected: rusqlite::Result<Vec<i64>> = stmt
|
||||
.query_map(rusqlite::params_from_iter(bind.iter()), |r| r.get(0))?
|
||||
.collect();
|
||||
collected
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_cross_server_hits(
|
||||
store: &LibraryStore,
|
||||
rowids: &[i64],
|
||||
library_scopes: &[String],
|
||||
) -> Result<Vec<(LibraryTrackDto, Option<String>)>, String> {
|
||||
if rowids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let placeholders = vec!["?"; rowids.len()].join(", ");
|
||||
let cols = aliased_track_columns("t");
|
||||
let mut scope_sql = String::new();
|
||||
if !library_scopes.is_empty() {
|
||||
scope_sql = format!(" AND {}", library_scope_in_sql("t", library_scopes.len()));
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT {cols}, l.canonical_id, t.rowid \
|
||||
FROM track t \
|
||||
LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \
|
||||
WHERE t.rowid IN ({placeholders}) \
|
||||
AND t.deleted = 0{scope_sql}",
|
||||
);
|
||||
let canonical_idx = repos::track_columns().split(',').count();
|
||||
let rowid_idx = canonical_idx + 1;
|
||||
store.with_read_conn(|conn| {
|
||||
let mut bind: Vec<SqlValue> = rowids.iter().copied().map(SqlValue::Integer).collect();
|
||||
push_library_scope_binds(&mut bind, library_scopes);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut by_rowid: HashMap<i64, (LibraryTrackDto, Option<String>)> = HashMap::new();
|
||||
for row in stmt.query_map(rusqlite::params_from_iter(bind.iter()), |r| {
|
||||
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
|
||||
let canonical: Option<String> = r.get(canonical_idx)?;
|
||||
let rowid: i64 = r.get(rowid_idx)?;
|
||||
Ok((rowid, (track, canonical)))
|
||||
})? {
|
||||
let (rowid, pair) = row?;
|
||||
by_rowid.insert(rowid, pair);
|
||||
}
|
||||
Ok(rowids
|
||||
.iter()
|
||||
.filter_map(|rid| by_rowid.get(rid).cloned())
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// §5.9 fuzzy fallback: per target server, `title LIKE %query%` for matches
|
||||
/// the exact FTS pass missed (diacritics, partial words). Skips rows already
|
||||
/// in `hit_keys` and dedupes by canonical id against `seen` (which holds the
|
||||
@@ -181,20 +113,15 @@ fn fuzzy_matches(
|
||||
seen: &mut HashSet<String>,
|
||||
hit_keys: &HashSet<(String, String)>,
|
||||
overall_cap: usize,
|
||||
library_scopes: &[String],
|
||||
) -> Result<Vec<LibraryTrackDto>, String> {
|
||||
let like = like_contains(query);
|
||||
let cols = aliased_track_columns("t");
|
||||
let canonical_idx = repos::track_columns().split(',').count();
|
||||
let mut scope_sql = String::new();
|
||||
if !library_scopes.is_empty() {
|
||||
scope_sql = format!(" AND {}", library_scope_in_sql("t", library_scopes.len()));
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT {cols}, l.canonical_id \
|
||||
FROM track t \
|
||||
LEFT JOIN track_canonical_link l ON l.server_id = t.server_id AND l.track_id = t.id \
|
||||
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\'{scope_sql} \
|
||||
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \
|
||||
ORDER BY t.title COLLATE NOCASE ASC LIMIT ?"
|
||||
);
|
||||
|
||||
@@ -203,12 +130,11 @@ fn fuzzy_matches(
|
||||
if out.len() >= overall_cap {
|
||||
break;
|
||||
}
|
||||
let mut bound = vec![
|
||||
let bound = [
|
||||
SqlValue::Text(server.clone()),
|
||||
SqlValue::Text(like.clone()),
|
||||
SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64),
|
||||
];
|
||||
push_library_scope_binds(&mut bound, library_scopes);
|
||||
bound.push(SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64));
|
||||
let rows: Vec<(LibraryTrackDto, Option<String>)> = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let collected: rusqlite::Result<Vec<(LibraryTrackDto, Option<String>)>> = stmt
|
||||
@@ -286,7 +212,6 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -309,42 +234,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn track_with_lib(
|
||||
server: &str,
|
||||
id: &str,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
library_id: Option<&str>,
|
||||
) -> TrackRow {
|
||||
let mut t = track(server, id, title, artist, album);
|
||||
t.library_id = library_id.map(str::to_string);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_scope_narrows_cross_server_hits() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_with_lib("s1", "t1", "Aurora", "Anna", "Alb", Some("lib1")),
|
||||
track_with_lib("s2", "t2", "Aurora", "Beth", "Alb", Some("lib2")),
|
||||
])
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
let resp = run_cross_server_search(
|
||||
&store,
|
||||
"aurora",
|
||||
50,
|
||||
None,
|
||||
Some(&["lib1".to_string()]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resp.hits.len(), 1);
|
||||
assert_eq!(resp.hits[0].server_id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_searches_ready_servers_only() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
@@ -358,7 +247,7 @@ mod tests {
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
set_phase(&store, "s3", "idle"); // not ready → excluded
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
let servers: HashSet<&str> = resp.hits.iter().map(|t| t.server_id.as_str()).collect();
|
||||
assert_eq!(servers, HashSet::from(["s1", "s2"]));
|
||||
assert_eq!(resp.servers_searched.len(), 2);
|
||||
@@ -371,7 +260,7 @@ mod tests {
|
||||
.upsert_batch(&[track("s9", "t1", "Aurora", "Anna", "Alb")])
|
||||
.unwrap();
|
||||
// s9 is not marked ready, but an explicit servers list overrides.
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, Some(&["s9".to_string()]), None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, Some(&["s9".to_string()])).unwrap();
|
||||
assert_eq!(resp.hits.len(), 1);
|
||||
assert_eq!(resp.servers_searched, vec!["s9".to_string()]);
|
||||
}
|
||||
@@ -405,7 +294,7 @@ mod tests {
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 1, "duplicate canonical id collapses to one hit");
|
||||
}
|
||||
|
||||
@@ -421,7 +310,7 @@ mod tests {
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
// No canonical links → both kept even though titles match.
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 2);
|
||||
}
|
||||
|
||||
@@ -432,7 +321,7 @@ mod tests {
|
||||
.upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")])
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
let resp = run_cross_server_search(&store, " ", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, " ", 50, None).unwrap();
|
||||
assert!(resp.hits.is_empty());
|
||||
assert!(resp.servers_searched.is_empty());
|
||||
}
|
||||
@@ -444,7 +333,7 @@ mod tests {
|
||||
.upsert_batch(&[track("s1", "t1", "Aurora", "Anna", "Alb")])
|
||||
.unwrap();
|
||||
// No sync_state row marked ready, and no explicit servers given.
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
assert!(resp.hits.is_empty());
|
||||
assert!(resp.servers_searched.is_empty());
|
||||
}
|
||||
@@ -465,7 +354,7 @@ mod tests {
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 1, "exact FTS hit");
|
||||
assert_eq!(resp.hits[0].id, "t1");
|
||||
assert_eq!(resp.fuzzy.len(), 1, "fuzzy catches the FTS miss");
|
||||
@@ -480,7 +369,7 @@ mod tests {
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
// "Aurora" is both an FTS hit and a LIKE match — must appear only once.
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 1);
|
||||
assert!(resp.fuzzy.is_empty(), "exact hits are not repeated in fuzzy");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::store::LibraryStore;
|
||||
|
||||
/// `library_get_status` payload — mirrors the `sync_state` row plus a
|
||||
/// few derived counters from `track`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStateDto {
|
||||
pub server_id: String,
|
||||
@@ -106,7 +106,6 @@ pub struct LibraryTrackDto {
|
||||
pub bpm_source: Option<String>,
|
||||
pub replay_gain_track_db: Option<f64>,
|
||||
pub replay_gain_album_db: Option<f64>,
|
||||
pub replay_gain_peak: Option<f64>,
|
||||
|
||||
pub server_updated_at: Option<i64>,
|
||||
pub server_created_at: Option<i64>,
|
||||
@@ -158,7 +157,6 @@ impl LibraryTrackDto {
|
||||
bpm_source: None,
|
||||
replay_gain_track_db: row.replay_gain_track_db,
|
||||
replay_gain_album_db: row.replay_gain_album_db,
|
||||
replay_gain_peak: row.replay_gain_peak,
|
||||
server_updated_at: row.server_updated_at,
|
||||
server_created_at: row.server_created_at,
|
||||
synced_at: row.synced_at,
|
||||
@@ -177,7 +175,7 @@ pub struct LibraryTracksEnvelope {
|
||||
}
|
||||
|
||||
/// `library_get_artifact` payload — one row of `track_artifact`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackArtifactDto {
|
||||
pub server_id: String,
|
||||
@@ -196,7 +194,7 @@ pub struct TrackArtifactDto {
|
||||
}
|
||||
|
||||
/// `library_get_facts` row.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackFactDto {
|
||||
pub server_id: String,
|
||||
@@ -216,7 +214,7 @@ pub struct TrackFactDto {
|
||||
|
||||
/// `library_get_offline_path` outcome — either a path string or a
|
||||
/// `missing` flag so the frontend can show a hint without polling.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OfflinePathDto {
|
||||
pub server_id: String,
|
||||
@@ -238,7 +236,7 @@ pub struct TrackRefDto {
|
||||
/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto`
|
||||
/// minus the server-supplied `server_id` / `track_id` (provided as
|
||||
/// command args) and `fetched_at` (stamped server-side from `now`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtifactInputDto {
|
||||
pub artifact_kind: String,
|
||||
@@ -263,7 +261,7 @@ pub struct ArtifactInputDto {
|
||||
|
||||
/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the
|
||||
/// indices.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FactInputDto {
|
||||
pub fact_kind: String,
|
||||
@@ -290,7 +288,7 @@ fn default_confidence() -> f64 {
|
||||
}
|
||||
|
||||
/// Input to `library_record_play_session`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionInputDto {
|
||||
pub server_id: String,
|
||||
@@ -305,7 +303,7 @@ pub struct PlaySessionInputDto {
|
||||
}
|
||||
|
||||
/// Cross-server year summary for the Player stats tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionYearSummaryDto {
|
||||
pub total_listened_sec: f64,
|
||||
@@ -321,14 +319,14 @@ pub struct PlaySessionYearSummaryDto {
|
||||
pub partial_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionHeatmapDayDto {
|
||||
pub date: String,
|
||||
pub track_play_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionDayTotalsDto {
|
||||
pub total_listened_sec: f64,
|
||||
@@ -338,7 +336,7 @@ pub struct PlaySessionDayTotalsDto {
|
||||
pub partial_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionDayTrackDto {
|
||||
pub server_id: String,
|
||||
@@ -348,23 +346,17 @@ pub struct PlaySessionDayTrackDto {
|
||||
pub listened_sec: f64,
|
||||
pub completion: String,
|
||||
pub started_at_ms: i64,
|
||||
pub album: Option<String>,
|
||||
pub album_id: Option<String>,
|
||||
pub cover_art_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionDayDetailDto {
|
||||
pub totals: PlaySessionDayTotalsDto,
|
||||
pub tracks: Vec<PlaySessionDayTrackDto>,
|
||||
}
|
||||
|
||||
/// One row from `library_get_recent_play_sessions` (timeline cold bootstrap).
|
||||
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
|
||||
|
||||
/// Summary for one day in the recent-days list (no track rows).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionRecentDayDto {
|
||||
pub date: String,
|
||||
@@ -376,7 +368,7 @@ pub struct PlaySessionRecentDayDto {
|
||||
}
|
||||
|
||||
/// Earliest/latest calendar years with at least one session (local TZ).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionYearBoundsDto {
|
||||
pub min_year: Option<i32>,
|
||||
@@ -384,7 +376,7 @@ pub struct PlaySessionYearBoundsDto {
|
||||
}
|
||||
|
||||
/// Min/max `year` from indexed tracks for a server (Albums year filter UI).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogYearBoundsDto {
|
||||
pub min_year: Option<i32>,
|
||||
@@ -392,7 +384,7 @@ pub struct CatalogYearBoundsDto {
|
||||
}
|
||||
|
||||
/// Per-genre album/track totals from the local track catalog (Genres cloud + browse).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreAlbumCountDto {
|
||||
pub value: String,
|
||||
@@ -408,9 +400,6 @@ pub struct LibraryGenreAlbumsRequest {
|
||||
pub genre: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Ordered multi-library scope; merge runs only when more than one pair is set.
|
||||
#[serde(default)]
|
||||
pub library_scopes: Option<Vec<LibraryScopePair>>,
|
||||
#[serde(default)]
|
||||
pub sort: Vec<LibrarySortClause>,
|
||||
#[serde(default = "default_genre_album_limit")]
|
||||
@@ -436,7 +425,7 @@ pub struct LibraryGenreAlbumsResponse {
|
||||
}
|
||||
|
||||
/// `library_purge_server` outcome.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PurgeReportDto {
|
||||
pub tracks_deleted: u32,
|
||||
@@ -448,7 +437,7 @@ pub struct PurgeReportDto {
|
||||
}
|
||||
|
||||
/// `library_sync_start` ack.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncJobDto {
|
||||
pub job_id: String,
|
||||
@@ -526,15 +515,6 @@ pub enum SortDir {
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Artist browse credit semantics when `entity_types` includes `artist` (#1209).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArtistCreditMode {
|
||||
#[default]
|
||||
Album,
|
||||
Track,
|
||||
}
|
||||
|
||||
/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an
|
||||
/// `fts` clause on the text fields; `entityTypes` controls which of the
|
||||
/// three queries run; `filters` are combined with AND.
|
||||
@@ -544,9 +524,6 @@ pub struct LibraryAdvancedSearchRequest {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Ordered multi-library scope; merge runs only when more than one pair is set.
|
||||
#[serde(default)]
|
||||
pub library_scopes: Option<Vec<LibraryScopePair>>,
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
pub entity_types: Vec<EntityKind>,
|
||||
@@ -570,13 +547,6 @@ pub struct LibraryAdvancedSearchRequest {
|
||||
/// When true, skip per-entity COUNT queries (Live Search / small pages).
|
||||
#[serde(default)]
|
||||
pub skip_totals: bool,
|
||||
/// Album-artist vs track-performer browse when querying `artist` (#1209).
|
||||
/// `None` is treated as [`ArtistCreditMode::Album`].
|
||||
#[serde(default)]
|
||||
pub artist_credit_mode: Option<ArtistCreditMode>,
|
||||
/// A–Z / `#` / `OTHER` / omit or `ALL` — letter bucket on `name_sort` (#1209 browse).
|
||||
#[serde(default)]
|
||||
pub artist_letter_bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-entity result counts (full match count, not page size).
|
||||
@@ -612,9 +582,6 @@ pub struct LibraryLiveSearchRequest {
|
||||
pub query: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Ordered multi-library scope; merge runs only when more than one pair is set.
|
||||
#[serde(default)]
|
||||
pub library_scopes: Option<Vec<LibraryScopePair>>,
|
||||
#[serde(default)]
|
||||
pub artist_limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
@@ -642,10 +609,6 @@ pub struct LibraryLosslessAlbumsRequest {
|
||||
pub server_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Ordered library ids for a multi-library selection; takes precedence over
|
||||
/// the legacy single `library_scope` when present.
|
||||
#[serde(default)]
|
||||
pub library_scopes: Option<Vec<String>>,
|
||||
#[serde(default = "default_lossless_limit")]
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
@@ -673,10 +636,6 @@ pub struct LibraryArtistLosslessBrowseRequest {
|
||||
pub artist_id: String,
|
||||
#[serde(default)]
|
||||
pub library_scope: Option<String>,
|
||||
/// Ordered library ids for a multi-library selection; takes precedence over
|
||||
/// the legacy single `library_scope` when present.
|
||||
#[serde(default)]
|
||||
pub library_scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Lossless albums + tracks for one artist (local index).
|
||||
@@ -688,117 +647,6 @@ pub struct LibraryArtistLosslessBrowseResponse {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Multi-library scope merge (WO-4)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One `(server_id, library_id)` pair in priority order (index 0 = highest).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopePair {
|
||||
pub server_id: String,
|
||||
pub library_id: String,
|
||||
}
|
||||
|
||||
/// Derive ordered `(server_id, library_id)` pairs from request fields.
|
||||
/// List order is merge priority (index 0 wins). Empty = all libraries on the server.
|
||||
pub(crate) fn ordered_library_scope_pairs(
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: Option<&[LibraryScopePair]>,
|
||||
) -> Vec<LibraryScopePair> {
|
||||
if let Some(scopes) = library_scopes {
|
||||
let pairs: Vec<LibraryScopePair> = scopes
|
||||
.iter()
|
||||
.filter(|p| !p.server_id.trim().is_empty() && !p.library_id.trim().is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
if !pairs.is_empty() {
|
||||
return pairs;
|
||||
}
|
||||
}
|
||||
if let Some(scope) = library_scope.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return vec![LibraryScopePair {
|
||||
server_id: server_id.to_string(),
|
||||
library_id: scope.to_string(),
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Layer-2 dedup runs only when the ordered scope has more than one pair.
|
||||
pub(crate) fn multi_library_merge_enabled(scopes: &[LibraryScopePair]) -> bool {
|
||||
scopes.len() > 1
|
||||
}
|
||||
|
||||
/// Layer-1 scoped browse (sargable `library_id`, no cluster join) for one server.
|
||||
pub(crate) fn scoped_layer1_eligible(scopes: &[LibraryScopePair]) -> bool {
|
||||
let Some(first) = scopes.first() else {
|
||||
return false;
|
||||
};
|
||||
scopes
|
||||
.iter()
|
||||
.all(|p| p.server_id == first.server_id && !p.library_id.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Paginated album/artist browse over an ordered multi-library scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeListRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// FTS track search over an ordered multi-library scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeSearchRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
pub query: String,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// Aggregated album detail — anchor entity id on one server, scope for union.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeAlbumDetailRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
pub album_id: String,
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
/// Aggregated artist detail — anchor entity id on one server, scope for union.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeArtistDetailRequest {
|
||||
pub scopes: Vec<LibraryScopePair>,
|
||||
pub artist_id: String,
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
/// `library_scope_album_detail` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeAlbumDetailResponse {
|
||||
pub album: LibraryAlbumDto,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
}
|
||||
|
||||
/// `library_scope_artist_detail` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryScopeArtistDetailResponse {
|
||||
pub artist: LibraryArtistDto,
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
}
|
||||
|
||||
/// `library_search_cross_server` response (§5.5B / §5.9).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -896,7 +744,6 @@ mod tests {
|
||||
bpm: Some(120),
|
||||
replay_gain_track_db: Some(-1.2),
|
||||
replay_gain_album_db: Some(-0.8),
|
||||
replay_gain_peak: Some(0.95),
|
||||
content_hash: Some("deadbeef".into()),
|
||||
server_updated_at: Some(1_700_000_000),
|
||||
server_created_at: Some(1_699_000_000),
|
||||
@@ -927,7 +774,6 @@ mod tests {
|
||||
"mbidRecording",
|
||||
"replayGainTrackDb",
|
||||
"replayGainAlbumDb",
|
||||
"replayGainPeak",
|
||||
"serverUpdatedAt",
|
||||
"syncedAt",
|
||||
"rawJson",
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
//! LIMIT/OFFSET on grouped rows) instead of the heavier Advanced Search builder.
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibraryScopePair,
|
||||
LibrarySortClause, SortDir, multi_library_merge_enabled, ordered_library_scope_pairs,
|
||||
scoped_layer1_eligible,
|
||||
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
|
||||
SortDir,
|
||||
};
|
||||
use crate::scope_merge;
|
||||
use crate::search::library_scope_sargable_equals_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
@@ -47,11 +45,15 @@ fn count_genre_albums(
|
||||
conn: &rusqlite::Connection,
|
||||
where_sql: &str,
|
||||
params: &[SqlValue],
|
||||
_library_scoped: bool,
|
||||
library_scoped: bool,
|
||||
) -> Result<u32, rusqlite::Error> {
|
||||
let from = "FROM track_genre tg \
|
||||
let from = if library_scoped {
|
||||
"FROM track_genre tg \
|
||||
INNER JOIN track t \
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0";
|
||||
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0"
|
||||
} else {
|
||||
"FROM track_genre tg"
|
||||
};
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
|
||||
let n: i64 = conn.query_row(
|
||||
&count_sql,
|
||||
@@ -109,34 +111,7 @@ pub fn list_albums_by_genre(
|
||||
|
||||
let limit = req.limit.max(1);
|
||||
let offset = req.offset;
|
||||
|
||||
let scope_pairs = ordered_library_scope_pairs(
|
||||
&req.server_id,
|
||||
req.library_scope.as_deref(),
|
||||
req.library_scopes.as_deref(),
|
||||
);
|
||||
// Any >1-library scope collapses duplicates via cluster keys — including the
|
||||
// Layer-1 same-server path, whose genre `EXISTS` sets `merge_by_album_key`.
|
||||
// Build keys first so dedup works on a cold index (not only after a prior
|
||||
// search / sync-idle rebuild happened to populate them).
|
||||
if multi_library_merge_enabled(&scope_pairs) {
|
||||
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
|
||||
}
|
||||
if scoped_layer1_eligible(&scope_pairs) {
|
||||
return list_albums_by_genre_layer1_scope(store, req, &scope_pairs, genre, limit, offset);
|
||||
}
|
||||
if multi_library_merge_enabled(&scope_pairs) {
|
||||
return list_albums_by_genre_multi_scope(store, req, &scope_pairs, genre, limit, offset);
|
||||
}
|
||||
|
||||
let mut legacy = req.clone();
|
||||
if legacy.library_scope.is_none() {
|
||||
if let Some(pair) = scope_pairs.first() {
|
||||
legacy.library_scope = Some(pair.library_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let order_sql = genre_album_order_sql(&legacy.sort);
|
||||
let order_sql = genre_album_order_sql(&req.sort);
|
||||
|
||||
let mut where_clauses = vec![
|
||||
"tg.server_id = ?1".to_string(),
|
||||
@@ -144,13 +119,13 @@ pub fn list_albums_by_genre(
|
||||
"tg.genre = ?2 COLLATE NOCASE".to_string(),
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![
|
||||
SqlValue::Text(legacy.server_id.clone()),
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(genre.to_string()),
|
||||
];
|
||||
|
||||
let library_scoped = trimmed_nonempty(legacy.library_scope.as_deref()).is_some();
|
||||
if let Some(scope) = trimmed_nonempty(legacy.library_scope.as_deref()) {
|
||||
where_clauses.push(library_scope_sargable_equals_sql("t"));
|
||||
let library_scoped = trimmed_nonempty(req.library_scope.as_deref()).is_some();
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
where_clauses.push(library_scope_equals_sql("t"));
|
||||
params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
@@ -202,7 +177,7 @@ pub fn list_albums_by_genre(
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let total = if legacy.include_total {
|
||||
let total = if req.include_total {
|
||||
Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?)
|
||||
} else {
|
||||
None
|
||||
@@ -222,104 +197,6 @@ pub fn list_albums_by_genre(
|
||||
})
|
||||
}
|
||||
|
||||
fn genre_multi_scope_order_sql(sort: &[LibrarySortClause]) -> String {
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for s in sort {
|
||||
let col = match s.field.as_str() {
|
||||
"name" => "album COLLATE NOCASE".to_string(),
|
||||
"artist" => "artist COLLATE NOCASE".to_string(),
|
||||
"year" => "year".to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
let dir = match s.dir {
|
||||
SortDir::Asc => "ASC",
|
||||
SortDir::Desc => "DESC",
|
||||
};
|
||||
keys.push(format!("{col} {dir}"));
|
||||
}
|
||||
if keys.is_empty() {
|
||||
keys.push("album COLLATE NOCASE ASC".to_string());
|
||||
}
|
||||
keys.push("album_id ASC".to_string());
|
||||
format!("ORDER BY {}", keys.join(", "))
|
||||
}
|
||||
|
||||
fn list_albums_by_genre_layer1_scope(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryGenreAlbumsRequest,
|
||||
scopes: &[LibraryScopePair],
|
||||
genre: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LibraryGenreAlbumsResponse, String> {
|
||||
let extra_where = "EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)";
|
||||
let extra_params = vec![SqlValue::Text(genre.to_string())];
|
||||
// Plain-identifier keys, so the same string is correct for both the grouped and
|
||||
// the dedup shape (SQLite resolves a bare ORDER BY name to the result alias).
|
||||
let order = genre_multi_scope_order_sql(&req.sort);
|
||||
let (albums, total_count) = scope_merge::list_albums_layer1_filtered(
|
||||
store,
|
||||
scopes,
|
||||
extra_where,
|
||||
&extra_params,
|
||||
&order,
|
||||
&order,
|
||||
limit,
|
||||
offset,
|
||||
!req.include_total,
|
||||
true,
|
||||
)?;
|
||||
let total = if req.include_total {
|
||||
Some(total_count)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(LibraryGenreAlbumsResponse {
|
||||
albums: albums.clone(),
|
||||
has_more: albums.len() as u32 == limit,
|
||||
total,
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_albums_by_genre_multi_scope(
|
||||
store: &LibraryStore,
|
||||
req: &LibraryGenreAlbumsRequest,
|
||||
scopes: &[LibraryScopePair],
|
||||
genre: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LibraryGenreAlbumsResponse, String> {
|
||||
let extra_where = "EXISTS (SELECT 1 FROM track_genre tg \
|
||||
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
|
||||
AND tg.genre = ? COLLATE NOCASE)";
|
||||
let extra_params = vec![SqlValue::Text(genre.to_string())];
|
||||
let order = genre_multi_scope_order_sql(&req.sort);
|
||||
let (albums, total_count) = scope_merge::list_albums_filtered(
|
||||
store,
|
||||
scopes,
|
||||
extra_where,
|
||||
&extra_params,
|
||||
&order,
|
||||
limit,
|
||||
offset,
|
||||
!req.include_total,
|
||||
)?;
|
||||
let has_more = albums.len() as u32 == limit;
|
||||
Ok(LibraryGenreAlbumsResponse {
|
||||
albums,
|
||||
has_more,
|
||||
total: if req.include_total {
|
||||
Some(total_count)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -357,7 +234,6 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -388,7 +264,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Rock".into(),
|
||||
library_scope: Some("lib1".into()),
|
||||
library_scopes: None,
|
||||
sort: vec![LibrarySortClause {
|
||||
field: "name".into(),
|
||||
dir: SortDir::Asc,
|
||||
@@ -408,7 +283,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Rock".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
sort: vec![],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
@@ -438,7 +312,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Dark Ambient".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
@@ -456,7 +329,6 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Noise Metal".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user