mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(playback): ReplayGain prefetch, gapless UI sync, library peak column (#1231)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compare two environment dumps from dump-environment.sh
|
||||
# Usage: ./scripts/compare-environment.sh FILE_A FILE_B
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
echo "Usage: $0 FILE_A FILE_B" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE_A="$1"
|
||||
FILE_B="$2"
|
||||
|
||||
for f in "$FILE_A" "$FILE_B"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "Missing file: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
parse_dump() {
|
||||
local file="$1"
|
||||
awk -F= '
|
||||
/^\[/ {
|
||||
section = substr($0, 2, length($0) - 2)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*$/ { next }
|
||||
/^#/ { next }
|
||||
{
|
||||
key = $1
|
||||
$1 = ""
|
||||
sub(/^=/, "", $0)
|
||||
full = (section == "" ? key : section "." key)
|
||||
print full "\t" $0
|
||||
}
|
||||
' "$file" | sort -u
|
||||
}
|
||||
|
||||
TMP_A="$(mktemp)"
|
||||
TMP_B="$(mktemp)"
|
||||
TMP_JOIN="$(mktemp)"
|
||||
trap 'rm -f "$TMP_A" "$TMP_B" "$TMP_JOIN"' EXIT
|
||||
|
||||
parse_dump "$FILE_A" >"$TMP_A"
|
||||
parse_dump "$FILE_B" >"$TMP_B"
|
||||
|
||||
join -t $'\t' -a 1 -a 2 -e '—' -o '0,1.2,2.2' "$TMP_A" "$TMP_B" >"$TMP_JOIN"
|
||||
|
||||
ONLY_A=0
|
||||
ONLY_B=0
|
||||
DIFF=0
|
||||
SAME=0
|
||||
|
||||
echo "Comparing:"
|
||||
echo " A: $FILE_A"
|
||||
echo " B: $FILE_B"
|
||||
echo
|
||||
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ -z "$key" ]] && continue
|
||||
if [[ "$val_a" == "—" ]]; then
|
||||
ONLY_B=$((ONLY_B + 1))
|
||||
printf ' + %-40s B=%s\n' "$key" "$val_b"
|
||||
elif [[ "$val_b" == "—" ]]; then
|
||||
ONLY_A=$((ONLY_A + 1))
|
||||
printf ' - %-40s A=%s\n' "$key" "$val_a"
|
||||
elif [[ "$val_a" != "$val_b" ]]; then
|
||||
DIFF=$((DIFF + 1))
|
||||
printf ' ≠ %-40s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
else
|
||||
SAME=$((SAME + 1))
|
||||
fi
|
||||
done <"$TMP_JOIN"
|
||||
|
||||
echo
|
||||
echo "Summary: same=$SAME different=$DIFF only_in_A=$ONLY_A only_in_B=$ONLY_B"
|
||||
|
||||
# High-signal keys for the common "works on one NixOS box" case.
|
||||
echo
|
||||
echo "High-signal differences (if any):"
|
||||
HIGH_SIGNAL=0
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ "$val_a" == "$val_b" ]] && continue
|
||||
case "$key" in
|
||||
meta.flake_lock_sha256|meta.nixpkgs_locked_rev|meta.npm_lock_sha256|meta.git_rev|\
|
||||
installed_app.psysonic_realpath|installed_app.psysonic_closure_paths|installed_app.psysonic_drv|\
|
||||
runtime_env.HTTP_PROXY|runtime_env.HTTPS_PROXY|runtime_env.http_proxy|runtime_env.https_proxy|\
|
||||
runtime_env.NO_PROXY|runtime_env.no_proxy|runtime_env.SSL_CERT_FILE|runtime_env.NIX_SSL_CERT_FILE|\
|
||||
toolchain_nix_develop.node_realpath|toolchain_nix_develop.rustc_realpath|\
|
||||
host.nixos_version|host.uname|\
|
||||
app_config_paths.data_dir|app_config_paths.localstorage_db_path|\
|
||||
app_preferences.language|app_servers.active_server_id|app_servers.server_count|\
|
||||
app_servers.server.*.url|app_servers.server.*.alternateUrl|\
|
||||
app_servers.server.*.customHeaders_count|app_servers.server.*.customHeadersApplyTo|\
|
||||
app_servers.server.*.password_sha256|app_servers.server.*.customHeaders.*.name|\
|
||||
app_servers.server.*.customHeaders.*.value_sha256|\
|
||||
app_network_probe.probe.*)
|
||||
HIGH_SIGNAL=$((HIGH_SIGNAL + 1))
|
||||
printf ' ! %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
;;
|
||||
esac
|
||||
done <"$TMP_JOIN"
|
||||
|
||||
if [[ "$HIGH_SIGNAL" -eq 0 && "$DIFF" -eq 0 && "$ONLY_A" -eq 0 && "$ONLY_B" -eq 0 ]]; then
|
||||
echo " (none — environments match on recorded keys)"
|
||||
elif [[ "$HIGH_SIGNAL" -eq 0 ]]; then
|
||||
echo " (no high-signal keys differ; see full list above — may be npm patch-level deps only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Server / network config differences:"
|
||||
SERVER_DIFF=0
|
||||
while IFS=$'\t' read -r key val_a val_b; do
|
||||
[[ "$val_a" == "$val_b" ]] && continue
|
||||
case "$key" in
|
||||
app_servers.*|app_network_probe.*|app_config_paths.*|app_preferences.*)
|
||||
SERVER_DIFF=$((SERVER_DIFF + 1))
|
||||
printf ' • %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
|
||||
;;
|
||||
esac
|
||||
done <"$TMP_JOIN"
|
||||
if [[ "$SERVER_DIFF" -eq 0 ]]; then
|
||||
echo " (none — server URLs, headers, and curl probes match)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Reminder: server offline is usually URL/network/headers, not Node patch versions."
|
||||
echo "Next: curl the Navidrome URL from both hosts; compare Settings → Servers side by side."
|
||||
|
||||
if [[ "$DIFF" -gt 0 || "$ONLY_A" -gt 0 || "$ONLY_B" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dump Psysonic-related toolchain, Nix closure hints, app config, and env for cross-machine diff.
|
||||
# Re-enters `nix develop` automatically when flake.nix is present (no manual dev shell needed).
|
||||
# Usage: ./scripts/dump-environment.sh [-o FILE]
|
||||
# Compare: ./scripts/compare-environment.sh a.txt b.txt
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/dump-environment.sh"
|
||||
|
||||
# Bootstrap: run the rest inside the flake dev shell so node/jq match the project.
|
||||
if [[ -z "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]] && [[ -f "$REPO_ROOT/flake.nix" ]]; then
|
||||
if ! command -v nix >/dev/null 2>&1; then
|
||||
echo "$0: flake.nix found but nix is not on PATH — install Nix or run from a NixOS profile with flakes." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PSYSONIC_ENV_DUMP_IN_NIX=1
|
||||
exec env REPO_ROOT="$REPO_ROOT" nix develop --command bash "$SCRIPT" "$@"
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "$0: node not found after nix develop — check flake.nix devShell." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUTPUT_FILE=""
|
||||
while getopts 'o:h' opt; do
|
||||
case "$opt" in
|
||||
o) OUTPUT_FILE="$OPTARG" ;;
|
||||
h)
|
||||
echo "Usage: $0 [-o FILE]" >&2
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [-o FILE]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
emit() {
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
printf '%s\n' "$*" >>"$OUTPUT_FILE"
|
||||
else
|
||||
printf '%s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
kv() {
|
||||
local key="$1"
|
||||
local value="${2-}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
emit "${key}=${value}"
|
||||
}
|
||||
|
||||
section() {
|
||||
emit ""
|
||||
emit "[$1]"
|
||||
}
|
||||
|
||||
run_optional() {
|
||||
"$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
: >"$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
section meta
|
||||
kv generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
kv in_nix_dev_shell "${PSYSONIC_ENV_DUMP_IN_NIX:-no}"
|
||||
kv hostname "$(hostname 2>/dev/null || echo unknown)"
|
||||
kv repo_root "$REPO_ROOT"
|
||||
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
kv git_rev "$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
kv git_branch "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
|
||||
kv git_dirty "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
|
||||
else
|
||||
kv git_rev "n/a"
|
||||
kv git_branch "n/a"
|
||||
kv git_dirty "n/a"
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/package.json" ]]; then
|
||||
kv package_json_version "$(node -p "require('./package.json').version" 2>/dev/null || sed -n 's/.*\"version\": \"\\([^\"]*\\)\".*/\\1/p' "$REPO_ROOT/package.json" | head -1)"
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/flake.lock" ]]; then
|
||||
kv flake_lock_sha256 "$(sha256sum "$REPO_ROOT/flake.lock" | awk '{print $1}')"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
kv nixpkgs_locked_rev "$(jq -r '.nodes.nixpkgs.locked.rev // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
|
||||
kv nixpkgs_locked_narHash "$(jq -r '.nodes.nixpkgs.locked.narHash // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
|
||||
fi
|
||||
fi
|
||||
if [[ -f "$REPO_ROOT/package-lock.json" ]]; then
|
||||
kv npm_lockfile_version "$(jq -r '.lockfileVersion // "unknown"' "$REPO_ROOT/package-lock.json" 2>/dev/null || echo unknown)"
|
||||
kv npm_lock_sha256 "$(sha256sum "$REPO_ROOT/package-lock.json" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
section host
|
||||
kv uname "$(uname -a 2>/dev/null || echo unknown)"
|
||||
if [[ -r /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
kv os_id "${ID:-unknown}"
|
||||
kv os_version "${VERSION_ID:-unknown}"
|
||||
kv os_pretty "${PRETTY_NAME:-unknown}"
|
||||
fi
|
||||
if command -v nixos-version >/dev/null 2>&1; then
|
||||
kv nixos_version "$(nixos-version 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
|
||||
section nix
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
kv nix_version "$(nix --version 2>/dev/null | head -1)"
|
||||
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
|
||||
else
|
||||
kv nix_version "not_installed"
|
||||
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
|
||||
fi
|
||||
|
||||
dump_toolchain_block() {
|
||||
local label="$1"
|
||||
shift
|
||||
section "$label"
|
||||
for tool in node npm rustc cargo clippy jq cmake pkg-config; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
case "$tool" in
|
||||
node) kv node_version "$("$tool" -v 2>/dev/null)" ;;
|
||||
npm) kv npm_version "$("$tool" -v 2>/dev/null)" ;;
|
||||
rustc) kv rustc_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
cargo) kv cargo_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
clippy) kv clippy_version "$("$tool" -V 2>/dev/null | head -1)" ;;
|
||||
jq) kv jq_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
cmake) kv cmake_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
pkg-config) kv pkg_config_version "$("$tool" --version 2>/dev/null | head -1)" ;;
|
||||
esac
|
||||
kv "${tool}_path" "$(command -v "$tool")"
|
||||
if [[ -L "$(command -v "$tool")" ]] || [[ -e "$(command -v "$tool")" ]]; then
|
||||
kv "${tool}_realpath" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
else
|
||||
kv "${tool}_version" "missing"
|
||||
fi
|
||||
done
|
||||
kv CARGO_TARGET_DIR "${CARGO_TARGET_DIR:-unset}"
|
||||
kv LD_LIBRARY_PATH "${LD_LIBRARY_PATH:-unset}"
|
||||
kv GST_PLUGIN_PATH "${GST_PLUGIN_PATH:-unset}"
|
||||
kv GIO_EXTRA_MODULES "${GIO_EXTRA_MODULES:-unset}"
|
||||
}
|
||||
|
||||
if [[ -n "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]]; then
|
||||
dump_toolchain_block toolchain_nix_develop
|
||||
elif command -v nix >/dev/null 2>&1 && [[ -f "$REPO_ROOT/flake.nix" ]]; then
|
||||
# No bootstrap (should not happen when flake exists) — capture devShell separately.
|
||||
NIX_DEV_DUMP="$(nix develop --command bash -lc '
|
||||
set +e
|
||||
cd "$REPO_ROOT" || exit 0
|
||||
for tool in node npm rustc cargo clippy jq; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
case "$tool" in
|
||||
node) printf "node_version=%s\n" "$("$tool" -v)" ;;
|
||||
npm) printf "npm_version=%s\n" "$("$tool" -v)" ;;
|
||||
rustc) printf "rustc_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
cargo) printf "cargo_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
clippy) printf "clippy_version=%s\n" "$("$tool" -V | head -1)" ;;
|
||||
jq) printf "jq_version=%s\n" "$("$tool" --version | head -1)" ;;
|
||||
esac
|
||||
printf "%s_path=%s\n" "$tool" "$(command -v "$tool")"
|
||||
printf "%s_realpath=%s\n" "$tool" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
|
||||
else
|
||||
printf "%s_version=missing\n" "$tool"
|
||||
fi
|
||||
done
|
||||
printf "CARGO_TARGET_DIR=%s\n" "${CARGO_TARGET_DIR:-unset}"
|
||||
printf "LD_LIBRARY_PATH=%s\n" "${LD_LIBRARY_PATH:-unset}"
|
||||
printf "GST_PLUGIN_PATH=%s\n" "${GST_PLUGIN_PATH:-unset}"
|
||||
printf "GIO_EXTRA_MODULES=%s\n" "${GIO_EXTRA_MODULES:-unset}"
|
||||
' REPO_ROOT="$REPO_ROOT" 2>/dev/null | tr -d '\r' || true)"
|
||||
if [[ -n "$NIX_DEV_DUMP" ]]; then
|
||||
section toolchain_nix_develop
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" && "$line" == *=* ]] && emit "$line"
|
||||
done <<<"$NIX_DEV_DUMP"
|
||||
else
|
||||
section toolchain_nix_develop
|
||||
kv status "nix develop failed or unavailable"
|
||||
fi
|
||||
else
|
||||
dump_toolchain_block toolchain_ambient
|
||||
fi
|
||||
|
||||
section runtime_env
|
||||
for var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy GDK_BACKEND PSYSONIC_SKIP_WAYLAND_FONT_TUNING PSYSONIC_ALLOW_NATIVE_GDK SSL_CERT_FILE SSL_CERT_DIR NIX_SSL_CERT_FILE; do
|
||||
kv "$var" "${!var-unset}"
|
||||
done
|
||||
kv navigator_online "n/a (browser-only)"
|
||||
|
||||
section installed_app
|
||||
if command -v psysonic >/dev/null 2>&1; then
|
||||
PSY_PATH="$(command -v psysonic)"
|
||||
kv psysonic_path "$PSY_PATH"
|
||||
kv psysonic_realpath "$(readlink -f "$PSY_PATH" 2>/dev/null || echo unknown)"
|
||||
run_optional kv psysonic_version "$(psysonic --version 2>/dev/null | head -1)"
|
||||
if command -v nix-store >/dev/null 2>&1; then
|
||||
kv psysonic_closure_paths "$(nix-store -qR "$PSY_PATH" 2>/dev/null | wc -l | tr -d ' ')"
|
||||
kv psysonic_drv "$(nix-store -q --deriver "$PSY_PATH" 2>/dev/null | sed 's/\.drv$//' | xargs -r basename 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
else
|
||||
kv psysonic_path "not_in_path"
|
||||
fi
|
||||
|
||||
section npm_dependencies
|
||||
if [[ -f "$REPO_ROOT/package-lock.json" ]] && command -v node >/dev/null 2>&1; then
|
||||
REPO_ROOT="$REPO_ROOT" node <<'NODE' 2>/dev/null | while IFS= read -r line; do emit "$line"; done || true
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lockPath = path.join(process.env.REPO_ROOT || '.', 'package-lock.json');
|
||||
let lock;
|
||||
try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { process.exit(0); }
|
||||
const root = lock.packages?.['']?.dependencies || {};
|
||||
const deps = Object.keys(root).sort();
|
||||
for (const name of deps) {
|
||||
const entry = lock.packages?.[`node_modules/${name}`] || lock.packages?.[name];
|
||||
const version = entry?.version || 'unknown';
|
||||
console.log(`dep.${name}=${version}`);
|
||||
}
|
||||
NODE
|
||||
else
|
||||
kv status "node or package-lock.json unavailable"
|
||||
fi
|
||||
|
||||
run_app_config_dump() {
|
||||
local extractor="$REPO_ROOT/scripts/lib/extract-app-config.mjs"
|
||||
[[ -f "$extractor" ]] || return 0
|
||||
if node "$extractor" --repo-root "$REPO_ROOT" >>"${OUTPUT_FILE:-/dev/stdout}" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
section app_config
|
||||
kv status "extract-app-config failed (is Psysonic installed / has it been run once?)"
|
||||
fi
|
||||
}
|
||||
|
||||
run_app_config_dump
|
||||
|
||||
section network_probe_hint
|
||||
kv note_1 "App server profiles and curl probes are in app_servers / app_network_probe sections above."
|
||||
kv note_2 "Passwords and custom header values are redacted; compare password_sha256 and value_sha256 only."
|
||||
kv note_3 "Compare two dumps with: scripts/compare-environment.sh a.txt b.txt"
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
echo "Wrote $OUTPUT_FILE" >&2
|
||||
fi
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Read Psysonic app config from WebKit localStorage + XDG dirs.
|
||||
* Emits key=value lines (passwords/secrets redacted; header values hashed).
|
||||
*
|
||||
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--app-id' && argv[i + 1]) {
|
||||
out.appId = argv[++i];
|
||||
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
|
||||
out.repoRoot = argv[++i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function emitSection(name) {
|
||||
process.stdout.write(`\n[${name}]\n`);
|
||||
}
|
||||
|
||||
function emit(key, value) {
|
||||
const v = String(value ?? '').replace(/\n/g, '\\n');
|
||||
process.stdout.write(`${key}=${v}\n`);
|
||||
}
|
||||
|
||||
function readAppIdFromRepo(repoRoot) {
|
||||
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
|
||||
if (!fs.existsSync(conf)) return '';
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
|
||||
return typeof j.identifier === 'string' ? j.identifier : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function xdgDataHome() {
|
||||
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
}
|
||||
|
||||
function xdgConfigHome() {
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
function decodeWebKitValue(raw) {
|
||||
if (raw == null) return null;
|
||||
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
|
||||
if (buf) {
|
||||
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
||||
return new TextDecoder('utf-16le').decode(buf.subarray(2));
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
||||
return new TextDecoder('utf-16be').decode(buf.subarray(2));
|
||||
}
|
||||
// WebKit often stores UTF-16LE without BOM
|
||||
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
|
||||
return new TextDecoder('utf-16le').decode(buf);
|
||||
}
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
if (typeof raw === 'string') return raw;
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function readLocalStorageRaw(dbPath, storageKey) {
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
|
||||
db.close();
|
||||
if (!row?.value) return null;
|
||||
return decodeWebKitValue(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalStorageKey(dbPath, storageKey) {
|
||||
const text = readLocalStorageRaw(dbPath, storageKey);
|
||||
if (text == null) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function pickLocalStorageFile(dataDir) {
|
||||
const dir = path.join(dataDir, 'localstorage');
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
|
||||
.map(f => path.join(dir, f));
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Prefer packaged app origin over vite dev (1420) when both exist.
|
||||
const ranked = files.sort((a, b) => {
|
||||
const score = p => {
|
||||
const base = path.basename(p);
|
||||
if (base.includes('tauri_localhost')) return 0;
|
||||
if (base.includes('1420')) return 2;
|
||||
return 1;
|
||||
};
|
||||
return score(a) - score(b);
|
||||
});
|
||||
|
||||
for (const file of ranked) {
|
||||
const auth = readLocalStorageKey(file, 'psysonic-auth');
|
||||
if (auth?.state?.servers?.length) return file;
|
||||
}
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
function probeHttpReachability(rawUrl) {
|
||||
if (!rawUrl) return 'empty';
|
||||
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
|
||||
const r = spawnSync(
|
||||
'curl',
|
||||
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
|
||||
if (r.status !== 0) return `curl_exit_${r.status}`;
|
||||
return `http_${r.stdout.trim() || '000'}`;
|
||||
}
|
||||
|
||||
function dumpNetworkProbes(servers) {
|
||||
emitSection('app_network_probe');
|
||||
const seen = new Set();
|
||||
servers.forEach((srv, i) => {
|
||||
for (const [kind, raw] of [
|
||||
['url', srv.url],
|
||||
['alternateUrl', srv.alternateUrl],
|
||||
]) {
|
||||
if (!raw || seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
|
||||
emit(`probe.${i}.${kind}_target`, raw);
|
||||
}
|
||||
});
|
||||
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
|
||||
}
|
||||
|
||||
function dumpServerProfiles(state) {
|
||||
const servers = state?.servers ?? [];
|
||||
emit('active_server_id', state?.activeServerId ?? '');
|
||||
emit('server_count', servers.length);
|
||||
servers.forEach((srv, i) => {
|
||||
const p = `server.${i}`;
|
||||
emit(`${p}.id`, srv.id ?? '');
|
||||
emit(`${p}.name`, srv.name ?? '');
|
||||
emit(`${p}.url`, srv.url ?? '');
|
||||
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
|
||||
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
|
||||
emit(`${p}.username`, srv.username ?? '');
|
||||
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
|
||||
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
|
||||
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
|
||||
const headers = srv.customHeaders ?? [];
|
||||
emit(`${p}.customHeaders_count`, headers.length);
|
||||
headers.forEach((h, hi) => {
|
||||
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
|
||||
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
|
||||
});
|
||||
});
|
||||
dumpNetworkProbes(servers);
|
||||
}
|
||||
|
||||
function fileMeta(label, filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
const st = fs.statSync(filePath);
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, filePath);
|
||||
emit(`${label}_size`, st.size);
|
||||
emit(`${label}_mtime`, st.mtime.toISOString());
|
||||
}
|
||||
|
||||
function listDir(label, dirPath, max = 12) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
emit(`${label}_exists`, 'no');
|
||||
return;
|
||||
}
|
||||
emit(`${label}_exists`, 'yes');
|
||||
emit(`${label}_path`, dirPath);
|
||||
const entries = fs.readdirSync(dirPath).sort();
|
||||
emit(`${label}_entry_count`, entries.length);
|
||||
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
|
||||
}
|
||||
|
||||
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
|
||||
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
|
||||
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
|
||||
|
||||
const dataDir = path.join(xdgDataHome(), appId);
|
||||
const configDir = path.join(xdgConfigHome(), appId);
|
||||
|
||||
emitSection('app_config_paths');
|
||||
emit('app_id', appId);
|
||||
emit('data_dir', dataDir);
|
||||
emit('config_dir', configDir);
|
||||
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
|
||||
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
|
||||
|
||||
const lsFile = pickLocalStorageFile(dataDir);
|
||||
fileMeta('localstorage_db', lsFile);
|
||||
|
||||
emitSection('app_preferences');
|
||||
if (lsFile) {
|
||||
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
|
||||
emit('language', lang ?? 'unknown');
|
||||
|
||||
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
|
||||
if (authWrap?.state) {
|
||||
emitSection('app_servers');
|
||||
dumpServerProfiles(authWrap.state);
|
||||
} else {
|
||||
emit('app_servers_status', 'psysonic-auth not found or empty');
|
||||
}
|
||||
} else {
|
||||
emit('app_servers_status', 'no localstorage database found');
|
||||
}
|
||||
|
||||
emitSection('app_config_files');
|
||||
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
|
||||
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
|
||||
}
|
||||
|
||||
emitSection('app_data_artifacts');
|
||||
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
|
||||
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
|
||||
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
|
||||
@@ -0,0 +1,2 @@
|
||||
-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak).
|
||||
ALTER TABLE track ADD COLUMN replay_gain_peak REAL;
|
||||
@@ -1438,6 +1438,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -213,6 +213,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -208,6 +208,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -1492,6 +1492,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: Some(format!("hash-{id}")),
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -212,6 +212,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -106,6 +106,7 @@ pub struct LibraryTrackDto {
|
||||
pub bpm_source: Option<String>,
|
||||
pub replay_gain_track_db: Option<f64>,
|
||||
pub replay_gain_album_db: Option<f64>,
|
||||
pub replay_gain_peak: Option<f64>,
|
||||
|
||||
pub server_updated_at: Option<i64>,
|
||||
pub server_created_at: Option<i64>,
|
||||
@@ -157,6 +158,7 @@ impl LibraryTrackDto {
|
||||
bpm_source: None,
|
||||
replay_gain_track_db: row.replay_gain_track_db,
|
||||
replay_gain_album_db: row.replay_gain_album_db,
|
||||
replay_gain_peak: row.replay_gain_peak,
|
||||
server_updated_at: row.server_updated_at,
|
||||
server_created_at: row.server_created_at,
|
||||
synced_at: row.synced_at,
|
||||
@@ -750,6 +752,7 @@ mod tests {
|
||||
bpm: Some(120),
|
||||
replay_gain_track_db: Some(-1.2),
|
||||
replay_gain_album_db: Some(-0.8),
|
||||
replay_gain_peak: Some(0.95),
|
||||
content_hash: Some("deadbeef".into()),
|
||||
server_updated_at: Some(1_700_000_000),
|
||||
server_created_at: Some(1_699_000_000),
|
||||
@@ -780,6 +783,7 @@ mod tests {
|
||||
"mbidRecording",
|
||||
"replayGainTrackDb",
|
||||
"replayGainAlbumDb",
|
||||
"replayGainPeak",
|
||||
"serverUpdatedAt",
|
||||
"syncedAt",
|
||||
"rawJson",
|
||||
|
||||
@@ -230,6 +230,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -288,6 +288,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -375,6 +375,7 @@ fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<
|
||||
mbid_recording: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
synced_at: row.get(offset + 21)?,
|
||||
@@ -429,6 +430,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -180,6 +180,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -233,6 +233,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -34,6 +34,7 @@ fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, duration_se
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -79,6 +80,7 @@ fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: if hash.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -503,6 +505,7 @@ fn recent_plays_includes_album_cover_metadata() {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct TrackRow {
|
||||
pub bpm: Option<i64>,
|
||||
pub replay_gain_track_db: Option<f64>,
|
||||
pub replay_gain_album_db: Option<f64>,
|
||||
pub replay_gain_peak: Option<f64>,
|
||||
pub content_hash: Option<String>,
|
||||
pub server_updated_at: Option<i64>,
|
||||
pub server_created_at: Option<i64>,
|
||||
@@ -150,6 +151,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.bpm,
|
||||
r.replay_gain_track_db,
|
||||
r.replay_gain_album_db,
|
||||
r.replay_gain_peak,
|
||||
r.content_hash,
|
||||
r.server_updated_at,
|
||||
r.server_created_at,
|
||||
@@ -189,6 +191,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.bpm,
|
||||
r.replay_gain_track_db,
|
||||
r.replay_gain_album_db,
|
||||
r.replay_gain_peak,
|
||||
r.content_hash,
|
||||
r.server_updated_at,
|
||||
r.server_created_at,
|
||||
@@ -499,6 +502,7 @@ impl<'a> TrackRepository<'a> {
|
||||
r.bpm,
|
||||
r.replay_gain_track_db,
|
||||
r.replay_gain_album_db,
|
||||
r.replay_gain_peak,
|
||||
r.content_hash,
|
||||
r.server_updated_at,
|
||||
r.server_created_at,
|
||||
@@ -673,27 +677,27 @@ const TRACK_COLUMNS: &str = "\
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, \
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm, \
|
||||
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, \
|
||||
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at, \
|
||||
server_created_at, deleted, synced_at, raw_json";
|
||||
|
||||
const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE id = ?1 AND deleted = 0";
|
||||
|
||||
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
|
||||
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
|
||||
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
|
||||
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
|
||||
FROM track WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 \
|
||||
ORDER BY disc_number ASC NULLS LAST, track_number ASC NULLS LAST, id ASC";
|
||||
@@ -733,12 +737,13 @@ pub(crate) fn row_to_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Trac
|
||||
bpm: row.get(26)?,
|
||||
replay_gain_track_db: row.get(27)?,
|
||||
replay_gain_album_db: row.get(28)?,
|
||||
content_hash: row.get(29)?,
|
||||
server_updated_at: row.get(30)?,
|
||||
server_created_at: row.get(31)?,
|
||||
deleted: row.get::<_, i64>(32)? != 0,
|
||||
synced_at: row.get(33)?,
|
||||
raw_json: row.get(34)?,
|
||||
replay_gain_peak: row.get(29)?,
|
||||
content_hash: row.get(30)?,
|
||||
server_updated_at: row.get(31)?,
|
||||
server_created_at: row.get(32)?,
|
||||
deleted: row.get::<_, i64>(33)? != 0,
|
||||
synced_at: row.get(34)?,
|
||||
raw_json: row.get(35)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -748,60 +753,8 @@ INSERT INTO track (
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm,
|
||||
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
|
||||
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
|
||||
server_created_at, deleted, synced_at, raw_json
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
|
||||
?33, ?34, ?35
|
||||
)
|
||||
ON CONFLICT(server_id, id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
title_sort = excluded.title_sort,
|
||||
artist = excluded.artist,
|
||||
artist_id = excluded.artist_id,
|
||||
album = excluded.album,
|
||||
album_id = excluded.album_id,
|
||||
album_artist = excluded.album_artist,
|
||||
duration_sec = excluded.duration_sec,
|
||||
track_number = excluded.track_number,
|
||||
disc_number = excluded.disc_number,
|
||||
year = excluded.year,
|
||||
genre = excluded.genre,
|
||||
suffix = excluded.suffix,
|
||||
bit_rate = excluded.bit_rate,
|
||||
size_bytes = excluded.size_bytes,
|
||||
cover_art_id = excluded.cover_art_id,
|
||||
starred_at = excluded.starred_at,
|
||||
user_rating = excluded.user_rating,
|
||||
play_count = excluded.play_count,
|
||||
played_at = excluded.played_at,
|
||||
server_path = excluded.server_path,
|
||||
library_id = excluded.library_id,
|
||||
isrc = excluded.isrc,
|
||||
mbid_recording = excluded.mbid_recording,
|
||||
bpm = excluded.bpm,
|
||||
replay_gain_track_db = excluded.replay_gain_track_db,
|
||||
replay_gain_album_db = excluded.replay_gain_album_db,
|
||||
-- E2: never let a sync (which passes NULL content_hash) clobber the
|
||||
-- playback-derived md5_16kb written via library_patch_track / the analysis
|
||||
-- bridge. A non-empty incoming hash still wins.
|
||||
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
|
||||
server_updated_at = excluded.server_updated_at,
|
||||
server_created_at = excluded.server_created_at,
|
||||
deleted = excluded.deleted,
|
||||
synced_at = excluded.synced_at,
|
||||
raw_json = excluded.raw_json
|
||||
"#;
|
||||
|
||||
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
|
||||
INSERT INTO track (
|
||||
server_id, id, title, title_sort, artist, artist_id, album, album_id,
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm,
|
||||
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
|
||||
server_created_at, deleted, synced_at, raw_json, resync_gen
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
|
||||
@@ -835,6 +788,60 @@ ON CONFLICT(server_id, id) DO UPDATE SET
|
||||
bpm = excluded.bpm,
|
||||
replay_gain_track_db = excluded.replay_gain_track_db,
|
||||
replay_gain_album_db = excluded.replay_gain_album_db,
|
||||
replay_gain_peak = excluded.replay_gain_peak,
|
||||
-- E2: never let a sync (which passes NULL content_hash) clobber the
|
||||
-- playback-derived md5_16kb written via library_patch_track / the analysis
|
||||
-- bridge. A non-empty incoming hash still wins.
|
||||
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
|
||||
server_updated_at = excluded.server_updated_at,
|
||||
server_created_at = excluded.server_created_at,
|
||||
deleted = excluded.deleted,
|
||||
synced_at = excluded.synced_at,
|
||||
raw_json = excluded.raw_json
|
||||
"#;
|
||||
|
||||
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
|
||||
INSERT INTO track (
|
||||
server_id, id, title, title_sort, artist, artist_id, album, album_id,
|
||||
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
|
||||
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
|
||||
played_at, server_path, library_id, isrc, mbid_recording, bpm,
|
||||
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
|
||||
server_created_at, deleted, synced_at, raw_json, resync_gen
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
|
||||
?33, ?34, ?35, ?36, ?37
|
||||
)
|
||||
ON CONFLICT(server_id, id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
title_sort = excluded.title_sort,
|
||||
artist = excluded.artist,
|
||||
artist_id = excluded.artist_id,
|
||||
album = excluded.album,
|
||||
album_id = excluded.album_id,
|
||||
album_artist = excluded.album_artist,
|
||||
duration_sec = excluded.duration_sec,
|
||||
track_number = excluded.track_number,
|
||||
disc_number = excluded.disc_number,
|
||||
year = excluded.year,
|
||||
genre = excluded.genre,
|
||||
suffix = excluded.suffix,
|
||||
bit_rate = excluded.bit_rate,
|
||||
size_bytes = excluded.size_bytes,
|
||||
cover_art_id = excluded.cover_art_id,
|
||||
starred_at = excluded.starred_at,
|
||||
user_rating = excluded.user_rating,
|
||||
play_count = excluded.play_count,
|
||||
played_at = excluded.played_at,
|
||||
server_path = excluded.server_path,
|
||||
library_id = excluded.library_id,
|
||||
isrc = excluded.isrc,
|
||||
mbid_recording = excluded.mbid_recording,
|
||||
bpm = excluded.bpm,
|
||||
replay_gain_track_db = excluded.replay_gain_track_db,
|
||||
replay_gain_album_db = excluded.replay_gain_album_db,
|
||||
replay_gain_peak = excluded.replay_gain_peak,
|
||||
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
|
||||
server_updated_at = excluded.server_updated_at,
|
||||
server_created_at = excluded.server_created_at,
|
||||
@@ -887,6 +894,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: Some("hash-abc".into()),
|
||||
server_updated_at: Some(1_700_000_000),
|
||||
server_created_at: Some(1_699_000_000),
|
||||
|
||||
@@ -356,6 +356,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -12,11 +12,14 @@ use tauri::Manager;
|
||||
///
|
||||
/// Migration checklist (wiring, data backfill, open/swap path):
|
||||
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 14;
|
||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 15;
|
||||
|
||||
/// One-time data repair after migration 014 (`artist.name_sort`).
|
||||
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
|
||||
|
||||
/// One-time backfill after migration 015 (`track.replay_gain_peak`).
|
||||
pub(crate) const REPLAY_GAIN_PEAK_RECONCILE_ID: &str = "replay_gain_peak_reconcile_v1";
|
||||
|
||||
/// Lowest applied schema version the current code can advance from purely
|
||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
||||
/// fires (spec §5.7 / P22): the library is treated as incompatible, must be
|
||||
@@ -38,6 +41,8 @@ pub(crate) const MIGRATION_013_ARTIST_ARTWORK_LOOKUP: &str =
|
||||
include_str!("../migrations/013_artist_artwork_lookup.sql");
|
||||
pub(crate) const MIGRATION_014_ARTIST_NAME_SORT: &str =
|
||||
include_str!("../migrations/014_artist_name_sort.sql");
|
||||
pub(crate) const MIGRATION_015_REPLAY_GAIN_PEAK: &str =
|
||||
include_str!("../migrations/015_replay_gain_peak.sql");
|
||||
|
||||
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
|
||||
/// defensively before applying so the source order can stay readable.
|
||||
@@ -46,6 +51,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
|
||||
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
|
||||
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
|
||||
(14, MIGRATION_014_ARTIST_NAME_SORT),
|
||||
(15, MIGRATION_015_REPLAY_GAIN_PEAK),
|
||||
];
|
||||
|
||||
/// Idempotent repair — also runs after the migration runner on every open so
|
||||
@@ -589,6 +595,7 @@ fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Co
|
||||
fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> {
|
||||
run_migrations(conn)?;
|
||||
maybe_reconcile_artist_name_sort(conn)?;
|
||||
maybe_reconcile_replay_gain_peak(conn)?;
|
||||
ensure_genre_tags_schema(conn)?;
|
||||
checkpoint_wal_conn(conn, "open")?;
|
||||
Ok(())
|
||||
@@ -721,6 +728,68 @@ fn repair_artist_name_sort_keys(conn: &Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replay_gain_peak_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let column_exists: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('track') WHERE name = 'replay_gain_peak'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(column_exists > 0)
|
||||
}
|
||||
|
||||
fn replay_gain_peak_reconcile_completed(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let completed: Option<Option<i64>> = conn
|
||||
.query_row(
|
||||
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
|
||||
params![REPLAY_GAIN_PEAK_RECONCILE_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(completed.flatten().is_some())
|
||||
}
|
||||
|
||||
fn mark_replay_gain_peak_reconcile_completed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
|
||||
VALUES (?1, 0, strftime('%s','now'), strftime('%s','now')) \
|
||||
ON CONFLICT(id) DO UPDATE SET completed_at = excluded.completed_at",
|
||||
params![REPLAY_GAIN_PEAK_RECONCILE_ID],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-time backfill after schema 015 — project peak from stored `raw_json`.
|
||||
fn repair_replay_gain_peak_from_raw_json(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE track SET replay_gain_peak = json_extract(raw_json, '$.replayGain.trackPeak') \
|
||||
WHERE replay_gain_peak IS NULL \
|
||||
AND json_type(json_extract(raw_json, '$.replayGain.trackPeak')) = 'real'",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE track SET replay_gain_peak = json_extract(raw_json, '$.rgTrackPeak') \
|
||||
WHERE replay_gain_peak IS NULL \
|
||||
AND json_type(json_extract(raw_json, '$.rgTrackPeak')) = 'real'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One-time reconcile after schema 015 — not on every open.
|
||||
fn maybe_reconcile_replay_gain_peak(conn: &Connection) -> rusqlite::Result<()> {
|
||||
if !replay_gain_peak_column_exists(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
if replay_gain_peak_reconcile_completed(conn)? {
|
||||
return Ok(());
|
||||
}
|
||||
repair_replay_gain_peak_from_raw_json(conn)?;
|
||||
mark_replay_gain_peak_reconcile_completed(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
|
||||
run_migrations_with(
|
||||
conn,
|
||||
|
||||
@@ -639,6 +639,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: Some(server_updated_at),
|
||||
server_created_at: None,
|
||||
|
||||
@@ -2265,6 +2265,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -2306,6 +2307,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -70,6 +70,10 @@ pub fn subsonic_song_to_track_row(
|
||||
.get("replayGain")
|
||||
.and_then(|rg| rg.get("albumGain"))
|
||||
.and_then(|v| v.as_f64()),
|
||||
replay_gain_peak: raw_value
|
||||
.get("replayGain")
|
||||
.and_then(|rg| rg.get("trackPeak"))
|
||||
.and_then(|v| v.as_f64()),
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
@@ -134,6 +138,7 @@ pub fn navidrome_song_to_track_row(
|
||||
bpm: raw.get("bpm").and_then(|v| v.as_i64()),
|
||||
replay_gain_track_db: raw.get("rgTrackGain").and_then(|v| v.as_f64()),
|
||||
replay_gain_album_db: raw.get("rgAlbumGain").and_then(|v| v.as_f64()),
|
||||
replay_gain_peak: raw.get("rgTrackPeak").and_then(|v| v.as_f64()),
|
||||
content_hash: None,
|
||||
server_updated_at,
|
||||
server_created_at: raw
|
||||
@@ -258,7 +263,7 @@ mod tests {
|
||||
"track": 3,
|
||||
"year": 2024,
|
||||
"musicBrainzId": "mb-1",
|
||||
"replayGain": { "trackGain": -1.2, "albumGain": -0.8 }
|
||||
"replayGain": { "trackGain": -1.2, "albumGain": -0.8, "trackPeak": 0.91 }
|
||||
});
|
||||
let song: Song = serde_json::from_value(raw.clone()).unwrap();
|
||||
let row = subsonic_song_to_track_row("s1", &song, &raw, 1_000, Some("lib-fb"));
|
||||
@@ -268,6 +273,7 @@ mod tests {
|
||||
assert_eq!(row.mbid_recording.as_deref(), Some("mb-1"));
|
||||
assert_eq!(row.replay_gain_track_db, Some(-1.2));
|
||||
assert_eq!(row.replay_gain_album_db, Some(-0.8));
|
||||
assert_eq!(row.replay_gain_peak, Some(0.91));
|
||||
// Fallback library_id kicks in when the song didn't ship one.
|
||||
assert_eq!(row.library_id.as_deref(), Some("lib-fb"));
|
||||
assert!(row.raw_json.contains("replayGain"));
|
||||
|
||||
@@ -271,6 +271,7 @@ mod tests {
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
replay_gain_peak: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
* `artistInfo` (bio / similar) has no index source and stays network-only — it
|
||||
* is intentionally absent here.
|
||||
*/
|
||||
import { libraryGetTrack, libraryGetTracksByAlbum } from '@/lib/api/library';
|
||||
import { libraryGetTracksByAlbum } from '@/lib/api/library';
|
||||
import { getArtistForServer, getTopSongsForServer } from '@/lib/api/subsonicArtists';
|
||||
import { getAlbumForServer, getSongForServer } from '@/lib/api/subsonicLibrary';
|
||||
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
|
||||
import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGuard';
|
||||
import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex } from '@/features/offline';
|
||||
@@ -96,13 +97,5 @@ export async function resolveNpSongMeta(
|
||||
serverId: string,
|
||||
songId: string,
|
||||
): Promise<SubsonicSong | null> {
|
||||
if (await libraryIsReady(serverId)) {
|
||||
try {
|
||||
const dto = await libraryGetTrack(serverId, songId);
|
||||
if (dto) return trackToSong(dto);
|
||||
} catch { /* index error → network fallback */ }
|
||||
}
|
||||
// Network arm keeps its own byte-style guard (`shouldAttemptSubsonicForServer`
|
||||
// with the trackId + psysonic-local:// skip) — unchanged from #1042.
|
||||
return getSongForServer(serverId, songId);
|
||||
return resolveSongMetaIndexFirst(serverId, songId);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export function libraryDtoToTrack(dto: LibraryTrackDto): Track {
|
||||
genre: song.genre,
|
||||
replayGainTrackDb: dto.replayGainTrackDb ?? undefined,
|
||||
replayGainAlbumDb: dto.replayGainAlbumDb ?? undefined,
|
||||
replayGainPeak: dto.replayGainPeak ?? undefined,
|
||||
size: song.size,
|
||||
serverId: dto.serverId,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { scrobbleSong } from '@/lib/api/subsonicScrobble';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
playbackReportPlaying,
|
||||
playbackReportStart,
|
||||
playbackReportStopped,
|
||||
} from '@/features/playback/store/playbackReportSession';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
@@ -13,7 +11,6 @@ import { notifyLibraryPlaybackHint } from '@/features/playback/store/libraryPlay
|
||||
import {
|
||||
playListenSessionFinalize,
|
||||
playListenSessionOnProgress,
|
||||
playListenSessionOnTrackSwitched,
|
||||
playListenSessionOpen,
|
||||
} from '@/features/playback/store/playListenSession';
|
||||
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
|
||||
@@ -27,8 +24,12 @@ import {
|
||||
playbackProfileIdForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||
import { requestGaplessChainPreload } from '@/features/playback/store/gaplessChainPreload';
|
||||
import {
|
||||
applyGaplessQueueAdvance,
|
||||
maybeReconcileGaplessFromProgress,
|
||||
} from '@/features/playback/store/gaplessQueueAdvance';
|
||||
import { noteEngineProgressForGapless } from '@/features/playback/store/gaplessProgressTracking';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
|
||||
@@ -39,16 +40,8 @@ import {
|
||||
getLastGaplessSwitchTime,
|
||||
markGaplessSwitch,
|
||||
setBytePreloadingId,
|
||||
setGaplessPreloadingId,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
|
||||
import {
|
||||
isReplayGainActive,
|
||||
loudnessGainDbForEngineBind,
|
||||
} from '@/features/playback/store/loudnessGainCache';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import {
|
||||
emitPlaybackProgress,
|
||||
getPlaybackProgressSnapshot,
|
||||
@@ -64,15 +57,11 @@ import {
|
||||
markStoreProgressCommit,
|
||||
resetProgressEmitThrottles,
|
||||
} from '@/features/playback/store/playbackThrottles';
|
||||
import {
|
||||
playbackSourceHintForResolvedUrl,
|
||||
} from '@/features/playback/store/playbackUrlRouting';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
||||
import {
|
||||
flushQueueSyncToServer,
|
||||
getLastQueueHeartbeatAt,
|
||||
syncQueueToServer,
|
||||
flushQueueSyncToServer,
|
||||
} from '@/features/playback/store/queueSync';
|
||||
import { clearQueueNaturallyEnded } from '@/features/playback/store/queuePlaybackIdle';
|
||||
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
|
||||
@@ -87,7 +76,6 @@ import {
|
||||
getSeekTarget,
|
||||
getSeekTargetSetAt,
|
||||
} from '@/features/playback/store/seekTargetState';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { analyzeBoundary, computeWaveformSilence } from '@/lib/waveform/waveformSilence';
|
||||
import { autodjMaxOverlapCapSec } from '@/lib/audio/autodjOverlapCap';
|
||||
import {
|
||||
@@ -167,12 +155,16 @@ export function handleAudioProgress(
|
||||
clearSeekTarget();
|
||||
} else {
|
||||
clearSeekTarget();
|
||||
noteEngineProgressForGapless(current_time);
|
||||
}
|
||||
}
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track) return;
|
||||
if (!buffering) {
|
||||
maybeReconcileGaplessFromProgress(current_time, duration);
|
||||
}
|
||||
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
|
||||
usePlayerStore.setState({ isPlaybackBuffering: buffering });
|
||||
}
|
||||
@@ -201,6 +193,9 @@ export function handleAudioProgress(
|
||||
}
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
if (!buffering) {
|
||||
noteEngineProgressForGapless(current_time);
|
||||
}
|
||||
const progress = displayTime / dur;
|
||||
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
|
||||
if (!progressUiDisabled) {
|
||||
@@ -374,90 +369,69 @@ export function handleAudioProgress(
|
||||
const nextTrack = repeatMode === 'one'
|
||||
? track
|
||||
: (nextRef ? resolveQueueTrack(nextRef) : null);
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||
// the boundary. Start earlier for larger files / slower conservative link.
|
||||
const estBytes = (() => {
|
||||
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||||
return nextTrack.size;
|
||||
if (nextTrack && nextTrack.id !== track.id) {
|
||||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||
// the boundary. Start earlier for larger files / slower conservative link.
|
||||
const estBytes = (() => {
|
||||
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||||
return nextTrack.size;
|
||||
}
|
||||
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||||
? nextTrack.bitRate
|
||||
: 320;
|
||||
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||||
})();
|
||||
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||||
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||||
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||||
const shouldBytePreloadForGaplessBackup =
|
||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||
|
||||
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
|
||||
const analysisServerId = nextRef
|
||||
? playbackCacheKeyForRef(nextRef)
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
||||
if (
|
||||
shouldBytePreloadForGaplessBackup
|
||||
&& nextTrack.id !== getBytePreloadingId()
|
||||
) {
|
||||
setBytePreloadingId(nextTrack.id);
|
||||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||||
// waveformBins and would replace the current track's seekbar while still playing it.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
nextUrl,
|
||||
shouldBytePreloadForGaplessBackup,
|
||||
remaining,
|
||||
gaplessEnabled,
|
||||
});
|
||||
}
|
||||
invoke('audio_preload', {
|
||||
url: nextUrl,
|
||||
durationHint: nextTrack.duration,
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||||
? nextTrack.bitRate
|
||||
: 320;
|
||||
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||||
})();
|
||||
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||||
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||||
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||||
const shouldBytePreloadForGaplessBackup =
|
||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||
|
||||
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
|
||||
const analysisServerId = nextRef
|
||||
? playbackCacheKeyForRef(nextRef)
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
|
||||
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
||||
if (
|
||||
shouldBytePreloadForGaplessBackup
|
||||
&& nextTrack.id !== getBytePreloadingId()
|
||||
) {
|
||||
setBytePreloadingId(nextTrack.id);
|
||||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||||
// waveformBins and would replace the current track's seekbar while still playing it.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
nextUrl,
|
||||
shouldBytePreloadForGaplessBackup,
|
||||
remaining,
|
||||
gaplessEnabled,
|
||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
|
||||
requestGaplessChainPreload({
|
||||
currentTrack: track,
|
||||
nextTrack,
|
||||
nextRef,
|
||||
nextIdx,
|
||||
queueItems,
|
||||
repeatMode,
|
||||
volume: store.volume,
|
||||
});
|
||||
}
|
||||
invoke('audio_preload', {
|
||||
url: nextUrl,
|
||||
durationHint: nextTrack.duration,
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
|
||||
setGaplessPreloadingId(nextTrack.id);
|
||||
// Ensure loudness gain is already cached for the chained request payload.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
const authState = useAuthStore.getState();
|
||||
// Auto-mode neighbours for the *next* track: current track on its left,
|
||||
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
|
||||
// — only its replaygain tags matter, which a placeholder lacks → fallback).
|
||||
const nextNeighbourRef = nextIdx + 1 < queueItems.length
|
||||
? queueItems[nextIdx + 1]
|
||||
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||||
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
nextTrack, track, nextNeighbour,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive()
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
...audioPlayHiResBlendArgs(authState),
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,104 +506,20 @@ export function handleAudioEnded(): void {
|
||||
* next source sample-accurately. We just need to update the UI state without
|
||||
* touching the audio stream (no playTrack() call!).
|
||||
*/
|
||||
export function handleAudioTrackSwitched(_duration: number): void {
|
||||
export function handleAudioTrackSwitched(duration: number): void {
|
||||
markGaplessSwitch();
|
||||
clearPreloadingIds(); // allow preloading for the track after this one
|
||||
clearPreloadingIds();
|
||||
setIsAudioPaused(false);
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
if (store.currentTrack?.id) {
|
||||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||||
}
|
||||
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
|
||||
if (currentTrack && !currentRadio) {
|
||||
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
|
||||
}
|
||||
const nextIdx = queueIndex + 1;
|
||||
let nextTrack: Track | null = null;
|
||||
let newIndex = queueIndex;
|
||||
|
||||
if (repeatMode === 'one' && store.currentTrack) {
|
||||
nextTrack = store.currentTrack;
|
||||
// queueIndex stays the same
|
||||
} else if (nextIdx < queueItems.length) {
|
||||
// The Rust engine already chained this source sample-accurately, so it must
|
||||
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
|
||||
// returns the full Track from cache (placeholder only on an unexpected miss).
|
||||
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
|
||||
newIndex = nextIdx;
|
||||
} else if (repeatMode === 'all' && queueItems.length > 0) {
|
||||
nextTrack = resolveQueueTrack(queueItems[0]);
|
||||
newIndex = 0;
|
||||
}
|
||||
|
||||
if (!nextTrack) return;
|
||||
|
||||
void playListenSessionOnTrackSwitched(nextTrack);
|
||||
|
||||
const switchRef = queueItems[newIndex];
|
||||
const switchServerId = playbackCacheKeyForRef(switchRef);
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||
|
||||
// Neighbour window for normalization (replaygain album-mode reads prev/next).
|
||||
// current track on the left, the track after `nextTrack` on the right.
|
||||
const switchPrev = store.currentTrack;
|
||||
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
|
||||
const switchNeighbourWindow: Track[] = [
|
||||
switchPrev ?? nextTrack,
|
||||
nextTrack,
|
||||
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
|
||||
];
|
||||
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
|
||||
normalizationDbgSource: 'track-switched',
|
||||
normalizationDbgTrackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
isPlaying: true,
|
||||
isPlaybackBuffering: switchPlaybackSource === 'stream',
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: false,
|
||||
networkLoved: false,
|
||||
currentPlaybackSource: switchPlaybackSource,
|
||||
applyGaplessQueueAdvance({
|
||||
engineDurationHint: duration,
|
||||
source: 'track-switched',
|
||||
});
|
||||
emitNormalizationDebug('track-switched', {
|
||||
trackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
engineRequested: useAuthStore.getState().normalizationEngine,
|
||||
});
|
||||
void refreshWaveformForTrack(nextTrack.id);
|
||||
void refreshLoudnessForTrack(nextTrack.id);
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
|
||||
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
|
||||
// now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
|
||||
// on the master toggle, per-account enable and the nowPlaying capability
|
||||
// internally). playbackReportStart opens the FSM on extension-capable servers
|
||||
// and falls back to the legacy presence call otherwise (gating is internal).
|
||||
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
void runtime?.dispatchNowPlaying({
|
||||
title: nextTrack.title,
|
||||
artist: nextTrack.artist,
|
||||
album: nextTrack.album,
|
||||
duration: nextTrack.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (runtime?.getEnrichmentPrimaryId()) {
|
||||
void runtime
|
||||
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
|
||||
.then(loved => {
|
||||
usePlayerStore.getState().setNetworkLoved(loved);
|
||||
});
|
||||
}
|
||||
syncQueueToServer(queueItems, nextTrack, 0);
|
||||
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
|
||||
}
|
||||
|
||||
export function handleAudioError(message: string): void {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
_resetGaplessPreloadStateForTest,
|
||||
getGaplessPreloadingId,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
import {
|
||||
_resetGaplessChainPrepareInflightForTest,
|
||||
requestGaplessChainPreload,
|
||||
} from '@/features/playback/store/gaplessChainPreload';
|
||||
|
||||
const prepareMock = vi.hoisted(() =>
|
||||
vi.fn(async (track: Track) => ({
|
||||
...track,
|
||||
title: 'Next indexed',
|
||||
duration: 180,
|
||||
replayGainTrackDb: -5.5,
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
|
||||
prepareTrackForEngineBind: prepareMock,
|
||||
}));
|
||||
|
||||
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
|
||||
|
||||
const track = (id: string, extra: Partial<Track> = {}): Track => ({
|
||||
id,
|
||||
title: extra.title ?? `Track ${id}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'alb-1',
|
||||
duration: extra.duration ?? 200,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('requestGaplessChainPreload', () => {
|
||||
beforeEach(() => {
|
||||
_resetGaplessPreloadStateForTest();
|
||||
_resetGaplessChainPrepareInflightForTest();
|
||||
prepareMock.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
onInvoke('audio_chain_preload', () => undefined);
|
||||
useAuthStore.setState({
|
||||
activeServerId: 's1',
|
||||
servers: [{
|
||||
id: 's1',
|
||||
name: 'Test',
|
||||
url: 'https://music.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
}],
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
replayGainMode: 'track',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: -6,
|
||||
gaplessEnabled: true,
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track('t1'),
|
||||
queueItems: [ref('t1'), ref('t2')],
|
||||
queueIndex: 0,
|
||||
repeatMode: 'off',
|
||||
isPlaying: true,
|
||||
currentRadio: null,
|
||||
volume: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefetches metadata then chains with ReplayGain from prepare', async () => {
|
||||
requestGaplessChainPreload({
|
||||
currentTrack: track('t1'),
|
||||
nextTrack: track('t2', { title: '…', duration: 0 }),
|
||||
nextRef: ref('t2'),
|
||||
nextIdx: 1,
|
||||
queueItems: [ref('t1'), ref('t2')],
|
||||
repeatMode: 'off',
|
||||
volume: 0.75,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getGaplessPreloadingId()).toBe('t2');
|
||||
});
|
||||
|
||||
expect(prepareMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 't2' }),
|
||||
'music.example',
|
||||
);
|
||||
const chainCalls = invokeMock.mock.calls.filter(c => c[0] === 'audio_chain_preload');
|
||||
expect(chainCalls.length).toBe(1);
|
||||
expect(chainCalls[0]?.[1]).toMatchObject({
|
||||
replayGainDb: -5.5,
|
||||
durationHint: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('no-ops when playback context changed before prepare finished', async () => {
|
||||
requestGaplessChainPreload({
|
||||
currentTrack: track('t1'),
|
||||
nextTrack: track('t2', { title: '…', duration: 0 }),
|
||||
nextRef: ref('t2'),
|
||||
nextIdx: 1,
|
||||
queueItems: [ref('t1'), ref('t2')],
|
||||
repeatMode: 'off',
|
||||
volume: 0.75,
|
||||
});
|
||||
|
||||
usePlayerStore.setState({ currentTrack: track('t99', { id: 't99' }) });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(prepareMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(getGaplessPreloadingId()).toBeNull();
|
||||
expect(invokeMock.mock.calls.filter(c => c[0] === 'audio_chain_preload').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import {
|
||||
getPlaybackCacheServerKey,
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForRef,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
getGaplessPreloadingId,
|
||||
setGaplessPreloadingId,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
import {
|
||||
isReplayGainActive,
|
||||
loudnessGainDbForEngineBind,
|
||||
} from '@/features/playback/store/loudnessGainCache';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
export type GaplessChainPreloadContext = {
|
||||
currentTrack: Track;
|
||||
nextTrack: Track;
|
||||
nextRef: QueueItemRef | null;
|
||||
nextIdx: number;
|
||||
queueItems: QueueItemRef[];
|
||||
repeatMode: 'off' | 'one' | 'all';
|
||||
volume: number;
|
||||
};
|
||||
|
||||
const gaplessChainPrepareInflight = new Map<string, Promise<void>>();
|
||||
|
||||
function gaplessNextNeighbour(
|
||||
ctx: GaplessChainPreloadContext,
|
||||
): Track | null {
|
||||
const { nextIdx, queueItems, repeatMode } = ctx;
|
||||
const nextNeighbourRef = nextIdx + 1 < queueItems.length
|
||||
? queueItems[nextIdx + 1]
|
||||
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||||
return nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
|
||||
}
|
||||
|
||||
function invokeGaplessChainPreload(
|
||||
prepared: Track,
|
||||
ctx: GaplessChainPreloadContext,
|
||||
): void {
|
||||
const authState = useAuthStore.getState();
|
||||
const serverId = ctx.nextRef ? playbackCacheKeyForRef(ctx.nextRef) : getPlaybackCacheServerKey();
|
||||
const analysisServerId = ctx.nextRef
|
||||
? playbackCacheKeyForRef(ctx.nextRef)
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(prepared.id, serverId);
|
||||
const nextNeighbour = gaplessNextNeighbour(ctx);
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
prepared, ctx.currentTrack, nextNeighbour,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive()
|
||||
? (prepared.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: ctx.volume,
|
||||
durationHint: prepared.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(prepared.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
...audioPlayHiResBlendArgs(authState),
|
||||
analysisTrackId: prepared.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function liveNextRefForContext(
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
repeatMode: 'off' | 'one' | 'all',
|
||||
): QueueItemRef | null {
|
||||
if (repeatMode === 'one') return null;
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (nextIdx < queueItems.length) return queueItems[nextIdx] ?? null;
|
||||
if (repeatMode === 'all' && queueItems.length > 0) return queueItems[0] ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch metadata + loudness, then hand the next track to `audio_chain_preload`.
|
||||
* Coalesces concurrent prepares for the same id; retries on the next progress
|
||||
* tick when validation fails (track skip, queue rewrite).
|
||||
*/
|
||||
export function requestGaplessChainPreload(ctx: GaplessChainPreloadContext): void {
|
||||
const { nextTrack } = ctx;
|
||||
if (getGaplessPreloadingId() === nextTrack.id) return;
|
||||
if (gaplessChainPrepareInflight.has(nextTrack.id)) return;
|
||||
|
||||
const job = (async () => {
|
||||
try {
|
||||
const serverId = ctx.nextRef
|
||||
? playbackCacheKeyForRef(ctx.nextRef)
|
||||
: getPlaybackCacheServerKey();
|
||||
const prepared = serverId
|
||||
? await prepareTrackForEngineBind(nextTrack, serverId)
|
||||
: nextTrack;
|
||||
if (serverId) {
|
||||
await refreshLoudnessForTrack(prepared.id, { syncPlayingEngine: false });
|
||||
}
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
if (!store.isPlaying || store.currentRadio) return;
|
||||
if (store.currentTrack?.id !== ctx.currentTrack.id) return;
|
||||
|
||||
const liveNextRef = liveNextRefForContext(
|
||||
store.queueItems,
|
||||
store.queueIndex,
|
||||
store.repeatMode,
|
||||
);
|
||||
if (liveNextRef?.trackId !== prepared.id) return;
|
||||
if (getGaplessPreloadingId() === prepared.id) return;
|
||||
|
||||
setGaplessPreloadingId(prepared.id);
|
||||
invokeGaplessChainPreload(prepared, {
|
||||
...ctx,
|
||||
currentTrack: store.currentTrack ?? ctx.currentTrack,
|
||||
nextTrack: prepared,
|
||||
nextRef: liveNextRef,
|
||||
});
|
||||
} finally {
|
||||
gaplessChainPrepareInflight.delete(nextTrack.id);
|
||||
}
|
||||
})().catch(() => {});
|
||||
gaplessChainPrepareInflight.set(nextTrack.id, job);
|
||||
}
|
||||
|
||||
/** Test-only: drop pending gapless chain prepare promises. */
|
||||
export function _resetGaplessChainPrepareInflightForTest(): void {
|
||||
gaplessChainPrepareInflight.clear();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Last engine progress position for gapless rewind detection.
|
||||
* Kept separate from gaplessQueueAdvance to avoid seekAction → gaplessQueueAdvance
|
||||
* → playerStore → seekAction cycles.
|
||||
*/
|
||||
|
||||
let lastEngineProgressSec = 0;
|
||||
|
||||
export function _resetGaplessProgressTrackingForTest(): void {
|
||||
lastEngineProgressSec = 0;
|
||||
}
|
||||
|
||||
/** Clear stale position after a gapless or manual track switch. */
|
||||
export function resetGaplessProgressTracking(): void {
|
||||
lastEngineProgressSec = 0;
|
||||
}
|
||||
|
||||
export function getLastEngineProgressSec(): number {
|
||||
return lastEngineProgressSec;
|
||||
}
|
||||
|
||||
export function noteEngineProgressForGapless(currentTime: number): void {
|
||||
lastEngineProgressSec = currentTime;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
_resetQueueResolverForTest,
|
||||
seedQueueResolver,
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { _resetGaplessPreloadStateForTest } from '@/features/playback/store/gaplessPreloadState';
|
||||
import { setSeekTarget, _resetSeekTargetStateForTest } from '@/features/playback/store/seekTargetState';
|
||||
import {
|
||||
_resetPlaybackProgressForTest,
|
||||
getPlaybackProgressSnapshot,
|
||||
} from '@/features/playback/store/playbackProgress';
|
||||
import {
|
||||
_resetGaplessProgressTrackingForTest,
|
||||
noteEngineProgressForGapless,
|
||||
} from '@/features/playback/store/gaplessProgressTracking';
|
||||
import {
|
||||
applyGaplessQueueAdvance,
|
||||
maybeReconcileGaplessFromProgress,
|
||||
} from '@/features/playback/store/gaplessQueueAdvance';
|
||||
|
||||
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
|
||||
|
||||
const track = (id: string, extra: Partial<Track> = {}): Track => ({
|
||||
id,
|
||||
title: extra.title ?? `Track ${id}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'alb-1',
|
||||
duration: extra.duration ?? 200,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('applyGaplessQueueAdvance', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
_resetPlaybackProgressForTest();
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
useAuthStore.setState({ gaplessEnabled: true });
|
||||
seedQueueResolver('s1', [
|
||||
track('t1'),
|
||||
track('t2', { title: 'Second' }),
|
||||
]);
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track('t1'),
|
||||
queueItems: [ref('t1'), ref('t2')],
|
||||
queueIndex: 0,
|
||||
repeatMode: 'off',
|
||||
isPlaying: true,
|
||||
currentRadio: null,
|
||||
progress: 0.8,
|
||||
currentTime: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('advances currentTrack and resets the progress channel', () => {
|
||||
const result = applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' });
|
||||
|
||||
expect(result.advanced).toBe(true);
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||
expect(getPlaybackProgressSnapshot().currentTime).toBe(0);
|
||||
expect(getPlaybackProgressSnapshot().progress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeReconcileGaplessFromProgress', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
_resetGaplessPreloadStateForTest();
|
||||
_resetPlaybackProgressForTest();
|
||||
_resetGaplessProgressTrackingForTest();
|
||||
_resetSeekTargetStateForTest();
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
useAuthStore.setState({ gaplessEnabled: true });
|
||||
seedQueueResolver('s1', [track('t1'), track('t2', { title: 'Second' })]);
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track('t1'),
|
||||
queueItems: [ref('t1'), ref('t2')],
|
||||
queueIndex: 0,
|
||||
repeatMode: 'off',
|
||||
isPlaying: true,
|
||||
currentRadio: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('catches up UI when engine position regresses without track_switched', () => {
|
||||
noteEngineProgressForGapless(170);
|
||||
maybeReconcileGaplessFromProgress(0.4, 205);
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
|
||||
expect(getPlaybackProgressSnapshot().progress).toBe(0);
|
||||
});
|
||||
|
||||
it('no-ops when position moves forward normally', () => {
|
||||
noteEngineProgressForGapless(10);
|
||||
maybeReconcileGaplessFromProgress(11, 200);
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
|
||||
});
|
||||
|
||||
it('no-ops during an active seek guard', () => {
|
||||
noteEngineProgressForGapless(100);
|
||||
setSeekTarget(20);
|
||||
maybeReconcileGaplessFromProgress(0.5, 200);
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('no-ops on mid-track position regressions (not a gapless boundary)', () => {
|
||||
noteEngineProgressForGapless(170);
|
||||
maybeReconcileGaplessFromProgress(100, 200);
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('does not double-advance after track_switched already moved the UI', () => {
|
||||
noteEngineProgressForGapless(170);
|
||||
applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' });
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
|
||||
|
||||
maybeReconcileGaplessFromProgress(2, 210);
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Gapless queue advance: Rust already switched the audio source — update JS
|
||||
* store + side-effects without calling `playTrack` / `audio_play`.
|
||||
*/
|
||||
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
|
||||
import {
|
||||
playbackReportStart,
|
||||
} from '@/features/playback/store/playbackReportSession';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
clearPreloadingIds,
|
||||
getLastGaplessSwitchTime,
|
||||
markGaplessSwitch,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
|
||||
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
|
||||
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
|
||||
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
|
||||
import {
|
||||
emitPlaybackProgress,
|
||||
} from '@/features/playback/store/playbackProgress';
|
||||
import {
|
||||
resetProgressEmitThrottles,
|
||||
} from '@/features/playback/store/playbackThrottles';
|
||||
import {
|
||||
playbackSourceHintForResolvedUrl,
|
||||
} from '@/features/playback/store/playbackUrlRouting';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { playListenSessionOnTrackSwitched } from '@/features/playback/store/playListenSession';
|
||||
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
|
||||
import {
|
||||
playbackCacheKeyForRef,
|
||||
playbackProfileIdForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { syncQueueToServer } from '@/features/playback/store/queueSync';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { setIsAudioPaused } from '@/features/playback/store/engineState';
|
||||
import {
|
||||
getLastEngineProgressSec,
|
||||
noteEngineProgressForGapless,
|
||||
resetGaplessProgressTracking,
|
||||
} from '@/features/playback/store/gaplessProgressTracking';
|
||||
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
|
||||
import { getSeekTarget } from '@/features/playback/store/seekTargetState';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
|
||||
export type GaplessQueueAdvanceResult = {
|
||||
advanced: boolean;
|
||||
nextTrack: Track | null;
|
||||
newIndex: number;
|
||||
};
|
||||
|
||||
/** Resolve the queue successor for a gapless engine transition. */
|
||||
export function resolveGaplessSuccessor(
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
repeatMode: 'off' | 'one' | 'all',
|
||||
currentTrack: Track | null,
|
||||
): { nextTrack: Track | null; newIndex: number } {
|
||||
const nextIdx = queueIndex + 1;
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
return { nextTrack: currentTrack, newIndex: queueIndex };
|
||||
}
|
||||
if (nextIdx < queueItems.length) {
|
||||
return {
|
||||
nextTrack: resolveQueueTrack(queueItems[nextIdx]),
|
||||
newIndex: nextIdx,
|
||||
};
|
||||
}
|
||||
if (repeatMode === 'all' && queueItems.length > 0) {
|
||||
return {
|
||||
nextTrack: resolveQueueTrack(queueItems[0]),
|
||||
newIndex: 0,
|
||||
};
|
||||
}
|
||||
return { nextTrack: null, newIndex: queueIndex };
|
||||
}
|
||||
|
||||
function applyGaplessSuccessorUi(
|
||||
store: ReturnType<typeof usePlayerStore.getState>,
|
||||
nextTrack: Track,
|
||||
newIndex: number,
|
||||
source: 'track-switched' | 'progress-reconcile',
|
||||
): void {
|
||||
const switchRef = store.queueItems[newIndex];
|
||||
const switchServerId = playbackCacheKeyForRef(switchRef);
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(
|
||||
nextTrack.id,
|
||||
switchServerId,
|
||||
switchResolvedUrl,
|
||||
);
|
||||
|
||||
const switchPrev = store.currentTrack;
|
||||
const switchNextNextRef = newIndex + 1 < store.queueItems.length
|
||||
? store.queueItems[newIndex + 1]
|
||||
: null;
|
||||
const switchNeighbourWindow: Track[] = [
|
||||
switchPrev ?? nextTrack,
|
||||
nextTrack,
|
||||
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
|
||||
];
|
||||
|
||||
resetProgressEmitThrottles();
|
||||
resetGaplessProgressTracking();
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
|
||||
normalizationDbgSource: source,
|
||||
normalizationDbgTrackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
isPlaying: true,
|
||||
isPlaybackBuffering: switchPlaybackSource === 'stream',
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
scrobbled: false,
|
||||
networkLoved: false,
|
||||
currentPlaybackSource: switchPlaybackSource,
|
||||
});
|
||||
emitPlaybackProgress({
|
||||
currentTime: 0,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
buffering: switchPlaybackSource === 'stream',
|
||||
});
|
||||
|
||||
emitNormalizationDebug(source, {
|
||||
trackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
engineRequested: useAuthStore.getState().normalizationEngine,
|
||||
});
|
||||
void refreshWaveformForTrack(nextTrack.id);
|
||||
void refreshLoudnessForTrack(nextTrack.id);
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
|
||||
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
void runtime?.dispatchNowPlaying({
|
||||
title: nextTrack.title,
|
||||
artist: nextTrack.artist,
|
||||
album: nextTrack.album,
|
||||
duration: nextTrack.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (runtime?.getEnrichmentPrimaryId()) {
|
||||
void runtime
|
||||
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
|
||||
.then(loved => {
|
||||
usePlayerStore.getState().setNetworkLoved(loved);
|
||||
});
|
||||
}
|
||||
syncQueueToServer(store.queueItems, nextTrack, 0);
|
||||
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the queue UI to match a gapless engine transition. Returns whether
|
||||
* the store was updated (false when there is no successor or already advanced).
|
||||
*/
|
||||
export function applyGaplessQueueAdvance(opts?: {
|
||||
/** When set, patch duration on the successor if the resolver snapshot is thin. */
|
||||
engineDurationHint?: number;
|
||||
source?: 'track-switched' | 'progress-reconcile';
|
||||
}): GaplessQueueAdvanceResult {
|
||||
const source = opts?.source ?? 'track-switched';
|
||||
const store = usePlayerStore.getState();
|
||||
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
|
||||
if (currentRadio) {
|
||||
return { advanced: false, nextTrack: null, newIndex: queueIndex };
|
||||
}
|
||||
|
||||
const { nextTrack: resolved, newIndex } = resolveGaplessSuccessor(
|
||||
queueItems,
|
||||
queueIndex,
|
||||
repeatMode,
|
||||
currentTrack,
|
||||
);
|
||||
if (!resolved) {
|
||||
return { advanced: false, nextTrack: null, newIndex: queueIndex };
|
||||
}
|
||||
|
||||
const hint = opts?.engineDurationHint;
|
||||
const nextTrack = hint != null && hint > 0 && resolved.duration <= 0
|
||||
? { ...resolved, duration: hint }
|
||||
: resolved;
|
||||
|
||||
if (currentTrack && repeatMode !== 'one' && currentTrack.id === nextTrack.id && queueIndex === newIndex) {
|
||||
return { advanced: false, nextTrack, newIndex };
|
||||
}
|
||||
|
||||
if (currentTrack && !currentRadio) {
|
||||
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
|
||||
}
|
||||
void playListenSessionOnTrackSwitched(nextTrack);
|
||||
applyGaplessSuccessorUi(store, nextTrack, newIndex, source);
|
||||
return { advanced: true, nextTrack, newIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* When gapless is on and the engine position jumps backward mid-playback, the
|
||||
* Rust side likely switched sources without delivering `audio:track_switched`.
|
||||
*/
|
||||
export function maybeReconcileGaplessFromProgress(
|
||||
currentTime: number,
|
||||
engineDuration: number,
|
||||
): void {
|
||||
if (!useAuthStore.getState().gaplessEnabled) return;
|
||||
if (isSeekDebouncePending() || getSeekTarget() !== null) return;
|
||||
const store = usePlayerStore.getState();
|
||||
if (!store.isPlaying || store.currentRadio || !store.currentTrack) return;
|
||||
if (Date.now() - getLastGaplessSwitchTime() < 400) return;
|
||||
|
||||
const prevSec = getLastEngineProgressSec();
|
||||
// Gapless transitions restart near 0 — mid-track regressions are usually
|
||||
// buffering/seek glitches, not a decoder boundary without track_switched.
|
||||
const nearStart = currentTime < 8;
|
||||
const regressed = nearStart && currentTime + 1.5 < prevSec && prevSec > 8;
|
||||
if (!regressed) {
|
||||
noteEngineProgressForGapless(currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
const { nextTrack, newIndex } = resolveGaplessSuccessor(
|
||||
store.queueItems,
|
||||
store.queueIndex,
|
||||
store.repeatMode,
|
||||
store.currentTrack,
|
||||
);
|
||||
if (!nextTrack || nextTrack.id === store.currentTrack.id) return;
|
||||
const slotRef = store.queueItems[store.queueIndex];
|
||||
if (!slotRef || slotRef.trackId !== store.currentTrack.id) return;
|
||||
if (store.repeatMode !== 'one' && newIndex <= store.queueIndex) return;
|
||||
|
||||
applyGaplessQueueAdvance({
|
||||
engineDurationHint: engineDuration,
|
||||
source: 'progress-reconcile',
|
||||
});
|
||||
markGaplessSwitch();
|
||||
clearPreloadingIds();
|
||||
setIsAudioPaused(false);
|
||||
noteEngineProgressForGapless(currentTime);
|
||||
}
|
||||
@@ -54,6 +54,10 @@ vi.mock('@/features/playback/store/queueTrackView', () => ({
|
||||
}));
|
||||
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
|
||||
getPlaybackCacheServerKey: () => 'srv-key',
|
||||
playbackCacheKeyForTrack: () => 'srv-key',
|
||||
}));
|
||||
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
|
||||
prepareTrackForEngineBind: vi.fn(async (track: Track) => track),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
|
||||
import { scheduleHotCachePrefetchForTrack } from '@/hotCachePrefetch';
|
||||
import { getPlaybackCacheServerKey } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { getPlaybackCacheServerKey, playbackCacheKeyForTrack } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { bumpPlayGeneration, getPlayGeneration } from '@/features/playback/store/engineState';
|
||||
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
|
||||
@@ -62,10 +63,20 @@ export function preparePausedRestoreOnStartup(
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (usePlayerStore.getState().isPlaying) return;
|
||||
|
||||
const queue = getQueueTracksView(queueItems, [track]);
|
||||
const metadataSid =
|
||||
playbackCacheKeyForTrack(track)
|
||||
|| getPlaybackCacheServerKey()
|
||||
|| '';
|
||||
const trackForEngine = metadataSid
|
||||
? await prepareTrackForEngineBind(track, metadataSid)
|
||||
: track;
|
||||
if (getPlayGeneration() !== generation) return;
|
||||
if (usePlayerStore.getState().isPlaying) return;
|
||||
|
||||
const queue = getQueueTracksView(queueItems, [trackForEngine]);
|
||||
engineLoadTrackAtPosition({
|
||||
generation,
|
||||
track,
|
||||
track: trackForEngine,
|
||||
queue,
|
||||
queueIndex,
|
||||
atSeconds,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@/store/localPlaybackResolve';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from '@/features/playback/store/crossfadeTrimCache';
|
||||
@@ -41,10 +42,10 @@ import {
|
||||
getPlayGeneration,
|
||||
setIsAudioPaused,
|
||||
} from '@/features/playback/store/engineState';
|
||||
import {
|
||||
clearPreloadingIds,
|
||||
import { clearPreloadingIds,
|
||||
getLastGaplessSwitchTime,
|
||||
} from '@/features/playback/store/gaplessPreloadState';
|
||||
import { resetGaplessProgressTracking } from '@/features/playback/store/gaplessProgressTracking';
|
||||
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
|
||||
import {
|
||||
isReplayGainActive,
|
||||
@@ -325,25 +326,26 @@ export function runPlayTrack(
|
||||
&& getPlaybackCacheServerKey(),
|
||||
);
|
||||
|
||||
const runPlayTrackBody = () => {
|
||||
const runPlayTrackBody = (trackForPlay: Track) => {
|
||||
const playNormWindow = normWindow.map((t, i) => (i === normIdx ? trackForPlay : t));
|
||||
const authStateNow = useAuthStore.getState();
|
||||
const playbackSid = playbackProfileIdForTrack(scopedTrack, playingRef);
|
||||
const playbackCacheSid = playbackCacheKeyForTrack(scopedTrack, playingRef);
|
||||
const playbackSid = playbackProfileIdForTrack(trackForPlay, playingRef);
|
||||
const playbackCacheSid = playbackCacheKeyForTrack(trackForPlay, playingRef);
|
||||
const url = libraryLocalUrl
|
||||
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'library')
|
||||
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'favorite-auto')
|
||||
?? resolvePlaybackUrl(scopedTrack.id, playbackCacheSid);
|
||||
recordEnginePlayUrl(scopedTrack.id, url);
|
||||
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'library')
|
||||
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'favorite-auto')
|
||||
?? resolvePlaybackUrl(trackForPlay.id, playbackCacheSid);
|
||||
recordEnginePlayUrl(trackForPlay.id, url);
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === scopedTrack.id;
|
||||
const keepPreloadHint = preloadedTrackId === trackForPlay.id;
|
||||
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
||||
scopedTrack.id,
|
||||
trackForPlay.id,
|
||||
playbackCacheSid,
|
||||
url,
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][playTrack-source]', {
|
||||
trackId: scopedTrack.id,
|
||||
trackId: trackForPlay.id,
|
||||
resolvedUrl: url,
|
||||
preloadedTrackId,
|
||||
keepPreloadHint,
|
||||
@@ -363,24 +365,25 @@ export function runPlayTrack(
|
||||
if (sid) seedQueueResolver(sid, [t]);
|
||||
}
|
||||
} else if (queueSid) {
|
||||
seedQueueResolver(queueSid, [scopedTrack]);
|
||||
seedQueueResolver(queueSid, [trackForPlay]);
|
||||
}
|
||||
|
||||
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(scopedTrack.id);
|
||||
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(trackForPlay.id);
|
||||
const wantInterruptBlend = Boolean(
|
||||
shouldAutodjInterruptBlend(wasPlayingBeforeSkip, hasJsAutoHandoff)
|
||||
&& prevTrack
|
||||
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id),
|
||||
&& !sameQueueTrackId(prevTrack.id, trackForPlay.id),
|
||||
);
|
||||
const bReadyNow = isCrossfadeNextReady(scopedTrack.id, playbackSid, playbackCacheSid);
|
||||
const bReadyNow = isCrossfadeNextReady(trackForPlay.id, playbackSid, playbackCacheSid);
|
||||
/** Cold interrupt: engine still on A — don't swap player-bar metadata until handoff. */
|
||||
const deferInterruptUi = shouldDeferInterruptHandoffUi(wantInterruptBlend, bReadyNow);
|
||||
|
||||
const applyInterruptHandoffUi = () => {
|
||||
resetGaplessProgressTracking();
|
||||
set({
|
||||
currentTrack: scopedTrack,
|
||||
currentTrack: trackForPlay,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
|
||||
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
|
||||
progress: initialProgress,
|
||||
buffered: 0,
|
||||
currentTime: initialTime,
|
||||
@@ -389,10 +392,10 @@ export function runPlayTrack(
|
||||
isPlaying: playbackSourceHint !== 'stream',
|
||||
isPlaybackBuffering: playbackSourceHint === 'stream',
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
|
||||
enginePreloadedTrackId: keepPreloadHint ? trackForPlay.id : null,
|
||||
});
|
||||
void refreshWaveformForTrack(scopedTrack.id);
|
||||
void refreshLoudnessForTrack(scopedTrack.id, { syncPlayingEngine: false });
|
||||
void refreshWaveformForTrack(trackForPlay.id);
|
||||
void refreshLoudnessForTrack(trackForPlay.id, { syncPlayingEngine: false });
|
||||
};
|
||||
|
||||
if (deferInterruptUi) {
|
||||
@@ -402,11 +405,12 @@ export function runPlayTrack(
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
});
|
||||
} else {
|
||||
resetGaplessProgressTracking();
|
||||
set({
|
||||
currentTrack: scopedTrack,
|
||||
currentTrack: trackForPlay,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
|
||||
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
|
||||
// Only a replace rewrites the queue; navigation keeps the canonical refs.
|
||||
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
@@ -423,11 +427,11 @@ export function runPlayTrack(
|
||||
? false
|
||||
: playbackSourceHint === 'stream',
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
|
||||
enginePreloadedTrackId: keepPreloadHint ? trackForPlay.id : null,
|
||||
});
|
||||
void refreshWaveformForTrack(scopedTrack.id);
|
||||
void refreshWaveformForTrack(trackForPlay.id);
|
||||
void refreshLoudnessForTrack(
|
||||
scopedTrack.id,
|
||||
trackForPlay.id,
|
||||
wantInterruptBlend ? { syncPlayingEngine: false } : undefined,
|
||||
);
|
||||
}
|
||||
@@ -435,7 +439,7 @@ export function runPlayTrack(
|
||||
setDeferHotCachePrefetch(true);
|
||||
if (
|
||||
prevTrack
|
||||
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
|
||||
&& !sameQueueTrackId(prevTrack.id, trackForPlay.id)
|
||||
&& authStateNow.hotCacheEnabled
|
||||
) {
|
||||
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
|
||||
@@ -448,10 +452,10 @@ export function runPlayTrack(
|
||||
}
|
||||
}
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
scopedTrack, prevTrack, nextNeighbour,
|
||||
trackForPlay, prevTrack, nextNeighbour,
|
||||
isReplayGainActive(), authStateNow.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
|
||||
const replayGainPeak = isReplayGainActive() ? (trackForPlay.replayGainPeak ?? null) : null;
|
||||
|
||||
const invokeAudioPlay = (manualBlend: CrossfadeTransitionPlan | null) => {
|
||||
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
|
||||
@@ -467,8 +471,8 @@ export function runPlayTrack(
|
||||
&& initialTime <= 0.05;
|
||||
const useManualBlend = manualBlend !== null;
|
||||
|
||||
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
|
||||
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
|
||||
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(trackForPlay.id) : null;
|
||||
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(trackForPlay.id) : null;
|
||||
const crossfadeStartSecs = useManualBlend
|
||||
? manualBlend.bStartSec
|
||||
: (crossfadePlan?.bStartSec ?? 0);
|
||||
@@ -490,17 +494,17 @@ export function runPlayTrack(
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: scopedTrack.duration,
|
||||
durationHint: trackForPlay.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(trackForPlay.id),
|
||||
preGainDb: authStateNow.replayGainPreGainDb,
|
||||
fallbackDb: authStateNow.replayGainFallbackDb,
|
||||
manual,
|
||||
...audioPlayHiResBlendArgs(authStateNow),
|
||||
analysisTrackId: scopedTrack.id,
|
||||
analysisTrackId: trackForPlay.id,
|
||||
serverId: getPlaybackIndexKey() || null,
|
||||
streamFormatSuffix: scopedTrack.suffix ?? null,
|
||||
streamFormatSuffix: trackForPlay.suffix ?? null,
|
||||
startPaused: false,
|
||||
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
|
||||
crossfadeSecsOverride,
|
||||
@@ -515,7 +519,7 @@ export function runPlayTrack(
|
||||
if (keepPreloadHint) {
|
||||
set({ enginePreloadedTrackId: null });
|
||||
}
|
||||
const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null;
|
||||
const durSeek = trackForPlay.duration && trackForPlay.duration > 0 ? trackForPlay.duration : null;
|
||||
const seekTo = initialTime;
|
||||
const canSeekAfterPlay =
|
||||
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
|
||||
@@ -524,12 +528,12 @@ export function runPlayTrack(
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
setSeekTarget(seekTo);
|
||||
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
|
||||
if (getSeekFallbackVisualTarget()?.trackId === trackForPlay.id) {
|
||||
setSeekFallbackVisualTarget(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
|
||||
if (getSeekFallbackVisualTarget()?.trackId === trackForPlay.id) {
|
||||
setSeekFallbackVisualTarget(null);
|
||||
}
|
||||
});
|
||||
@@ -552,28 +556,28 @@ export function runPlayTrack(
|
||||
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
|
||||
// internally). playbackReportStart opens the live FSM on extension-capable
|
||||
// servers and falls back to the legacy presence call otherwise.
|
||||
playbackReportStart(scopedTrack.id, playbackSid);
|
||||
playbackReportStart(trackForPlay.id, playbackSid);
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
void runtime?.dispatchNowPlaying({
|
||||
title: scopedTrack.title,
|
||||
artist: scopedTrack.artist,
|
||||
album: scopedTrack.album,
|
||||
duration: scopedTrack.duration,
|
||||
title: trackForPlay.title,
|
||||
artist: trackForPlay.artist,
|
||||
album: trackForPlay.album,
|
||||
duration: trackForPlay.duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (runtime?.getEnrichmentPrimaryId()) {
|
||||
void runtime
|
||||
.isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist })
|
||||
.isTrackLoved({ title: trackForPlay.title, artist: trackForPlay.artist })
|
||||
.then(loved => {
|
||||
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
|
||||
const cacheKey = `${trackForPlay.title}::${trackForPlay.artist}`;
|
||||
set(s => ({
|
||||
networkLoved: loved,
|
||||
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
}
|
||||
pushQueueOnPlaybackStart(get().queueItems, scopedTrack, initialTime);
|
||||
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
|
||||
pushQueueOnPlaybackStart(get().queueItems, trackForPlay, initialTime);
|
||||
touchHotCacheOnPlayback(trackForPlay.id, playbackCacheSid);
|
||||
};
|
||||
|
||||
const startAudio = (manualBlend: CrossfadeTransitionPlan | null) => {
|
||||
@@ -593,12 +597,12 @@ export function runPlayTrack(
|
||||
bReadyNow
|
||||
? Promise.resolve({ ready: true })
|
||||
: runInterruptBlendPrep(
|
||||
scopedTrack,
|
||||
trackForPlay,
|
||||
playbackSid,
|
||||
playbackCacheSid,
|
||||
() => getPlayGeneration() !== gen,
|
||||
),
|
||||
fetchWaveformBins(scopedTrack.id, playbackCacheSid || null),
|
||||
fetchWaveformBins(trackForPlay.id, playbackCacheSid || null),
|
||||
]);
|
||||
if (getPlayGeneration() !== gen) {
|
||||
clearInterruptHandoff();
|
||||
@@ -610,7 +614,7 @@ export function runPlayTrack(
|
||||
aDur,
|
||||
skipFromTimeSec,
|
||||
bBins,
|
||||
scopedTrack.duration || 0,
|
||||
trackForPlay.duration || 0,
|
||||
)
|
||||
: null;
|
||||
startAudio(blend
|
||||
@@ -634,6 +638,22 @@ export function runPlayTrack(
|
||||
startAudio(null);
|
||||
};
|
||||
|
||||
const launchPlayTrackBody = () => {
|
||||
void (async () => {
|
||||
let trackForPlay = scopedTrack;
|
||||
const metadataSid =
|
||||
playbackCacheKeyForTrack(scopedTrack, playingRef)
|
||||
|| get().queueServerId
|
||||
|| getPlaybackIndexKey()
|
||||
|| '';
|
||||
if (metadataSid) {
|
||||
trackForPlay = await enrichTrackPlaybackMetadata(scopedTrack, metadataSid);
|
||||
}
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
runPlayTrackBody(trackForPlay);
|
||||
})();
|
||||
};
|
||||
|
||||
const hotPromoteSid = getPlaybackCacheServerKey();
|
||||
if (needSameTrackHotPromote && hotPromoteSid) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
@@ -643,7 +663,7 @@ export function runPlayTrack(
|
||||
)
|
||||
.then(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
runPlayTrackBody();
|
||||
launchPlayTrackBody();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
@@ -652,6 +672,6 @@ export function runPlayTrack(
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
} else {
|
||||
runPlayTrackBody();
|
||||
launchPlayTrackBody();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
setState: hoisted.playerSetStateMock,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
|
||||
prepareTrackForEngineBind: vi.fn(async (track: Track) => track),
|
||||
}));
|
||||
|
||||
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
|
||||
|
||||
@@ -90,12 +93,14 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
atSeconds: 0,
|
||||
wantPlaying: true,
|
||||
});
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
|
||||
url: 'https://mock/t1',
|
||||
durationHint: 100,
|
||||
manual: false,
|
||||
analysisTrackId: 't1',
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
|
||||
url: 'https://mock/t1',
|
||||
durationHint: 100,
|
||||
manual: false,
|
||||
analysisTrackId: 't1',
|
||||
}));
|
||||
});
|
||||
expect(hoisted.recordEnginePlayUrlMock).toHaveBeenCalledWith('t1', 'https://mock/t1');
|
||||
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(true);
|
||||
expect(hoisted.touchHotCacheOnPlaybackMock).toHaveBeenCalledWith('t1', 'srv');
|
||||
@@ -110,9 +115,9 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
atSeconds: 30,
|
||||
wantPlaying: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
it('skips audio_seek when atSeconds is near zero', async () => {
|
||||
@@ -124,7 +129,9 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
atSeconds: 0,
|
||||
wantPlaying: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.anything());
|
||||
});
|
||||
await Promise.resolve();
|
||||
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
|
||||
expect(seekCall).toBeUndefined();
|
||||
@@ -139,17 +146,17 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
atSeconds: 0,
|
||||
wantPlaying: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
|
||||
startPaused: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
|
||||
startPaused: true,
|
||||
}));
|
||||
});
|
||||
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
|
||||
expect(pauseCall).toBeUndefined();
|
||||
expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('bails out of the .then chain when generation has moved on', async () => {
|
||||
it('bails out before audio_play when generation has moved on during prepare', async () => {
|
||||
hoisted.getPlayGeneration.mockReturnValue(2); // user navigated, new gen
|
||||
queueUndoRestoreAudioEngine({
|
||||
generation: 1,
|
||||
@@ -159,9 +166,9 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
atSeconds: 30,
|
||||
wantPlaying: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Should NOT have called audio_seek or audio_pause — gen moved on.
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.invokeMock.mock.calls.length).toBe(0);
|
||||
});
|
||||
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
|
||||
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
|
||||
expect(seekCall).toBeUndefined();
|
||||
@@ -178,7 +185,9 @@ describe('queueUndoRestoreAudioEngine', () => {
|
||||
atSeconds: 0,
|
||||
wantPlaying: true,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.anything());
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(false);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { getPlayGeneration } from '@/features/playback/store/engineState';
|
||||
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
|
||||
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
|
||||
|
||||
/**
|
||||
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand
|
||||
@@ -20,5 +26,15 @@ export function queueUndoRestoreAudioEngine(opts: {
|
||||
atSeconds: number;
|
||||
wantPlaying: boolean;
|
||||
}): void {
|
||||
engineLoadTrackAtPosition(opts);
|
||||
void (async () => {
|
||||
const serverId =
|
||||
playbackCacheKeyForTrack(opts.track)
|
||||
|| getPlaybackIndexKey()
|
||||
|| '';
|
||||
const track = serverId
|
||||
? await prepareTrackForEngineBind(opts.track, serverId)
|
||||
: opts.track;
|
||||
if (getPlayGeneration() !== opts.generation) return;
|
||||
engineLoadTrackAtPosition({ ...opts, track });
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
_resetQueueResolverForTest,
|
||||
seedQueueResolver,
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import * as resolveSongMetaIndexFirst from '@/lib/library/resolveSongMetaIndexFirst';
|
||||
import {
|
||||
_resetIndexRefreshInflightForTest,
|
||||
maybeRefreshCurrentTrackMetadataFromIndex,
|
||||
maybeSyncCurrentTrackFromResolver,
|
||||
shouldSyncCurrentTrackMetadata,
|
||||
shouldUpgradeReplayGainMetadata,
|
||||
syncIdleAppliesToQueueRef,
|
||||
} from '@/features/playback/store/replayGainMetadataSync';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
|
||||
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
|
||||
|
||||
const track = (id: string, extra: Partial<Track> = {}): Track => ({
|
||||
id,
|
||||
title: extra.title ?? `Track ${id}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: extra.albumId ?? 'alb-1',
|
||||
duration: 200,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('shouldUpgradeReplayGainMetadata', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
replayGainMode: 'track',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true when track gain appears on a placeholder snapshot', () => {
|
||||
const prev = track('t1', { title: '…', replayGainTrackDb: undefined });
|
||||
const next = track('t1', { replayGainTrackDb: -8.5 });
|
||||
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when both snapshots lack ReplayGain tags', () => {
|
||||
const prev = track('t1', { replayGainTrackDb: undefined });
|
||||
const next = track('t1', { title: 'Resolved title' });
|
||||
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when peak metadata arrives', () => {
|
||||
const base = track('t1', { replayGainTrackDb: -6 });
|
||||
const withPeak = { ...base, replayGainPeak: 0.98 };
|
||||
expect(shouldUpgradeReplayGainMetadata(base, withPeak, [ref('t1')], 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when track gain was recalculated on the server', () => {
|
||||
const prev = track('t1', { replayGainTrackDb: -6.0 });
|
||||
const next = track('t1', { replayGainTrackDb: -8.5 });
|
||||
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSyncCurrentTrackMetadata', () => {
|
||||
it('returns true when placeholder title resolves', () => {
|
||||
const prev = track('t1', { title: '…' });
|
||||
const next = track('t1', { title: 'Resolved title' });
|
||||
expect(shouldSyncCurrentTrackMetadata(prev, next, [ref('t1')], 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when duration arrives on a thin snapshot', () => {
|
||||
const prev = track('t1', { duration: 0 });
|
||||
const next = track('t1', { duration: 240 });
|
||||
expect(shouldSyncCurrentTrackMetadata(prev, next, [ref('t1')], 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeSyncCurrentTrackFromResolver', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
replayGainMode: 'track',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: -6,
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track('t1', { title: '…' }),
|
||||
queueItems: [ref('t1')],
|
||||
queueIndex: 0,
|
||||
isPlaying: true,
|
||||
currentRadio: null,
|
||||
volume: 0.8,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upgrades currentTrack and pushes replay gain when the resolver cache fills', () => {
|
||||
seedQueueResolver('s1', [track('t1', { replayGainTrackDb: -7.2, replayGainPeak: 0.95 })]);
|
||||
|
||||
maybeSyncCurrentTrackFromResolver();
|
||||
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.replayGainTrackDb).toBe(-7.2);
|
||||
expect(s.currentTrack?.replayGainPeak).toBe(0.95);
|
||||
});
|
||||
|
||||
it('no-ops engine gain when ReplayGain mode is off but still syncs thin title', () => {
|
||||
useAuthStore.setState({ normalizationEngine: 'off', replayGainEnabled: false });
|
||||
seedQueueResolver('s1', [track('t1', { title: 'Full title' })]);
|
||||
|
||||
maybeSyncCurrentTrackFromResolver();
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.title).toBe('Full title');
|
||||
expect(usePlayerStore.getState().currentTrack?.replayGainTrackDb).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no-ops when transport is idle', () => {
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
seedQueueResolver('s1', [track('t1', { title: 'Full title', duration: 220 })]);
|
||||
|
||||
maybeSyncCurrentTrackFromResolver();
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.title).toBe('…');
|
||||
});
|
||||
|
||||
it('upgrades placeholder title without ReplayGain when normalization is off', () => {
|
||||
useAuthStore.setState({ normalizationEngine: 'off', replayGainEnabled: false });
|
||||
seedQueueResolver('s1', [track('t1', { title: 'Full title', duration: 220 })]);
|
||||
|
||||
maybeSyncCurrentTrackFromResolver();
|
||||
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.title).toBe('Full title');
|
||||
expect(s.currentTrack?.duration).toBe(220);
|
||||
});
|
||||
|
||||
it('syncs ReplayGain tags from the resolver onto the live track', () => {
|
||||
seedQueueResolver('s1', [track('t1', { replayGainTrackDb: -7.2, replayGainPeak: 0.95 })]);
|
||||
|
||||
maybeSyncCurrentTrackFromResolver();
|
||||
|
||||
expect(usePlayerStore.getState().currentTrack?.replayGainTrackDb).toBe(-7.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeRefreshCurrentTrackMetadataFromIndex', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
_resetIndexRefreshInflightForTest();
|
||||
vi.restoreAllMocks();
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
replayGainMode: 'track',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: -6,
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track('t1', { replayGainTrackDb: -6.0, replayGainPeak: 0.8 }),
|
||||
queueItems: [ref('t1')],
|
||||
queueIndex: 0,
|
||||
isPlaying: true,
|
||||
currentRadio: null,
|
||||
volume: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
it('upgrades recalculated ReplayGain from the library index', async () => {
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1', libraryScope: '', syncPhase: 'ready',
|
||||
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
|
||||
}));
|
||||
onInvoke('library_get_track', () => ({
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'Track t1',
|
||||
album: 'Album',
|
||||
durationSec: 200,
|
||||
replayGainTrackDb: -8.5,
|
||||
replayGainPeak: 0.91,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}));
|
||||
|
||||
await maybeRefreshCurrentTrackMetadataFromIndex();
|
||||
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.replayGainTrackDb).toBe(-8.5);
|
||||
expect(s.currentTrack?.replayGainPeak).toBe(0.91);
|
||||
});
|
||||
|
||||
it('no-ops when the playing slot changed during index fetch', async () => {
|
||||
vi.spyOn(resolveSongMetaIndexFirst, 'resolveSongMetaIndexFirst').mockImplementation(async () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: track('t2', { replayGainTrackDb: -3.0 }),
|
||||
queueItems: [ref('t1'), ref('t2')],
|
||||
queueIndex: 1,
|
||||
});
|
||||
return {
|
||||
id: 't1',
|
||||
title: 'Track t1',
|
||||
album: 'Album',
|
||||
duration: 200,
|
||||
replayGain: { trackGain: -8.5, trackPeak: 0.91 },
|
||||
} as Awaited<ReturnType<typeof resolveSongMetaIndexFirst.resolveSongMetaIndexFirst>>;
|
||||
});
|
||||
|
||||
await maybeRefreshCurrentTrackMetadataFromIndex();
|
||||
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.id).toBe('t2');
|
||||
expect(s.currentTrack?.replayGainTrackDb).toBe(-3.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncIdleAppliesToQueueRef', () => {
|
||||
it('matches profile id to index key on the ref', () => {
|
||||
expect(syncIdleAppliesToQueueRef('profile-uuid', { serverId: 'profile-uuid', trackId: 't1' }))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* HTTP-stream playback often starts from a thin-queue placeholder (or any track
|
||||
* snapshot missing ReplayGain tags). `audio_play` then applies fallback gain.
|
||||
* The queue resolver fetches full metadata asynchronously — this side-effect
|
||||
* upgrades `currentTrack` and pushes fresh gain to the engine once tags land,
|
||||
* mirroring the loudness cache refresh path (`refreshLoudnessForTrack`).
|
||||
*
|
||||
* After library sync, {@link maybeRefreshCurrentTrackMetadataFromIndex} re-reads
|
||||
* index-first metadata for the live slot so recalculated ReplayGain tags apply
|
||||
* without re-starting playback.
|
||||
*/
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { LibrarySyncIdlePayload } from '@/lib/api/library/dto';
|
||||
import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst';
|
||||
import { mergePlaybackTrackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { isReplayGainActive } from '@/features/playback/store/loudnessGainCache';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
patchCachedTrack,
|
||||
subscribeQueueResolver,
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
||||
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
|
||||
function replayGainNeighbours(
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
): { prev: Track | null; next: Track | null } {
|
||||
const prev = queueIndex > 0 && queueItems[queueIndex - 1]
|
||||
? resolveQueueTrack(queueItems[queueIndex - 1])
|
||||
: null;
|
||||
const next = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
|
||||
? resolveQueueTrack(queueItems[queueIndex + 1])
|
||||
: null;
|
||||
return { prev, next };
|
||||
}
|
||||
|
||||
function resolvedReplayGainDb(
|
||||
track: Track,
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
): number | null {
|
||||
const auth = useAuthStore.getState();
|
||||
const { prev, next } = replayGainNeighbours(queueItems, queueIndex);
|
||||
return resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
|
||||
}
|
||||
|
||||
/** True when resolver metadata would change the ReplayGain bind for this slot. */
|
||||
export function shouldUpgradeReplayGainMetadata(
|
||||
prev: Track,
|
||||
next: Track,
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
): boolean {
|
||||
if (prev.replayGainPeak !== next.replayGainPeak) return true;
|
||||
return resolvedReplayGainDb(prev, queueItems, queueIndex)
|
||||
!== resolvedReplayGainDb(next, queueItems, queueIndex);
|
||||
}
|
||||
|
||||
/** True when resolver metadata would improve the live player-bar snapshot. */
|
||||
export function shouldSyncCurrentTrackMetadata(
|
||||
prev: Track,
|
||||
next: Track,
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
): boolean {
|
||||
if (prev.title === '…' && next.title && next.title !== '…') return true;
|
||||
if (prev.duration === 0 && next.duration > 0) return true;
|
||||
return shouldUpgradeReplayGainMetadata(prev, next, queueItems, queueIndex);
|
||||
}
|
||||
|
||||
function applyCurrentTrackMetadataUpgrade(
|
||||
prev: Track,
|
||||
merged: Track,
|
||||
queueItems: QueueItemRef[],
|
||||
queueIndex: number,
|
||||
): void {
|
||||
if (!shouldSyncCurrentTrackMetadata(prev, merged, queueItems, queueIndex)) return;
|
||||
|
||||
usePlayerStore.setState({ currentTrack: merged });
|
||||
patchCachedTrack(prev.id, {
|
||||
title: merged.title,
|
||||
duration: merged.duration,
|
||||
replayGainTrackDb: merged.replayGainTrackDb,
|
||||
replayGainAlbumDb: merged.replayGainAlbumDb,
|
||||
replayGainPeak: merged.replayGainPeak,
|
||||
});
|
||||
if (
|
||||
isReplayGainActive()
|
||||
&& shouldUpgradeReplayGainMetadata(prev, merged, queueItems, queueIndex)
|
||||
) {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a library sync-idle event applies to this queue ref's server. */
|
||||
export function syncIdleAppliesToQueueRef(syncServerId: string, ref: QueueItemRef): boolean {
|
||||
const scopeId = syncServerId.trim();
|
||||
if (!scopeId) return false;
|
||||
const profileId = resolveServerIdForIndexKey(scopeId) || scopeId;
|
||||
const refCanonical = canonicalQueueServerKey(ref.serverId);
|
||||
const refProfile = resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
|
||||
return ref.serverId === scopeId
|
||||
|| ref.serverId === profileId
|
||||
|| refCanonical === scopeId
|
||||
|| refCanonical === profileId
|
||||
|| refProfile === scopeId
|
||||
|| refProfile === profileId;
|
||||
}
|
||||
|
||||
/** Push resolver-fetched metadata onto the live track; upgrade engine gain when needed. */
|
||||
export function maybeSyncCurrentTrackFromResolver(): void {
|
||||
const state = usePlayerStore.getState();
|
||||
const { currentTrack, queueItems, queueIndex, isPlaying, currentRadio } = state;
|
||||
if (!currentTrack || !isPlaying || currentRadio) return;
|
||||
const ref = queueItems[queueIndex];
|
||||
if (!ref || ref.trackId !== currentTrack.id) return;
|
||||
|
||||
const resolved = resolveQueueTrack(ref, currentTrack);
|
||||
const merged = mergePlaybackTrackMetadata(currentTrack, resolved);
|
||||
applyCurrentTrackMetadataUpgrade(currentTrack, merged, queueItems, queueIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read index-first metadata for the playing slot and upgrade ReplayGain when
|
||||
* library tags differ from the live snapshot (post-sync recalc, new tags, peak).
|
||||
*/
|
||||
export async function maybeRefreshCurrentTrackMetadataFromIndex(): Promise<void> {
|
||||
const state = usePlayerStore.getState();
|
||||
const { currentTrack, queueItems, queueIndex, isPlaying, currentRadio } = state;
|
||||
if (!currentTrack || !isPlaying || currentRadio) return;
|
||||
const ref = queueItems[queueIndex];
|
||||
if (!ref || ref.trackId !== currentTrack.id) return;
|
||||
|
||||
const serverId = playbackCacheKeyForRef(ref);
|
||||
if (!serverId) return;
|
||||
|
||||
const trackId = currentTrack.id;
|
||||
const song = await resolveSongMetaIndexFirst(serverId, trackId);
|
||||
if (!song) return;
|
||||
|
||||
const live = usePlayerStore.getState();
|
||||
if (!live.isPlaying || live.currentRadio || !live.currentTrack) return;
|
||||
const liveRef = live.queueItems[live.queueIndex];
|
||||
if (live.currentTrack.id !== trackId || liveRef?.trackId !== trackId) return;
|
||||
|
||||
const merged = mergePlaybackTrackMetadata(live.currentTrack, songToTrack(song));
|
||||
applyCurrentTrackMetadataUpgrade(live.currentTrack, merged, live.queueItems, live.queueIndex);
|
||||
}
|
||||
|
||||
let indexRefreshInflight: Promise<void> | null = null;
|
||||
|
||||
export function scheduleCurrentTrackMetadataRefreshFromIndex(): void {
|
||||
if (indexRefreshInflight) return;
|
||||
indexRefreshInflight = maybeRefreshCurrentTrackMetadataFromIndex()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
indexRefreshInflight = null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-only: drop coalesced index refresh state. */
|
||||
export function _resetIndexRefreshInflightForTest(): void {
|
||||
indexRefreshInflight = null;
|
||||
}
|
||||
|
||||
function scheduleIndexRefreshAfterSyncIdle(payload: LibrarySyncIdlePayload): void {
|
||||
if (!payload.ok) return;
|
||||
const state = usePlayerStore.getState();
|
||||
if (!state.isPlaying || state.currentRadio || !state.currentTrack) return;
|
||||
const ref = state.queueItems[state.queueIndex];
|
||||
if (!ref || ref.trackId !== state.currentTrack.id) return;
|
||||
if (!syncIdleAppliesToQueueRef(payload.serverId, ref)) return;
|
||||
scheduleCurrentTrackMetadataRefreshFromIndex();
|
||||
}
|
||||
|
||||
subscribeQueueResolver(() => {
|
||||
maybeSyncCurrentTrackFromResolver();
|
||||
});
|
||||
|
||||
void listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) => {
|
||||
scheduleIndexRefreshAfterSyncIdle(payload);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSong } from '@/lib/api/subsonicLibrary';
|
||||
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { estimateLivePosition, orbitSnapshot } from '@/store/orbitRuntime';
|
||||
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
|
||||
@@ -57,10 +58,9 @@ type GetState = () => PlayerState;
|
||||
* `audio_resume` is enough.
|
||||
* - **Cold**: engine has no loaded stream (app relaunch, or track
|
||||
* ended and user hit play again). Promote any
|
||||
* `stream_completed_cache` to hot disk, refetch the song from
|
||||
* Navidrome for fresh ReplayGain metadata, call `audio_play`,
|
||||
* then seek to the persisted `currentTime`. A `getSong` failure
|
||||
* falls back to the in-memory `currentTrack`.
|
||||
* `stream_completed_cache` to hot disk, prefetch ReplayGain metadata
|
||||
* (index → getSong), call `audio_play`, then seek to the persisted
|
||||
* `currentTime`.
|
||||
*/
|
||||
export function runResume(set: SetState, get: GetState): void {
|
||||
clearAllPlaybackScheduleTimers();
|
||||
@@ -156,88 +156,55 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
}
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
|
||||
// Fetch fresh track data from server to get replay gain metadata
|
||||
getSong(currentTrack.id).then(freshSong => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
|
||||
const coldServerId = getPlaybackIndexKey();
|
||||
let trackToPlay = currentTrack;
|
||||
try {
|
||||
if (coldServerId) {
|
||||
trackToPlay = await enrichTrackPlaybackMetadata(currentTrack, coldServerId);
|
||||
}
|
||||
} catch { /* keep currentTrack */ }
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
if (trackToPlay !== currentTrack) set({ currentTrack: trackToPlay });
|
||||
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = resolveReplayGainDb(
|
||||
trackToPlay, coldPrev, coldNext,
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
||||
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
||||
invoke('audio_play', {
|
||||
url: coldUrl,
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
...audioPlayHiResBlendArgs(useAuthStore.getState()),
|
||||
analysisTrackId: trackToPlay.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: trackToPlay.suffix ?? null,
|
||||
startPaused: false,
|
||||
}).then(() => {
|
||||
if (getPlayGeneration() === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
|
||||
// Update store with fresh track data if available
|
||||
if (freshSong) set({ currentTrack: trackToPlay });
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = resolveReplayGainDb(
|
||||
trackToPlay, coldPrev, coldNext,
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const coldServerId = getPlaybackIndexKey();
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
||||
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
||||
invoke('audio_play', {
|
||||
url: coldUrl,
|
||||
volume: vol,
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
...audioPlayHiResBlendArgs(useAuthStore.getState()),
|
||||
analysisTrackId: trackToPlay.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: trackToPlay.suffix ?? null,
|
||||
startPaused: false,
|
||||
}).then(() => {
|
||||
if (getPlayGeneration() === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
|
||||
}).catch(() => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
// Fallback to currentTrack if fetch fails
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = resolveReplayGainDb(
|
||||
currentTrack, coldPrev, coldNext,
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
const coldServerId = getPlaybackIndexKey();
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
||||
recordEnginePlayUrl(currentTrack.id, coldUrl);
|
||||
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
|
||||
invoke('audio_play', {
|
||||
url: coldUrl,
|
||||
volume: vol,
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
...audioPlayHiResBlendArgs(useAuthStore.getState()),
|
||||
analysisTrackId: currentTrack.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: currentTrack.suffix ?? null,
|
||||
startPaused: false,
|
||||
}).catch((err: unknown) => {
|
||||
if (getPlayGeneration() !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
pushQueueOnPlaybackStart(queueItems, currentTrack, currentTime);
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play (cold resume) failed:', err);
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
setSeekFallbackTrackId,
|
||||
setSeekFallbackVisualTarget,
|
||||
} from '@/features/playback/store/seekFallbackState';
|
||||
import { noteEngineProgressForGapless } from '@/features/playback/store/gaplessProgressTracking';
|
||||
import {
|
||||
clearSeekTarget,
|
||||
setSeekTarget,
|
||||
@@ -66,6 +67,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
||||
invoke('audio_seek', { seconds: time }).then(() => {
|
||||
// Arm stale-progress guard only after backend acknowledged seek.
|
||||
setSeekTarget(time);
|
||||
noteEngineProgressForGapless(time);
|
||||
setSeekFallbackVisualTarget(null);
|
||||
clearSeekFallbackRetry();
|
||||
// Seeking straight into the crossfade pre-buffer window must kick off the
|
||||
@@ -99,6 +101,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
||||
// Keep stale progress ticks from snapping UI back to start while
|
||||
// recoverable seek retries are still in flight.
|
||||
setSeekTarget(time);
|
||||
noteEngineProgressForGapless(time);
|
||||
if (msg.includes('not seekable') && !sameBurst) {
|
||||
setSeekFallbackTrackId(s.currentTrack.id);
|
||||
setSeekFallbackRestartAt(now);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTracks, seedQueue } from '@/test/helpers/factories';
|
||||
@@ -22,13 +22,15 @@ describe('timeline history on queue replace', () => {
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
});
|
||||
|
||||
it('keeps session history when playTrack replaces the queue', () => {
|
||||
it('keeps session history when playTrack replaces the queue', async () => {
|
||||
const first = makeTracks(1);
|
||||
seedQueue(first, { index: 0, currentTrack: first[0] });
|
||||
const album = makeTracks(3);
|
||||
usePlayerStore.getState().playTrack(album[0]!, album, true, true);
|
||||
await vi.waitFor(() => {
|
||||
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
|
||||
});
|
||||
const history = getTimelineSessionHistorySnapshot();
|
||||
expect(history.some(h => h.trackId === first[0]!.id)).toBe(true);
|
||||
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as subsonicLibrary from '@/lib/api/subsonicLibrary';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
enrichTrackPlaybackMetadata,
|
||||
mergePlaybackTrackMetadata,
|
||||
trackNeedsPlaybackMetadataPrefetch,
|
||||
trackNeedsReplayGainMetadataPrefetch,
|
||||
} from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||
|
||||
const track = (extra: Partial<Track> = {}): Track => ({
|
||||
id: 't1',
|
||||
title: extra.title ?? 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'alb-1',
|
||||
duration: 200,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('trackNeedsReplayGainMetadataPrefetch', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true when ReplayGain is on and tags are missing', () => {
|
||||
expect(trackNeedsReplayGainMetadataPrefetch(track())).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when track gain is present', () => {
|
||||
expect(trackNeedsReplayGainMetadataPrefetch(track({ replayGainTrackDb: -6 }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePlaybackTrackMetadata', () => {
|
||||
it('fills placeholder title and ReplayGain from the resolved track', () => {
|
||||
const base = track({ title: '…', duration: 0 });
|
||||
const resolved = track({
|
||||
title: 'Resolved',
|
||||
duration: 240,
|
||||
replayGainTrackDb: -7.5,
|
||||
replayGainPeak: 0.99,
|
||||
});
|
||||
const merged = mergePlaybackTrackMetadata(base, resolved);
|
||||
expect(merged.title).toBe('Resolved');
|
||||
expect(merged.duration).toBe(240);
|
||||
expect(merged.replayGainTrackDb).toBe(-7.5);
|
||||
expect(merged.replayGainPeak).toBe(0.99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackNeedsPlaybackMetadataPrefetch', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'off',
|
||||
replayGainEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true for thin placeholder snapshots even when ReplayGain is off', () => {
|
||||
expect(trackNeedsPlaybackMetadataPrefetch(track({ title: '…' }))).toBe(true);
|
||||
expect(trackNeedsPlaybackMetadataPrefetch(track({ duration: 0 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when ReplayGain is on and peak is missing but gain tags exist', () => {
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
});
|
||||
expect(trackNeedsPlaybackMetadataPrefetch(track({ replayGainTrackDb: -6 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when ReplayGain tags and peak are present', () => {
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
});
|
||||
expect(trackNeedsPlaybackMetadataPrefetch(track({
|
||||
replayGainTrackDb: -6,
|
||||
replayGainPeak: 0.88,
|
||||
}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichTrackPlaybackMetadata', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
normalizationEngine: 'replaygain',
|
||||
replayGainEnabled: true,
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads ReplayGain from the local index before network', async () => {
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1', libraryScope: '', syncPhase: 'ready',
|
||||
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
|
||||
}));
|
||||
onInvoke('library_get_track', () => ({
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'Indexed',
|
||||
album: 'Album',
|
||||
durationSec: 200,
|
||||
replayGainTrackDb: -8.1,
|
||||
replayGainAlbumDb: -7.0,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}));
|
||||
const networkSpy = vi.spyOn(subsonicLibrary, 'getSongForServer');
|
||||
|
||||
const enriched = await enrichTrackPlaybackMetadata(track({ title: '…' }), 's1');
|
||||
expect(enriched.replayGainTrackDb).toBe(-8.1);
|
||||
expect(enriched.title).toBe('Indexed');
|
||||
expect(networkSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads replayGainPeak from the local index without network', async () => {
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1', libraryScope: '', syncPhase: 'ready',
|
||||
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
|
||||
}));
|
||||
onInvoke('library_get_track', () => ({
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'Indexed',
|
||||
album: 'Album',
|
||||
durationSec: 200,
|
||||
replayGainTrackDb: -8.1,
|
||||
replayGainPeak: 0.88,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}));
|
||||
const networkSpy = vi.spyOn(subsonicLibrary, 'getSongForServer');
|
||||
|
||||
const enriched = await enrichTrackPlaybackMetadata(
|
||||
track({ replayGainTrackDb: -8.1 }),
|
||||
's1',
|
||||
);
|
||||
expect(enriched.replayGainPeak).toBe(0.88);
|
||||
expect(networkSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes recalculated ReplayGain from the index when tags already exist', async () => {
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1', libraryScope: '', syncPhase: 'ready',
|
||||
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
|
||||
}));
|
||||
onInvoke('library_get_track', () => ({
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'Indexed',
|
||||
album: 'Album',
|
||||
durationSec: 200,
|
||||
replayGainTrackDb: -8.5,
|
||||
replayGainPeak: 0.91,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}));
|
||||
|
||||
const enriched = await enrichTrackPlaybackMetadata(
|
||||
track({ replayGainTrackDb: -6.0, replayGainPeak: 0.8 }),
|
||||
's1',
|
||||
);
|
||||
expect(enriched.replayGainTrackDb).toBe(-8.5);
|
||||
expect(enriched.replayGainPeak).toBe(0.91);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { libraryGetTrack } from '@/lib/api/library';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { isReplayGainActive } from '@/features/playback/store/loudnessGainCache';
|
||||
import { trackToSong } from '@/lib/library/trackDtoMapping';
|
||||
import { libraryIsReady } from '@/lib/library/libraryReady';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
|
||||
async function resolveSongMetaFromIndexForPlaybackEnrich(
|
||||
serverId: string,
|
||||
songId: string,
|
||||
): Promise<SubsonicSong | null> {
|
||||
if (!await libraryIsReady(serverId)) return null;
|
||||
try {
|
||||
const dto = await libraryGetTrack(serverId, songId);
|
||||
if (dto) return trackToSong(dto);
|
||||
} catch { /* index unavailable */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when ReplayGain is on and the track snapshot has no gain tags yet. */
|
||||
export function trackNeedsReplayGainMetadataPrefetch(track: Track): boolean {
|
||||
if (!isReplayGainActive()) return false;
|
||||
return track.replayGainTrackDb == null && track.replayGainAlbumDb == null;
|
||||
}
|
||||
|
||||
/** True when index/getSong prefetch would improve a thin snapshot or ReplayGain tags. */
|
||||
export function trackNeedsPlaybackMetadataPrefetch(track: Track): boolean {
|
||||
if (track.title === '…' || track.duration === 0) return true;
|
||||
if (trackNeedsReplayGainMetadataPrefetch(track)) return true;
|
||||
return isReplayGainActive() && track.replayGainPeak == null
|
||||
&& (track.replayGainTrackDb != null || track.replayGainAlbumDb != null);
|
||||
}
|
||||
|
||||
/** Merge resolver/index metadata onto a thin playback snapshot without dropping queue flags. */
|
||||
export function mergePlaybackTrackMetadata(base: Track, resolved: Track): Track {
|
||||
const thin = base.title === '…' || base.duration === 0;
|
||||
return {
|
||||
...(thin ? resolved : base),
|
||||
...base,
|
||||
id: base.id,
|
||||
autoAdded: base.autoAdded,
|
||||
radioAdded: base.radioAdded,
|
||||
playNextAdded: base.playNextAdded,
|
||||
title: thin && resolved.title ? resolved.title : base.title,
|
||||
artist: thin && resolved.artist ? resolved.artist : base.artist,
|
||||
album: thin && resolved.album ? resolved.album : base.album,
|
||||
albumId: resolved.albumId || base.albumId,
|
||||
duration: resolved.duration > 0 ? resolved.duration : base.duration,
|
||||
replayGainTrackDb: resolved.replayGainTrackDb ?? base.replayGainTrackDb,
|
||||
replayGainAlbumDb: resolved.replayGainAlbumDb ?? base.replayGainAlbumDb,
|
||||
replayGainPeak: resolved.replayGainPeak ?? base.replayGainPeak,
|
||||
suffix: resolved.suffix ?? base.suffix,
|
||||
bitRate: resolved.bitRate ?? base.bitRate,
|
||||
samplingRate: resolved.samplingRate ?? base.samplingRate,
|
||||
bitDepth: resolved.bitDepth ?? base.bitDepth,
|
||||
coverArt: resolved.coverArt ?? base.coverArt,
|
||||
artistId: resolved.artistId ?? base.artistId,
|
||||
serverId: base.serverId ?? resolved.serverId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch playback metadata (thin fields + ReplayGain) from the local index
|
||||
* before binding the engine on stream / gapless paths. Network fallback stays
|
||||
* in the queue resolver / {@link resolveSongMetaIndexFirst} path.
|
||||
*/
|
||||
export async function enrichTrackPlaybackMetadata(
|
||||
track: Track,
|
||||
serverId: string,
|
||||
): Promise<Track> {
|
||||
if (!serverId) return track;
|
||||
const needsPrefetch = trackNeedsPlaybackMetadataPrefetch(track);
|
||||
const rgRecheck = isReplayGainActive()
|
||||
&& !needsPrefetch
|
||||
&& (track.replayGainTrackDb != null
|
||||
|| track.replayGainAlbumDb != null
|
||||
|| track.replayGainPeak != null);
|
||||
if (!needsPrefetch && !rgRecheck) return track;
|
||||
const song = await resolveSongMetaFromIndexForPlaybackEnrich(serverId, track.id);
|
||||
if (!song) return track;
|
||||
return mergePlaybackTrackMetadata(track, songToTrack(song));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||
|
||||
/**
|
||||
* Index-first metadata before `audio_play` / `audio_chain_preload`.
|
||||
* Callers in the playback store should await {@link refreshLoudnessForTrack}
|
||||
* separately so dependency-cruiser does not route utils → store → utils cycles.
|
||||
*/
|
||||
export async function prepareTrackForEngineBind(
|
||||
track: Track,
|
||||
serverId: string,
|
||||
): Promise<Track> {
|
||||
return serverId
|
||||
? enrichTrackPlaybackMetadata(track, serverId)
|
||||
: track;
|
||||
}
|
||||
@@ -143,8 +143,15 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
// Initial draw for static styles when style, track, or waveform payload changes.
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
progressRef.current = 0;
|
||||
bufferedRef.current = 0;
|
||||
visualProgressRef.current = 0;
|
||||
visualTargetProgressRef.current = 0;
|
||||
// React Compiler immutability rule: intentional imperative reset on track change.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
progressAnchorRef.current = { progress: 0, atMs: performance.now() };
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, 0, 0);
|
||||
}, [
|
||||
seekbarStyle,
|
||||
trackId,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type LibraryArtistDto,
|
||||
type LibraryEntityType,
|
||||
type LibraryFilterClause,
|
||||
type LibraryTrackDto,
|
||||
} from '@/lib/api/library';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { search } from '@/lib/api/subsonicSearch';
|
||||
@@ -31,6 +30,9 @@ import { logLibrarySearch, timed } from './libraryDevLog';
|
||||
import { isLosslessSuffix } from './losslessFormats';
|
||||
import { albumIsCompilation } from './albumCompilation';
|
||||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
|
||||
import { trackToSong } from './trackDtoMapping';
|
||||
|
||||
export { resolveTrackCoverArtId, trackToSong } from './trackDtoMapping';
|
||||
|
||||
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
|
||||
|
||||
@@ -149,66 +151,6 @@ function buildRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover art id for a library track — mirrors Rust cover backfill
|
||||
* (`COALESCE(cover_art_id, album_id)`). Many servers only expose album art.
|
||||
*/
|
||||
export function resolveTrackCoverArtId(
|
||||
hot: Pick<LibraryTrackDto, 'coverArtId' | 'albumId'>,
|
||||
song: Partial<SubsonicSong> = {},
|
||||
): string | undefined {
|
||||
const songArt = typeof song.coverArt === 'string' ? song.coverArt.trim() : '';
|
||||
const hotArt = typeof hot.coverArtId === 'string' ? hot.coverArtId.trim() : '';
|
||||
// `raw_json` per-disc `coverArt` wins over a stale index `cover_art_id` (often disc 1).
|
||||
if (songArt && hotArt && songArt !== hotArt && songArt.startsWith('mf-')) {
|
||||
return songArt;
|
||||
}
|
||||
for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) {
|
||||
const id = typeof c === 'string' ? c.trim() : '';
|
||||
if (id) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
|
||||
const raw = isObject(t.rawJson) ? t.rawJson : {};
|
||||
const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined;
|
||||
const base: SubsonicSong = {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist ?? '',
|
||||
album: t.album,
|
||||
albumId: t.albumId ?? '',
|
||||
artistId: t.artistId ?? undefined,
|
||||
duration: t.durationSec,
|
||||
track: t.trackNumber ?? undefined,
|
||||
discNumber: t.discNumber ?? undefined,
|
||||
coverArt: resolveTrackCoverArtId(t),
|
||||
year: t.year ?? undefined,
|
||||
genre: t.genre ?? undefined,
|
||||
suffix: t.suffix ?? undefined,
|
||||
bitRate: t.bitRate ?? undefined,
|
||||
size: t.sizeBytes ?? undefined,
|
||||
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
|
||||
userRating: t.userRating ?? undefined,
|
||||
playCount: t.playCount ?? undefined,
|
||||
bpm: resolvedBpm,
|
||||
isrc: t.isrc ?? undefined,
|
||||
albumArtist: t.albumArtist ?? undefined,
|
||||
};
|
||||
// `rawJson` is the authoritative original song — let it override the
|
||||
// hot-column fallbacks (it carries OpenSubsonic extras too).
|
||||
const merged: SubsonicSong = { ...base, ...(raw as Partial<SubsonicSong>) };
|
||||
const coverArt = resolveTrackCoverArtId(t, merged);
|
||||
if (coverArt) merged.coverArt = coverArt;
|
||||
if (resolvedBpm != null) merged.bpm = resolvedBpm;
|
||||
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
|
||||
merged.localBpmSource = t.bpmSource;
|
||||
}
|
||||
if (t.serverId) merged.serverId = t.serverId;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Merge `raw_json` without nullish Subsonic fields wiping hot columns (e.g. year). */
|
||||
function mergeAlbumRawJson(base: SubsonicAlbum, raw: Partial<SubsonicAlbum>): SubsonicAlbum {
|
||||
const merged = { ...base } as SubsonicAlbum & Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { libraryGetTrack } from '@/lib/api/library';
|
||||
import { getSongForServer } from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { trackToSong } from '@/lib/library/trackDtoMapping';
|
||||
import { libraryIsReady } from '@/lib/library/libraryReady';
|
||||
|
||||
/**
|
||||
* Index-first song metadata: local SQLite (`libraryGetTrack`) when the index is
|
||||
* ready, else Subsonic `getSong.view`. Shared by playback prefetch, Now Playing,
|
||||
* and the queue resolver's network fallback family.
|
||||
*/
|
||||
export async function resolveSongMetaIndexFirst(
|
||||
serverId: string,
|
||||
songId: string,
|
||||
): Promise<SubsonicSong | null> {
|
||||
if (await libraryIsReady(serverId)) {
|
||||
try {
|
||||
const dto = await libraryGetTrack(serverId, songId);
|
||||
if (dto) return trackToSong(dto);
|
||||
} catch { /* index error → network fallback */ }
|
||||
}
|
||||
return getSongForServer(serverId, songId);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { LibraryTrackDto } from '@/lib/api/library';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* Cover art id for a library track — mirrors Rust cover backfill
|
||||
* (`COALESCE(cover_art_id, album_id)`). Many servers only expose album art.
|
||||
*/
|
||||
export function resolveTrackCoverArtId(
|
||||
hot: Pick<LibraryTrackDto, 'coverArtId' | 'albumId'>,
|
||||
song: Partial<SubsonicSong> = {},
|
||||
): string | undefined {
|
||||
const songArt = typeof song.coverArt === 'string' ? song.coverArt.trim() : '';
|
||||
const hotArt = typeof hot.coverArtId === 'string' ? hot.coverArtId.trim() : '';
|
||||
// `raw_json` per-disc `coverArt` wins over a stale index `cover_art_id` (often disc 1).
|
||||
if (songArt && hotArt && songArt !== hotArt && songArt.startsWith('mf-')) {
|
||||
return songArt;
|
||||
}
|
||||
for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) {
|
||||
const id = typeof c === 'string' ? c.trim() : '';
|
||||
if (id) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
|
||||
const raw = isObject(t.rawJson) ? t.rawJson : {};
|
||||
const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined;
|
||||
const base: SubsonicSong = {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist ?? '',
|
||||
album: t.album,
|
||||
albumId: t.albumId ?? '',
|
||||
artistId: t.artistId ?? undefined,
|
||||
duration: t.durationSec,
|
||||
track: t.trackNumber ?? undefined,
|
||||
discNumber: t.discNumber ?? undefined,
|
||||
coverArt: resolveTrackCoverArtId(t),
|
||||
year: t.year ?? undefined,
|
||||
genre: t.genre ?? undefined,
|
||||
suffix: t.suffix ?? undefined,
|
||||
bitRate: t.bitRate ?? undefined,
|
||||
size: t.sizeBytes ?? undefined,
|
||||
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
|
||||
userRating: t.userRating ?? undefined,
|
||||
playCount: t.playCount ?? undefined,
|
||||
bpm: resolvedBpm,
|
||||
isrc: t.isrc ?? undefined,
|
||||
albumArtist: t.albumArtist ?? undefined,
|
||||
};
|
||||
// `rawJson` is the authoritative original song — let it override the
|
||||
// hot-column fallbacks (it carries OpenSubsonic extras too).
|
||||
const merged: SubsonicSong = { ...base, ...(raw as Partial<SubsonicSong>) };
|
||||
const coverArt = resolveTrackCoverArtId(t, merged);
|
||||
if (coverArt) merged.coverArt = coverArt;
|
||||
if (resolvedBpm != null) merged.bpm = resolvedBpm;
|
||||
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
|
||||
merged.localBpmSource = t.bpmSource;
|
||||
}
|
||||
if (t.replayGainTrackDb != null || t.replayGainAlbumDb != null || t.replayGainPeak != null) {
|
||||
merged.replayGain = {
|
||||
...merged.replayGain,
|
||||
trackGain: t.replayGainTrackDb ?? merged.replayGain?.trackGain,
|
||||
albumGain: t.replayGainAlbumDb ?? merged.replayGain?.albumGain,
|
||||
trackPeak: t.replayGainPeak ?? merged.replayGain?.trackPeak,
|
||||
};
|
||||
}
|
||||
if (t.serverId) merged.serverId = t.serverId;
|
||||
return merged;
|
||||
}
|
||||
Reference in New Issue
Block a user