fix(library): unify album versions across sources

Associate physical copies only when normalized album identity and strong track-title/duration overlap agree. Keep every source visible on one card and resolve each authoritative album id into the existing version cache.

Order versions by delivered quality and then track count, expose inferred edition labels and full format metadata, and keep source filters aware of every represented copy.

cx
This commit is contained in:
vicrodh committed 2026-08-23 21:56:38 -06:00
1 parent b4744b6e7f
commit d627560f4f
13 files changed
+735 -90

No files matched your search

+35 -31
View File
@@ -72,6 +72,11 @@ Rectangle {
// (AlbumCard.slint:434 keeps the entry off local/plex rows). Hosts that
// want the badge hidden set `showSourceBadge`, never `source: ""`.
property string source: ""
// Local Library logical albums can have one physical copy on several
// sources. Other hosts leave this empty and retain the scalar badge.
property var sources: []
readonly property var badgeSources: root.sources && root.sources.length > 0
? root.sources : (root.source !== "" ? [root.source] : [])
/// Source words that mean "this album does not live in the Qobuz catalog".
/// Same fold as `SourceIcon.isSubsonic`: a row stamped with a Subsonic
/// BRAND is still a server row, so it must not be offered a catalog-only
@@ -620,40 +625,39 @@ Rectangle {
// `hard-drive` for Plex — a blue hard drive where the design calls
// for the Plex mark.
Rectangle {
visible: root.showSourceBadge && root.source !== ""
visible: root.showSourceBadge && root.badgeSources.length > 0
x: parent.width - width - 6
y: parent.height - height - 6
width: 24
width: sourceBadgeRow.implicitWidth
height: 24
radius: 4
// .slint:335-338. Slint literals are #RRGGBBAA, Qt's are
// #AARRGGBB: #1e1400d9 -> #d91e1400, #000000b3 -> #b3000000,
// #eab30880 -> #80eab308.
// qobuz_purchase -> §8.7 gold chip (dark-amber translucent
// fill + 1px gold border)
// qobuz_download -> no chip at all (the wordmark stands
// alone at 22px)
// everything else -> the near-black chip.
color: root.source === "qobuz_purchase" ? "#d91e1400"
: (root.source === "offline" ? "transparent" : "#b3000000")
border.width: root.source === "qobuz_purchase" ? 1 : 0
border.color: "#80eab308"
SourceIcon {
kind: root.source
// .slint:347-349 — one size per arm: plex 16, download 22,
// qobuz/purchase 18, local 14. "offline" IS the Qt word for
// `qobuz_download` (local_rows.rs `badge_source`).
glyphSize: 14
plexSize: 16
qobuzSize: root.source === "offline" ? 22 : 18
// .slint:345-346 — the monochrome glyph is #ffffff here (the
// chip is dark under every theme), not the rows' muted.
localTint: "white"
// The .slint centres with explicit pixel rounding
// (:351-352); anchors.centerIn would leave a half pixel on
// the odd sizes and blur the mark.
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
color: "transparent"
Row {
id: sourceBadgeRow
height: 24
spacing: 3
Repeater {
model: root.badgeSources
delegate: Rectangle {
id: sourceBadge
required property string modelData
width: 24
height: 24
radius: 4
color: modelData === "qobuz_purchase" ? "#d91e1400"
: (modelData === "offline" ? "transparent" : "#b3000000")
border.width: modelData === "qobuz_purchase" ? 1 : 0
border.color: "#80eab308"
SourceIcon {
kind: sourceBadge.modelData
glyphSize: 14
plexSize: 16
qobuzSize: sourceBadge.modelData === "offline" ? 22 : 18
localTint: "white"
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}
}
}
}
+20 -10
View File
@@ -501,6 +501,16 @@ Rectangle {
// Quality / format / source chips. Each SECTION is an OR within itself
// and an AND across sections — an empty section means "any".
function sourceBucket(word) {
var source = (word || "local").toLowerCase()
if (source === "qobuz_purchase" || source === "qobuz_download")
return "offline"
if (source === "navidrome" || source === "gonic"
|| source === "airsonic" || source === "astiga")
return "subsonic"
return source
}
function applyFilter(rows) {
if (filterCount === 0) return rows
var qAny = filter.hires || filter.cd || filter.lossy
@@ -536,16 +546,16 @@ Rectangle {
// closed: Tauri's three arms never mention `qobuz_purchase`,
// so a raw word reaching here matches no chip and ticking ANY
// source filter hides every purchased album, silently.
var src = (r.source || "local").toLowerCase()
if (src === "qobuz_purchase" || src === "qobuz_download") src = "offline"
// Every brand spelling of a Subsonic server is ONE chip, the
// same fold `SourceId::from_word` applies. A row stamped
// "navidrome" that matched no chip would VANISH the moment any
// source filter was ticked — the §10-H shape above, in a new
// source.
if (src === "navidrome" || src === "gonic" || src === "airsonic"
|| src === "astiga") src = "subsonic"
if (!filter[src]) continue
var sources = r.sources && r.sources.length > 0
? r.sources : [r.source || "local"]
var sourceMatches = false
for (var si = 0; si < sources.length; si++) {
if (filter[sourceBucket(sources[si])] === true) {
sourceMatches = true
break
}
}
if (!sourceMatches) continue
}
out.push(r)
}
@@ -103,7 +103,8 @@ Item {
/// churn the constant cell count removed.
readonly property var emptySlot: ({
"id": "", "title": "", "artist": "", "year": "",
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": ""
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": "",
"sources": []
})
// ---------------------------------------------------------------------
@@ -496,6 +497,7 @@ Item {
// about the same album.
source: cardCell.slot.sourceRaw
|| cardCell.slot.source
sources: cardCell.slot.sources || []
showSourceBadge: root.showSource
// SELECT MODE IS THE CARD'S (AlbumCard's
// `selectMode`/`selected`/`selectToggled`, the
+21 -18
View File
@@ -167,31 +167,34 @@ Rectangle {
}
Item {
visible: root.showSource
width: visible ? 34 : 0
readonly property var sourceValues: root.item.sources
&& root.item.sources.length > 0
? root.item.sources
: ((root.item.sourceRaw || root.item.source || "") !== ""
? [root.item.sourceRaw || root.item.source] : [])
width: visible ? Math.max(34, sourceIcons.implicitWidth) : 0
height: parent.height
// AlbumListRow.slint:308-322 — through controls/SourceIcon.qml,
// never QbzIcon: the Plex and Qobuz marks are MULTI-COLOUR and a
// tint flattens them to a silhouette. This row used to draw an
// accent-tinted `hard-drive` for Plex (a blue hard drive) and
// `cloud-download` for an offline copy.
SourceIcon {
visible: (root.item.source || "") !== ""
// `sourceRaw` FIRST (contract §D.1): it carries
// `qobuz_purchase`, which `source` folds into "offline" for the
// benefit of the source chips. Without this the LIST mode of
// the very same album loses the gold mark the GRID shows.
kind: root.item.sourceRaw || root.item.source || ""
// A dense ROW: the media marks draw monochrome and tinted, like the
// hard-drive beside them. Colour logos are for cards — a list of
// them fights the text it labels.
mono: true
// .slint:319 — local 15, the marks 16; muted local tint.
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
x: 0 // .slint:321
Row {
id: sourceIcons
spacing: 3
anchors.verticalCenter: parent.verticalCenter
Repeater {
model: sourceIcons.parent.sourceValues
delegate: SourceIcon {
required property string modelData
kind: modelData
mono: true
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
}
}
}
}
// Trailing ⋯ overflow (AlbumListRow.slint:360).
@@ -15,7 +15,7 @@ import "../../theme"
Rectangle {
id: root
/// [{ label, source }]
/// [{ version, trackCount, quality, source }]
property var versions: []
property int current: 0
signal picked(int index)
@@ -24,6 +24,17 @@ Rectangle {
readonly property var currentVersion: versions[current] || ({})
function optionText(version) {
var parts = []
if ((version.version || "") !== "")
parts.push(version.version)
parts.push((version.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev))
if ((version.quality || "") !== "")
parts.push(version.quality)
return parts.join(" · ")
}
height: 30
width: row.width
radius: 6
@@ -48,7 +59,7 @@ Rectangle {
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.currentVersion.label || ""
text: root.optionText(root.currentVersion)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
}
@@ -70,7 +81,7 @@ Rectangle {
QbzContextMenu {
id: menu
menuWidth: 280
menuWidth: 380
Repeater {
model: root.versions
delegate: Rectangle {
@@ -95,7 +106,7 @@ Rectangle {
Text {
width: parent.width - 22 - (opt.index === root.current ? 20 : 0)
height: parent.height
text: opt.modelData.label || ""
text: root.optionText(opt.modelData)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
+124 -11
View File
@@ -30,7 +30,7 @@ use std::sync::Arc;
use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_library::LocalTrack;
use qbz_library::{AudioFormat, LocalTrack};
use qbz_models::QueueTrack;
use serde::Serialize;
@@ -50,11 +50,15 @@ type Runtime = Arc<AppRuntime<LoggingAdapter>>;
/// One selectable physical copy — the Slint's `LocalAlbumVersion`
/// (album/LocalAlbumView.slint:42), consumed by
/// `qml/views/local/VersionPicker.qml` as `{ label, source }`.
/// `qml/views/local/VersionPicker.qml`.
#[derive(Clone, Default, Serialize)]
pub struct AlbumVersion {
/// "Remastered", "Deluxe Edition", … when metadata makes it inferable.
pub version: String,
#[serde(rename = "trackCount")]
pub track_count: u32,
/// "24-bit / 96 kHz · FLAC" (quality + container).
pub label: String,
pub quality: String,
/// RAW `local_tracks.source` ("" | "qobuz_download" | "qobuz_purchase" |
/// "plex") — `SourceIcon.qml` keys its glyph/tint off exactly these.
pub source: String,
@@ -116,8 +120,30 @@ pub struct AlbumDetailDoc {
// ---------------------------------------------------------------------------
/// Quality rank for ordering versions (hi-res first).
fn version_rank(t: &LocalTrack) -> (u32, u64) {
(t.bit_depth.unwrap_or(0), t.sample_rate as u64)
fn version_rank(t: &LocalTrack) -> (u8, u32, u64) {
let lossless = matches!(
t.format,
AudioFormat::Flac
| AudioFormat::Alac
| AudioFormat::Wav
| AudioFormat::Aiff
| AudioFormat::Ape
| AudioFormat::Dsd
);
let sample_rate = t.sample_rate.max(0.0) as u64;
let depth = t.bit_depth.unwrap_or(0);
let tier = if t.format == AudioFormat::Dsd
|| (lossless && (depth > 16 || sample_rate > 48_000))
{
3
} else if lossless {
2
} else if t.format != AudioFormat::Unknown {
1
} else {
0
};
(tier, depth, sample_rate)
}
/// Group the album's tracks by SOURCE DIRECTORY, sort each copy by
@@ -143,26 +169,36 @@ pub fn split_versions(tracks: Vec<LocalTrack>) -> Vec<(String, Vec<LocalTrack>)>
})
.collect();
versions.sort_by(|a, b| {
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0));
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0));
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0, 0));
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0, 0));
qb.cmp(&qa)
.then_with(|| b.1.len().cmp(&a.1.len()))
.then_with(|| a.0.cmp(&b.0))
});
versions
}
/// A version's picker entry (`version_label` + `version_source` 1:1).
fn version_info(tracks: &[LocalTrack]) -> AlbumVersion {
match tracks.first() {
match tracks.iter().max_by_key(|track| version_rank(track)) {
Some(t) => {
let detail = crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
let detail =
crate::home_qt::quality_detail_from_parts(t.bit_depth, Some(t.sample_rate));
let fmt = t.format.to_string();
let raw_source = badge_source_raw(t.source.as_deref());
AlbumVersion {
label: if detail.is_empty() {
version: crate::local_albums::edition_descriptor(&t.album_group_title),
track_count: tracks.len() as u32,
quality: if detail.is_empty() {
fmt
} else {
format!("{detail} · {fmt}")
},
source: t.source.clone().unwrap_or_default(),
source: if raw_source.is_empty() {
badge_source(t.source.as_deref())
} else {
raw_source
},
}
}
None => AlbumVersion::default(),
@@ -333,6 +369,26 @@ fn album_header(id: &str, tracks: &[LocalTrack]) -> AlbumRow {
format: best.format.to_string(),
art_key: album_key(id),
source: badge_source(first.source.as_deref()),
sources: card.as_ref().map(|c| c.sources.clone()).unwrap_or_else(|| {
let mut values = Vec::new();
for track in state(|s| {
s.album_versions
.iter()
.filter_map(|(_, tracks)| tracks.first().cloned())
.collect::<Vec<_>>()
}) {
let raw = badge_source_raw(track.source.as_deref());
let source = if raw.is_empty() {
badge_source(track.source.as_deref())
} else {
raw
};
if !values.contains(&source) {
values.push(source);
}
}
values
}),
source_raw: badge_source_raw(first.source.as_deref()),
directory_path: card.as_ref().map(|c| c.directory_path.clone()).unwrap_or_default(),
folder_count: card.map(|c| c.folder_count).unwrap_or(0),
@@ -616,3 +672,60 @@ async fn enqueue_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, mode: &str) {
}
crate::playback_qt::publish_queue(runtime).await;
}
#[cfg(test)]
mod version_tests {
use super::*;
fn copy(key: &str, source: &str, title: &str, count: usize, depth: u32) -> Vec<LocalTrack> {
(0..count)
.map(|index| LocalTrack {
id: index as i64 + 1,
file_path: format!("{key}/{index}.flac"),
title: format!("Track {index}"),
artist: "Artist".to_string(),
album: title.to_string(),
album_artist: Some("Artist".to_string()),
album_group_key: key.to_string(),
album_group_title: title.to_string(),
track_number: Some(index as u32 + 1),
disc_number: Some(1),
format: AudioFormat::Flac,
bit_depth: Some(depth),
sample_rate: if depth > 16 { 96_000.0 } else { 44_100.0 },
source: Some(source.to_string()),
..Default::default()
})
.collect()
}
#[test]
fn versions_sort_by_quality_then_track_count_and_stay_distinct() {
let mut tracks = copy("cd-short", "plex", "Album", 10, 16);
tracks.extend(copy(
"hires-short",
"jellyfin",
"Album (2012 Remaster)",
9,
24,
));
tracks.extend(copy(
"hires-deluxe",
"subsonic",
"Album - Deluxe Edition",
12,
24,
));
let versions = split_versions(tracks);
assert_eq!(versions.len(), 3);
assert_eq!(versions[0].0, "hires-deluxe");
assert_eq!(versions[1].0, "hires-short");
assert_eq!(versions[2].0, "cd-short");
let info = version_info(&versions[0].1);
assert_eq!(info.version, "Deluxe Edition");
assert_eq!(info.track_count, 12);
assert_eq!(info.source, "subsonic");
assert!(info.quality.contains("FLAC"));
}
}
+405 -4
View File
@@ -16,7 +16,7 @@
use std::collections::HashMap;
use qbz_library::album_grouping::AlbumGroupMode;
use qbz_library::LocalTrack;
use qbz_library::{AlbumTrackEvidence, AudioFormat, LocalAlbum, LocalTrack};
use crate::local_album_actions::AlbumDetailDoc;
use crate::local_artist_match::{
@@ -67,8 +67,11 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
})
.ok_or_else(|| "local library not available".to_string())?;
let t_sql = t0.elapsed();
let total = page.total;
let albums = page.albums;
let identity_started = std::time::Instant::now();
let source_album_count = page.albums.len();
let (albums, version_ids) = coalesce_album_versions(page.albums);
let t_identity = identity_started.elapsed();
let total = albums.len() as u64;
let n = albums.len();
let t1 = std::time::Instant::now();
let rows = with_art(|art| {
@@ -78,7 +81,7 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
.collect::<Vec<AlbumRow>>()
});
log::info!(
"[qbz-qt][perf] albums load: {n} rows — sql {t_sql:?}, map {:?} (plex={} remote={:?})",
"[qbz-qt][perf] albums load: {source_album_count} source rows -> {n} logical rows — sql {t_sql:?}, identity {t_identity:?}, map {:?} (plex={} remote={:?})",
t1.elapsed(),
plex_path.is_some(),
remote_words,
@@ -86,10 +89,267 @@ pub fn load_albums_blocking() -> Result<Vec<AlbumRow>, String> {
state(|s| {
s.counts.albums = total as i64;
s.albums = rows.clone();
s.album_version_ids = version_ids;
});
Ok(rows)
}
/// Fold source copies into one visible album only when their content strongly
/// agrees. Title/artist merely form a candidate bucket; the actual authority
/// is an at-least-80% one-to-one track-title + duration overlap (or the one
/// track itself for a single). The map is ephemeral and rebuilt from the
/// authoritative caches, so a later correction can split an association.
fn coalesce_album_versions(
mut albums: Vec<LocalAlbum>,
) -> (Vec<LocalAlbum>, HashMap<String, Vec<String>>) {
albums.sort_by(|left, right| left.id.cmp(&right.id));
let mut buckets = std::collections::BTreeMap::<String, Vec<LocalAlbum>>::new();
let mut singles = Vec::new();
for mut album in albums {
album.sources.sort_by(|left, right| {
source_order(left)
.cmp(&source_order(right))
.then_with(|| left.cmp(right))
});
album.sources.dedup();
let title = logical_title(&album.title);
let artist = normalize_artist(&album.artist);
if title.is_empty()
|| artist.is_empty()
|| title == "unknown album"
|| artist == "unknown artist"
|| album.identity_tracks.is_empty()
{
singles.push(album);
} else {
buckets
.entry(format!("{artist}\u{1f}{title}"))
.or_default()
.push(album);
}
}
let mut groups = Vec::<Vec<LocalAlbum>>::new();
for bucket in buckets.into_values() {
let mut bucket_groups = Vec::<Vec<LocalAlbum>>::new();
for album in bucket {
if let Some(group) = bucket_groups
.iter_mut()
.find(|group| group.iter().all(|other| copies_match(other, &album)))
{
group.push(album);
} else {
bucket_groups.push(vec![album]);
}
}
groups.extend(bucket_groups);
}
groups.extend(singles.into_iter().map(|album| vec![album]));
let mut version_ids = HashMap::new();
let mut merged = groups
.into_iter()
.map(|mut group| {
group.sort_by(|left, right| {
album_quality_rank(right)
.cmp(&album_quality_rank(left))
.then_with(|| left.id.cmp(&right.id))
});
let ids = group
.iter()
.map(|album| album.id.clone())
.collect::<Vec<_>>();
let mut representative = group.remove(0);
if !group.is_empty() {
let logical_id = logical_album_id(&ids);
let mut source_words = std::iter::once(&representative)
.chain(group.iter())
.flat_map(|album| {
if album.sources.is_empty() {
vec![album.source.clone()]
} else {
album.sources.clone()
}
})
.collect::<Vec<_>>();
source_words.sort_by(|left, right| {
source_order(left)
.cmp(&source_order(right))
.then_with(|| left.cmp(right))
});
source_words.dedup();
representative.sources = source_words;
if let Some(plain_title) = std::iter::once(&representative)
.chain(group.iter())
.filter(|album| edition_descriptor(&album.title).is_empty())
.map(|album| album.title.clone())
.min_by_key(String::len)
{
representative.title = plain_title;
}
representative.id = logical_id.clone();
version_ids.insert(logical_id, ids);
} else if representative.sources.is_empty() {
representative.sources.push(representative.source.clone());
}
representative
})
.collect::<Vec<_>>();
merged.sort_by(|left, right| {
normalize_artist(&left.artist)
.cmp(&normalize_artist(&right.artist))
.then_with(|| logical_title(&left.title).cmp(&logical_title(&right.title)))
.then_with(|| left.id.cmp(&right.id))
});
(merged, version_ids)
}
fn album_quality_rank(album: &LocalAlbum) -> (u8, u32, u64, u32) {
let lossless = matches!(
album.format,
AudioFormat::Flac
| AudioFormat::Alac
| AudioFormat::Wav
| AudioFormat::Aiff
| AudioFormat::Ape
| AudioFormat::Dsd
);
let sample_rate = album.sample_rate.max(0.0) as u64;
let depth = album.bit_depth.unwrap_or(0);
let tier =
if album.format == AudioFormat::Dsd || (lossless && (depth > 16 || sample_rate > 48_000)) {
3
} else if lossless {
2
} else if album.format != AudioFormat::Unknown {
1
} else {
0
};
(tier, depth, sample_rate, album.track_count)
}
fn source_order(source: &str) -> u8 {
match source {
"user" | "local" => 0,
"qobuz_purchase" | "qobuz_download" | "offline" => 1,
"plex" => 2,
"jellyfin" => 3,
"subsonic" | "navidrome" | "gonic" | "airsonic" | "astiga" => 4,
_ => 5,
}
}
fn logical_album_id(ids: &[String]) -> String {
// Deterministic FNV-1a over length-delimited source-native ids. The full
// id vector remains in LocalState and is what resolves the click.
let mut hash = 0xcbf29ce484222325_u64;
let mut sorted = ids.to_vec();
sorted.sort();
for id in sorted {
for byte in (id.len() as u64)
.to_le_bytes()
.into_iter()
.chain(id.bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
}
format!("logical:{hash:016x}")
}
fn copies_match(left: &LocalAlbum, right: &LocalAlbum) -> bool {
let smaller = left.identity_tracks.len().min(right.identity_tracks.len());
if smaller == 0 {
return false;
}
let matched = track_overlap(&left.identity_tracks, &right.identity_tracks);
let required = if smaller == 1 {
1
} else {
3.min(smaller).max((smaller * 4 + 4) / 5)
};
matched >= required
}
fn track_overlap(left: &[AlbumTrackEvidence], right: &[AlbumTrackEvidence]) -> usize {
let mut used = vec![false; right.len()];
let mut matched = 0;
for candidate in left {
let title = logical_title(&candidate.title);
if title.is_empty() {
continue;
}
if let Some((index, _)) = right.iter().enumerate().find(|(index, other)| {
!used[*index]
&& logical_title(&other.title) == title
&& candidate.duration_secs.abs_diff(other.duration_secs) <= 5
}) {
used[index] = true;
matched += 1;
}
}
matched
}
fn logical_title(value: &str) -> String {
normalize_artist(strip_edition_suffix(value))
}
pub(crate) fn edition_descriptor(value: &str) -> String {
let trimmed = value.trim();
let stripped = strip_edition_suffix(trimmed).trim();
if stripped.len() == trimmed.len() {
String::new()
} else {
trimmed[stripped.len()..]
.trim_matches(|ch: char| ch.is_whitespace() || "-–—()[]{}".contains(ch))
.trim()
.to_string()
}
}
fn strip_edition_suffix(value: &str) -> &str {
const MARKERS: [&str; 15] = [
"remaster",
"deluxe",
"expanded",
"anniversary",
"edition",
"reissue",
"re-release",
"mix",
"version",
"mono",
"stereo",
"sacd",
"bonus",
"legacy",
"collector",
];
let marked = |tail: &str| MARKERS.iter().any(|marker| tail.contains(marker));
if let Some(start) = value
.char_indices()
.rev()
.find(|(index, ch)| {
matches!(*ch, '(' | '[' | '{')
&& marked(&value[*index + ch.len_utf8()..].to_lowercase())
})
.map(|(index, _)| index)
{
return value[..start].trim_end();
}
for separator in [" - ", " – ", " — "] {
if let Some(index) = value.rfind(separator) {
if marked(&value[index + separator.len()..].to_lowercase()) {
return value[..index].trim_end();
}
}
}
value.trim()
}
/// Folders tab, FLAT mode: the album grid grouped by DIRECTORY. Local-only
/// by definition — Plex rows have no filesystem folder.
pub fn load_folders_blocking() -> Result<Vec<AlbumRow>, String> {
@@ -551,6 +811,17 @@ pub fn load_counts_blocking() -> LocalCounts {
/// or native edition key, else the ACTIVE identity mode's query against
/// `library.db`.
pub fn fetch_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
let version_ids = state(|s| s.album_version_ids.get(id).cloned());
if let Some(version_ids) = version_ids {
return version_ids
.into_iter()
.flat_map(|version_id| fetch_source_album_tracks_blocking(&version_id))
.collect();
}
fetch_source_album_tracks_blocking(id)
}
fn fetch_source_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
if id.starts_with("plex:") {
return crate::local_plex::album_tracks(id);
}
@@ -1008,4 +1279,134 @@ mod phase_a_tests {
after.published.subsonic,
);
}
fn album_copy(
id: &str,
source: &str,
title: &str,
tracks: &[(&str, u64)],
bit_depth: u32,
sample_rate: f64,
) -> LocalAlbum {
LocalAlbum {
id: id.to_string(),
title: title.to_string(),
artist: "Talk Talk".to_string(),
all_artists: "Talk Talk".to_string(),
year: Some(1988),
catalog_number: None,
artwork_path: None,
track_count: tracks.len() as u32,
total_duration_secs: tracks.iter().map(|(_, duration)| duration).sum(),
format: AudioFormat::Flac,
bit_depth: Some(bit_depth),
sample_rate,
directory_path: String::new(),
source_folders: None,
source: source.to_string(),
sources: vec![source.to_string()],
identity_tracks: tracks
.iter()
.map(|(title, duration_secs)| AlbumTrackEvidence {
title: (*title).to_string(),
duration_secs: *duration_secs,
})
.collect(),
}
}
#[test]
fn album_copies_require_content_evidence_and_keep_every_source() {
let tracks = [
("The Rainbow", 568),
("Eden", 421),
("Desire", 439),
("Inheritance", 341),
];
let local = album_copy(
"eden|talk talk",
"user",
"Spirit of Eden",
&tracks,
16,
44_100.0,
);
let plex = album_copy(
"plex:eden",
"plex",
"Spirit of Eden (2012 Remaster)",
&tracks,
24,
96_000.0,
);
let (rows, ids) = coalesce_album_versions(vec![local, plex]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].source, "plex");
assert_eq!(rows[0].sources, vec!["user", "plex"]);
assert_eq!(ids.get(&rows[0].id).map(Vec::len), Some(2));
let unrelated = album_copy(
"subsonic:other",
"subsonic",
"Spirit of Eden",
&[("Completely Different", 568), ("Still Different", 421)],
24,
96_000.0,
);
let (rows, _) = coalesce_album_versions(vec![rows[0].clone(), unrelated]);
assert_eq!(rows.len(), 2, "title and artist alone must never merge");
}
#[test]
fn album_copy_matching_allows_bonus_tracks_but_not_one_shared_compilation_track() {
let base = [
("One", 201),
("Two", 202),
("Three", 203),
("Four", 204),
("Five", 205),
];
let mut deluxe = base.to_vec();
deluxe.push(("Bonus A", 180));
deluxe.push(("Bonus B", 181));
assert!(copies_match(
&album_copy("a", "user", "Album", &base, 16, 44_100.0),
&album_copy(
"b",
"jellyfin",
"Album (Deluxe Edition)",
&deluxe,
16,
44_100.0
)
));
assert!(!copies_match(
&album_copy("a", "user", "Album", &base, 16, 44_100.0),
&album_copy(
"c",
"subsonic",
"Album",
&[("One", 201), ("Else", 202), ("Other", 203), ("Nope", 204)],
16,
44_100.0,
)
));
}
#[test]
fn edition_suffix_inference_is_deliberately_narrow() {
assert_eq!(
logical_title("Spirit of Eden (2012 Remaster)"),
"spirit of eden"
);
assert_eq!(
edition_descriptor("Spirit of Eden (2012 Remaster)"),
"2012 Remaster"
);
assert_eq!(
edition_descriptor("Album - Deluxe Edition"),
"Deluxe Edition"
);
assert_eq!(edition_descriptor("Album (Live at Montreux)"), "");
}
}
@@ -1041,6 +1041,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
format: record.format.to_ascii_uppercase(),
art_key,
source: badge_source(Some(record.source_raw_or_kind())),
sources: vec![badge_source(Some(record.source_raw_or_kind()))],
source_raw: badge_source_raw(Some(record.source_raw_or_kind())),
directory_path: record.directory_path.clone(),
folder_count: record.folder_count,
@@ -1362,6 +1362,7 @@ fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
format: record.format.to_ascii_uppercase(),
art_key,
source: badge_source(Some(source_word(record))),
sources: vec![badge_source(Some(source_word(record)))],
source_raw: badge_source_raw(Some(source_word(record))),
directory_path: record.directory_path.clone(),
folder_count: record.folder_count,
+29 -2
View File
@@ -53,6 +53,10 @@ pub struct AlbumRow {
pub art_key: String,
/// "local" | "offline" | "plex" (the source badge).
pub source: String,
/// Every physical source represented by this logical album. Values stay
/// in the raw badge vocabulary so a Qobuz purchase keeps its gold mark.
#[serde(default)]
pub sources: Vec<String>,
/// `"qobuz_purchase"`, or empty (omitted from the JSON). See the module
/// header: the badge prefers this, every filter keeps reading `source`.
#[serde(rename = "sourceRaw", skip_serializing_if = "String::is_empty")]
@@ -411,6 +415,28 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -
.as_deref()
.map(|s| s.split(',').filter(|x| !x.trim().is_empty()).count() as u32)
.unwrap_or(0);
let source = badge_source(Some(a.source.as_str()));
let source_raw = badge_source_raw(Some(a.source.as_str()));
let mut sources = if a.sources.is_empty() {
vec![if source_raw.is_empty() {
source.clone()
} else {
source_raw.clone()
}]
} else {
a.sources
.iter()
.map(|value| {
let raw = badge_source_raw(Some(value));
if raw.is_empty() {
badge_source(Some(value))
} else {
raw
}
})
.collect::<Vec<_>>()
};
sources.dedup();
AlbumRow {
quality_tier: tier_of(&a.format, a.bit_depth, a.sample_rate).into(),
quality_detail: detail_of(&a.format, a.bit_depth, a.sample_rate),
@@ -419,8 +445,9 @@ pub fn map_album(a: LocalAlbum, art: &mut HashMap<String, (SourceId, String)>) -
duration: total_duration(a.total_duration_secs),
track_count: a.track_count,
art_key: key,
source: badge_source(Some(a.source.as_str())),
source_raw: badge_source_raw(Some(a.source.as_str())),
source,
sources,
source_raw,
directory_path: a.directory_path,
all_artists: a.all_artists,
folder_count,
+4
View File
@@ -143,6 +143,10 @@ pub struct LocalState {
/// Album identity ("folder" | "metadata") — persisted (locallibrary_ui).
pub album_mode: String,
pub counts: LocalCounts,
/// Synthetic logical album id -> strongly-associated authoritative album
/// ids. Rebuilt with each Albums query; never persisted and therefore
/// reversible when source evidence changes.
pub album_version_ids: HashMap<String, Vec<String>>,
/// The OPEN local album's group key ("" when nothing is open).
pub album_id: String,
/// The OPEN album's VERSIONS — (source-directory key, its tracks). A