fix(playback): ReplayGain prefetch, gapless UI sync, library peak column (#1231)

This commit is contained in:
cucadmuh
2026-07-04 16:00:03 +03:00
committed by GitHub
parent 9183c3d657
commit fdbb9deac6
52 changed files with 2564 additions and 488 deletions
+10
View File
@@ -34,6 +34,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Reorganised the frontend into a feature-folder architecture with a CI-enforced layering guard, added unit + behavior-scenario + boot-smoke test coverage, and introduced a compile-time frontend/backend IPC contract via tauri-specta. Internal only — no change to how the app looks or behaves.
## Fixed
### Playback — ReplayGain prefetch, gapless playbar sync, and library peak index
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1231](https://github.com/Psychotoxical/psysonic/pull/1231)**
* ReplayGain applies when stream or queue metadata resolves late — index-first prefetch before bind, reactive sync when resolver tags land, and live refresh from the library index after sync when tags differ on the playing track.
* Gapless auto-advance no longer leaves the playbar on the previous track; missed `audio:track_switched` is reconciled from engine position with seek guards so backward seek is not treated as a gapless switch.
* Local library index stores `replayGainPeak` (migration 015) so anti-clipping peak is available on index-first paths without an extra network round-trip.
## [1.49.0] - 2026-06-29
+134
View File
@@ -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
+254
View File
@@ -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
+249
View File
@@ -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'));
@@ -0,0 +1,2 @@
-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak).
ALTER TABLE track ADD COLUMN replay_gain_peak REAL;
@@ -1438,6 +1438,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -213,6 +213,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -208,6 +208,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -1492,6 +1492,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: Some(format!("hash-{id}")),
server_updated_at: None,
server_created_at: None,
@@ -212,6 +212,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -106,6 +106,7 @@ pub struct LibraryTrackDto {
pub bpm_source: Option<String>,
pub replay_gain_track_db: Option<f64>,
pub replay_gain_album_db: Option<f64>,
pub replay_gain_peak: Option<f64>,
pub server_updated_at: Option<i64>,
pub server_created_at: Option<i64>,
@@ -157,6 +158,7 @@ impl LibraryTrackDto {
bpm_source: None,
replay_gain_track_db: row.replay_gain_track_db,
replay_gain_album_db: row.replay_gain_album_db,
replay_gain_peak: row.replay_gain_peak,
server_updated_at: row.server_updated_at,
server_created_at: row.server_created_at,
synced_at: row.synced_at,
@@ -750,6 +752,7 @@ mod tests {
bpm: Some(120),
replay_gain_track_db: Some(-1.2),
replay_gain_album_db: Some(-0.8),
replay_gain_peak: Some(0.95),
content_hash: Some("deadbeef".into()),
server_updated_at: Some(1_700_000_000),
server_created_at: Some(1_699_000_000),
@@ -780,6 +783,7 @@ mod tests {
"mbidRecording",
"replayGainTrackDb",
"replayGainAlbumDb",
"replayGainPeak",
"serverUpdatedAt",
"syncedAt",
"rawJson",
@@ -230,6 +230,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -288,6 +288,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -375,6 +375,7 @@ fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<
mbid_recording: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
server_updated_at: None,
server_created_at: None,
synced_at: row.get(offset + 21)?,
@@ -429,6 +430,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -180,6 +180,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -233,6 +233,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -34,6 +34,7 @@ fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, duration_se
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -79,6 +80,7 @@ fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: if hash.is_empty() {
None
} else {
@@ -503,6 +505,7 @@ fn recent_plays_includes_album_cover_metadata() {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -53,6 +53,7 @@ pub struct TrackRow {
pub bpm: Option<i64>,
pub replay_gain_track_db: Option<f64>,
pub replay_gain_album_db: Option<f64>,
pub replay_gain_peak: Option<f64>,
pub content_hash: Option<String>,
pub server_updated_at: Option<i64>,
pub server_created_at: Option<i64>,
@@ -150,6 +151,7 @@ impl<'a> TrackRepository<'a> {
r.bpm,
r.replay_gain_track_db,
r.replay_gain_album_db,
r.replay_gain_peak,
r.content_hash,
r.server_updated_at,
r.server_created_at,
@@ -189,6 +191,7 @@ impl<'a> TrackRepository<'a> {
r.bpm,
r.replay_gain_track_db,
r.replay_gain_album_db,
r.replay_gain_peak,
r.content_hash,
r.server_updated_at,
r.server_created_at,
@@ -499,6 +502,7 @@ impl<'a> TrackRepository<'a> {
r.bpm,
r.replay_gain_track_db,
r.replay_gain_album_db,
r.replay_gain_peak,
r.content_hash,
r.server_updated_at,
r.server_created_at,
@@ -673,27 +677,27 @@ const TRACK_COLUMNS: &str = "\
album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, \
played_at, server_path, library_id, isrc, mbid_recording, bpm, \
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, \
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at, \
server_created_at, deleted, synced_at, raw_json";
const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 \
ORDER BY disc_number ASC NULLS LAST, track_number ASC NULLS LAST, id ASC";
@@ -733,12 +737,13 @@ pub(crate) fn row_to_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Trac
bpm: row.get(26)?,
replay_gain_track_db: row.get(27)?,
replay_gain_album_db: row.get(28)?,
content_hash: row.get(29)?,
server_updated_at: row.get(30)?,
server_created_at: row.get(31)?,
deleted: row.get::<_, i64>(32)? != 0,
synced_at: row.get(33)?,
raw_json: row.get(34)?,
replay_gain_peak: row.get(29)?,
content_hash: row.get(30)?,
server_updated_at: row.get(31)?,
server_created_at: row.get(32)?,
deleted: row.get::<_, i64>(33)? != 0,
synced_at: row.get(34)?,
raw_json: row.get(35)?,
})
}
@@ -748,60 +753,8 @@ INSERT INTO track (
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
played_at, server_path, library_id, isrc, mbid_recording, bpm,
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
server_created_at, deleted, synced_at, raw_json
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
?33, ?34, ?35
)
ON CONFLICT(server_id, id) DO UPDATE SET
title = excluded.title,
title_sort = excluded.title_sort,
artist = excluded.artist,
artist_id = excluded.artist_id,
album = excluded.album,
album_id = excluded.album_id,
album_artist = excluded.album_artist,
duration_sec = excluded.duration_sec,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
year = excluded.year,
genre = excluded.genre,
suffix = excluded.suffix,
bit_rate = excluded.bit_rate,
size_bytes = excluded.size_bytes,
cover_art_id = excluded.cover_art_id,
starred_at = excluded.starred_at,
user_rating = excluded.user_rating,
play_count = excluded.play_count,
played_at = excluded.played_at,
server_path = excluded.server_path,
library_id = excluded.library_id,
isrc = excluded.isrc,
mbid_recording = excluded.mbid_recording,
bpm = excluded.bpm,
replay_gain_track_db = excluded.replay_gain_track_db,
replay_gain_album_db = excluded.replay_gain_album_db,
-- E2: never let a sync (which passes NULL content_hash) clobber the
-- playback-derived md5_16kb written via library_patch_track / the analysis
-- bridge. A non-empty incoming hash still wins.
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
server_updated_at = excluded.server_updated_at,
server_created_at = excluded.server_created_at,
deleted = excluded.deleted,
synced_at = excluded.synced_at,
raw_json = excluded.raw_json
"#;
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
INSERT INTO track (
server_id, id, title, title_sort, artist, artist_id, album, album_id,
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
played_at, server_path, library_id, isrc, mbid_recording, bpm,
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
server_created_at, deleted, synced_at, raw_json, resync_gen
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
@@ -835,6 +788,60 @@ ON CONFLICT(server_id, id) DO UPDATE SET
bpm = excluded.bpm,
replay_gain_track_db = excluded.replay_gain_track_db,
replay_gain_album_db = excluded.replay_gain_album_db,
replay_gain_peak = excluded.replay_gain_peak,
-- E2: never let a sync (which passes NULL content_hash) clobber the
-- playback-derived md5_16kb written via library_patch_track / the analysis
-- bridge. A non-empty incoming hash still wins.
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
server_updated_at = excluded.server_updated_at,
server_created_at = excluded.server_created_at,
deleted = excluded.deleted,
synced_at = excluded.synced_at,
raw_json = excluded.raw_json
"#;
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
INSERT INTO track (
server_id, id, title, title_sort, artist, artist_id, album, album_id,
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
played_at, server_path, library_id, isrc, mbid_recording, bpm,
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
server_created_at, deleted, synced_at, raw_json, resync_gen
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
?33, ?34, ?35, ?36, ?37
)
ON CONFLICT(server_id, id) DO UPDATE SET
title = excluded.title,
title_sort = excluded.title_sort,
artist = excluded.artist,
artist_id = excluded.artist_id,
album = excluded.album,
album_id = excluded.album_id,
album_artist = excluded.album_artist,
duration_sec = excluded.duration_sec,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
year = excluded.year,
genre = excluded.genre,
suffix = excluded.suffix,
bit_rate = excluded.bit_rate,
size_bytes = excluded.size_bytes,
cover_art_id = excluded.cover_art_id,
starred_at = excluded.starred_at,
user_rating = excluded.user_rating,
play_count = excluded.play_count,
played_at = excluded.played_at,
server_path = excluded.server_path,
library_id = excluded.library_id,
isrc = excluded.isrc,
mbid_recording = excluded.mbid_recording,
bpm = excluded.bpm,
replay_gain_track_db = excluded.replay_gain_track_db,
replay_gain_album_db = excluded.replay_gain_album_db,
replay_gain_peak = excluded.replay_gain_peak,
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
server_updated_at = excluded.server_updated_at,
server_created_at = excluded.server_created_at,
@@ -887,6 +894,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: Some("hash-abc".into()),
server_updated_at: Some(1_700_000_000),
server_created_at: Some(1_699_000_000),
@@ -356,6 +356,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
+70 -1
View File
@@ -12,11 +12,14 @@ use tauri::Manager;
///
/// Migration checklist (wiring, data backfill, open/swap path):
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 14;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 15;
/// One-time data repair after migration 014 (`artist.name_sort`).
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
/// One-time backfill after migration 015 (`track.replay_gain_peak`).
pub(crate) const REPLAY_GAIN_PEAK_RECONCILE_ID: &str = "replay_gain_peak_reconcile_v1";
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
/// fires (spec §5.7 / P22): the library is treated as incompatible, must be
@@ -38,6 +41,8 @@ pub(crate) const MIGRATION_013_ARTIST_ARTWORK_LOOKUP: &str =
include_str!("../migrations/013_artist_artwork_lookup.sql");
pub(crate) const MIGRATION_014_ARTIST_NAME_SORT: &str =
include_str!("../migrations/014_artist_name_sort.sql");
pub(crate) const MIGRATION_015_REPLAY_GAIN_PEAK: &str =
include_str!("../migrations/015_replay_gain_peak.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
@@ -46,6 +51,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
(14, MIGRATION_014_ARTIST_NAME_SORT),
(15, MIGRATION_015_REPLAY_GAIN_PEAK),
];
/// Idempotent repair — also runs after the migration runner on every open so
@@ -589,6 +595,7 @@ fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Co
fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> {
run_migrations(conn)?;
maybe_reconcile_artist_name_sort(conn)?;
maybe_reconcile_replay_gain_peak(conn)?;
ensure_genre_tags_schema(conn)?;
checkpoint_wal_conn(conn, "open")?;
Ok(())
@@ -721,6 +728,68 @@ fn repair_artist_name_sort_keys(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
fn replay_gain_peak_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
let column_exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('track') WHERE name = 'replay_gain_peak'",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(column_exists > 0)
}
fn replay_gain_peak_reconcile_completed(conn: &Connection) -> rusqlite::Result<bool> {
let completed: Option<Option<i64>> = conn
.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![REPLAY_GAIN_PEAK_RECONCILE_ID],
|row| row.get(0),
)
.optional()?;
Ok(completed.flatten().is_some())
}
fn mark_replay_gain_peak_reconcile_completed(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
VALUES (?1, 0, strftime('%s','now'), strftime('%s','now')) \
ON CONFLICT(id) DO UPDATE SET completed_at = excluded.completed_at",
params![REPLAY_GAIN_PEAK_RECONCILE_ID],
)?;
Ok(())
}
/// One-time backfill after schema 015 — project peak from stored `raw_json`.
fn repair_replay_gain_peak_from_raw_json(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"UPDATE track SET replay_gain_peak = json_extract(raw_json, '$.replayGain.trackPeak') \
WHERE replay_gain_peak IS NULL \
AND json_type(json_extract(raw_json, '$.replayGain.trackPeak')) = 'real'",
[],
)?;
conn.execute(
"UPDATE track SET replay_gain_peak = json_extract(raw_json, '$.rgTrackPeak') \
WHERE replay_gain_peak IS NULL \
AND json_type(json_extract(raw_json, '$.rgTrackPeak')) = 'real'",
[],
)?;
Ok(())
}
/// One-time reconcile after schema 015 — not on every open.
fn maybe_reconcile_replay_gain_peak(conn: &Connection) -> rusqlite::Result<()> {
if !replay_gain_peak_column_exists(conn)? {
return Ok(());
}
if replay_gain_peak_reconcile_completed(conn)? {
return Ok(());
}
repair_replay_gain_peak_from_raw_json(conn)?;
mark_replay_gain_peak_reconcile_completed(conn)?;
Ok(())
}
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
run_migrations_with(
conn,
@@ -639,6 +639,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: Some(server_updated_at),
server_created_at: None,
@@ -2265,6 +2265,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -2306,6 +2307,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -70,6 +70,10 @@ pub fn subsonic_song_to_track_row(
.get("replayGain")
.and_then(|rg| rg.get("albumGain"))
.and_then(|v| v.as_f64()),
replay_gain_peak: raw_value
.get("replayGain")
.and_then(|rg| rg.get("trackPeak"))
.and_then(|v| v.as_f64()),
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -134,6 +138,7 @@ pub fn navidrome_song_to_track_row(
bpm: raw.get("bpm").and_then(|v| v.as_i64()),
replay_gain_track_db: raw.get("rgTrackGain").and_then(|v| v.as_f64()),
replay_gain_album_db: raw.get("rgAlbumGain").and_then(|v| v.as_f64()),
replay_gain_peak: raw.get("rgTrackPeak").and_then(|v| v.as_f64()),
content_hash: None,
server_updated_at,
server_created_at: raw
@@ -258,7 +263,7 @@ mod tests {
"track": 3,
"year": 2024,
"musicBrainzId": "mb-1",
"replayGain": { "trackGain": -1.2, "albumGain": -0.8 }
"replayGain": { "trackGain": -1.2, "albumGain": -0.8, "trackPeak": 0.91 }
});
let song: Song = serde_json::from_value(raw.clone()).unwrap();
let row = subsonic_song_to_track_row("s1", &song, &raw, 1_000, Some("lib-fb"));
@@ -268,6 +273,7 @@ mod tests {
assert_eq!(row.mbid_recording.as_deref(), Some("mb-1"));
assert_eq!(row.replay_gain_track_db, Some(-1.2));
assert_eq!(row.replay_gain_album_db, Some(-0.8));
assert_eq!(row.replay_gain_peak, Some(0.91));
// Fallback library_id kicks in when the song didn't ship one.
assert_eq!(row.library_id.as_deref(), Some("lib-fb"));
assert!(row.raw_json.contains("replayGain"));
@@ -271,6 +271,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
+1
View File
@@ -61,6 +61,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import '@/features/playback/store/previewPlayerVolumeSync';
import '@/features/playback/store/queueResolverBridge';
import '@/features/playback/store/replayGainMetadataSync';
import { useThemeStore } from '../store/themeStore';
import { useFontStore } from '../store/fontStore';
import { useEqStore } from '../store/eqStore';
+1
View File
@@ -178,6 +178,7 @@ const CONTRIBUTOR_ENTRIES = [
'AutoDJ overlap cap — Auto (12 s content cap) or Limit (230 s slider, default 15 s) in track-transition settings; Orbit sync + engine override up to 30 s (PR #1173)',
'Connection recovery — shared connection status across hook instances so manual Retry clears offline sidebar gating with the header indicator (PR #1190)',
'Timeline play history — session buffer + play_session bootstrap across queue replace; pin current to top; history replay inserts in-place (PR #1204)',
'Playback — ReplayGain index prefetch, gapless playbar sync, library replayGainPeak column, live RG refresh after sync (PR #1231)',
],
},
{
@@ -16,9 +16,10 @@
* `artistInfo` (bio / similar) has no index source and stays network-only it
* is intentionally absent here.
*/
import { libraryGetTrack, libraryGetTracksByAlbum } from '@/lib/api/library';
import { libraryGetTracksByAlbum } from '@/lib/api/library';
import { getArtistForServer, getTopSongsForServer } from '@/lib/api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '@/lib/api/subsonicLibrary';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst';
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGuard';
import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex } from '@/features/offline';
@@ -96,13 +97,5 @@ export async function resolveNpSongMeta(
serverId: string,
songId: string,
): Promise<SubsonicSong | null> {
if (await libraryIsReady(serverId)) {
try {
const dto = await libraryGetTrack(serverId, songId);
if (dto) return trackToSong(dto);
} catch { /* index error → network fallback */ }
}
// Network arm keeps its own byte-style guard (`shouldAttemptSubsonicForServer`
// with the trackId + psysonic-local:// skip) — unchanged from #1042.
return getSongForServer(serverId, songId);
return resolveSongMetaIndexFirst(serverId, songId);
}
@@ -105,6 +105,7 @@ export function libraryDtoToTrack(dto: LibraryTrackDto): Track {
genre: song.genre,
replayGainTrackDb: dto.replayGainTrackDb ?? undefined,
replayGainAlbumDb: dto.replayGainAlbumDb ?? undefined,
replayGainPeak: dto.replayGainPeak ?? undefined,
size: song.size,
serverId: dto.serverId,
};
+77 -187
View File
@@ -1,8 +1,6 @@
import { scrobbleSong } from '@/lib/api/subsonicScrobble';
import type { Track } from '@/lib/media/trackTypes';
import {
playbackReportPlaying,
playbackReportStart,
playbackReportStopped,
} from '@/features/playback/store/playbackReportSession';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
@@ -13,7 +11,6 @@ import { notifyLibraryPlaybackHint } from '@/features/playback/store/libraryPlay
import {
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOnTrackSwitched,
playListenSessionOpen,
} from '@/features/playback/store/playListenSession';
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
@@ -27,8 +24,12 @@ import {
playbackProfileIdForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
import { requestGaplessChainPreload } from '@/features/playback/store/gaplessChainPreload';
import {
applyGaplessQueueAdvance,
maybeReconcileGaplessFromProgress,
} from '@/features/playback/store/gaplessQueueAdvance';
import { noteEngineProgressForGapless } from '@/features/playback/store/gaplessProgressTracking';
import { showToast } from '@/lib/dom/toast';
import { useAuthStore } from '@/store/authStore';
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
@@ -39,16 +40,8 @@ import {
getLastGaplessSwitchTime,
markGaplessSwitch,
setBytePreloadingId,
setGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
emitPlaybackProgress,
getPlaybackProgressSnapshot,
@@ -64,15 +57,11 @@ import {
markStoreProgressCommit,
resetProgressEmitThrottles,
} from '@/features/playback/store/playbackThrottles';
import {
playbackSourceHintForResolvedUrl,
} from '@/features/playback/store/playbackUrlRouting';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
import {
flushQueueSyncToServer,
getLastQueueHeartbeatAt,
syncQueueToServer,
flushQueueSyncToServer,
} from '@/features/playback/store/queueSync';
import { clearQueueNaturallyEnded } from '@/features/playback/store/queuePlaybackIdle';
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
@@ -87,7 +76,6 @@ import {
getSeekTarget,
getSeekTargetSetAt,
} from '@/features/playback/store/seekTargetState';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { analyzeBoundary, computeWaveformSilence } from '@/lib/waveform/waveformSilence';
import { autodjMaxOverlapCapSec } from '@/lib/audio/autodjOverlapCap';
import {
@@ -167,12 +155,16 @@ export function handleAudioProgress(
clearSeekTarget();
} else {
clearSeekTarget();
noteEngineProgressForGapless(current_time);
}
}
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
if (!buffering) {
maybeReconcileGaplessFromProgress(current_time, duration);
}
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
usePlayerStore.setState({ isPlaybackBuffering: buffering });
}
@@ -201,6 +193,9 @@ export function handleAudioProgress(
}
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
if (!buffering) {
noteEngineProgressForGapless(current_time);
}
const progress = displayTime / dur;
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
if (!progressUiDisabled) {
@@ -374,90 +369,69 @@ export function handleAudioProgress(
const nextTrack = repeatMode === 'one'
? track
: (nextRef ? resolveQueueTrack(nextRef) : null);
if (!nextTrack || nextTrack.id === track.id) return;
// Gapless backup: keep next-track bytes ready even if chain/decode misses
// the boundary. Start earlier for larger files / slower conservative link.
const estBytes = (() => {
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
return nextTrack.size;
if (nextTrack && nextTrack.id !== track.id) {
// Gapless backup: keep next-track bytes ready even if chain/decode misses
// the boundary. Start earlier for larger files / slower conservative link.
const estBytes = (() => {
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
return nextTrack.size;
}
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
? nextTrack.bitRate
: 320;
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
})();
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
const estDownloadSecs = estBytes / conservativeBytesPerSec;
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
const shouldBytePreloadForGaplessBackup =
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
const analysisServerId = nextRef
? playbackCacheKeyForRef(nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
if (
shouldBytePreloadForGaplessBackup
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
// waveformBins and would replace the current track's seekbar while still playing it.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
if (import.meta.env.DEV) {
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreloadForGaplessBackup,
remaining,
gaplessEnabled,
});
}
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
? nextTrack.bitRate
: 320;
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
})();
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
const estDownloadSecs = estBytes / conservativeBytesPerSec;
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
const shouldBytePreloadForGaplessBackup =
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
const analysisServerId = nextRef
? playbackCacheKeyForRef(nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
if (
shouldBytePreloadForGaplessBackup
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
// waveformBins and would replace the current track's seekbar while still playing it.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
if (import.meta.env.DEV) {
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreloadForGaplessBackup,
remaining,
gaplessEnabled,
// Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
requestGaplessChainPreload({
currentTrack: track,
nextTrack,
nextRef,
nextIdx,
queueItems,
repeatMode,
volume: store.volume,
});
}
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
// Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
setGaplessPreloadingId(nextTrack.id);
// Ensure loudness gain is already cached for the chained request payload.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
const authState = useAuthStore.getState();
// Auto-mode neighbours for the *next* track: current track on its left,
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
// — only its replaygain tags matter, which a placeholder lacks → fallback).
const nextNeighbourRef = nextIdx + 1 < queueItems.length
? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
const replayGainDb = resolveReplayGainDb(
nextTrack, track, nextNeighbour,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive()
? (nextTrack.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: store.volume,
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
...audioPlayHiResBlendArgs(authState),
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
}
}
@@ -532,104 +506,20 @@ export function handleAudioEnded(): void {
* next source sample-accurately. We just need to update the UI state without
* touching the audio stream (no playTrack() call!).
*/
export function handleAudioTrackSwitched(_duration: number): void {
export function handleAudioTrackSwitched(duration: number): void {
markGaplessSwitch();
clearPreloadingIds(); // allow preloading for the track after this one
clearPreloadingIds();
setIsAudioPaused(false);
const store = usePlayerStore.getState();
if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
}
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
if (currentTrack && !currentRadio) {
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
}
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
let newIndex = queueIndex;
if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack;
// queueIndex stays the same
} else if (nextIdx < queueItems.length) {
// The Rust engine already chained this source sample-accurately, so it must
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
// returns the full Track from cache (placeholder only on an unexpected miss).
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
newIndex = nextIdx;
} else if (repeatMode === 'all' && queueItems.length > 0) {
nextTrack = resolveQueueTrack(queueItems[0]);
newIndex = 0;
}
if (!nextTrack) return;
void playListenSessionOnTrackSwitched(nextTrack);
const switchRef = queueItems[newIndex];
const switchServerId = playbackCacheKeyForRef(switchRef);
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
// Neighbour window for normalization (replaygain album-mode reads prev/next).
// current track on the left, the track after `nextTrack` on the right.
const switchPrev = store.currentTrack;
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
const switchNeighbourWindow: Track[] = [
switchPrev ?? nextTrack,
nextTrack,
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
];
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
isPlaybackBuffering: switchPlaybackSource === 'stream',
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
networkLoved: false,
currentPlaybackSource: switchPlaybackSource,
applyGaplessQueueAdvance({
engineDurationHint: duration,
source: 'track-switched',
});
emitNormalizationDebug('track-switched', {
trackId: nextTrack.id,
queueIndex: newIndex,
engineRequested: useAuthStore.getState().normalizationEngine,
});
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
// on the master toggle, per-account enable and the nowPlaying capability
// internally). playbackReportStart opens the FSM on extension-capable servers
// and falls back to the legacy presence call otherwise (gating is internal).
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: nextTrack.title,
artist: nextTrack.artist,
album: nextTrack.album,
duration: nextTrack.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
.then(loved => {
usePlayerStore.getState().setNetworkLoved(loved);
});
}
syncQueueToServer(queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}
export function handleAudioError(message: string): void {
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import {
_resetGaplessPreloadStateForTest,
getGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import {
_resetGaplessChainPrepareInflightForTest,
requestGaplessChainPreload,
} from '@/features/playback/store/gaplessChainPreload';
const prepareMock = vi.hoisted(() =>
vi.fn(async (track: Track) => ({
...track,
title: 'Next indexed',
duration: 180,
replayGainTrackDb: -5.5,
})),
);
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
prepareTrackForEngineBind: prepareMock,
}));
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const track = (id: string, extra: Partial<Track> = {}): Track => ({
id,
title: extra.title ?? `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
duration: extra.duration ?? 200,
...extra,
});
describe('requestGaplessChainPreload', () => {
beforeEach(() => {
_resetGaplessPreloadStateForTest();
_resetGaplessChainPrepareInflightForTest();
prepareMock.mockClear();
vi.restoreAllMocks();
onInvoke('audio_chain_preload', () => undefined);
useAuthStore.setState({
activeServerId: 's1',
servers: [{
id: 's1',
name: 'Test',
url: 'https://music.example',
username: 'u',
password: 'p',
}],
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
gaplessEnabled: true,
});
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 0,
repeatMode: 'off',
isPlaying: true,
currentRadio: null,
volume: 0.75,
});
});
it('prefetches metadata then chains with ReplayGain from prepare', async () => {
requestGaplessChainPreload({
currentTrack: track('t1'),
nextTrack: track('t2', { title: '…', duration: 0 }),
nextRef: ref('t2'),
nextIdx: 1,
queueItems: [ref('t1'), ref('t2')],
repeatMode: 'off',
volume: 0.75,
});
await vi.waitFor(() => {
expect(getGaplessPreloadingId()).toBe('t2');
});
expect(prepareMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 't2' }),
'music.example',
);
const chainCalls = invokeMock.mock.calls.filter(c => c[0] === 'audio_chain_preload');
expect(chainCalls.length).toBe(1);
expect(chainCalls[0]?.[1]).toMatchObject({
replayGainDb: -5.5,
durationHint: 180,
});
});
it('no-ops when playback context changed before prepare finished', async () => {
requestGaplessChainPreload({
currentTrack: track('t1'),
nextTrack: track('t2', { title: '…', duration: 0 }),
nextRef: ref('t2'),
nextIdx: 1,
queueItems: [ref('t1'), ref('t2')],
repeatMode: 'off',
volume: 0.75,
});
usePlayerStore.setState({ currentTrack: track('t99', { id: 't99' }) });
await vi.waitFor(() => {
expect(prepareMock).toHaveBeenCalled();
});
expect(getGaplessPreloadingId()).toBeNull();
expect(invokeMock.mock.calls.filter(c => c[0] === 'audio_chain_preload').length).toBe(0);
});
});
@@ -0,0 +1,143 @@
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import { invoke } from '@tauri-apps/api/core';
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
playbackCacheKeyForRef,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
getGaplessPreloadingId,
setGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
export type GaplessChainPreloadContext = {
currentTrack: Track;
nextTrack: Track;
nextRef: QueueItemRef | null;
nextIdx: number;
queueItems: QueueItemRef[];
repeatMode: 'off' | 'one' | 'all';
volume: number;
};
const gaplessChainPrepareInflight = new Map<string, Promise<void>>();
function gaplessNextNeighbour(
ctx: GaplessChainPreloadContext,
): Track | null {
const { nextIdx, queueItems, repeatMode } = ctx;
const nextNeighbourRef = nextIdx + 1 < queueItems.length
? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
return nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
}
function invokeGaplessChainPreload(
prepared: Track,
ctx: GaplessChainPreloadContext,
): void {
const authState = useAuthStore.getState();
const serverId = ctx.nextRef ? playbackCacheKeyForRef(ctx.nextRef) : getPlaybackCacheServerKey();
const analysisServerId = ctx.nextRef
? playbackCacheKeyForRef(ctx.nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(prepared.id, serverId);
const nextNeighbour = gaplessNextNeighbour(ctx);
const replayGainDb = resolveReplayGainDb(
prepared, ctx.currentTrack, nextNeighbour,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive()
? (prepared.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: ctx.volume,
durationHint: prepared.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(prepared.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
...audioPlayHiResBlendArgs(authState),
analysisTrackId: prepared.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
function liveNextRefForContext(
queueItems: QueueItemRef[],
queueIndex: number,
repeatMode: 'off' | 'one' | 'all',
): QueueItemRef | null {
if (repeatMode === 'one') return null;
const nextIdx = queueIndex + 1;
if (nextIdx < queueItems.length) return queueItems[nextIdx] ?? null;
if (repeatMode === 'all' && queueItems.length > 0) return queueItems[0] ?? null;
return null;
}
/**
* Prefetch metadata + loudness, then hand the next track to `audio_chain_preload`.
* Coalesces concurrent prepares for the same id; retries on the next progress
* tick when validation fails (track skip, queue rewrite).
*/
export function requestGaplessChainPreload(ctx: GaplessChainPreloadContext): void {
const { nextTrack } = ctx;
if (getGaplessPreloadingId() === nextTrack.id) return;
if (gaplessChainPrepareInflight.has(nextTrack.id)) return;
const job = (async () => {
try {
const serverId = ctx.nextRef
? playbackCacheKeyForRef(ctx.nextRef)
: getPlaybackCacheServerKey();
const prepared = serverId
? await prepareTrackForEngineBind(nextTrack, serverId)
: nextTrack;
if (serverId) {
await refreshLoudnessForTrack(prepared.id, { syncPlayingEngine: false });
}
const store = usePlayerStore.getState();
if (!store.isPlaying || store.currentRadio) return;
if (store.currentTrack?.id !== ctx.currentTrack.id) return;
const liveNextRef = liveNextRefForContext(
store.queueItems,
store.queueIndex,
store.repeatMode,
);
if (liveNextRef?.trackId !== prepared.id) return;
if (getGaplessPreloadingId() === prepared.id) return;
setGaplessPreloadingId(prepared.id);
invokeGaplessChainPreload(prepared, {
...ctx,
currentTrack: store.currentTrack ?? ctx.currentTrack,
nextTrack: prepared,
nextRef: liveNextRef,
});
} finally {
gaplessChainPrepareInflight.delete(nextTrack.id);
}
})().catch(() => {});
gaplessChainPrepareInflight.set(nextTrack.id, job);
}
/** Test-only: drop pending gapless chain prepare promises. */
export function _resetGaplessChainPrepareInflightForTest(): void {
gaplessChainPrepareInflight.clear();
}
@@ -0,0 +1,24 @@
/**
* Last engine progress position for gapless rewind detection.
* Kept separate from gaplessQueueAdvance to avoid seekAction gaplessQueueAdvance
* playerStore seekAction cycles.
*/
let lastEngineProgressSec = 0;
export function _resetGaplessProgressTrackingForTest(): void {
lastEngineProgressSec = 0;
}
/** Clear stale position after a gapless or manual track switch. */
export function resetGaplessProgressTracking(): void {
lastEngineProgressSec = 0;
}
export function getLastEngineProgressSec(): number {
return lastEngineProgressSec;
}
export function noteEngineProgressForGapless(currentTime: number): void {
lastEngineProgressSec = currentTime;
}
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import {
_resetQueueResolverForTest,
seedQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import { _resetGaplessPreloadStateForTest } from '@/features/playback/store/gaplessPreloadState';
import { setSeekTarget, _resetSeekTargetStateForTest } from '@/features/playback/store/seekTargetState';
import {
_resetPlaybackProgressForTest,
getPlaybackProgressSnapshot,
} from '@/features/playback/store/playbackProgress';
import {
_resetGaplessProgressTrackingForTest,
noteEngineProgressForGapless,
} from '@/features/playback/store/gaplessProgressTracking';
import {
applyGaplessQueueAdvance,
maybeReconcileGaplessFromProgress,
} from '@/features/playback/store/gaplessQueueAdvance';
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const track = (id: string, extra: Partial<Track> = {}): Track => ({
id,
title: extra.title ?? `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
duration: extra.duration ?? 200,
...extra,
});
describe('applyGaplessQueueAdvance', () => {
beforeEach(() => {
_resetQueueResolverForTest();
_resetPlaybackProgressForTest();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({ gaplessEnabled: true });
seedQueueResolver('s1', [
track('t1'),
track('t2', { title: 'Second' }),
]);
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 0,
repeatMode: 'off',
isPlaying: true,
currentRadio: null,
progress: 0.8,
currentTime: 160,
});
});
it('advances currentTrack and resets the progress channel', () => {
const result = applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' });
expect(result.advanced).toBe(true);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
expect(usePlayerStore.getState().queueIndex).toBe(1);
expect(getPlaybackProgressSnapshot().currentTime).toBe(0);
expect(getPlaybackProgressSnapshot().progress).toBe(0);
});
});
describe('maybeReconcileGaplessFromProgress', () => {
beforeEach(() => {
_resetQueueResolverForTest();
_resetGaplessPreloadStateForTest();
_resetPlaybackProgressForTest();
_resetGaplessProgressTrackingForTest();
_resetSeekTargetStateForTest();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({ gaplessEnabled: true });
seedQueueResolver('s1', [track('t1'), track('t2', { title: 'Second' })]);
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 0,
repeatMode: 'off',
isPlaying: true,
currentRadio: null,
});
});
it('catches up UI when engine position regresses without track_switched', () => {
noteEngineProgressForGapless(170);
maybeReconcileGaplessFromProgress(0.4, 205);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
expect(getPlaybackProgressSnapshot().progress).toBe(0);
});
it('no-ops when position moves forward normally', () => {
noteEngineProgressForGapless(10);
maybeReconcileGaplessFromProgress(11, 200);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
});
it('no-ops during an active seek guard', () => {
noteEngineProgressForGapless(100);
setSeekTarget(20);
maybeReconcileGaplessFromProgress(0.5, 200);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
expect(usePlayerStore.getState().queueIndex).toBe(0);
});
it('no-ops on mid-track position regressions (not a gapless boundary)', () => {
noteEngineProgressForGapless(170);
maybeReconcileGaplessFromProgress(100, 200);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
expect(usePlayerStore.getState().queueIndex).toBe(0);
});
it('does not double-advance after track_switched already moved the UI', () => {
noteEngineProgressForGapless(170);
applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' });
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
maybeReconcileGaplessFromProgress(2, 210);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
});
@@ -0,0 +1,246 @@
/**
* Gapless queue advance: Rust already switched the audio source update JS
* store + side-effects without calling `playTrack` / `audio_play`.
*/
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import {
playbackReportStart,
} from '@/features/playback/store/playbackReportSession';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
clearPreloadingIds,
getLastGaplessSwitchTime,
markGaplessSwitch,
} from '@/features/playback/store/gaplessPreloadState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
emitPlaybackProgress,
} from '@/features/playback/store/playbackProgress';
import {
resetProgressEmitThrottles,
} from '@/features/playback/store/playbackThrottles';
import {
playbackSourceHintForResolvedUrl,
} from '@/features/playback/store/playbackUrlRouting';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { playListenSessionOnTrackSwitched } from '@/features/playback/store/playListenSession';
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
import {
playbackCacheKeyForRef,
playbackProfileIdForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { syncQueueToServer } from '@/features/playback/store/queueSync';
import { useAuthStore } from '@/store/authStore';
import { setIsAudioPaused } from '@/features/playback/store/engineState';
import {
getLastEngineProgressSec,
noteEngineProgressForGapless,
resetGaplessProgressTracking,
} from '@/features/playback/store/gaplessProgressTracking';
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
import { getSeekTarget } from '@/features/playback/store/seekTargetState';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
export type GaplessQueueAdvanceResult = {
advanced: boolean;
nextTrack: Track | null;
newIndex: number;
};
/** Resolve the queue successor for a gapless engine transition. */
export function resolveGaplessSuccessor(
queueItems: QueueItemRef[],
queueIndex: number,
repeatMode: 'off' | 'one' | 'all',
currentTrack: Track | null,
): { nextTrack: Track | null; newIndex: number } {
const nextIdx = queueIndex + 1;
if (repeatMode === 'one' && currentTrack) {
return { nextTrack: currentTrack, newIndex: queueIndex };
}
if (nextIdx < queueItems.length) {
return {
nextTrack: resolveQueueTrack(queueItems[nextIdx]),
newIndex: nextIdx,
};
}
if (repeatMode === 'all' && queueItems.length > 0) {
return {
nextTrack: resolveQueueTrack(queueItems[0]),
newIndex: 0,
};
}
return { nextTrack: null, newIndex: queueIndex };
}
function applyGaplessSuccessorUi(
store: ReturnType<typeof usePlayerStore.getState>,
nextTrack: Track,
newIndex: number,
source: 'track-switched' | 'progress-reconcile',
): void {
const switchRef = store.queueItems[newIndex];
const switchServerId = playbackCacheKeyForRef(switchRef);
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(
nextTrack.id,
switchServerId,
switchResolvedUrl,
);
const switchPrev = store.currentTrack;
const switchNextNextRef = newIndex + 1 < store.queueItems.length
? store.queueItems[newIndex + 1]
: null;
const switchNeighbourWindow: Track[] = [
switchPrev ?? nextTrack,
nextTrack,
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
];
resetProgressEmitThrottles();
resetGaplessProgressTracking();
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: source,
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
isPlaybackBuffering: switchPlaybackSource === 'stream',
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
networkLoved: false,
currentPlaybackSource: switchPlaybackSource,
});
emitPlaybackProgress({
currentTime: 0,
progress: 0,
buffered: 0,
buffering: switchPlaybackSource === 'stream',
});
emitNormalizationDebug(source, {
trackId: nextTrack.id,
queueIndex: newIndex,
engineRequested: useAuthStore.getState().normalizationEngine,
});
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: nextTrack.title,
artist: nextTrack.artist,
album: nextTrack.album,
duration: nextTrack.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
.then(loved => {
usePlayerStore.getState().setNetworkLoved(loved);
});
}
syncQueueToServer(store.queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}
/**
* Advance the queue UI to match a gapless engine transition. Returns whether
* the store was updated (false when there is no successor or already advanced).
*/
export function applyGaplessQueueAdvance(opts?: {
/** When set, patch duration on the successor if the resolver snapshot is thin. */
engineDurationHint?: number;
source?: 'track-switched' | 'progress-reconcile';
}): GaplessQueueAdvanceResult {
const source = opts?.source ?? 'track-switched';
const store = usePlayerStore.getState();
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
if (currentRadio) {
return { advanced: false, nextTrack: null, newIndex: queueIndex };
}
const { nextTrack: resolved, newIndex } = resolveGaplessSuccessor(
queueItems,
queueIndex,
repeatMode,
currentTrack,
);
if (!resolved) {
return { advanced: false, nextTrack: null, newIndex: queueIndex };
}
const hint = opts?.engineDurationHint;
const nextTrack = hint != null && hint > 0 && resolved.duration <= 0
? { ...resolved, duration: hint }
: resolved;
if (currentTrack && repeatMode !== 'one' && currentTrack.id === nextTrack.id && queueIndex === newIndex) {
return { advanced: false, nextTrack, newIndex };
}
if (currentTrack && !currentRadio) {
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
}
void playListenSessionOnTrackSwitched(nextTrack);
applyGaplessSuccessorUi(store, nextTrack, newIndex, source);
return { advanced: true, nextTrack, newIndex };
}
/**
* When gapless is on and the engine position jumps backward mid-playback, the
* Rust side likely switched sources without delivering `audio:track_switched`.
*/
export function maybeReconcileGaplessFromProgress(
currentTime: number,
engineDuration: number,
): void {
if (!useAuthStore.getState().gaplessEnabled) return;
if (isSeekDebouncePending() || getSeekTarget() !== null) return;
const store = usePlayerStore.getState();
if (!store.isPlaying || store.currentRadio || !store.currentTrack) return;
if (Date.now() - getLastGaplessSwitchTime() < 400) return;
const prevSec = getLastEngineProgressSec();
// Gapless transitions restart near 0 — mid-track regressions are usually
// buffering/seek glitches, not a decoder boundary without track_switched.
const nearStart = currentTime < 8;
const regressed = nearStart && currentTime + 1.5 < prevSec && prevSec > 8;
if (!regressed) {
noteEngineProgressForGapless(currentTime);
return;
}
const { nextTrack, newIndex } = resolveGaplessSuccessor(
store.queueItems,
store.queueIndex,
store.repeatMode,
store.currentTrack,
);
if (!nextTrack || nextTrack.id === store.currentTrack.id) return;
const slotRef = store.queueItems[store.queueIndex];
if (!slotRef || slotRef.trackId !== store.currentTrack.id) return;
if (store.repeatMode !== 'one' && newIndex <= store.queueIndex) return;
applyGaplessQueueAdvance({
engineDurationHint: engineDuration,
source: 'progress-reconcile',
});
markGaplessSwitch();
clearPreloadingIds();
setIsAudioPaused(false);
noteEngineProgressForGapless(currentTime);
}
@@ -54,6 +54,10 @@ vi.mock('@/features/playback/store/queueTrackView', () => ({
}));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
getPlaybackCacheServerKey: () => 'srv-key',
playbackCacheKeyForTrack: () => 'srv-key',
}));
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
prepareTrackForEngineBind: vi.fn(async (track: Track) => track),
}));
import {
@@ -1,7 +1,8 @@
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
import { scheduleHotCachePrefetchForTrack } from '@/hotCachePrefetch';
import { getPlaybackCacheServerKey } from '@/features/playback/utils/playback/playbackServer';
import { getPlaybackCacheServerKey, playbackCacheKeyForTrack } from '@/features/playback/utils/playback/playbackServer';
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
import { useAuthStore } from '@/store/authStore';
import { bumpPlayGeneration, getPlayGeneration } from '@/features/playback/store/engineState';
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
@@ -62,10 +63,20 @@ export function preparePausedRestoreOnStartup(
if (getPlayGeneration() !== generation) return;
if (usePlayerStore.getState().isPlaying) return;
const queue = getQueueTracksView(queueItems, [track]);
const metadataSid =
playbackCacheKeyForTrack(track)
|| getPlaybackCacheServerKey()
|| '';
const trackForEngine = metadataSid
? await prepareTrackForEngineBind(track, metadataSid)
: track;
if (getPlayGeneration() !== generation) return;
if (usePlayerStore.getState().isPlaying) return;
const queue = getQueueTracksView(queueItems, [trackForEngine]);
engineLoadTrackAtPosition({
generation,
track,
track: trackForEngine,
queue,
queueIndex,
atSeconds,
+72 -52
View File
@@ -33,6 +33,7 @@ import {
} from '@/store/localPlaybackResolve';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
import { useAuthStore } from '@/store/authStore';
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from '@/features/playback/store/crossfadeTrimCache';
@@ -41,10 +42,10 @@ import {
getPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
import {
clearPreloadingIds,
import { clearPreloadingIds,
getLastGaplessSwitchTime,
} from '@/features/playback/store/gaplessPreloadState';
import { resetGaplessProgressTracking } from '@/features/playback/store/gaplessProgressTracking';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import {
isReplayGainActive,
@@ -325,25 +326,26 @@ export function runPlayTrack(
&& getPlaybackCacheServerKey(),
);
const runPlayTrackBody = () => {
const runPlayTrackBody = (trackForPlay: Track) => {
const playNormWindow = normWindow.map((t, i) => (i === normIdx ? trackForPlay : t));
const authStateNow = useAuthStore.getState();
const playbackSid = playbackProfileIdForTrack(scopedTrack, playingRef);
const playbackCacheSid = playbackCacheKeyForTrack(scopedTrack, playingRef);
const playbackSid = playbackProfileIdForTrack(trackForPlay, playingRef);
const playbackCacheSid = playbackCacheKeyForTrack(trackForPlay, playingRef);
const url = libraryLocalUrl
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'library')
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'favorite-auto')
?? resolvePlaybackUrl(scopedTrack.id, playbackCacheSid);
recordEnginePlayUrl(scopedTrack.id, url);
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'library')
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'favorite-auto')
?? resolvePlaybackUrl(trackForPlay.id, playbackCacheSid);
recordEnginePlayUrl(trackForPlay.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === scopedTrack.id;
const keepPreloadHint = preloadedTrackId === trackForPlay.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
scopedTrack.id,
trackForPlay.id,
playbackCacheSid,
url,
);
if (import.meta.env.DEV) {
console.info('[psysonic][playTrack-source]', {
trackId: scopedTrack.id,
trackId: trackForPlay.id,
resolvedUrl: url,
preloadedTrackId,
keepPreloadHint,
@@ -363,24 +365,25 @@ export function runPlayTrack(
if (sid) seedQueueResolver(sid, [t]);
}
} else if (queueSid) {
seedQueueResolver(queueSid, [scopedTrack]);
seedQueueResolver(queueSid, [trackForPlay]);
}
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(scopedTrack.id);
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(trackForPlay.id);
const wantInterruptBlend = Boolean(
shouldAutodjInterruptBlend(wasPlayingBeforeSkip, hasJsAutoHandoff)
&& prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id),
&& !sameQueueTrackId(prevTrack.id, trackForPlay.id),
);
const bReadyNow = isCrossfadeNextReady(scopedTrack.id, playbackSid, playbackCacheSid);
const bReadyNow = isCrossfadeNextReady(trackForPlay.id, playbackSid, playbackCacheSid);
/** Cold interrupt: engine still on A — don't swap player-bar metadata until handoff. */
const deferInterruptUi = shouldDeferInterruptHandoffUi(wantInterruptBlend, bReadyNow);
const applyInterruptHandoffUi = () => {
resetGaplessProgressTracking();
set({
currentTrack: scopedTrack,
currentTrack: trackForPlay,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
@@ -389,10 +392,10 @@ export function runPlayTrack(
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
enginePreloadedTrackId: keepPreloadHint ? trackForPlay.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id, { syncPlayingEngine: false });
void refreshWaveformForTrack(trackForPlay.id);
void refreshLoudnessForTrack(trackForPlay.id, { syncPlayingEngine: false });
};
if (deferInterruptUi) {
@@ -402,11 +405,12 @@ export function runPlayTrack(
queueIndex: idx >= 0 ? idx : 0,
});
} else {
resetGaplessProgressTracking();
set({
currentTrack: scopedTrack,
currentTrack: trackForPlay,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
@@ -423,11 +427,11 @@ export function runPlayTrack(
? false
: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
enginePreloadedTrackId: keepPreloadHint ? trackForPlay.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshWaveformForTrack(trackForPlay.id);
void refreshLoudnessForTrack(
scopedTrack.id,
trackForPlay.id,
wantInterruptBlend ? { syncPlayingEngine: false } : undefined,
);
}
@@ -435,7 +439,7 @@ export function runPlayTrack(
setDeferHotCachePrefetch(true);
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& !sameQueueTrackId(prevTrack.id, trackForPlay.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
@@ -448,10 +452,10 @@ export function runPlayTrack(
}
}
const replayGainDb = resolveReplayGainDb(
scopedTrack, prevTrack, nextNeighbour,
trackForPlay, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
const replayGainPeak = isReplayGainActive() ? (trackForPlay.replayGainPeak ?? null) : null;
const invokeAudioPlay = (manualBlend: CrossfadeTransitionPlan | null) => {
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
@@ -467,8 +471,8 @@ export function runPlayTrack(
&& initialTime <= 0.05;
const useManualBlend = manualBlend !== null;
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(trackForPlay.id) : null;
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(trackForPlay.id) : null;
const crossfadeStartSecs = useManualBlend
? manualBlend.bStartSec
: (crossfadePlan?.bStartSec ?? 0);
@@ -490,17 +494,17 @@ export function runPlayTrack(
invoke('audio_play', {
url,
volume: state.volume,
durationHint: scopedTrack.duration,
durationHint: trackForPlay.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
loudnessGainDb: loudnessGainDbForEngineBind(trackForPlay.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
...audioPlayHiResBlendArgs(authStateNow),
analysisTrackId: scopedTrack.id,
analysisTrackId: trackForPlay.id,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: scopedTrack.suffix ?? null,
streamFormatSuffix: trackForPlay.suffix ?? null,
startPaused: false,
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
crossfadeSecsOverride,
@@ -515,7 +519,7 @@ export function runPlayTrack(
if (keepPreloadHint) {
set({ enginePreloadedTrackId: null });
}
const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null;
const durSeek = trackForPlay.duration && trackForPlay.duration > 0 ? trackForPlay.duration : null;
const seekTo = initialTime;
const canSeekAfterPlay =
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
@@ -524,12 +528,12 @@ export function runPlayTrack(
.then(() => {
if (getPlayGeneration() !== gen) return;
setSeekTarget(seekTo);
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
if (getSeekFallbackVisualTarget()?.trackId === trackForPlay.id) {
setSeekFallbackVisualTarget(null);
}
})
.catch(() => {
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
if (getSeekFallbackVisualTarget()?.trackId === trackForPlay.id) {
setSeekFallbackVisualTarget(null);
}
});
@@ -552,28 +556,28 @@ export function runPlayTrack(
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
// internally). playbackReportStart opens the live FSM on extension-capable
// servers and falls back to the legacy presence call otherwise.
playbackReportStart(scopedTrack.id, playbackSid);
playbackReportStart(trackForPlay.id, playbackSid);
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: scopedTrack.title,
artist: scopedTrack.artist,
album: scopedTrack.album,
duration: scopedTrack.duration,
title: trackForPlay.title,
artist: trackForPlay.artist,
album: trackForPlay.album,
duration: trackForPlay.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist })
.isTrackLoved({ title: trackForPlay.title, artist: trackForPlay.artist })
.then(loved => {
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
const cacheKey = `${trackForPlay.title}::${trackForPlay.artist}`;
set(s => ({
networkLoved: loved,
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved },
}));
});
}
pushQueueOnPlaybackStart(get().queueItems, scopedTrack, initialTime);
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
pushQueueOnPlaybackStart(get().queueItems, trackForPlay, initialTime);
touchHotCacheOnPlayback(trackForPlay.id, playbackCacheSid);
};
const startAudio = (manualBlend: CrossfadeTransitionPlan | null) => {
@@ -593,12 +597,12 @@ export function runPlayTrack(
bReadyNow
? Promise.resolve({ ready: true })
: runInterruptBlendPrep(
scopedTrack,
trackForPlay,
playbackSid,
playbackCacheSid,
() => getPlayGeneration() !== gen,
),
fetchWaveformBins(scopedTrack.id, playbackCacheSid || null),
fetchWaveformBins(trackForPlay.id, playbackCacheSid || null),
]);
if (getPlayGeneration() !== gen) {
clearInterruptHandoff();
@@ -610,7 +614,7 @@ export function runPlayTrack(
aDur,
skipFromTimeSec,
bBins,
scopedTrack.duration || 0,
trackForPlay.duration || 0,
)
: null;
startAudio(blend
@@ -634,6 +638,22 @@ export function runPlayTrack(
startAudio(null);
};
const launchPlayTrackBody = () => {
void (async () => {
let trackForPlay = scopedTrack;
const metadataSid =
playbackCacheKeyForTrack(scopedTrack, playingRef)
|| get().queueServerId
|| getPlaybackIndexKey()
|| '';
if (metadataSid) {
trackForPlay = await enrichTrackPlaybackMetadata(scopedTrack, metadataSid);
}
if (getPlayGeneration() !== gen) return;
runPlayTrackBody(trackForPlay);
})();
};
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
@@ -643,7 +663,7 @@ export function runPlayTrack(
)
.then(() => {
if (getPlayGeneration() !== gen) return;
runPlayTrackBody();
launchPlayTrackBody();
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
@@ -652,6 +672,6 @@ export function runPlayTrack(
set({ isPlaying: false });
});
} else {
runPlayTrackBody();
launchPlayTrackBody();
}
}
@@ -61,6 +61,9 @@ vi.mock('@/features/playback/store/playerStore', () => ({
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
prepareTrackForEngineBind: vi.fn(async (track: Track) => track),
}));
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
@@ -90,12 +93,14 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: true,
});
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
url: 'https://mock/t1',
durationHint: 100,
manual: false,
analysisTrackId: 't1',
}));
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
url: 'https://mock/t1',
durationHint: 100,
manual: false,
analysisTrackId: 't1',
}));
});
expect(hoisted.recordEnginePlayUrlMock).toHaveBeenCalledWith('t1', 'https://mock/t1');
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(true);
expect(hoisted.touchHotCacheOnPlaybackMock).toHaveBeenCalledWith('t1', 'srv');
@@ -110,9 +115,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 30,
wantPlaying: true,
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
});
});
it('skips audio_seek when atSeconds is near zero', async () => {
@@ -124,7 +129,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: true,
});
await Promise.resolve();
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.anything());
});
await Promise.resolve();
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
expect(seekCall).toBeUndefined();
@@ -139,17 +146,17 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: false,
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
startPaused: true,
}));
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
startPaused: true,
}));
});
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(pauseCall).toBeUndefined();
expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true);
});
it('bails out of the .then chain when generation has moved on', async () => {
it('bails out before audio_play when generation has moved on during prepare', async () => {
hoisted.getPlayGeneration.mockReturnValue(2); // user navigated, new gen
queueUndoRestoreAudioEngine({
generation: 1,
@@ -159,9 +166,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 30,
wantPlaying: false,
});
await Promise.resolve();
await Promise.resolve();
// Should NOT have called audio_seek or audio_pause — gen moved on.
await vi.waitFor(() => {
expect(hoisted.invokeMock.mock.calls.length).toBe(0);
});
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(seekCall).toBeUndefined();
@@ -178,7 +185,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: true,
});
await Promise.resolve();
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.anything());
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(false);
@@ -1,5 +1,11 @@
import type { Track } from '@/lib/media/trackTypes';
import {
getPlaybackIndexKey,
playbackCacheKeyForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { getPlayGeneration } from '@/features/playback/store/engineState';
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
/**
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand
@@ -20,5 +26,15 @@ export function queueUndoRestoreAudioEngine(opts: {
atSeconds: number;
wantPlaying: boolean;
}): void {
engineLoadTrackAtPosition(opts);
void (async () => {
const serverId =
playbackCacheKeyForTrack(opts.track)
|| getPlaybackIndexKey()
|| '';
const track = serverId
? await prepareTrackForEngineBind(opts.track, serverId)
: opts.track;
if (getPlayGeneration() !== opts.generation) return;
engineLoadTrackAtPosition({ ...opts, track });
})();
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import {
_resetQueueResolverForTest,
seedQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import * as resolveSongMetaIndexFirst from '@/lib/library/resolveSongMetaIndexFirst';
import {
_resetIndexRefreshInflightForTest,
maybeRefreshCurrentTrackMetadataFromIndex,
maybeSyncCurrentTrackFromResolver,
shouldSyncCurrentTrackMetadata,
shouldUpgradeReplayGainMetadata,
syncIdleAppliesToQueueRef,
} from '@/features/playback/store/replayGainMetadataSync';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const track = (id: string, extra: Partial<Track> = {}): Track => ({
id,
title: extra.title ?? `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: extra.albumId ?? 'alb-1',
duration: 200,
...extra,
});
describe('shouldUpgradeReplayGainMetadata', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
});
});
it('returns true when track gain appears on a placeholder snapshot', () => {
const prev = track('t1', { title: '…', replayGainTrackDb: undefined });
const next = track('t1', { replayGainTrackDb: -8.5 });
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
it('returns false when both snapshots lack ReplayGain tags', () => {
const prev = track('t1', { replayGainTrackDb: undefined });
const next = track('t1', { title: 'Resolved title' });
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(false);
});
it('returns true when peak metadata arrives', () => {
const base = track('t1', { replayGainTrackDb: -6 });
const withPeak = { ...base, replayGainPeak: 0.98 };
expect(shouldUpgradeReplayGainMetadata(base, withPeak, [ref('t1')], 0)).toBe(true);
});
it('returns true when track gain was recalculated on the server', () => {
const prev = track('t1', { replayGainTrackDb: -6.0 });
const next = track('t1', { replayGainTrackDb: -8.5 });
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
});
describe('shouldSyncCurrentTrackMetadata', () => {
it('returns true when placeholder title resolves', () => {
const prev = track('t1', { title: '…' });
const next = track('t1', { title: 'Resolved title' });
expect(shouldSyncCurrentTrackMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
it('returns true when duration arrives on a thin snapshot', () => {
const prev = track('t1', { duration: 0 });
const next = track('t1', { duration: 240 });
expect(shouldSyncCurrentTrackMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
});
describe('maybeSyncCurrentTrackFromResolver', () => {
beforeEach(() => {
_resetQueueResolverForTest();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
});
usePlayerStore.setState({
currentTrack: track('t1', { title: '…' }),
queueItems: [ref('t1')],
queueIndex: 0,
isPlaying: true,
currentRadio: null,
volume: 0.8,
});
vi.clearAllMocks();
});
it('upgrades currentTrack and pushes replay gain when the resolver cache fills', () => {
seedQueueResolver('s1', [track('t1', { replayGainTrackDb: -7.2, replayGainPeak: 0.95 })]);
maybeSyncCurrentTrackFromResolver();
const s = usePlayerStore.getState();
expect(s.currentTrack?.replayGainTrackDb).toBe(-7.2);
expect(s.currentTrack?.replayGainPeak).toBe(0.95);
});
it('no-ops engine gain when ReplayGain mode is off but still syncs thin title', () => {
useAuthStore.setState({ normalizationEngine: 'off', replayGainEnabled: false });
seedQueueResolver('s1', [track('t1', { title: 'Full title' })]);
maybeSyncCurrentTrackFromResolver();
expect(usePlayerStore.getState().currentTrack?.title).toBe('Full title');
expect(usePlayerStore.getState().currentTrack?.replayGainTrackDb).toBeUndefined();
});
it('no-ops when transport is idle', () => {
usePlayerStore.setState({ isPlaying: false });
seedQueueResolver('s1', [track('t1', { title: 'Full title', duration: 220 })]);
maybeSyncCurrentTrackFromResolver();
expect(usePlayerStore.getState().currentTrack?.title).toBe('…');
});
it('upgrades placeholder title without ReplayGain when normalization is off', () => {
useAuthStore.setState({ normalizationEngine: 'off', replayGainEnabled: false });
seedQueueResolver('s1', [track('t1', { title: 'Full title', duration: 220 })]);
maybeSyncCurrentTrackFromResolver();
const s = usePlayerStore.getState();
expect(s.currentTrack?.title).toBe('Full title');
expect(s.currentTrack?.duration).toBe(220);
});
it('syncs ReplayGain tags from the resolver onto the live track', () => {
seedQueueResolver('s1', [track('t1', { replayGainTrackDb: -7.2, replayGainPeak: 0.95 })]);
maybeSyncCurrentTrackFromResolver();
expect(usePlayerStore.getState().currentTrack?.replayGainTrackDb).toBe(-7.2);
});
});
describe('maybeRefreshCurrentTrackMetadataFromIndex', () => {
beforeEach(() => {
_resetQueueResolverForTest();
_resetIndexRefreshInflightForTest();
vi.restoreAllMocks();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
});
useLibraryIndexStore.setState({ masterEnabled: true });
usePlayerStore.setState({
currentTrack: track('t1', { replayGainTrackDb: -6.0, replayGainPeak: 0.8 }),
queueItems: [ref('t1')],
queueIndex: 0,
isPlaying: true,
currentRadio: null,
volume: 0.8,
});
});
it('upgrades recalculated ReplayGain from the library index', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Track t1',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.5,
replayGainPeak: 0.91,
syncedAt: 0,
rawJson: {},
}));
await maybeRefreshCurrentTrackMetadataFromIndex();
const s = usePlayerStore.getState();
expect(s.currentTrack?.replayGainTrackDb).toBe(-8.5);
expect(s.currentTrack?.replayGainPeak).toBe(0.91);
});
it('no-ops when the playing slot changed during index fetch', async () => {
vi.spyOn(resolveSongMetaIndexFirst, 'resolveSongMetaIndexFirst').mockImplementation(async () => {
usePlayerStore.setState({
currentTrack: track('t2', { replayGainTrackDb: -3.0 }),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 1,
});
return {
id: 't1',
title: 'Track t1',
album: 'Album',
duration: 200,
replayGain: { trackGain: -8.5, trackPeak: 0.91 },
} as Awaited<ReturnType<typeof resolveSongMetaIndexFirst.resolveSongMetaIndexFirst>>;
});
await maybeRefreshCurrentTrackMetadataFromIndex();
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe('t2');
expect(s.currentTrack?.replayGainTrackDb).toBe(-3.0);
});
});
describe('syncIdleAppliesToQueueRef', () => {
it('matches profile id to index key on the ref', () => {
expect(syncIdleAppliesToQueueRef('profile-uuid', { serverId: 'profile-uuid', trackId: 't1' }))
.toBe(true);
});
});
@@ -0,0 +1,189 @@
/**
* HTTP-stream playback often starts from a thin-queue placeholder (or any track
* snapshot missing ReplayGain tags). `audio_play` then applies fallback gain.
* The queue resolver fetches full metadata asynchronously this side-effect
* upgrades `currentTrack` and pushes fresh gain to the engine once tags land,
* mirroring the loudness cache refresh path (`refreshLoudnessForTrack`).
*
* After library sync, {@link maybeRefreshCurrentTrackMetadataFromIndex} re-reads
* index-first metadata for the live slot so recalculated ReplayGain tags apply
* without re-starting playback.
*/
import { listen } from '@tauri-apps/api/event';
import type { LibrarySyncIdlePayload } from '@/lib/api/library/dto';
import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst';
import { mergePlaybackTrackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { isReplayGainActive } from '@/features/playback/store/loudnessGainCache';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
patchCachedTrack,
subscribeQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
import { songToTrack } from '@/lib/media/songToTrack';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
import { useAuthStore } from '@/store/authStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
function replayGainNeighbours(
queueItems: QueueItemRef[],
queueIndex: number,
): { prev: Track | null; next: Track | null } {
const prev = queueIndex > 0 && queueItems[queueIndex - 1]
? resolveQueueTrack(queueItems[queueIndex - 1])
: null;
const next = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
? resolveQueueTrack(queueItems[queueIndex + 1])
: null;
return { prev, next };
}
function resolvedReplayGainDb(
track: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): number | null {
const auth = useAuthStore.getState();
const { prev, next } = replayGainNeighbours(queueItems, queueIndex);
return resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
}
/** True when resolver metadata would change the ReplayGain bind for this slot. */
export function shouldUpgradeReplayGainMetadata(
prev: Track,
next: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): boolean {
if (prev.replayGainPeak !== next.replayGainPeak) return true;
return resolvedReplayGainDb(prev, queueItems, queueIndex)
!== resolvedReplayGainDb(next, queueItems, queueIndex);
}
/** True when resolver metadata would improve the live player-bar snapshot. */
export function shouldSyncCurrentTrackMetadata(
prev: Track,
next: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): boolean {
if (prev.title === '…' && next.title && next.title !== '…') return true;
if (prev.duration === 0 && next.duration > 0) return true;
return shouldUpgradeReplayGainMetadata(prev, next, queueItems, queueIndex);
}
function applyCurrentTrackMetadataUpgrade(
prev: Track,
merged: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): void {
if (!shouldSyncCurrentTrackMetadata(prev, merged, queueItems, queueIndex)) return;
usePlayerStore.setState({ currentTrack: merged });
patchCachedTrack(prev.id, {
title: merged.title,
duration: merged.duration,
replayGainTrackDb: merged.replayGainTrackDb,
replayGainAlbumDb: merged.replayGainAlbumDb,
replayGainPeak: merged.replayGainPeak,
});
if (
isReplayGainActive()
&& shouldUpgradeReplayGainMetadata(prev, merged, queueItems, queueIndex)
) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
}
/** True when a library sync-idle event applies to this queue ref's server. */
export function syncIdleAppliesToQueueRef(syncServerId: string, ref: QueueItemRef): boolean {
const scopeId = syncServerId.trim();
if (!scopeId) return false;
const profileId = resolveServerIdForIndexKey(scopeId) || scopeId;
const refCanonical = canonicalQueueServerKey(ref.serverId);
const refProfile = resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
return ref.serverId === scopeId
|| ref.serverId === profileId
|| refCanonical === scopeId
|| refCanonical === profileId
|| refProfile === scopeId
|| refProfile === profileId;
}
/** Push resolver-fetched metadata onto the live track; upgrade engine gain when needed. */
export function maybeSyncCurrentTrackFromResolver(): void {
const state = usePlayerStore.getState();
const { currentTrack, queueItems, queueIndex, isPlaying, currentRadio } = state;
if (!currentTrack || !isPlaying || currentRadio) return;
const ref = queueItems[queueIndex];
if (!ref || ref.trackId !== currentTrack.id) return;
const resolved = resolveQueueTrack(ref, currentTrack);
const merged = mergePlaybackTrackMetadata(currentTrack, resolved);
applyCurrentTrackMetadataUpgrade(currentTrack, merged, queueItems, queueIndex);
}
/**
* Re-read index-first metadata for the playing slot and upgrade ReplayGain when
* library tags differ from the live snapshot (post-sync recalc, new tags, peak).
*/
export async function maybeRefreshCurrentTrackMetadataFromIndex(): Promise<void> {
const state = usePlayerStore.getState();
const { currentTrack, queueItems, queueIndex, isPlaying, currentRadio } = state;
if (!currentTrack || !isPlaying || currentRadio) return;
const ref = queueItems[queueIndex];
if (!ref || ref.trackId !== currentTrack.id) return;
const serverId = playbackCacheKeyForRef(ref);
if (!serverId) return;
const trackId = currentTrack.id;
const song = await resolveSongMetaIndexFirst(serverId, trackId);
if (!song) return;
const live = usePlayerStore.getState();
if (!live.isPlaying || live.currentRadio || !live.currentTrack) return;
const liveRef = live.queueItems[live.queueIndex];
if (live.currentTrack.id !== trackId || liveRef?.trackId !== trackId) return;
const merged = mergePlaybackTrackMetadata(live.currentTrack, songToTrack(song));
applyCurrentTrackMetadataUpgrade(live.currentTrack, merged, live.queueItems, live.queueIndex);
}
let indexRefreshInflight: Promise<void> | null = null;
export function scheduleCurrentTrackMetadataRefreshFromIndex(): void {
if (indexRefreshInflight) return;
indexRefreshInflight = maybeRefreshCurrentTrackMetadataFromIndex()
.catch(() => {})
.finally(() => {
indexRefreshInflight = null;
});
}
/** Test-only: drop coalesced index refresh state. */
export function _resetIndexRefreshInflightForTest(): void {
indexRefreshInflight = null;
}
function scheduleIndexRefreshAfterSyncIdle(payload: LibrarySyncIdlePayload): void {
if (!payload.ok) return;
const state = usePlayerStore.getState();
if (!state.isPlaying || state.currentRadio || !state.currentTrack) return;
const ref = state.queueItems[state.queueIndex];
if (!ref || ref.trackId !== state.currentTrack.id) return;
if (!syncIdleAppliesToQueueRef(payload.serverId, ref)) return;
scheduleCurrentTrackMetadataRefreshFromIndex();
}
subscribeQueueResolver(() => {
maybeSyncCurrentTrackFromResolver();
});
void listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) => {
scheduleIndexRefreshAfterSyncIdle(payload);
});
+51 -84
View File
@@ -1,4 +1,5 @@
import { getSong } from '@/lib/api/subsonicLibrary';
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
import { invoke } from '@tauri-apps/api/core';
import { estimateLivePosition, orbitSnapshot } from '@/store/orbitRuntime';
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
@@ -57,10 +58,9 @@ type GetState = () => PlayerState;
* `audio_resume` is enough.
* - **Cold**: engine has no loaded stream (app relaunch, or track
* ended and user hit play again). Promote any
* `stream_completed_cache` to hot disk, refetch the song from
* Navidrome for fresh ReplayGain metadata, call `audio_play`,
* then seek to the persisted `currentTime`. A `getSong` failure
* falls back to the in-memory `currentTrack`.
* `stream_completed_cache` to hot disk, prefetch ReplayGain metadata
* (index getSong), call `audio_play`, then seek to the persisted
* `currentTime`.
*/
export function runResume(set: SetState, get: GetState): void {
clearAllPlaybackScheduleTimers();
@@ -156,88 +156,55 @@ export function runResume(set: SetState, get: GetState): void {
}
if (getPlayGeneration() !== gen) return;
// Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => {
if (getPlayGeneration() !== gen) return;
const coldServerId = getPlaybackIndexKey();
let trackToPlay = currentTrack;
try {
if (coldServerId) {
trackToPlay = await enrichTrackPlaybackMetadata(currentTrack, coldServerId);
}
} catch { /* keep currentTrack */ }
if (getPlayGeneration() !== gen) return;
if (trackToPlay !== currentTrack) set({ currentTrack: trackToPlay });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
recordEnginePlayUrl(trackToPlay.id, coldUrl);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(useAuthStore.getState()),
analysisTrackId: trackToPlay.id,
serverId: coldServerId || null,
streamFormatSuffix: trackToPlay.suffix ?? null,
startPaused: false,
}).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
// Update store with fresh track data if available
if (freshSong) set({ currentTrack: trackToPlay });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = getPlaybackIndexKey();
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
recordEnginePlayUrl(trackToPlay.id, coldUrl);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(useAuthStore.getState()),
analysisTrackId: trackToPlay.id,
serverId: coldServerId || null,
streamFormatSuffix: trackToPlay.suffix ?? null,
startPaused: false,
}).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
}).catch(() => {
if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
currentTrack, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = getPlaybackIndexKey();
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
recordEnginePlayUrl(currentTrack.id, coldUrl);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(useAuthStore.getState()),
analysisTrackId: currentTrack.id,
serverId: coldServerId || null,
streamFormatSuffix: currentTrack.suffix ?? null,
startPaused: false,
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
pushQueueOnPlaybackStart(queueItems, currentTrack, currentTime);
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
})();
}
}
@@ -15,6 +15,7 @@ import {
setSeekFallbackTrackId,
setSeekFallbackVisualTarget,
} from '@/features/playback/store/seekFallbackState';
import { noteEngineProgressForGapless } from '@/features/playback/store/gaplessProgressTracking';
import {
clearSeekTarget,
setSeekTarget,
@@ -66,6 +67,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
invoke('audio_seek', { seconds: time }).then(() => {
// Arm stale-progress guard only after backend acknowledged seek.
setSeekTarget(time);
noteEngineProgressForGapless(time);
setSeekFallbackVisualTarget(null);
clearSeekFallbackRetry();
// Seeking straight into the crossfade pre-buffer window must kick off the
@@ -99,6 +101,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
// Keep stale progress ticks from snapping UI back to start while
// recoverable seek retries are still in flight.
setSeekTarget(time);
noteEngineProgressForGapless(time);
if (msg.includes('not seekable') && !sameBurst) {
setSeekFallbackTrackId(s.currentTrack.id);
setSeekFallbackRestartAt(now);
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTracks, seedQueue } from '@/test/helpers/factories';
@@ -22,13 +22,15 @@ describe('timeline history on queue replace', () => {
onInvoke('discord_update_presence', () => undefined);
});
it('keeps session history when playTrack replaces the queue', () => {
it('keeps session history when playTrack replaces the queue', async () => {
const first = makeTracks(1);
seedQueue(first, { index: 0, currentTrack: first[0] });
const album = makeTracks(3);
usePlayerStore.getState().playTrack(album[0]!, album, true, true);
await vi.waitFor(() => {
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
});
const history = getTimelineSessionHistorySnapshot();
expect(history.some(h => h.trackId === first[0]!.id)).toBe(true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
});
});
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as subsonicLibrary from '@/lib/api/subsonicLibrary';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import type { Track } from '@/lib/media/trackTypes';
import {
enrichTrackPlaybackMetadata,
mergePlaybackTrackMetadata,
trackNeedsPlaybackMetadataPrefetch,
trackNeedsReplayGainMetadataPrefetch,
} from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
const track = (extra: Partial<Track> = {}): Track => ({
id: 't1',
title: extra.title ?? 'Track',
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
duration: 200,
...extra,
});
describe('trackNeedsReplayGainMetadataPrefetch', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
});
it('returns true when ReplayGain is on and tags are missing', () => {
expect(trackNeedsReplayGainMetadataPrefetch(track())).toBe(true);
});
it('returns false when track gain is present', () => {
expect(trackNeedsReplayGainMetadataPrefetch(track({ replayGainTrackDb: -6 }))).toBe(false);
});
});
describe('mergePlaybackTrackMetadata', () => {
it('fills placeholder title and ReplayGain from the resolved track', () => {
const base = track({ title: '…', duration: 0 });
const resolved = track({
title: 'Resolved',
duration: 240,
replayGainTrackDb: -7.5,
replayGainPeak: 0.99,
});
const merged = mergePlaybackTrackMetadata(base, resolved);
expect(merged.title).toBe('Resolved');
expect(merged.duration).toBe(240);
expect(merged.replayGainTrackDb).toBe(-7.5);
expect(merged.replayGainPeak).toBe(0.99);
});
});
describe('trackNeedsPlaybackMetadataPrefetch', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'off',
replayGainEnabled: false,
});
});
it('returns true for thin placeholder snapshots even when ReplayGain is off', () => {
expect(trackNeedsPlaybackMetadataPrefetch(track({ title: '…' }))).toBe(true);
expect(trackNeedsPlaybackMetadataPrefetch(track({ duration: 0 }))).toBe(true);
});
it('returns true when ReplayGain is on and peak is missing but gain tags exist', () => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
expect(trackNeedsPlaybackMetadataPrefetch(track({ replayGainTrackDb: -6 }))).toBe(true);
});
it('returns false when ReplayGain tags and peak are present', () => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
expect(trackNeedsPlaybackMetadataPrefetch(track({
replayGainTrackDb: -6,
replayGainPeak: 0.88,
}))).toBe(false);
});
});
describe('enrichTrackPlaybackMetadata', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
useLibraryIndexStore.setState({ masterEnabled: true });
vi.restoreAllMocks();
});
it('reads ReplayGain from the local index before network', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Indexed',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.1,
replayGainAlbumDb: -7.0,
syncedAt: 0,
rawJson: {},
}));
const networkSpy = vi.spyOn(subsonicLibrary, 'getSongForServer');
const enriched = await enrichTrackPlaybackMetadata(track({ title: '…' }), 's1');
expect(enriched.replayGainTrackDb).toBe(-8.1);
expect(enriched.title).toBe('Indexed');
expect(networkSpy).not.toHaveBeenCalled();
});
it('reads replayGainPeak from the local index without network', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Indexed',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.1,
replayGainPeak: 0.88,
syncedAt: 0,
rawJson: {},
}));
const networkSpy = vi.spyOn(subsonicLibrary, 'getSongForServer');
const enriched = await enrichTrackPlaybackMetadata(
track({ replayGainTrackDb: -8.1 }),
's1',
);
expect(enriched.replayGainPeak).toBe(0.88);
expect(networkSpy).not.toHaveBeenCalled();
});
it('refreshes recalculated ReplayGain from the index when tags already exist', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Indexed',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.5,
replayGainPeak: 0.91,
syncedAt: 0,
rawJson: {},
}));
const enriched = await enrichTrackPlaybackMetadata(
track({ replayGainTrackDb: -6.0, replayGainPeak: 0.8 }),
's1',
);
expect(enriched.replayGainTrackDb).toBe(-8.5);
expect(enriched.replayGainPeak).toBe(0.91);
});
});
@@ -0,0 +1,83 @@
import { libraryGetTrack } from '@/lib/api/library';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { isReplayGainActive } from '@/features/playback/store/loudnessGainCache';
import { trackToSong } from '@/lib/library/trackDtoMapping';
import { libraryIsReady } from '@/lib/library/libraryReady';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Track } from '@/lib/media/trackTypes';
async function resolveSongMetaFromIndexForPlaybackEnrich(
serverId: string,
songId: string,
): Promise<SubsonicSong | null> {
if (!await libraryIsReady(serverId)) return null;
try {
const dto = await libraryGetTrack(serverId, songId);
if (dto) return trackToSong(dto);
} catch { /* index unavailable */ }
return null;
}
/** True when ReplayGain is on and the track snapshot has no gain tags yet. */
export function trackNeedsReplayGainMetadataPrefetch(track: Track): boolean {
if (!isReplayGainActive()) return false;
return track.replayGainTrackDb == null && track.replayGainAlbumDb == null;
}
/** True when index/getSong prefetch would improve a thin snapshot or ReplayGain tags. */
export function trackNeedsPlaybackMetadataPrefetch(track: Track): boolean {
if (track.title === '…' || track.duration === 0) return true;
if (trackNeedsReplayGainMetadataPrefetch(track)) return true;
return isReplayGainActive() && track.replayGainPeak == null
&& (track.replayGainTrackDb != null || track.replayGainAlbumDb != null);
}
/** Merge resolver/index metadata onto a thin playback snapshot without dropping queue flags. */
export function mergePlaybackTrackMetadata(base: Track, resolved: Track): Track {
const thin = base.title === '…' || base.duration === 0;
return {
...(thin ? resolved : base),
...base,
id: base.id,
autoAdded: base.autoAdded,
radioAdded: base.radioAdded,
playNextAdded: base.playNextAdded,
title: thin && resolved.title ? resolved.title : base.title,
artist: thin && resolved.artist ? resolved.artist : base.artist,
album: thin && resolved.album ? resolved.album : base.album,
albumId: resolved.albumId || base.albumId,
duration: resolved.duration > 0 ? resolved.duration : base.duration,
replayGainTrackDb: resolved.replayGainTrackDb ?? base.replayGainTrackDb,
replayGainAlbumDb: resolved.replayGainAlbumDb ?? base.replayGainAlbumDb,
replayGainPeak: resolved.replayGainPeak ?? base.replayGainPeak,
suffix: resolved.suffix ?? base.suffix,
bitRate: resolved.bitRate ?? base.bitRate,
samplingRate: resolved.samplingRate ?? base.samplingRate,
bitDepth: resolved.bitDepth ?? base.bitDepth,
coverArt: resolved.coverArt ?? base.coverArt,
artistId: resolved.artistId ?? base.artistId,
serverId: base.serverId ?? resolved.serverId,
};
}
/**
* Prefetch playback metadata (thin fields + ReplayGain) from the local index
* before binding the engine on stream / gapless paths. Network fallback stays
* in the queue resolver / {@link resolveSongMetaIndexFirst} path.
*/
export async function enrichTrackPlaybackMetadata(
track: Track,
serverId: string,
): Promise<Track> {
if (!serverId) return track;
const needsPrefetch = trackNeedsPlaybackMetadataPrefetch(track);
const rgRecheck = isReplayGainActive()
&& !needsPrefetch
&& (track.replayGainTrackDb != null
|| track.replayGainAlbumDb != null
|| track.replayGainPeak != null);
if (!needsPrefetch && !rgRecheck) return track;
const song = await resolveSongMetaFromIndexForPlaybackEnrich(serverId, track.id);
if (!song) return track;
return mergePlaybackTrackMetadata(track, songToTrack(song));
}
@@ -0,0 +1,16 @@
import type { Track } from '@/lib/media/trackTypes';
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
/**
* Index-first metadata before `audio_play` / `audio_chain_preload`.
* Callers in the playback store should await {@link refreshLoudnessForTrack}
* separately so dependency-cruiser does not route utils store utils cycles.
*/
export async function prepareTrackForEngineBind(
track: Track,
serverId: string,
): Promise<Track> {
return serverId
? enrichTrackPlaybackMetadata(track, serverId)
: track;
}
@@ -143,8 +143,15 @@ export default function WaveformSeek({ trackId }: Props) {
// Initial draw for static styles when style, track, or waveform payload changes.
useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return;
progressRef.current = 0;
bufferedRef.current = 0;
visualProgressRef.current = 0;
visualTargetProgressRef.current = 0;
// React Compiler immutability rule: intentional imperative reset on track change.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = { progress: 0, atMs: performance.now() };
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, 0, 0);
}, [
seekbarStyle,
trackId,
+1
View File
@@ -52,6 +52,7 @@ export interface LibraryTrackDto {
bpmSource?: string | null;
replayGainTrackDb?: number | null;
replayGainAlbumDb?: number | null;
replayGainPeak?: number | null;
serverUpdatedAt?: number | null;
serverCreatedAt?: number | null;
syncedAt: number;
+3 -61
View File
@@ -18,7 +18,6 @@ import {
type LibraryArtistDto,
type LibraryEntityType,
type LibraryFilterClause,
type LibraryTrackDto,
} from '@/lib/api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { search } from '@/lib/api/subsonicSearch';
@@ -31,6 +30,9 @@ import { logLibrarySearch, timed } from './libraryDevLog';
import { isLosslessSuffix } from './losslessFormats';
import { albumIsCompilation } from './albumCompilation';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
import { trackToSong } from './trackDtoMapping';
export { resolveTrackCoverArtId, trackToSong } from './trackDtoMapping';
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
@@ -149,66 +151,6 @@ function buildRequest(
};
}
/**
* Cover art id for a library track mirrors Rust cover backfill
* (`COALESCE(cover_art_id, album_id)`). Many servers only expose album art.
*/
export function resolveTrackCoverArtId(
hot: Pick<LibraryTrackDto, 'coverArtId' | 'albumId'>,
song: Partial<SubsonicSong> = {},
): string | undefined {
const songArt = typeof song.coverArt === 'string' ? song.coverArt.trim() : '';
const hotArt = typeof hot.coverArtId === 'string' ? hot.coverArtId.trim() : '';
// `raw_json` per-disc `coverArt` wins over a stale index `cover_art_id` (often disc 1).
if (songArt && hotArt && songArt !== hotArt && songArt.startsWith('mf-')) {
return songArt;
}
for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) {
const id = typeof c === 'string' ? c.trim() : '';
if (id) return id;
}
return undefined;
}
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
const raw = isObject(t.rawJson) ? t.rawJson : {};
const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined;
const base: SubsonicSong = {
id: t.id,
title: t.title,
artist: t.artist ?? '',
album: t.album,
albumId: t.albumId ?? '',
artistId: t.artistId ?? undefined,
duration: t.durationSec,
track: t.trackNumber ?? undefined,
discNumber: t.discNumber ?? undefined,
coverArt: resolveTrackCoverArtId(t),
year: t.year ?? undefined,
genre: t.genre ?? undefined,
suffix: t.suffix ?? undefined,
bitRate: t.bitRate ?? undefined,
size: t.sizeBytes ?? undefined,
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
userRating: t.userRating ?? undefined,
playCount: t.playCount ?? undefined,
bpm: resolvedBpm,
isrc: t.isrc ?? undefined,
albumArtist: t.albumArtist ?? undefined,
};
// `rawJson` is the authoritative original song — let it override the
// hot-column fallbacks (it carries OpenSubsonic extras too).
const merged: SubsonicSong = { ...base, ...(raw as Partial<SubsonicSong>) };
const coverArt = resolveTrackCoverArtId(t, merged);
if (coverArt) merged.coverArt = coverArt;
if (resolvedBpm != null) merged.bpm = resolvedBpm;
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
merged.localBpmSource = t.bpmSource;
}
if (t.serverId) merged.serverId = t.serverId;
return merged;
}
/** Merge `raw_json` without nullish Subsonic fields wiping hot columns (e.g. year). */
function mergeAlbumRawJson(base: SubsonicAlbum, raw: Partial<SubsonicAlbum>): SubsonicAlbum {
const merged = { ...base } as SubsonicAlbum & Record<string, unknown>;
@@ -0,0 +1,23 @@
import { libraryGetTrack } from '@/lib/api/library';
import { getSongForServer } from '@/lib/api/subsonicLibrary';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { trackToSong } from '@/lib/library/trackDtoMapping';
import { libraryIsReady } from '@/lib/library/libraryReady';
/**
* Index-first song metadata: local SQLite (`libraryGetTrack`) when the index is
* ready, else Subsonic `getSong.view`. Shared by playback prefetch, Now Playing,
* and the queue resolver's network fallback family.
*/
export async function resolveSongMetaIndexFirst(
serverId: string,
songId: string,
): Promise<SubsonicSong | null> {
if (await libraryIsReady(serverId)) {
try {
const dto = await libraryGetTrack(serverId, songId);
if (dto) return trackToSong(dto);
} catch { /* index error → network fallback */ }
}
return getSongForServer(serverId, songId);
}
+73
View File
@@ -0,0 +1,73 @@
import type { LibraryTrackDto } from '@/lib/api/library';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
/**
* Cover art id for a library track mirrors Rust cover backfill
* (`COALESCE(cover_art_id, album_id)`). Many servers only expose album art.
*/
export function resolveTrackCoverArtId(
hot: Pick<LibraryTrackDto, 'coverArtId' | 'albumId'>,
song: Partial<SubsonicSong> = {},
): string | undefined {
const songArt = typeof song.coverArt === 'string' ? song.coverArt.trim() : '';
const hotArt = typeof hot.coverArtId === 'string' ? hot.coverArtId.trim() : '';
// `raw_json` per-disc `coverArt` wins over a stale index `cover_art_id` (often disc 1).
if (songArt && hotArt && songArt !== hotArt && songArt.startsWith('mf-')) {
return songArt;
}
for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) {
const id = typeof c === 'string' ? c.trim() : '';
if (id) return id;
}
return undefined;
}
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
const raw = isObject(t.rawJson) ? t.rawJson : {};
const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined;
const base: SubsonicSong = {
id: t.id,
title: t.title,
artist: t.artist ?? '',
album: t.album,
albumId: t.albumId ?? '',
artistId: t.artistId ?? undefined,
duration: t.durationSec,
track: t.trackNumber ?? undefined,
discNumber: t.discNumber ?? undefined,
coverArt: resolveTrackCoverArtId(t),
year: t.year ?? undefined,
genre: t.genre ?? undefined,
suffix: t.suffix ?? undefined,
bitRate: t.bitRate ?? undefined,
size: t.sizeBytes ?? undefined,
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
userRating: t.userRating ?? undefined,
playCount: t.playCount ?? undefined,
bpm: resolvedBpm,
isrc: t.isrc ?? undefined,
albumArtist: t.albumArtist ?? undefined,
};
// `rawJson` is the authoritative original song — let it override the
// hot-column fallbacks (it carries OpenSubsonic extras too).
const merged: SubsonicSong = { ...base, ...(raw as Partial<SubsonicSong>) };
const coverArt = resolveTrackCoverArtId(t, merged);
if (coverArt) merged.coverArt = coverArt;
if (resolvedBpm != null) merged.bpm = resolvedBpm;
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
merged.localBpmSource = t.bpmSource;
}
if (t.replayGainTrackDb != null || t.replayGainAlbumDb != null || t.replayGainPeak != null) {
merged.replayGain = {
...merged.replayGain,
trackGain: t.replayGainTrackDb ?? merged.replayGain?.trackGain,
albumGain: t.replayGainAlbumDb ?? merged.replayGain?.albumGain,
trackPeak: t.replayGainPeak ?? merged.replayGain?.trackPeak,
};
}
if (t.serverId) merged.serverId = t.serverId;
return merged;
}