mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 07:45:45 +00:00
Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4cb95f7f | |||
| 4e05d32b88 | |||
| e822fff39f | |||
| dc7addae03 | |||
| c74e8f1921 | |||
| 7dfe58dba6 | |||
| 6759ee40e2 | |||
| fa69d11885 | |||
| f9760fe8a8 | |||
| 4e876f6e12 | |||
| 51b12eeec0 | |||
| 8610b2230e | |||
| e548c47078 | |||
| fa390e9211 | |||
| 25b8b57328 | |||
| 599ac31306 | |||
| 16aee64d66 | |||
| fa1dc1a328 | |||
| 0df547e3be | |||
| 9509b78073 | |||
| 1b73e929f0 | |||
| 6d9018e6b6 | |||
| 0a52e875a2 | |||
| ab70cbd528 | |||
| 1e8db450c4 | |||
| a451509d94 | |||
| 0331ac173d | |||
| 2e023fb8d3 | |||
| 398adaf214 | |||
| fd19419ac2 | |||
| f5ddb28d05 | |||
| 163e0e46a8 | |||
| d0edd925e4 | |||
| dac7bf56d9 | |||
| 52d927ca0b | |||
| efb8e5782c | |||
| 108a1fb74a | |||
| 9ca762dfd6 | |||
| ad5d88d105 | |||
| 6e448dcc3c | |||
| 54bc9814f1 | |||
| 097438f9da | |||
| 83aaf253cc | |||
| 96530f244e | |||
| 1625f4a8be | |||
| 51140c613a | |||
| ed99c8f2f7 | |||
| 199eb24465 | |||
| c10b13944b | |||
| a307f04b88 | |||
| 4bae7005cd | |||
| 51d08b7861 | |||
| 8e0e707544 | |||
| 89140523dc | |||
| fbd3aaa094 | |||
| 6445e12eb4 | |||
| 15d9f2bd4e | |||
| 9a0e388e4e | |||
| fc56e0823b | |||
| fa7034f100 | |||
| e25f904b04 | |||
| 01633e3501 | |||
| 80a84481c9 | |||
| f24d605ca0 | |||
| a0c8da073b | |||
| c8d2a55e86 | |||
| cf982a6ac2 | |||
| 37190775cb | |||
| 4e6b58967c | |||
| a874192408 | |||
| 41ae30f05d | |||
| 9a556dfa12 | |||
| 84164570b9 | |||
| 9557f8e10a | |||
| f48089e222 | |||
| db645ca72f | |||
| 32a21ec2c3 | |||
| 5e075b2c36 | |||
| bf0e73ce6c | |||
| 77ab95170d | |||
| fdbb9deac6 | |||
| 9183c3d657 | |||
| 114ad4633f | |||
| 05156727a9 | |||
| fb5dee0701 | |||
| b722b1abaf | |||
| f2cf8a08df | |||
| 25055dc79a | |||
| f4fa766b20 | |||
| 751ad35365 | |||
| c976b79c7d | |||
| 73c32882e0 | |||
| f982e47229 | |||
| 93fe34b332 | |||
| d3c86cf666 | |||
| 567a8b92a4 | |||
| 577b1ec7cf | |||
| 9cb55cbcc5 | |||
| 201ac309c4 | |||
| 21b8acec7c | |||
| 1bc65ca8bf | |||
| 27ef3a3881 | |||
| 47f2883ca2 | |||
| 57811f2b3f | |||
| a323212d0d | |||
| 7f48071ec4 | |||
| 87c3c588d9 | |||
| 386742e2f2 | |||
| 97bd7afed1 | |||
| 0c1a9e249e | |||
| 16e014ca28 | |||
| ece4ae651c | |||
| e021796f16 | |||
| 92635381ea | |||
| 6590bd15ef | |||
| ccaecdddd8 | |||
| 10d948db61 | |||
| c37d5f7389 | |||
| d4ab56f5a6 | |||
| fbca1831a1 | |||
| f43dcc0e76 | |||
| 13142e6ae3 | |||
| 7d98e649da | |||
| 390fdfbcca | |||
| 6becd57798 | |||
| 31d4a87401 | |||
| aea8b7750b | |||
| 695462ed87 | |||
| 982499d32b | |||
| 03ce57feff | |||
| 79c02a93cf | |||
| 7ae259cacf | |||
| 312fa78240 | |||
| c2461c88d5 | |||
| f03a5f13e8 | |||
| 28e049425e | |||
| 777c72adf7 | |||
| 1f93984482 | |||
| 31bc24a178 | |||
| e0d6623bf1 | |||
| d9dfdf66e9 | |||
| 15a6590e77 | |||
| 7d2645171b | |||
| ba9e0d6541 | |||
| d876781fcb | |||
| 9eee4eed5b | |||
| 0a8d82fe2a | |||
| afa8999d37 | |||
| 5c3a135040 | |||
| 5cb5cf1d24 | |||
| 41877d10d8 | |||
| 8cd23e86db | |||
| b3b7af0390 | |||
| 1e3c7f1915 | |||
| 132db4e620 | |||
| 2f4303ecc8 | |||
| f05183d412 | |||
| f443a60c54 | |||
| 5fdec6cd2a | |||
| 5808ef0839 | |||
| ac6974d4f1 | |||
| 8690e1529a | |||
| 572dce4703 | |||
| 9058abd340 | |||
| 2756cb7698 | |||
| e5705f853e | |||
| edab32d7ee | |||
| f41005682d | |||
| cb1a110afb | |||
| 6651abbc6f | |||
| 651a2adba4 | |||
| 42ef09f1e4 | |||
| e23f879f91 | |||
| bd5143d98c | |||
| 36b0042d9a | |||
| a27b26abbd | |||
| d5bbabac1d | |||
| 7ad196711e | |||
| 209dd61442 | |||
| 8cc022581f | |||
| 7e9cb60763 | |||
| 429e632598 | |||
| fecda65f11 | |||
| 862941c145 | |||
| 2a425862ae | |||
| 9ffc42688c | |||
| 17ea96dcdb | |||
| 321d5ff6ab | |||
| d6dbb615fd | |||
| 70c145db06 | |||
| e945da693a | |||
| fbc37db64e | |||
| 931c47e19e | |||
| e0e10e0034 | |||
| e6269c7b85 | |||
| 6dd0d4a130 | |||
| 896fe3f407 | |||
| dbffe59e58 | |||
| ea3e3f1adf | |||
| 236acc33dd | |||
| 431d65b587 | |||
| 53b244de1f | |||
| bd2d669b99 | |||
| 146727e980 | |||
| a18f8fc857 | |||
| 6d7464fddf | |||
| d9f8d01be9 | |||
| e66b674937 | |||
| e1ff4385d6 | |||
| 4c102d88a5 | |||
| c9fa69a0ad |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
// 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,25 +13,29 @@
|
||||
# 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/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
|
||||
# - src/features/playback/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
|
||||
# - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability)
|
||||
# - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
# - src/lib/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
|
||||
|
||||
# ── utils (already at or above threshold) ────────────────────────────
|
||||
src/utils/cover/coverArtRegisteredSizes.ts
|
||||
src/utils/server/serverDisplayName.ts
|
||||
src/utils/server/serverMagicString.ts
|
||||
src/utils/share/shareLink.ts
|
||||
src/utils/ui/dynamicColors.ts
|
||||
src/utils/playback/resolvePlaybackUrl.ts
|
||||
src/utils/share/copyEntityShareLink.ts
|
||||
# ── 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
|
||||
|
||||
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
|
||||
src/utils/playback/shuffleArray.ts
|
||||
src/utils/audio/resolveReplayGainDb.ts
|
||||
src/utils/playback/songToTrack.ts
|
||||
src/utils/playback/buildInfiniteQueueCandidates.ts
|
||||
src/lib/util/shuffleArray.ts
|
||||
src/features/playback/utils/audio/resolveReplayGainDb.ts
|
||||
src/lib/media/songToTrack.ts
|
||||
src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts
|
||||
|
||||
# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ──
|
||||
src/app/windowKind.ts
|
||||
@@ -41,4 +45,4 @@ src/app/bootstrap.ts
|
||||
src/app/MiniPlayerApp.tsx
|
||||
|
||||
# ── stores (added as their tests grew past the floor) ────────────────
|
||||
src/store/previewStore.ts
|
||||
src/features/playback/store/previewStore.ts
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
const FRONTEND_PATH_RE =
|
||||
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.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$)/;
|
||||
/^(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$)/;
|
||||
|
||||
@@ -13,6 +13,7 @@ const FRONTEND_JOBS = [
|
||||
'tsc --noEmit',
|
||||
'vitest --coverage (baseline + hot-path file gate)',
|
||||
'eslint',
|
||||
'dependency-cruiser',
|
||||
];
|
||||
|
||||
const RUST_JOBS = [
|
||||
@@ -25,6 +26,33 @@ 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);
|
||||
}
|
||||
@@ -149,7 +177,11 @@ export async function runCiOkAggregate(github, context, core) {
|
||||
const { owner, repo } = context.repo;
|
||||
const sha = resolveTargetSha(context);
|
||||
const excludeRunId = String(context.runId);
|
||||
const changedFiles = await listChangedFiles(github, context);
|
||||
const changedFiles = await withTransientRetry(
|
||||
'listChangedFiles',
|
||||
() => listChangedFiles(github, context),
|
||||
core,
|
||||
);
|
||||
const required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
@@ -161,12 +193,24 @@ export async function runCiOkAggregate(github, context, core) {
|
||||
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
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);
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
isTransientApiError,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
withTransientRetry,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
@@ -52,3 +54,75 @@ test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ on:
|
||||
- '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'
|
||||
@@ -25,6 +27,8 @@ on:
|
||||
- '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'
|
||||
@@ -47,3 +51,16 @@ jobs:
|
||||
- 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
|
||||
|
||||
+6
-2
@@ -37,8 +37,12 @@ src-tauri/lcov.info
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
|
||||
src/generated/
|
||||
# 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
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
+355
@@ -9,6 +9,361 @@ 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
|
||||
|
||||
## Added
|
||||
|
||||
+24
-2
@@ -84,11 +84,12 @@ 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`) runs in CI on frontend paths; run `npm run lint` 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:** 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,13 +109,32 @@ 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, etc.): `npm run lint` (ESLint strict), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -132,7 +152,9 @@ 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/1.47.0"><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"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
|
||||
|
||||
<br><br>
|
||||
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese and Hungarian.
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese, Hungarian and Polish.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
@@ -38,95 +38,142 @@ 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**. 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**, 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.
|
||||
|
||||
---
|
||||
|
||||
# Highlights
|
||||
# ⭐ Key Features
|
||||
|
||||
## Playback & Queue
|
||||
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
|
||||
|
||||
* Gapless playback
|
||||
* Crossfade
|
||||
* ReplayGain support
|
||||
* LUFS-based Smart Loudness Normalization
|
||||
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
|
||||
* Infinite Queue
|
||||
* Smart Radio sessions
|
||||
* Fast and responsive playback handling
|
||||
* Low memory usage compared to heavy web-first clients
|
||||
|
||||
## Audio Tools
|
||||
|
||||
* 10-band Equalizer
|
||||
* Equalizer presets
|
||||
* ReplayGain support and loudness-aware playback
|
||||
* 10-band Equalizer with presets
|
||||
* AutoEQ headphone correction
|
||||
* Per-device optimization
|
||||
* Loudness-aware playback options
|
||||
* Per-device EQ and output optimization
|
||||
* Adjustable playback speed
|
||||
|
||||
## Library Management
|
||||
## Lyrics & Listening
|
||||
|
||||
* 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
|
||||
|
||||
* 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
|
||||
* Drag & drop playlist management
|
||||
* Multi-select bulk actions
|
||||
|
||||
## Lyrics & Discovery
|
||||
## 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
|
||||
* Magic Strings sharing for albums, artists and queues
|
||||
* Navidrome user-management helpers for fast account sharing
|
||||
|
||||
## Sharing & Social Listening
|
||||
## Offline, Sync & Deployment
|
||||
|
||||
* 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
|
||||
* 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
|
||||
|
||||
## Personalization & Accessibility
|
||||
|
||||
* Large theme collection
|
||||
* Catppuccin and Nord inspired styles
|
||||
* Glassmorphism effects
|
||||
* Font customization
|
||||
* Zoom controls
|
||||
* Font customization and 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
|
||||
|
||||
---
|
||||
|
||||
<div align="left">
|
||||
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
|
||||
</div>
|
||||
# ✅ The Basics, Done Right
|
||||
|
||||
Orbit brings synchronized shared listening sessions directly into Psysonic.
|
||||
The things you simply expect from a serious music player — and Psysonic does them well.
|
||||
|
||||
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.
|
||||
* 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ 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.
|
||||
|
||||
+106
-1
@@ -8,6 +8,111 @@ 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
|
||||
@@ -56,7 +161,7 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
|
||||
### Equalizer — a profile per output device
|
||||
|
||||
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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'
|
||||
(( n == 1 )) && _message -e descriptions 'percent 0–100, or ±N for relative change'
|
||||
;;
|
||||
play)
|
||||
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
|
||||
|
||||
@@ -165,12 +165,17 @@ _psysonic_complete() {
|
||||
rating)
|
||||
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
|
||||
;;
|
||||
seek|volume)
|
||||
seek)
|
||||
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
|
||||
|
||||
+4
-1
@@ -7,7 +7,10 @@ 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.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
|
||||
// `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'] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1782467914,
|
||||
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||
"lastModified": 1784356753,
|
||||
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-ujJETHWrjZXDt+c0GGlsK/v8L8Ceturp16I3VxQvUo0="
|
||||
"npmDepsHash": "sha256-ORdnzlm65b2HeaUrvZehq0jGDxbdchpCzRDPD0EFVh4="
|
||||
}
|
||||
|
||||
Generated
+437
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
@@ -57,6 +57,7 @@
|
||||
"@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",
|
||||
@@ -2686,6 +2687,39 @@
|
||||
"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",
|
||||
@@ -2934,6 +2968,39 @@
|
||||
"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",
|
||||
@@ -2943,6 +3010,26 @@
|
||||
"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",
|
||||
@@ -2955,6 +3042,16 @@
|
||||
"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",
|
||||
@@ -3081,6 +3178,54 @@
|
||||
"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",
|
||||
@@ -3130,6 +3275,20 @@
|
||||
"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",
|
||||
@@ -3675,6 +3834,22 @@
|
||||
"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",
|
||||
@@ -3700,6 +3875,13 @@
|
||||
"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",
|
||||
@@ -3853,12 +4035,48 @@
|
||||
"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",
|
||||
@@ -3882,6 +4100,36 @@
|
||||
"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",
|
||||
@@ -4040,6 +4288,16 @@
|
||||
"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",
|
||||
@@ -4473,6 +4731,16 @@
|
||||
"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",
|
||||
@@ -4618,6 +4886,13 @@
|
||||
"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",
|
||||
@@ -4700,6 +4975,20 @@
|
||||
"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",
|
||||
@@ -4813,6 +5102,19 @@
|
||||
"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",
|
||||
@@ -4827,6 +5129,16 @@
|
||||
"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",
|
||||
@@ -4837,6 +5149,28 @@
|
||||
"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",
|
||||
@@ -4871,6 +5205,16 @@
|
||||
"@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",
|
||||
@@ -4891,9 +5235,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -4939,6 +5283,13 @@
|
||||
"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",
|
||||
@@ -4963,6 +5314,16 @@
|
||||
"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",
|
||||
@@ -4989,6 +5350,19 @@
|
||||
"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",
|
||||
@@ -4996,6 +5370,20 @@
|
||||
"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",
|
||||
@@ -5099,6 +5487,37 @@
|
||||
"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",
|
||||
@@ -5415,6 +5834,19 @@
|
||||
"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",
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.49.0",
|
||||
"version": "1.50.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
@@ -10,10 +10,14 @@
|
||||
"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 && tauri build",
|
||||
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
|
||||
"lint": "eslint -c eslint.config.mjs src",
|
||||
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
|
||||
"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:watch": "vitest",
|
||||
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
|
||||
},
|
||||
@@ -67,6 +71,7 @@
|
||||
"@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.48.1
|
||||
pkgver=1.49.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -155,7 +155,25 @@
|
||||
}
|
||||
|
||||
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,5 +1,7 @@
|
||||
/**
|
||||
* 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() {
|
||||
@@ -12,7 +14,22 @@
|
||||
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 () {
|
||||
@@ -20,6 +37,18 @@
|
||||
}, 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);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/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');
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/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');
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/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
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/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,3 +43,15 @@ 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})`);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/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,3 +47,8 @@ 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',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/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`);
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
+105
-7
@@ -2,6 +2,12 @@
|
||||
# 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"
|
||||
@@ -4114,7 +4120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4140,6 +4146,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"sysinfo",
|
||||
@@ -4154,6 +4162,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"thread-priority",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -4167,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4178,6 +4187,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"tauri",
|
||||
@@ -4186,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4203,6 +4213,7 @@ dependencies = [
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"symphonia",
|
||||
"symphonia-adapter-libopus",
|
||||
"tauri",
|
||||
@@ -4216,18 +4227,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"specta",
|
||||
"tauri",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4236,6 +4248,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -4244,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4252,6 +4265,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tauri",
|
||||
"tokio",
|
||||
"wiremock",
|
||||
@@ -4259,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
@@ -4272,6 +4286,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-plugin-process",
|
||||
@@ -5381,6 +5396,57 @@ 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"
|
||||
@@ -5821,6 +5887,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"serialize-to-javascript",
|
||||
"specta",
|
||||
"swift-rs",
|
||||
"tauri-build",
|
||||
"tauri-macros",
|
||||
@@ -6133,6 +6200,37 @@ 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.49.0"
|
||||
version = "1.50.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -44,6 +44,9 @@ 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,3 +1,20 @@
|
||||
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,6 +12,7 @@ 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"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
@@ -148,6 +148,7 @@ pub fn get_loudness_payload_for_track(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_waveform(
|
||||
track_id: String,
|
||||
md5_16kb: String,
|
||||
@@ -172,6 +173,7 @@ pub fn analysis_get_waveform(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_get_waveform_for_track(
|
||||
track_id: String,
|
||||
server_id: Option<String>,
|
||||
@@ -192,6 +194,7 @@ 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>,
|
||||
@@ -203,6 +206,7 @@ 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>,
|
||||
@@ -212,6 +216,7 @@ 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>,
|
||||
@@ -221,6 +226,7 @@ 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> {
|
||||
@@ -228,6 +234,7 @@ 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>,
|
||||
@@ -240,6 +247,7 @@ 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>,
|
||||
@@ -252,6 +260,7 @@ pub fn analysis_get_failed_track_count(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_list_failed_tracks(
|
||||
server_id: String,
|
||||
limit: Option<u32>,
|
||||
@@ -269,6 +278,7 @@ pub fn analysis_list_failed_tracks(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_clear_failed_tracks(
|
||||
server_id: String,
|
||||
track_ids: Option<Vec<String>>,
|
||||
@@ -288,6 +298,7 @@ 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>,
|
||||
@@ -299,6 +310,7 @@ pub fn analysis_migrate_server_index_keys(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_enqueue_seed_from_url(
|
||||
track_id: String,
|
||||
url: String,
|
||||
@@ -318,7 +330,7 @@ pub fn analysis_enqueue_seed_from_url(
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPriorityHintDto {
|
||||
pub server_id: String,
|
||||
@@ -326,6 +338,7 @@ pub struct AnalysisPriorityHintDto {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn analysis_set_playback_priority_hints(
|
||||
middle_track_refs: Vec<AnalysisPriorityHintDto>,
|
||||
hints: tauri::State<'_, PlaybackPriorityHints>,
|
||||
@@ -337,7 +350,7 @@ pub fn analysis_set_playback_priority_hints(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisBackfillQueueStatsDto {
|
||||
pub queued: usize,
|
||||
@@ -346,17 +359,20 @@ 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();
|
||||
@@ -367,7 +383,7 @@ pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsD
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalysisPrunePendingResult {
|
||||
pub keep_count: usize,
|
||||
@@ -380,6 +396,7 @@ 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,6 +11,7 @@ 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"] }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,6 +40,7 @@ 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")
|
||||
@@ -49,6 +50,7 @@ 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,
|
||||
|
||||
@@ -40,6 +40,10 @@ 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,
|
||||
@@ -602,6 +606,9 @@ 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,
|
||||
|
||||
@@ -27,18 +27,540 @@ pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
||||
}
|
||||
|
||||
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
with_suppressed_alsa_stderr(|| {
|
||||
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(|| {
|
||||
let host = rodio::cpal::default_host();
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
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<_>>()
|
||||
})
|
||||
.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")]
|
||||
@@ -72,6 +594,20 @@ 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),
|
||||
@@ -81,7 +617,32 @@ 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()
|
||||
@@ -110,18 +671,21 @@ 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"));
|
||||
@@ -188,4 +752,165 @@ 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,67 +7,135 @@ use std::sync::atomic::Ordering;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
enumerate_output_device_entries, legacy_output_device_display_label,
|
||||
output_devices_logically_same, resolve_legacy_pinned_key, OutputDeviceEntry,
|
||||
};
|
||||
#[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 list = enumerate_output_device_names();
|
||||
if list.iter().any(|d| d == &pinned) {
|
||||
let entries = list_output_device_entries(&state);
|
||||
if entries.iter().any(|e| e.key == pinned) {
|
||||
return None;
|
||||
}
|
||||
let canon = list
|
||||
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
|
||||
.iter()
|
||||
.find(|d| output_devices_logically_same(d, &pinned))?
|
||||
.find(|e| output_devices_logically_same(&e.key, &pinned))?
|
||||
.key
|
||||
.clone();
|
||||
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
||||
Some(canon)
|
||||
}
|
||||
|
||||
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
|
||||
let mut list = enumerate_output_device_names();
|
||||
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
|
||||
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||
list.push(name.clone());
|
||||
}
|
||||
}
|
||||
list
|
||||
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
|
||||
list_output_device_entries(engine)
|
||||
}
|
||||
|
||||
/// Returns the names of all available audio output devices on the current host.
|
||||
/// Returns the keys of all available audio output devices on the current host.
|
||||
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
|
||||
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||
///
|
||||
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// The user-pinned device is appended when cpal omits it (e.g. HDMI busy while
|
||||
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||
#[tauri::command]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||
#[specta::specta]
|
||||
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<AudioOutputDeviceEntry> {
|
||||
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> {
|
||||
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()))
|
||||
})
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
|
||||
@@ -132,10 +132,7 @@ 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(|| {
|
||||
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()))
|
||||
super::dev_io::effective_default_output_device_name_for_poll()
|
||||
}).await.unwrap_or(None);
|
||||
|
||||
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||
@@ -246,7 +243,6 @@ 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);
|
||||
@@ -259,17 +255,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
libc::close(devnull);
|
||||
StderrGuard(saved)
|
||||
};
|
||||
let host = rodio::cpal::default_host();
|
||||
let default = host
|
||||
.default_output_device()
|
||||
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
|
||||
let default = super::dev_io::effective_default_output_device_name_for_poll();
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
host.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
super::dev_io::enumerate_output_device_names()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -326,13 +314,57 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_default = current_default.clone();
|
||||
let Some(new_name) = current_default else {
|
||||
// Transient wpctl/cpal miss — keep last known default.
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(_new_name) = current_default else { continue };
|
||||
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());
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -206,21 +206,23 @@ 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_name = |name: &str| -> Option<_> {
|
||||
host.output_devices().ok()?.find(|d| {
|
||||
d.description()
|
||||
.ok()
|
||||
.map(|desc| desc.name().to_string())
|
||||
.as_deref()
|
||||
== Some(name)
|
||||
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 device = device_name
|
||||
.and_then(find_by_name)
|
||||
.and_then(find_by_key)
|
||||
.or_else(|| {
|
||||
#[cfg(target_os = "linux")]
|
||||
{ find_by_name("pipewire").or_else(|| find_by_name("pulse")) }
|
||||
{ find_by_key("pipewire").or_else(|| find_by_key("pulse")) }
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ None }
|
||||
})
|
||||
@@ -578,15 +580,7 @@ pub(crate) fn apply_playback_request_headers(
|
||||
url: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if let Some(reg) = registry {
|
||||
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
|
||||
return reg.apply_for_http_url(sid, url, req);
|
||||
}
|
||||
if let Some(ctx) = reg.get_for_server_url(url) {
|
||||
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
|
||||
}
|
||||
}
|
||||
req
|
||||
psysonic_core::server_http::apply_optional_registry_headers(registry, server_id, url, req)
|
||||
}
|
||||
|
||||
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
|
||||
|
||||
@@ -123,10 +123,8 @@ fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInpu
|
||||
.filter(|s| s.url == url)
|
||||
.map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => std::fs::read(&p).ok()?,
|
||||
None => return None,
|
||||
}
|
||||
let path = spill_path?;
|
||||
std::fs::read(&path).ok()?
|
||||
};
|
||||
Some(PlayInput::Bytes(bytes))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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);
|
||||
@@ -22,6 +23,7 @@ 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,
|
||||
@@ -132,6 +134,7 @@ 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);
|
||||
@@ -141,12 +144,14 @@ 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);
|
||||
}
|
||||
@@ -154,6 +159,7 @@ 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();
|
||||
@@ -173,6 +179,7 @@ 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
|
||||
@@ -180,6 +187,7 @@ 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,
|
||||
@@ -263,6 +271,7 @@ pub fn audio_set_playback_rate(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
|
||||
@@ -114,6 +114,7 @@ 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,6 +336,7 @@ 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,
|
||||
@@ -532,6 +533,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
}
|
||||
@@ -542,6 +544,7 @@ 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);
|
||||
}
|
||||
@@ -552,6 +555,7 @@ 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));
|
||||
|
||||
@@ -31,6 +31,7 @@ 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,
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 {
|
||||
@@ -49,6 +50,7 @@ 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.
|
||||
@@ -112,6 +114,7 @@ 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);
|
||||
@@ -135,6 +138,7 @@ 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,6 +8,7 @@ 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"
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::path::{Path, PathBuf};
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
|
||||
|
||||
/// 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-")
|
||||
@@ -110,6 +111,20 @@ 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 {
|
||||
@@ -118,7 +133,7 @@ pub fn resolve_album_cover(
|
||||
Some(CoverEntry {
|
||||
cache_kind: "album",
|
||||
cache_entity_id,
|
||||
fetch_cover_art_id: fetch.to_string(),
|
||||
fetch_cover_art_id: fetch_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,6 +286,41 @@ 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,7 +293,10 @@ fn is_lan_ipv6(host: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_lan_host(host: &str) -> bool {
|
||||
/// 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 {
|
||||
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::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
@@ -19,6 +19,8 @@ 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
|
||||
@@ -100,6 +102,24 @@ 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)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EndpointKind {
|
||||
Local,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CustomHeadersApplyTo {
|
||||
Local,
|
||||
@@ -24,19 +24,19 @@ pub enum CustomHeadersApplyTo {
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct ServerHttpEndpointWire {
|
||||
pub url: String,
|
||||
pub kind: EndpointKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct CustomHeaderEntryWire {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct ServerHttpContextSyncWire {
|
||||
#[serde(rename = "serverId")]
|
||||
pub server_id: String,
|
||||
@@ -271,32 +271,34 @@ impl ServerHttpRegistry {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn apply_for_http_url(
|
||||
/// 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(
|
||||
&self,
|
||||
server_ref: &str,
|
||||
server_ref: Option<&str>,
|
||||
full_http_url: &str,
|
||||
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)
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
|
||||
/// 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.
|
||||
pub fn apply_optional_registry_headers(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
@@ -304,10 +306,7 @@ pub fn apply_optional_registry_headers(
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
if let Some(reg) = registry {
|
||||
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) {
|
||||
if let Some(ctx) = reg.resolve_context(server_ref, full_http_url) {
|
||||
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
|
||||
}
|
||||
}
|
||||
@@ -343,6 +342,40 @@ 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,6 +10,7 @@ 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)]
|
||||
#[derive(serde::Serialize, Default, specta::Type)]
|
||||
pub struct BandsintownEvent {
|
||||
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
|
||||
venue_name: String,
|
||||
@@ -20,6 +20,7 @@ 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,6 +19,42 @@ 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,
|
||||
@@ -326,6 +362,7 @@ 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(
|
||||
@@ -367,6 +404,17 @@ 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.
|
||||
@@ -448,6 +496,7 @@ 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() {
|
||||
@@ -608,6 +657,68 @@ 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_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
|
||||
use psysonic_core::server_http::{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 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 req = apply_optional_registry_headers(
|
||||
registry,
|
||||
None,
|
||||
&login_url,
|
||||
client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({ "username": username, "password": password })),
|
||||
);
|
||||
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)]
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct NdLoginResult {
|
||||
pub(super) token: String,
|
||||
#[serde(rename = "userId")]
|
||||
@@ -123,7 +123,10 @@ 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()
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
// 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())
|
||||
.http1_only()
|
||||
.pool_max_idle_per_host(0)
|
||||
.max_tls_version(reqwest::tls::Version::TLS_1_2)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -45,6 +46,7 @@ pub async fn upload_playlist_cover(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -80,6 +82,7 @@ pub async fn upload_radio_cover(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_artist_image(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
@@ -115,6 +118,7 @@ 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,6 +10,7 @@ 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>>,
|
||||
@@ -46,6 +47,7 @@ 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>>,
|
||||
@@ -84,6 +86,7 @@ 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>>,
|
||||
@@ -123,6 +126,7 @@ 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>>,
|
||||
@@ -160,6 +164,7 @@ 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,6 +54,7 @@ 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>>,
|
||||
@@ -98,6 +99,7 @@ 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(
|
||||
@@ -158,6 +160,7 @@ 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(
|
||||
@@ -216,6 +219,7 @@ 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>>,
|
||||
@@ -249,6 +253,7 @@ 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,
|
||||
@@ -299,6 +304,7 @@ 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,6 +11,7 @@ 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,
|
||||
@@ -50,6 +51,7 @@ 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>>,
|
||||
@@ -83,6 +85,7 @@ 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(
|
||||
@@ -133,6 +136,7 @@ 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(
|
||||
@@ -188,6 +192,7 @@ 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,6 +3,7 @@ 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();
|
||||
@@ -26,6 +27,7 @@ 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();
|
||||
@@ -46,6 +48,7 @@ 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())
|
||||
@@ -75,6 +78,7 @@ 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()
|
||||
@@ -95,7 +99,7 @@ pub async fn fetch_json_url(url: String) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// ICY metadata response returned to the frontend.
|
||||
#[derive(serde::Serialize)]
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct IcyMetadata {
|
||||
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
|
||||
stream_title: Option<String>,
|
||||
@@ -173,6 +177,7 @@ 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;
|
||||
|
||||
@@ -277,6 +282,7 @@ 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))
|
||||
@@ -309,6 +315,7 @@ 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,
|
||||
@@ -371,6 +378,7 @@ 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,
|
||||
@@ -410,6 +418,7 @@ 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, ScanStatus, SearchResult, ServerInfo, Song};
|
||||
use super::types::{Album, AlbumSummary, ArtistIndex, MusicFolder, ScanStatus, SearchResult, ServerInfo, Song};
|
||||
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
|
||||
|
||||
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
|
||||
@@ -187,6 +187,15 @@ 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.
|
||||
@@ -343,6 +352,47 @@ 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)]
|
||||
@@ -351,9 +401,22 @@ 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()
|
||||
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
|
||||
// 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())
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
@@ -642,6 +705,126 @@ 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;
|
||||
@@ -814,6 +997,54 @@ 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, ScanStatus, SearchResult, ServerInfo,
|
||||
Song,
|
||||
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, MusicFolder, ScanStatus,
|
||||
SearchResult, ServerInfo, Song,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,41 @@ 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> {
|
||||
@@ -116,6 +151,15 @@ 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,6 +11,7 @@ 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"] }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak).
|
||||
ALTER TABLE track ADD COLUMN replay_gain_peak REAL;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 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
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 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,15 +49,28 @@ 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 {
|
||||
format!(
|
||||
"CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \
|
||||
THEN trim({a}.album_artist) \
|
||||
ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END",
|
||||
a = alias
|
||||
sql_display_artist_from(
|
||||
&format!("{alias}.artist"),
|
||||
&format!("{alias}.album_artist"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,4 +174,60 @@ 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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAnalysisProgressDto {
|
||||
pub total_tracks: i64,
|
||||
|
||||
@@ -5,15 +5,34 @@ use crate::dto::{
|
||||
LibraryTrackDto,
|
||||
};
|
||||
use crate::lossless_formats::track_is_lossless_sql;
|
||||
use crate::search::{aliased_track_columns, library_scope_equals_sql};
|
||||
use crate::search::{
|
||||
aliased_track_columns, combined_scope_library_ids, library_scope_in_sql,
|
||||
library_scope_sargable_equals_sql,
|
||||
};
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
|
||||
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
/// 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_artist_lossless_browse(
|
||||
@@ -36,11 +55,9 @@ pub fn get_artist_lossless_browse(
|
||||
SqlValue::Text(req.artist_id.clone()),
|
||||
];
|
||||
|
||||
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 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);
|
||||
|
||||
let track_where_sql = track_where.join(" AND ");
|
||||
let track_cols = aliased_track_columns("t");
|
||||
@@ -74,11 +91,7 @@ pub fn get_artist_lossless_browse(
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(req.artist_id.clone()),
|
||||
];
|
||||
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));
|
||||
}
|
||||
push_library_scope_filter(&mut album_where, &mut album_params, &scope_ids);
|
||||
let album_where_sql = album_where.join(" AND ");
|
||||
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
@@ -213,6 +226,7 @@ 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,
|
||||
@@ -241,6 +255,7 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
artist_id: "ar1".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
//! Album browse helpers: favorites reconcile and catalog year bounds.
|
||||
|
||||
use rusqlite::params;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde_json::{Map, Value};
|
||||
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)]
|
||||
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StarredAlbumReconcileItem {
|
||||
pub id: String,
|
||||
@@ -19,6 +25,7 @@ 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,
|
||||
@@ -27,6 +34,125 @@ 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,
|
||||
@@ -99,34 +225,64 @@ 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> {
|
||||
catalog_year_bounds_for_server(&runtime.store, &server_id)
|
||||
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
|
||||
}
|
||||
|
||||
pub(crate) fn genre_album_counts_for_server(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
) -> 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_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 \
|
||||
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 mut params: Vec<rusqlite::types::Value> =
|
||||
vec![rusqlite::types::Value::Text(server_id.to_string())];
|
||||
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()));
|
||||
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);
|
||||
}
|
||||
sql.push_str(
|
||||
" GROUP BY tg.genre COLLATE NOCASE \
|
||||
@@ -150,16 +306,43 @@ 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> {
|
||||
genre_album_counts_for_server(
|
||||
&runtime.store,
|
||||
&server_id,
|
||||
library_scope.as_deref(),
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -171,9 +354,10 @@ mod tests {
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::{
|
||||
catalog_year_bounds_for_server, genre_album_counts_for_server, reconcile_album_stars,
|
||||
StarredAlbumReconcileItem,
|
||||
apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_server,
|
||||
overlay_album_level_starred_at, 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 {
|
||||
@@ -206,6 +390,7 @@ 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,
|
||||
@@ -219,6 +404,131 @@ 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());
|
||||
@@ -310,7 +620,7 @@ mod tests {
|
||||
.upsert_batch(&rock_one)
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
assert_eq!(counts.len(), 2);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 2);
|
||||
@@ -333,7 +643,7 @@ mod tests {
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[String::from("lib1")]).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
assert_eq!(counts[0].album_count, 1);
|
||||
@@ -345,21 +655,44 @@ 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 = None;
|
||||
scoped.raw_json = r#"{"libraryId":"lib1"}"#.into();
|
||||
scoped.library_id = Some("lib1".into());
|
||||
let mut other = make_row("s1", "r2", "al_b", 1);
|
||||
other.genre = Some("Rock".into());
|
||||
other.library_id = None;
|
||||
other.raw_json = r#"{"libraryId":"lib2"}"#.into();
|
||||
other.library_id = Some("lib2".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[scoped, other])
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[String::from("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());
|
||||
@@ -368,13 +701,13 @@ mod tests {
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track.clone()])
|
||||
.unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).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", None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Pop");
|
||||
}
|
||||
@@ -398,7 +731,7 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
|
||||
let counts = genre_album_counts_for_server(&store, "s1", &[]).unwrap();
|
||||
assert_eq!(counts.len(), 1);
|
||||
assert_eq!(counts[0].value, "Rock");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ 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, LibraryTrackDto,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse,
|
||||
LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest,
|
||||
LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest,
|
||||
LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
||||
@@ -31,6 +34,7 @@ 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;
|
||||
@@ -39,6 +43,7 @@ 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;
|
||||
|
||||
@@ -57,7 +62,7 @@ where
|
||||
const TRACKS_BATCH_LIMIT: usize = 100;
|
||||
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryServerKeyMigrationDto {
|
||||
pub legacy_id: String,
|
||||
@@ -66,6 +71,7 @@ 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,
|
||||
@@ -87,6 +93,7 @@ pub fn library_resolve_cover_entry(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_analysis_backfill_batch(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -106,6 +113,7 @@ pub fn library_analysis_backfill_batch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_analysis_progress(
|
||||
app: AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -156,6 +164,7 @@ pub fn library_analysis_progress(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_count_live_tracks(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -169,6 +178,7 @@ pub fn library_count_live_tracks(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_status(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -302,6 +312,7 @@ 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>,
|
||||
@@ -310,15 +321,18 @@ pub async fn library_search(
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
library_scope: Option<String>,
|
||||
library_scopes: Option<Vec<String>>,
|
||||
) -> Result<LibraryTracksEnvelope, String> {
|
||||
let _ = library_scope; // PR-5a accepts the arg for forward-compat; filter is wired in §5.13
|
||||
let scopes = effective_library_scopes(library_scope.as_deref(), library_scopes.as_deref());
|
||||
let limit = limit.unwrap_or(100).clamp(1, 500);
|
||||
let offset = offset.unwrap_or(0);
|
||||
// `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 hits = search_tracks(
|
||||
&runtime.store,
|
||||
&server_id,
|
||||
&query,
|
||||
limit as i64 + offset as i64,
|
||||
&scopes,
|
||||
)?;
|
||||
let mut paged: Vec<TrackRefDto> = hits
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
@@ -335,6 +349,7 @@ 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>,
|
||||
@@ -374,6 +389,7 @@ 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>,
|
||||
@@ -389,6 +405,7 @@ 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>,
|
||||
@@ -401,6 +418,7 @@ 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>,
|
||||
@@ -434,6 +452,7 @@ pub fn library_upsert_songs_from_api(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -456,6 +475,7 @@ pub async fn library_get_artifact(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_facts(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -472,6 +492,7 @@ pub async fn library_get_facts(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_offline_path(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -501,15 +522,90 @@ 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);
|
||||
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
|
||||
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
|
||||
}
|
||||
|
||||
// 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>,
|
||||
@@ -519,17 +615,46 @@ 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);
|
||||
library_spawn_blocking(move || crate::genre_album_browse::list_albums_by_genre(&store, &request))
|
||||
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
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_genre_tags_inspect(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
|
||||
@@ -537,6 +662,7 @@ pub fn library_genre_tags_inspect(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_genre_tags_run(
|
||||
app: tauri::AppHandle,
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
@@ -546,6 +672,71 @@ 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>,
|
||||
@@ -554,6 +745,7 @@ 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>,
|
||||
@@ -576,6 +768,7 @@ 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),
|
||||
@@ -589,6 +782,7 @@ 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>,
|
||||
@@ -597,11 +791,28 @@ 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())
|
||||
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref(), None)
|
||||
}
|
||||
|
||||
// ── 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],
|
||||
@@ -677,6 +888,7 @@ 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>>,
|
||||
@@ -750,6 +962,7 @@ 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,
|
||||
@@ -759,6 +972,7 @@ pub fn library_sync_clear_session(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_set_playback_hint(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
hint: String,
|
||||
@@ -774,6 +988,7 @@ 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(),
|
||||
@@ -783,6 +998,7 @@ 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>,
|
||||
@@ -919,7 +1135,19 @@ async fn library_sync_start_inner(
|
||||
if let Some(creds) = navidrome_creds.clone() {
|
||||
runner = runner.with_navidrome_credentials(creds);
|
||||
}
|
||||
sync_outcome_to_result(runner.run().await)
|
||||
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
|
||||
} else {
|
||||
// Delta — Mode A manual integrity uses the DeltaMismatch
|
||||
// budget for tombstones when the local/server count gap
|
||||
@@ -947,7 +1175,19 @@ async fn library_sync_start_inner(
|
||||
if let Some(creds) = navidrome_creds.clone() {
|
||||
runner = runner.with_navidrome_credentials(creds);
|
||||
}
|
||||
sync_outcome_to_result(runner.run().await)
|
||||
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
|
||||
};
|
||||
|
||||
// Closing the mpsc sender by dropping `progress` so the
|
||||
@@ -1017,6 +1257,7 @@ 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>,
|
||||
@@ -1035,6 +1276,7 @@ pub async fn library_sync_verify_integrity(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_sync_cancel(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
job_id: Option<String>,
|
||||
@@ -1080,6 +1322,7 @@ 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>,
|
||||
@@ -1160,6 +1403,7 @@ pub(crate) fn apply_track_patch(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_put_artifact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1176,6 +1420,7 @@ pub fn library_put_artifact(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_put_fact(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1188,6 +1433,7 @@ pub fn library_put_fact(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_record_play_session(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
input: PlaySessionInputDto,
|
||||
@@ -1196,6 +1442,7 @@ pub fn library_record_play_session(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_year_summary(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
year: i32,
|
||||
@@ -1204,6 +1451,7 @@ pub fn library_get_player_stats_year_summary(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_get_player_stats_heatmap(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
year: i32,
|
||||
@@ -1212,6 +1460,7 @@ 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,
|
||||
@@ -1220,6 +1469,7 @@ 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> {
|
||||
@@ -1227,6 +1477,7 @@ 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>,
|
||||
@@ -1235,6 +1486,7 @@ 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>,
|
||||
@@ -1245,6 +1497,7 @@ pub fn library_get_recent_play_sessions(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_purge_server(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1359,6 +1612,7 @@ pub fn library_purge_server(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_migrate_server_index_keys(
|
||||
_runtime: State<'_, LibraryRuntime>,
|
||||
mappings: Vec<LibraryServerKeyMigrationDto>,
|
||||
@@ -1370,6 +1624,7 @@ pub fn library_migrate_server_index_keys(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn library_delete_server_data(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
@@ -1492,6 +1747,7 @@ 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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[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)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverProgressDto {
|
||||
pub total_distinct: i64,
|
||||
@@ -606,7 +606,10 @@ 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, "0DurV2S7arIOBQVEknOPWX");
|
||||
assert_eq!(
|
||||
batch.items[0].fetch_cover_art_id,
|
||||
"al-0DurV2S7arIOBQVEknOPWX_0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@ use rusqlite::OptionalExtension;
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverEntryDto {
|
||||
pub cache_kind: String,
|
||||
@@ -107,13 +107,50 @@ pub fn resolve_album_cover_entry(
|
||||
)
|
||||
.optional()
|
||||
})? {
|
||||
None => return Ok(None),
|
||||
None => {
|
||||
return track_only_album_backfill_entry(store, library_server_id, album_id);
|
||||
}
|
||||
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,
|
||||
@@ -374,6 +411,51 @@ 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,13 +3,16 @@
|
||||
//! (`fuzzy`): per-server `title LIKE` matches the exact FTS pass missed.
|
||||
//! UI wiring stays PR-7.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
|
||||
use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto};
|
||||
use crate::repos;
|
||||
use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX};
|
||||
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::store::LibraryStore;
|
||||
|
||||
/// §5.9 caps the fuzzy fallback at "top 20 per server" so a `LIKE %…%` scan
|
||||
@@ -24,6 +27,7 @@ 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 {
|
||||
@@ -40,38 +44,12 @@ pub fn run_cross_server_search(
|
||||
return Ok(LibraryCrossServerSearchResponse::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 scopes = library_scopes
|
||||
.map(normalized_library_scopes)
|
||||
.unwrap_or_default();
|
||||
|
||||
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
|
||||
})?;
|
||||
let rowids = collect_cross_server_fts_rowids(store, &fts, &targets, &scopes, limit as i64)?;
|
||||
let rows = fetch_cross_server_hits(store, &rowids, &scopes)?;
|
||||
|
||||
// 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
|
||||
@@ -92,7 +70,15 @@ 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)?;
|
||||
let fuzzy = fuzzy_matches(
|
||||
store,
|
||||
&targets,
|
||||
query.trim(),
|
||||
&mut seen,
|
||||
&hit_keys,
|
||||
limit as usize,
|
||||
&scopes,
|
||||
)?;
|
||||
|
||||
Ok(LibraryCrossServerSearchResponse {
|
||||
hits,
|
||||
@@ -101,6 +87,88 @@ 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
|
||||
@@ -113,15 +181,20 @@ 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 '\\' \
|
||||
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\'{scope_sql} \
|
||||
ORDER BY t.title COLLATE NOCASE ASC LIMIT ?"
|
||||
);
|
||||
|
||||
@@ -130,11 +203,12 @@ fn fuzzy_matches(
|
||||
if out.len() >= overall_cap {
|
||||
break;
|
||||
}
|
||||
let bound = [
|
||||
let mut bound = vec![
|
||||
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
|
||||
@@ -212,6 +286,7 @@ 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,
|
||||
@@ -234,6 +309,42 @@ 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();
|
||||
@@ -247,7 +358,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).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, 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);
|
||||
@@ -260,7 +371,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()])).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, Some(&["s9".to_string()]), None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 1);
|
||||
assert_eq!(resp.servers_searched, vec!["s9".to_string()]);
|
||||
}
|
||||
@@ -294,7 +405,7 @@ mod tests {
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 1, "duplicate canonical id collapses to one hit");
|
||||
}
|
||||
|
||||
@@ -310,7 +421,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).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
assert_eq!(resp.hits.len(), 2);
|
||||
}
|
||||
|
||||
@@ -321,7 +432,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).unwrap();
|
||||
let resp = run_cross_server_search(&store, " ", 50, None, None).unwrap();
|
||||
assert!(resp.hits.is_empty());
|
||||
assert!(resp.servers_searched.is_empty());
|
||||
}
|
||||
@@ -333,7 +444,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).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, None).unwrap();
|
||||
assert!(resp.hits.is_empty());
|
||||
assert!(resp.servers_searched.is_empty());
|
||||
}
|
||||
@@ -354,7 +465,7 @@ mod tests {
|
||||
.unwrap();
|
||||
set_phase(&store, "s1", "ready");
|
||||
set_phase(&store, "s2", "ready");
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, 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");
|
||||
@@ -369,7 +480,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).unwrap();
|
||||
let resp = run_cross_server_search(&store, "aurora", 50, None, 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)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStateDto {
|
||||
pub server_id: String,
|
||||
@@ -106,6 +106,7 @@ 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>,
|
||||
@@ -157,6 +158,7 @@ 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,
|
||||
@@ -175,7 +177,7 @@ pub struct LibraryTracksEnvelope {
|
||||
}
|
||||
|
||||
/// `library_get_artifact` payload — one row of `track_artifact`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackArtifactDto {
|
||||
pub server_id: String,
|
||||
@@ -194,7 +196,7 @@ pub struct TrackArtifactDto {
|
||||
}
|
||||
|
||||
/// `library_get_facts` row.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackFactDto {
|
||||
pub server_id: String,
|
||||
@@ -214,7 +216,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)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OfflinePathDto {
|
||||
pub server_id: String,
|
||||
@@ -236,7 +238,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)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtifactInputDto {
|
||||
pub artifact_kind: String,
|
||||
@@ -261,7 +263,7 @@ pub struct ArtifactInputDto {
|
||||
|
||||
/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the
|
||||
/// indices.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FactInputDto {
|
||||
pub fact_kind: String,
|
||||
@@ -288,7 +290,7 @@ fn default_confidence() -> f64 {
|
||||
}
|
||||
|
||||
/// Input to `library_record_play_session`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionInputDto {
|
||||
pub server_id: String,
|
||||
@@ -303,7 +305,7 @@ pub struct PlaySessionInputDto {
|
||||
}
|
||||
|
||||
/// Cross-server year summary for the Player stats tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionYearSummaryDto {
|
||||
pub total_listened_sec: f64,
|
||||
@@ -319,14 +321,14 @@ pub struct PlaySessionYearSummaryDto {
|
||||
pub partial_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionHeatmapDayDto {
|
||||
pub date: String,
|
||||
pub track_play_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionDayTotalsDto {
|
||||
pub total_listened_sec: f64,
|
||||
@@ -336,7 +338,7 @@ pub struct PlaySessionDayTotalsDto {
|
||||
pub partial_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionDayTrackDto {
|
||||
pub server_id: String,
|
||||
@@ -351,7 +353,7 @@ pub struct PlaySessionDayTrackDto {
|
||||
pub cover_art_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionDayDetailDto {
|
||||
pub totals: PlaySessionDayTotalsDto,
|
||||
@@ -362,7 +364,7 @@ pub struct PlaySessionDayDetailDto {
|
||||
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
|
||||
|
||||
/// Summary for one day in the recent-days list (no track rows).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionRecentDayDto {
|
||||
pub date: String,
|
||||
@@ -374,7 +376,7 @@ pub struct PlaySessionRecentDayDto {
|
||||
}
|
||||
|
||||
/// Earliest/latest calendar years with at least one session (local TZ).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaySessionYearBoundsDto {
|
||||
pub min_year: Option<i32>,
|
||||
@@ -382,7 +384,7 @@ pub struct PlaySessionYearBoundsDto {
|
||||
}
|
||||
|
||||
/// Min/max `year` from indexed tracks for a server (Albums year filter UI).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogYearBoundsDto {
|
||||
pub min_year: Option<i32>,
|
||||
@@ -390,7 +392,7 @@ pub struct CatalogYearBoundsDto {
|
||||
}
|
||||
|
||||
/// Per-genre album/track totals from the local track catalog (Genres cloud + browse).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreAlbumCountDto {
|
||||
pub value: String,
|
||||
@@ -406,6 +408,9 @@ 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")]
|
||||
@@ -431,7 +436,7 @@ pub struct LibraryGenreAlbumsResponse {
|
||||
}
|
||||
|
||||
/// `library_purge_server` outcome.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PurgeReportDto {
|
||||
pub tracks_deleted: u32,
|
||||
@@ -443,7 +448,7 @@ pub struct PurgeReportDto {
|
||||
}
|
||||
|
||||
/// `library_sync_start` ack.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncJobDto {
|
||||
pub job_id: String,
|
||||
@@ -521,6 +526,15 @@ 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.
|
||||
@@ -530,6 +544,9 @@ 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>,
|
||||
@@ -553,6 +570,13 @@ 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).
|
||||
@@ -588,6 +612,9 @@ 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)]
|
||||
@@ -615,6 +642,10 @@ 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)]
|
||||
@@ -642,6 +673,10 @@ 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).
|
||||
@@ -653,6 +688,117 @@ 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")]
|
||||
@@ -750,6 +896,7 @@ 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),
|
||||
@@ -780,6 +927,7 @@ mod tests {
|
||||
"mbidRecording",
|
||||
"replayGainTrackDb",
|
||||
"replayGainAlbumDb",
|
||||
"replayGainPeak",
|
||||
"serverUpdatedAt",
|
||||
"syncedAt",
|
||||
"rawJson",
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
//! LIMIT/OFFSET on grouped rows) instead of the heavier Advanced Search builder.
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
|
||||
SortDir,
|
||||
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibraryScopePair,
|
||||
LibrarySortClause, SortDir, multi_library_merge_enabled, ordered_library_scope_pairs,
|
||||
scoped_layer1_eligible,
|
||||
};
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::scope_merge;
|
||||
use crate::search::library_scope_sargable_equals_sql;
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
@@ -107,7 +109,34 @@ pub fn list_albums_by_genre(
|
||||
|
||||
let limit = req.limit.max(1);
|
||||
let offset = req.offset;
|
||||
let order_sql = genre_album_order_sql(&req.sort);
|
||||
|
||||
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 mut where_clauses = vec![
|
||||
"tg.server_id = ?1".to_string(),
|
||||
@@ -115,13 +144,13 @@ pub fn list_albums_by_genre(
|
||||
"tg.genre = ?2 COLLATE NOCASE".to_string(),
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![
|
||||
SqlValue::Text(req.server_id.clone()),
|
||||
SqlValue::Text(legacy.server_id.clone()),
|
||||
SqlValue::Text(genre.to_string()),
|
||||
];
|
||||
|
||||
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"));
|
||||
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"));
|
||||
params.push(SqlValue::Text(scope));
|
||||
}
|
||||
|
||||
@@ -173,7 +202,7 @@ pub fn list_albums_by_genre(
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let total = if req.include_total {
|
||||
let total = if legacy.include_total {
|
||||
Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?)
|
||||
} else {
|
||||
None
|
||||
@@ -193,6 +222,104 @@ 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::*;
|
||||
@@ -230,6 +357,7 @@ 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,
|
||||
@@ -260,6 +388,7 @@ 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,
|
||||
@@ -279,6 +408,7 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Rock".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
sort: vec![],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
@@ -308,6 +438,7 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Dark Ambient".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
@@ -325,6 +456,7 @@ mod tests {
|
||||
server_id: "s1".into(),
|
||||
genre: "Noise Metal".into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
sort: vec![],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
|
||||
@@ -26,7 +26,7 @@ fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
|
||||
crate::store::ensure_genre_tags_schema(conn)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenreTagsInspectDto {
|
||||
pub needed: bool,
|
||||
@@ -288,6 +288,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! ATTACH wiring for the rebuildable `library-cluster.db` sidecar.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Fixed SQLite schema name for the attached identity database.
|
||||
pub const CLUSTER_SCHEMA: &str = "cluster";
|
||||
|
||||
pub const CLUSTER_DB_FILENAME: &str = "library-cluster.db";
|
||||
|
||||
const CLUSTER_SCHEMA_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS cluster.track_cluster_key (
|
||||
server_id TEXT NOT NULL,
|
||||
library_id TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
cluster_key TEXT,
|
||||
album_key TEXT,
|
||||
artist_key TEXT,
|
||||
duration_sec INTEGER,
|
||||
PRIMARY KEY (server_id, track_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_scope_album
|
||||
ON track_cluster_key(server_id, library_id, album_key);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_scope_artist
|
||||
ON track_cluster_key(server_id, library_id, artist_key);
|
||||
CREATE INDEX IF NOT EXISTS cluster.idx_ck_scope_track
|
||||
ON track_cluster_key(server_id, library_id, cluster_key);
|
||||
CREATE TABLE IF NOT EXISTS cluster.cluster_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
";
|
||||
|
||||
pub fn cluster_db_path_for_library(library_db_path: &Path) -> PathBuf {
|
||||
library_db_path
|
||||
.parent()
|
||||
.map(|dir| dir.join(CLUSTER_DB_FILENAME))
|
||||
.unwrap_or_else(|| PathBuf::from(CLUSTER_DB_FILENAME))
|
||||
}
|
||||
|
||||
fn escape_sqlite_literal(path: &str) -> String {
|
||||
path.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Build a well-formed SQLite `file:` URI for a filesystem path so URI-mode
|
||||
/// ATTACH works on every platform. A raw Windows path (`D:\dir\library-cluster.db`)
|
||||
/// is not a valid URI — backslashes and the bare drive letter must become
|
||||
/// `file:///D:/dir/library-cluster.db`. Query-significant characters are
|
||||
/// percent-encoded so a path containing `?`/`#`/spaces cannot corrupt the URI.
|
||||
fn file_uri(cluster_path: &Path, query: &str) -> String {
|
||||
let normalized = cluster_path.display().to_string().replace('\\', "/");
|
||||
let encoded: String = normalized
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'%' => "%25".to_string(),
|
||||
'?' => "%3F".to_string(),
|
||||
'#' => "%23".to_string(),
|
||||
' ' => "%20".to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
// Unix paths already start with `/` (→ `file://` + `/abs`); Windows paths
|
||||
// start with a drive letter and need the extra slash (`file:///C:/…`).
|
||||
let prefix = if encoded.starts_with('/') { "file://" } else { "file:///" };
|
||||
if query.is_empty() {
|
||||
format!("{prefix}{encoded}")
|
||||
} else {
|
||||
format!("{prefix}{encoded}?{query}")
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_file_write(conn: &Connection, cluster_path: &Path) -> rusqlite::Result<()> {
|
||||
if let Some(parent) = cluster_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
rusqlite::Error::ToSqlConversionFailure(Box::new(e))
|
||||
})?;
|
||||
}
|
||||
let literal = escape_sqlite_literal(&cluster_path.display().to_string());
|
||||
conn.execute_batch(&format!(
|
||||
"ATTACH DATABASE '{literal}' AS {CLUSTER_SCHEMA}"
|
||||
))?;
|
||||
conn.execute_batch(CLUSTER_SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only attach — only after the write connection has created the file + schema.
|
||||
fn attach_file_read(conn: &Connection, cluster_path: &Path) -> rusqlite::Result<()> {
|
||||
// Bind the URI as a parameter (no literal quoting) and build it as a proper
|
||||
// `file:` URI so read-only attach also works on Windows.
|
||||
let uri = file_uri(cluster_path, "mode=ro");
|
||||
conn.execute(
|
||||
&format!("ATTACH DATABASE ?1 AS {CLUSTER_SCHEMA}"),
|
||||
rusqlite::params![uri],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the cluster sidecar and its WAL/SHM siblings. Safe to call when they
|
||||
/// do not exist. The identity DB is fully rebuildable, so removing it only costs
|
||||
/// the next lazy rebuild.
|
||||
pub fn remove_cluster_files(cluster_path: &Path) {
|
||||
let _ = std::fs::remove_file(cluster_path);
|
||||
let _ = std::fs::remove_file(cluster_path.with_extension("db-wal"));
|
||||
let _ = std::fs::remove_file(cluster_path.with_extension("db-shm"));
|
||||
}
|
||||
|
||||
/// Remove the rebuildable cluster sidecar for a library DB path (swap / restore /
|
||||
/// import must invalidate it — see store/backup). No-op if absent.
|
||||
pub fn remove_cluster_files_for_library(library_db_path: &Path) {
|
||||
remove_cluster_files(&cluster_db_path_for_library(library_db_path));
|
||||
}
|
||||
|
||||
/// Attach the identity sidecar on both handles with one-shot recovery: a corrupt
|
||||
/// or partially-attached `library-cluster.db` is detached, deleted and recreated
|
||||
/// (the file is fully rebuildable), so it can never block the library from
|
||||
/// opening. Returns the second attach error only if recreation also fails.
|
||||
pub fn attach_cluster_pair_file(
|
||||
write_conn: &Connection,
|
||||
read_conn: &Connection,
|
||||
library_db_path: &Path,
|
||||
) -> rusqlite::Result<()> {
|
||||
let cluster_path = cluster_db_path_for_library(library_db_path);
|
||||
match attach_pair_once(write_conn, read_conn, &cluster_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first) => {
|
||||
crate::app_eprintln!(
|
||||
"[library-cluster] attach failed ({first}); recreating rebuildable sidecar"
|
||||
);
|
||||
let _ = write_conn.execute_batch(&format!("DETACH DATABASE {CLUSTER_SCHEMA}"));
|
||||
let _ = read_conn.execute_batch(&format!("DETACH DATABASE {CLUSTER_SCHEMA}"));
|
||||
remove_cluster_files(&cluster_path);
|
||||
attach_pair_once(write_conn, read_conn, &cluster_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_pair_once(
|
||||
write_conn: &Connection,
|
||||
read_conn: &Connection,
|
||||
cluster_path: &Path,
|
||||
) -> rusqlite::Result<()> {
|
||||
attach_file_write(write_conn, cluster_path)?;
|
||||
attach_file_read(read_conn, cluster_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// In-memory cluster DB uses `cache=shared` so the read/write library pair see one identity store.
|
||||
fn attach_memory(conn: &Connection, cluster_uri: &str) -> rusqlite::Result<()> {
|
||||
let literal = escape_sqlite_literal(cluster_uri);
|
||||
conn.execute_batch(&format!(
|
||||
"ATTACH DATABASE '{literal}' AS {CLUSTER_SCHEMA}"
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn attach_cluster_write_file(
|
||||
conn: &Connection,
|
||||
library_db_path: &Path,
|
||||
) -> rusqlite::Result<()> {
|
||||
attach_file_write(conn, &cluster_db_path_for_library(library_db_path))
|
||||
}
|
||||
|
||||
pub fn attach_cluster_read_file(
|
||||
conn: &Connection,
|
||||
library_db_path: &Path,
|
||||
) -> rusqlite::Result<()> {
|
||||
attach_file_read(conn, &cluster_db_path_for_library(library_db_path))
|
||||
}
|
||||
|
||||
pub fn attach_cluster_write_memory(conn: &Connection, cluster_uri: &str) -> rusqlite::Result<()> {
|
||||
attach_memory(conn, cluster_uri)?;
|
||||
conn.execute_batch(CLUSTER_SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared-cache in-memory identity DB — attach after write side created schema.
|
||||
pub fn attach_cluster_read_memory(conn: &Connection, cluster_uri: &str) -> rusqlite::Result<()> {
|
||||
attach_memory(conn, cluster_uri)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Composite cluster / album / artist keys from track metadata.
|
||||
|
||||
use super::norm::{join_norm_parts, norm_part};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TrackClusterKeys {
|
||||
pub cluster_key: Option<String>,
|
||||
pub album_key: Option<String>,
|
||||
pub artist_key: Option<String>,
|
||||
}
|
||||
|
||||
/// `album_artist` when non-empty, else `artist`.
|
||||
fn album_identity_source<'a>(album_artist: Option<&'a str>, artist: Option<&'a str>) -> Option<&'a str> {
|
||||
album_artist
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artist.map(str::trim).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
pub fn build_track_cluster_keys(
|
||||
artist: Option<&str>,
|
||||
title: &str,
|
||||
album: &str,
|
||||
album_artist: Option<&str>,
|
||||
) -> TrackClusterKeys {
|
||||
let artist_norm = norm_part(artist.unwrap_or(""));
|
||||
let title_norm = norm_part(title);
|
||||
let album_norm = norm_part(album);
|
||||
|
||||
let cluster_key = join_norm_parts([artist_norm.clone(), title_norm, album_norm.clone()]);
|
||||
|
||||
let album_source = album_identity_source(album_artist, artist);
|
||||
let album_key = join_norm_parts([
|
||||
norm_part(album_source.unwrap_or("")),
|
||||
album_norm,
|
||||
]);
|
||||
|
||||
TrackClusterKeys {
|
||||
cluster_key,
|
||||
album_key,
|
||||
artist_key: artist_norm,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::norm::KEY_SEP;
|
||||
|
||||
#[test]
|
||||
fn composite_keys_built_correctly() {
|
||||
let keys = build_track_cluster_keys(
|
||||
Some("The Beatles"),
|
||||
"Hey Jude",
|
||||
"Hey Jude",
|
||||
Some("The Beatles"),
|
||||
);
|
||||
let sep = KEY_SEP;
|
||||
assert_eq!(
|
||||
keys.cluster_key,
|
||||
Some(format!("thebeatles{}heyjude{}heyjude", sep, sep))
|
||||
);
|
||||
assert_eq!(keys.album_key, Some(format!("thebeatles{}heyjude", sep)));
|
||||
assert_eq!(keys.artist_key, Some("thebeatles".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_artist_yields_null_cluster_and_artist_keys() {
|
||||
let keys = build_track_cluster_keys(None, "Title", "Album", None);
|
||||
assert!(keys.cluster_key.is_none());
|
||||
assert!(keys.artist_key.is_none());
|
||||
// album_key can still exist when album + fallback artist source is empty — no, artist empty
|
||||
assert!(keys.album_key.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_key_uses_album_artist_over_artist() {
|
||||
let keys = build_track_cluster_keys(
|
||||
Some("Track Artist"),
|
||||
"T",
|
||||
"Greatest Hits",
|
||||
Some("Comp Artist"),
|
||||
);
|
||||
let sep = KEY_SEP;
|
||||
assert_eq!(
|
||||
keys.album_key,
|
||||
Some(format!("compartist{}greatesthits", sep))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Precomputed identity keys for multi-library dedup (spec §3.1).
|
||||
|
||||
mod attach;
|
||||
mod keys;
|
||||
mod norm;
|
||||
mod rebuild;
|
||||
|
||||
pub use attach::{
|
||||
attach_cluster_pair_file, attach_cluster_read_file, attach_cluster_read_memory,
|
||||
attach_cluster_write_file, attach_cluster_write_memory, cluster_db_path_for_library,
|
||||
remove_cluster_files_for_library, CLUSTER_DB_FILENAME, CLUSTER_SCHEMA,
|
||||
};
|
||||
pub use norm::NORM_VERSION;
|
||||
pub use rebuild::{cluster_rebuild_needed, ensure_cluster_keys_built, rebuild_cluster_keys};
|
||||
|
||||
pub use keys::{build_track_cluster_keys, TrackClusterKeys};
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Identity-string normalization for cluster keys (spec §3.1, TEXT not hash).
|
||||
//!
|
||||
//! `norm(s)`: Unicode NFD → drop combining marks → lowercase → letters/digits only.
|
||||
//! Empty after normalization → `None` (key is NULL; track never merges).
|
||||
//!
|
||||
//! Diacritic folding is driven by an in-crate decomposition table (dependency-free),
|
||||
//! so it must be kept in step with the languages the app actually ships as UI
|
||||
//! locales (`src/locales/*`). The table currently covers the Latin scripts
|
||||
//! (incl. German ß→ss, Norwegian/Danish æ→ae, French œ→oe, Romanian comma-below
|
||||
//! ș/ț) and the Cyrillic folds (ru/bg: ё→е, й→и). CJK locales (ja, zh) are left
|
||||
//! intact on purpose — Han/Kana are kept verbatim and Japanese dakuten are
|
||||
//! phonemic, so they must NOT be stripped.
|
||||
//!
|
||||
//! WHEN ADDING A NEW UI LOCALE: audit this table for that language's letters and
|
||||
//! extend it if any diacritic/ligature would otherwise not fold (then bump
|
||||
//! `NORM_VERSION` so existing cluster keys rebuild). See the i18n locale-adding
|
||||
//! guide for the checklist entry that points here.
|
||||
|
||||
/// Separator for composite keys — U+001F cannot appear in normalized output.
|
||||
pub(crate) const KEY_SEP: char = '\u{001f}';
|
||||
|
||||
/// Bump when normalization rules change; stored in `cluster.cluster_meta.norm_version`.
|
||||
/// v2: locale-aware folding (ß→ss, æ→ae, œ→oe, Romanian ș/ț, Cyrillic ё/й).
|
||||
pub const NORM_VERSION: &str = "2";
|
||||
|
||||
/// Normalize one identity field. Returns `None` when input is empty/whitespace-only
|
||||
/// or when normalization strips everything (punctuation-only, etc.).
|
||||
pub fn norm_part(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(trimmed.len());
|
||||
for c in decompose_canonical(trimmed.chars()) {
|
||||
if is_combining_mark(c) {
|
||||
continue;
|
||||
}
|
||||
for lc in c.to_lowercase() {
|
||||
if lc.is_alphanumeric() {
|
||||
out.push(lc);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Join normalized parts; any missing part → composite key is `None`.
|
||||
pub fn join_norm_parts(parts: impl IntoIterator<Item = Option<String>>) -> Option<String> {
|
||||
let mut joined = String::new();
|
||||
let mut count = 0usize;
|
||||
for part in parts {
|
||||
let p = part?;
|
||||
if count > 0 {
|
||||
joined.push(KEY_SEP);
|
||||
}
|
||||
joined.push_str(&p);
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(joined)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_combining_mark(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{0300}'..='\u{036F}'
|
||||
| '\u{1AB0}'..='\u{1AFF}'
|
||||
| '\u{1DC0}'..='\u{1DFF}'
|
||||
| '\u{20D0}'..='\u{20FF}'
|
||||
| '\u{FE20}'..='\u{FE2F}'
|
||||
)
|
||||
}
|
||||
|
||||
/// Expand to canonical NFD using left-to-right iterative decomposition (dependency-free).
|
||||
fn decompose_canonical(chars: impl IntoIterator<Item = char>) -> Vec<char> {
|
||||
let mut work: Vec<char> = chars.into_iter().collect();
|
||||
let mut idx = 0;
|
||||
while idx < work.len() {
|
||||
let c = work[idx];
|
||||
if let Some(parts) = canonical_decomposition(c) {
|
||||
work.splice(idx..idx + 1, parts);
|
||||
} else {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
work
|
||||
}
|
||||
|
||||
/// Canonical single-char decomposition for the shipped UI-locale scripts
|
||||
/// (Latin incl. ligatures + Romanian comma-below, and Cyrillic ё/й). Unmapped
|
||||
/// chars — notably CJK — pass through untouched. Keep in sync with `src/locales/*`.
|
||||
fn canonical_decomposition(c: char) -> Option<Vec<char>> {
|
||||
let cp = c as u32;
|
||||
let (base, mark) = match cp {
|
||||
0x00C0 | 0x00E0 => ('A', '\u{0300}'),
|
||||
0x00C1 | 0x00E1 => ('A', '\u{0301}'),
|
||||
0x00C2 | 0x00E2 => ('A', '\u{0302}'),
|
||||
0x00C3 | 0x00E3 => ('A', '\u{0303}'),
|
||||
0x00C4 | 0x00E4 => ('A', '\u{0308}'),
|
||||
0x00C5 | 0x00E5 => ('A', '\u{030A}'),
|
||||
0x00C6 => return Some(vec!['A', 'E']),
|
||||
0x00E6 => return Some(vec!['a', 'e']),
|
||||
0x00C7 | 0x00E7 => ('C', '\u{0327}'),
|
||||
0x00C8 | 0x00E8 => ('E', '\u{0300}'),
|
||||
0x00C9 | 0x00E9 => ('E', '\u{0301}'),
|
||||
0x00CA | 0x00EA => ('E', '\u{0302}'),
|
||||
0x00CB | 0x00EB => ('E', '\u{0308}'),
|
||||
0x00CC | 0x00EC => ('I', '\u{0300}'),
|
||||
0x00CD | 0x00ED => ('I', '\u{0301}'),
|
||||
0x00CE | 0x00EE => ('I', '\u{0302}'),
|
||||
0x00CF | 0x00EF => ('I', '\u{0308}'),
|
||||
0x00D0 => ('D', '\u{0330}'),
|
||||
0x00F0 => ('d', '\u{0330}'),
|
||||
0x00D1 | 0x00F1 => ('N', '\u{0303}'),
|
||||
0x00D2 | 0x00F2 => ('O', '\u{0300}'),
|
||||
0x00D3 | 0x00F3 => ('O', '\u{0301}'),
|
||||
0x00D4 | 0x00F4 => ('O', '\u{0302}'),
|
||||
0x00D5 | 0x00F5 => ('O', '\u{0303}'),
|
||||
0x00D6 | 0x00F6 => ('O', '\u{0308}'),
|
||||
0x00D8 | 0x00F8 => ('O', '\u{0338}'),
|
||||
0x00D9 | 0x00F9 => ('U', '\u{0300}'),
|
||||
0x00DA | 0x00FA => ('U', '\u{0301}'),
|
||||
0x00DB | 0x00FB => ('U', '\u{0302}'),
|
||||
0x00DC | 0x00FC => ('U', '\u{0308}'),
|
||||
0x00DD | 0x00FD => ('Y', '\u{0301}'),
|
||||
0x00DF => return Some(vec!['s', 's']),
|
||||
0x0100 | 0x0101 => ('A', '\u{0304}'),
|
||||
0x0102 | 0x0103 => ('A', '\u{0306}'),
|
||||
0x0104 | 0x0105 => ('A', '\u{0328}'),
|
||||
0x0106 | 0x0107 => ('C', '\u{0301}'),
|
||||
0x0108 | 0x0109 => ('C', '\u{0302}'),
|
||||
0x010A | 0x010B => ('C', '\u{0307}'),
|
||||
0x010C | 0x010D => ('C', '\u{030C}'),
|
||||
0x010E | 0x010F => ('D', '\u{030C}'),
|
||||
0x0110 | 0x0111 => ('D', '\u{0330}'),
|
||||
0x0112 | 0x0113 => ('E', '\u{0304}'),
|
||||
0x0114 | 0x0115 => ('E', '\u{0306}'),
|
||||
0x0116 | 0x0117 => ('E', '\u{0307}'),
|
||||
0x0118 | 0x0119 => ('E', '\u{0328}'),
|
||||
0x011A | 0x011B => ('E', '\u{030C}'),
|
||||
0x011C | 0x011D => ('G', '\u{0302}'),
|
||||
0x011E | 0x011F => ('G', '\u{0306}'),
|
||||
0x0120 | 0x0121 => ('G', '\u{0307}'),
|
||||
0x0122 | 0x0123 => ('G', '\u{0327}'),
|
||||
0x0124 | 0x0125 => ('H', '\u{0302}'),
|
||||
0x0126 | 0x0127 => ('H', '\u{0330}'),
|
||||
0x0128 | 0x0129 => ('I', '\u{0303}'),
|
||||
0x012A | 0x012B => ('I', '\u{0304}'),
|
||||
0x012C | 0x012D => ('I', '\u{0306}'),
|
||||
0x012E | 0x012F => ('I', '\u{0328}'),
|
||||
0x0130 => return Some(vec!['I', '\u{0307}']),
|
||||
0x0131 => return Some(vec!['i']),
|
||||
0x0132 => return Some(vec!['I', 'J']),
|
||||
0x0133 => return Some(vec!['i', 'j']),
|
||||
0x0134 | 0x0135 => ('J', '\u{0302}'),
|
||||
0x0136 | 0x0137 => ('K', '\u{0327}'),
|
||||
0x0139 | 0x013A => ('L', '\u{0301}'),
|
||||
0x013B | 0x013C => ('L', '\u{0327}'),
|
||||
0x013D | 0x013E => ('L', '\u{030C}'),
|
||||
0x0141 | 0x0142 => ('L', '\u{0330}'),
|
||||
0x0143 | 0x0144 => ('N', '\u{0301}'),
|
||||
0x0145 | 0x0146 => ('N', '\u{0327}'),
|
||||
0x0147 | 0x0148 => ('N', '\u{030C}'),
|
||||
0x014C | 0x014D => ('O', '\u{0304}'),
|
||||
0x014E | 0x014F => ('O', '\u{0306}'),
|
||||
0x0150 | 0x0151 => ('O', '\u{030B}'),
|
||||
0x0154 | 0x0155 => ('R', '\u{0301}'),
|
||||
0x0156 | 0x0157 => ('R', '\u{0327}'),
|
||||
0x0158 | 0x0159 => ('R', '\u{030C}'),
|
||||
0x015A | 0x015B => ('S', '\u{0301}'),
|
||||
0x015C | 0x015D => ('S', '\u{0302}'),
|
||||
0x015E | 0x015F => ('S', '\u{0327}'),
|
||||
0x0160 | 0x0161 => ('S', '\u{030C}'),
|
||||
0x0162 | 0x0163 => ('T', '\u{0327}'),
|
||||
0x0164 | 0x0165 => ('T', '\u{030C}'),
|
||||
0x0166 | 0x0167 => ('T', '\u{0330}'),
|
||||
0x0168 | 0x0169 => ('U', '\u{0303}'),
|
||||
0x016A | 0x016B => ('U', '\u{0304}'),
|
||||
0x016C | 0x016D => ('U', '\u{0306}'),
|
||||
0x016E | 0x016F => ('U', '\u{030A}'),
|
||||
0x0170 | 0x0171 => ('U', '\u{030B}'),
|
||||
0x0172 | 0x0173 => ('U', '\u{0328}'),
|
||||
0x0174 | 0x0175 => ('W', '\u{0302}'),
|
||||
0x0176 | 0x0177 => ('Y', '\u{0302}'),
|
||||
0x0178 => return Some(vec!['Y', '\u{0308}']),
|
||||
0x0179 | 0x017A => ('Z', '\u{0301}'),
|
||||
0x017B | 0x017C => ('Z', '\u{0307}'),
|
||||
0x017D | 0x017E => ('Z', '\u{030C}'),
|
||||
0x017F => return Some(vec!['s']),
|
||||
// French ligature œ (æ/ß handled above). Fold to the two-letter form.
|
||||
0x0152 => return Some(vec!['O', 'E']),
|
||||
0x0153 => return Some(vec!['o', 'e']),
|
||||
// Romanian comma-below (Latin Extended-B): Ș/ș, Ț/ț. Fold like the
|
||||
// cedilla forms above (U+0326 is dropped as a combining mark).
|
||||
0x0218 | 0x0219 => ('S', '\u{0326}'),
|
||||
0x021A | 0x021B => ('T', '\u{0326}'),
|
||||
// Cyrillic (ru, bg) canonical decompositions: Ё→Е (+diaeresis), Й→И
|
||||
// (+breve). The mark is dropped, so ё/е and й/и fold together — the
|
||||
// same diacritic-folding contract the Latin rows above provide.
|
||||
0x0401 | 0x0451 => ('\u{0415}', '\u{0308}'),
|
||||
0x0419 | 0x0439 => ('\u{0418}', '\u{0306}'),
|
||||
_ => return None,
|
||||
};
|
||||
let base_out = if c.is_uppercase() {
|
||||
base
|
||||
} else {
|
||||
base.to_lowercase().next().unwrap_or(base)
|
||||
};
|
||||
Some(vec![base_out, mark])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn norm_strips_diacritics_case_and_punctuation() {
|
||||
assert_eq!(norm_part("Café"), Some("cafe".into()));
|
||||
assert_eq!(norm_part("AC/DC"), Some("acdc".into()));
|
||||
assert_eq!(norm_part(" Björk "), Some("bjork".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_folds_locale_ligatures() {
|
||||
// German ß → ss, Norwegian/Danish æ → ae, French œ → oe.
|
||||
assert_eq!(norm_part("Straße"), Some("strasse".into()));
|
||||
assert_eq!(norm_part("Blæst"), Some("blaest".into()));
|
||||
assert_eq!(norm_part("Cœur"), Some("coeur".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_folds_romanian_comma_below() {
|
||||
assert_eq!(norm_part("Ștefan"), Some("stefan".into()));
|
||||
assert_eq!(norm_part("București"), Some("bucuresti".into()));
|
||||
assert_eq!(norm_part("Constanța"), Some("constanta".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_folds_cyrillic_diacritics_but_keeps_base_letters() {
|
||||
// ё/й fold to е/и; other Cyrillic letters survive as lowercase.
|
||||
assert_eq!(norm_part("Фёдор"), norm_part("Федор"));
|
||||
assert_eq!(norm_part("Фёдор"), Some("федор".into()));
|
||||
assert_eq!(norm_part("Й"), Some("и".into()));
|
||||
assert_eq!(norm_part("Пётр Ильич"), Some("петрильич".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_keeps_cjk_and_phonemic_kana() {
|
||||
// Han/Kanji kept verbatim (no wrongful folding).
|
||||
assert_eq!(norm_part("周杰倫"), Some("周杰倫".into()));
|
||||
assert_eq!(norm_part("久石譲"), Some("久石譲".into()));
|
||||
// Japanese dakuten is phonemic: が (ga) must NOT fold to か (ka).
|
||||
assert_ne!(norm_part("が"), norm_part("か"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_empty_or_punctuation_only_is_none() {
|
||||
assert_eq!(norm_part(""), None);
|
||||
assert_eq!(norm_part(" "), None);
|
||||
assert_eq!(norm_part("..."), None);
|
||||
assert_eq!(norm_part("!!!"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_norm_parts_requires_all_parts() {
|
||||
assert_eq!(
|
||||
join_norm_parts([Some("a".into()), Some("b".into())]),
|
||||
Some(format!("a{}b", KEY_SEP))
|
||||
);
|
||||
assert_eq!(join_norm_parts([Some("a".into()), None]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Batch rebuild of `cluster.track_cluster_key` from live `track` rows.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::attach::CLUSTER_SCHEMA;
|
||||
use super::keys::build_track_cluster_keys;
|
||||
use super::norm::NORM_VERSION;
|
||||
|
||||
const UPSERT_CLUSTER_KEY_SQL: &str = "
|
||||
INSERT INTO cluster.track_cluster_key (
|
||||
server_id, library_id, track_id, cluster_key, album_key, artist_key, duration_sec
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(server_id, track_id) DO UPDATE SET
|
||||
library_id = excluded.library_id,
|
||||
cluster_key = excluded.cluster_key,
|
||||
album_key = excluded.album_key,
|
||||
artist_key = excluded.artist_key,
|
||||
duration_sec = excluded.duration_sec
|
||||
";
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `true` when `cluster_meta.norm_version` is missing or differs from [`NORM_VERSION`].
|
||||
pub fn cluster_rebuild_needed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let stored: Option<String> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT value FROM {CLUSTER_SCHEMA}.cluster_meta WHERE key = 'norm_version'"
|
||||
),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(stored.as_deref() != Some(NORM_VERSION))
|
||||
}
|
||||
|
||||
fn set_cluster_meta(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let now = now_unix().to_string();
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT INTO {CLUSTER_SCHEMA}.cluster_meta(key, value) VALUES ('norm_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
),
|
||||
params![NORM_VERSION],
|
||||
)?;
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT INTO {CLUSTER_SCHEMA}.cluster_meta(key, value) VALUES ('build_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
),
|
||||
params![now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type SourceTrackRow = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
);
|
||||
|
||||
/// Rebuild identity keys for one server or all servers. Returns rows upserted.
|
||||
pub fn rebuild_cluster_keys(
|
||||
store: &LibraryStore,
|
||||
server_id: Option<&str>,
|
||||
) -> Result<u64, String> {
|
||||
store.with_conn_mut("identity.rebuild_cluster_keys", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut select = String::from(
|
||||
"SELECT server_id, COALESCE(library_id, ''), id, artist, title, album_artist, album, duration_sec \
|
||||
FROM track WHERE deleted = 0",
|
||||
);
|
||||
if server_id.is_some() {
|
||||
select.push_str(" AND server_id = ?1");
|
||||
}
|
||||
// Stream rows straight from the `track` SELECT into the sidecar UPSERT
|
||||
// (both statements borrow the same tx; the SELECT reads `track`, the
|
||||
// UPSERT writes the attached `cluster` table, so they don't contend).
|
||||
// Avoids materializing the whole track table (~60–70 MB on 212k rows)
|
||||
// before writing.
|
||||
let filter_params: Vec<&str> = server_id.into_iter().collect();
|
||||
let mut stmt = tx.prepare(&select)?;
|
||||
let mut upsert = tx.prepare_cached(UPSERT_CLUSTER_KEY_SQL)?;
|
||||
let mut upserted = 0u64;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(filter_params.iter()))?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let (server_id, library_id, track_id, artist, title, album_artist, album, duration_sec) =
|
||||
map_source_track_row(row)?;
|
||||
let keys = build_track_cluster_keys(
|
||||
artist.as_deref(),
|
||||
&title,
|
||||
&album,
|
||||
album_artist.as_deref(),
|
||||
);
|
||||
upsert.execute(params![
|
||||
server_id,
|
||||
library_id,
|
||||
track_id,
|
||||
keys.cluster_key,
|
||||
keys.album_key,
|
||||
keys.artist_key,
|
||||
duration_sec,
|
||||
])?;
|
||||
upserted = upserted.saturating_add(1);
|
||||
}
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
drop(upsert);
|
||||
// Prune keys whose track no longer exists (soft-deleted via tombstone, or
|
||||
// dropped when a server mints a fresh id on rename). The UPSERT above only
|
||||
// refreshes live rows; without this, orphaned keys accumulate forever and
|
||||
// are only reclaimed when the whole sidecar is dropped (swap/restore/import).
|
||||
// Reads join `cluster.track_cluster_key` against `track WHERE deleted = 0`,
|
||||
// so these rows are inert — this is bloat cleanup, scoped to the rebuilt
|
||||
// server(s) so a single-server rebuild never touches other servers' keys.
|
||||
if let Some(sid) = server_id {
|
||||
tx.execute(
|
||||
"DELETE FROM cluster.track_cluster_key \
|
||||
WHERE server_id = ?1 \
|
||||
AND track_id NOT IN (\
|
||||
SELECT id FROM track WHERE deleted = 0 AND server_id = ?1\
|
||||
)",
|
||||
params![sid],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"DELETE FROM cluster.track_cluster_key \
|
||||
WHERE (server_id, track_id) NOT IN (\
|
||||
SELECT server_id, id FROM track WHERE deleted = 0\
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
set_cluster_meta(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(upserted)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build cluster keys before a multi-library read. Rebuilds when either:
|
||||
/// - the stored `norm_version` differs from [`NORM_VERSION`] (normalization rules
|
||||
/// changed) — then **all** servers are rebuilt, because [`rebuild_cluster_keys`]
|
||||
/// stamps a single global `norm_version`; a per-server rebuild would flip the
|
||||
/// gate and strand every other server's stale keys; or
|
||||
/// - this server has tracks but no keys yet (fresh index / newly synced server).
|
||||
pub fn ensure_cluster_keys_built(store: &LibraryStore, server_id: &str) -> Result<(), String> {
|
||||
let rebuild_all = store
|
||||
.with_read_conn(cluster_rebuild_needed)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if rebuild_all {
|
||||
rebuild_cluster_keys(store, None)?;
|
||||
return Ok(());
|
||||
}
|
||||
let needs_rebuild = store
|
||||
.with_read_conn(|conn| {
|
||||
let track_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM track WHERE server_id = ?1 AND deleted = 0",
|
||||
[server_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if track_count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let key_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM cluster.track_cluster_key WHERE server_id = ?1",
|
||||
[server_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(key_count == 0)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
if needs_rebuild {
|
||||
rebuild_cluster_keys(store, Some(server_id))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_source_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SourceTrackRow> {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
row.get(7)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Test helper: read one row from the attached `cluster` schema on any connection.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn read_cluster_row(
|
||||
conn: &Connection,
|
||||
server_id: &str,
|
||||
track_id: &str,
|
||||
) -> rusqlite::Result<Option<(Option<String>, Option<String>, Option<String>, i64)>> {
|
||||
conn.query_row(
|
||||
"SELECT cluster_key, album_key, artist_key, duration_sec \
|
||||
FROM cluster.track_cluster_key WHERE server_id = ?1 AND track_id = ?2",
|
||||
params![server_id, track_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identity::norm::{norm_part, NORM_VERSION};
|
||||
use crate::repos::track::{TrackRepository, TrackRow};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn track_row(
|
||||
server: &str,
|
||||
id: &str,
|
||||
title: &str,
|
||||
artist: Option<&str>,
|
||||
album: &str,
|
||||
album_artist: Option<&str>,
|
||||
duration: i64,
|
||||
library_id: &str,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
title_sort: None,
|
||||
artist: artist.map(str::to_string),
|
||||
artist_id: None,
|
||||
album: album.into(),
|
||||
album_id: None,
|
||||
album_artist: album_artist.map(str::to_string),
|
||||
duration_sec: duration,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: Some(library_id.into()),
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
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,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_populates_keys_and_duration() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_row(
|
||||
"s1",
|
||||
"t1",
|
||||
"Café Song",
|
||||
Some("Björk"),
|
||||
"Homogenic",
|
||||
Some("Björk"),
|
||||
312,
|
||||
"lib-a",
|
||||
),
|
||||
track_row("s1", "t2", "No Artist", None, "Al", None, 100, "lib-a"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let n = rebuild_cluster_keys(&store, Some("s1")).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
|
||||
let row = store
|
||||
.with_read_conn(|conn| read_cluster_row(conn, "s1", "t1"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (cluster_key, album_key, artist_key, duration) = row;
|
||||
assert_eq!(duration, 312);
|
||||
assert_eq!(artist_key.as_deref(), norm_part("Björk").as_deref());
|
||||
assert!(cluster_key.is_some());
|
||||
assert!(album_key.is_some());
|
||||
|
||||
let empty_artist = store
|
||||
.with_read_conn(|conn| read_cluster_row(conn, "s1", "t2"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(empty_artist.0.is_none());
|
||||
assert!(empty_artist.2.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_is_idempotent() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track_row(
|
||||
"s1",
|
||||
"t1",
|
||||
"Title",
|
||||
Some("Artist"),
|
||||
"Album",
|
||||
None,
|
||||
200,
|
||||
"lib",
|
||||
)])
|
||||
.unwrap();
|
||||
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
let first = store
|
||||
.with_read_conn(|conn| read_cluster_row(conn, "s1", "t1"))
|
||||
.unwrap();
|
||||
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
let second = store
|
||||
.with_read_conn(|conn| read_cluster_row(conn, "s1", "t1"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_prunes_orphaned_cluster_keys() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_row("s1", "t1", "T1", Some("A"), "Al", None, 100, "lib"),
|
||||
track_row("s1", "t2", "T2", Some("B"), "Al", None, 120, "lib"),
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, Some("s1")).unwrap();
|
||||
assert!(store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s1", "t2"))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
// Soft-delete t2 (tombstone) → its stale cluster key must be pruned on
|
||||
// the next rebuild, not linger forever.
|
||||
store
|
||||
.with_conn_mut("test.soft_delete", |c| {
|
||||
c.execute(
|
||||
"UPDATE track SET deleted = 1 WHERE server_id = 's1' AND id = 't2'",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, Some("s1")).unwrap();
|
||||
|
||||
assert!(
|
||||
store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"live track key must remain"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s1", "t2"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"orphaned cluster key must be pruned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_rebuild_prunes_orphans_across_servers() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_row("s1", "t1", "T1", Some("A"), "Al", None, 100, "lib"),
|
||||
track_row("s2", "t2", "T2", Some("B"), "Al", None, 120, "lib"),
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
assert!(store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s2", "t2"))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
// Both tracks go to tombstone; a global (server_id = None) rebuild must
|
||||
// prune the orphan on every server via the tuple-scoped DELETE branch.
|
||||
store
|
||||
.with_conn_mut("test.del", |c| {
|
||||
c.execute("UPDATE track SET deleted = 1 WHERE id IN ('t1', 't2')", [])
|
||||
})
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
assert!(
|
||||
store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"global rebuild must prune s1 orphan"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s2", "t2"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"global rebuild must prune s2 orphan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_server_rebuild_leaves_other_server_keys() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_row("s1", "t1", "T1", Some("A"), "Al", None, 100, "lib"),
|
||||
track_row("s2", "t2", "T2", Some("B"), "Al", None, 120, "lib"),
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
// Both tracks go to tombstone, but we rebuild only s1: s1's orphan is
|
||||
// pruned while s2's key is untouched (single global norm stamp, but the
|
||||
// prune is scoped to the rebuilt server).
|
||||
store
|
||||
.with_conn_mut("test.del", |c| {
|
||||
c.execute("UPDATE track SET deleted = 1 WHERE id IN ('t1', 't2')", [])
|
||||
})
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, Some("s1")).unwrap();
|
||||
|
||||
assert!(
|
||||
store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s1", "t1"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"rebuilt server's orphan must be pruned"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.with_read_conn(|c| read_cluster_row(c, "s2", "t2"))
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"single-server rebuild must not prune another server's keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norm_version_gate_and_bump() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
assert!(
|
||||
store
|
||||
.with_conn("misc", cluster_rebuild_needed)
|
||||
.unwrap(),
|
||||
"fresh attach should need rebuild"
|
||||
);
|
||||
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track_row(
|
||||
"s1",
|
||||
"t1",
|
||||
"T",
|
||||
Some("A"),
|
||||
"Al",
|
||||
None,
|
||||
1,
|
||||
"lib",
|
||||
)])
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
assert!(
|
||||
!store
|
||||
.with_conn("misc", cluster_rebuild_needed)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
store
|
||||
.with_conn_mut("test.stale_norm", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cluster.cluster_meta SET value = '0' WHERE key = 'norm_version'",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.with_conn("misc", cluster_rebuild_needed)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
let version: String = store
|
||||
.with_conn("misc", |conn| {
|
||||
conn.query_row(
|
||||
"SELECT value FROM cluster.cluster_meta WHERE key = 'norm_version'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(version, NORM_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_cluster_keys_built_rebuilds_on_norm_version_mismatch() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track_row("s1", "t1", "T", Some("A"), "Al", None, 1, "lib"),
|
||||
track_row("s2", "t2", "T2", Some("A2"), "Al2", None, 2, "lib"),
|
||||
])
|
||||
.unwrap();
|
||||
// Build once (stamps the current NORM_VERSION), then simulate keys left
|
||||
// over from an older normalization by rewinding the stored version.
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
store
|
||||
.with_conn_mut("test.stale_norm", |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cluster.cluster_meta SET value = 'stale' WHERE key = 'norm_version'",
|
||||
[],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.with_conn("misc", cluster_rebuild_needed).unwrap());
|
||||
|
||||
// The read path must notice the mismatch and rebuild even though keys exist.
|
||||
ensure_cluster_keys_built(&store, "s1").unwrap();
|
||||
|
||||
assert!(
|
||||
!store.with_conn("misc", cluster_rebuild_needed).unwrap(),
|
||||
"version mismatch must be reconciled by the read path"
|
||||
);
|
||||
// All servers rebuilt, not just the one requested (single global stamp).
|
||||
let s2_keys: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM cluster.track_cluster_key WHERE server_id = 's2'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s2_keys, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_attach_visible_on_read_connection() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track_row(
|
||||
"s1",
|
||||
"t1",
|
||||
"T",
|
||||
Some("A"),
|
||||
"Al",
|
||||
None,
|
||||
42,
|
||||
"lib",
|
||||
)])
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
let count: i64 = store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM cluster.track_cluster_key WHERE server_id = 's1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,16 @@ pub mod filter;
|
||||
pub mod genre_album_browse;
|
||||
pub mod genre_tags;
|
||||
pub mod genre_tags_backfill;
|
||||
pub mod identity;
|
||||
pub mod mood_groups;
|
||||
pub mod live_search;
|
||||
pub mod orphan_cleanup;
|
||||
pub mod lossless_albums;
|
||||
pub mod lossless_formats;
|
||||
pub mod payload;
|
||||
pub mod repos;
|
||||
pub mod runtime;
|
||||
pub mod scope_merge;
|
||||
pub mod search;
|
||||
pub mod store;
|
||||
pub mod sync;
|
||||
|
||||
@@ -5,27 +5,34 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::dto::{LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryTrackDto};
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryScopePair,
|
||||
LibraryTrackDto, multi_library_merge_enabled, ordered_library_scope_pairs,
|
||||
};
|
||||
use crate::scope_merge;
|
||||
use crate::search::{
|
||||
fts_album_prefix_any_token_match_query, fts_artist_prefix_any_token_match_query,
|
||||
fts_query_meets_min_len, fts_track_prefix_any_token_match_query, library_scope_equals_sql,
|
||||
fts_query_meets_min_len, fts_track_prefix_any_token_match_query, library_scope_in_sql,
|
||||
normalized_library_scopes, push_library_scope_binds,
|
||||
};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const TRACK_FTS_BM25_RANK: &str = "bm25(track_fts, 10.0, 3.0, 5.0, 3.0, 0.0)";
|
||||
/// FTS row candidates before GROUP BY dedupe — avoids one artist filling the whole cap.
|
||||
const LIVE_SEARCH_FTS_CANDIDATE_CAP: i64 = 150;
|
||||
pub(crate) const LIVE_SEARCH_FTS_CANDIDATE_CAP: i64 = 150;
|
||||
|
||||
struct LiveHit {
|
||||
track: LibraryTrackDto,
|
||||
}
|
||||
|
||||
/// `library_live_search` — read connection, scoped FTS rowid picks + join.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_live_search(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
query: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: Option<&[LibraryScopePair]>,
|
||||
artist_limit: u32,
|
||||
album_limit: u32,
|
||||
song_limit: u32,
|
||||
@@ -39,12 +46,31 @@ pub fn run_live_search(
|
||||
});
|
||||
}
|
||||
|
||||
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes);
|
||||
if multi_library_merge_enabled(&scope_pairs) {
|
||||
crate::identity::ensure_cluster_keys_built(store, server_id)?;
|
||||
return run_live_search_multi_scope(
|
||||
store,
|
||||
&scope_pairs,
|
||||
query,
|
||||
artist_limit,
|
||||
album_limit,
|
||||
song_limit,
|
||||
);
|
||||
}
|
||||
|
||||
let effective_scope = library_scope
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| scope_pairs.first().map(|p| p.library_id.clone()));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let scope = trimmed_scope(library_scope);
|
||||
let scopes = scopes_from_option(effective_scope.as_deref());
|
||||
// Songs first — smallest FTS cap; warms the page cache for follow-up queries.
|
||||
let songs = query_songs(conn, query, server_id, scope.as_deref(), song_limit)?;
|
||||
let artists = query_artists(conn, query, server_id, scope.as_deref(), artist_limit)?;
|
||||
let albums = query_albums(conn, query, server_id, scope.as_deref(), album_limit)?;
|
||||
let songs = query_songs(conn, query, server_id, &scopes, song_limit)?;
|
||||
let artists = query_artists(conn, query, server_id, &scopes, artist_limit)?;
|
||||
let albums = query_albums(conn, query, server_id, &scopes, album_limit)?;
|
||||
Ok(LibraryLiveSearchResponse {
|
||||
artists,
|
||||
albums,
|
||||
@@ -54,19 +80,66 @@ pub fn run_live_search(
|
||||
})
|
||||
}
|
||||
|
||||
fn run_live_search_multi_scope(
|
||||
store: &LibraryStore,
|
||||
scopes: &[LibraryScopePair],
|
||||
query: &str,
|
||||
artist_limit: u32,
|
||||
album_limit: u32,
|
||||
song_limit: u32,
|
||||
) -> Result<LibraryLiveSearchResponse, String> {
|
||||
let Some(song_fts) = fts_track_prefix_any_token_match_query(query) else {
|
||||
return Ok(LibraryLiveSearchResponse {
|
||||
artists: Vec::new(),
|
||||
albums: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
source: "local".to_string(),
|
||||
});
|
||||
};
|
||||
let songs = scope_merge::live_search_songs(store, scopes, &song_fts, song_limit)?;
|
||||
|
||||
let artists = if let Some(artist_fts) = fts_artist_prefix_any_token_match_query(query) {
|
||||
scope_merge::live_search_artists(store, scopes, &artist_fts, artist_limit)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let albums = if let Some(album_fts) = fts_album_prefix_any_token_match_query(query) {
|
||||
scope_merge::live_search_albums(store, scopes, &album_fts, album_limit)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(LibraryLiveSearchResponse {
|
||||
artists,
|
||||
albums,
|
||||
tracks: songs,
|
||||
source: "local".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scopes_from_option(library_scope: Option<&str>) -> Vec<String> {
|
||||
normalized_library_scopes(
|
||||
&library_scope
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| vec![s.to_string()])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Top FTS rowids for column-scoped MATCH, scoped to `server_id` (multi-server safe).
|
||||
fn collect_fts_rowids(
|
||||
conn: &rusqlite::Connection,
|
||||
match_queries: &[String],
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
per_query_limit: i64,
|
||||
total_limit: usize,
|
||||
) -> rusqlite::Result<Vec<i64>> {
|
||||
let scope = trimmed_scope(library_scope);
|
||||
let mut scope_sql = String::new();
|
||||
if scope.is_some() {
|
||||
scope_sql = format!(" AND {}", library_scope_equals_sql("c"));
|
||||
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 \
|
||||
@@ -87,9 +160,7 @@ fn collect_fts_rowids(
|
||||
rusqlite::types::Value::Text(mq.clone()),
|
||||
rusqlite::types::Value::Text(server_id.to_string()),
|
||||
];
|
||||
if let Some(ref s) = scope {
|
||||
bind.push(rusqlite::types::Value::Text(s.clone()));
|
||||
}
|
||||
push_library_scope_binds(&mut bind, library_scopes);
|
||||
bind.push(rusqlite::types::Value::Integer(per_query_limit));
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(bind.iter()), |r| r.get(0))?;
|
||||
for rowid in rows {
|
||||
@@ -105,29 +176,22 @@ fn collect_fts_rowids(
|
||||
Ok(rowids)
|
||||
}
|
||||
|
||||
fn trimmed_scope(scope: Option<&str>) -> Option<String> {
|
||||
scope
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn append_library_scope(
|
||||
sql: &mut String,
|
||||
params: &mut Vec<rusqlite::types::Value>,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
) {
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
if !library_scopes.is_empty() {
|
||||
sql.push_str(" AND ");
|
||||
sql.push_str(&library_scope_equals_sql("t"));
|
||||
params.push(rusqlite::types::Value::Text(scope.to_string()));
|
||||
sql.push_str(&library_scope_in_sql("t", library_scopes.len()));
|
||||
push_library_scope_binds(params, library_scopes);
|
||||
}
|
||||
}
|
||||
|
||||
fn scoped_exists_sql(library_scope: Option<&str>, extra: &str) -> String {
|
||||
fn scoped_exists_sql(library_scopes: &[String], extra: &str) -> String {
|
||||
let mut scope_sql = String::new();
|
||||
if library_scope.is_some() {
|
||||
scope_sql = format!(" AND {}", library_scope_equals_sql("c"));
|
||||
if !library_scopes.is_empty() {
|
||||
scope_sql = format!(" AND {}", library_scope_in_sql("c", library_scopes.len()));
|
||||
}
|
||||
format!(
|
||||
"EXISTS (\
|
||||
@@ -139,24 +203,18 @@ fn scoped_exists_sql(library_scope: Option<&str>, extra: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn push_scope_bind(params: &mut Vec<rusqlite::types::Value>, library_scope: Option<&str>) {
|
||||
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
|
||||
params.push(rusqlite::types::Value::Text(scope.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn query_artists(
|
||||
conn: &rusqlite::Connection,
|
||||
query: &str,
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
limit: u32,
|
||||
) -> rusqlite::Result<Vec<LibraryArtistDto>> {
|
||||
let Some(artist_fts) = fts_artist_prefix_any_token_match_query(query) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let exists = scoped_exists_sql(
|
||||
library_scope,
|
||||
library_scopes,
|
||||
" AND c.artist_id IS NOT NULL AND c.artist_id != ''",
|
||||
);
|
||||
let sql = format!(
|
||||
@@ -180,10 +238,10 @@ fn query_artists(
|
||||
rusqlite::types::Value::Text(artist_fts),
|
||||
rusqlite::types::Value::Text(server_id.to_string()),
|
||||
];
|
||||
push_scope_bind(&mut params, library_scope);
|
||||
push_library_scope_binds(&mut params, library_scopes);
|
||||
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
|
||||
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
||||
append_library_scope(&mut sql, &mut params, library_scope);
|
||||
append_library_scope(&mut sql, &mut params, library_scopes);
|
||||
sql.push_str(" GROUP BY t.artist_id ORDER BY best_rank LIMIT ?");
|
||||
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
@@ -208,32 +266,32 @@ fn query_songs(
|
||||
conn: &rusqlite::Connection,
|
||||
query: &str,
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
limit: u32,
|
||||
) -> rusqlite::Result<Vec<LibraryTrackDto>> {
|
||||
let Some(song_fts) = fts_track_prefix_any_token_match_query(query) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let per_col = i64::from(limit.max(4));
|
||||
let rowids = collect_fts_rowids(conn, &[song_fts], server_id, library_scope, per_col, limit as usize)?;
|
||||
let rowids = collect_fts_rowids(conn, &[song_fts], server_id, library_scopes, per_col, limit as usize)?;
|
||||
if rowids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
fetch_tracks_by_rowids(conn, &rowids, server_id, library_scope)
|
||||
fetch_tracks_by_rowids(conn, &rowids, server_id, library_scopes)
|
||||
}
|
||||
|
||||
fn query_albums(
|
||||
conn: &rusqlite::Connection,
|
||||
query: &str,
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
limit: u32,
|
||||
) -> rusqlite::Result<Vec<LibraryAlbumDto>> {
|
||||
let Some(album_fts) = fts_album_prefix_any_token_match_query(query) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let exists = scoped_exists_sql(
|
||||
library_scope,
|
||||
library_scopes,
|
||||
" AND c.album_id IS NOT NULL AND c.album_id != ''",
|
||||
);
|
||||
let sql = format!(
|
||||
@@ -259,10 +317,10 @@ fn query_albums(
|
||||
rusqlite::types::Value::Text(album_fts),
|
||||
rusqlite::types::Value::Text(server_id.to_string()),
|
||||
];
|
||||
push_scope_bind(&mut params, library_scope);
|
||||
push_library_scope_binds(&mut params, library_scopes);
|
||||
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
|
||||
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
||||
append_library_scope(&mut sql, &mut params, library_scope);
|
||||
append_library_scope(&mut sql, &mut params, library_scopes);
|
||||
sql.push_str(" GROUP BY t.server_id, t.album_id ORDER BY best_rank LIMIT ?");
|
||||
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
@@ -302,7 +360,7 @@ fn fetch_tracks_by_rowids(
|
||||
conn: &rusqlite::Connection,
|
||||
rowids: &[i64],
|
||||
server_id: &str,
|
||||
library_scope: Option<&str>,
|
||||
library_scopes: &[String],
|
||||
) -> rusqlite::Result<Vec<LibraryTrackDto>> {
|
||||
let placeholders = rowid_placeholders(rowids.len());
|
||||
let sql = format!(
|
||||
@@ -324,7 +382,7 @@ fn fetch_tracks_by_rowids(
|
||||
.collect();
|
||||
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
||||
let mut sql = sql;
|
||||
append_library_scope(&mut sql, &mut params, library_scope);
|
||||
append_library_scope(&mut sql, &mut params, library_scopes);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut by_rowid: HashMap<i64, LibraryTrackDto> = HashMap::new();
|
||||
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
@@ -375,6 +433,7 @@ fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<
|
||||
mbid_recording: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
synced_at: row.get(offset + 21)?,
|
||||
@@ -388,7 +447,6 @@ fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
use serde_json::json;
|
||||
|
||||
fn track(
|
||||
server: &str,
|
||||
@@ -429,6 +487,7 @@ 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,
|
||||
@@ -455,7 +514,7 @@ mod tests {
|
||||
track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"),
|
||||
])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "metal", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "metal", None, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.artists.iter().any(|a| a.name == "Metallica"),
|
||||
"expected Metallica from prefix query metal"
|
||||
@@ -472,7 +531,7 @@ mod tests {
|
||||
track("s1", "t2", "Other", "Other Artist", "Other Album", "al2", "ar2"),
|
||||
])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "aurora", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "aurora", None, None, 5, 5, 10).unwrap();
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "al1");
|
||||
@@ -506,7 +565,7 @@ mod tests {
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "manowar", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "manowar", None, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.artists.iter().any(|a| a.name == "Manowar"),
|
||||
"expected Manowar artist"
|
||||
@@ -532,7 +591,7 @@ mod tests {
|
||||
"ar1",
|
||||
)])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "а", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "а", None, None, 5, 5, 10).unwrap();
|
||||
assert!(resp.tracks.is_empty());
|
||||
assert!(resp.artists.is_empty());
|
||||
assert!(resp.albums.is_empty());
|
||||
@@ -564,7 +623,7 @@ mod tests {
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[in_lib, other])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "scoped", Some("lib1"), 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "scoped", Some("lib1"), None, 5, 5, 10).unwrap();
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.tracks[0].id, "t1");
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
@@ -574,9 +633,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_search_library_scope_matches_raw_json_when_column_null() {
|
||||
fn live_search_library_scope_narrows_multi_id() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut row = track(
|
||||
let mut in_lib1 = track(
|
||||
"s1",
|
||||
"t1",
|
||||
"Scoped Song",
|
||||
@@ -585,9 +644,31 @@ mod tests {
|
||||
"al1",
|
||||
"ar1",
|
||||
);
|
||||
row.raw_json = json!({"libraryId": 3}).to_string();
|
||||
TrackRepository::new(&store).upsert_batch(&[row]).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "scoped", Some("3"), 5, 5, 10).unwrap();
|
||||
in_lib1.library_id = Some("lib1".into());
|
||||
let mut in_lib2 = track(
|
||||
"s1",
|
||||
"t2",
|
||||
"Scoped Song",
|
||||
"Other Artist",
|
||||
"Other Album",
|
||||
"al2",
|
||||
"ar2",
|
||||
);
|
||||
in_lib2.library_id = Some("lib2".into());
|
||||
let mut in_lib3 = track(
|
||||
"s1",
|
||||
"t3",
|
||||
"Scoped Song",
|
||||
"Third Artist",
|
||||
"Third Album",
|
||||
"al3",
|
||||
"ar3",
|
||||
);
|
||||
in_lib3.library_id = Some("lib3".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[in_lib1, in_lib2, in_lib3])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "scoped", Some("lib1"), None, 5, 5, 10).unwrap();
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.tracks[0].id, "t1");
|
||||
}
|
||||
@@ -619,7 +700,7 @@ mod tests {
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&batch)
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s_small", "night", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s_small", "night", None, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.artists.iter().any(|a| a.name == "Nightwish"),
|
||||
"expected Nightwish on s_small; global bm25 must not crowd out the active server"
|
||||
@@ -657,7 +738,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
TrackRepository::new(&store).upsert_batch(&batch).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "metall", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "metall", None, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.artists.len() >= 3,
|
||||
"expected distinct metall* artists, got {} ({:?})",
|
||||
@@ -685,7 +766,7 @@ mod tests {
|
||||
])
|
||||
.unwrap();
|
||||
for q in ["1=2", "1=1", "M=c"] {
|
||||
let resp = run_live_search(&store, "s1", q, None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", q, None, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.tracks.is_empty() && resp.albums.is_empty() && resp.artists.is_empty(),
|
||||
"query {q:?} must not fuzzy-match unrelated library rows"
|
||||
@@ -710,7 +791,7 @@ mod tests {
|
||||
track("s1", "t2", "Other Song", "Artist", "Album", "al2", "ar2"),
|
||||
])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "***Flawless", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "***Flawless", None, None, 5, 5, 10).unwrap();
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.tracks[0].title, "***Flawless");
|
||||
}
|
||||
@@ -740,13 +821,80 @@ mod tests {
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let resp = run_live_search(&store, "s1", "love supreme", None, 5, 5, 10).unwrap();
|
||||
let resp = run_live_search(&store, "s1", "love supreme", None, None, 5, 5, 10).unwrap();
|
||||
assert!(
|
||||
resp.albums.iter().any(|a| a.name == "Supreme Ballads"),
|
||||
"second token supreme must match album title; AND-all-tokens would miss this album"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_scope_live_search_dedupes_album_and_artist_with_priority() {
|
||||
use crate::dto::LibraryScopePair;
|
||||
use crate::identity::rebuild_cluster_keys;
|
||||
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
{
|
||||
let mut t = track(
|
||||
"s1",
|
||||
"t-a",
|
||||
"Shared Song",
|
||||
"Shared Artist",
|
||||
"Shared Album",
|
||||
"alb-a",
|
||||
"ar-a",
|
||||
);
|
||||
t.library_id = Some("lib-a".into());
|
||||
t
|
||||
},
|
||||
{
|
||||
let mut t = track(
|
||||
"s1",
|
||||
"t-b",
|
||||
"Shared Song",
|
||||
"Shared Artist",
|
||||
"Shared Album",
|
||||
"alb-b",
|
||||
"ar-b",
|
||||
);
|
||||
t.library_id = Some("lib-b".into());
|
||||
t
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_cluster_keys(&store, None).unwrap();
|
||||
|
||||
let scopes = vec![
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: "lib-a".into(),
|
||||
},
|
||||
LibraryScopePair {
|
||||
server_id: "s1".into(),
|
||||
library_id: "lib-b".into(),
|
||||
},
|
||||
];
|
||||
let resp = run_live_search(
|
||||
&store,
|
||||
"s1",
|
||||
"shared",
|
||||
None,
|
||||
Some(&scopes),
|
||||
5,
|
||||
5,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
assert_eq!(resp.artists[0].id, "ar-a");
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].id, "alb-a");
|
||||
assert_eq!(resp.tracks.len(), 1);
|
||||
assert_eq!(resp.tracks[0].id, "t-a");
|
||||
}
|
||||
|
||||
/// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
@@ -782,11 +930,11 @@ mod tests {
|
||||
|
||||
for q in ["manowar", "metallica", "arch enemy", "metal", "meta"] {
|
||||
let t0 = Instant::now();
|
||||
let songs = query_songs(&conn, q, &server_id, None, 10).unwrap();
|
||||
let songs = query_songs(&conn, q, &server_id, &[], 10).unwrap();
|
||||
let t1 = Instant::now();
|
||||
let artists = query_artists(&conn, q, &server_id, None, 5).unwrap();
|
||||
let artists = query_artists(&conn, q, &server_id, &[], 5).unwrap();
|
||||
let t2 = Instant::now();
|
||||
let albums = query_albums(&conn, q, &server_id, None, 5).unwrap();
|
||||
let albums = query_albums(&conn, q, &server_id, &[], 5).unwrap();
|
||||
let t3 = Instant::now();
|
||||
eprintln!(
|
||||
"{q:?}: songs={} ({:.1}ms) artists={} ({:.1}ms) albums={} ({:.1}ms) total={:.1}ms",
|
||||
|
||||
@@ -4,15 +4,32 @@
|
||||
|
||||
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
|
||||
use crate::lossless_formats::track_is_lossless_sql;
|
||||
use crate::search::library_scope_equals_sql;
|
||||
use crate::search::{combined_scope_library_ids, library_scope_in_sql, library_scope_sargable_equals_sql};
|
||||
use crate::store::LibraryStore;
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
|
||||
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
/// Push a sargable `library_id` filter (hot column, matching the rest of the
|
||||
/// migrated browse/search paths) for a single or multi-library scope. 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paginated lossless albums for one server. Returns empty when the index has
|
||||
@@ -37,11 +54,9 @@ pub fn list_lossless_albums(
|
||||
];
|
||||
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
|
||||
|
||||
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
|
||||
let clause = library_scope_equals_sql("t");
|
||||
where_clauses.push(clause);
|
||||
params.push(SqlValue::Text(scope));
|
||||
}
|
||||
let scope_ids =
|
||||
combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref());
|
||||
push_library_scope_filter(&mut where_clauses, &mut params, &scope_ids);
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||
@@ -180,6 +195,7 @@ 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,
|
||||
@@ -205,6 +221,7 @@ mod tests {
|
||||
LibraryLosslessAlbumsRequest {
|
||||
server_id: server.into(),
|
||||
library_scope: None,
|
||||
library_scopes: None,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
@@ -273,6 +290,27 @@ mod tests {
|
||||
assert_eq!(resp.albums[0].id, "al1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_library_scope_includes_every_selected_library() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let mut a = track_with_suffix("s1", "t1", "al1", "A", "flac", 16);
|
||||
a.library_id = Some("lib1".into());
|
||||
let mut b = track_with_suffix("s1", "t2", "al2", "B", "flac", 16);
|
||||
b.library_id = Some("lib2".into());
|
||||
let mut c = track_with_suffix("s1", "t3", "al3", "C", "flac", 16);
|
||||
c.library_id = Some("lib3".into());
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[a, b, c])
|
||||
.unwrap();
|
||||
|
||||
let mut scoped = req("s1", 50, 0);
|
||||
scoped.library_scopes = Some(vec!["lib1".into(), "lib2".into()]);
|
||||
let resp = list_lossless_albums(&store, &scoped).unwrap();
|
||||
let mut ids: Vec<_> = resp.albums.iter().map(|a| a.id.clone()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["al1".to_string(), "al2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_sets_has_more() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Remove `artist` browse rows orphaned by server-side renames/removals.
|
||||
//!
|
||||
//! `getArtists` derives artist ids from tags, so renaming an artist mints a
|
||||
//! *new* id server-side and drops the old one. Track ingest moves the affected
|
||||
//! tracks onto the new id (or the resync orphan sweep / tombstone reconciler
|
||||
//! soft-deletes them), but the stale **`artist`** browse row was never pruned —
|
||||
//! it lingered in the catalog and opened to "Artist not found" because detail
|
||||
//! resolves from live tracks.
|
||||
//!
|
||||
//! **Authority.** A row is removed only when *both* signals agree:
|
||||
//! - it is **not confirmed by the latest `getArtists` pass** — its `synced_at`
|
||||
//! is older than the freshest `artist` row for the same server (every artist
|
||||
//! the pass returned was re-stamped `now`), and
|
||||
//! - **no live (`deleted = 0`) track credits it** (any album/track credit).
|
||||
//!
|
||||
//! Callers must only invoke the prune after a *confirmed* `getArtists` pass
|
||||
//! (`upsert_index` wrote ≥ 1 row): otherwise `backfill_from_tracks` can advance
|
||||
//! the freshest `synced_at` from a track alone, and an empty/partial `getArtists`
|
||||
//! would mass-prune album-artist-only rows. The sync call sites gate on that
|
||||
//! count; the one-time open reconcile only runs on the last authoritative
|
||||
//! snapshot already in the DB.
|
||||
//!
|
||||
//! **Album rows are intentionally not pruned here.** They have no `getArtists`
|
||||
//! equivalent to supply a freshness stamp (N1 ingest never stamps `album`, and
|
||||
//! several on-demand paths insert track-less rows), so a "no live track" delete
|
||||
//! is unsafe. Album-ghost cleanup needs its own positive-confirmation signal —
|
||||
//! tracked as a follow-up.
|
||||
//!
|
||||
//! **Scope.** Track matching is server-wide, which is correct for the default
|
||||
//! all-libraries sync. A session bound to a single `library_scope` is a
|
||||
//! pre-existing sweep hazard (it soft-deletes other libraries' tracks); this
|
||||
//! prune inherits that assumption and should only run for all-libraries syncs.
|
||||
|
||||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
/// Prune orphaned `artist` rows for one server (sync paths). Computes the
|
||||
/// freshest `synced_at` once (index-backed) and deletes rows below it with no
|
||||
/// live track. Caller must confirm `getArtists` returned ≥ 1 artist first.
|
||||
pub fn prune_orphan_artists_for_server(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
) -> Result<u32, String> {
|
||||
store.with_conn_mut("orphan_cleanup.prune_artists", |conn| {
|
||||
let cutoff: Option<i64> = conn.query_row(
|
||||
"SELECT MAX(synced_at) FROM artist WHERE server_id = ?1",
|
||||
params![server_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let Some(cutoff) = cutoff else { return Ok(0) };
|
||||
let removed = conn.execute(
|
||||
"DELETE FROM artist \
|
||||
WHERE server_id = ?1 AND synced_at < ?2 \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM track \
|
||||
WHERE track.server_id = artist.server_id \
|
||||
AND track.artist_id = artist.id \
|
||||
AND track.deleted = 0 \
|
||||
)",
|
||||
params![server_id, cutoff],
|
||||
)?;
|
||||
Ok(removed as u32)
|
||||
})
|
||||
}
|
||||
|
||||
/// Prune orphaned `artist` rows across **every** server (one-time open
|
||||
/// reconcile). Per-server freshest `synced_at` via a correlated subquery — runs
|
||||
/// once at open, not on the hot sync path.
|
||||
pub fn prune_orphan_artists_all(conn: &Connection) -> SqlResult<usize> {
|
||||
conn.execute(
|
||||
"DELETE FROM artist \
|
||||
WHERE synced_at < ( \
|
||||
SELECT MAX(ai.synced_at) FROM artist ai WHERE ai.server_id = artist.server_id \
|
||||
) \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM track \
|
||||
WHERE track.server_id = artist.server_id \
|
||||
AND track.artist_id = artist.id \
|
||||
AND track.deleted = 0 \
|
||||
)",
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seed_artist(store: &LibraryStore, server: &str, id: &str, synced_at: i64) {
|
||||
store
|
||||
.with_conn_mut("test.seed_artist", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO artist (server_id, id, name, name_sort, synced_at) \
|
||||
VALUES (?1, ?2, ?2, ?2, ?3)",
|
||||
params![server, id, synced_at],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_track(store: &LibraryStore, server: &str, id: &str, artist_id: &str, deleted: bool) {
|
||||
store
|
||||
.with_conn_mut("test.seed_track", |c| {
|
||||
c.execute(
|
||||
"INSERT INTO track (server_id, id, title, artist_id, album, \
|
||||
duration_sec, deleted, synced_at, raw_json) \
|
||||
VALUES (?1, ?2, 'Song', ?3, 'Al', 1, ?4, 1, '{}')",
|
||||
params![server, id, artist_id, i64::from(deleted)],
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn artist_ids(store: &LibraryStore, server: &str) -> Vec<String> {
|
||||
store
|
||||
.with_read_conn(|c| {
|
||||
let mut stmt = c
|
||||
.prepare("SELECT id FROM artist WHERE server_id = ?1 ORDER BY id")?;
|
||||
let rows = stmt
|
||||
.query_map(params![server], |r| r.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prunes_renamed_away_artist_but_keeps_live_and_confirmed() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
// ar_new: confirmed this pass (fresh synced_at) + live track → keep.
|
||||
seed_artist(&store, "s1", "ar_new", 100);
|
||||
seed_track(&store, "s1", "tr_1", "ar_new", false);
|
||||
// ar_old: stale synced_at, only a soft-deleted track (renamed away) → prune.
|
||||
seed_artist(&store, "s1", "ar_old", 50);
|
||||
seed_track(&store, "s1", "tr_1_old", "ar_old", true);
|
||||
// ar_scopeb: stale synced_at but still backs a live track → keep.
|
||||
seed_artist(&store, "s1", "ar_scopeb", 50);
|
||||
seed_track(&store, "s1", "tr_2", "ar_scopeb", false);
|
||||
|
||||
let removed = prune_orphan_artists_for_server(&store, "s1").unwrap();
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(artist_ids(&store, "s1"), vec!["ar_new", "ar_scopeb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_album_artist_confirmed_this_pass_without_track_credit() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
// Various-Artists style row: no track credits its id, but it was just
|
||||
// confirmed by getArtists (shares the freshest synced_at) → keep.
|
||||
seed_artist(&store, "s1", "ar_va", 100);
|
||||
seed_artist(&store, "s1", "ar_real", 100);
|
||||
seed_track(&store, "s1", "tr_1", "ar_real", false);
|
||||
|
||||
let removed = prune_orphan_artists_for_server(&store, "s1").unwrap();
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(artist_ids(&store, "s1"), vec!["ar_real", "ar_va"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_synced_at_equal_prunes_nothing() {
|
||||
// No authoritative "newer" pass to distinguish a ghost from a fresh
|
||||
// album-artist → cutoff equals every row, `synced_at < cutoff` is empty.
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_artist(&store, "s1", "ar_a", 100);
|
||||
seed_artist(&store, "s1", "ar_b", 100);
|
||||
|
||||
let removed = prune_orphan_artists_for_server(&store, "s1").unwrap();
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(artist_ids(&store, "s1"), vec!["ar_a", "ar_b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_server_scopes_prune_to_a_single_server() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_artist(&store, "s1", "ar_fresh", 100);
|
||||
seed_track(&store, "s1", "tr_1", "ar_fresh", false);
|
||||
seed_artist(&store, "s1", "ar_ghost", 50);
|
||||
seed_artist(&store, "s2", "ar_fresh2", 100);
|
||||
seed_track(&store, "s2", "tr_2", "ar_fresh2", false);
|
||||
seed_artist(&store, "s2", "ar_ghost2", 50);
|
||||
|
||||
let removed = prune_orphan_artists_for_server(&store, "s1").unwrap();
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(artist_ids(&store, "s1"), vec!["ar_fresh"]);
|
||||
assert_eq!(artist_ids(&store, "s2"), vec!["ar_fresh2", "ar_ghost2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_prune_sweeps_every_server() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_artist(&store, "s1", "ar_fresh", 100);
|
||||
seed_track(&store, "s1", "tr_1", "ar_fresh", false);
|
||||
seed_artist(&store, "s1", "ar_ghost", 50);
|
||||
seed_artist(&store, "s2", "ar_fresh2", 100);
|
||||
seed_track(&store, "s2", "tr_2", "ar_fresh2", false);
|
||||
seed_artist(&store, "s2", "ar_ghost2", 50);
|
||||
|
||||
let removed = store
|
||||
.with_conn_mut("test.all", |c| prune_orphan_artists_all(c))
|
||||
.unwrap();
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(artist_ids(&store, "s1"), vec!["ar_fresh"]);
|
||||
assert_eq!(artist_ids(&store, "s2"), vec!["ar_fresh2"]);
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,7 @@ 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,
|
||||
|
||||
@@ -34,6 +34,7 @@ fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, duration_se
|
||||
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,
|
||||
@@ -79,6 +80,7 @@ fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: if hash.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -503,6 +505,7 @@ fn recent_plays_includes_album_cover_metadata() {
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use rusqlite::{params, OptionalExtension, Transaction};
|
||||
use rusqlite::{params, params_from_iter, OptionalExtension, Transaction};
|
||||
|
||||
use crate::genre_tags::{self, genres_for_track_raw_json};
|
||||
use crate::store::{LibraryStore, WriteOpTiming};
|
||||
@@ -53,6 +53,7 @@ pub struct TrackRow {
|
||||
pub bpm: Option<i64>,
|
||||
pub replay_gain_track_db: Option<f64>,
|
||||
pub replay_gain_album_db: Option<f64>,
|
||||
pub replay_gain_peak: Option<f64>,
|
||||
pub content_hash: Option<String>,
|
||||
pub server_updated_at: Option<i64>,
|
||||
pub server_created_at: Option<i64>,
|
||||
@@ -150,6 +151,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.bpm,
|
||||
r.replay_gain_track_db,
|
||||
r.replay_gain_album_db,
|
||||
r.replay_gain_peak,
|
||||
r.content_hash,
|
||||
r.server_updated_at,
|
||||
r.server_created_at,
|
||||
@@ -189,6 +191,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.bpm,
|
||||
r.replay_gain_track_db,
|
||||
r.replay_gain_album_db,
|
||||
r.replay_gain_peak,
|
||||
r.content_hash,
|
||||
r.server_updated_at,
|
||||
r.server_created_at,
|
||||
@@ -426,6 +429,62 @@ impl<'a> TrackRepository<'a> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Live tracks with no `library_id` hot column (multi-library scope gap).
|
||||
pub fn count_untagged_tracks(&self, server_id: &str) -> Result<u64, String> {
|
||||
self.store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM track \
|
||||
WHERE server_id = ?1 AND deleted = 0 \
|
||||
AND (library_id IS NULL OR library_id = '')",
|
||||
params![server_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
})
|
||||
.map(|n| n.max(0) as u64)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Tag empty `library_id` rows by album membership. Only fills rows
|
||||
/// where `library_id` is NULL/empty so prior tags are never clobbered.
|
||||
pub fn tag_library_by_album_ids(
|
||||
&self,
|
||||
server_id: &str,
|
||||
library_id: &str,
|
||||
album_ids: &[String],
|
||||
) -> Result<u64, String> {
|
||||
if album_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
const CHUNK: usize = 400;
|
||||
let mut total = 0u64;
|
||||
self.store.with_conn_mut("track.tag_library_by_album_ids", |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for chunk in album_ids.chunks(CHUNK) {
|
||||
let placeholders = (0..chunk.len())
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let sql = format!(
|
||||
"UPDATE track SET library_id = ?1 \
|
||||
WHERE server_id = ?2 AND deleted = 0 \
|
||||
AND album_id IN ({placeholders}) \
|
||||
AND (library_id IS NULL OR library_id = '')"
|
||||
);
|
||||
let mut params: Vec<rusqlite::types::Value> = vec![
|
||||
rusqlite::types::Value::Text(library_id.to_string()),
|
||||
rusqlite::types::Value::Text(server_id.to_string()),
|
||||
];
|
||||
for id in chunk {
|
||||
params.push(id.clone().into());
|
||||
}
|
||||
let n = tx.execute(&sql, params_from_iter(params.iter()))?;
|
||||
total += n as u64;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(total)
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch upsert with optional §6.9 id-remap detection. When
|
||||
/// `unstable_track_ids` is `true`, each incoming row is checked
|
||||
/// against the existing `track` table for a collision via
|
||||
@@ -499,6 +558,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.bpm,
|
||||
r.replay_gain_track_db,
|
||||
r.replay_gain_album_db,
|
||||
r.replay_gain_peak,
|
||||
r.content_hash,
|
||||
r.server_updated_at,
|
||||
r.server_created_at,
|
||||
@@ -673,27 +733,27 @@ const TRACK_COLUMNS: &str = "\
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, \
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm, \
|
||||
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, \
|
||||
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at, \
|
||||
server_created_at, deleted, synced_at, raw_json";
|
||||
|
||||
const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE id = ?1 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 \
|
||||
ORDER BY disc_number ASC NULLS LAST, track_number ASC NULLS LAST, id ASC";
|
||||
@@ -733,12 +793,13 @@ pub(crate) fn row_to_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Trac
|
||||
bpm: row.get(26)?,
|
||||
replay_gain_track_db: row.get(27)?,
|
||||
replay_gain_album_db: row.get(28)?,
|
||||
content_hash: row.get(29)?,
|
||||
server_updated_at: row.get(30)?,
|
||||
server_created_at: row.get(31)?,
|
||||
deleted: row.get::<_, i64>(32)? != 0,
|
||||
synced_at: row.get(33)?,
|
||||
raw_json: row.get(34)?,
|
||||
replay_gain_peak: row.get(29)?,
|
||||
content_hash: row.get(30)?,
|
||||
server_updated_at: row.get(31)?,
|
||||
server_created_at: row.get(32)?,
|
||||
deleted: row.get::<_, i64>(33)? != 0,
|
||||
synced_at: row.get(34)?,
|
||||
raw_json: row.get(35)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -748,60 +809,8 @@ INSERT INTO track (
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm,
|
||||
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
|
||||
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
|
||||
server_created_at, deleted, synced_at, raw_json
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
|
||||
?33, ?34, ?35
|
||||
)
|
||||
ON CONFLICT(server_id, id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
title_sort = excluded.title_sort,
|
||||
artist = excluded.artist,
|
||||
artist_id = excluded.artist_id,
|
||||
album = excluded.album,
|
||||
album_id = excluded.album_id,
|
||||
album_artist = excluded.album_artist,
|
||||
duration_sec = excluded.duration_sec,
|
||||
track_number = excluded.track_number,
|
||||
disc_number = excluded.disc_number,
|
||||
year = excluded.year,
|
||||
genre = excluded.genre,
|
||||
suffix = excluded.suffix,
|
||||
bit_rate = excluded.bit_rate,
|
||||
size_bytes = excluded.size_bytes,
|
||||
cover_art_id = excluded.cover_art_id,
|
||||
starred_at = excluded.starred_at,
|
||||
user_rating = excluded.user_rating,
|
||||
play_count = excluded.play_count,
|
||||
played_at = excluded.played_at,
|
||||
server_path = excluded.server_path,
|
||||
library_id = excluded.library_id,
|
||||
isrc = excluded.isrc,
|
||||
mbid_recording = excluded.mbid_recording,
|
||||
bpm = excluded.bpm,
|
||||
replay_gain_track_db = excluded.replay_gain_track_db,
|
||||
replay_gain_album_db = excluded.replay_gain_album_db,
|
||||
-- E2: never let a sync (which passes NULL content_hash) clobber the
|
||||
-- playback-derived md5_16kb written via library_patch_track / the analysis
|
||||
-- bridge. A non-empty incoming hash still wins.
|
||||
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
|
||||
server_updated_at = excluded.server_updated_at,
|
||||
server_created_at = excluded.server_created_at,
|
||||
deleted = excluded.deleted,
|
||||
synced_at = excluded.synced_at,
|
||||
raw_json = excluded.raw_json
|
||||
"#;
|
||||
|
||||
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
|
||||
INSERT INTO track (
|
||||
server_id, id, title, title_sort, artist, artist_id, album, album_id,
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm,
|
||||
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
|
||||
server_created_at, deleted, synced_at, raw_json, resync_gen
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
|
||||
@@ -829,12 +838,71 @@ ON CONFLICT(server_id, id) DO UPDATE SET
|
||||
play_count = excluded.play_count,
|
||||
played_at = excluded.played_at,
|
||||
server_path = excluded.server_path,
|
||||
library_id = excluded.library_id,
|
||||
-- P20: never let a sync path that omits library membership (OpenSubsonic
|
||||
-- whole-server search3/getAlbumList2 carry no libraryId) clobber a library_id
|
||||
-- previously captured by a scoped / Navidrome-native sync back to NULL —
|
||||
-- that silently erases multi-library scope tagging. A non-empty incoming id wins.
|
||||
library_id = COALESCE(NULLIF(excluded.library_id, ''), track.library_id),
|
||||
isrc = excluded.isrc,
|
||||
mbid_recording = excluded.mbid_recording,
|
||||
bpm = excluded.bpm,
|
||||
replay_gain_track_db = excluded.replay_gain_track_db,
|
||||
replay_gain_album_db = excluded.replay_gain_album_db,
|
||||
replay_gain_peak = excluded.replay_gain_peak,
|
||||
-- E2: never let a sync (which passes NULL content_hash) clobber the
|
||||
-- playback-derived md5_16kb written via library_patch_track / the analysis
|
||||
-- bridge. A non-empty incoming hash still wins.
|
||||
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
|
||||
server_updated_at = excluded.server_updated_at,
|
||||
server_created_at = excluded.server_created_at,
|
||||
deleted = excluded.deleted,
|
||||
synced_at = excluded.synced_at,
|
||||
raw_json = excluded.raw_json
|
||||
"#;
|
||||
|
||||
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
|
||||
INSERT INTO track (
|
||||
server_id, id, title, title_sort, artist, artist_id, album, album_id,
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm,
|
||||
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
|
||||
server_created_at, deleted, synced_at, raw_json, resync_gen
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
|
||||
?33, ?34, ?35, ?36, ?37
|
||||
)
|
||||
ON CONFLICT(server_id, id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
title_sort = excluded.title_sort,
|
||||
artist = excluded.artist,
|
||||
artist_id = excluded.artist_id,
|
||||
album = excluded.album,
|
||||
album_id = excluded.album_id,
|
||||
album_artist = excluded.album_artist,
|
||||
duration_sec = excluded.duration_sec,
|
||||
track_number = excluded.track_number,
|
||||
disc_number = excluded.disc_number,
|
||||
year = excluded.year,
|
||||
genre = excluded.genre,
|
||||
suffix = excluded.suffix,
|
||||
bit_rate = excluded.bit_rate,
|
||||
size_bytes = excluded.size_bytes,
|
||||
cover_art_id = excluded.cover_art_id,
|
||||
starred_at = excluded.starred_at,
|
||||
user_rating = excluded.user_rating,
|
||||
play_count = excluded.play_count,
|
||||
played_at = excluded.played_at,
|
||||
server_path = excluded.server_path,
|
||||
-- P20: preserve prior library_id when a sync path omits it (see UPSERT above).
|
||||
library_id = COALESCE(NULLIF(excluded.library_id, ''), track.library_id),
|
||||
isrc = excluded.isrc,
|
||||
mbid_recording = excluded.mbid_recording,
|
||||
bpm = excluded.bpm,
|
||||
replay_gain_track_db = excluded.replay_gain_track_db,
|
||||
replay_gain_album_db = excluded.replay_gain_album_db,
|
||||
replay_gain_peak = excluded.replay_gain_peak,
|
||||
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
|
||||
server_updated_at = excluded.server_updated_at,
|
||||
server_created_at = excluded.server_created_at,
|
||||
@@ -887,6 +955,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: Some("hash-abc".into()),
|
||||
server_updated_at: Some(1_700_000_000),
|
||||
server_created_at: Some(1_699_000_000),
|
||||
@@ -983,6 +1052,100 @@ mod tests {
|
||||
assert_eq!(read(&store).as_deref(), Some("server-hash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resync_does_not_clobber_library_id_when_incoming_is_empty() {
|
||||
// P20: a Navidrome-native / scoped sync tags a track with library_id, then
|
||||
// a whole-server OpenSubsonic resync (no libraryId) must not wipe it — that
|
||||
// is what silently emptied multi-library scope on large servers.
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
|
||||
let mut tagged = row("s1", "t1", "First");
|
||||
tagged.library_id = Some("1".into());
|
||||
repo.upsert_batch(&[tagged]).unwrap();
|
||||
|
||||
let read = |store: &LibraryStore| -> Option<String> {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
c.query_row(
|
||||
"SELECT library_id FROM track WHERE server_id='s1' AND id='t1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// OpenSubsonic resync carries no library membership.
|
||||
let mut none_scope = row("s1", "t1", "First (resynced, no lib)");
|
||||
none_scope.library_id = None;
|
||||
repo.upsert_batch(&[none_scope]).unwrap();
|
||||
assert_eq!(read(&store).as_deref(), Some("1"));
|
||||
|
||||
// Empty-string is treated the same as NULL.
|
||||
let mut empty_scope = row("s1", "t1", "First (resynced, empty lib)");
|
||||
empty_scope.library_id = Some(String::new());
|
||||
repo.upsert_batch(&[empty_scope]).unwrap();
|
||||
assert_eq!(read(&store).as_deref(), Some("1"));
|
||||
|
||||
// A genuine library move (non-empty id) still wins.
|
||||
let mut moved = row("s1", "t1", "First");
|
||||
moved.library_id = Some("2".into());
|
||||
repo.upsert_batch(&[moved]).unwrap();
|
||||
assert_eq!(read(&store).as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_library_by_album_ids_fills_only_empty_rows_and_chunks() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
let mut tagged = row("s1", "t1", "First");
|
||||
tagged.library_id = Some("9".into());
|
||||
tagged.album_id = Some("al1".into());
|
||||
let mut empty = row("s1", "t2", "Second");
|
||||
empty.album_id = Some("al1".into());
|
||||
empty.library_id = None;
|
||||
let mut other_album = row("s1", "t3", "Third");
|
||||
other_album.album_id = Some("al2".into());
|
||||
other_album.library_id = None;
|
||||
repo.upsert_batch(&[tagged, empty, other_album]).unwrap();
|
||||
|
||||
let n = repo
|
||||
.tag_library_by_album_ids("s1", "1", &["al1".into(), "al2".into()])
|
||||
.unwrap();
|
||||
assert_eq!(n, 2);
|
||||
|
||||
let read = |id: &str| -> Option<String> {
|
||||
store
|
||||
.with_read_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT library_id FROM track WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(read("t1").as_deref(), Some("9"));
|
||||
assert_eq!(read("t2").as_deref(), Some("1"));
|
||||
assert_eq!(read("t3").as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_untagged_tracks_excludes_deleted_and_populated_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let repo = TrackRepository::new(&store);
|
||||
let mut tagged = row("s1", "t1", "First");
|
||||
tagged.library_id = Some("1".into());
|
||||
let mut empty = row("s1", "t2", "Second");
|
||||
empty.library_id = None;
|
||||
let mut deleted = row("s1", "t3", "Third");
|
||||
deleted.library_id = None;
|
||||
deleted.deleted = true;
|
||||
repo.upsert_batch(&[tagged, empty, deleted]).unwrap();
|
||||
assert_eq!(repo.count_untagged_tracks("s1").unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_inserts_new_rows() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user