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

This commit is contained in:
cucadmuh
2026-07-04 16:00:03 +03:00
committed by GitHub
parent 9183c3d657
commit fdbb9deac6
52 changed files with 2564 additions and 488 deletions
@@ -0,0 +1,2 @@
-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak).
ALTER TABLE track ADD COLUMN replay_gain_peak REAL;
@@ -1438,6 +1438,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -213,6 +213,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -208,6 +208,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -1492,6 +1492,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: Some(format!("hash-{id}")),
server_updated_at: None,
server_created_at: None,
@@ -212,6 +212,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -106,6 +106,7 @@ pub struct LibraryTrackDto {
pub bpm_source: Option<String>,
pub replay_gain_track_db: Option<f64>,
pub replay_gain_album_db: Option<f64>,
pub replay_gain_peak: Option<f64>,
pub server_updated_at: Option<i64>,
pub server_created_at: Option<i64>,
@@ -157,6 +158,7 @@ impl LibraryTrackDto {
bpm_source: None,
replay_gain_track_db: row.replay_gain_track_db,
replay_gain_album_db: row.replay_gain_album_db,
replay_gain_peak: row.replay_gain_peak,
server_updated_at: row.server_updated_at,
server_created_at: row.server_created_at,
synced_at: row.synced_at,
@@ -750,6 +752,7 @@ mod tests {
bpm: Some(120),
replay_gain_track_db: Some(-1.2),
replay_gain_album_db: Some(-0.8),
replay_gain_peak: Some(0.95),
content_hash: Some("deadbeef".into()),
server_updated_at: Some(1_700_000_000),
server_created_at: Some(1_699_000_000),
@@ -780,6 +783,7 @@ mod tests {
"mbidRecording",
"replayGainTrackDb",
"replayGainAlbumDb",
"replayGainPeak",
"serverUpdatedAt",
"syncedAt",
"rawJson",
@@ -230,6 +230,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -288,6 +288,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -375,6 +375,7 @@ fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<
mbid_recording: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
server_updated_at: None,
server_created_at: None,
synced_at: row.get(offset + 21)?,
@@ -429,6 +430,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -180,6 +180,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -233,6 +233,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -34,6 +34,7 @@ fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, duration_se
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -79,6 +80,7 @@ fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: if hash.is_empty() {
None
} else {
@@ -503,6 +505,7 @@ fn recent_plays_includes_album_cover_metadata() {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -53,6 +53,7 @@ pub struct TrackRow {
pub bpm: Option<i64>,
pub replay_gain_track_db: Option<f64>,
pub replay_gain_album_db: Option<f64>,
pub replay_gain_peak: Option<f64>,
pub content_hash: Option<String>,
pub server_updated_at: Option<i64>,
pub server_created_at: Option<i64>,
@@ -150,6 +151,7 @@ impl<'a> TrackRepository<'a> {
r.bpm,
r.replay_gain_track_db,
r.replay_gain_album_db,
r.replay_gain_peak,
r.content_hash,
r.server_updated_at,
r.server_created_at,
@@ -189,6 +191,7 @@ impl<'a> TrackRepository<'a> {
r.bpm,
r.replay_gain_track_db,
r.replay_gain_album_db,
r.replay_gain_peak,
r.content_hash,
r.server_updated_at,
r.server_created_at,
@@ -499,6 +502,7 @@ impl<'a> TrackRepository<'a> {
r.bpm,
r.replay_gain_track_db,
r.replay_gain_album_db,
r.replay_gain_peak,
r.content_hash,
r.server_updated_at,
r.server_created_at,
@@ -673,27 +677,27 @@ const TRACK_COLUMNS: &str = "\
album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, \
played_at, server_path, library_id, isrc, mbid_recording, bpm, \
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at, \
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at, \
server_created_at, deleted, synced_at, raw_json";
const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, replay_gain_peak, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 \
ORDER BY disc_number ASC NULLS LAST, track_number ASC NULLS LAST, id ASC";
@@ -733,12 +737,13 @@ pub(crate) fn row_to_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Trac
bpm: row.get(26)?,
replay_gain_track_db: row.get(27)?,
replay_gain_album_db: row.get(28)?,
content_hash: row.get(29)?,
server_updated_at: row.get(30)?,
server_created_at: row.get(31)?,
deleted: row.get::<_, i64>(32)? != 0,
synced_at: row.get(33)?,
raw_json: row.get(34)?,
replay_gain_peak: row.get(29)?,
content_hash: row.get(30)?,
server_updated_at: row.get(31)?,
server_created_at: row.get(32)?,
deleted: row.get::<_, i64>(33)? != 0,
synced_at: row.get(34)?,
raw_json: row.get(35)?,
})
}
@@ -748,60 +753,8 @@ INSERT INTO track (
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
played_at, server_path, library_id, isrc, mbid_recording, bpm,
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
server_created_at, deleted, synced_at, raw_json
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
?33, ?34, ?35
)
ON CONFLICT(server_id, id) DO UPDATE SET
title = excluded.title,
title_sort = excluded.title_sort,
artist = excluded.artist,
artist_id = excluded.artist_id,
album = excluded.album,
album_id = excluded.album_id,
album_artist = excluded.album_artist,
duration_sec = excluded.duration_sec,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
year = excluded.year,
genre = excluded.genre,
suffix = excluded.suffix,
bit_rate = excluded.bit_rate,
size_bytes = excluded.size_bytes,
cover_art_id = excluded.cover_art_id,
starred_at = excluded.starred_at,
user_rating = excluded.user_rating,
play_count = excluded.play_count,
played_at = excluded.played_at,
server_path = excluded.server_path,
library_id = excluded.library_id,
isrc = excluded.isrc,
mbid_recording = excluded.mbid_recording,
bpm = excluded.bpm,
replay_gain_track_db = excluded.replay_gain_track_db,
replay_gain_album_db = excluded.replay_gain_album_db,
-- E2: never let a sync (which passes NULL content_hash) clobber the
-- playback-derived md5_16kb written via library_patch_track / the analysis
-- bridge. A non-empty incoming hash still wins.
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
server_updated_at = excluded.server_updated_at,
server_created_at = excluded.server_created_at,
deleted = excluded.deleted,
synced_at = excluded.synced_at,
raw_json = excluded.raw_json
"#;
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
INSERT INTO track (
server_id, id, title, title_sort, artist, artist_id, album, album_id,
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
played_at, server_path, library_id, isrc, mbid_recording, bpm,
replay_gain_track_db, replay_gain_album_db, content_hash, server_updated_at,
server_created_at, deleted, synced_at, raw_json, resync_gen
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
@@ -835,6 +788,60 @@ ON CONFLICT(server_id, id) DO UPDATE SET
bpm = excluded.bpm,
replay_gain_track_db = excluded.replay_gain_track_db,
replay_gain_album_db = excluded.replay_gain_album_db,
replay_gain_peak = excluded.replay_gain_peak,
-- E2: never let a sync (which passes NULL content_hash) clobber the
-- playback-derived md5_16kb written via library_patch_track / the analysis
-- bridge. A non-empty incoming hash still wins.
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
server_updated_at = excluded.server_updated_at,
server_created_at = excluded.server_created_at,
deleted = excluded.deleted,
synced_at = excluded.synced_at,
raw_json = excluded.raw_json
"#;
const UPSERT_INITIAL_RESYNC_SQL: &str = r#"
INSERT INTO track (
server_id, id, title, title_sort, artist, artist_id, album, album_id,
album_artist, duration_sec, track_number, disc_number, year, genre, suffix,
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count,
played_at, server_path, library_id, isrc, mbid_recording, bpm,
replay_gain_track_db, replay_gain_album_db, replay_gain_peak, content_hash, server_updated_at,
server_created_at, deleted, synced_at, raw_json, resync_gen
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32,
?33, ?34, ?35, ?36, ?37
)
ON CONFLICT(server_id, id) DO UPDATE SET
title = excluded.title,
title_sort = excluded.title_sort,
artist = excluded.artist,
artist_id = excluded.artist_id,
album = excluded.album,
album_id = excluded.album_id,
album_artist = excluded.album_artist,
duration_sec = excluded.duration_sec,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
year = excluded.year,
genre = excluded.genre,
suffix = excluded.suffix,
bit_rate = excluded.bit_rate,
size_bytes = excluded.size_bytes,
cover_art_id = excluded.cover_art_id,
starred_at = excluded.starred_at,
user_rating = excluded.user_rating,
play_count = excluded.play_count,
played_at = excluded.played_at,
server_path = excluded.server_path,
library_id = excluded.library_id,
isrc = excluded.isrc,
mbid_recording = excluded.mbid_recording,
bpm = excluded.bpm,
replay_gain_track_db = excluded.replay_gain_track_db,
replay_gain_album_db = excluded.replay_gain_album_db,
replay_gain_peak = excluded.replay_gain_peak,
content_hash = COALESCE(NULLIF(excluded.content_hash, ''), track.content_hash),
server_updated_at = excluded.server_updated_at,
server_created_at = excluded.server_created_at,
@@ -887,6 +894,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: Some("hash-abc".into()),
server_updated_at: Some(1_700_000_000),
server_created_at: Some(1_699_000_000),
@@ -356,6 +356,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
+70 -1
View File
@@ -12,11 +12,14 @@ use tauri::Manager;
///
/// Migration checklist (wiring, data backfill, open/swap path):
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 14;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 15;
/// One-time data repair after migration 014 (`artist.name_sort`).
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
/// One-time backfill after migration 015 (`track.replay_gain_peak`).
pub(crate) const REPLAY_GAIN_PEAK_RECONCILE_ID: &str = "replay_gain_peak_reconcile_v1";
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
/// fires (spec §5.7 / P22): the library is treated as incompatible, must be
@@ -38,6 +41,8 @@ pub(crate) const MIGRATION_013_ARTIST_ARTWORK_LOOKUP: &str =
include_str!("../migrations/013_artist_artwork_lookup.sql");
pub(crate) const MIGRATION_014_ARTIST_NAME_SORT: &str =
include_str!("../migrations/014_artist_name_sort.sql");
pub(crate) const MIGRATION_015_REPLAY_GAIN_PEAK: &str =
include_str!("../migrations/015_replay_gain_peak.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
@@ -46,6 +51,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
(14, MIGRATION_014_ARTIST_NAME_SORT),
(15, MIGRATION_015_REPLAY_GAIN_PEAK),
];
/// Idempotent repair — also runs after the migration runner on every open so
@@ -589,6 +595,7 @@ fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Co
fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> {
run_migrations(conn)?;
maybe_reconcile_artist_name_sort(conn)?;
maybe_reconcile_replay_gain_peak(conn)?;
ensure_genre_tags_schema(conn)?;
checkpoint_wal_conn(conn, "open")?;
Ok(())
@@ -721,6 +728,68 @@ fn repair_artist_name_sort_keys(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
fn replay_gain_peak_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
let column_exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('track') WHERE name = 'replay_gain_peak'",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(column_exists > 0)
}
fn replay_gain_peak_reconcile_completed(conn: &Connection) -> rusqlite::Result<bool> {
let completed: Option<Option<i64>> = conn
.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![REPLAY_GAIN_PEAK_RECONCILE_ID],
|row| row.get(0),
)
.optional()?;
Ok(completed.flatten().is_some())
}
fn mark_replay_gain_peak_reconcile_completed(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
VALUES (?1, 0, strftime('%s','now'), strftime('%s','now')) \
ON CONFLICT(id) DO UPDATE SET completed_at = excluded.completed_at",
params![REPLAY_GAIN_PEAK_RECONCILE_ID],
)?;
Ok(())
}
/// One-time backfill after schema 015 — project peak from stored `raw_json`.
fn repair_replay_gain_peak_from_raw_json(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"UPDATE track SET replay_gain_peak = json_extract(raw_json, '$.replayGain.trackPeak') \
WHERE replay_gain_peak IS NULL \
AND json_type(json_extract(raw_json, '$.replayGain.trackPeak')) = 'real'",
[],
)?;
conn.execute(
"UPDATE track SET replay_gain_peak = json_extract(raw_json, '$.rgTrackPeak') \
WHERE replay_gain_peak IS NULL \
AND json_type(json_extract(raw_json, '$.rgTrackPeak')) = 'real'",
[],
)?;
Ok(())
}
/// One-time reconcile after schema 015 — not on every open.
fn maybe_reconcile_replay_gain_peak(conn: &Connection) -> rusqlite::Result<()> {
if !replay_gain_peak_column_exists(conn)? {
return Ok(());
}
if replay_gain_peak_reconcile_completed(conn)? {
return Ok(());
}
repair_replay_gain_peak_from_raw_json(conn)?;
mark_replay_gain_peak_reconcile_completed(conn)?;
Ok(())
}
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
run_migrations_with(
conn,
@@ -639,6 +639,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: Some(server_updated_at),
server_created_at: None,
@@ -2265,6 +2265,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -2306,6 +2307,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -70,6 +70,10 @@ pub fn subsonic_song_to_track_row(
.get("replayGain")
.and_then(|rg| rg.get("albumGain"))
.and_then(|v| v.as_f64()),
replay_gain_peak: raw_value
.get("replayGain")
.and_then(|rg| rg.get("trackPeak"))
.and_then(|v| v.as_f64()),
content_hash: None,
server_updated_at: None,
server_created_at: None,
@@ -134,6 +138,7 @@ pub fn navidrome_song_to_track_row(
bpm: raw.get("bpm").and_then(|v| v.as_i64()),
replay_gain_track_db: raw.get("rgTrackGain").and_then(|v| v.as_f64()),
replay_gain_album_db: raw.get("rgAlbumGain").and_then(|v| v.as_f64()),
replay_gain_peak: raw.get("rgTrackPeak").and_then(|v| v.as_f64()),
content_hash: None,
server_updated_at,
server_created_at: raw
@@ -258,7 +263,7 @@ mod tests {
"track": 3,
"year": 2024,
"musicBrainzId": "mb-1",
"replayGain": { "trackGain": -1.2, "albumGain": -0.8 }
"replayGain": { "trackGain": -1.2, "albumGain": -0.8, "trackPeak": 0.91 }
});
let song: Song = serde_json::from_value(raw.clone()).unwrap();
let row = subsonic_song_to_track_row("s1", &song, &raw, 1_000, Some("lib-fb"));
@@ -268,6 +273,7 @@ mod tests {
assert_eq!(row.mbid_recording.as_deref(), Some("mb-1"));
assert_eq!(row.replay_gain_track_db, Some(-1.2));
assert_eq!(row.replay_gain_album_db, Some(-0.8));
assert_eq!(row.replay_gain_peak, Some(0.91));
// Fallback library_id kicks in when the song didn't ship one.
assert_eq!(row.library_id.as_deref(), Some("lib-fb"));
assert!(row.raw_json.contains("replayGain"));
@@ -271,6 +271,7 @@ mod tests {
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
replay_gain_peak: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,