diff --git a/CHANGELOG.md b/CHANGELOG.md index 577a9bb8..a0914120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/compare-environment.sh b/scripts/compare-environment.sh new file mode 100755 index 00000000..54f1e1e0 --- /dev/null +++ b/scripts/compare-environment.sh @@ -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 diff --git a/scripts/dump-environment.sh b/scripts/dump-environment.sh new file mode 100755 index 00000000..baf59292 --- /dev/null +++ b/scripts/dump-environment.sh @@ -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 diff --git a/scripts/lib/extract-app-config.mjs b/scripts/lib/extract-app-config.mjs new file mode 100644 index 00000000..097f3817 --- /dev/null +++ b/scripts/lib/extract-app-config.mjs @@ -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')); diff --git a/src-tauri/crates/psysonic-library/migrations/015_replay_gain_peak.sql b/src-tauri/crates/psysonic-library/migrations/015_replay_gain_peak.sql new file mode 100644 index 00000000..9bc1f92c --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/015_replay_gain_peak.sql @@ -0,0 +1,2 @@ +-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak). +ALTER TABLE track ADD COLUMN replay_gain_peak REAL; diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index d430623e..ce894b4a 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs index bbbf5d71..538fdecc 100644 --- a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs +++ b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs index c67d23e6..0719db16 100644 --- a/src-tauri/crates/psysonic-library/src/browse_support.rs +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index f81d7f50..9a3959b0 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/cross_server.rs b/src-tauri/crates/psysonic-library/src/cross_server.rs index ad930073..19c1b065 100644 --- a/src-tauri/crates/psysonic-library/src/cross_server.rs +++ b/src-tauri/crates/psysonic-library/src/cross_server.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index d326bb02..ae008240 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -106,6 +106,7 @@ pub struct LibraryTrackDto { pub bpm_source: Option, pub replay_gain_track_db: Option, pub replay_gain_album_db: Option, + pub replay_gain_peak: Option, pub server_updated_at: Option, pub server_created_at: Option, @@ -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", diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs index 12687e8f..3d53dde0 100644 --- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs b/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs index 4b549187..af637c30 100644 --- a/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs index 0cb1a692..540c3ddf 100644 --- a/src-tauri/crates/psysonic-library/src/live_search.rs +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/lossless_albums.rs b/src-tauri/crates/psysonic-library/src/lossless_albums.rs index 58adf44c..f59f6e4b 100644 --- a/src-tauri/crates/psysonic-library/src/lossless_albums.rs +++ b/src-tauri/crates/psysonic-library/src/lossless_albums.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/repos/artist.rs b/src-tauri/crates/psysonic-library/src/repos/artist.rs index 487a1f7e..71adee75 100644 --- a/src-tauri/crates/psysonic-library/src/repos/artist.rs +++ b/src-tauri/crates/psysonic-library/src/repos/artist.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs index 795e8f0c..9c0ade06 100644 --- a/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index db70d774..f7b6e55d 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -53,6 +53,7 @@ pub struct TrackRow { pub bpm: Option, pub replay_gain_track_db: Option, pub replay_gain_album_db: Option, + pub replay_gain_peak: Option, pub content_hash: Option, pub server_updated_at: Option, pub server_created_at: Option, @@ -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(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), diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs index 2e02cc7f..4dc7f903 100644 --- a/src-tauri/crates/psysonic-library/src/search.rs +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index 58ccd0ff..b280e0cf 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -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 { + 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 { + let completed: Option> = 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 { run_migrations_with( conn, diff --git a/src-tauri/crates/psysonic-library/src/sync/delta.rs b/src-tauri/crates/psysonic-library/src/sync/delta.rs index d248674b..11fa89c9 100644 --- a/src-tauri/crates/psysonic-library/src/sync/delta.rs +++ b/src-tauri/crates/psysonic-library/src/sync/delta.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/sync/initial.rs b/src-tauri/crates/psysonic-library/src/sync/initial.rs index ea8035cb..568b1dfa 100644 --- a/src-tauri/crates/psysonic-library/src/sync/initial.rs +++ b/src-tauri/crates/psysonic-library/src/sync/initial.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/sync/mapping.rs b/src-tauri/crates/psysonic-library/src/sync/mapping.rs index 4ce02783..b542b4c9 100644 --- a/src-tauri/crates/psysonic-library/src/sync/mapping.rs +++ b/src-tauri/crates/psysonic-library/src/sync/mapping.rs @@ -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")); diff --git a/src-tauri/crates/psysonic-library/src/sync/tombstone.rs b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs index 19835188..332bb420 100644 --- a/src-tauri/crates/psysonic-library/src/sync/tombstone.rs +++ b/src-tauri/crates/psysonic-library/src/sync/tombstone.rs @@ -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, diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index bcecb106..ba0dacaf 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -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'; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 0754ebb3..3a80b416 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -178,6 +178,7 @@ const CONTRIBUTOR_ENTRIES = [ 'AutoDJ overlap cap — Auto (12 s content cap) or Limit (2–30 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)', ], }, { diff --git a/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts b/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts index 1176ec11..9918a975 100644 --- a/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts +++ b/src/features/nowPlaying/utils/nowPlayingMetadataResolve.ts @@ -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 { - 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); } diff --git a/src/features/offline/utils/offlineLibraryHelpers.ts b/src/features/offline/utils/offlineLibraryHelpers.ts index 3967f03e..88f408a1 100644 --- a/src/features/offline/utils/offlineLibraryHelpers.ts +++ b/src/features/offline/utils/offlineLibraryHelpers.ts @@ -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, }; diff --git a/src/features/playback/store/audioEventHandlers.ts b/src/features/playback/store/audioEventHandlers.ts index 664bb4b1..7eef5db4 100644 --- a/src/features/playback/store/audioEventHandlers.ts +++ b/src/features/playback/store/audioEventHandlers.ts @@ -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 { diff --git a/src/features/playback/store/gaplessChainPreload.test.ts b/src/features/playback/store/gaplessChainPreload.test.ts new file mode 100644 index 00000000..7e0ed289 --- /dev/null +++ b/src/features/playback/store/gaplessChainPreload.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/features/playback/store/gaplessChainPreload.ts b/src/features/playback/store/gaplessChainPreload.ts new file mode 100644 index 00000000..7dddaab3 --- /dev/null +++ b/src/features/playback/store/gaplessChainPreload.ts @@ -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>(); + +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(); +} diff --git a/src/features/playback/store/gaplessProgressTracking.ts b/src/features/playback/store/gaplessProgressTracking.ts new file mode 100644 index 00000000..88e0bde8 --- /dev/null +++ b/src/features/playback/store/gaplessProgressTracking.ts @@ -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; +} diff --git a/src/features/playback/store/gaplessQueueAdvance.test.ts b/src/features/playback/store/gaplessQueueAdvance.test.ts new file mode 100644 index 00000000..a8f74193 --- /dev/null +++ b/src/features/playback/store/gaplessQueueAdvance.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/features/playback/store/gaplessQueueAdvance.ts b/src/features/playback/store/gaplessQueueAdvance.ts new file mode 100644 index 00000000..7322daa1 --- /dev/null +++ b/src/features/playback/store/gaplessQueueAdvance.ts @@ -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, + 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); +} diff --git a/src/features/playback/store/pausedRestorePrepare.test.ts b/src/features/playback/store/pausedRestorePrepare.test.ts index d674db17..40fd8374 100644 --- a/src/features/playback/store/pausedRestorePrepare.test.ts +++ b/src/features/playback/store/pausedRestorePrepare.test.ts @@ -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 { diff --git a/src/features/playback/store/pausedRestorePrepare.ts b/src/features/playback/store/pausedRestorePrepare.ts index e6cce755..3c82117a 100644 --- a/src/features/playback/store/pausedRestorePrepare.ts +++ b/src/features/playback/store/pausedRestorePrepare.ts @@ -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, diff --git a/src/features/playback/store/playTrackAction.ts b/src/features/playback/store/playTrackAction.ts index e1922a76..42d5db0c 100644 --- a/src/features/playback/store/playTrackAction.ts +++ b/src/features/playback/store/playTrackAction.ts @@ -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(); } } diff --git a/src/features/playback/store/queueUndoAudioRestore.test.ts b/src/features/playback/store/queueUndoAudioRestore.test.ts index 3a0f0b30..1a5f3978 100644 --- a/src/features/playback/store/queueUndoAudioRestore.test.ts +++ b/src/features/playback/store/queueUndoAudioRestore.test.ts @@ -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); diff --git a/src/features/playback/store/queueUndoAudioRestore.ts b/src/features/playback/store/queueUndoAudioRestore.ts index 42f72a73..457879d7 100644 --- a/src/features/playback/store/queueUndoAudioRestore.ts +++ b/src/features/playback/store/queueUndoAudioRestore.ts @@ -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 }); + })(); } diff --git a/src/features/playback/store/replayGainMetadataSync.test.ts b/src/features/playback/store/replayGainMetadataSync.test.ts new file mode 100644 index 00000000..c8090b95 --- /dev/null +++ b/src/features/playback/store/replayGainMetadataSync.test.ts @@ -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 => ({ + 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>; + }); + + 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); + }); +}); diff --git a/src/features/playback/store/replayGainMetadataSync.ts b/src/features/playback/store/replayGainMetadataSync.ts new file mode 100644 index 00000000..e4d8fc70 --- /dev/null +++ b/src/features/playback/store/replayGainMetadataSync.ts @@ -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 { + 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 | 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('library:sync-idle', ({ payload }) => { + scheduleIndexRefreshAfterSyncIdle(payload); +}); diff --git a/src/features/playback/store/resumeAction.ts b/src/features/playback/store/resumeAction.ts index 622f0e86..d375b695 100644 --- a/src/features/playback/store/resumeAction.ts +++ b/src/features/playback/store/resumeAction.ts @@ -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); })(); } } diff --git a/src/features/playback/store/seekAction.ts b/src/features/playback/store/seekAction.ts index 6b64e0fd..9a037ca6 100644 --- a/src/features/playback/store/seekAction.ts +++ b/src/features/playback/store/seekAction.ts @@ -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); diff --git a/src/features/playback/store/timelinePlayHistory.integration.test.ts b/src/features/playback/store/timelinePlayHistory.integration.test.ts index c4e3bac6..373d8c09 100644 --- a/src/features/playback/store/timelinePlayHistory.integration.test.ts +++ b/src/features/playback/store/timelinePlayHistory.integration.test.ts @@ -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)); }); }); diff --git a/src/features/playback/utils/audio/enrichTrackReplayGainMetadata.test.ts b/src/features/playback/utils/audio/enrichTrackReplayGainMetadata.test.ts new file mode 100644 index 00000000..6939fde2 --- /dev/null +++ b/src/features/playback/utils/audio/enrichTrackReplayGainMetadata.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/features/playback/utils/audio/enrichTrackReplayGainMetadata.ts b/src/features/playback/utils/audio/enrichTrackReplayGainMetadata.ts new file mode 100644 index 00000000..5b6fe9ca --- /dev/null +++ b/src/features/playback/utils/audio/enrichTrackReplayGainMetadata.ts @@ -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 { + 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 { + 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)); +} diff --git a/src/features/playback/utils/audio/prepareTrackForEngineBind.ts b/src/features/playback/utils/audio/prepareTrackForEngineBind.ts new file mode 100644 index 00000000..1b1572b2 --- /dev/null +++ b/src/features/playback/utils/audio/prepareTrackForEngineBind.ts @@ -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 { + return serverId + ? enrichTrackPlaybackMetadata(track, serverId) + : track; +} diff --git a/src/features/waveform/components/WaveformSeek.tsx b/src/features/waveform/components/WaveformSeek.tsx index c1a8f863..730a87a4 100644 --- a/src/features/waveform/components/WaveformSeek.tsx +++ b/src/features/waveform/components/WaveformSeek.tsx @@ -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, diff --git a/src/lib/api/library/dto.ts b/src/lib/api/library/dto.ts index cba26fca..dcdfcf84 100644 --- a/src/lib/api/library/dto.ts +++ b/src/lib/api/library/dto.ts @@ -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; diff --git a/src/lib/library/advancedSearchLocal.ts b/src/lib/library/advancedSearchLocal.ts index 8d645b16..8f68999d 100644 --- a/src/lib/library/advancedSearchLocal.ts +++ b/src/lib/library/advancedSearchLocal.ts @@ -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, - song: Partial = {}, -): 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) }; - 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 { const merged = { ...base } as SubsonicAlbum & Record; diff --git a/src/lib/library/resolveSongMetaIndexFirst.ts b/src/lib/library/resolveSongMetaIndexFirst.ts new file mode 100644 index 00000000..5217574e --- /dev/null +++ b/src/lib/library/resolveSongMetaIndexFirst.ts @@ -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 { + 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); +} diff --git a/src/lib/library/trackDtoMapping.ts b/src/lib/library/trackDtoMapping.ts new file mode 100644 index 00000000..0bb8bb4a --- /dev/null +++ b/src/lib/library/trackDtoMapping.ts @@ -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 => + 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, + song: Partial = {}, +): 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) }; + 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; +}