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