feat(plex): split same-title Plex albums into selectable versions

A Plex album_key is a hash of artist+album, so two distinct Plex albums
that share a title (e.g. a matched album plus loose files added as a second
album) collapsed into one cache album and their tracks interleaved
(1,1,2,2,...) in the album view with no way to pick a version.

Capture parentRatingKey (the real per-album Plex id) on sync, store it in a
new plex_cache_tracks column, and key the album-view version grouping on it
so each Plex album becomes its own version in the existing header picker
(best quality first). Pre-resync rows lack the column and fall back to the
old single-group behaviour; a re-sync backfills it.

cc
This commit is contained in:
vicrodh committed 2026-06-08 18:28:15 -06:00
1 parent ca4b43d635
commit 1409c9d917
2 files changed
+42 -7

No files matched your search

+30 -6
View File
@@ -47,6 +47,10 @@ pub struct PlexTrack {
pub disc_number: Option<u32>, pub disc_number: Option<u32>,
pub year: Option<u32>, pub year: Option<u32>,
pub genre: Option<String>, pub genre: Option<String>,
/// `parentRatingKey` — the Plex album this track belongs to. Distinct per
/// physical album/edition, so it separates two albums that share the same
/// title+artist (which `album_key`, a title+artist hash, collapses into one).
pub parent_rating_key: Option<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -128,6 +132,9 @@ pub struct PlexCachedTrack {
pub album_key: String, pub album_key: String,
pub track_number: Option<u32>, pub track_number: Option<u32>,
pub disc_number: Option<u32>, pub disc_number: Option<u32>,
/// The Plex album (`parentRatingKey`) this track belongs to — used to split
/// distinct same-title albums into separate versions in the album view.
pub parent_rating_key: Option<String>,
} }
/// An artist aggregated client-side from the flat `plex_cache_tracks` table /// An artist aggregated client-side from the flat `plex_cache_tracks` table
@@ -183,6 +190,7 @@ struct TrackBuilder {
disc_number: Option<u32>, disc_number: Option<u32>,
year: Option<u32>, year: Option<u32>,
genre: Option<String>, genre: Option<String>,
parent_rating_key: Option<String>,
} }
fn normalize_base_url(base_url: &str) -> String { fn normalize_base_url(base_url: &str) -> String {
@@ -251,6 +259,11 @@ fn open_plex_cache_db() -> Result<Connection, String> {
"album_key TEXT", "album_key TEXT",
"year INTEGER", "year INTEGER",
"genre TEXT", "genre TEXT",
// parent_rating_key (2026-06-08): the Plex album a track belongs to, so
// two same-title albums no longer interleave in the album view. NULL on
// pre-migration rows (and rows synced before this column) — a re-sync
// backfills it; the album view falls back to one version meanwhile.
"parent_rating_key TEXT",
] { ] {
let stmt = format!("ALTER TABLE plex_cache_tracks ADD COLUMN {col}"); let stmt = format!("ALTER TABLE plex_cache_tracks ADD COLUMN {col}");
let _ = conn.execute(&stmt, []); let _ = conn.execute(&stmt, []);
@@ -605,6 +618,7 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
disc_number: parse_u32(get_attr(start_tag, "parentIndex")), disc_number: parse_u32(get_attr(start_tag, "parentIndex")),
year, year,
genre, genre,
parent_rating_key: get_attr(start_tag, "parentRatingKey"),
..Default::default() ..Default::default()
}; };
@@ -693,6 +707,7 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
disc_number: t.disc_number, disc_number: t.disc_number,
year: t.year, year: t.year,
genre: t.genre, genre: t.genre,
parent_rating_key: t.parent_rating_key,
}) })
} }
@@ -1019,7 +1034,7 @@ pub fn plex_cache_get_tracks(
.prepare( .prepare(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container, "SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre year, genre, parent_rating_key
FROM plex_cache_tracks FROM plex_cache_tracks
WHERE section_key = ?1 WHERE section_key = ?1
ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE
@@ -1050,6 +1065,7 @@ pub fn plex_cache_get_tracks(
disc_number: row.get(14)?, disc_number: row.get(14)?,
year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32), year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32),
genre: row.get(16)?, genre: row.get(16)?,
parent_rating_key: row.get(17)?,
}) })
}) })
.map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?; .map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?;
@@ -1061,7 +1077,7 @@ pub fn plex_cache_get_tracks(
.prepare( .prepare(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container, "SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre year, genre, parent_rating_key
FROM plex_cache_tracks FROM plex_cache_tracks
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT ?1", LIMIT ?1",
@@ -1091,6 +1107,7 @@ pub fn plex_cache_get_tracks(
disc_number: row.get(14)?, disc_number: row.get(14)?,
year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32), year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32),
genre: row.get(16)?, genre: row.get(16)?,
parent_rating_key: row.get(17)?,
}) })
}) })
.map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?; .map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?;
@@ -1121,7 +1138,7 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
let sql = format!( let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container, "SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre year, genre, parent_rating_key
FROM plex_cache_tracks FROM plex_cache_tracks
WHERE rating_key IN ({})", WHERE rating_key IN ({})",
placeholders placeholders
@@ -1154,6 +1171,7 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
disc_number: row.get(14)?, disc_number: row.get(14)?,
year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32), year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32),
genre: row.get(16)?, genre: row.get(16)?,
parent_rating_key: row.get(17)?,
}) })
}) })
.map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?; .map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?;
@@ -1381,7 +1399,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result<Vec<PlexCachedTr
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key
FROM plex_cache_tracks FROM plex_cache_tracks
WHERE album_key = ?1 WHERE album_key = ?1
ORDER BY disc_number, track_number, title COLLATE NOCASE", ORDER BY disc_number, track_number, title COLLATE NOCASE",
@@ -1402,6 +1420,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result<Vec<PlexCachedTr
row.get::<_, Option<String>>(8)?, row.get::<_, Option<String>>(8)?,
row.get::<_, Option<i64>>(9)?, row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<i64>>(10)?, row.get::<_, Option<i64>>(10)?,
row.get::<_, Option<String>>(11)?,
)) ))
}) })
.map_err(|e| format!("Failed to query Plex cache album tracks: {}", e))?; .map_err(|e| format!("Failed to query Plex cache album tracks: {}", e))?;
@@ -1420,6 +1439,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result<Vec<PlexCachedTr
artwork_path, artwork_path,
track_number_opt, track_number_opt,
disc_number_opt, disc_number_opt,
parent_rating_key,
) = row.map_err(|e| format!("Failed to read Plex cache album track row: {}", e))?; ) = row.map_err(|e| format!("Failed to read Plex cache album track row: {}", e))?;
let artist = artist_opt let artist = artist_opt
.map(|v| decode_xml_entities(v.trim())) .map(|v| decode_xml_entities(v.trim()))
@@ -1445,6 +1465,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result<Vec<PlexCachedTr
album_key: album_key.clone(), album_key: album_key.clone(),
track_number: track_number_opt.map(|v| v as u32), track_number: track_number_opt.map(|v| v as u32),
disc_number: disc_number_opt.map(|v| v as u32), disc_number: disc_number_opt.map(|v| v as u32),
parent_rating_key,
}); });
} }
Ok(tracks) Ok(tracks)
@@ -1528,6 +1549,8 @@ pub fn plex_cache_search_tracks(
album_key: plex_album_key(&artist, &album), album_key: plex_album_key(&artist, &album),
track_number: track_number_opt.map(|v| v as u32), track_number: track_number_opt.map(|v| v as u32),
disc_number: disc_number_opt.map(|v| v as u32), disc_number: disc_number_opt.map(|v| v as u32),
// Flat search list — version grouping doesn't apply here.
parent_rating_key: None,
}); });
} }
Ok(tracks) Ok(tracks)
@@ -1615,8 +1638,8 @@ pub fn plex_cache_save_tracks(
"INSERT INTO plex_cache_tracks "INSERT INTO plex_cache_tracks
(rating_key, section_key, server_id, title, artist, album, duration_ms, artwork_path, (rating_key, section_key, server_id, title, artist, album, duration_ms, artwork_path,
part_key, container, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, part_key, container, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth,
track_number, disc_number, album_key, year, genre, updated_at) track_number, disc_number, album_key, year, genre, parent_rating_key, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
params![ params![
track.rating_key, track.rating_key,
section_key, section_key,
@@ -1638,6 +1661,7 @@ pub fn plex_cache_save_tracks(
track_album_key, track_album_key,
track.year.map(|v| v as i64), track.year.map(|v| v as i64),
track.genre.clone(), track.genre.clone(),
track.parent_rating_key.clone(),
now, now,
], ],
) )
+12 -1
View File
@@ -3007,7 +3007,18 @@ fn map_plex_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> qbz_library::
title: t.title, title: t.title,
artist: t.artist, artist: t.artist,
album: t.album.clone(), album: t.album.clone(),
album_group_key: t.album_key.clone(), // Version key: open_local_album groups tracks into selectable versions
// by album_group_key. `album_key` is a title+artist hash, so two
// distinct Plex albums with the same name collapse into one group and
// their tracks interleave (1,1,2,2,...). Key on the Plex album
// (parent_rating_key) instead so each edition is its own version; fall
// back to album_key on pre-resync rows that lack it (one merged group).
album_group_key: t
.parent_rating_key
.as_deref()
.filter(|k| !k.is_empty())
.map(|k| format!("plex:album:{k}"))
.unwrap_or_else(|| t.album_key.clone()),
// Feeds the album-detail header title (apply_album_version reads // Feeds the album-detail header title (apply_album_version reads
// tracks.first().album_group_title) — use the Plex album name. // tracks.first().album_group_title) — use the Plex album name.
album_group_title: t.album.clone(), album_group_title: t.album.clone(),