mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
385 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 6b1a898151 | |||
| 19860672eb | |||
| 2b80a87bfc | |||
| fcdb58e0d8 | |||
| 184501744b | |||
| 281e86fd3b | |||
| 979eb85ad1 | |||
| 604cdd54d6 | |||
| 6d4d82d6a3 | |||
| 7e6a2100e5 | |||
| ed02ba4e1d | |||
| c609beddfa | |||
| d70060923b | |||
| d5ad275851 | |||
| c59bf937e4 | |||
| 86ae462ad6 | |||
| 014d57c53c | |||
| afe5b377e0 | |||
| eb42a32315 | |||
| cea814e993 | |||
| 00512df207 | |||
| d49424e95b | |||
| 8b89596fcf | |||
| 4aa6427727 | |||
| a88d5f3181 | |||
| b6ac879b14 | |||
| 4e8f16b8ae | |||
| 7b9e676af7 | |||
| ec98fcc4ff | |||
| 1031590742 | |||
| 9bbe69e7e7 | |||
| 58e6efc68c | |||
| c3e6be537c | |||
| 09f24beb32 | |||
| 0b7b5df30c | |||
| de36f79e46 | |||
| 53a4bf9330 | |||
| 7c724a642f | |||
| c7d76af790 | |||
| 78a177b1bc | |||
| 9b03949f34 | |||
| a4fbd45d5d | |||
| 15cecb5d7d | |||
| 2c9b2eeb46 | |||
| bd43ea5240 | |||
| 9a31fe8295 | |||
| 9323f5fee6 | |||
| d162c2557b | |||
| 93166c774a | |||
| e69b89b28a | |||
| ec8cfbc1c8 | |||
| 887c940e1b | |||
| d9969ed76f | |||
| 986550c854 | |||
| 30ccaf51a8 | |||
| 538dbe3db1 | |||
| a5313d5cb1 | |||
| b950d4704b | |||
| 23f8008248 | |||
| b9b4f76c11 | |||
| d8e5d4eed4 | |||
| 2d1a078186 | |||
| c037ab459a | |||
| 955a9fcbd6 | |||
| c428d37e0e | |||
| 4225146a16 | |||
| d50c9c444d | |||
| 99c0b6cdac | |||
| ee044ece1a | |||
| fde7ab432f | |||
| f28e82c022 | |||
| 0f580f58c8 | |||
| a6ee0668c8 | |||
| ed52a9991f | |||
| ad74578ef6 | |||
| ccb2d11fc4 | |||
| 44d373d7bb | |||
| 116196f0d4 | |||
| 68b21643f8 | |||
| 8498d5a566 | |||
| 3ec65a6407 | |||
| 47b09d6f25 | |||
| 1e956d6043 | |||
| 82967caa9c | |||
| a6122f9db4 | |||
| 6d63365c2a | |||
| 6168e81195 | |||
| 067ed00ae2 | |||
| 16e562b42d | |||
| 961dba996c | |||
| 4fd558fa28 | |||
| 42dcbb9323 | |||
| 997e697a53 | |||
| 4c0dfaaada | |||
| e563749ace | |||
| 15fb0f6c56 | |||
| 6d404fdc2d | |||
| c453f01b94 | |||
| 07232cea9a | |||
| 6f555bdc96 | |||
| c1403f8bd6 | |||
| 0b7d9eae2d | |||
| 41c8187186 | |||
| 028eb65f7d | |||
| be3f1dc299 | |||
| 52fbb33b00 | |||
| 947711a98d | |||
| 891ab0dd5b | |||
| abc2c0b579 | |||
| 184e87a469 | |||
| 80822fd742 | |||
| 4902c0e25b | |||
| 3de7b57cc5 | |||
| 1a82376f8c | |||
| ea304357ca | |||
| 90452a8f8c | |||
| c503226b0a | |||
| 5cd01c90ac | |||
| 8593858f3a | |||
| c7d71ea57c | |||
| fb5a257735 | |||
| 707a41f615 | |||
| ae9be74719 | |||
| 36a0615dcb | |||
| 98bdf310d6 | |||
| 155ef88cc0 | |||
| 316c99ba07 | |||
| c33d1e64c5 | |||
| 276903d0af | |||
| a151cf5deb | |||
| ad8e376c9c | |||
| b2a5baa48d | |||
| cfc9419de7 | |||
| c6298d8c25 | |||
| 37089ea0f1 | |||
| 6118b3940f | |||
| 0878cbf308 | |||
| 68f0f09aae | |||
| 1b4fb9e9b3 | |||
| 5167d8f49e | |||
| 086c7e43b4 | |||
| 32832246c0 | |||
| e527cfe67f | |||
| 30e9db1a2b | |||
| 49ad3618a8 | |||
| 4148e063dd | |||
| 88f7a7bc90 | |||
| daa6fbbfd7 | |||
| aabd342a64 | |||
| fc34a0ec59 | |||
| f4e1086131 | |||
| 23f032b274 | |||
| aad1a6c3f0 | |||
| 10fc489ce9 | |||
| b3573e7107 | |||
| 9fb0d5638b | |||
| f9df918c72 | |||
| 03a1ba9582 | |||
| 2d3c723a6e | |||
| 40db6b08d2 | |||
| a66d932afe | |||
| f706336e58 | |||
| 1c23305887 | |||
| 41157ccaca | |||
| dc4eef1a97 | |||
| c674e4515b | |||
| c39465dfad | |||
| 5f345dc7aa | |||
| d1320ea2c8 | |||
| 8ca16c1971 | |||
| 7a2f937984 | |||
| 76dd5c2087 | |||
| 2f50a1bade | |||
| ec2bee1400 | |||
| ca502ad833 | |||
| 6da98e476b |
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
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
|
||||
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
|
||||
*/
|
||||
|
||||
const FRONTEND_PATH_RE =
|
||||
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.dependency-cruiser\.cjs$|\.dependency-cruiser-known-violations\.json$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
|
||||
|
||||
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
|
||||
|
||||
const FRONTEND_JOBS = [
|
||||
'vitest run',
|
||||
'tsc --noEmit',
|
||||
'vitest --coverage (baseline + hot-path file gate)',
|
||||
'eslint',
|
||||
'dependency-cruiser',
|
||||
];
|
||||
|
||||
const RUST_JOBS = [
|
||||
'cargo test --workspace',
|
||||
'cargo clippy --workspace',
|
||||
'cargo llvm-cov (baseline + hot-path file gate)',
|
||||
];
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
const TIMEOUT_MS = 90 * 60 * 1000;
|
||||
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
|
||||
|
||||
/**
|
||||
* GitHub API hiccups (5xx, secondary rate limits, dropped connections) must
|
||||
* not fail the gate — the answer is to poll again, not to go red while the
|
||||
* real jobs are green. 4xx config errors (401/404 …) still throw.
|
||||
*/
|
||||
export function isTransientApiError(err) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 0;
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function withTransientRetry(label, fn, core, attempts = 5, delayMs = POLL_MS) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err) || attempt >= attempts) {
|
||||
throw err;
|
||||
}
|
||||
// err.message can be a whole HTML error page — log only the status.
|
||||
core.info(
|
||||
`${label}: transient API error (status=${err?.status ?? 'network'}), retry ${attempt}/${attempts - 1} in ${delayMs / 1000}s`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pathTriggersFrontend(file) {
|
||||
return FRONTEND_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function pathTriggersRust(file) {
|
||||
return RUST_PATH_RE.test(file);
|
||||
}
|
||||
|
||||
export function requiredJobNames(changedFiles) {
|
||||
const names = [];
|
||||
if (changedFiles.some(pathTriggersFrontend)) {
|
||||
names.push(...FRONTEND_JOBS);
|
||||
}
|
||||
if (changedFiles.some(pathTriggersRust)) {
|
||||
names.push(...RUST_JOBS);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function newestChecksByName(checks, excludeRunId) {
|
||||
const newest = new Map();
|
||||
for (const check of checks) {
|
||||
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
|
||||
continue;
|
||||
}
|
||||
const key = check.name;
|
||||
const prev = newest.get(key);
|
||||
if (!prev) {
|
||||
newest.set(key, check);
|
||||
continue;
|
||||
}
|
||||
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
|
||||
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
|
||||
if (curTime >= prevTime) {
|
||||
newest.set(key, check);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function evaluateRequiredJobs(required, newestByName) {
|
||||
const pending = [];
|
||||
const failures = [];
|
||||
|
||||
for (const name of required) {
|
||||
const latest = newestByName.get(name);
|
||||
if (!latest) {
|
||||
pending.push(`${name}: not started`);
|
||||
continue;
|
||||
}
|
||||
if (latest.status !== 'completed') {
|
||||
pending.push(`${name}: status=${latest.status}`);
|
||||
continue;
|
||||
}
|
||||
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
|
||||
failures.push(`${name}: conclusion=${latest.conclusion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
|
||||
}
|
||||
|
||||
export async function listChangedFiles(github, context) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (context.eventName === 'pull_request') {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
if (context.eventName === 'push') {
|
||||
const before = context.payload.before;
|
||||
const after = context.sha;
|
||||
if (!before || /^0+$/.test(before)) {
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
const compare = await github.rest.repos.compareCommits({
|
||||
owner,
|
||||
repo,
|
||||
base: before,
|
||||
head: after,
|
||||
});
|
||||
return compare.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (pr?.number) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
|
||||
return commit.data.files?.map((f) => f.filename) ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function resolveTargetSha(context) {
|
||||
if (context.eventName === 'pull_request') {
|
||||
return context.payload.pull_request.head.sha;
|
||||
}
|
||||
if (context.eventName === 'workflow_run') {
|
||||
return context.payload.workflow_run.head_sha;
|
||||
}
|
||||
return context.sha;
|
||||
}
|
||||
|
||||
export async function runCiOkAggregate(github, context, core) {
|
||||
const { owner, repo } = context.repo;
|
||||
const sha = resolveTargetSha(context);
|
||||
const excludeRunId = String(context.runId);
|
||||
const changedFiles = await withTransientRetry(
|
||||
'listChangedFiles',
|
||||
() => listChangedFiles(github, context),
|
||||
core,
|
||||
);
|
||||
const required = requiredJobNames(changedFiles);
|
||||
|
||||
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
|
||||
if (required.length === 0) {
|
||||
core.info('No path-filtered test workflows apply — ci-ok passes.');
|
||||
return;
|
||||
}
|
||||
core.info(`Waiting for required job checks: ${required.join(', ')}`);
|
||||
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
let checksAll;
|
||||
try {
|
||||
checksAll = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isTransientApiError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Same as an inconclusive poll: wait out the hiccup, the 90-minute
|
||||
// deadline stays the backstop.
|
||||
core.info(`checks.listForRef: transient API error (status=${err?.status ?? 'network'}) — retrying next poll`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
continue;
|
||||
}
|
||||
const newestByName = newestChecksByName(checksAll, excludeRunId);
|
||||
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
|
||||
|
||||
if (failures.length > 0) {
|
||||
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
core.info('All required checks are green.');
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
||||
}
|
||||
|
||||
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
evaluateRequiredJobs,
|
||||
isTransientApiError,
|
||||
newestChecksByName,
|
||||
pathTriggersFrontend,
|
||||
pathTriggersRust,
|
||||
requiredJobNames,
|
||||
withTransientRetry,
|
||||
} from './ci-ok-aggregate.mjs';
|
||||
|
||||
test('pathTriggersFrontend matches frontend workflow paths', () => {
|
||||
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
|
||||
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
|
||||
assert.equal(pathTriggersFrontend('README.md'), false);
|
||||
});
|
||||
|
||||
test('pathTriggersRust matches rust workflow paths', () => {
|
||||
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
|
||||
assert.equal(pathTriggersRust('src/App.tsx'), false);
|
||||
});
|
||||
|
||||
test('requiredJobNames unions frontend and rust jobs', () => {
|
||||
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
|
||||
assert.ok(names.includes('eslint'));
|
||||
assert.ok(names.includes('cargo test --workspace'));
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs fails on red conclusions', () => {
|
||||
const newest = newestChecksByName([
|
||||
{
|
||||
name: 'eslint',
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
},
|
||||
]);
|
||||
const result = evaluateRequiredJobs(['eslint'], newest);
|
||||
assert.equal(result.done, false);
|
||||
assert.equal(result.failures.length, 1);
|
||||
});
|
||||
|
||||
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
|
||||
const checks = ['eslint', 'vitest run'].map((name) => ({
|
||||
name,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
details_url: '',
|
||||
}));
|
||||
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
|
||||
assert.equal(result.done, true);
|
||||
});
|
||||
|
||||
test('isTransientApiError treats 5xx, 429 and network errors as transient', () => {
|
||||
assert.equal(isTransientApiError({ status: 503 }), true);
|
||||
assert.equal(isTransientApiError({ status: 500 }), true);
|
||||
assert.equal(isTransientApiError({ status: 429 }), true);
|
||||
assert.equal(isTransientApiError(new Error('socket hang up')), true);
|
||||
assert.equal(isTransientApiError({ status: 404 }), false);
|
||||
assert.equal(isTransientApiError({ status: 401 }), false);
|
||||
});
|
||||
|
||||
const silentCore = { info: () => {} };
|
||||
|
||||
test('withTransientRetry retries transient errors and returns the late success', async () => {
|
||||
let calls = 0;
|
||||
const result = await withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('withTransientRetry rethrows non-transient errors immediately', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
5,
|
||||
1,
|
||||
),
|
||||
/not found/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('withTransientRetry gives up after the attempt budget', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withTransientRetry(
|
||||
'test',
|
||||
async () => {
|
||||
calls += 1;
|
||||
const err = new Error('unavailable');
|
||||
err.status = 503;
|
||||
throw err;
|
||||
},
|
||||
silentCore,
|
||||
3,
|
||||
1,
|
||||
),
|
||||
/unavailable/,
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
@@ -1,15 +1,30 @@
|
||||
name: ci-main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_run:
|
||||
workflows: [frontend-tests, rust-tests, eslint]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
ci-ok:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ci sentinel
|
||||
run: echo "main CI sentinel is green"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
|
||||
- name: aggregate required checks
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
|
||||
await runCiOkAggregate(github, context, core);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: eslint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.dependency-cruiser.cjs'
|
||||
- '.dependency-cruiser-known-violations.json'
|
||||
- '.github/workflows/eslint.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.dependency-cruiser.cjs'
|
||||
- '.dependency-cruiser-known-violations.json'
|
||||
- '.github/workflows/eslint.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
- 'scripts/check-css-import-graph.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
eslint:
|
||||
name: eslint
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: eslint
|
||||
run: npm run lint
|
||||
|
||||
dependency-cruiser:
|
||||
name: dependency-cruiser
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: layering + cycle guard
|
||||
run: npm run dep:check
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
@@ -23,6 +24,7 @@ on:
|
||||
- 'vitest.config.ts'
|
||||
- 'vite.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
- '.github/frontend-hot-path-files.txt'
|
||||
- 'scripts/check-frontend-hot-path-coverage.sh'
|
||||
@@ -56,6 +58,7 @@ jobs:
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
@@ -71,6 +74,7 @@ jobs:
|
||||
- name: install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
- run: npm ci
|
||||
- run: npm run prebuild:release-notes
|
||||
- name: vitest run --coverage
|
||||
run: npx vitest run --coverage
|
||||
- name: hot-path file coverage gate
|
||||
|
||||
@@ -176,18 +176,32 @@ jobs:
|
||||
- name: extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$BODY" ]; then
|
||||
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
|
||||
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
|
||||
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
fi
|
||||
BODY=""
|
||||
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
|
||||
BODY="$(cat /tmp/changelog-body.md)"
|
||||
fi
|
||||
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "$BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
|
||||
- name: extract what's new for release asset
|
||||
id: whats-new
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
CHANNEL="${{ inputs.channel }}"
|
||||
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
|
||||
if [ "$CHANNEL" = "release" ]; then
|
||||
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
- name: create or update release
|
||||
id: create-release
|
||||
uses: actions/github-script@v9
|
||||
@@ -237,6 +251,14 @@ jobs:
|
||||
prerelease,
|
||||
});
|
||||
return data.id;
|
||||
- name: upload whats-new.md release asset
|
||||
if: steps.whats-new.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_TAG="${{ steps.tag.outputs.value }}"
|
||||
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
|
||||
|
||||
build-macos-windows:
|
||||
if: ${{ inputs.build_platform_artifacts }}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Derive winget version from app-v* tag
|
||||
id: ver
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Submit to WinGet Community Repository
|
||||
uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Psychotoxical.Psysonic
|
||||
version: ${{ steps.ver.outputs.value }}
|
||||
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
@@ -37,6 +37,13 @@ src-tauri/lcov.info
|
||||
# Frontend test coverage
|
||||
coverage/
|
||||
|
||||
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs).
|
||||
# Ignore the contents (not the dir) so the committed tauri-specta FE↔BE contract
|
||||
# snapshot below can be re-included — a `!` exception cannot escape an ignored dir.
|
||||
src/generated/*
|
||||
# ...except the committed tauri-specta contract snapshot, so CI diffs catch drift.
|
||||
!src/generated/bindings.ts
|
||||
|
||||
# Documentation
|
||||
CLAUDE.md
|
||||
|
||||
|
||||
+1457
-116
File diff suppressed because it is too large
Load Diff
+28
-3
@@ -73,7 +73,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
|
||||
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
|
||||
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
|
||||
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
|
||||
@@ -84,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:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
|
||||
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,15 +109,36 @@ 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, etc.): `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.
|
||||
|
||||
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
|
||||
|
||||
---
|
||||
@@ -129,7 +151,10 @@ 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
|
||||
|
||||
+13
-7
@@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
|
||||
### Your Subsonic / Navidrome server
|
||||
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
|
||||
|
||||
### Last.fm
|
||||
If you connect a Last.fm account in Settings, Psysonic sends:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album)
|
||||
- **Love / Unlove** — when you mark a track as loved or unloved
|
||||
### Music Network (scrobble & enrichment services)
|
||||
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
|
||||
|
||||
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
|
||||
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
|
||||
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
|
||||
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
|
||||
|
||||
To each connected service, Psysonic may send:
|
||||
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
|
||||
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
|
||||
- **Love / Unlove** — when you mark a track as loved, on services that support it
|
||||
|
||||
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
|
||||
|
||||
### LRCLIB (Lyrics)
|
||||
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
|
||||
@@ -37,7 +43,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th
|
||||
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
|
||||
|
||||
- Server profiles (URL, username, password)
|
||||
- Last.fm session key
|
||||
- Scrobble service credentials (session keys / API tokens)
|
||||
- Playback preferences, themes, keybindings, and all other settings
|
||||
- Synced device manifests
|
||||
|
||||
|
||||
@@ -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://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 and Chinese.
|
||||
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese, Hungarian and Polish.
|
||||
|
||||
More translations are added over time.
|
||||
|
||||
@@ -32,104 +32,148 @@ More translations are added over time.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
|
||||
|
||||
## What is Psysonic?
|
||||
|
||||
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -137,7 +181,7 @@ Start a session, invite others with a link and listen together with host-control
|
||||
|
||||
| OS | Support |
|
||||
| ------- | --------------------------------------------------------------- |
|
||||
| Windows | Native installer |
|
||||
| Windows | Native installer / WinGet |
|
||||
| macOS | Signed DMG |
|
||||
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
|
||||
|
||||
@@ -157,7 +201,14 @@ Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
|
||||
|
||||
## Windows
|
||||
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
|
||||
or,
|
||||
install via Windows Package Manager (WinGet):
|
||||
```powershell
|
||||
winget install Psysonic
|
||||
```
|
||||
|
||||
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
|
||||
|
||||
## macOS
|
||||
|
||||
@@ -197,6 +248,12 @@ See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVA
|
||||
|
||||
---
|
||||
|
||||
# Reviews
|
||||
|
||||
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
|
||||
|
||||
---
|
||||
|
||||
# Community & Support
|
||||
|
||||
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
|
||||
|
||||
@@ -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.
|
||||
@@ -49,6 +51,7 @@ Rules:
|
||||
|
||||
### Step B: Promote to RC (`next`)
|
||||
|
||||
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
|
||||
1. Run workflow: **Promote main to next**.
|
||||
2. Workflow behavior:
|
||||
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
|
||||
|
||||
@@ -36,6 +36,7 @@ Some Psysonic features can communicate with external services, such as:
|
||||
- Last.fm
|
||||
- Bandsintown
|
||||
- Discord Rich Presence
|
||||
- Fanart.tv
|
||||
|
||||
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
|
||||
|
||||
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
# What's New
|
||||
|
||||
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
|
||||
current line before promoting to `next` / `release`. Technical details and PR credits stay in
|
||||
`CHANGELOG.md`.
|
||||
|
||||
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
|
||||
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
|
||||
|
||||
|
||||
## [1.50.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Multi-library filter — browse across your libraries
|
||||
|
||||
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
|
||||
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
|
||||
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
|
||||
|
||||
### Navidrome public share links — listen without logging in
|
||||
|
||||
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
|
||||
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
|
||||
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
|
||||
|
||||
### Fullscreen player — Minimal, Immersive, and Prism
|
||||
|
||||
- **Settings → Appearance → Fullscreen player style** now offers three looks: **Minimal** (the current sharp view), **Immersive** (artist photo/backdrop with rail or Apple-style scrolling lyrics), and **Prism** (full-bleed artist backdrop, glass lyrics panel, and a single glass control bar).
|
||||
- In Immersive, **Show artist photo** and **Photo dimming** are configurable; Prism drives progress and the active lyric line from the cover-derived accent colour.
|
||||
|
||||
### Lyrics that follow the singer, word by word
|
||||
|
||||
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
|
||||
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
|
||||
|
||||
### Player bar — build your own, plus shuffle
|
||||
|
||||
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
|
||||
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
|
||||
|
||||
### Track lists — optional album cover thumbnails
|
||||
|
||||
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
|
||||
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
|
||||
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
|
||||
|
||||
### Discord — Server cover art without exposing your login
|
||||
|
||||
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
|
||||
|
||||
### Artists browse — album artists or track credits
|
||||
|
||||
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
|
||||
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
|
||||
|
||||
### Theme Store — what's new on each theme
|
||||
|
||||
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
|
||||
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
|
||||
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
|
||||
|
||||
### Italian and Bulgarian — now in your language
|
||||
|
||||
- Psysonic is now available in **Italian (Italiano)** and **Bulgarian (Български)** — pick either from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Start minimized to tray
|
||||
|
||||
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
|
||||
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
|
||||
|
||||
### Square corners — a sharper, boxier look
|
||||
|
||||
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
|
||||
|
||||
## Improved
|
||||
|
||||
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
|
||||
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
|
||||
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
|
||||
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` at `false` while the server is reachable — the app confirms with a real server probe instead.
|
||||
- When browsing offline, Artists, Albums, Tracks, and Genres list only content with on-disk bytes — pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
|
||||
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
|
||||
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
|
||||
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
|
||||
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
|
||||
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
|
||||
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
|
||||
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
|
||||
|
||||
### Other
|
||||
|
||||
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
|
||||
- Modal dialogs now announce their title to screen readers when they open.
|
||||
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
|
||||
|
||||
|
||||
## [1.49.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Play queue sync — pick up where you left off on another device
|
||||
|
||||
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
|
||||
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
|
||||
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
|
||||
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
|
||||
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
|
||||
|
||||
### AutoDJ — minimum pauses, maximum music
|
||||
|
||||
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
|
||||
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
|
||||
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 2–30 s) under **Settings → Audio → Track transitions**.
|
||||
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
|
||||
|
||||
### Playlist folders — your playlists, organised
|
||||
|
||||
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
|
||||
|
||||
### Settings — tidier and easier to scan
|
||||
|
||||
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
|
||||
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
|
||||
|
||||
### Japanese, Hungarian, and Polish — now in your language
|
||||
|
||||
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
|
||||
|
||||
### Theme store — spot updates, pick your style
|
||||
|
||||
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
|
||||
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
|
||||
|
||||
### Hi-Res playback — smoother transitions between sample rates
|
||||
|
||||
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
|
||||
|
||||
### Artist artwork — richer home, artist, and fullscreen views
|
||||
|
||||
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
|
||||
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
|
||||
|
||||
### Equalizer — a profile per output device
|
||||
|
||||
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
|
||||
|
||||
### Orbit — everyone hears transitions the host chose
|
||||
|
||||
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
|
||||
|
||||
### Themes — follow your system's light and dark mode
|
||||
|
||||
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
|
||||
|
||||
### Servers behind a reverse proxy — custom HTTP headers
|
||||
|
||||
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
|
||||
|
||||
### Album details — every genre, not just the first
|
||||
|
||||
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
|
||||
|
||||
### Compact buttons — switch to icon-only controls
|
||||
|
||||
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
|
||||
|
||||
### Playlists — sort by date added
|
||||
|
||||
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
|
||||
|
||||
## Improved
|
||||
|
||||
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
|
||||
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
|
||||
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
|
||||
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
|
||||
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
|
||||
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
|
||||
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
|
||||
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
|
||||
- The **Artists** A–Z index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
|
||||
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
|
||||
|
||||
### Player and playlists
|
||||
|
||||
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
|
||||
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
|
||||
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
|
||||
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
|
||||
|
||||
### Other
|
||||
|
||||
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
|
||||
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
|
||||
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
|
||||
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
|
||||
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
|
||||
|
||||
|
||||
## [1.48.1]
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
|
||||
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
|
||||
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
|
||||
|
||||
### Other
|
||||
|
||||
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
|
||||
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
## Highlights
|
||||
|
||||
### Offline listening
|
||||
|
||||
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
|
||||
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
|
||||
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
|
||||
|
||||
### Music Network — scrobble beyond Last.fm
|
||||
|
||||
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
|
||||
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
|
||||
- A master switch turns the whole network on or off.
|
||||
|
||||
### Theme Store
|
||||
|
||||
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
|
||||
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
|
||||
- **Now Playing** follows every theme cleanly, including light palettes.
|
||||
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
|
||||
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
|
||||
|
||||
### Fullscreen player
|
||||
|
||||
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
|
||||
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
|
||||
|
||||
### Live — richer now playing on Navidrome 0.62+
|
||||
|
||||
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
|
||||
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
|
||||
|
||||
### Queue — Timeline mode
|
||||
|
||||
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
|
||||
|
||||
### Settings → Servers
|
||||
|
||||
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
|
||||
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
|
||||
|
||||
### Sidebar — pin Now Playing to the top
|
||||
|
||||
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
|
||||
|
||||
### Startup
|
||||
|
||||
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
|
||||
|
||||
## Improved
|
||||
|
||||
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
|
||||
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
|
||||
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Playback and audio
|
||||
|
||||
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
|
||||
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
|
||||
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
|
||||
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
|
||||
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
|
||||
- Mixed-server queues still report to the correct Navidrome server.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
|
||||
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
|
||||
- Discord Rich Presence shows album art again when covers come from the server.
|
||||
- Focus rings and dropdown borders follow the active theme consistently.
|
||||
|
||||
### Browse and library
|
||||
|
||||
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
|
||||
- **All Albums → Only compilations** returns results for common tagging patterns.
|
||||
- Album grids show the album artist on compilations instead of a random track artist.
|
||||
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
|
||||
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
|
||||
- **Home → Most Played** no longer jumps the page when you load more albums.
|
||||
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
|
||||
|
||||
### Other
|
||||
|
||||
- **Linux:** the `curl | bash` auto-installer works again.
|
||||
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
|
||||
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
|
||||
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
|
||||
- The active server card under **Settings → Servers** draws a complete border on all sides.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
|
||||
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
|
||||
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
|
||||
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
|
||||
// this config only — the strict config keeps the default reporting and stays 0/0.
|
||||
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
|
||||
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
|
||||
// `src/generated` holds machine-generated output (release-notes bundle,
|
||||
// tauri-specta `bindings.ts`) — not linted. `bindings.ts` is still type-checked
|
||||
// by tsc (the FE↔BE contract); its generated runtime helper uses `any`.
|
||||
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts', 'src/generated'] },
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
// Promote deps to error at strict stage (wave 6+)
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1780243769,
|
||||
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||
"lastModified": 1784356753,
|
||||
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+82
@@ -6,9 +6,91 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Psysonic – Dein Navidrome Desktop Player" />
|
||||
<title>Psysonic</title>
|
||||
<script src="/startup-splash-preflight.js"></script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--startup-splash-bg, #1e1e2e);
|
||||
}
|
||||
#app-startup-splash {
|
||||
--splash-bg: var(--startup-splash-bg, #1e1e2e);
|
||||
--splash-text: var(--startup-splash-text, #cdd6f4);
|
||||
--splash-muted: var(--startup-splash-muted, #a6adc8);
|
||||
--splash-accent: var(--startup-splash-accent, #cba6f7);
|
||||
--splash-track: var(--startup-splash-track, #313244);
|
||||
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
|
||||
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483646;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.75rem;
|
||||
background: var(--splash-bg);
|
||||
color: var(--splash-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
opacity: 1;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
#app-startup-splash.app-startup-splash--hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__logo {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
opacity: 0.95;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--splash-muted);
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar {
|
||||
width: min(240px, 72vw);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--splash-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
#app-startup-splash .app-startup-splash__bar-fill {
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--splash-accent);
|
||||
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
|
||||
}
|
||||
@keyframes app-startup-splash-indeterminate {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
|
||||
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--splash-logo-start)" />
|
||||
<stop offset="100%" stop-color="var(--splash-logo-end)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(220.53237,27.789086)">
|
||||
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="app-startup-splash__bar" aria-hidden="true">
|
||||
<div class="app-startup-splash__bar-fill"></div>
|
||||
</div>
|
||||
<span class="app-startup-splash__label">Loading</span>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/startup-splash-reveal.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-1Z0TwyPU7qoE3qzdZ/RYg2TZnCcgWIT52cIIuYawbfw="
|
||||
"npmDepsHash": "sha256-xwXDgWdkPEd5W8TVUCP29fbJesI4shYjeCUzlG69/aE="
|
||||
}
|
||||
|
||||
Generated
+2627
-546
File diff suppressed because it is too large
Load Diff
+28
-11
@@ -1,18 +1,25 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.47.0-rc.4",
|
||||
"version": "1.50.0-rc.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
|
||||
"dev": "npm run prebuild:release-notes && vite",
|
||||
"build": "npm run prebuild:release-notes && tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"test": "vitest run && npm run check:css-imports",
|
||||
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
|
||||
"lint": "eslint -c eslint.config.mjs src",
|
||||
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
|
||||
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
|
||||
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
|
||||
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
|
||||
"build:verify": "npm run build && npm run check:boot-chunks",
|
||||
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage && npm run check:css-imports"
|
||||
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
@@ -52,6 +59,7 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -62,11 +70,20 @@
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"esbuild": "^0.28.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"@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",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.7"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.28.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.46.0
|
||||
pkgver=1.49.0
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Synchronous startup splash theme (before the Vite bundle loads).
|
||||
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
|
||||
*/
|
||||
(function startupSplashPreflight() {
|
||||
var THEME_KEY = 'psysonic_theme';
|
||||
var INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
var DEFAULT = {
|
||||
bg: '#1e1e2e',
|
||||
text: '#cdd6f4',
|
||||
muted: '#a6adc8',
|
||||
accent: '#cba6f7',
|
||||
track: '#313244',
|
||||
logoStart: '#cba6f7',
|
||||
logoEnd: '#89b4fa',
|
||||
};
|
||||
var BUILTIN = {
|
||||
mocha: DEFAULT,
|
||||
latte: {
|
||||
bg: '#eff1f5',
|
||||
text: '#4c4f69',
|
||||
muted: '#6c6f85',
|
||||
accent: '#8839ef',
|
||||
track: '#ccd0da',
|
||||
logoStart: '#8839ef',
|
||||
logoEnd: '#1e66f5',
|
||||
},
|
||||
'kanagawa-wave': {
|
||||
bg: '#1F1F28',
|
||||
text: '#DCD7BA',
|
||||
muted: '#727169',
|
||||
accent: '#7E9CD8',
|
||||
track: '#2A2A37',
|
||||
logoStart: '#7E9CD8',
|
||||
logoEnd: '#957FB8',
|
||||
},
|
||||
'stark-hud': {
|
||||
bg: '#0b0f15',
|
||||
text: '#e0f7fa',
|
||||
muted: '#7da5aa',
|
||||
accent: '#00f2ff',
|
||||
track: '#141b24',
|
||||
logoStart: '#00f2ff',
|
||||
logoEnd: '#7df9ff',
|
||||
},
|
||||
'vision-dark': {
|
||||
bg: '#0d0b12',
|
||||
text: '#f2eef8',
|
||||
muted: '#a6a2b8',
|
||||
accent: '#ffd700',
|
||||
track: '#16131e',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
'vision-navy': {
|
||||
bg: '#0a1628',
|
||||
text: '#e8eef8',
|
||||
muted: '#9caac2',
|
||||
accent: '#ffd700',
|
||||
track: '#12213a',
|
||||
logoStart: '#ffd700',
|
||||
logoEnd: '#a07af8',
|
||||
},
|
||||
};
|
||||
|
||||
function readCssVar(css, name) {
|
||||
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
|
||||
var value = match && match[1] ? match[1].trim() : '';
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolveScheduledTheme(state) {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
var now = new Date();
|
||||
var nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
var dayParts = state.timeDayStart.split(':').map(Number);
|
||||
var nightParts = state.timeNightStart.split(':').map(Number);
|
||||
var dayMins = dayParts[0] * 60 + dayParts[1];
|
||||
var nightMins = nightParts[0] * 60 + nightParts[1];
|
||||
var isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
function readThemeState() {
|
||||
try {
|
||||
var raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var s = parsed && parsed.state;
|
||||
if (!s) return null;
|
||||
return {
|
||||
enableThemeScheduler: !!s.enableThemeScheduler,
|
||||
theme: String(s.theme || 'mocha'),
|
||||
themeDay: String(s.themeDay || 'latte'),
|
||||
themeNight: String(s.themeNight || 'mocha'),
|
||||
timeDayStart: String(s.timeDayStart || '07:00'),
|
||||
timeNightStart: String(s.timeNightStart || '19:00'),
|
||||
};
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledThemes() {
|
||||
try {
|
||||
var raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return [];
|
||||
var parsed = JSON.parse(raw);
|
||||
var themes = parsed && parsed.state && parsed.state.themes;
|
||||
return Array.isArray(themes) ? themes : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function paletteForTheme(themeId, installedThemes) {
|
||||
if (BUILTIN[themeId]) return BUILTIN[themeId];
|
||||
for (var i = 0; i < installedThemes.length; i += 1) {
|
||||
var theme = installedThemes[i];
|
||||
if (!theme || theme.id !== themeId || !theme.css) continue;
|
||||
var bg = readCssVar(theme.css, '--bg-app');
|
||||
var accent = readCssVar(theme.css, '--accent');
|
||||
if (!bg || !accent) break;
|
||||
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
|
||||
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|
||||
|| readCssVar(theme.css, '--accent-2')
|
||||
|| accent;
|
||||
return {
|
||||
bg: bg,
|
||||
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
|
||||
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
|
||||
accent: accent,
|
||||
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
|
||||
logoStart: logoStart,
|
||||
logoEnd: logoEnd,
|
||||
};
|
||||
}
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
function applyPalette(themeId, palette) {
|
||||
var root = document.documentElement;
|
||||
root.setAttribute('data-theme', themeId);
|
||||
root.style.setProperty('--startup-splash-bg', palette.bg);
|
||||
root.style.setProperty('--startup-splash-text', palette.text);
|
||||
root.style.setProperty('--startup-splash-muted', palette.muted);
|
||||
root.style.setProperty('--startup-splash-accent', palette.accent);
|
||||
root.style.setProperty('--startup-splash-track', palette.track);
|
||||
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
|
||||
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
|
||||
root.style.background = palette.bg;
|
||||
if (document.body) document.body.style.background = palette.bg;
|
||||
}
|
||||
|
||||
var persisted = readThemeState();
|
||||
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;
|
||||
})();
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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() {
|
||||
var MAX_ATTEMPTS = 60;
|
||||
|
||||
function tryShowMainWindow() {
|
||||
var internals = window.__TAURI_INTERNALS__;
|
||||
if (!internals || typeof internals.invoke !== 'function') return false;
|
||||
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function 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 () {
|
||||
reveal(attempt + 1);
|
||||
}, 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
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
|
||||
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
|
||||
*
|
||||
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
|
||||
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
|
||||
|
||||
function versionCore(version) {
|
||||
const m = version.trim().match(SEMVER_CORE);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function isPlainTriple(header) {
|
||||
return /^\d+\.\d+\.\d+$/.test(header.trim());
|
||||
}
|
||||
|
||||
function splitBlocks(raw) {
|
||||
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
|
||||
}
|
||||
|
||||
function headerVersion(block) {
|
||||
const m = block.match(/^## \[([^\]]+)\]/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function parseBlock(block) {
|
||||
const lines = block.split('\n');
|
||||
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
headerVersion: m[1],
|
||||
date: (m[2] ?? '').trim(),
|
||||
body: lines.slice(1).join('\n').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function findReleaseSection(raw, appVersion) {
|
||||
const blocks = splitBlocks(raw);
|
||||
|
||||
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
|
||||
if (exact) return parseBlock(exact);
|
||||
|
||||
const appCore = versionCore(appVersion);
|
||||
if (!appCore) return null;
|
||||
|
||||
const candidates = blocks.filter((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && versionCore(hv) === appCore;
|
||||
});
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const plain = candidates.find((b) => {
|
||||
const hv = headerVersion(b);
|
||||
return hv !== null && isPlainTriple(hv);
|
||||
});
|
||||
return parseBlock(plain ?? candidates[0]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const allowEmpty = args.includes('--allow-empty');
|
||||
const positional = args.filter((a) => a !== '--allow-empty');
|
||||
const [file, version] = positional;
|
||||
|
||||
if (!file || !version) {
|
||||
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
const entry = findReleaseSection(raw, version);
|
||||
const body = entry?.body?.trim() ?? '';
|
||||
|
||||
if (!body) {
|
||||
if (allowEmpty) process.exit(0);
|
||||
console.error(`No release section found in ${file} for version ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`${body}\n`);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) main();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const FIXTURE = `
|
||||
## [1.48.0] - 2026-06-10
|
||||
|
||||
## Highlights
|
||||
- One
|
||||
|
||||
## [1.47.0]
|
||||
- Old
|
||||
`;
|
||||
|
||||
describe('findReleaseSection', () => {
|
||||
it('matches base line for -rc versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
assert.match(entry.body, /Highlights/);
|
||||
});
|
||||
|
||||
it('matches base line for -dev versions', () => {
|
||||
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
|
||||
assert.equal(entry.headerVersion, '1.48.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build src/generated/releaseNotesBundle.ts for production bundles.
|
||||
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
|
||||
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { findReleaseSection } from './extract-release-section.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
const version = pkg.version;
|
||||
|
||||
const whatsNewPath = join(root, 'WHATS_NEW.md');
|
||||
const changelogPath = join(root, 'CHANGELOG.md');
|
||||
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
|
||||
const changelogFull = readFileSync(changelogPath, 'utf8');
|
||||
|
||||
function sliceForVersion(full, fileLabel) {
|
||||
const entry = findReleaseSection(full, version);
|
||||
if (!entry?.body) {
|
||||
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
|
||||
return '';
|
||||
}
|
||||
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
|
||||
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
|
||||
}
|
||||
|
||||
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
|
||||
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
|
||||
|
||||
const outDir = join(root, 'src/generated');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
|
||||
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
|
||||
|
||||
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
|
||||
`;
|
||||
|
||||
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
|
||||
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
|
||||
|
||||
// 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})`);
|
||||
+19
-8
@@ -14,20 +14,22 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Log helpers write to stderr so functions that return values via stdout
|
||||
# (e.g. get_download_url) stay clean when called in command substitution.
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
echo -e "${BLUE}[INFO]${NC} $1" >&2
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
echo -e "${YELLOW}[WARN]${NC} $1" >&2
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ install_package() {
|
||||
|
||||
if [ "$OS_TYPE" = "debian" ]; then
|
||||
package_file="${package_file}.deb"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file" || {
|
||||
@@ -114,7 +116,7 @@ install_package() {
|
||||
}
|
||||
elif [ "$OS_TYPE" = "rhel" ]; then
|
||||
package_file="${package_file}.rpm"
|
||||
curl -L -o "$package_file" "$download_url"
|
||||
curl --fail --globoff -L -o "$package_file" "$download_url"
|
||||
|
||||
info "Installing package..."
|
||||
$PACKAGE_MANAGER install -y "$package_file"
|
||||
@@ -128,8 +130,17 @@ install_package() {
|
||||
check_installed() {
|
||||
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
|
||||
warn "${APP_NAME} appears to be already installed."
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r
|
||||
echo
|
||||
# Under `curl ... | bash`, stdin is the script stream itself, so
|
||||
# read the answer from the controlling terminal instead. Probe by
|
||||
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
|
||||
# without a controlling terminal; only open() reports the failure.
|
||||
if { : < /dev/tty; } 2>/dev/null; then
|
||||
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
|
||||
echo
|
||||
else
|
||||
warn "No terminal available for prompt; skipping reinstall."
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Installation cancelled."
|
||||
exit 0
|
||||
|
||||
@@ -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
+346
-185
File diff suppressed because it is too large
Load Diff
+19
-16
@@ -3,9 +3,10 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.47.0-rc.4"
|
||||
version = "1.50.0-rc.4"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tempfile = "3"
|
||||
@@ -18,7 +19,7 @@ name = "psysonic"
|
||||
version.workspace = true
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
license.workspace = true
|
||||
repository = ""
|
||||
default-run = "psysonic"
|
||||
edition.workspace = true
|
||||
@@ -43,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"
|
||||
@@ -51,8 +55,8 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
md5 = "0.8"
|
||||
@@ -67,13 +71,13 @@ discord-rich-presence = "1.1"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
sysinfo = { version = "0.38", default-features = false, features = ["disk", "system"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.9"
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
|
||||
id3 = "1.17"
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "4.6.1"
|
||||
zip = "8"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
webp = "0.3"
|
||||
|
||||
@@ -84,7 +88,7 @@ libc = "0.2"
|
||||
mach2 = "0.5"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = { version = "5.15", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
|
||||
# Match wry/tauri’s WebKitGTK stack — used only to turn off kinetic wheel scrolling.
|
||||
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
|
||||
webkit2gtk-nvidia-quirk = "1.3"
|
||||
@@ -102,11 +106,10 @@ windows = { version = "0.62", features = [
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
# Local patch for Symphonia's isomp4 demuxer:
|
||||
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
|
||||
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
|
||||
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
|
||||
# of failing the entire probe
|
||||
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
|
||||
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
|
||||
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
|
||||
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
|
||||
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
|
||||
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
|
||||
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ accepted = [
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"GPL-3.0-or-later",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
# Skip the build host's own platform-pinning; we want a list across all targets
|
||||
@@ -38,5 +40,4 @@ targets = [
|
||||
ignore-build-dependencies = false
|
||||
ignore-dev-dependencies = true
|
||||
ignore-transitive-dependencies = false
|
||||
filter-noassertion = false
|
||||
workarounds = ["ring"]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-app-write-recursive",
|
||||
"fs:scope-download-recursive",
|
||||
"fs:scope-home-recursive",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
@@ -11,14 +12,15 @@ 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"
|
||||
ebur128 = "0.1"
|
||||
md5 = "0.8"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
symphonia-adapter-libopus = "0.2.9"
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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='*'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,12 @@ use std::io::Cursor;
|
||||
use std::time::Instant;
|
||||
|
||||
use ebur128::{EbuR128, Mode as Ebur128Mode};
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
|
||||
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
||||
use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType};
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::core::units::Time;
|
||||
use tauri::{Manager, Runtime};
|
||||
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
|
||||
@@ -299,7 +298,7 @@ fn analyze_loudness_and_waveform(
|
||||
/// when the container reports total track length.
|
||||
struct DecodeSession {
|
||||
format: Box<dyn FormatReader>,
|
||||
decoder: Box<dyn Decoder>,
|
||||
decoder: Box<dyn AudioDecoder>,
|
||||
track_id: u32,
|
||||
timeline_hint: Option<u64>,
|
||||
}
|
||||
@@ -334,25 +333,25 @@ fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option<Decode
|
||||
if let Some(ext) = format_hint.or(sniffed.as_deref()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||||
let format = symphonia::default::get_probe()
|
||||
.probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
|
||||
.ok()?;
|
||||
let format = probed.format;
|
||||
// Prefer an audio track that reports both sample rate and channels; fall back to
|
||||
// the first audio track with a known codec (skips e.g. MJPEG cover-art tracks).
|
||||
let track = format
|
||||
.default_track()
|
||||
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.or_else(|| {
|
||||
format.tracks().iter().find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
&& t.codec_params.channels.is_some()
|
||||
})
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| {
|
||||
t.codec_params
|
||||
.as_ref()
|
||||
.and_then(|c| c.audio())
|
||||
.is_some_and(|a| a.sample_rate.is_some() && a.channels.is_some())
|
||||
})
|
||||
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
||||
.or_else(|| format.first_track_known_codec(TrackType::Audio))?;
|
||||
let track_id = track.id;
|
||||
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
|
||||
let codec_params = track.codec_params.clone();
|
||||
let decoder = match make_decoder(&codec_params, &DecoderOptions::default()) {
|
||||
let timeline_hint = track.num_frames.filter(|&n| n > 0);
|
||||
let audio_params = track.codec_params.as_ref()?.audio()?.clone();
|
||||
let decoder = match make_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
|
||||
@@ -372,8 +371,9 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
|
||||
|
||||
let mut total: u64 = 0;
|
||||
let mut loop_i: u32 = 0;
|
||||
while let Ok(packet) = format.next_packet() {
|
||||
if packet.track_id() != track_id {
|
||||
let mut samples_buf: Vec<f32> = Vec::new();
|
||||
while let Ok(Some(packet)) = format.next_packet() {
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
@@ -382,14 +382,12 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
|
||||
Err(SymphoniaError::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
let n_ch = decoded.spec().channels().count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let n = samples.samples().len();
|
||||
decoded.copy_to_vec_interleaved(&mut samples_buf);
|
||||
let n = samples_buf.len();
|
||||
if n < n_ch || !n.is_multiple_of(n_ch) {
|
||||
continue;
|
||||
}
|
||||
@@ -460,8 +458,9 @@ fn decode_scan_pcm(
|
||||
}
|
||||
let bin_grid_frames = decoded_frames.max(1);
|
||||
|
||||
while let Ok(packet) = format.next_packet() {
|
||||
if packet.track_id() != track_id {
|
||||
let mut samples_buf: Vec<f32> = Vec::new();
|
||||
while let Ok(Some(packet)) = format.next_packet() {
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
@@ -471,15 +470,14 @@ fn decode_scan_pcm(
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
let n_ch = decoded.spec().channels().count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() && ebu.is_none() {
|
||||
let ch = spec.channels.count() as u32;
|
||||
let sr = spec.rate;
|
||||
let ch = decoded.spec().channels().count() as u32;
|
||||
let sr = decoded.spec().rate();
|
||||
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
|
||||
Ok(v) => {
|
||||
ebu = Some(v);
|
||||
@@ -497,9 +495,8 @@ fn decode_scan_pcm(
|
||||
}
|
||||
}
|
||||
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let slice = samples.samples();
|
||||
decoded.copy_to_vec_interleaved(&mut samples_buf);
|
||||
let slice = samples_buf.as_slice();
|
||||
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
|
||||
continue;
|
||||
}
|
||||
@@ -531,7 +528,7 @@ fn decode_scan_pcm(
|
||||
|
||||
if loudness_target_lufs.is_some() {
|
||||
if let Some(e) = ebu.as_mut() {
|
||||
match e.add_frames_f32(samples.samples()) {
|
||||
match e.add_frames_f32(&samples_buf) {
|
||||
Ok(_) => fed_any_frames = true,
|
||||
Err(err) => {
|
||||
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
|
||||
@@ -634,9 +631,11 @@ pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
|
||||
let session = open_decode_session(bytes, None)?;
|
||||
let sample_rate = session
|
||||
.format
|
||||
.default_track()
|
||||
.default_track(TrackType::Audio)
|
||||
.or_else(|| session.format.tracks().first())
|
||||
.and_then(|t| t.codec_params.sample_rate)
|
||||
.and_then(|t| t.codec_params.as_ref())
|
||||
.and_then(|c| c.audio())
|
||||
.and_then(|a| a.sample_rate)
|
||||
.filter(|&sr| sr > 0)?;
|
||||
let frames = session.timeline_hint?;
|
||||
Some(frames as f64 / sample_rate as f64)
|
||||
@@ -659,7 +658,8 @@ pub fn decode_mono_pcm_window(
|
||||
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
|
||||
|
||||
if start_sec.is_finite() && start_sec > 0.0 {
|
||||
let time: Time = start_sec.max(0.0).into();
|
||||
let time = Time::try_from_secs_f64(start_sec.max(0.0))
|
||||
.ok_or_else(|| "pcm window: invalid seek time".to_string())?;
|
||||
format
|
||||
.seek(
|
||||
SeekMode::Accurate,
|
||||
@@ -693,7 +693,7 @@ pub fn decode_mono_pcm_limited(
|
||||
|
||||
fn decode_mono_pcm_from_session(
|
||||
format: &mut Box<dyn FormatReader>,
|
||||
decoder: &mut Box<dyn Decoder>,
|
||||
decoder: &mut Box<dyn AudioDecoder>,
|
||||
track_id: u32,
|
||||
max_seconds: Option<f64>,
|
||||
) -> Result<(Vec<f32>, f32), String> {
|
||||
@@ -701,9 +701,10 @@ fn decode_mono_pcm_from_session(
|
||||
let mut sample_rate = 0_f32;
|
||||
let mut max_frames: Option<u64> = None;
|
||||
let mut loop_i: u32 = 0;
|
||||
let mut samples_buf: Vec<f32> = Vec::new();
|
||||
|
||||
while let Ok(packet) = format.next_packet() {
|
||||
if packet.track_id() != track_id {
|
||||
while let Ok(Some(packet)) = format.next_packet() {
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
@@ -713,13 +714,12 @@ fn decode_mono_pcm_from_session(
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let spec = *decoded.spec();
|
||||
let n_ch = spec.channels.count();
|
||||
let n_ch = decoded.spec().channels().count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
if sample_rate <= 0.0 {
|
||||
sample_rate = spec.rate as f32;
|
||||
sample_rate = decoded.spec().rate() as f32;
|
||||
if sample_rate <= 0.0 {
|
||||
return Err("invalid sample rate".to_string());
|
||||
}
|
||||
@@ -732,9 +732,8 @@ fn decode_mono_pcm_from_session(
|
||||
});
|
||||
}
|
||||
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let slice = samples.samples();
|
||||
decoded.copy_to_vec_interleaved(&mut samples_buf);
|
||||
let slice = samples_buf.as_slice();
|
||||
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use psysonic_core::ports::PlaybackQueryHandle;
|
||||
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
|
||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
|
||||
|
||||
@@ -36,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,
|
||||
@@ -291,7 +292,7 @@ impl AnalysisBackfillQueueState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
|
||||
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
|
||||
#[derive(Default)]
|
||||
pub struct PlaybackPriorityHints {
|
||||
middle_track_ids: Mutex<HashSet<String>>,
|
||||
@@ -516,6 +517,145 @@ pub async fn enqueue_track_analysis_from_file(
|
||||
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
|
||||
}
|
||||
|
||||
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
|
||||
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
|
||||
pub async fn enqueue_offline_library_analysis_from_file(
|
||||
app: &tauri::AppHandle,
|
||||
server_index_key: &str,
|
||||
library_server_id: &str,
|
||||
track_id: &str,
|
||||
file_path: &std::path::Path,
|
||||
explicit_priority: Option<AnalysisBackfillPriority>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::track_analysis_plan::plan_track_analysis_offline_library;
|
||||
|
||||
let mut file = tokio::fs::File::open(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut prefix = vec![0u8; 16384];
|
||||
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
|
||||
prefix.truncate(n);
|
||||
if prefix.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(&prefix);
|
||||
let plan = plan_track_analysis_offline_library(
|
||||
app,
|
||||
&[server_index_key, library_server_id],
|
||||
library_server_id,
|
||||
track_id,
|
||||
&content_hash,
|
||||
);
|
||||
if !plan.any() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
|
||||
track_id,
|
||||
server_index_key,
|
||||
library_server_id,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = tokio::fs::read(file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let format_hint = file_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| !e.is_empty());
|
||||
let priority = explicit_priority.unwrap_or_else(|| {
|
||||
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
|
||||
});
|
||||
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
|
||||
app,
|
||||
cache_server_id: server_index_key,
|
||||
enrichment_server_id: library_server_id,
|
||||
track_id,
|
||||
bytes: &bytes,
|
||||
format_hint: format_hint.as_deref(),
|
||||
priority,
|
||||
plan,
|
||||
fetch_ms: 0,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct OfflineLibraryAnalysisEnqueue<'a> {
|
||||
app: &'a tauri::AppHandle,
|
||||
cache_server_id: &'a str,
|
||||
enrichment_server_id: &'a str,
|
||||
track_id: &'a str,
|
||||
bytes: &'a [u8],
|
||||
format_hint: Option<&'a str>,
|
||||
priority: AnalysisBackfillPriority,
|
||||
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
|
||||
fetch_ms: u64,
|
||||
}
|
||||
|
||||
async fn enqueue_track_analysis_offline_library_with_plan(
|
||||
args: OfflineLibraryAnalysisEnqueue<'_>,
|
||||
) -> Result<EnqueueTrackAnalysisOutcome, String> {
|
||||
if args.bytes.is_empty() || !args.plan.any() {
|
||||
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||
}
|
||||
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
|
||||
if args.plan.needs_full_cpu_seed() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
|
||||
args.track_id,
|
||||
content_hash,
|
||||
args.plan.need_waveform,
|
||||
args.plan.need_loudness,
|
||||
args.plan.enrichment.any()
|
||||
);
|
||||
submit_analysis_cpu_seed(
|
||||
args.app.clone(),
|
||||
args.cache_server_id.to_string(),
|
||||
args.track_id.to_string(),
|
||||
args.bytes.to_vec(),
|
||||
args.format_hint.map(str::to_string),
|
||||
args.priority,
|
||||
args.fetch_ms,
|
||||
)
|
||||
.await?;
|
||||
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
|
||||
}
|
||||
if args.plan.needs_enrichment_only() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] enrichment-only track_id={} hash={}",
|
||||
args.track_id,
|
||||
content_hash
|
||||
);
|
||||
let bpm_started = std::time::Instant::now();
|
||||
let outcome = run_track_enrichment_from_bytes(
|
||||
args.app,
|
||||
args.enrichment_server_id,
|
||||
args.track_id,
|
||||
args.bytes,
|
||||
analysis_emits_ui_events(args.priority),
|
||||
)
|
||||
.await;
|
||||
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
|
||||
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
let key = analysis_cache::TrackKey {
|
||||
server_id: args.cache_server_id.to_string(),
|
||||
track_id: args.track_id.to_string(),
|
||||
md5_16kb: content_hash.clone(),
|
||||
};
|
||||
let _ = cache.touch_track_status(&key, "failed");
|
||||
}
|
||||
return Err("track enrichment failed".to_string());
|
||||
}
|
||||
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
|
||||
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
|
||||
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
|
||||
}
|
||||
Ok(EnqueueTrackAnalysisOutcome::Complete)
|
||||
}
|
||||
|
||||
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
|
||||
pub async fn enqueue_analysis_seed(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -540,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
|
||||
})
|
||||
}
|
||||
|
||||
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
|
||||
async fn analysis_backfill_download_bytes(
|
||||
app: &tauri::AppHandle,
|
||||
server_id: &str,
|
||||
url: &str,
|
||||
) -> Result<(Vec<u8>, u64), String> {
|
||||
let fetch_started = std::time::Instant::now();
|
||||
let response = analysis_http_client()
|
||||
.get(url)
|
||||
let registry = app
|
||||
.try_state::<Arc<ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
let request = apply_optional_registry_headers(
|
||||
registry.as_deref(),
|
||||
Some(server_id),
|
||||
url,
|
||||
analysis_http_client().get(url),
|
||||
);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -642,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
|
||||
let app = app.clone();
|
||||
let shared = shared.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let download_result = analysis_backfill_download_bytes(&url).await;
|
||||
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
|
||||
{
|
||||
let mut st = shared
|
||||
.state
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
|
||||
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
|
||||
use symphonia::core::codecs::registry::CodecRegistry;
|
||||
|
||||
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn make_decoder(
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
opts: &DecoderOptions,
|
||||
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make(params, opts)
|
||||
params: &AudioCodecParameters,
|
||||
opts: &AudioDecoderOptions,
|
||||
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make_audio_decoder(params, opts)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
|
||||
}
|
||||
|
||||
/// Offline/library download: waveform cache and enrichment facts may live under the
|
||||
/// playback index key while library rows use the UUID — try every scope before seeding.
|
||||
pub fn plan_track_analysis_offline_library(
|
||||
app: &AppHandle,
|
||||
cache_server_ids: &[&str],
|
||||
_enrichment_server_id: &str,
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> TrackAnalysisPlan {
|
||||
let (need_waveform, need_loudness) =
|
||||
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
|
||||
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
|
||||
TrackAnalysisPlan {
|
||||
need_waveform,
|
||||
need_loudness,
|
||||
@@ -103,6 +116,32 @@ fn cache_gaps(
|
||||
)
|
||||
}
|
||||
|
||||
fn cache_gaps_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> (bool, bool) {
|
||||
let mut need_waveform = true;
|
||||
let mut need_loudness = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
|
||||
if !nw {
|
||||
need_waveform = false;
|
||||
}
|
||||
if !nl {
|
||||
need_loudness = false;
|
||||
}
|
||||
if !need_waveform && !need_loudness {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(need_waveform, need_loudness)
|
||||
}
|
||||
|
||||
fn enrichment_plan(
|
||||
app: &AppHandle,
|
||||
server_id: &str,
|
||||
@@ -117,6 +156,45 @@ fn enrichment_plan(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enrichment_plan_multi(
|
||||
app: &AppHandle,
|
||||
server_ids: &[&str],
|
||||
track_id: &str,
|
||||
content_hash: &str,
|
||||
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
let mut need_bpm = true;
|
||||
let mut need_valence = true;
|
||||
let mut need_arousal = true;
|
||||
let mut need_moods = true;
|
||||
for &server_id in server_ids {
|
||||
if server_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let plan = enrichment_plan(app, server_id, track_id, content_hash);
|
||||
if !plan.need_bpm {
|
||||
need_bpm = false;
|
||||
}
|
||||
if !plan.need_valence {
|
||||
need_valence = false;
|
||||
}
|
||||
if !plan.need_arousal {
|
||||
need_arousal = false;
|
||||
}
|
||||
if !plan.need_moods {
|
||||
need_moods = false;
|
||||
}
|
||||
if !need_bpm && !need_valence && !need_arousal && !need_moods {
|
||||
break;
|
||||
}
|
||||
}
|
||||
psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||
need_bpm,
|
||||
need_valence,
|
||||
need_arousal,
|
||||
need_moods,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_gaps_for_content(
|
||||
cache: Option<&AnalysisCache>,
|
||||
server_id: &str,
|
||||
@@ -194,4 +272,14 @@ mod tests {
|
||||
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
|
||||
let cache = AnalysisCache::open_in_memory();
|
||||
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
|
||||
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
|
||||
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
|
||||
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
|
||||
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "psysonic-audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
@@ -10,14 +11,15 @@ 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"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
|
||||
futures-util = "0.3"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||
symphonia-adapter-libopus = "0.2.9"
|
||||
rodio = { version = "0.22", default-features = false, features = ["playback"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
|
||||
symphonia-adapter-libopus = "0.3"
|
||||
ringbuf = "0.5"
|
||||
biquad = "0.6"
|
||||
dasp_sample = "0.11.0"
|
||||
@@ -25,7 +27,7 @@ md5 = "0.8"
|
||||
url = "2"
|
||||
thread-priority = "3"
|
||||
lofty = "0.24"
|
||||
id3 = "1.16.4"
|
||||
id3 = "1.17"
|
||||
pitch_shift = "2.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
@@ -39,6 +41,8 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Power",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
|
||||
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
|
||||
use symphonia::core::codecs::registry::CodecRegistry;
|
||||
|
||||
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
|
||||
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
let mut registry = CodecRegistry::new();
|
||||
symphonia::default::register_enabled_codecs(&mut registry);
|
||||
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
|
||||
registry
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn try_make_radio_decoder(
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
opts: &DecoderOptions,
|
||||
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make(params, opts)
|
||||
params: &AudioCodecParameters,
|
||||
opts: &AudioDecoderOptions,
|
||||
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
|
||||
psysonic_codec_registry().make_audio_decoder(params, opts)
|
||||
}
|
||||
|
||||
@@ -11,15 +11,17 @@ use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::build_source;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{
|
||||
build_playback_source_with_probe_fallback, select_play_input,
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
|
||||
PlayInputContext, SinkSwapInputs,
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
|
||||
use super::sink_swap::{
|
||||
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
|
||||
SinkSwapInputs,
|
||||
};
|
||||
use super::playback_rate::preserve_pitch_will_run;
|
||||
use super::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
|
||||
use super::preview::preview_clear_for_new_main_playback;
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::state::{ChainedInfo, PreloadedTrack};
|
||||
@@ -38,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,
|
||||
@@ -50,12 +56,36 @@ pub async fn audio_play(
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
|
||||
// `false` so older/external `audio_play` callers that omit it still work.
|
||||
start_paused: Option<bool>,
|
||||
// Silence-aware crossfade (B-head): begin playback past the next track's
|
||||
// leading silence. Optional + defaults to `0` so existing callers are
|
||||
// unaffected; only applied when the freshly built source is seekable.
|
||||
start_secs: Option<f64>,
|
||||
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
|
||||
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
|
||||
// transition instead of the global `crossfade_secs`. `None` → use the global
|
||||
// setting (today's behaviour); always still clamped to the measured remaining.
|
||||
crossfade_secs_override: Option<f32>,
|
||||
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
|
||||
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
|
||||
// already fades out in the recording, so let it ride at full engine gain
|
||||
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
|
||||
// B's fade (today's behaviour). Always clamped to A's measured remaining.
|
||||
outgoing_fade_secs_override: Option<f32>,
|
||||
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
|
||||
// while a track is playing. Optional; only honoured when `manual` is true.
|
||||
manual_autodj_blend: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
let start_paused = start_paused.unwrap_or(false);
|
||||
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
|
||||
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
|
||||
|
||||
// ── Ghost-command guard ───────────────────────────────────────────────────
|
||||
@@ -215,9 +245,18 @@ pub async fn audio_play(
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
|
||||
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
|
||||
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
|
||||
let crossfade_enabled =
|
||||
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
|
||||
// Per-transition override (dynamic crossfade) caps the fade for this swap;
|
||||
// otherwise fall back to the global crossfade length. Both clamped the same.
|
||||
let crossfade_secs_val = if let Some(override_secs) = crossfade_secs_override {
|
||||
override_secs.clamp(0.5, 30.0)
|
||||
} else {
|
||||
f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed))
|
||||
.clamp(0.5, 12.0)
|
||||
};
|
||||
|
||||
// Measure how much audio Track A actually has left right now.
|
||||
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
|
||||
@@ -242,6 +281,26 @@ pub async fn audio_play(
|
||||
Duration::from_millis(5)
|
||||
};
|
||||
|
||||
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
|
||||
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
|
||||
// override means A already fades out in the recording, so we leave it at
|
||||
// full engine gain (scenario A). Never longer than A's remaining audio.
|
||||
let outgoing_fade_secs: f32 = if crossfade_enabled {
|
||||
match outgoing_fade_secs_override {
|
||||
Some(v) => v.max(0.0).min(actual_fade_secs),
|
||||
None => actual_fade_secs,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(
|
||||
hi_res_enabled,
|
||||
crossfade_enabled || (gapless && !manual),
|
||||
hi_res_crossfade_resample_hz,
|
||||
);
|
||||
let resample_target_hz = blend_rate.unwrap_or(0);
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
@@ -258,6 +317,7 @@ pub async fn audio_play(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
&state,
|
||||
@@ -281,8 +341,9 @@ pub async fn audio_play(
|
||||
e
|
||||
})?;
|
||||
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
|
||||
let source_seekable = playback_source.is_seekable;
|
||||
let built = playback_source.built;
|
||||
let source = built.source;
|
||||
let mut source = built.source;
|
||||
let duration_secs = built.duration_secs;
|
||||
let output_rate = built.output_rate;
|
||||
let output_channels = built.output_channels;
|
||||
@@ -295,6 +356,26 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let outgoing_blend: Option<OutgoingBlendSnapshot> =
|
||||
if let Some(blend) = blend_rate {
|
||||
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
|
||||
hi_res_blend::capture_outgoing_blend_snapshot(
|
||||
&state,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if outgoing_blend.is_some() {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
}
|
||||
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
@@ -303,31 +384,34 @@ pub async fn audio_play(
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
let target_rate = if let Some(blend) = blend_rate {
|
||||
blend
|
||||
} else if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
|
||||
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
|
||||
Ok(new_handle) => {
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
|
||||
match super::engine::open_output_stream_blocking(
|
||||
&state,
|
||||
target_rate,
|
||||
hi_res_enabled,
|
||||
dev,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// Give PipeWire time to reconfigure at the new rate before
|
||||
// we open a Sink — only needed for large hi-res quanta.
|
||||
if hi_res_enabled && target_rate > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +421,25 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
|
||||
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
|
||||
&app,
|
||||
&state,
|
||||
snap,
|
||||
blend,
|
||||
gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
|
||||
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
|
||||
@@ -352,7 +454,7 @@ pub async fn audio_play(
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
(hi_res_enabled && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
@@ -389,6 +491,17 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
// Silence-aware crossfade (B-head): skip the next track's leading silence by
|
||||
// seeking the freshly built source before it is appended. The outermost
|
||||
// `CountingSource` stores the sample counter on a successful seek; we still
|
||||
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
|
||||
// so the seekbar and the crossfade-remaining math are content-relative.
|
||||
let did_start_seek = if start_secs > 0.05 && source_seekable {
|
||||
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
sink.append(source);
|
||||
|
||||
if needs_prefill {
|
||||
@@ -401,7 +514,7 @@ pub async fn audio_play(
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(()); // skipped during pre-fill — abort silently
|
||||
}
|
||||
if !defer_playback_start {
|
||||
if !defer_playback_start && !start_paused {
|
||||
sink.play();
|
||||
}
|
||||
}
|
||||
@@ -415,24 +528,46 @@ pub async fn audio_play(
|
||||
fadeout_samples: built.fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
});
|
||||
|
||||
if defer_playback_start {
|
||||
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
|
||||
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
|
||||
// sample counter to the content offset so position reporting is correct.
|
||||
if did_start_seek {
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.seek_offset = start_secs;
|
||||
}
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
start_secs,
|
||||
output_rate,
|
||||
output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
if defer_playback_start {
|
||||
if !start_paused {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
spawn_legacy_stream_start_when_armed(
|
||||
spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
state.stream_playback_armed.clone(),
|
||||
state.samples_played.clone(),
|
||||
state.current.clone(),
|
||||
app.clone(),
|
||||
gen_arc: state.generation.clone(),
|
||||
playback_armed: state.stream_playback_armed.clone(),
|
||||
samples_played: state.samples_played.clone(),
|
||||
current: state.current.clone(),
|
||||
app: app.clone(),
|
||||
duration_secs,
|
||||
);
|
||||
} else {
|
||||
hold_paused: start_paused,
|
||||
});
|
||||
} else if !start_paused {
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
}
|
||||
|
||||
@@ -445,6 +580,7 @@ pub async fn audio_play(
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
Some(analysis_app),
|
||||
@@ -470,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,
|
||||
@@ -481,6 +620,7 @@ pub async fn audio_chain_preload(
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
hi_res_crossfade_resample_hz: Option<u32>,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
@@ -516,7 +656,14 @@ pub async fn audio_chain_preload(
|
||||
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let resp = audio_http_client(&state).get(&url).send().await
|
||||
let resp = crate::engine::playback_scoped_get(
|
||||
&state,
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Ok(()); // silently fail — audio_play will retry
|
||||
@@ -587,8 +734,9 @@ pub async fn audio_chain_preload(
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
// Always 0 unless hi-res gapless blend resampling is active.
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
|
||||
let target_rate: u32 = blend_rate.unwrap_or(0);
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
@@ -614,23 +762,70 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
// Hi-res gapless: resample the chained track to the blend rate and realign
|
||||
// the output stream when its Hz differs from the current track.
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
if let Some(br) = blend_rate {
|
||||
if stream_rate > 0 && stream_rate != br {
|
||||
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
|
||||
if hi_res_enabled && br > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
|
||||
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
|
||||
&app,
|
||||
&state,
|
||||
&snap,
|
||||
br,
|
||||
snapshot_gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend skipped: current track not cached for realign"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::source::UniformSourceIterator;
|
||||
use rodio::Source;
|
||||
use symphonia::core::{
|
||||
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
|
||||
codecs::{DecoderOptions, CODEC_TYPE_NULL},
|
||||
audio::{AudioSpec, GenericAudioBufferRef},
|
||||
codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions},
|
||||
formats::probe::Hint,
|
||||
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
|
||||
common::Limit,
|
||||
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
units::{self, Time},
|
||||
units::{Time, Timestamp},
|
||||
};
|
||||
|
||||
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
|
||||
@@ -52,6 +53,46 @@ impl MediaSource for SizedCursorSource {
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
|
||||
// ─── ProbeSeekGate — temporarily hide seekability during probing ──────────────
|
||||
//
|
||||
// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…)
|
||||
// whenever the source reports `is_seekable() == true` and a known `byte_len()`.
|
||||
// That scan seeks to the end of the stream. For a progressive ranged-HTTP source
|
||||
// this forces a download all the way to EOF before the first sample can play
|
||||
// (FLAC/MP3/OGG regressed to "won't start until fully downloaded").
|
||||
//
|
||||
// These formats are demuxed sequentially from the start, and their seek paths
|
||||
// re-check `is_seekable()` dynamically, so we can advertise the source as
|
||||
// non-seekable for the duration of the probe (skipping the trailing scan) and
|
||||
// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is
|
||||
// excluded because its demuxer captures seekability at construction and relies
|
||||
// on seeking to locate `moov` (its tail is prefetched separately instead).
|
||||
struct ProbeSeekGate {
|
||||
inner: Box<dyn MediaSource>,
|
||||
seekable: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Read for ProbeSeekGate {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for ProbeSeekGate {
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for ProbeSeekGate {
|
||||
fn is_seekable(&self) -> bool {
|
||||
self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable()
|
||||
}
|
||||
fn byte_len(&self) -> Option<u64> {
|
||||
self.inner.byte_len()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
|
||||
//
|
||||
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
|
||||
@@ -65,19 +106,19 @@ impl MediaSource for SizedCursorSource {
|
||||
/// playback is genuinely lossless.
|
||||
pub(crate) fn log_codec_resolution(
|
||||
tag: &str,
|
||||
params: &symphonia::core::codecs::CodecParameters,
|
||||
params: &AudioCodecParameters,
|
||||
container_hint: Option<&str>,
|
||||
) {
|
||||
let codec_name = symphonia::default::get_codecs()
|
||||
.get_codec(params.codec)
|
||||
.map(|d| d.short_name)
|
||||
.get_audio_decoder(params.codec)
|
||||
.map(|d| d.codec.info.short_name)
|
||||
.unwrap_or("?");
|
||||
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
|
||||
let bits = params.bits_per_sample
|
||||
.or(params.bits_per_coded_sample)
|
||||
.map(|b| format!("{}-bit", b))
|
||||
.unwrap_or_else(|| "?-bit".into());
|
||||
let ch = params.channels
|
||||
let ch = params.channels.as_ref()
|
||||
.map(|c| format!("{}ch", c.count()))
|
||||
.unwrap_or_else(|| "?ch".into());
|
||||
let lossless = codec_name.starts_with("pcm")
|
||||
@@ -99,14 +140,20 @@ const DECODE_MAX_RETRIES: usize = 3;
|
||||
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
|
||||
/// playable track (VLC-style frame dropping).
|
||||
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
|
||||
/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose
|
||||
/// download stalls (e.g. right after a server switch) can otherwise block the
|
||||
/// probe — and therefore playback start — indefinitely. On timeout we abort with
|
||||
/// an error so the player can recover/retry instead of hanging until a restart.
|
||||
const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub(crate) struct SizedDecoder {
|
||||
decoder: Box<dyn symphonia::core::codecs::Decoder>,
|
||||
decoder: Box<dyn AudioDecoder>,
|
||||
current_frame_offset: usize,
|
||||
format: Box<dyn FormatReader>,
|
||||
total_duration: Option<Time>,
|
||||
buffer: SampleBuffer<f32>,
|
||||
spec: SignalSpec,
|
||||
/// Interleaved f32 samples of the currently decoded packet.
|
||||
buffer: Vec<f32>,
|
||||
spec: AudioSpec,
|
||||
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
|
||||
/// successfully decoded frame. Used to detect fully undecodable streams.
|
||||
consecutive_decode_errors: usize,
|
||||
@@ -119,36 +166,44 @@ impl SizedDecoder {
|
||||
inner: Cursor::new(data),
|
||||
len: data_len,
|
||||
};
|
||||
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||
// seekability during probe (same as `new_streaming`) so preview does not
|
||||
// read the entire in-memory file before the first sample.
|
||||
//
|
||||
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
|
||||
// otherwise its demuxer never records `phys_byte_range_end` and the first
|
||||
// seek panics (see `container_hint_is_ogg`). This source is fully
|
||||
// in-memory, so the trailing-metadata scan it re-enables is free.
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !crate::stream::container_hint_is_ogg(format_hint);
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate {
|
||||
inner: Box::new(source),
|
||||
seekable: gate.clone(),
|
||||
}),
|
||||
None => Box::new(source),
|
||||
};
|
||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||
// and compete with the playback thread at track start.
|
||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||
let mss = MediaSourceStream::new(
|
||||
Box::new(source) as Box<dyn MediaSource>,
|
||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
||||
);
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let format_opts = FormatOptions {
|
||||
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
|
||||
// present in older iTunes-purchased M4A files.
|
||||
enable_gapless: false,
|
||||
..Default::default()
|
||||
};
|
||||
let format_opts = FormatOptions::default();
|
||||
|
||||
let meta_opts = symphonia::core::meta::MetadataOptions {
|
||||
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
|
||||
// iTunes M4A files don't choke the parser.
|
||||
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
|
||||
..Default::default()
|
||||
};
|
||||
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
|
||||
// iTunes M4A files don't choke the parser.
|
||||
let meta_opts =
|
||||
MetadataOptions::default().limit_visual_bytes(Limit::Maximum(8 * 1024 * 1024));
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &meta_opts)
|
||||
let mut format = symphonia::default::get_probe()
|
||||
.probe(&hint, mss, format_opts, meta_opts)
|
||||
.map_err(|e| {
|
||||
let hint_str = format_hint.unwrap_or("unknown");
|
||||
// Always print the raw Symphonia error to the terminal for diagnosis.
|
||||
@@ -160,30 +215,49 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
let track = probed.format
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
// Explicitly select only audio tracks: must have a valid codec and a
|
||||
// Explicitly select only audio tracks: must have an audio codec and a
|
||||
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
|
||||
// files embed as a secondary video track.
|
||||
.find(|t| {
|
||||
t.codec_params.codec != CODEC_TYPE_NULL
|
||||
&& t.codec_params.sample_rate.is_some()
|
||||
t.codec_params
|
||||
.as_ref()
|
||||
.and_then(|c| c.audio())
|
||||
.is_some_and(|a| a.sample_rate.is_some())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
|
||||
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", format.tracks().len());
|
||||
"no playable audio track found in file".to_string()
|
||||
})?;
|
||||
|
||||
let track_id = track.id;
|
||||
let total_duration = track.codec_params.time_base
|
||||
.zip(track.codec_params.n_frames)
|
||||
.map(|(base, frames)| base.calc_time(frames));
|
||||
// Encoder-delay-aware total duration (timebase units → Time).
|
||||
let total_duration = track
|
||||
.time_base
|
||||
.zip(track.num_frames)
|
||||
.and_then(|(base, frames)| {
|
||||
Timestamp::try_from(frames).ok().and_then(|ts| base.calc_time(ts))
|
||||
});
|
||||
|
||||
log_codec_resolution("bytes", &track.codec_params, format_hint);
|
||||
let audio_params = track
|
||||
.codec_params
|
||||
.as_ref()
|
||||
.and_then(|c| c.audio())
|
||||
.ok_or_else(|| "selected track has no audio codec parameters".to_string())?
|
||||
.clone();
|
||||
|
||||
log_codec_resolution("bytes", &audio_params, format_hint);
|
||||
|
||||
// Gapless trimming is performed by `build_source` (iTunSMPB), so disable
|
||||
// the decoder's built-in trimming to avoid double-trimming.
|
||||
let mut decoder = psysonic_codec_registry()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
|
||||
.map_err(|e| {
|
||||
crate::app_eprintln!("[psysonic] codec init failed: {e}");
|
||||
if e.to_string().to_lowercase().contains("unsupported") {
|
||||
@@ -193,15 +267,15 @@ impl SizedDecoder {
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
// Decode the first packet to initialise spec + buffer.
|
||||
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
|
||||
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
|
||||
let mut decode_errors: usize = 0;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Ok(Some(p)) => p,
|
||||
// Clean EOF before any decodable packet.
|
||||
Ok(None) => break decoder.last_decoded(),
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => {
|
||||
break decoder.last_decoded();
|
||||
}
|
||||
@@ -210,8 +284,8 @@ impl SizedDecoder {
|
||||
return Err(format!("could not read audio data: {e}"));
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
|
||||
if packet.track_id != track_id {
|
||||
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id, track_id);
|
||||
continue;
|
||||
}
|
||||
match decoder.decode(&packet) {
|
||||
@@ -230,8 +304,8 @@ impl SizedDecoder {
|
||||
}
|
||||
};
|
||||
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
let spec = decoded.spec().clone();
|
||||
let buffer = Self::make_buffer(&decoded);
|
||||
|
||||
Ok(SizedDecoder {
|
||||
decoder,
|
||||
@@ -247,39 +321,126 @@ impl SizedDecoder {
|
||||
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
|
||||
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
|
||||
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
|
||||
/// `source_random_access`: the underlying source can cheaply seek to EOF
|
||||
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
|
||||
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
|
||||
pub(crate) fn new_streaming(
|
||||
media: Box<dyn MediaSource>,
|
||||
format_hint: Option<&str>,
|
||||
source_tag: &str,
|
||||
source_random_access: bool,
|
||||
) -> Result<Self, String> {
|
||||
// For non-MP4 progressive streams, hide seekability during the probe so
|
||||
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
|
||||
// and block until the whole file is downloaded). Re-enabled right after.
|
||||
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
|
||||
// prefetched separately).
|
||||
//
|
||||
// Ogg also keeps seekability through the probe, but only on random-access
|
||||
// sources: its demuxer records `phys_byte_range_end` during the probe and
|
||||
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
|
||||
// local file the stream-end scan is cheap; on a progressive ranged stream
|
||||
// it would force a full download, so there we keep the gate and accept
|
||||
// that seeking is a no-op (the panic itself is contained in `try_seek`).
|
||||
let stream_len = media.byte_len();
|
||||
let ogg_needs_seekable_probe =
|
||||
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
|
||||
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
|
||||
&& !ogg_needs_seekable_probe;
|
||||
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
|
||||
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
|
||||
None => media,
|
||||
};
|
||||
|
||||
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
|
||||
// read() call frequency into the ring buffer, easing I/O spikes.
|
||||
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = format_hint { hint.with_extension(ext); }
|
||||
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &MetadataOptions::default())
|
||||
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
|
||||
let format_opts = FormatOptions::default();
|
||||
let meta_opts = MetadataOptions::default();
|
||||
|
||||
let track = probed.format.tracks().iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
crate::app_deprintln!(
|
||||
"[stream] {source_tag}: probe start (hint={}, stream_len={})",
|
||||
format_hint.unwrap_or("?"),
|
||||
stream_len.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
|
||||
);
|
||||
let probe_start = std::time::Instant::now();
|
||||
|
||||
// Run the probe on a dedicated thread guarded by a timeout. If a ranged
|
||||
// source stalls (download never reaches the bytes Symphonia needs), the
|
||||
// probe blocks forever; without this guard playback start would hang until
|
||||
// the user restarts the player. On timeout we abandon the worker thread
|
||||
// (it unblocks once the underlying read errors/returns) and surface an
|
||||
// error so the caller can retry.
|
||||
let hint_ext = format_hint.map(|s| s.to_string());
|
||||
let tag_owned = source_tag.to_string();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("symphonia-probe".into())
|
||||
.spawn(move || {
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = &hint_ext {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let result = symphonia::default::get_probe()
|
||||
.probe(&hint, mss, format_opts, meta_opts)
|
||||
.map_err(|e| format!("{tag_owned}: format probe failed: {e}"));
|
||||
// Receiver is gone if we already timed out — ignore the send error.
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("{source_tag}: failed to spawn probe thread: {e}"))?;
|
||||
|
||||
let mut format = match rx.recv_timeout(STREAM_PROBE_TIMEOUT) {
|
||||
Ok(Ok(format)) => format,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
crate::app_eprintln!(
|
||||
"[stream] {source_tag}: probe timed out after {STREAM_PROBE_TIMEOUT:?} \
|
||||
(stream stalled?) — aborting so the player can retry"
|
||||
);
|
||||
return Err(format!(
|
||||
"{source_tag}: format probe timed out after {STREAM_PROBE_TIMEOUT:?}"
|
||||
));
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(format!("{source_tag}: probe thread ended unexpectedly"));
|
||||
}
|
||||
};
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[stream] {source_tag}: probe done in {} ms",
|
||||
probe_start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
// Trailing-metadata scan is done; restore real seekability for scrubbing.
|
||||
if let Some(gate) = &probe_seek_gate {
|
||||
gate.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
let track = format.tracks().iter()
|
||||
.find(|t| t.codec_params.as_ref().and_then(|c| c.audio()).is_some())
|
||||
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
|
||||
let track_id = track.id;
|
||||
log_codec_resolution(source_tag, &track.codec_params, format_hint);
|
||||
let audio_params = track
|
||||
.codec_params
|
||||
.as_ref()
|
||||
.and_then(|c| c.audio())
|
||||
.ok_or_else(|| format!("{source_tag}: track has no audio codec parameters"))?
|
||||
.clone();
|
||||
log_codec_resolution(source_tag, &audio_params, format_hint);
|
||||
// Live streams have no known total frame count → total_duration = None.
|
||||
let total_duration = None;
|
||||
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
|
||||
let mut decoder = try_make_radio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
|
||||
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
|
||||
let mut format = probed.format;
|
||||
|
||||
let mut errors = 0usize;
|
||||
let decoded = loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => break decoder.last_decoded(),
|
||||
Err(_) => break decoder.last_decoded(),
|
||||
};
|
||||
if packet.track_id() != track_id { continue; }
|
||||
if packet.track_id != track_id { continue; }
|
||||
match decoder.decode(&packet) {
|
||||
Ok(d) => break d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
|
||||
@@ -292,16 +453,15 @@ impl SizedDecoder {
|
||||
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
|
||||
}
|
||||
};
|
||||
let spec = decoded.spec().to_owned();
|
||||
let buffer = Self::make_buffer(decoded, &spec);
|
||||
let spec = decoded.spec().clone();
|
||||
let buffer = Self::make_buffer(&decoded);
|
||||
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
|
||||
let duration = units::Duration::from(decoded.capacity() as u64);
|
||||
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
|
||||
buffer.copy_interleaved_ref(decoded);
|
||||
fn make_buffer(decoded: &GenericAudioBufferRef<'_>) -> Vec<f32> {
|
||||
let mut buffer = Vec::new();
|
||||
decoded.copy_to_vec_interleaved(&mut buffer);
|
||||
buffer
|
||||
}
|
||||
|
||||
@@ -311,29 +471,43 @@ impl SizedDecoder {
|
||||
&mut self,
|
||||
seek_res: symphonia::core::formats::SeekedTo,
|
||||
) -> Result<(), String> {
|
||||
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
|
||||
// Number of frames between where the demuxer landed and the requested ts.
|
||||
let mut samples_to_pass: u64 = seek_res
|
||||
.required_ts
|
||||
.get()
|
||||
.saturating_sub(seek_res.actual_ts.get())
|
||||
.max(0) as u64;
|
||||
let packet = loop {
|
||||
let candidate = self.format.next_packet()
|
||||
.map_err(|e| format!("refine seek: {e}"))?;
|
||||
if candidate.dur() > samples_to_pass {
|
||||
let candidate = match self.format.next_packet()
|
||||
.map_err(|e| format!("refine seek: {e}"))?
|
||||
{
|
||||
Some(p) => p,
|
||||
// EOF while refining — nothing more to skip.
|
||||
None => return Ok(()),
|
||||
};
|
||||
if candidate.dur.get() > samples_to_pass {
|
||||
break candidate;
|
||||
}
|
||||
samples_to_pass -= candidate.dur();
|
||||
samples_to_pass -= candidate.dur.get();
|
||||
};
|
||||
|
||||
let mut decoded = self.decoder.decode(&packet);
|
||||
for _ in 0..DECODE_MAX_RETRIES {
|
||||
if decoded.is_err() {
|
||||
let p = self.format.next_packet()
|
||||
.map_err(|e| format!("refine retry: {e}"))?;
|
||||
let p = match self.format.next_packet()
|
||||
.map_err(|e| format!("refine retry: {e}"))?
|
||||
{
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
decoded = self.decoder.decode(&p);
|
||||
}
|
||||
}
|
||||
|
||||
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
|
||||
self.spec = decoded.spec().clone();
|
||||
self.buffer = Self::make_buffer(&decoded);
|
||||
self.current_frame_offset = samples_to_pass as usize * self.spec.channels().count();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -349,12 +523,12 @@ impl Iterator for SizedDecoder {
|
||||
// drop the frame and advance to the next packet. IO errors and a
|
||||
// clean end-of-stream both terminate the iterator normally.
|
||||
loop {
|
||||
let packet = self.format.next_packet().ok()?;
|
||||
let packet = self.format.next_packet().ok()??;
|
||||
match self.decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
self.consecutive_decode_errors = 0;
|
||||
decoded.spec().clone_into(&mut self.spec);
|
||||
self.buffer = Self::make_buffer(decoded, &self.spec);
|
||||
self.spec = decoded.spec().clone();
|
||||
self.buffer = Self::make_buffer(&decoded);
|
||||
self.current_frame_offset = 0;
|
||||
break;
|
||||
}
|
||||
@@ -385,7 +559,7 @@ impl Iterator for SizedDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
|
||||
let sample = *self.buffer.get(self.current_frame_offset)?;
|
||||
self.current_frame_offset += 1;
|
||||
Some(sample)
|
||||
}
|
||||
@@ -394,25 +568,24 @@ impl Iterator for SizedDecoder {
|
||||
impl Source for SizedDecoder {
|
||||
#[inline]
|
||||
fn current_span_len(&self) -> Option<usize> {
|
||||
Some(self.buffer.samples().len())
|
||||
Some(self.buffer.len())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn channels(&self) -> rodio::ChannelCount {
|
||||
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
|
||||
std::num::NonZeroU16::new(self.spec.channels().count() as u16)
|
||||
.unwrap_or(std::num::NonZeroU16::MIN)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_rate(&self) -> rodio::SampleRate {
|
||||
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
|
||||
std::num::NonZeroU32::new(self.spec.rate()).unwrap_or(std::num::NonZeroU32::MIN)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
self.total_duration.map(|Time { seconds, frac }| {
|
||||
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
|
||||
})
|
||||
self.total_duration
|
||||
.map(|t| Duration::from_secs_f64(t.as_secs_f64().max(0.0)))
|
||||
}
|
||||
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
@@ -420,36 +593,51 @@ impl Source for SizedDecoder {
|
||||
.total_duration()
|
||||
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
|
||||
|
||||
let time: Time = if seek_beyond_end {
|
||||
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
|
||||
let target_secs = if seek_beyond_end {
|
||||
// Step back a tiny bit — some demuxers can't seek to the exact end.
|
||||
let mut secs = t.seconds;
|
||||
let mut frac = t.frac - 0.0001;
|
||||
if frac < 0.0 {
|
||||
secs = secs.saturating_sub(1);
|
||||
frac = 1.0 - frac;
|
||||
}
|
||||
Time { seconds: secs, frac }
|
||||
let total = self
|
||||
.total_duration
|
||||
.map(|t| t.as_secs_f64())
|
||||
.unwrap_or_else(|| pos.as_secs_f64());
|
||||
(total - 0.0001).max(0.0)
|
||||
} else {
|
||||
pos.as_secs_f64().into()
|
||||
pos.as_secs_f64()
|
||||
};
|
||||
|
||||
let time = Time::try_from_secs_f64(target_secs).unwrap_or(Time::ZERO);
|
||||
|
||||
let to_skip = self.current_frame_offset % self.channels().get() as usize;
|
||||
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e.to_string()))
|
||||
))?;
|
||||
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
|
||||
// `None` in `OggReader::do_seek`) on some streams instead of returning
|
||||
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
|
||||
// panic poisons the engine mutexes and then aborts the whole process at
|
||||
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
|
||||
// symptom of that poison). Contain the unwind here — including the packet
|
||||
// reads in `refine_position`, which can hit the same broken demuxer state —
|
||||
// and surface it as a recoverable `SeekError` so the engine stays alive
|
||||
// (the seek becomes a no-op rather than killing playback).
|
||||
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let seek_res = self
|
||||
.format
|
||||
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.refine_position(seek_res)?;
|
||||
Ok::<(), String>(())
|
||||
}));
|
||||
|
||||
self.refine_position(seek_res)
|
||||
.map_err(|e| rodio::source::SeekError::Other(
|
||||
std::sync::Arc::new(std::io::Error::other(e))
|
||||
))?;
|
||||
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
match seek_outcome {
|
||||
Ok(Ok(())) => {
|
||||
self.current_frame_offset += to_skip;
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other(e),
|
||||
))),
|
||||
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
|
||||
std::io::Error::other("seek panicked inside the demuxer (contained)"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,8 +1029,8 @@ mod tests {
|
||||
fn sized_decoder_constructs_from_synthetic_wav() {
|
||||
let wav = synthetic_wav_bytes(0.5);
|
||||
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
|
||||
assert_eq!(decoder.spec.rate, 44_100);
|
||||
assert_eq!(decoder.spec.channels.count(), 1);
|
||||
assert_eq!(decoder.spec.rate(), 44_100);
|
||||
assert_eq!(decoder.spec.channels().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -857,21 +1045,86 @@ mod tests {
|
||||
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
|
||||
}
|
||||
|
||||
// ── new_streaming + ProbeSeekGate ────────────────────────────────────────
|
||||
|
||||
fn seekable_source(bytes: Vec<u8>) -> Box<dyn MediaSource> {
|
||||
let len = bytes.len() as u64;
|
||||
Box::new(SizedCursorSource { inner: Cursor::new(bytes), len })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_streaming_constructs_from_synthetic_wav() {
|
||||
let wav = synthetic_wav_bytes(0.5);
|
||||
let decoder =
|
||||
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
|
||||
.expect("streaming WAV decode setup");
|
||||
assert_eq!(decoder.spec.rate(), 44_100);
|
||||
assert_eq!(decoder.spec.channels().count(), 1);
|
||||
// Live streams report no total duration.
|
||||
assert!(decoder.total_duration.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_streaming_returns_err_for_garbage_input() {
|
||||
let result = SizedDecoder::new_streaming(
|
||||
seekable_source(vec![0x00u8; 64]),
|
||||
None,
|
||||
"test-stream",
|
||||
true,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_seek_gate_toggles_seekability() {
|
||||
let wav = synthetic_wav_bytes(0.1);
|
||||
let len = wav.len() as u64;
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let gate = ProbeSeekGate {
|
||||
inner: seekable_source(wav),
|
||||
seekable: flag.clone(),
|
||||
};
|
||||
// Hidden during probe …
|
||||
assert!(!gate.is_seekable());
|
||||
// … restored afterwards.
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
assert!(gate.is_seekable());
|
||||
// byte_len always passes through to the inner source.
|
||||
assert_eq!(gate.byte_len(), Some(len));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_seek_gate_read_and_seek_pass_through() {
|
||||
let bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
|
||||
let mut gate = ProbeSeekGate {
|
||||
inner: seekable_source(bytes),
|
||||
seekable: Arc::new(AtomicBool::new(true)),
|
||||
};
|
||||
let mut buf = [0u8; 4];
|
||||
let n = gate.read(&mut buf).expect("read");
|
||||
assert_eq!(n, 4);
|
||||
assert_eq!(&buf, &[1, 2, 3, 4]);
|
||||
let pos = gate.seek(std::io::SeekFrom::Start(6)).expect("seek");
|
||||
assert_eq!(pos, 6);
|
||||
let n = gate.read(&mut buf).expect("read after seek");
|
||||
assert_eq!(&buf[..n], &[7, 8]);
|
||||
}
|
||||
|
||||
// ── log_codec_resolution ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn log_codec_resolution_does_not_panic_for_valid_params() {
|
||||
let mut params = symphonia::core::codecs::CodecParameters::new();
|
||||
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
|
||||
let mut params = AudioCodecParameters::new();
|
||||
params.codec = symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE;
|
||||
params.sample_rate = Some(44_100);
|
||||
params.bits_per_sample = Some(16);
|
||||
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
|
||||
params.channels = Some(symphonia::core::audio::Channels::Discrete(1));
|
||||
log_codec_resolution("test-tag", ¶ms, Some("wav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_codec_resolution_handles_unknown_codec_gracefully() {
|
||||
let params = symphonia::core::codecs::CodecParameters::new();
|
||||
let params = AudioCodecParameters::new();
|
||||
log_codec_resolution("unknown", ¶ms, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,74 +2,140 @@
|
||||
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
|
||||
//! enumeration + reopen path.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use super::dev_io::{
|
||||
enumerate_output_device_names, output_devices_logically_same,
|
||||
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
|
||||
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>,
|
||||
@@ -78,16 +144,13 @@ pub async fn audio_set_device(
|
||||
*state.selected_device.lock().unwrap() = device_name.clone();
|
||||
|
||||
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
state.stream_reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
}).await.unwrap_or(None).ok_or("device open timed out")?;
|
||||
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
state.device_default_rate
|
||||
};
|
||||
super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
|
||||
.map_err(|_| "device open timed out".to_string())?;
|
||||
|
||||
// Capture position and drop the active sink atomically so the position
|
||||
// reading is still valid (play_started / paused_at intact before take).
|
||||
|
||||
@@ -23,10 +23,11 @@ use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::play_input::{
|
||||
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint,
|
||||
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs,
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{
|
||||
build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource,
|
||||
};
|
||||
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
@@ -86,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
@@ -159,6 +161,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
resample_target_hz: 0,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
@@ -187,9 +190,14 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let sink = Arc::new(Player::connect_new(
|
||||
engine.stream_handle.lock().unwrap().mixer(),
|
||||
));
|
||||
let stream = match super::engine::ensure_output_stream_open(&engine) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
@@ -205,6 +213,8 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
outgoing_fade_secs: 0.0,
|
||||
start_paused: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -253,6 +263,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
engine.chained_info.clone(),
|
||||
engine.crossfade_enabled.clone(),
|
||||
engine.crossfade_secs.clone(),
|
||||
engine.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
Some(analysis_app),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Poll default output device and pinned-device presence; reopen stream when needed.
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::Emitter;
|
||||
@@ -41,8 +40,11 @@ pub(crate) async fn reopen_output_stream(
|
||||
};
|
||||
|
||||
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||
let stream_handle = engine.stream_handle.clone();
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
@@ -62,25 +64,33 @@ pub(crate) async fn reopen_output_stream(
|
||||
}
|
||||
};
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
if reopen_tx
|
||||
.send((rate, false, device_name, reply_tx))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
|
||||
let app_for_open = app.clone();
|
||||
let device_name_for_open = device_name.clone();
|
||||
let opened = tauri::async_runtime::spawn_blocking(move || {
|
||||
let engine = app_for_open.state::<AudioEngine>();
|
||||
super::engine::open_output_stream_blocking(
|
||||
&engine,
|
||||
open_rate,
|
||||
false,
|
||||
device_name_for_open,
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
.unwrap_or(false);
|
||||
|
||||
let Some(handle) = new_handle else {
|
||||
if !opened {
|
||||
return false;
|
||||
};
|
||||
|
||||
*stream_handle.lock().unwrap() = handle;
|
||||
}
|
||||
// When we're not actively playing (paused/stopped), bump the generation
|
||||
// before stopping the old sink so the still-running progress task sees the
|
||||
// mismatch and bails out instead of emitting a spurious `audio:ended` —
|
||||
// which would otherwise trigger a frontend restart of paused playback
|
||||
// (#1094). The active-playback path bumps inside
|
||||
// `try_resume_after_device_change`, so only guard the non-playing case here.
|
||||
if !snapshot.is_playing {
|
||||
engine.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(s) = current.lock().unwrap().sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
@@ -122,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.
|
||||
@@ -224,10 +231,18 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate all available output devices and the current default.
|
||||
// The full `output_devices()` + per-device `description()` scan is the
|
||||
// CoreAudio HAL call that contends with the audio render thread and
|
||||
// produces a brief dropout once per poll interval (issue #996: stutter
|
||||
// every ~3s, cadence tracking the poll exactly). It is only needed to
|
||||
// detect a *pinned* output device disappearing. With no pin — system
|
||||
// default, the common case — only the current default is needed, a
|
||||
// single cheap query, so the full enumeration is skipped entirely.
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
let need_full_enum = pinned.is_some();
|
||||
|
||||
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(unix)]
|
||||
let _guard = unsafe {
|
||||
struct StderrGuard(i32);
|
||||
@@ -240,30 +255,24 @@ 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 available: Vec<String> = host
|
||||
.output_devices()
|
||||
.map(|iter| {
|
||||
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let default = super::dev_io::effective_default_output_device_name_for_poll();
|
||||
let available: Vec<String> = if need_full_enum {
|
||||
super::dev_io::enumerate_output_device_names()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(default, available)
|
||||
}).await.unwrap_or((None, vec![]));
|
||||
|
||||
// Empty list almost always means a transient enumeration failure, not
|
||||
// that every output device vanished. Treating it as "pinned missing"
|
||||
// caused false audio:device-reset (UI jumped back to system default)
|
||||
// when switching to external USB / class-compliant interfaces.
|
||||
if available.is_empty() {
|
||||
// Empty list (only when we actually enumerated for a pinned device)
|
||||
// almost always means a transient enumeration failure, not that every
|
||||
// output device vanished. Treating it as "pinned missing" caused false
|
||||
// audio:device-reset (UI jumped back to system default) when switching
|
||||
// to external USB / class-compliant interfaces.
|
||||
if need_full_enum && available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = selected_device.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if pinned.is_some() {
|
||||
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||
@@ -305,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");
|
||||
}
|
||||
|
||||
@@ -4,24 +4,36 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
||||
|
||||
/// Reply channel handed back to the audio-stream thread once a re-open finishes.
|
||||
pub type StreamReopenReply = std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>;
|
||||
/// Stream-thread re-open request: `(desired_rate, is_hi_res, device_name, reply_tx)`.
|
||||
pub type StreamReopenRequest = (u32, bool, Option<String>, StreamReopenReply);
|
||||
/// Reply channel handed back to the audio-stream thread once an open finishes.
|
||||
pub type StreamOpenReply =
|
||||
std::sync::mpsc::SyncSender<(Arc<rodio::MixerDeviceSink>, u32)>;
|
||||
|
||||
/// Requests handled on the dedicated audio-stream thread (open / idle release).
|
||||
pub enum StreamThreadMsg {
|
||||
Open {
|
||||
desired_rate: u32,
|
||||
is_hi_res: bool,
|
||||
device_name: Option<String>,
|
||||
reply: StreamOpenReply,
|
||||
},
|
||||
Release {
|
||||
reply: std::sync::mpsc::SyncSender<()>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
|
||||
pub stream_handle: Arc<std::sync::Mutex<Option<Arc<rodio::MixerDeviceSink>>>>,
|
||||
/// Sample rate the output stream was last opened at (updated on every re-open).
|
||||
pub stream_sample_rate: Arc<AtomicU32>,
|
||||
/// The rate the device was opened at on cold start — used to restore the
|
||||
/// stream when Hi-Res is toggled off while a hi-res rate is active.
|
||||
pub device_default_rate: u32,
|
||||
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
|
||||
/// thread to re-open the output device. `device_name = None` → system default.
|
||||
pub stream_reopen_tx: std::sync::mpsc::SyncSender<StreamReopenRequest>,
|
||||
/// Open or release the CPAL output stream on the audio-stream thread.
|
||||
pub stream_thread_tx: std::sync::mpsc::SyncSender<StreamThreadMsg>,
|
||||
/// User-selected output device name (None = follow system default).
|
||||
pub selected_device: Arc<Mutex<Option<String>>>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
@@ -49,6 +61,15 @@ pub struct AudioEngine {
|
||||
pub(crate) stream_playback_armed: Arc<AtomicBool>,
|
||||
pub crossfade_enabled: Arc<AtomicBool>,
|
||||
pub crossfade_secs: Arc<AtomicU32>,
|
||||
/// AutoDJ: when true, the progress task does NOT fire its autonomous
|
||||
/// `crossfade_secs`-before-end `audio:ended` timer — the JS A-tail logic
|
||||
/// drives every advance (gated on the next track being playable). Prevents
|
||||
/// the engine from starting a still-buffering next track and fading over it
|
||||
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
|
||||
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
|
||||
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
|
||||
/// outgoing sink; block normalization/volume ramps until the handoff swap.
|
||||
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
|
||||
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
|
||||
/// When true, audio_play chains sources to the existing Sink instead of
|
||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||
@@ -147,6 +168,15 @@ impl AudioCurrent {
|
||||
/// 3. Device default.
|
||||
/// 4. System default (last resort).
|
||||
///
|
||||
/// Rodio prints a stderr line on every intentional stream drop. Keep that only
|
||||
/// when runtime logging is in **debug** mode; normal/off silence the noise.
|
||||
fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc<rodio::MixerDeviceSink> {
|
||||
if !crate::logging::should_log_debug() {
|
||||
handle.log_on_drop(false);
|
||||
}
|
||||
Arc::new(handle)
|
||||
}
|
||||
|
||||
/// Returns `(stream_handle, actual_sample_rate)`.
|
||||
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
@@ -176,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 }
|
||||
})
|
||||
@@ -214,7 +246,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
|
||||
{
|
||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
|
||||
return (Arc::new(handle), desired_rate);
|
||||
return (finalize_mixer_device_sink(handle), desired_rate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +263,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
|
||||
rate, desired_rate
|
||||
);
|
||||
return (Arc::new(handle), rate);
|
||||
return (finalize_mixer_device_sink(handle), rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +276,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44100);
|
||||
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
|
||||
return (Arc::new(handle), rate);
|
||||
return (finalize_mixer_device_sink(handle), rate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +289,78 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
|
||||
.and_then(|d| d.default_output_config().ok())
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44100);
|
||||
(Arc::new(handle), rate)
|
||||
(finalize_mixer_device_sink(handle), rate)
|
||||
}
|
||||
|
||||
fn probe_device_default_rate() -> u32 {
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
rodio::cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.default_output_config().ok())
|
||||
.map(|c| c.sample_rate())
|
||||
.unwrap_or(44_100)
|
||||
}
|
||||
|
||||
/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`.
|
||||
pub(crate) fn open_output_stream_blocking(
|
||||
engine: &AudioEngine,
|
||||
desired_rate: u32,
|
||||
is_hi_res: bool,
|
||||
device_name: Option<String>,
|
||||
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
|
||||
let rate = if desired_rate > 0 {
|
||||
desired_rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
|
||||
engine
|
||||
.stream_thread_tx
|
||||
.send(StreamThreadMsg::Open {
|
||||
desired_rate: rate,
|
||||
is_hi_res,
|
||||
device_name,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (handle, actual_rate) = reply_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.map_err(|_| "audio stream open timed out".to_string())?;
|
||||
engine
|
||||
.stream_sample_rate
|
||||
.store(actual_rate, std::sync::atomic::Ordering::Relaxed);
|
||||
*engine.stream_handle.lock().unwrap() = Some(handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Ensure a live output stream exists; lazy-opens on first playback.
|
||||
pub(crate) fn ensure_output_stream_open(
|
||||
engine: &AudioEngine,
|
||||
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
|
||||
if let Some(handle) = engine.stream_handle.lock().unwrap().clone() {
|
||||
return Ok(handle);
|
||||
}
|
||||
let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let open_rate = if rate > 0 {
|
||||
rate
|
||||
} else {
|
||||
engine.device_default_rate
|
||||
};
|
||||
let device = engine.selected_device.lock().unwrap().clone();
|
||||
open_output_stream_blocking(engine, open_rate, false, device)
|
||||
}
|
||||
|
||||
pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
|
||||
engine
|
||||
.stream_thread_tx
|
||||
.send(StreamThreadMsg::Release { reply: reply_tx })
|
||||
.map_err(|e| e.to_string())?;
|
||||
reply_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.map_err(|_| "audio stream release timed out".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
@@ -269,13 +372,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Channels: main thread ←→ audio-stream thread.
|
||||
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
|
||||
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
|
||||
let (init_tx, init_rx) =
|
||||
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
|
||||
let (reopen_tx, reopen_rx) =
|
||||
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
|
||||
// Channel: main thread ←→ audio-stream thread (lazy open + idle release).
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0);
|
||||
let (stream_thread_tx, stream_thread_rx) =
|
||||
std::sync::mpsc::sync_channel::<StreamThreadMsg>(4);
|
||||
|
||||
let device_default_rate = probe_device_default_rate();
|
||||
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("psysonic-audio-stream".into())
|
||||
@@ -296,52 +398,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
// Thread priority is kept at default during standard-mode playback.
|
||||
// It is escalated to Max only when a Hi-Res stream reopen is requested,
|
||||
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
|
||||
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
|
||||
let handle = _stream.clone();
|
||||
init_tx.send((handle, rate)).ok();
|
||||
let mut _stream: Option<Arc<rodio::MixerDeviceSink>> = None;
|
||||
ready_tx.send(()).ok();
|
||||
|
||||
// Keep the stream alive and handle sample-rate / device-switch requests.
|
||||
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
// elevated priority would only waste scheduler budget.
|
||||
if is_hi_res {
|
||||
thread_priority::set_current_thread_priority(
|
||||
thread_priority::ThreadPriority::Max
|
||||
).ok();
|
||||
while let Ok(msg) = stream_thread_rx.recv() {
|
||||
match msg {
|
||||
StreamThreadMsg::Release { reply } => {
|
||||
_stream = None;
|
||||
let _ = reply.send(());
|
||||
}
|
||||
StreamThreadMsg::Open {
|
||||
desired_rate,
|
||||
is_hi_res,
|
||||
device_name,
|
||||
reply,
|
||||
} => {
|
||||
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
|
||||
// real-time scheduling to avoid underruns). No escalation for
|
||||
// standard mode — the thread blocks on recv() between reopens so
|
||||
// elevated priority would only waste scheduler budget.
|
||||
if is_hi_res {
|
||||
thread_priority::set_current_thread_priority(
|
||||
thread_priority::ThreadPriority::Max,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
_stream = None;
|
||||
|
||||
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||
// latency stays roughly constant (≈93 ms) at all rates.
|
||||
#[cfg(target_os = "linux")]
|
||||
if desired_rate > 0 {
|
||||
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||
let latency_ms =
|
||||
(frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, actual_rate) =
|
||||
open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||
let new_handle = new_stream.clone();
|
||||
_stream = Some(new_stream);
|
||||
let _ = reply.send((new_handle, actual_rate));
|
||||
}
|
||||
}
|
||||
|
||||
drop(_stream); // close old stream before opening new one
|
||||
|
||||
// Scale the PipeWire quantum with the sample rate so wall-clock
|
||||
// latency stays roughly constant (≈93 ms) at all rates.
|
||||
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
|
||||
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
|
||||
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
|
||||
// get the same wall-clock quantum as PipeWire.
|
||||
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
|
||||
}
|
||||
|
||||
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
|
||||
let new_handle = new_stream.clone();
|
||||
_stream = new_stream;
|
||||
reply_tx.send(new_handle).ok();
|
||||
}
|
||||
})
|
||||
.expect("spawn audio stream thread");
|
||||
|
||||
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
|
||||
ready_rx.recv().expect("audio stream thread ready");
|
||||
|
||||
let engine = AudioEngine {
|
||||
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
|
||||
device_default_rate: initial_rate,
|
||||
stream_reopen_tx: reopen_tx,
|
||||
stream_handle: Arc::new(std::sync::Mutex::new(None)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
device_default_rate,
|
||||
stream_thread_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
@@ -374,6 +487,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
@@ -458,3 +573,75 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
|
||||
*slot = client;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_playback_request_headers(
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: Option<&str>,
|
||||
url: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
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.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PlaybackHttpHeaders {
|
||||
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
|
||||
server_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PlaybackHttpHeaders {
|
||||
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
|
||||
Self {
|
||||
registry: app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s)),
|
||||
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
apply_playback_request_headers(
|
||||
self.registry.as_deref(),
|
||||
self.server_id.as_deref(),
|
||||
url,
|
||||
req,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scoped_http_get(
|
||||
state: &AudioEngine,
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_id: Option<&str>,
|
||||
url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
apply_playback_request_headers(
|
||||
registry,
|
||||
server_id,
|
||||
url,
|
||||
audio_http_client(state).get(url),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve registry + server id for playback/preload HTTP GETs.
|
||||
pub(crate) fn playback_scoped_get(
|
||||
state: &AudioEngine,
|
||||
app: &tauri::AppHandle,
|
||||
url: &str,
|
||||
server_id: Option<&str>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let registry = app
|
||||
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
|
||||
.map(|s| Arc::clone(&*s));
|
||||
let sid = server_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
|
||||
scoped_http_get(
|
||||
state,
|
||||
registry.as_deref(),
|
||||
sid.as_deref(),
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -708,7 +708,10 @@ pub(crate) async fn fetch_data(
|
||||
return Ok(Some(data));
|
||||
}
|
||||
|
||||
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
|
||||
let response = crate::engine::playback_scoped_get(state, app, url, None)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let ct = response.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
@@ -805,6 +808,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
|
||||
gain_linear_to_db(gain_linear)
|
||||
}
|
||||
|
||||
static SINK_VOLUME_RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Cancel any in-flight sink-volume ramp (new ramp wins).
|
||||
pub(crate) fn cancel_sink_volume_ramp() {
|
||||
SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Audible sink multiplier — may differ from `base_volume * replay_gain` after
|
||||
/// interrupt prep or a mid-ramp correction.
|
||||
pub(crate) fn sink_volume_now(sink: &Player) -> f32 {
|
||||
sink.volume().clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
@@ -812,8 +828,7 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
let delta = (to - from).abs();
|
||||
// Stretch large corrections to avoid audible "step down" moments.
|
||||
@@ -826,18 +841,46 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
|
||||
} else {
|
||||
(8, 16)
|
||||
};
|
||||
for i in 1..=steps {
|
||||
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
/// Linear sink-volume ramp over an explicit wall-clock duration (interrupt prep).
|
||||
pub(crate) fn ramp_sink_volume_over_secs(sink: Arc<Player>, from: f32, to: f32, secs: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
if (to - from).abs() < 0.002 {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let secs = secs.clamp(0.1, 12.0);
|
||||
let step_ms: u64 = 20;
|
||||
let steps = ((secs * 1000.0) / step_ms as f32).round().max(1.0) as usize;
|
||||
std::thread::spawn(move || {
|
||||
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
|
||||
});
|
||||
}
|
||||
|
||||
fn ramp_sink_volume_steps(
|
||||
sink: Arc<Player>,
|
||||
from: f32,
|
||||
to: f32,
|
||||
steps: usize,
|
||||
step_ms: u64,
|
||||
my_gen: u64,
|
||||
) {
|
||||
for i in 1..=steps {
|
||||
if SINK_VOLUME_RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
|
||||
//! AutoDJ, or gapless must cross a sample-rate boundary.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::playback_rate::raw_counter_samples_for_content_position;
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
const BLEND_44100: u32 = 44_100;
|
||||
const BLEND_88200: u32 = 88_200;
|
||||
const BLEND_96000: u32 = 96_000;
|
||||
|
||||
/// User-selected blend rate for hi-res transitions; `None` when inactive.
|
||||
pub(crate) fn blend_rate_hz(
|
||||
hi_res_enabled: bool,
|
||||
transition_blend_active: bool,
|
||||
hz: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
if !hi_res_enabled || !transition_blend_active {
|
||||
return None;
|
||||
}
|
||||
let raw = hz.unwrap_or(BLEND_44100);
|
||||
match raw {
|
||||
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
|
||||
_ => Some(BLEND_44100),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OutgoingBlendSnapshot {
|
||||
pub(crate) url: String,
|
||||
pub(crate) position_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Capture the currently playing track before a hi-res blend stream reopen.
|
||||
pub(crate) fn capture_outgoing_blend_snapshot(
|
||||
state: &AudioEngine,
|
||||
outgoing_fade_secs: f32,
|
||||
actual_fade_secs: f32,
|
||||
) -> Option<OutgoingBlendSnapshot> {
|
||||
let url = state.current_playback_url.lock().unwrap().clone()?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let playing = cur.sink.is_some() && cur.paused_at.is_none();
|
||||
(
|
||||
cur.position(),
|
||||
cur.duration_secs,
|
||||
cur.base_volume,
|
||||
cur.replay_gain_linear,
|
||||
playing,
|
||||
)
|
||||
};
|
||||
if !playing {
|
||||
return None;
|
||||
}
|
||||
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
|
||||
Some(OutgoingBlendSnapshot {
|
||||
url,
|
||||
position_secs,
|
||||
duration_secs,
|
||||
base_volume,
|
||||
gain_linear,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
analysis_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the live main sink so a stream reopen does not leave dangling players.
|
||||
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() {
|
||||
old.stop();
|
||||
}
|
||||
cur.fadeout_trigger = None;
|
||||
cur.fadeout_samples = None;
|
||||
}
|
||||
|
||||
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
|
||||
if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
return Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[hi-res-blend]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
});
|
||||
}
|
||||
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|t| t.url == url)
|
||||
.map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|s| s.url == url)
|
||||
.map(|s| s.path.clone())
|
||||
};
|
||||
let path = spill_path?;
|
||||
std::fs::read(&path).ok()?
|
||||
};
|
||||
Some(PlayInput::Bytes(bytes))
|
||||
}
|
||||
|
||||
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
|
||||
pub(crate) async fn spawn_outgoing_blend_resample(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
let fade_secs = snap.outgoing_fade_secs;
|
||||
if fade_secs > 0.0 {
|
||||
let rate = blend_rate;
|
||||
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
ps.built
|
||||
.fadeout_samples
|
||||
.store(fade_total.max(1), Ordering::SeqCst);
|
||||
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
sink.play();
|
||||
*state.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
|
||||
snap.position_secs,
|
||||
fade_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
|
||||
/// chain realign) so the next source can append to the same sink.
|
||||
pub(crate) async fn rebuild_current_track_at_blend_rate(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] current track not cached for gapless realign: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
state
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
sink.play();
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = ps.built.duration_secs;
|
||||
cur.seek_offset = snap.position_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = snap.gain_linear;
|
||||
cur.base_volume = snap.base_volume;
|
||||
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
|
||||
cur.fadeout_samples = Some(ps.built.fadeout_samples);
|
||||
}
|
||||
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
snap.position_secs,
|
||||
ps.built.output_rate,
|
||||
ps.built.output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
|
||||
snap.position_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_rate_inactive_without_hi_res_or_transition() {
|
||||
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
|
||||
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_rate_sanitizes_hz() {
|
||||
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ pub mod device_commands;
|
||||
pub mod mix_commands;
|
||||
mod play_input;
|
||||
pub mod playback_rate;
|
||||
mod sink_swap;
|
||||
mod source_build;
|
||||
mod preserve_worker;
|
||||
pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
@@ -24,6 +26,7 @@ pub mod transport_commands;
|
||||
mod device_resume;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
mod stream_idle;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod power_resume;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -31,6 +34,7 @@ mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod hi_res_blend;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
@@ -39,6 +43,7 @@ mod stream;
|
||||
|
||||
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
|
||||
pub use device_watcher::start_device_watcher;
|
||||
pub use stream_idle::start_stream_idle_watcher;
|
||||
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
|
||||
pub use helpers::{
|
||||
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
|
||||
|
||||
@@ -11,17 +11,19 @@ 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();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
@@ -105,11 +107,19 @@ pub fn audio_update_replay_gain(
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
|
||||
// ramp A back to full gain before the handoff swap.
|
||||
return;
|
||||
}
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
let prev_effective = sink_volume_now(sink);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
drop(cur);
|
||||
@@ -124,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);
|
||||
@@ -133,17 +144,50 @@ 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);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let Some(sink) = cur.sink.as_ref() else {
|
||||
return;
|
||||
};
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(true, Ordering::Relaxed);
|
||||
cancel_sink_volume_ramp();
|
||||
let from = sink_volume_now(sink);
|
||||
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
|
||||
}
|
||||
|
||||
/// AutoDJ: when `true`, the progress task stops firing its autonomous
|
||||
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
|
||||
/// (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
|
||||
.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_playback_rate(
|
||||
enabled: bool,
|
||||
strategy: String,
|
||||
@@ -227,6 +271,7 @@ pub fn audio_set_playback_rate(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
|
||||
@@ -15,11 +15,10 @@ use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
|
||||
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
|
||||
same_playback_target,
|
||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
};
|
||||
@@ -41,6 +40,9 @@ pub(crate) enum PlayInput {
|
||||
reader: Box<dyn MediaSource>,
|
||||
format_hint: Option<String>,
|
||||
tag: &'static str,
|
||||
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
|
||||
/// seekability through the probe so its seek path does not panic.
|
||||
random_access: bool,
|
||||
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
|
||||
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
|
||||
},
|
||||
@@ -202,6 +204,7 @@ fn open_local_file_input(
|
||||
reader: Box::new(reader),
|
||||
format_hint: local_hint,
|
||||
tag: "local-file",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
})
|
||||
}
|
||||
@@ -214,7 +217,12 @@ async fn open_ranged_or_streaming_input(
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<PlayInput>, String> {
|
||||
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
|
||||
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
|
||||
let response = http_headers
|
||||
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
if state.generation.load(Ordering::SeqCst) != ctx.gen {
|
||||
return Ok(None); // superseded
|
||||
@@ -253,8 +261,8 @@ async fn open_ranged_or_streaming_input(
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = audio_http_client(state)
|
||||
.get(ctx.url)
|
||||
if let Ok(pr) = http_headers
|
||||
.apply(ctx.url, audio_http_client(state).get(ctx.url))
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
@@ -325,12 +333,28 @@ async fn open_ranged_or_streaming_input(
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
http_headers.clone(),
|
||||
loudness_hold_for_defer,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
// On-demand random-access fetcher: lets seeks (Ogg bisection, end-of-
|
||||
// stream probe, forward scrubs) pull arbitrary byte ranges over HTTP
|
||||
// Range instead of blocking until the linear filler reaches the target.
|
||||
// This is what makes seeking work on a still-downloading Opus/Ogg stream
|
||||
// (previously a contained no-op) without forcing a full pre-download.
|
||||
let on_demand = Some(Arc::new(super::stream::OnDemand::new(
|
||||
audio_http_client(state),
|
||||
tokio::runtime::Handle::current(),
|
||||
ctx.url.to_string(),
|
||||
buf.clone(),
|
||||
total,
|
||||
state.generation.clone(),
|
||||
ctx.gen,
|
||||
http_headers.clone(),
|
||||
)));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
@@ -341,11 +365,16 @@ async fn open_ranged_or_streaming_input(
|
||||
done,
|
||||
gen_arc: state.generation.clone(),
|
||||
gen: ctx.gen,
|
||||
on_demand,
|
||||
};
|
||||
return Ok(Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
format_hint: stream_hint,
|
||||
tag: "ranged-stream",
|
||||
// The on-demand fetcher makes a seek-to-EOF during the probe cheap,
|
||||
// so Ogg can stay seekable through the probe (records its byte range
|
||||
// → real seeking) without forcing a full download.
|
||||
random_access: true,
|
||||
mp4_probe_gate,
|
||||
}));
|
||||
}
|
||||
@@ -379,6 +408,7 @@ async fn open_ranged_or_streaming_input(
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
ctx.cache_id_for_tasks.map(|s| s.to_string()),
|
||||
ctx.server_id.map(|s| s.to_string()),
|
||||
http_headers,
|
||||
playback_armed,
|
||||
));
|
||||
|
||||
@@ -401,47 +431,6 @@ async fn open_ranged_or_streaming_input(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
|
||||
/// playback, then reset counters and emit `audio:playing` so the UI does not
|
||||
/// extrapolate ahead of audible output.
|
||||
pub(super) fn spawn_legacy_stream_start_when_armed(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
current: Arc<Mutex<super::engine::AudioCurrent>>,
|
||||
app: AppHandle,
|
||||
duration_secs: f64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if playback_armed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
samples_played.store(0, Ordering::Relaxed);
|
||||
let sink = current.lock().unwrap().sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
{
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = Some(std::time::Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
}
|
||||
sink.play();
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pulled out of the format_hint extraction block in `audio_play` — strip the
|
||||
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
/// don't latch onto random query-param substrings; only accept short
|
||||
@@ -460,459 +449,3 @@ pub(crate) fn url_format_hint(url: &str) -> Option<String> {
|
||||
})
|
||||
.map(|s| s.to_lowercase())
|
||||
}
|
||||
|
||||
/// Arguments forwarded from `audio_play` into the source-build pipeline.
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
pub server_id: Option<&'a str>,
|
||||
pub url_format_hint: Option<&'a str>,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
use std::time::Instant;
|
||||
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if let Some(old) = old_sink {
|
||||
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
|
||||
// Calculate total fade samples from the measured actual_fade_secs.
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// Keep old sink alive until the fade completes + small margin,
|
||||
// then drop it. No volume stepping needed — the fade-out runs
|
||||
// at sample level inside the audio thread.
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
|
||||
match input {
|
||||
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
|
||||
format_hint.clone()
|
||||
}
|
||||
PlayInput::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged HTTP probe/decode failed in a way that may succeed after the
|
||||
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
|
||||
fn is_ranged_stream_probe_failure(err: &str) -> bool {
|
||||
err.contains("ranged-stream")
|
||||
&& (err.contains("format probe failed")
|
||||
|| err.contains("moov metadata")
|
||||
|| err.contains("end of stream"))
|
||||
}
|
||||
|
||||
/// Completed ranged download or spill file for `url`, if ready.
|
||||
async fn try_take_completed_stream_bytes(
|
||||
url: &str,
|
||||
state: &State<'_, AudioEngine>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
|
||||
return Some(data);
|
||||
}
|
||||
let spill_path = {
|
||||
let guard = state.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, url))
|
||||
.map(|p| p.path.clone())
|
||||
};
|
||||
if let Some(path) = spill_path {
|
||||
let data = tokio::fs::read(&path).await.ok()?;
|
||||
if !data.is_empty() {
|
||||
return Some(data);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
|
||||
async fn prefer_clean_http_bytes_for_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
ranged_data: Vec<u8>,
|
||||
format_hint: Option<&str>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
|
||||
if is_mp4 {
|
||||
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
|
||||
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|
||||
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
|
||||
);
|
||||
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
|
||||
if super::stream::isobmff_buffer_looks_complete(&fresh) {
|
||||
return Ok(Some(fresh));
|
||||
}
|
||||
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ranged_data))
|
||||
}
|
||||
|
||||
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
|
||||
pub(super) async fn wait_or_fetch_bytes_for_stream_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: using completed download ({} KiB)",
|
||||
data.len() / 1024
|
||||
);
|
||||
return prefer_clean_http_bytes_for_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
data,
|
||||
format_hint,
|
||||
"ranged-cache",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
|
||||
TRACK_READ_TIMEOUT_SECS
|
||||
);
|
||||
fetch_data(url, state, gen, app).await
|
||||
}
|
||||
|
||||
fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
err.contains("format probe failed")
|
||||
|| err.contains("could not open audio stream")
|
||||
|| err.contains("no playable audio track")
|
||||
}
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks,
|
||||
server_id,
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Some(ref h) = effective_hint {
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) if is_ranged_stream_probe_failure(&e) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
|
||||
e
|
||||
);
|
||||
let data = match wait_or_fetch_bytes_for_stream_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
effective_hint.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Err(e),
|
||||
};
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Err("ranged-stream: superseded during full-buffer fallback".into());
|
||||
}
|
||||
let bytes_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(&data),
|
||||
);
|
||||
if bytes_hint.as_ref() != effective_hint.as_ref() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
|
||||
bytes_hint,
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(pe) if is_in_memory_probe_failure(&pe) => {
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&data,
|
||||
bytes_hint.as_deref(),
|
||||
"ranged-cache-probe-fail",
|
||||
);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
|
||||
pe
|
||||
);
|
||||
let fresh = match fetch_data(url, state, gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Err(pe),
|
||||
};
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&fresh,
|
||||
bytes_hint.as_deref(),
|
||||
"http-refetch-after-probe-fail",
|
||||
);
|
||||
}
|
||||
build_source_from_play_input(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(pe) => Err(pe),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
pub(super) async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::device_watcher::{reopen_output_stream, ReopenNotify};
|
||||
use super::engine::AudioEngine;
|
||||
use super::engine::{request_stream_release, AudioEngine};
|
||||
use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release};
|
||||
|
||||
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
const DEBOUNCE: Duration = Duration::from_millis(900);
|
||||
@@ -30,10 +30,22 @@ pub(crate) fn debounce_allow_resume_reopen() -> bool {
|
||||
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
|
||||
let device_name = match app.try_state::<AudioEngine>() {
|
||||
Some(e) => e.selected_device.lock().unwrap().clone(),
|
||||
None => return,
|
||||
let Some(state) = app.try_state::<AudioEngine>() else {
|
||||
return;
|
||||
};
|
||||
let engine = state.inner();
|
||||
|
||||
if !output_stream_is_needed(engine) {
|
||||
if engine.stream_handle.lock().unwrap().is_some() {
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
let _ = request_stream_release(engine);
|
||||
*engine.stream_handle.lock().unwrap() = None;
|
||||
let _ = app.emit("audio:output-released", ());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let device_name = engine.selected_device.lock().unwrap().clone();
|
||||
|
||||
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
|
||||
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
|
||||
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||
TrackAnalysisOrigin,
|
||||
};
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||
use super::state::PreloadedTrack;
|
||||
|
||||
@@ -114,11 +114,13 @@ 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,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
eager: Option<bool>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -183,15 +185,28 @@ pub async fn audio_preload(
|
||||
|
||||
// Throttle: wait 8 s before starting the background download so it does not
|
||||
// compete with the decode + sink-feed work of the just-started current track.
|
||||
// If the user skips during the wait the generation counter changes and we abort.
|
||||
// Eager callers (crossfade/AutoDJ pre-buffer, fired ~30 s before the fade
|
||||
// when the current track is long-settled) skip the wait so the RAM slot
|
||||
// fills in time for the fade to fire. If the user skips during the wait the
|
||||
// generation counter changes and we abort.
|
||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
if !eager.unwrap_or(false) {
|
||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
let response = crate::engine::playback_scoped_get(
|
||||
&state,
|
||||
&app,
|
||||
&url,
|
||||
server_id.as_deref(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||
return Ok(());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Short preview playback on a secondary sink (same output stream).
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
@@ -8,9 +8,18 @@ use rodio::Source;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::decode::SizedDecoder;
|
||||
use super::engine::{audio_http_client, AudioEngine};
|
||||
use super::helpers::MASTER_HEADROOM;
|
||||
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
|
||||
use super::helpers::{
|
||||
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||
MASTER_HEADROOM,
|
||||
};
|
||||
use super::play_input::url_format_hint;
|
||||
use super::sources::PriorityBoostSource;
|
||||
use super::stream::{
|
||||
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
|
||||
RangedHttpSource, RangedMp4ProbeGate,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||
@@ -90,26 +99,252 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
||||
/// `format=` query param on Subsonic stream URLs (transcode targets).
|
||||
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||
url.split('?')
|
||||
.nth(1)?
|
||||
.split('&')
|
||||
.find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
||||
if k.eq_ignore_ascii_case("format") {
|
||||
Some(v.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Symphonia container hint for preview downloads — mirrors main playback:
|
||||
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
|
||||
pub(crate) fn resolve_preview_format_hint(
|
||||
url: &str,
|
||||
content_type: Option<&str>,
|
||||
content_disposition: Option<&str>,
|
||||
stream_suffix: Option<&str>,
|
||||
bytes: &[u8],
|
||||
) -> Option<String> {
|
||||
let media_hint = content_type
|
||||
.and_then(content_type_to_hint)
|
||||
.or_else(|| {
|
||||
content_disposition.and_then(format_hint_from_content_disposition)
|
||||
});
|
||||
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
|
||||
resolve_playback_format_hint(
|
||||
url_hint.as_deref(),
|
||||
stream_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(bytes),
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(state))
|
||||
}
|
||||
|
||||
/// Open a preview decoder — ranged HTTP when the server supports it (starts
|
||||
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
|
||||
async fn open_preview_decoder(
|
||||
url: &str,
|
||||
format_suffix: Option<&str>,
|
||||
gen: u64,
|
||||
state: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<Option<SizedDecoder>, String> {
|
||||
let http_headers = PlaybackHttpHeaders::from_app(app, None);
|
||||
let preview_http = preview_http_client(state);
|
||||
let response = http_headers
|
||||
.apply(url, preview_http.get(url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?;
|
||||
|
||||
let mut stream_hint = content_type_to_hint(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
)
|
||||
.or_else(|| {
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(format_hint_from_content_disposition)
|
||||
})
|
||||
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
|
||||
.or_else(|| preview_format_hint_from_url(url))
|
||||
.or_else(|| url_format_hint(url));
|
||||
|
||||
let supports_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if stream_hint.is_none() && supports_range {
|
||||
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||
let last = total_u64
|
||||
.saturating_sub(1)
|
||||
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||
if let Ok(pr) = http_headers
|
||||
.apply(url, preview_http.get(url))
|
||||
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let stat = pr.status();
|
||||
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| stat == reqwest::StatusCode::OK;
|
||||
if ok {
|
||||
if let Ok(bytes) = pr.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[preview] ranged open — total={} KB, hint={:?}",
|
||||
total_usize / 1024,
|
||||
stream_hint
|
||||
);
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let playback_armed = Arc::new(AtomicBool::new(false));
|
||||
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
|
||||
tail_ready: tail_ready.clone(),
|
||||
buf: buf.clone(),
|
||||
downloaded_to: downloaded_to.clone(),
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
format_hint: stream_hint.clone(),
|
||||
});
|
||||
tokio::spawn(ranged_download_task(
|
||||
gen,
|
||||
state.preview_gen.clone(),
|
||||
preview_http,
|
||||
app.clone(),
|
||||
0.0,
|
||||
url.to_string(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.stream_completed_spill.clone(),
|
||||
state.normalization_engine.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
None,
|
||||
None,
|
||||
http_headers.clone(),
|
||||
None,
|
||||
playback_armed,
|
||||
stream_hint.clone(),
|
||||
tail_ready.clone(),
|
||||
tail_filled_from.clone(),
|
||||
));
|
||||
if let Some(ref gate) = mp4_probe_gate {
|
||||
wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready,
|
||||
tail_filled_from,
|
||||
total_size: total,
|
||||
pos: 0,
|
||||
done,
|
||||
gen_arc: state.preview_gen.clone(),
|
||||
gen,
|
||||
// Preview plays a fixed short segment; no user seeking → no need for
|
||||
// the on-demand random-access fetcher.
|
||||
on_demand: None,
|
||||
};
|
||||
let hint = stream_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
return Ok(Some(decoder));
|
||||
}
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range,
|
||||
total_size,
|
||||
stream_hint
|
||||
);
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let content_disposition = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
let hint = resolve_preview_format_hint(
|
||||
url,
|
||||
content_type.as_deref(),
|
||||
content_disposition.as_deref(),
|
||||
format_suffix,
|
||||
&bytes,
|
||||
);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
Ok(Some(decoder))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[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,
|
||||
url: String,
|
||||
start_sec: f64,
|
||||
duration_sec: f64,
|
||||
volume: f32,
|
||||
format_suffix: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -134,48 +369,24 @@ pub async fn audio_preview_play(
|
||||
preview_pause_main(&state);
|
||||
}
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────────────────
|
||||
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
||||
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
||||
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
||||
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
||||
// bounds how long the preview plays once the bytes are in memory, so a
|
||||
// long download just means a longer "loading" spinner before audio starts.
|
||||
let preview_http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.use_rustls_tls()
|
||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||
.build()
|
||||
.unwrap_or_else(|_| audio_http_client(&state));
|
||||
let bytes = preview_http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("preview: read body: {e}"))?
|
||||
.to_vec();
|
||||
// ── Open decoder (ranged stream when possible) ───────────────────────────
|
||||
let decoder = match open_preview_decoder(
|
||||
&url,
|
||||
format_suffix.as_deref(),
|
||||
gen,
|
||||
&state,
|
||||
&app,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||
// A newer preview started while we were downloading — bail.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Decode ───────────────────────────────────────────────────────────────
|
||||
let hint = preview_format_hint_from_url(&url);
|
||||
let bytes_for_blocking = bytes;
|
||||
let hint_for_blocking = hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||
|
||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
// ── Build source pipeline ────────────────────────────────────────────────
|
||||
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||
// before the seek made take_duration's wall-clock counter tick from
|
||||
@@ -194,7 +405,8 @@ pub async fn audio_preview_play(
|
||||
let source = PriorityBoostSource::new(source);
|
||||
|
||||
// ── Build secondary sink on the existing OutputStream ────────────────────
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(source);
|
||||
|
||||
@@ -271,7 +483,57 @@ pub async fn audio_preview_play(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
Some("audio/mpeg"),
|
||||
None,
|
||||
None,
|
||||
b"fLaC\x00\x00\x00\x22",
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("mp3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_format_hint_uses_subsonic_suffix() {
|
||||
let hint = resolve_preview_format_hint(
|
||||
"https://host/rest/stream.view?id=1",
|
||||
None,
|
||||
None,
|
||||
Some("flac"),
|
||||
&[0x00, 0x01, 0x02, 0x03],
|
||||
);
|
||||
assert_eq!(hint.as_deref(), Some("flac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_format_hint_from_url_reads_format_query_param() {
|
||||
assert_eq!(
|
||||
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
|
||||
Some("opus".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
preview_stop_inner(&app, &state, true);
|
||||
}
|
||||
@@ -282,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);
|
||||
}
|
||||
@@ -292,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));
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled_arc: Arc<AtomicBool>,
|
||||
crossfade_secs_arc: Arc<AtomicU32>,
|
||||
autodj_suppress_arc: Arc<AtomicBool>,
|
||||
initial_done: Arc<AtomicBool>,
|
||||
emitter: E,
|
||||
analysis_app: Option<AppHandle>,
|
||||
@@ -245,7 +246,12 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
continue;
|
||||
}
|
||||
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
|
||||
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
|
||||
// every advance (gated on the next track being playable). Treat it
|
||||
// like crossfade-off here: only emit `audio:ended` on real source
|
||||
// exhaustion (above) or the watchdog — never the early timer.
|
||||
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
|
||||
&& !autodj_suppress_arc.load(Ordering::Relaxed);
|
||||
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
|
||||
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
|
||||
|
||||
@@ -335,6 +341,7 @@ mod tests {
|
||||
chained: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
crossfade_enabled: Arc<AtomicBool>,
|
||||
crossfade_secs: Arc<AtomicU32>,
|
||||
autodj_suppress: Arc<AtomicBool>,
|
||||
done: Arc<AtomicBool>,
|
||||
samples_played: Arc<AtomicU64>,
|
||||
sample_rate: Arc<AtomicU32>,
|
||||
@@ -365,6 +372,7 @@ mod tests {
|
||||
chained: Arc::new(Mutex::new(None)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||
autodj_suppress: Arc::new(AtomicBool::new(false)),
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
@@ -384,6 +392,7 @@ mod tests {
|
||||
self.chained.clone(),
|
||||
self.crossfade_enabled.clone(),
|
||||
self.crossfade_secs.clone(),
|
||||
self.autodj_suppress.clone(),
|
||||
self.done.clone(),
|
||||
emitter,
|
||||
None,
|
||||
@@ -639,4 +648,34 @@ mod tests {
|
||||
);
|
||||
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn autodj_suppress_does_not_fire_crossfade_timer() {
|
||||
// AutoDJ suppression on: even with crossfade enabled and the position
|
||||
// inside the crossfade window, the autonomous timer must NOT emit
|
||||
// audio:ended (JS drives the advance, gated on the next track being
|
||||
// ready). The real end is still reached via source exhaustion.
|
||||
let h = TaskHarness::new(120.0);
|
||||
h.crossfade_enabled.store(true, Ordering::SeqCst);
|
||||
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
|
||||
h.autodj_suppress.store(true, Ordering::SeqCst);
|
||||
// Position inside the crossfade window (>= dur - 5 s), source not done.
|
||||
let played = (117.0 * 44_100.0 * 2.0) as u64;
|
||||
h.samples_played.store(played, Ordering::SeqCst);
|
||||
|
||||
let emitter = Arc::new(MockEmitter::default());
|
||||
h.spawn_with(emitter.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1300)).await;
|
||||
assert_eq!(
|
||||
emitter.ended_count(),
|
||||
0,
|
||||
"suppressed AutoDJ must not fire the autonomous crossfade timer"
|
||||
);
|
||||
|
||||
// Source exhausts → audio:ended fires (clean sequential end).
|
||||
h.done.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -124,7 +125,7 @@ pub async fn audio_play_radio(
|
||||
|
||||
let hint_clone = fmt_hint.clone();
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
|
||||
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
@@ -150,7 +151,8 @@ pub async fn audio_play_radio(
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
|
||||
|
||||
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
sink.append(boosted);
|
||||
|
||||
@@ -183,6 +185,7 @@ pub async fn audio_play_radio(
|
||||
state.chained_info.clone(),
|
||||
state.crossfade_enabled.clone(),
|
||||
state.crossfade_secs.clone(),
|
||||
state.autodj_suppress_autocrossfade.clone(),
|
||||
done_flag,
|
||||
app,
|
||||
None,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink
|
||||
//! into `state.current` (handing off the old one to a crossfade tail or a hard
|
||||
//! stop), and the legacy non-seekable path that holds a sink paused until the
|
||||
//! download task arms playback. Split out of `play_input.rs` so source
|
||||
//! selection and source building stay focused on their own concerns.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::engine::{AudioCurrent, AudioEngine};
|
||||
|
||||
/// Args for [`spawn_legacy_stream_start_when_armed`].
|
||||
pub(super) struct LegacyStreamStartWhenArmed {
|
||||
pub gen: u64,
|
||||
pub gen_arc: Arc<AtomicU64>,
|
||||
pub playback_armed: Arc<AtomicBool>,
|
||||
pub samples_played: Arc<AtomicU64>,
|
||||
pub current: Arc<Mutex<AudioCurrent>>,
|
||||
pub app: AppHandle,
|
||||
pub duration_secs: f64,
|
||||
pub hold_paused: bool,
|
||||
}
|
||||
|
||||
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
|
||||
/// playback, then reset counters and emit `audio:playing` so the UI does not
|
||||
/// extrapolate ahead of audible output.
|
||||
pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) {
|
||||
let LegacyStreamStartWhenArmed {
|
||||
gen,
|
||||
gen_arc,
|
||||
playback_armed,
|
||||
samples_played,
|
||||
current,
|
||||
app,
|
||||
duration_secs,
|
||||
hold_paused,
|
||||
} = args;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
if playback_armed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
samples_played.store(0, Ordering::Relaxed);
|
||||
let sink = current.lock().unwrap().sink.clone();
|
||||
if let Some(sink) = sink {
|
||||
if hold_paused {
|
||||
sink.pause();
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = None;
|
||||
if cur.paused_at.is_none() {
|
||||
cur.paused_at = Some(0.0);
|
||||
}
|
||||
cur.seek_offset = 0.0;
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy track-stream: buffer ready, holding paused (silent prepare)"
|
||||
);
|
||||
} else {
|
||||
{
|
||||
let mut cur = current.lock().unwrap();
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.seek_offset = 0.0;
|
||||
}
|
||||
sink.play();
|
||||
app.emit("audio:playing", duration_secs).ok();
|
||||
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
|
||||
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) start_paused: bool,
|
||||
}
|
||||
|
||||
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
|
||||
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
|
||||
fn handoff_old_sink_fade_out(
|
||||
state: &State<'_, AudioEngine>,
|
||||
old_sink: Option<Arc<rodio::Player>>,
|
||||
old_fadeout_trigger: Option<Arc<AtomicBool>>,
|
||||
old_fadeout_samples: Option<Arc<AtomicU64>>,
|
||||
fade_secs: f32,
|
||||
cleanup_secs: f32,
|
||||
) {
|
||||
let Some(old) = old_sink else {
|
||||
return;
|
||||
};
|
||||
if fade_secs <= 0.0 {
|
||||
old.stop();
|
||||
return;
|
||||
}
|
||||
let rate = state.current_sample_rate.load(Ordering::Relaxed);
|
||||
let ch = state.current_channels.load(Ordering::Relaxed);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
|
||||
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
|
||||
samples.store(fade_total.max(1), Ordering::SeqCst);
|
||||
trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
let SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs,
|
||||
volume,
|
||||
gain_linear,
|
||||
fadeout_trigger: new_fadeout_trigger,
|
||||
fadeout_samples: new_fadeout_samples,
|
||||
crossfade_enabled,
|
||||
actual_fade_secs,
|
||||
outgoing_fade_secs,
|
||||
start_paused,
|
||||
} = inputs;
|
||||
|
||||
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let old = cur.sink.take();
|
||||
let old_fo_trigger = cur.fadeout_trigger.take();
|
||||
let old_fo_samples = cur.fadeout_samples.take();
|
||||
cur.sink = Some(sink.clone());
|
||||
cur.duration_secs = duration_secs;
|
||||
cur.seek_offset = 0.0;
|
||||
if start_paused {
|
||||
sink.pause();
|
||||
cur.play_started = None;
|
||||
cur.paused_at = Some(0.0);
|
||||
} else {
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
cur.fadeout_trigger = Some(new_fadeout_trigger);
|
||||
cur.fadeout_samples = Some(new_fadeout_samples);
|
||||
(old, old_fo_trigger, old_fo_samples)
|
||||
};
|
||||
|
||||
if crossfade_enabled {
|
||||
if outgoing_fade_secs > 0.0 {
|
||||
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
|
||||
// still keep the old sink alive until B's fade-in window elapses.
|
||||
handoff_old_sink_fade_out(
|
||||
state,
|
||||
old_sink,
|
||||
old_fadeout_trigger,
|
||||
old_fadeout_samples,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
|
||||
);
|
||||
} else if let Some(old) = old_sink {
|
||||
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
|
||||
// so clamp the handoff sink or A blasts over B's fade-in.
|
||||
if state
|
||||
.interrupt_outgoing_duck_active
|
||||
.load(Ordering::Relaxed)
|
||||
{
|
||||
old.set_volume(0.0);
|
||||
}
|
||||
*state.fading_out_sink.lock().unwrap() = Some(old);
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(cleanup_dur).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(old) = old_sink {
|
||||
old.stop();
|
||||
}
|
||||
state
|
||||
.interrupt_outgoing_duck_active
|
||||
.store(false, Ordering::Relaxed);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Source-building pipeline for `audio_play`: turn a resolved [`PlayInput`]
|
||||
//! into a fully wrapped rodio source, including the ranged-stream probe
|
||||
//! fallback (wait for / fetch a full download and retry from in-memory bytes
|
||||
//! when a partial ranged buffer can't be probed yet). Split out of
|
||||
//! `play_input.rs` so source *selection* stays separate from source *building*.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::analysis_dispatch::{
|
||||
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
|
||||
};
|
||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
|
||||
use super::play_input::PlayInput;
|
||||
use super::stream::TRACK_READ_TIMEOUT_SECS;
|
||||
|
||||
/// Arguments forwarded from `audio_play` into the source-build pipeline.
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
pub server_id: Option<&'a str>,
|
||||
pub url_format_hint: Option<&'a str>,
|
||||
pub stream_format_suffix: Option<&'a str>,
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
|
||||
pub resample_target_hz: u32,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
|
||||
struct PlaybackSourceShape {
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
resample_target_hz: u32,
|
||||
duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
|
||||
match input {
|
||||
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
|
||||
format_hint.clone()
|
||||
}
|
||||
PlayInput::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranged HTTP probe/decode failed in a way that may succeed after the
|
||||
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
|
||||
fn is_ranged_stream_probe_failure(err: &str) -> bool {
|
||||
err.contains("ranged-stream")
|
||||
&& (err.contains("format probe failed")
|
||||
|| err.contains("moov metadata")
|
||||
|| err.contains("end of stream"))
|
||||
}
|
||||
|
||||
/// Completed ranged download or spill file for `url`, if ready.
|
||||
async fn try_take_completed_stream_bytes(
|
||||
url: &str,
|
||||
state: &State<'_, AudioEngine>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
|
||||
return Some(data);
|
||||
}
|
||||
let spill_path = {
|
||||
let guard = state.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|p| same_playback_target(&p.url, url))
|
||||
.map(|p| p.path.clone())
|
||||
};
|
||||
if let Some(path) = spill_path {
|
||||
let data = tokio::fs::read(&path).await.ok()?;
|
||||
if !data.is_empty() {
|
||||
return Some(data);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
|
||||
async fn prefer_clean_http_bytes_for_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
ranged_data: Vec<u8>,
|
||||
format_hint: Option<&str>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
|
||||
if is_mp4 {
|
||||
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
|
||||
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|
||||
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
|
||||
{
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
|
||||
);
|
||||
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
|
||||
if super::stream::isobmff_buffer_looks_complete(&fresh) {
|
||||
return Ok(Some(fresh));
|
||||
}
|
||||
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(ranged_data))
|
||||
}
|
||||
|
||||
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
|
||||
async fn wait_or_fetch_bytes_for_stream_fallback(
|
||||
url: &str,
|
||||
gen: u64,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
format_hint: Option<&str>,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
use std::time::Instant;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
loop {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: using completed download ({} KiB)",
|
||||
data.len() / 1024
|
||||
);
|
||||
return prefer_clean_http_bytes_for_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
data,
|
||||
format_hint,
|
||||
"ranged-cache",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
|
||||
TRACK_READ_TIMEOUT_SECS
|
||||
);
|
||||
fetch_data(url, state, gen, app).await
|
||||
}
|
||||
|
||||
fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
err.contains("format probe failed")
|
||||
|| err.contains("could not open audio stream")
|
||||
|| err.contains("no playable audio track")
|
||||
}
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
app: &AppHandle,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks,
|
||||
server_id,
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
let effective_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
None,
|
||||
);
|
||||
if let Some(ref h) = effective_hint {
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
let shape = PlaybackSourceShape {
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
};
|
||||
|
||||
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) if is_ranged_stream_probe_failure(&e) => {
|
||||
crate::app_deprintln!(
|
||||
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
|
||||
e
|
||||
);
|
||||
let data = match wait_or_fetch_bytes_for_stream_fallback(
|
||||
url,
|
||||
gen,
|
||||
state,
|
||||
app,
|
||||
effective_hint.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return Err(e),
|
||||
};
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Err("ranged-stream: superseded during full-buffer fallback".into());
|
||||
}
|
||||
let bytes_hint = resolve_playback_format_hint(
|
||||
url_format_hint,
|
||||
stream_format_suffix,
|
||||
media_hint.as_deref(),
|
||||
Some(&data),
|
||||
);
|
||||
if bytes_hint.as_ref() != effective_hint.as_ref() {
|
||||
crate::app_deprintln!(
|
||||
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
|
||||
bytes_hint,
|
||||
effective_hint
|
||||
);
|
||||
}
|
||||
if let Some(track_id) = cache_id_for_tasks
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let (sid, high) =
|
||||
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||
spawn_track_analysis_bytes(
|
||||
app.clone(),
|
||||
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||
sid,
|
||||
track_id.to_string(),
|
||||
data.clone(),
|
||||
high,
|
||||
Some((gen, state.generation.clone())),
|
||||
);
|
||||
}
|
||||
match build_source_from_play_input(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
&shape,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
Err(pe) if is_in_memory_probe_failure(&pe) => {
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&data,
|
||||
bytes_hint.as_deref(),
|
||||
"ranged-cache-probe-fail",
|
||||
);
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
|
||||
pe
|
||||
);
|
||||
let fresh = match fetch_data(url, state, gen, app).await? {
|
||||
Some(d) => d,
|
||||
None => return Err(pe),
|
||||
};
|
||||
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
|
||||
super::stream::log_isobmff_buffer_diagnostic(
|
||||
&fresh,
|
||||
bytes_hint.as_deref(),
|
||||
"http-refetch-after-probe-fail",
|
||||
);
|
||||
}
|
||||
build_source_from_play_input(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
&PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(pe) => Err(pe),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
|
||||
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
|
||||
/// the streaming variant runs the decoder build on a blocking thread.
|
||||
async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
shape: &PlaybackSourceShape,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
let PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = shape;
|
||||
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
|
||||
let target_rate: u32 = *resample_target_hz;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
*hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
format_hint: media_hint,
|
||||
tag,
|
||||
random_access,
|
||||
mp4_probe_gate,
|
||||
} => {
|
||||
if let Some(gate) = mp4_probe_gate.as_ref() {
|
||||
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
|
||||
return Err("ranged-stream: superseded before moov metadata ready".into());
|
||||
}
|
||||
}
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
)
|
||||
}
|
||||
PlayInput::Streaming { reader, format_hint: stream_hint } => {
|
||||
is_seekable = false;
|
||||
let decoder = tokio::task::spawn_blocking(move || {
|
||||
SizedDecoder::new_streaming(
|
||||
Box::new(reader),
|
||||
stream_hint.as_deref(),
|
||||
"track-stream",
|
||||
false,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
)
|
||||
}
|
||||
}?;
|
||||
Ok(PlaybackSource { built, is_seekable })
|
||||
}
|
||||
@@ -221,13 +221,18 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
|
||||
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
|
||||
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
|
||||
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
|
||||
// For mid-track seeks: skip straight to unity gain so the new position
|
||||
// plays at full volume immediately — no audible fade-in glitch.
|
||||
// For seeks to the very start (< 100 ms): keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
if pos.as_millis() < 100 {
|
||||
if self.sample_count == 0 {
|
||||
// Seek before any audio has played → this is the initial start-offset
|
||||
// seek (B-head: skip the incoming track's leading silence). Keep the
|
||||
// fade-in (`sample_count` stays 0) so a crossfaded track still rises
|
||||
// in from its trimmed start instead of popping in at full gain.
|
||||
} else if pos.as_millis() < 100 {
|
||||
// Mid-playback seek to the very start: keep the micro-fade to
|
||||
// suppress any DC-offset click from the fresh decode.
|
||||
self.sample_count = 0;
|
||||
} else {
|
||||
// Mid-playback seek elsewhere (user dragging the seekbar): skip
|
||||
// straight to unity gain so the new position is at full volume.
|
||||
self.sample_count = self.fade_samples;
|
||||
}
|
||||
self.inner.try_seek(pos)
|
||||
|
||||
@@ -21,9 +21,26 @@ pub(crate) use mp4::{
|
||||
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
|
||||
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
|
||||
};
|
||||
|
||||
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
|
||||
/// Opus, Speex, FLAC-in-Ogg).
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
|
||||
/// construction time, but only when the source reports `is_seekable()` *during
|
||||
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
|
||||
/// `phys_byte_range_end` stays `None` and the first real seek panics with
|
||||
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
|
||||
/// seek to EOF must therefore stay seekable through the probe for Ogg.
|
||||
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
|
||||
let Some(h) = hint else { return false };
|
||||
matches!(
|
||||
h.to_ascii_lowercase().as_str(),
|
||||
"ogg" | "oga" | "ogx" | "opus" | "spx"
|
||||
)
|
||||
}
|
||||
pub(crate) use local_file::LocalFileSource;
|
||||
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
|
||||
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use ranged_http::{OnDemand, RangedHttpSource, ranged_download_task};
|
||||
pub(crate) use reader::AudioStreamReader;
|
||||
pub(crate) use track_stream::track_download_task;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
|
||||
use symphonia::core::io::MediaSource;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::super::engine::PlaybackHttpHeaders;
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
||||
@@ -50,6 +51,131 @@ impl Drop for RangedLoudnessSeedHoldClear {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum bytes fetched per on-demand Range request. A seek often triggers a
|
||||
/// short read; fetching a window amortizes the HTTP round-trip and lets the few
|
||||
/// pages a bisection lands on (and the playback that follows a forward seek) be
|
||||
/// served without a fresh request each time.
|
||||
const OD_FETCH_WINDOW: u64 = 1024 * 1024;
|
||||
/// Forward gap (cursor ahead of the contiguous linear download) above which a
|
||||
/// read is treated as a *seek* and served by an on-demand HTTP Range fetch
|
||||
/// instead of waiting for the linear filler to catch up. Below it we assume
|
||||
/// ordinary read-ahead that the linear download will satisfy shortly, so we do
|
||||
/// not issue redundant range requests during normal (slightly starved) play.
|
||||
const OD_SEEK_GAP: u64 = 512 * 1024;
|
||||
|
||||
/// Random-access companion for [`RangedHttpSource`]: fetches arbitrary byte
|
||||
/// ranges over HTTP `Range` on demand so seeks (which jump the read cursor far
|
||||
/// ahead of the linear download) resolve quickly instead of blocking until the
|
||||
/// linear filler reaches the target.
|
||||
///
|
||||
/// symphonia 0.6's Ogg demuxer seeks by *bisection* — it reads pages at
|
||||
/// midpoints across the whole byte range, and its probe scans the last pages to
|
||||
/// find the stream-end timestamp. On a purely linear-fill source every such read
|
||||
/// would block until the download caught up (effectively forcing a full
|
||||
/// download before any seek). On-demand range fetches make those reads cheap.
|
||||
pub(crate) struct OnDemand {
|
||||
http: reqwest::Client,
|
||||
handle: tokio::runtime::Handle,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
/// Byte ranges already fetched on demand (sorted/merged not required — N is
|
||||
/// the handful of seek targets per track).
|
||||
filled: Mutex<Vec<(u64, u64)>>,
|
||||
/// Ranges with an in-flight fetch, so a polling read does not respawn them.
|
||||
inflight: Mutex<Vec<(u64, u64)>>,
|
||||
/// Bumped after every completed (success or failure) fetch so the read loop
|
||||
/// can reset its stall deadline while on-demand fetches make progress.
|
||||
progress: AtomicU64,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
}
|
||||
|
||||
impl OnDemand {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
http: reqwest::Client,
|
||||
handle: tokio::runtime::Handle,
|
||||
url: String,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
total_size: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
gen: u64,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
) -> Self {
|
||||
OnDemand {
|
||||
http,
|
||||
handle,
|
||||
url,
|
||||
buf,
|
||||
total_size,
|
||||
gen_arc,
|
||||
gen,
|
||||
filled: Mutex::new(Vec::new()),
|
||||
inflight: Mutex::new(Vec::new()),
|
||||
progress: AtomicU64::new(0),
|
||||
http_headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn covers(&self, start: u64, end: u64) -> bool {
|
||||
self.filled
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|&(s, e)| s <= start && end <= e)
|
||||
}
|
||||
|
||||
fn inflight_covers(&self, start: u64, end: u64) -> bool {
|
||||
self.inflight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|&(s, e)| s <= start && end <= e)
|
||||
}
|
||||
|
||||
/// Spawn a Range fetch covering at least `[start, end)` (rounded up to
|
||||
/// [`OD_FETCH_WINDOW`]) unless it is already filled or in flight. Returns
|
||||
/// immediately; the caller polls [`OnDemand::covers`] / `progress`.
|
||||
fn request(self: &Arc<Self>, start: u64, end: u64) {
|
||||
if start >= self.total_size {
|
||||
return;
|
||||
}
|
||||
let want_end = end.max(start + OD_FETCH_WINDOW).min(self.total_size);
|
||||
if self.covers(start, want_end) || self.inflight_covers(start, want_end) {
|
||||
return;
|
||||
}
|
||||
self.inflight.lock().unwrap().push((start, want_end));
|
||||
let me = Arc::clone(self);
|
||||
self.handle.spawn(async move {
|
||||
let end_inclusive = want_end.saturating_sub(1);
|
||||
let res = ranged_write_http_range(
|
||||
&me.http,
|
||||
&me.url,
|
||||
&me.buf,
|
||||
start,
|
||||
end_inclusive,
|
||||
me.gen,
|
||||
&me.gen_arc,
|
||||
&me.http_headers,
|
||||
)
|
||||
.await;
|
||||
if let Ok(written) = res {
|
||||
if written > 0 {
|
||||
me.filled.lock().unwrap().push((start, start + written as u64));
|
||||
}
|
||||
}
|
||||
// Drop the reservation either way so a failed fetch can be retried.
|
||||
me.inflight
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|&(s, e)| !(s == start && e == want_end));
|
||||
me.progress.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RangedHttpSource {
|
||||
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
|
||||
pub(crate) buf: Arc<Mutex<Vec<u8>>>,
|
||||
@@ -64,6 +190,10 @@ pub(crate) struct RangedHttpSource {
|
||||
pub(crate) done: Arc<AtomicBool>,
|
||||
pub(crate) gen_arc: Arc<AtomicU64>,
|
||||
pub(crate) gen: u64,
|
||||
/// On-demand random-access fetcher. `None` keeps the legacy linear-only
|
||||
/// behaviour (used by unit tests); production ranged playback sets it so
|
||||
/// seeks resolve via HTTP `Range` instead of blocking on the linear filler.
|
||||
pub(crate) on_demand: Option<Arc<OnDemand>>,
|
||||
}
|
||||
|
||||
impl RangedHttpSource {
|
||||
@@ -78,6 +208,11 @@ impl RangedHttpSource {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(od) = &self.on_demand {
|
||||
if od.covers(start, end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -103,6 +238,11 @@ impl Read for RangedHttpSource {
|
||||
let stall_timeout = Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
|
||||
let mut deadline = Instant::now() + stall_timeout;
|
||||
let mut last_dl_seen = self.downloaded_to.load(Ordering::Relaxed) as u64;
|
||||
let mut last_od_seen = self
|
||||
.on_demand
|
||||
.as_ref()
|
||||
.map(|od| od.progress.load(Ordering::Relaxed))
|
||||
.unwrap_or(0);
|
||||
loop {
|
||||
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
||||
crate::app_deprintln!(
|
||||
@@ -120,6 +260,24 @@ impl Read for RangedHttpSource {
|
||||
last_dl_seen = dl;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
// A read whose cursor is far ahead of the contiguous linear download
|
||||
// is a seek (Ogg bisection midpoint, end-of-stream probe, or a
|
||||
// forward scrub). Serve it from an on-demand HTTP Range fetch rather
|
||||
// than blocking until the linear filler crawls there. While the
|
||||
// download is still running; an aborted download keeps the legacy
|
||||
// partial/EOF behaviour below.
|
||||
if let Some(od) = &self.on_demand {
|
||||
let od_progress = od.progress.load(Ordering::SeqCst);
|
||||
if od_progress != last_od_seen {
|
||||
last_od_seen = od_progress;
|
||||
deadline = Instant::now() + stall_timeout;
|
||||
}
|
||||
if !self.done.load(Ordering::SeqCst)
|
||||
&& self.pos > dl.saturating_add(OD_SEEK_GAP)
|
||||
{
|
||||
od.request(self.pos, target_end);
|
||||
}
|
||||
}
|
||||
// Download finished but our cursor is past downloaded_to (e.g. seek
|
||||
// beyond a partial download that aborted). Return what we have.
|
||||
if self.done.load(Ordering::SeqCst) {
|
||||
@@ -214,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
|
||||
downloaded_to: &Arc<AtomicUsize>,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
http_headers: &PlaybackHttpHeaders,
|
||||
mut on_partial: F,
|
||||
playback_armed: Option<&AtomicBool>,
|
||||
) -> (usize, RangedHttpLoopOutcome)
|
||||
@@ -234,6 +393,7 @@ where
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
req = http_headers.apply(url, req);
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
@@ -334,6 +494,7 @@ where
|
||||
}
|
||||
|
||||
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn ranged_write_http_range(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
@@ -342,22 +503,32 @@ async fn ranged_write_http_range(
|
||||
end_inclusive: u64,
|
||||
gen: u64,
|
||||
gen_arc: &Arc<AtomicU64>,
|
||||
http_headers: &PlaybackHttpHeaders,
|
||||
) -> Result<usize, ()> {
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
let response = http_client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
|
||||
let response = http_headers
|
||||
.apply(
|
||||
url,
|
||||
http_client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||
return Err(());
|
||||
}
|
||||
if !(response.status() == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| response.status() == reqwest::StatusCode::OK)
|
||||
{
|
||||
// Require 206 for any non-zero offset. A server that ignored the `Range`
|
||||
// header and replied 200 returns the *whole* body from byte 0; writing that
|
||||
// at `start` would corrupt the buffer. A 200 is only safe when we asked from
|
||||
// offset 0 (the body genuinely starts there).
|
||||
let status = response.status();
|
||||
let ok = status == reqwest::StatusCode::PARTIAL_CONTENT
|
||||
|| (status == reqwest::StatusCode::OK && start == 0);
|
||||
if !ok {
|
||||
return Err(());
|
||||
}
|
||||
let mut written = 0usize;
|
||||
@@ -397,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
) {
|
||||
const MIN_TAIL: u64 = 256 * 1024;
|
||||
const MAX_TAIL: u64 = 8 * 1024 * 1024;
|
||||
@@ -415,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
|
||||
end_inclusive,
|
||||
gen,
|
||||
&gen_arc,
|
||||
&http_headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -464,6 +637,7 @@ pub(crate) async fn ranged_download_task(
|
||||
cache_track_id: Option<String>,
|
||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||
server_id: Option<String>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
|
||||
// track; `None` for large files where ranged skips seed (needs backfill).
|
||||
loudness_seed_hold: Option<LoudnessSeedHold>,
|
||||
@@ -547,6 +721,7 @@ pub(crate) async fn ranged_download_task(
|
||||
let tail_from_bg = tail_filled_from.clone();
|
||||
let armed_bg = playback_armed.clone();
|
||||
let gen_bg = gen_arc.clone();
|
||||
let headers_bg = http_headers.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
ranged_prefetch_mp4_tail(
|
||||
client,
|
||||
@@ -558,6 +733,7 @@ pub(crate) async fn ranged_download_task(
|
||||
armed_bg,
|
||||
gen,
|
||||
gen_bg,
|
||||
headers_bg,
|
||||
)
|
||||
.await;
|
||||
}))
|
||||
@@ -578,6 +754,7 @@ pub(crate) async fn ranged_download_task(
|
||||
&downloaded_to,
|
||||
gen,
|
||||
&gen_arc,
|
||||
&http_headers,
|
||||
on_partial,
|
||||
linear_arm,
|
||||
)
|
||||
@@ -736,6 +913,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 7,
|
||||
on_demand: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +983,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
@@ -835,6 +1014,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 2];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
@@ -859,6 +1039,7 @@ mod tests {
|
||||
done,
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand: None,
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
assert_eq!(src.read(&mut out).unwrap(), 0);
|
||||
@@ -965,6 +1146,7 @@ mod tests {
|
||||
&dl,
|
||||
1,
|
||||
&gen_arc,
|
||||
&PlaybackHttpHeaders::default(),
|
||||
|_, _| {},
|
||||
None,
|
||||
)
|
||||
@@ -1000,6 +1182,7 @@ mod tests {
|
||||
&dl,
|
||||
1,
|
||||
&gen_arc,
|
||||
&PlaybackHttpHeaders::default(),
|
||||
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
|
||||
None,
|
||||
)
|
||||
@@ -1028,7 +1211,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(1024);
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
|
||||
@@ -1059,7 +1242,7 @@ mod tests {
|
||||
gen_arc.store(99, Ordering::SeqCst);
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
|
||||
@@ -1118,7 +1301,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
// Stream finishes via a Range-resumed second request.
|
||||
@@ -1136,6 +1319,126 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves whatever inclusive byte range the request asks for out of `body`,
|
||||
/// as a 206 — models a server that honours arbitrary `Range` requests.
|
||||
struct RangeResponder {
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Respond for RangeResponder {
|
||||
fn respond(&self, req: &Request) -> ResponseTemplate {
|
||||
let range = req
|
||||
.headers
|
||||
.get(reqwest::header::RANGE.as_str())
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("bytes="))
|
||||
.map(|s| s.to_string());
|
||||
let Some(range) = range else {
|
||||
return ResponseTemplate::new(200).set_body_bytes(self.body.clone());
|
||||
};
|
||||
let mut parts = range.splitn(2, '-');
|
||||
let start: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let end_inclusive: usize = parts
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(self.body.len().saturating_sub(1));
|
||||
let end = (end_inclusive + 1).min(self.body.len());
|
||||
ResponseTemplate::new(206).set_body_bytes(self.body[start..end].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn read_far_ahead_is_served_by_on_demand_range_fetch() {
|
||||
// 4 MiB track; nothing downloaded linearly yet and the download is still
|
||||
// "in progress" (done = false). A read whose cursor sits well past the
|
||||
// linear front must be satisfied by an on-demand Range fetch.
|
||||
let total: usize = 4 * 1024 * 1024;
|
||||
let body: Vec<u8> = (0..total).map(|i| (i % 256) as u8).collect();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/track"))
|
||||
.respond_with(RangeResponder { body: body.clone() })
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; total]));
|
||||
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let on_demand = Some(Arc::new(OnDemand::new(
|
||||
reqwest::Client::new(),
|
||||
tokio::runtime::Handle::current(),
|
||||
url,
|
||||
buf.clone(),
|
||||
total as u64,
|
||||
gen_arc.clone(),
|
||||
1,
|
||||
PlaybackHttpHeaders::default(),
|
||||
)));
|
||||
let mut src = RangedHttpSource {
|
||||
buf,
|
||||
downloaded_to,
|
||||
tail_ready: Arc::new(AtomicBool::new(false)),
|
||||
tail_filled_from: Arc::new(AtomicU64::new(0)),
|
||||
total_size: total as u64,
|
||||
pos: 2 * 1024 * 1024, // 2 MiB — far past the (empty) linear front
|
||||
done: Arc::new(AtomicBool::new(false)),
|
||||
gen_arc,
|
||||
gen: 1,
|
||||
on_demand,
|
||||
};
|
||||
|
||||
// The blocking read polls until the on-demand fetch fills the region.
|
||||
let out = tokio::task::spawn_blocking(move || {
|
||||
let mut out = [0u8; 16];
|
||||
let n = src.read(&mut out).unwrap();
|
||||
(n, out)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.0, 16, "read returns the requested bytes via on-demand fetch");
|
||||
let base = 2 * 1024 * 1024usize;
|
||||
let expected: Vec<u8> = (base..base + 16).map(|i| (i % 256) as u8).collect();
|
||||
assert_eq!(&out.1[..], &expected[..]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ranged_write_http_range_rejects_200_at_nonzero_offset() {
|
||||
// A server that ignores Range and answers 200 with the whole body must
|
||||
// NOT be written at a non-zero offset (would corrupt the buffer).
|
||||
let server = MockServer::start().await;
|
||||
let body = vec![0xCDu8; 4096];
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/track"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let url = format!("{}/track", server.uri());
|
||||
|
||||
let buf = Arc::new(Mutex::new(vec![0u8; 4096]));
|
||||
let gen_arc = Arc::new(AtomicU64::new(1));
|
||||
let res = ranged_write_http_range(
|
||||
&reqwest::Client::new(),
|
||||
&url,
|
||||
&buf,
|
||||
1024, // non-zero offset
|
||||
2047,
|
||||
1,
|
||||
&gen_arc,
|
||||
&PlaybackHttpHeaders::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "200 at a non-zero offset must be rejected");
|
||||
assert!(
|
||||
buf.lock().unwrap().iter().all(|&b| b == 0),
|
||||
"buffer must be left untouched on a rejected 200"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn loop_aborts_when_reconnect_returns_non_206() {
|
||||
// Returns 200 first time (partial body), then 200 again (not 206) on the
|
||||
@@ -1160,7 +1463,7 @@ mod tests {
|
||||
let (buf, dl, gen_arc) = loop_state(body.len());
|
||||
|
||||
let (downloaded, outcome) =
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
|
||||
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
|
||||
.await;
|
||||
|
||||
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
|
||||
|
||||
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
|
||||
use ringbuf::traits::Producer;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use super::super::engine::PlaybackHttpHeaders;
|
||||
use super::super::state::PreloadedTrack;
|
||||
use super::{
|
||||
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
|
||||
cache_track_id: Option<String>,
|
||||
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
|
||||
server_id: Option<String>,
|
||||
http_headers: PlaybackHttpHeaders,
|
||||
playback_armed: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
|
||||
if downloaded > 0 {
|
||||
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||
}
|
||||
req = http_headers.apply(&url, req);
|
||||
match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Release the CPAL/rodio output stream after playback has been idle so the OS
|
||||
//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use").
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
|
||||
/// Wall-clock idle period before closing the output device handle.
|
||||
pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60;
|
||||
|
||||
const IDLE_POLL_SECS: u64 = 5;
|
||||
|
||||
/// Returns true while the app must keep an open output stream (playing, preview, crossfade).
|
||||
pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool {
|
||||
if engine.preview_sink.lock().unwrap().is_some() {
|
||||
return true;
|
||||
}
|
||||
if engine.fading_out_sink.lock().unwrap().is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cur = engine.current.lock().unwrap();
|
||||
if let Some(sink) = &cur.sink {
|
||||
if sink.empty() {
|
||||
return false;
|
||||
}
|
||||
if cur.play_started.is_some() && cur.paused_at.is_none() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() {
|
||||
if !rs.flags.is_paused.load(Ordering::Relaxed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume.
|
||||
pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) {
|
||||
if let Some(s) = engine.preview_sink.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
if let Some(s) = engine.fading_out_sink.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
if let Some(s) = cur.sink.take() {
|
||||
s.stop();
|
||||
}
|
||||
cur.play_started = None;
|
||||
}
|
||||
|
||||
fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> {
|
||||
super::engine::request_stream_release(engine)?;
|
||||
*engine.stream_handle.lock().unwrap() = None;
|
||||
let _ = app.emit("audio:output-released", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release the output device after the idle timer (pause with no other active audio).
|
||||
pub(crate) fn release_output_stream(
|
||||
engine: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if engine.stream_handle.lock().unwrap().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
close_output_device_handle(engine, app)?;
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] audio output stream released after {}s idle",
|
||||
OUTPUT_STREAM_IDLE_RELEASE_SECS
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release immediately on explicit stop / queue end — do not wait for the idle timer.
|
||||
pub(crate) fn release_output_stream_on_stop(
|
||||
engine: &AudioEngine,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if engine.stream_handle.lock().unwrap().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// `audio_stop` already tore down the main sink; clear any crossfade/preview tail
|
||||
// still tied to the open device before closing the handle.
|
||||
if engine.preview_sink.lock().unwrap().is_some()
|
||||
|| engine.fading_out_sink.lock().unwrap().is_some()
|
||||
{
|
||||
teardown_playback_sinks_for_idle_release(engine);
|
||||
}
|
||||
close_output_device_handle(engine, app)?;
|
||||
crate::app_eprintln!("[psysonic] audio output stream released on stop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the engine from `app` each poll (the engine is managed Tauri state),
|
||||
/// so it takes only the `AppHandle` — no engine reference is needed here.
|
||||
pub fn start_stream_idle_watcher(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut idle_since: Option<Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await;
|
||||
let Some(state) = app.try_state::<AudioEngine>() else {
|
||||
idle_since = None;
|
||||
continue;
|
||||
};
|
||||
let engine = state.inner();
|
||||
let stream_open = engine.stream_handle.lock().unwrap().is_some();
|
||||
if !stream_open {
|
||||
idle_since = None;
|
||||
continue;
|
||||
}
|
||||
if output_stream_is_needed(engine) {
|
||||
idle_since = None;
|
||||
continue;
|
||||
}
|
||||
let since = *idle_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) {
|
||||
continue;
|
||||
}
|
||||
let _ = release_output_stream(engine, &app);
|
||||
idle_since = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use ringbuf::HeapCons;
|
||||
use rodio::source::Zero;
|
||||
use rodio::{ChannelCount, Player, SampleRate};
|
||||
|
||||
use super::super::engine::AudioCurrent;
|
||||
use super::super::playback_rate::PlaybackRateAtomics;
|
||||
use super::super::stream::{RadioLiveState, RadioSharedFlags};
|
||||
|
||||
/// A device-less rodio `Player` with one infinite source appended, so
|
||||
/// `empty()` reports `false` without ever opening an output device.
|
||||
/// Returns the queue output too — keep it alive for the test's duration.
|
||||
fn nonempty_player() -> (Arc<Player>, rodio::queue::SourcesQueueOutput) {
|
||||
let (player, out) = Player::new();
|
||||
player.append(Zero::new(
|
||||
ChannelCount::new(2).unwrap(),
|
||||
SampleRate::new(44_100).unwrap(),
|
||||
));
|
||||
(Arc::new(player), out)
|
||||
}
|
||||
|
||||
fn radio_session(is_paused: bool) -> RadioLiveState {
|
||||
let (tx, _rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
|
||||
RadioLiveState {
|
||||
url: "http://example.test/stream".to_string(),
|
||||
gen: 0,
|
||||
// Detached no-op task; never polled. Drop just aborts it.
|
||||
task: tokio::spawn(async {}),
|
||||
flags: Arc::new(RadioSharedFlags {
|
||||
is_paused: AtomicBool::new(is_paused),
|
||||
is_hard_paused: AtomicBool::new(false),
|
||||
new_cons_tx: Mutex::new(tx),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_engine() -> AudioEngine {
|
||||
let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0);
|
||||
AudioEngine {
|
||||
stream_handle: Arc::new(Mutex::new(None)),
|
||||
stream_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
device_default_rate: 48_000,
|
||||
stream_thread_tx,
|
||||
selected_device: Arc::new(Mutex::new(None)),
|
||||
current: Arc::new(Mutex::new(AudioCurrent {
|
||||
sink: None,
|
||||
duration_secs: 0.0,
|
||||
seek_offset: 0.0,
|
||||
play_started: None,
|
||||
paused_at: None,
|
||||
replay_gain_linear: 1.0,
|
||||
base_volume: 0.8,
|
||||
fadeout_trigger: None,
|
||||
fadeout_samples: None,
|
||||
})),
|
||||
generation: Arc::new(AtomicU64::new(0)),
|
||||
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
|
||||
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))),
|
||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||
eq_pre_gain: Arc::new(AtomicU32::new(0)),
|
||||
playback_rate: PlaybackRateAtomics::new(),
|
||||
preloaded: Arc::new(Mutex::new(None)),
|
||||
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||
stream_completed_spill: Arc::new(Mutex::new(None)),
|
||||
current_is_seekable: Arc::new(AtomicBool::new(true)),
|
||||
stream_playback_armed: Arc::new(AtomicBool::new(true)),
|
||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||
crossfade_secs: Arc::new(AtomicU32::new(0)),
|
||||
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
|
||||
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
normalization_target_lufs: Arc::new(AtomicU32::new(0)),
|
||||
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(44_100)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
current_analysis_track_id: Arc::new(Mutex::new(None)),
|
||||
current_playback_server_id: Arc::new(Mutex::new(None)),
|
||||
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
|
||||
preview_sink: Arc::new(Mutex::new(None)),
|
||||
preview_gen: Arc::new(AtomicU64::new(0)),
|
||||
preview_main_resume: Arc::new(AtomicBool::new(false)),
|
||||
preview_song_id: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_no_sink_and_no_preview() {
|
||||
let engine = minimal_engine();
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_sink_empty() {
|
||||
// A live but drained main sink (track finished) must not pin the device.
|
||||
let (player, _out) = Player::new(); // no source appended → empty()
|
||||
let player = Arc::new(player);
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(player);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
}
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_sink_playing() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None; // actively playing
|
||||
}
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_when_sink_paused() {
|
||||
// Non-empty sink but paused (paused_at set) — the idle watcher may release.
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
{
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = Some(12.0);
|
||||
}
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_preview_sink_present() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
*engine.preview_sink.lock().unwrap() = Some(sink);
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_when_fading_out_sink_present() {
|
||||
let (sink, _out) = nonempty_player();
|
||||
let engine = minimal_engine();
|
||||
*engine.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn needed_when_radio_playing() {
|
||||
let engine = minimal_engine();
|
||||
*engine.radio_state.lock().unwrap() = Some(radio_session(false));
|
||||
assert!(output_stream_is_needed(&engine));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_when_radio_paused() {
|
||||
let engine = minimal_engine();
|
||||
*engine.radio_state.lock().unwrap() = Some(radio_session(true));
|
||||
assert!(!output_stream_is_needed(&engine));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -130,9 +133,12 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
|
||||
cur.seek_offset = 0.0;
|
||||
cur.play_started = None;
|
||||
cur.paused_at = None;
|
||||
drop(cur);
|
||||
let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app);
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -3,11 +3,15 @@ name = "psysonic-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
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"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
|
||||
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
|
||||
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-")
|
||||
@@ -29,17 +30,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|
||||
|| id.starts_with("ra-")
|
||||
}
|
||||
|
||||
/// Windows reserved device names (case-insensitive) — invalid as path components.
|
||||
const WINDOWS_RESERVED_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
|
||||
/// Also used for media layout artist/album/title segments from server metadata.
|
||||
pub fn sanitize_path_segment(segment: &str) -> String {
|
||||
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let trimmed = segment.trim();
|
||||
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if trimmed.is_empty() {
|
||||
return "_".to_string();
|
||||
}
|
||||
trimmed
|
||||
let cleaned: String = trimmed
|
||||
.chars()
|
||||
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
|
||||
.collect()
|
||||
.map(|c| {
|
||||
if c.is_control() || FORBIDDEN.contains(&c) {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||
return "_".to_string();
|
||||
}
|
||||
let upper = cleaned.to_ascii_uppercase();
|
||||
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
|
||||
return format!("_{cleaned}");
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Relative path under `{root}/{server_segment}/` — change format here only.
|
||||
@@ -89,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 {
|
||||
@@ -97,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -250,12 +286,54 @@ 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);
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_dot_dot_and_reserved_names() {
|
||||
assert_eq!(sanitize_path_segment(".."), "_");
|
||||
assert_eq!(sanitize_path_segment("CON"), "_CON");
|
||||
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_disk_usage_counts_canonical_only() {
|
||||
let server = test_server_dir("usage");
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! macros) and the cross-crate port traits used to break dependency cycles
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod server_http;
|
||||
pub mod cover_cache_layout;
|
||||
pub mod log_sanitize;
|
||||
pub mod media_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
pub mod track_analysis;
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Redact secrets and partially mask remote server hostnames before log lines
|
||||
//! are stored or exported (PsyLab / Settings log export).
|
||||
|
||||
const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
||||
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
|
||||
"access_token", "refresh_token", "auth",
|
||||
];
|
||||
|
||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
|
||||
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
|
||||
];
|
||||
|
||||
/// Sanitize one runtime log line for display and export.
|
||||
pub fn sanitize_log_line(line: &str) -> String {
|
||||
let mut out = redact_bearer_tokens(line);
|
||||
out = redact_pangolin_headers(&out);
|
||||
out = redact_sensitive_key_values(&out);
|
||||
out = redact_urls_in_text(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Never panic on the logging hot path — fall back to the raw line if needed.
|
||||
pub fn sanitize_log_line_infallible(line: &str) -> String {
|
||||
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
|
||||
fn redact_bearer_tokens(line: &str) -> String {
|
||||
let marker = "Bearer ";
|
||||
let mut s = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = s[search_from..].find(marker) {
|
||||
let idx = search_from + rel;
|
||||
let start = idx + marker.len();
|
||||
let end = s[start..]
|
||||
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(s.len());
|
||||
if end > start {
|
||||
s.replace_range(start..end, "REDACTED");
|
||||
}
|
||||
search_from = start + "REDACTED".len();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn redact_pangolin_headers(line: &str) -> String {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let mut out = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
|
||||
let idx = search_from + rel;
|
||||
let after_prefix = &lower[idx..];
|
||||
let Some(sep_rel) = after_prefix.find([':', '=']) else {
|
||||
search_from = idx + 1;
|
||||
continue;
|
||||
};
|
||||
let sep_idx = idx + sep_rel;
|
||||
let val_start = sep_idx + 1;
|
||||
let slice = &out[val_start..];
|
||||
let trimmed = slice.trim_start();
|
||||
let ws = slice.len().saturating_sub(trimmed.len());
|
||||
let val_start = val_start + ws;
|
||||
let end = trimmed
|
||||
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||
.unwrap_or(trimmed.len());
|
||||
if end > 0 {
|
||||
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||
}
|
||||
search_from = val_start + "REDACTED".len();
|
||||
if search_from >= out.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_sensitive_key_values(line: &str) -> String {
|
||||
let mut out = line.to_string();
|
||||
for key in SENSITIVE_KV_KEYS {
|
||||
for sep in [':', '='] {
|
||||
let needle = format!("{key}{sep}");
|
||||
let lower = out.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find(&needle) {
|
||||
let idx = search_from + rel;
|
||||
let val_start = idx + needle.len();
|
||||
let slice = &out[val_start..];
|
||||
let trimmed = slice.trim_start();
|
||||
let ws = slice.len().saturating_sub(trimmed.len());
|
||||
let val_start = val_start + ws;
|
||||
let end = trimmed
|
||||
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||
.unwrap_or(trimmed.len());
|
||||
if end > 0 {
|
||||
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||
}
|
||||
search_from = val_start + "REDACTED".len();
|
||||
if search_from >= out.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
|
||||
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
|
||||
return true;
|
||||
}
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
if let Some(next) = s[byte_off..].chars().nth(1) {
|
||||
return next.is_whitespace() || next == '"' || next == '\'';
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn redact_urls_in_text(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < line.len() {
|
||||
let slice = &line[cursor..];
|
||||
let rel = match (slice.find("http://"), slice.find("https://")) {
|
||||
(Some(h), Some(s)) => Some(h.min(s)),
|
||||
(Some(h), None) => Some(h),
|
||||
(None, Some(s)) => Some(s),
|
||||
(None, None) => None,
|
||||
};
|
||||
let Some(rel) = rel else {
|
||||
out.push_str(slice);
|
||||
break;
|
||||
};
|
||||
out.push_str(&slice[..rel]);
|
||||
let url_start = cursor + rel;
|
||||
let url_slice = &line[url_start..];
|
||||
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
|
||||
let mut url_end = scheme_len;
|
||||
for (off, ch) in url_slice[scheme_len..].char_indices() {
|
||||
let abs = scheme_len + off;
|
||||
if url_char_ends_url(ch, url_slice, abs) {
|
||||
break;
|
||||
}
|
||||
url_end = abs + ch.len_utf8();
|
||||
}
|
||||
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
|
||||
cursor = url_start + url_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
let (url, suffix) = split_trailing_punct(raw);
|
||||
let mut out = String::new();
|
||||
|
||||
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
out.push_str(&url[..scheme_end]);
|
||||
|
||||
let mut rest = &url[scheme_end..];
|
||||
if let Some(at) = rest.rfind('@') {
|
||||
// Drop userinfo entirely.
|
||||
out.push_str("***@");
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
|
||||
let (hostport, path) = split_host_path(rest);
|
||||
let (host, port) = split_host_port(&hostport);
|
||||
let masked_host = mask_hostname(&host);
|
||||
out.push_str(&masked_host);
|
||||
if let Some(p) = port {
|
||||
out.push(':');
|
||||
out.push_str(&p);
|
||||
}
|
||||
if let Some((path_only, query)) = path.split_once('?') {
|
||||
out.push_str(path_only);
|
||||
out.push('?');
|
||||
out.push_str(&redact_query_string(query));
|
||||
} else {
|
||||
out.push_str(&path);
|
||||
}
|
||||
|
||||
format!("{out}{suffix}")
|
||||
}
|
||||
|
||||
fn split_trailing_punct(raw: &str) -> (&str, &str) {
|
||||
let mut end = raw.len();
|
||||
while end > 0 {
|
||||
let ch = raw.as_bytes()[end - 1] as char;
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
(&raw[..end], &raw[end..])
|
||||
}
|
||||
|
||||
fn split_host_path(rest: &str) -> (String, String) {
|
||||
if rest.starts_with('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
let hostport = &rest[..=end];
|
||||
return (hostport.to_string(), rest[end + 1..].to_string());
|
||||
}
|
||||
}
|
||||
if let Some(slash) = rest.find('/') {
|
||||
(rest[..slash].to_string(), rest[slash..].to_string())
|
||||
} else {
|
||||
(rest.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_host_port(hostport: &str) -> (String, Option<String>) {
|
||||
if hostport.starts_with('[') {
|
||||
if let Some(end) = hostport.find("]:") {
|
||||
return (
|
||||
hostport[..=end].to_string(),
|
||||
Some(hostport[end + 2..].to_string()),
|
||||
);
|
||||
}
|
||||
return (hostport.to_string(), None);
|
||||
}
|
||||
if let Some((h, p)) = hostport.rsplit_once(':') {
|
||||
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
|
||||
return (h.to_string(), Some(p.to_string()));
|
||||
}
|
||||
}
|
||||
(hostport.to_string(), None)
|
||||
}
|
||||
|
||||
fn redact_query_string(query: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.map(|pair| {
|
||||
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if is_sensitive_query_key(k) {
|
||||
format!("{k}=REDACTED")
|
||||
} else {
|
||||
pair.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn is_sensitive_query_key(key: &str) -> bool {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
|
||||
}
|
||||
|
||||
fn is_lan_ipv4(ip: &str) -> bool {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let Ok(a) = parts[0].parse::<u8>() else { return false };
|
||||
let Ok(b) = parts[1].parse::<u8>() else { return false };
|
||||
a == 127
|
||||
|| a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
}
|
||||
|
||||
fn is_lan_ipv6(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
if h == "::1" {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fc") || h.starts_with("fd") {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = h.strip_prefix("::ffff:") {
|
||||
if rest.contains('.') {
|
||||
return is_lan_ipv4(rest);
|
||||
}
|
||||
if let Some((a, b)) = rest.split_once(':') {
|
||||
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
|
||||
let ip = format!(
|
||||
"{}.{}.{}.{}",
|
||||
(v1 >> 8) & 0xff,
|
||||
v1 & 0xff,
|
||||
(v2 >> 8) & 0xff,
|
||||
v2 & 0xff
|
||||
);
|
||||
return is_lan_ipv4(&ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 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") {
|
||||
return true;
|
||||
}
|
||||
if stripped.contains(':') {
|
||||
return is_lan_ipv6(stripped);
|
||||
}
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return is_lan_ipv4(stripped);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn mask_label_prefix(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
let c1 = chars.next();
|
||||
let c2 = chars.next();
|
||||
match (c1, c2) {
|
||||
(None, _) => "*".to_string(),
|
||||
(Some(a), None) => a.to_string(),
|
||||
(Some(a), Some(b)) => {
|
||||
let rest = label.chars().count().saturating_sub(2);
|
||||
let stars = rest.clamp(1, 4);
|
||||
format!("{a}{b}{}", "*".repeat(stars))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_public_ipv4(ip: &str) -> String {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return "***".to_string();
|
||||
}
|
||||
format!("{}.*.*.{}", parts[0], parts[3])
|
||||
}
|
||||
|
||||
fn mask_hostname(host: &str) -> String {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
if is_lan_host(stripped) {
|
||||
return host.to_string();
|
||||
}
|
||||
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return mask_public_ipv4(stripped);
|
||||
}
|
||||
|
||||
if stripped.contains(':') {
|
||||
return "[ipv6-redacted]".to_string();
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = stripped.split('.').collect();
|
||||
if parts.is_empty() {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let masked_first = mask_label_prefix(parts[0]);
|
||||
|
||||
if parts.len() == 1 {
|
||||
masked_first
|
||||
} else {
|
||||
format!("{}.{}", masked_first, parts[1..].join("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_subsonic_wire_auth_params() {
|
||||
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("t=REDACTED"));
|
||||
assert!(out.contains("s=REDACTED"));
|
||||
assert!(out.contains("p=REDACTED"));
|
||||
assert!(!out.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_remote_hostname_keeps_lan_ip() {
|
||||
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
|
||||
assert!(remote.contains("my****.example.com"));
|
||||
assert!(!remote.contains("my-server.example.com"));
|
||||
|
||||
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
|
||||
assert!(lan.contains("192.168.1.42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_bearer_and_password_kv() {
|
||||
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("Bearer REDACTED"));
|
||||
assert!(!out.contains("eyJhbGci"));
|
||||
assert!(out.contains("password=REDACTED"));
|
||||
assert!(!out.contains("sekrit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_url_userinfo() {
|
||||
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("***@10.0.0.5"));
|
||||
assert!(!out.contains("user:pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_reverse_proxy_gate_headers() {
|
||||
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
|
||||
assert!(!out.contains("gate-secret"));
|
||||
assert!(!out.contains("tok123"));
|
||||
assert!(out.contains("x-pangolin-auth: REDACTED"));
|
||||
assert!(!out.contains("pangolin-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_log_with_em_dash_does_not_panic() {
|
||||
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains('—'));
|
||||
assert!(out.contains("RangedHttpSource"));
|
||||
}
|
||||
}
|
||||
@@ -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,7 +102,26 @@ 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;
|
||||
{
|
||||
let mut buf = log_buffer().lock().unwrap();
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Local playback disk layout — artist/album/track paths from library-index fields.
|
||||
//!
|
||||
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
|
||||
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
|
||||
//! index key; artist/album/filename segments are derived from track metadata only.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::cover_cache_layout::sanitize_path_segment;
|
||||
|
||||
/// Max length for a single path component after sanitization (Windows budget).
|
||||
pub const MAX_SEGMENT_LEN: usize = 120;
|
||||
|
||||
/// Inputs required to build hierarchical media paths (library index row projection).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TrackPathInput {
|
||||
pub artist: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub album: String,
|
||||
pub title: String,
|
||||
pub track_number: Option<i64>,
|
||||
pub disc_number: Option<i64>,
|
||||
pub suffix: Option<String>,
|
||||
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
|
||||
pub raw_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalTier {
|
||||
Ephemeral,
|
||||
Library,
|
||||
/// Auto-synced starred favorites — separate from user-pinned `library/`.
|
||||
Favorites,
|
||||
}
|
||||
|
||||
impl LocalTier {
|
||||
pub fn subdir(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ephemeral => "cache",
|
||||
Self::Library => "library",
|
||||
Self::Favorites => "favorites",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"ephemeral" | "cache" => Some(Self::Ephemeral),
|
||||
"library" => Some(Self::Library),
|
||||
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
|
||||
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
|
||||
let artist_seg = artist_folder_segment(input);
|
||||
let album_seg = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let suffix = input
|
||||
.suffix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("");
|
||||
let track_n = input.track_number.unwrap_or(0);
|
||||
let disc_n = input.disc_number.unwrap_or(0);
|
||||
format!(
|
||||
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
|
||||
input
|
||||
.album_artist
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or(""),
|
||||
input.title.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
|
||||
pub fn relative_path_for_track(
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
let server_segment = sanitize_path_segment(server_index_key);
|
||||
let artist = artist_folder_segment(input);
|
||||
let album = album_folder_segment(&input.album);
|
||||
let stem = track_filename_stem(input);
|
||||
let ext = suffix.trim().trim_start_matches('.');
|
||||
let filename = if ext.is_empty() {
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
|
||||
} else {
|
||||
format!(
|
||||
"{}.{}",
|
||||
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
|
||||
sanitize_path_segment(ext)
|
||||
)
|
||||
};
|
||||
PathBuf::from(server_segment)
|
||||
.join(artist)
|
||||
.join(album)
|
||||
.join(filename)
|
||||
}
|
||||
|
||||
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
|
||||
pub fn absolute_track_path(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
server_index_key: &str,
|
||||
input: &TrackPathInput,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
media_root
|
||||
.join(tier.subdir())
|
||||
.join(relative_path_for_track(server_index_key, input, suffix))
|
||||
}
|
||||
|
||||
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
|
||||
pub fn ensure_track_path_within_tier(
|
||||
media_root: &Path,
|
||||
tier: LocalTier,
|
||||
absolute: &Path,
|
||||
) -> Result<(), String> {
|
||||
let tier_root = media_root.join(tier.subdir());
|
||||
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
|
||||
return Err(format!(
|
||||
"path `{}` escapes tier root `{}`",
|
||||
absolute.display(),
|
||||
tier_root.display()
|
||||
));
|
||||
};
|
||||
for comp in rel.components() {
|
||||
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
|
||||
return Err(format!(
|
||||
"path `{}` contains forbidden component `{comp:?}`",
|
||||
absolute.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artist_folder_segment(input: &TrackPathInput) -> String {
|
||||
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
|
||||
let chosen = if artist.is_empty() || track_is_compilation(input) {
|
||||
if !album_artist.is_empty() {
|
||||
album_artist
|
||||
} else {
|
||||
"Various Artists"
|
||||
}
|
||||
} else {
|
||||
artist
|
||||
};
|
||||
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn album_folder_segment(album: &str) -> String {
|
||||
let trimmed = album.trim();
|
||||
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
|
||||
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
|
||||
}
|
||||
|
||||
fn track_filename_stem(input: &TrackPathInput) -> String {
|
||||
let title = input.title.trim();
|
||||
let title = if title.is_empty() { "Unknown Title" } else { title };
|
||||
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
|
||||
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
|
||||
if disc_n > 1 {
|
||||
format!("{disc_n:02}-{track_n:02} - {title}")
|
||||
} else {
|
||||
format!("{track_n:02} - {title}")
|
||||
}
|
||||
}
|
||||
|
||||
fn track_is_compilation(input: &TrackPathInput) -> bool {
|
||||
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
|
||||
return true;
|
||||
}
|
||||
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
raw_json_marks_compilation(raw)
|
||||
}
|
||||
|
||||
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
|
||||
fn raw_json_marks_compilation(raw: &str) -> bool {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
lower.contains("\"iscompilation\":true")
|
||||
|| lower.contains("\"iscompilation\": true")
|
||||
|| lower.contains("\"compilation\":true")
|
||||
|| lower.contains("\"compilation\": true")
|
||||
|| lower.contains("\"compilation\":1")
|
||||
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
|
||||
}
|
||||
|
||||
fn various_artists_label(s: &str) -> bool {
|
||||
let lower = s.trim().to_ascii_lowercase();
|
||||
lower.contains("various artists")
|
||||
}
|
||||
|
||||
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
|
||||
let sanitized = sanitize_path_segment(segment);
|
||||
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
|
||||
if sanitized.chars().count() <= max_len {
|
||||
return sanitized;
|
||||
}
|
||||
let hash = short_hash(segment);
|
||||
let keep = max_len.saturating_sub(1 + hash.len());
|
||||
let mut out = sanitized.chars().take(keep).collect::<String>();
|
||||
out.push('_');
|
||||
out.push_str(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0;
|
||||
for unit in s.encode_utf16() {
|
||||
h = h.wrapping_mul(31).wrapping_add(unit as u32);
|
||||
}
|
||||
format!("{:08x}", h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_input() -> TrackPathInput {
|
||||
TrackPathInput {
|
||||
artist: Some("Radiohead".to_string()),
|
||||
album_artist: None,
|
||||
album: "OK Computer".to_string(),
|
||||
title: "Paranoid Android".to_string(),
|
||||
track_number: Some(6),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_uses_library_segments() {
|
||||
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
|
||||
assert_eq!(
|
||||
rel,
|
||||
PathBuf::from("host_4533")
|
||||
.join("Radiohead")
|
||||
.join("OK Computer")
|
||||
.join("06 - Paranoid Android.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disc_adds_disc_prefix() {
|
||||
let mut input = sample_input();
|
||||
input.disc_number = Some(2);
|
||||
let rel = relative_path_for_track("srv", &input, "flac");
|
||||
assert!(rel
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_uses_album_artist_folder() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("Various Artists".to_string()),
|
||||
album_artist: Some("Original Soundtrack".to_string()),
|
||||
album: "Film Score".to_string(),
|
||||
title: "Main Theme".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_artist_falls_back_to_various_artists() {
|
||||
let input = TrackPathInput {
|
||||
artist: None,
|
||||
album_artist: None,
|
||||
album: "Comp".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let rel = relative_path_for_track("srv", &input, "mp3");
|
||||
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_fingerprint_is_stable() {
|
||||
let a = layout_fingerprint(&sample_input());
|
||||
let b = layout_fingerprint(&sample_input());
|
||||
assert_eq!(a, b);
|
||||
assert!(a.contains("Radiohead"));
|
||||
assert!(a.contains("OK Computer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_subdirs_are_fixed() {
|
||||
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
|
||||
assert_eq!(LocalTier::Library.subdir(), "library");
|
||||
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
|
||||
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
|
||||
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
|
||||
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_includes_tier() {
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_metadata_does_not_escape_tier_root() {
|
||||
let input = TrackPathInput {
|
||||
artist: Some("..".to_string()),
|
||||
album_artist: None,
|
||||
album: "..".to_string(),
|
||||
title: "Song".to_string(),
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
suffix: Some("mp3".to_string()),
|
||||
raw_json: None,
|
||||
};
|
||||
let root = Path::new("/media");
|
||||
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
|
||||
assert!(path.starts_with(root.join("library")));
|
||||
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_hash_matches_ts_imul31_utf16() {
|
||||
// "Radiohead" — same as mediaLayout.test parity anchor.
|
||||
assert_eq!(short_hash("Radiohead"), "3da68c3b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_and_truncate_uses_code_point_threshold() {
|
||||
let cyrillic_a = '\u{0430}';
|
||||
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
|
||||
assert!(hundred.len() > MAX_SEGMENT_LEN);
|
||||
assert_eq!(hundred.chars().count(), 100);
|
||||
assert_eq!(
|
||||
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
|
||||
hundred
|
||||
);
|
||||
|
||||
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
|
||||
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
|
||||
assert!(truncated.ends_with("_eef20600"));
|
||||
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
|
||||
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[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, specta::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CustomHeadersApplyTo {
|
||||
Local,
|
||||
#[default]
|
||||
Public,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct ServerHttpEndpointWire {
|
||||
pub url: String,
|
||||
pub kind: EndpointKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct CustomHeaderEntryWire {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
pub struct ServerHttpContextSyncWire {
|
||||
#[serde(rename = "serverId")]
|
||||
pub server_id: String,
|
||||
#[serde(rename = "appServerId")]
|
||||
pub app_server_id: String,
|
||||
pub endpoints: Vec<ServerHttpEndpointWire>,
|
||||
#[serde(rename = "customHeaders", default)]
|
||||
pub custom_headers: Vec<CustomHeaderEntryWire>,
|
||||
#[serde(rename = "customHeadersApplyTo", default)]
|
||||
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerHttpContext {
|
||||
pub endpoints: Vec<(String, EndpointKind)>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub apply_to: CustomHeadersApplyTo,
|
||||
}
|
||||
|
||||
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
|
||||
fn from(w: ServerHttpContextSyncWire) -> Self {
|
||||
Self {
|
||||
endpoints: w
|
||||
.endpoints
|
||||
.into_iter()
|
||||
.map(|e| (normalize_server_base_url(&e.url), e.kind))
|
||||
.collect(),
|
||||
headers: w
|
||||
.custom_headers
|
||||
.into_iter()
|
||||
.map(|h| (h.name.trim().to_string(), h.value))
|
||||
.filter(|(n, _)| !n.is_empty())
|
||||
.collect(),
|
||||
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_server_base_url(raw: &str) -> String {
|
||||
let trimmed = raw.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
|
||||
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
|
||||
let trimmed = raw_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
|
||||
return normalize_server_base_url(trimmed);
|
||||
};
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
let mut path = parsed.path().to_string();
|
||||
if let Some(idx) = path.find("/rest/") {
|
||||
path.truncate(idx);
|
||||
} else if path.ends_with("/rest") {
|
||||
path.truncate(path.len().saturating_sub("/rest".len()));
|
||||
} else {
|
||||
for seg in ["/api/", "/auth/"] {
|
||||
if let Some(idx) = path.find(seg) {
|
||||
path.truncate(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while path.ends_with('/') && path.len() > 1 {
|
||||
path.pop();
|
||||
}
|
||||
parsed.set_path(if path.is_empty() { "/" } else { &path });
|
||||
let host = parsed.host_str().unwrap_or_default();
|
||||
if host.is_empty() {
|
||||
return normalize_server_base_url(trimmed);
|
||||
}
|
||||
let mut out = format!("{}://{}", parsed.scheme(), host);
|
||||
if let Some(port) = parsed.port() {
|
||||
out.push(':');
|
||||
out.push_str(&port.to_string());
|
||||
}
|
||||
if !path.is_empty() && path != "/" {
|
||||
out.push_str(&path);
|
||||
}
|
||||
normalize_server_base_url(&out)
|
||||
}
|
||||
|
||||
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
|
||||
let mut map = HeaderMap::new();
|
||||
if ctx.headers.is_empty() {
|
||||
return map;
|
||||
}
|
||||
let normalized = normalize_server_base_url(request_base_url);
|
||||
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
|
||||
return map;
|
||||
};
|
||||
let apply = match ctx.apply_to {
|
||||
CustomHeadersApplyTo::Both => true,
|
||||
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
|
||||
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
|
||||
};
|
||||
if !apply {
|
||||
return map;
|
||||
}
|
||||
for (name, value) in &ctx.headers {
|
||||
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(header_value) = HeaderValue::from_str(value) else {
|
||||
continue;
|
||||
};
|
||||
map.insert(header_name, header_value);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
pub fn apply_server_headers(
|
||||
builder: RequestBuilder,
|
||||
ctx: &ServerHttpContext,
|
||||
request_base_url: &str,
|
||||
) -> RequestBuilder {
|
||||
let map = headers_for_request_base_url(ctx, request_base_url);
|
||||
if map.is_empty() {
|
||||
return builder;
|
||||
}
|
||||
builder.headers(map)
|
||||
}
|
||||
|
||||
pub fn apply_server_headers_for_http_url(
|
||||
builder: RequestBuilder,
|
||||
ctx: &ServerHttpContext,
|
||||
full_http_url: &str,
|
||||
) -> RequestBuilder {
|
||||
let base = request_base_url_from_http_url(full_http_url);
|
||||
apply_server_headers(builder, ctx, &base)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ServerHttpRegistry {
|
||||
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
|
||||
ref_to_key: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl ServerHttpRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
|
||||
let index_key = wire.server_id.clone();
|
||||
let app_id = wire.app_server_id.clone();
|
||||
let ctx = Arc::new(ServerHttpContext::from(wire));
|
||||
if ctx.headers.is_empty() {
|
||||
self.remove(&index_key, &app_id);
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut contexts = self.contexts.lock().unwrap();
|
||||
contexts.insert(index_key.clone(), Arc::clone(&ctx));
|
||||
}
|
||||
let mut refs = self.ref_to_key.lock().unwrap();
|
||||
refs.insert(index_key.clone(), index_key.clone());
|
||||
refs.insert(app_id, index_key);
|
||||
}
|
||||
|
||||
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
|
||||
let mut new_contexts = HashMap::new();
|
||||
let mut new_refs = HashMap::new();
|
||||
for wire in entries {
|
||||
let index_key = wire.server_id.clone();
|
||||
let app_id = wire.app_server_id.clone();
|
||||
let ctx = Arc::new(ServerHttpContext::from(wire));
|
||||
if ctx.headers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
|
||||
new_refs.insert(index_key.clone(), index_key.clone());
|
||||
new_refs.insert(app_id, index_key);
|
||||
}
|
||||
*self.contexts.lock().unwrap() = new_contexts;
|
||||
*self.ref_to_key.lock().unwrap() = new_refs;
|
||||
}
|
||||
|
||||
pub fn remove(&self, index_key: &str, app_server_id: &str) {
|
||||
self.contexts.lock().unwrap().remove(index_key);
|
||||
let mut refs = self.ref_to_key.lock().unwrap();
|
||||
refs.remove(index_key);
|
||||
refs.remove(app_server_id);
|
||||
}
|
||||
|
||||
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
|
||||
self.contexts.lock().unwrap().get(index_key).cloned()
|
||||
}
|
||||
|
||||
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
|
||||
if server_ref.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let key = {
|
||||
let refs = self.ref_to_key.lock().unwrap();
|
||||
refs.get(server_ref).cloned()
|
||||
};
|
||||
if let Some(k) = key {
|
||||
return self.get(&k);
|
||||
}
|
||||
self.get(server_ref)
|
||||
}
|
||||
|
||||
/// Fallback when only a server base URL is known (Navidrome invoke paths).
|
||||
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
|
||||
let base = request_base_url_from_http_url(server_url);
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let contexts = self.contexts.lock().unwrap();
|
||||
for ctx in contexts.values() {
|
||||
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
|
||||
return Some(Arc::clone(ctx));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a context by `server_ref` first, then fall back to matching the
|
||||
/// request URL against the registered endpoints. The URL fallback is what
|
||||
/// keeps gated servers working when a caller passes a stale/foreign ref
|
||||
/// (e.g. the audio engine's playback server id vs. the index key): endpoint
|
||||
/// matching only ever hits a registered gated server, and `apply_to` is
|
||||
/// still enforced downstream, so non-gated servers are never touched.
|
||||
pub fn resolve_context(
|
||||
&self,
|
||||
server_ref: Option<&str>,
|
||||
full_http_url: &str,
|
||||
) -> Option<Arc<ServerHttpContext>> {
|
||||
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
|
||||
if let Some(ctx) = self.get_for_server_ref(sid) {
|
||||
return Some(ctx);
|
||||
}
|
||||
}
|
||||
self.get_for_server_url(full_http_url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
full_http_url: &str,
|
||||
builder: RequestBuilder,
|
||||
) -> RequestBuilder {
|
||||
if let Some(reg) = registry {
|
||||
if let Some(ctx) = reg.resolve_context(server_ref, full_http_url) {
|
||||
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_base_url_strips_rest_and_query() {
|
||||
let url = "https://music.example/rest/stream.view?id=1&u=x";
|
||||
assert_eq!(
|
||||
request_base_url_from_http_url(url),
|
||||
"https://music.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_apply_public_only_on_public_endpoint() {
|
||||
let ctx = ServerHttpContext {
|
||||
endpoints: vec![
|
||||
("http://192.168.0.10".into(), EndpointKind::Local),
|
||||
("https://music.example".into(), EndpointKind::Public),
|
||||
],
|
||||
headers: vec![("X-Gate".into(), "secret".into())],
|
||||
apply_to: CustomHeadersApplyTo::Public,
|
||||
};
|
||||
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
|
||||
assert!(lan.is_empty());
|
||||
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
|
||||
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();
|
||||
reg.sync(ServerHttpContextSyncWire {
|
||||
server_id: "music.example".into(),
|
||||
app_server_id: "uuid-1".into(),
|
||||
endpoints: vec![ServerHttpEndpointWire {
|
||||
url: "https://music.example".into(),
|
||||
kind: EndpointKind::Public,
|
||||
}],
|
||||
custom_headers: vec![CustomHeaderEntryWire {
|
||||
name: "X-Gate".into(),
|
||||
value: "tok".into(),
|
||||
}],
|
||||
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
|
||||
});
|
||||
assert!(reg.get("music.example").is_some());
|
||||
assert!(reg.get_for_server_ref("uuid-1").is_some());
|
||||
assert!(reg.get("uuid-1").is_none());
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ name = "psysonic-integration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
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,15 +1,31 @@
|
||||
//! Auth + retry + HTTP client for Navidrome's native REST API.
|
||||
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
|
||||
|
||||
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
|
||||
|
||||
/// 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> {
|
||||
navidrome_token_with_registry(None, server_url, username, password).await
|
||||
}
|
||||
|
||||
pub async fn navidrome_token_with_registry(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/auth/login", server_url))
|
||||
.json(&serde_json::json!({ "username": username, "password": password }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let base = server_url.trim_end_matches('/');
|
||||
let login_url = format!("{base}/auth/login");
|
||||
let req = apply_optional_registry_headers(
|
||||
registry,
|
||||
None,
|
||||
&login_url,
|
||||
client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({ "username": username, "password": password })),
|
||||
);
|
||||
let 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"]
|
||||
.as_str()
|
||||
@@ -17,8 +33,18 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
|
||||
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
|
||||
}
|
||||
|
||||
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
|
||||
pub fn nd_apply_request(
|
||||
registry: Option<&ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
full_url: &str,
|
||||
builder: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
apply_optional_registry_headers(registry, server_ref, full_url, builder)
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
@@ -97,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)
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
//! login (via `navidrome_token`) and then a multipart POST to the relevant
|
||||
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
|
||||
|
||||
use super::client::navidrome_token;
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
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,
|
||||
playlist_id: String,
|
||||
username: String,
|
||||
@@ -13,26 +20,35 @@ pub async fn upload_playlist_cover(
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
@@ -40,26 +56,35 @@ pub async fn upload_radio_cover(
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upload_artist_image(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
artist_id: String,
|
||||
username: String,
|
||||
@@ -67,40 +92,59 @@ pub async fn upload_artist_image(
|
||||
file_bytes: Vec<u8>,
|
||||
mime_type: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("cover.jpg")
|
||||
.mime_str(&mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("image", part);
|
||||
reqwest::Client::new()
|
||||
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.multipart(form),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_radio_cover(
|
||||
http_registry: State<'_, Arc<ServerHttpRegistry>>,
|
||||
server_url: String,
|
||||
radio_id: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let token = navidrome_token(&server_url, &username, &password).await?;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let reg = http_registry.as_ref();
|
||||
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
|
||||
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
|
||||
let resp = nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.delete(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 404/503 = no image existed — treat as success
|
||||
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
||||
if !resp.status().is_success()
|
||||
&& resp.status() != reqwest::StatusCode::NOT_FOUND
|
||||
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
|
||||
{
|
||||
resp.error_for_status().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -11,4 +11,4 @@ pub mod probe;
|
||||
pub mod queries;
|
||||
pub mod users;
|
||||
|
||||
pub use client::navidrome_token;
|
||||
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
|
||||
|
||||
@@ -2,24 +2,42 @@
|
||||
//! payload is forwarded as-is so the frontend can compose any rule the
|
||||
//! Navidrome version supports without backend changes.
|
||||
|
||||
use super::client::{nd_err, nd_http_client, nd_retry};
|
||||
use std::sync::Arc;
|
||||
|
||||
use psysonic_core::server_http::ServerHttpRegistry;
|
||||
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>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
smart: Option<bool>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let base = format!("{}/api/playlist", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
let client = nd_http_client();
|
||||
let mut req = client
|
||||
.get(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token));
|
||||
if let Some(s) = smart {
|
||||
req = req.query(&[("smart", s)]);
|
||||
let base = base.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
let mut req = nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&base,
|
||||
nd_http_client()
|
||||
.get(&base)
|
||||
.header("X-ND-Authorization", auth),
|
||||
);
|
||||
if let Some(s) = smart {
|
||||
req = req.query(&[("smart", s)]);
|
||||
}
|
||||
req.send().await
|
||||
}
|
||||
req.send()
|
||||
})
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -29,18 +47,34 @@ 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>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist", server_url);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.post(format!("{}/api/playlist", server_url))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.post(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
@@ -52,19 +86,35 @@ 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>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.put(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
.json(&body)
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
let body = body.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.put(&url)
|
||||
.header("X-ND-Authorization", auth)
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
@@ -76,17 +126,32 @@ 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>>,
|
||||
server_url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.get(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
@@ -99,16 +164,31 @@ 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,
|
||||
token: String,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let reg = http_registry.as_ref();
|
||||
let url = format!("{}/api/playlist/{}", server_url, id);
|
||||
let auth = format!("Bearer {}", token);
|
||||
let resp = nd_retry(|| {
|
||||
nd_http_client()
|
||||
.delete(format!("{}/api/playlist/{}", server_url, id))
|
||||
.header("X-ND-Authorization", format!("Bearer {}", token))
|
||||
let url = url.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
nd_apply_request(
|
||||
Some(reg),
|
||||
None,
|
||||
&url,
|
||||
nd_http_client()
|
||||
.delete(&url)
|
||||
.header("X-ND-Authorization", auth),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! endpoint? — so this stays a free function rather than a client
|
||||
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
|
||||
|
||||
use super::client::{nd_err, nd_http_client};
|
||||
use super::client::{nd_apply_request, nd_err, nd_http_client};
|
||||
|
||||
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
|
||||
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
|
||||
@@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client};
|
||||
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
|
||||
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
|
||||
/// is PR-3b's job.
|
||||
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
|
||||
pub async fn native_bulk_available(
|
||||
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
|
||||
server_ref: Option<&str>,
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
) -> Result<bool, String> {
|
||||
let client = nd_http_client();
|
||||
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("X-ND-Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(nd_err)?;
|
||||
let resp = nd_apply_request(
|
||||
registry,
|
||||
server_ref,
|
||||
&url,
|
||||
client
|
||||
.get(&url)
|
||||
.header("X-ND-Authorization", format!("Bearer {token}")),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(nd_err)?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
@@ -56,7 +66,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
|
||||
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
@@ -71,7 +81,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
|
||||
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
@@ -84,7 +94,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
|
||||
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
|
||||
assert!(!ok, "401 reads as `endpoint not available for this caller`");
|
||||
}
|
||||
|
||||
@@ -97,7 +107,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
|
||||
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
|
||||
assert!(err.contains("503"));
|
||||
}
|
||||
|
||||
@@ -111,6 +121,6 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let with_slash = format!("{}/", server.uri());
|
||||
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
|
||||
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user