fix(library): harden cross-source catalog data

Preserve per-disc and collection artwork, multi-value genres and remote metadata across Local, Plex, Jellyfin and Subsonic.

Apply source, format and quality filters before pagination, reject invalid CUE rows, and keep Qt library surfaces aligned with the selected physical version.

cx
This commit is contained in:
vicrodh committed 2026-08-25 18:26:06 -06:00
1 parent 1bd99aed67
commit d244213a64
40 files changed
+3061 -638

No files matched your search

+23 -4
View File
@@ -134,6 +134,12 @@ Rectangle {
// album page agree. Do NOT let a host paint its own heart on top of the
// card — see the `selectMode` note below for the same mistake's other half.
property bool catalogAffordances: !root.localMode
// A Local Library album uses the separate LocalFavoritesService rather
// than the Qobuz catalog seam. This additive flag exposes the same heart
// geometry/menu row while handing the write back to that host.
property bool hostFavorite: false
readonly property bool favoriteAffordance: root.catalogAffordances || root.hostFavorite
signal favoriteRequested()
// --- Multi-select mode — discover/AlbumCard.slint:83, :179-197, :207,
// :239, :465. NOT an invention of this port and NOT a host concern: the
@@ -230,7 +236,10 @@ Rectangle {
function toggleFavorite() {
root.isFavorite = !root.isFavorite
QbzLibrary.libraryToggleFavorite("album", root.albumId)
if (root.hostFavorite)
root.favoriteRequested()
else
QbzLibrary.libraryToggleFavorite("album", root.albumId)
}
function togglePin() {
root.isPinned = !root.isPinned
@@ -262,7 +271,15 @@ Rectangle {
// success, the UNCHANGED one on failure — so this is both the
// cross-surface walk and the rollback.
function onLibraryFavoriteChanged(key, value) {
if (root.albumId !== "" && key === "album:" + root.albumId)
if (!root.hostFavorite && root.albumId !== ""
&& key === "album:" + root.albumId)
root.isFavorite = value
}
}
Connections {
target: root.hostFavorite ? QbzLocal : null
function onLocalAlbumFavoriteChanged(id, value) {
if (root.hostFavorite && root.albumId !== "" && id === root.albumId)
root.isFavorite = value
}
}
@@ -284,10 +301,12 @@ Rectangle {
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
}
if (root.catalogAffordances) {
if (root.favoriteAffordance) {
// Keep removal reachable even after Qobuz withdraws the old id.
m.push({ "label": root.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
"icon": root.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
}
if (root.catalogAffordances) {
// .slint gates this on a non-local/plex source as well — and the
// rule is "not a Qobuz catalog album", so every server source is
// in the same class as Plex. Blocking a Jellyfin album would write
@@ -531,7 +550,7 @@ Rectangle {
// ABSENT, not present-and-dead, when `albumId` is not a
// catalog album id — the same gate as the menu's heart row
// and the pin badge.
visible: root.catalogAffordances
visible: root.favoriteAffordance
name: root.isFavorite ? "heart-filled" : "heart"
active: root.isFavorite
anchors.verticalCenter: parent.verticalCenter
+10 -6
View File
@@ -61,6 +61,8 @@ QtObject {
readonly property int colNumber: 32
/// Artwork thumbnail (TrackRow.slint:336, show-artwork arm).
readonly property int colArt: 36
/// Origin mark for mixed/offline playlist rows.
readonly property int colSource: 22
/// Album link column (TrackRow.slint:540, show-album arm).
readonly property int colAlbum: 220
/// Duration (TrackRow.slint:569).
@@ -79,11 +81,12 @@ QtObject {
/// `reorder` is TRAILING and optional on all three functions: it landed
/// after the call sites did, and `undefined` is falsy, so a caller that
/// does not know about the gutter keeps its old answer exactly.
function fixedWidth(artwork, albumCol, favorite, download, menu, reorder) {
function fixedWidth(artwork, albumCol, favorite, download, menu, reorder, source) {
return colNumber + (artwork ? colArt : 0) + (albumCol ? colAlbum : 0)
+ colDuration + colQuality
+ (favorite ? colFavorite : 0) + (download ? colDownload : 0)
+ (menu ? colMenu : 0) + (reorder ? colReorder : 0)
+ (source ? colSource : 0)
}
/// Sum of the inter-column gaps. The unconditional cells are number,
@@ -91,17 +94,18 @@ QtObject {
/// (QML's Row skips invisible children entirely — no cell, no gap — and
/// so does Slint's `if` in a HorizontalLayout, which is why the count is
/// arm-dependent on both sides.)
function gapWidth(artwork, albumCol, favorite, download, menu, reorder) {
function gapWidth(artwork, albumCol, favorite, download, menu, reorder, source) {
return (3 + (artwork ? 1 : 0) + (albumCol ? 1 : 0) + (favorite ? 1 : 0)
+ (download ? 1 : 0) + (menu ? 1 : 0) + (reorder ? 1 : 0)) * gap
+ (download ? 1 : 0) + (menu ? 1 : 0) + (reorder ? 1 : 0)
+ (source ? 1 : 0)) * gap
}
/// The stretch column. `rowWidth` is the OUTER width of the row (the
/// padding is subtracted here), so a header and a row that are the same
/// width place every column at the same x.
function titleWidth(rowWidth, artwork, albumCol, favorite, download, menu, reorder) {
function titleWidth(rowWidth, artwork, albumCol, favorite, download, menu, reorder, source) {
return Math.max(0, rowWidth - 2 * padH
- fixedWidth(artwork, albumCol, favorite, download, menu, reorder)
- gapWidth(artwork, albumCol, favorite, download, menu, reorder))
- fixedWidth(artwork, albumCol, favorite, download, menu, reorder, source)
- gapWidth(artwork, albumCol, favorite, download, menu, reorder, source))
}
}
+7 -1
View File
@@ -37,6 +37,7 @@ Item {
// Defaults are TrackRow's own defaults, so a host that sets nothing gets
// a header for a row that sets nothing.
property bool showArtwork: false
property bool showSource: false
property bool showAlbum: false
property bool showFavorite: true
property bool showDownload: false
@@ -122,10 +123,15 @@ Item {
width: cols.colArt
height: root.bandH
}
Item {
visible: root.showSource
width: cols.colSource
height: root.bandH
}
ColLabel {
width: cols.titleWidth(root.innerWidth, root.showArtwork, root.showAlbum,
root.showFavorite, root.showDownload, root.showMenu,
root.showReorder)
root.showReorder, root.showSource)
text: QbzSession.tr("Title", QbzSession.trRev)
}
ColLabel {
+29 -10
View File
@@ -79,6 +79,7 @@ Rectangle {
property var item: ({})
property int number: 0
property bool showArtwork: false
property bool showSource: false
property bool showAlbum: false
property bool showFavorite: true
property bool showDownload: false
@@ -493,10 +494,11 @@ Rectangle {
// The animated eq bars carry the playing state in the play cell when the
// pref is ON (TrackPlayCell.slint:99-100 `show-bars`): only while this
// row's track is actually PLAYING, at rest (hover reveals the pause
// affordance instead), and never in select mode. The bars tick on the
// shared shell pulse, so they cost ZERO extra presents (EqualizerBars
// rides QbzShell.pulseMs) — that is what makes the indicator affordable
// here, where a display-rate animation was not.
// affordance instead), and never in select mode. The Loader is part of
// the performance contract: an expanded multidisc album may contain
// hundreds of non-virtualized rows, and each eager EqualizerBars would
// otherwise receive the shared 30 Hz pulse even while hidden. Exactly
// one indicator exists: the active row while it is visible at rest.
readonly property bool showBars: root.playIndicatorAnim
&& root.recycleActive && root.isActive && QbzPlayer.npPlaying && !root.hovered
&& !root.selectMode
@@ -559,7 +561,7 @@ Rectangle {
readonly property int titleColWidth: cols.titleWidth(root.width,
root.showArtwork, root.showAlbum, root.showFavorite,
root.showDownload, root.showMenu, root.showReorder)
root.showDownload, root.showMenu, root.showReorder, root.showSource)
// Static now-playing mark: 3px accent pill on the left edge. With the
// animation pref ON the eq bars in the play cell carry the state instead
@@ -671,12 +673,15 @@ Rectangle {
readonly property bool accentRing: !root.playIndicatorAnim
&& root.isActive && QbzPlayer.npPlaying
// `show-bars` (TrackPlayCell.slint:184-187): the animated eq bars
// replace the disc at rest on the playing row. Pulse-driven, so
// they ride the shell's frame instead of owning one.
EqualizerBars {
visible: root.showBars
active: root.recycleActive && root.isActive && QbzPlayer.npPlaying
// replace the disc at rest on the playing row. The Loader avoids
// constructing a pulse receiver in every inactive track row.
Loader {
active: root.showBars
anchors.centerIn: parent
sourceComponent: EqualizerBars {
active: true
tint: theme.accent
}
}
// Selection checkbox — TrackRow.slint:392-419: a 13px disc, accent
// when selected, muted/text-primary ring when not. It REPLACES the
@@ -845,6 +850,20 @@ Rectangle {
settleMs: root.artSettleMs
}
}
Item {
visible: root.showSource
width: cols.colSource
height: parent.height
SourceIcon {
anchors.centerIn: parent
kind: root.item.source || "qobuz"
mono: true
glyphSize: 15
plexSize: 16
qobuzSize: 16
localTint: "muted"
}
}
// Title (+ explicit) / artist.
Column {
width: root.titleColWidth
+13
View File
@@ -575,6 +575,19 @@ Item {
}
}
// Opening ad-hoc media is a GLOBAL navigation action. The old listener
// lived only inside LocalLibraryView, so opening a folder/disc from
// Settings or another section completed successfully but left the user on
// the old page. The sequence changes only for an explicit user open (not
// boot rehydrate); navigateToTab avoids duplicate history when Local
// Library is already mounted and applies the tab after a fresh mount.
Connections {
target: QbzLocal
function onLocalEphemeralOpenSeqChanged() {
QbzShell.navigateToTab("local", "ephemeral")
}
}
Binding {
target: viewLoader.item
property: "activeTab"
+20 -14
View File
@@ -254,29 +254,35 @@ Rectangle {
return root.discRows[i]
return null
}
function discArt(disc) {
if (!disc) return ""
return (disc.artKey && root.artMap[disc.artKey])
|| disc.cover || ""
}
/// TRUE when the discs genuinely have DIFFERENT covers.
///
/// `DiscRow.cover` is resolved in Rust from each disc's OWN folder, on
/// purpose: the scan-time artwork path is biased to the album ROOT, so a
/// box always reports one shared image and a thumbnail drawn from it would
/// be N copies of the same picture. This still guards the honest case — a
/// box that really does ship one cover for every disc — where drawing them
/// is noise rather than information.
/// The id-keyed path handles embedded and remote art; `DiscRow.cover` is
/// the direct local-file fallback. This still guards the honest case — a
/// box that really ships one shared cover — where drawing N copies is noise.
readonly property bool discArtDistinct: {
var seen = {}
for (var i = 0; i < root.discRows.length; i++) {
var p = root.discRows[i].cover || ""
var p = root.discArt(root.discRows[i])
if (p === "") return false
if (seen[p]) return false
seen[p] = true
}
return root.discRows.length > 1
}
onAlbumChanged: {
if (album && album.artKey) {
QbzLocal.artworkWindow(JSON.stringify([album.artKey]))
}
function requestHeaderArtwork() {
var keys = []
if (album && album.artKey) keys.push(album.artKey)
for (var i = 0; i < discRows.length; i++)
if (discRows[i].artKey) keys.push(discRows[i].artKey)
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
}
onAlbumChanged: requestHeaderArtwork()
onDiscRowsChanged: requestHeaderArtwork()
// ------------------------- skeleton pulse ----------------------------
// ONE 900ms Timer for the whole page. GATING RULE: freeze on NOT VISIBLE
@@ -634,7 +640,7 @@ Rectangle {
// folder names contain '#'.
source: {
var d = root.discInfo(root.discHeader(trackBlock.index))
return d ? (d.cover || "") : ""
return root.discArt(d)
}
}
Text {
@@ -732,8 +738,8 @@ Rectangle {
// no new component (rule 5).
selectMode: root.multiSelect
checked: root.selected[trackBlock.modelData.id] === true
onPlayRequested: QbzLocal.playAlbumTrack(
root.album.id, trackBlock.modelData.id)
onPlayRequested: QbzLocal.albumSelectedAction(
"play", trackBlock.modelData.id)
onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
}
+2
View File
@@ -849,6 +849,7 @@ Rectangle {
visible: root.tracks.length > 0
width: parent.width - 14
showArtwork: true
showSource: root.isLocal || !root.online
showAlbum: true
showDownload: true
// MIRROR the delegate's arm, or the labels slide by 22 + 14 the
@@ -894,6 +895,7 @@ Rectangle {
item: modelData
number: index + 1
showArtwork: true
showSource: root.isLocal || !root.online
showAlbum: true
showDownload: true
// Alternating row tint, like the local album page
@@ -159,12 +159,12 @@ Row {
QbzCircleAction {
name: "play-fill"
primary: true
onClicked: QbzLocal.playAlbum(root.album.id, false)
onClicked: QbzLocal.albumSelectedAction("play", "")
}
QbzCircleAction {
name: "shuffle"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzLocal.playAlbum(root.album.id, true)
onClicked: QbzLocal.albumSelectedAction("shuffle", "")
}
// ASSET GAP: Slint uses `pencil`; the Qt set ships pen-line.
QbzCircleAction {
@@ -170,7 +170,7 @@ Item {
Text {
width: parent.width
text: root.eph ? (root.eph.name || "") : ""
text: root.eph ? (root.eph.title || root.eph.name || "") : ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
@@ -198,17 +198,20 @@ Item {
font.weight: theme.weightBold
elide: Text.ElideRight
}
// "10 tracks · 42 min". The PATH used to live here and no
// longer does: the medium's name is already the line above
// and the tab, and the duration is what a listener actually
// wants before pressing play.
// "10 tracks · 42 min · Folder name". Folder context is
// appended only when Rust inferred one consistent tagged
// album/collection title; a mixed folder already uses its
// own name as the title and does not repeat it here.
Text {
width: parent.width
text: {
var n = root.eph ? (root.eph.trackCount || 0) : 0
var d = root.eph ? (root.eph.totalDuration || "") : ""
var folder = root.eph ? (root.eph.folderContext || "") : ""
var t = n + " " + QbzSession.tr("tracks", QbzSession.trRev)
return d === "" ? t : t + " · " + d
if (d !== "") t += " · " + d
if (folder !== "") t += " · " + folder
return t
}
color: theme.textMuted
font.pixelSize: theme.fontLegal
+43 -18
View File
@@ -184,10 +184,15 @@ pub fn classify(url: &str) -> ArtUrl {
// sniffing.
if let Some((word, token)) = url.split_once(':') {
if let Some(id) = qbz_source::SourceId::from_word(word) {
if matches!(id, qbz_source::SourceId::JELLYFIN | qbz_source::SourceId::SUBSONIC) {
return match qbz_source::registry()
.artwork_token(id, token, qbz_source::ArtSize::Card)
{
if matches!(
id,
qbz_source::SourceId::JELLYFIN | qbz_source::SourceId::SUBSONIC
) {
return match qbz_source::registry().artwork_token(
id,
token,
qbz_source::ArtSize::Card,
) {
qbz_source::ArtRef::Fetch { url, .. } => ArtUrl::Http(url),
// The server is not connected. Reuse the Plex arm's answer:
// "the art exists, it cannot be resolved right now", so the
@@ -563,18 +568,18 @@ fn np_feed_url(url: &str) -> String {
/// Attach the current track's artwork to the NowPlayingModel: a disk hit (or
/// a local file, which is always a hit) applies immediately; a miss downloads
/// in the background and then republishes.
pub fn attach_now_playing(url: &str) {
/// in the background and then republishes both Qt and MPRIS metadata.
pub fn attach_now_playing(track: &qbz_models::QueueTrack, title: &str, album: &str) {
// D2 track edge: the size-aware large feed belongs to the track that just
// ended. Clear it, and re-resolve at the pending bucket when a panel has
// one outstanding (the QML side only calls `requestNpArtworkSize` on
// bucket/visibility changes, so without this kick a track change with a
// settled window would leave the immersive art on the small feed).
restart_now_playing_large();
if url.is_empty() {
let Some(url) = track.artwork_url.as_deref().filter(|url| !url.is_empty()) else {
crate::now_playing::set_artwork_path(String::new());
return;
}
};
let feed = np_feed_url(url);
let hit = cached_path(&feed);
if !hit.is_empty() {
@@ -590,12 +595,21 @@ pub fn attach_now_playing(url: &str) {
crate::now_playing::set_artwork_path(String::new());
return;
}
let expected_track_id = track.id;
let track = track.clone();
let title = title.to_string();
let album = album.to_string();
crate::spawn(async move {
download_missing(vec![feed.clone()]).await;
let path = cached_path(&feed);
if !path.is_empty() {
crate::now_playing::set_artwork_path(path);
// Downloads can outlive their track. Publishing an old completion
// would paint the next song with the previous cover and would also
// overwrite its MPRIS metadata, so both consumers share this guard.
if path.is_empty() || crate::now_playing::art_seed().track_id != expected_track_id {
return;
}
crate::now_playing::set_artwork_path(path);
crate::media_controls_qt::refresh_now_playing_artwork(&track, &title, &album);
});
}
@@ -755,7 +769,11 @@ async fn resolve_large(seed: &crate::now_playing::ArtSeed, bucket: u32) -> Strin
return String::new();
}
if let Some(upgraded) = qbz_models::qobuz_cover_at_px(&seed.url, bucket) {
if NP_LARGE_NEG.read().ok().is_some_and(|n| n.contains(&upgraded)) {
if NP_LARGE_NEG
.read()
.ok()
.is_some_and(|n| n.contains(&upgraded))
{
return String::new();
}
let hit = cached_path(&upgraded);
@@ -794,11 +812,12 @@ async fn resolve_large(seed: &crate::now_playing::ArtSeed, bucket: u32) -> Strin
// when smaller). Blocking — off the async executor.
if let ArtUrl::LocalFile(path) = classify(&seed.url) {
let track_id = (seed.source == "local").then_some(seed.track_id);
let resolved =
tokio::task::spawn_blocking(move || crate::local_artwork::large_art_blocking(&path, track_id))
.await
.ok()
.flatten();
let resolved = tokio::task::spawn_blocking(move || {
crate::local_artwork::large_art_blocking(&path, track_id)
})
.await
.ok()
.flatten();
return resolved
.map(|p| file_url(&p.to_string_lossy()))
.unwrap_or_default();
@@ -889,8 +908,14 @@ mod tests {
// connected in a unit test, so that is what this asserts — the
// CONNECTED shape is covered where the credentials live
// (`JellyfinSource::artwork_token`).
assert_eq!(classify("jellyfin:alb-1/deadbeef"), ArtUrl::PlexUnconfigured);
assert_eq!(classify("subsonic:al-abc_59fec8ff"), ArtUrl::PlexUnconfigured);
assert_eq!(
classify("jellyfin:alb-1/deadbeef"),
ArtUrl::PlexUnconfigured
);
assert_eq!(
classify("subsonic:al-abc_59fec8ff"),
ArtUrl::PlexUnconfigured
);
// A brand spelling folds the same way the source words do.
assert_eq!(classify("navidrome:al-abc"), ArtUrl::PlexUnconfigured);
// A Subsonic track cover id contains its own colon; only the FIRST
+206 -29
View File
@@ -133,7 +133,11 @@ pub struct HomeCard {
/// Playlist rows: the artwork IS the playlist's own graphic (or a custom
/// cover), so the card contain-fits it instead of building a mosaic. Those
/// images are landscape and cropping butchers them.
#[serde(default, rename = "playlistOwnImage", skip_serializing_if = "std::ops::Not::not")]
#[serde(
default,
rename = "playlistOwnImage",
skip_serializing_if = "std::ops::Not::not"
)]
pub playlist_own_image: bool,
/// Playlist rows: ALL of the playlist's tag SLUGS — the material the
/// client-side category filter matches against (home.rs
@@ -264,9 +268,15 @@ fn pinned_cards() -> Vec<HomeCard> {
// routed by the row's own stored kind rather than by the rail's.
is_favorite: crate::fav_cache_qt::is_favorite(&p.kind, &p.id),
playlist_owned: p.kind == "playlist"
&& p.id.parse::<u64>().map(crate::playlist_qt::is_owned).unwrap_or(false),
&& p.id
.parse::<u64>()
.map(crate::playlist_qt::is_owned)
.unwrap_or(false),
playlist_following: p.kind == "playlist"
&& p.id.parse::<u64>().map(crate::playlist_qt::is_following).unwrap_or(false),
&& p.id
.parse::<u64>()
.map(crate::playlist_qt::is_following)
.unwrap_or(false),
id: p.id,
title: p.title,
artist: p.subtitle.clone(),
@@ -325,7 +335,11 @@ pub(crate) fn publish_pinned() {
fn build_recent_sections() -> Vec<HomeSection> {
let mut out = Vec::with_capacity(3);
let recent_albums: Vec<HomeCard> = crate::recently_qt::load_albums()
let mut stored_albums = crate::recently_qt::load_albums();
let mut stored_tracks = crate::recently_qt::load_tracks();
backfill_recent_artwork(&mut stored_albums, &mut stored_tracks);
let recent_albums: Vec<HomeCard> = stored_albums
.into_iter()
.map(map_recent_album)
.collect();
@@ -376,7 +390,7 @@ fn build_recent_sections() -> Vec<HomeSection> {
});
}
let recent_tracks: Vec<HomeCard> = crate::recently_qt::load_tracks()
let recent_tracks: Vec<HomeCard> = stored_tracks
.into_iter()
.take(24)
.map(map_recent_track)
@@ -404,6 +418,82 @@ fn build_recent_sections() -> Vec<HomeSection> {
out
}
/// Repair artwork snapshots written by older builds from the authoritative
/// per-source cache before the short recent rails are mapped. History is not
/// rewritten: a disconnected server merely leaves its old placeholder, while
/// a later connected publish gets another chance.
///
/// Rows are memoized per physical album, so a 24-track recent window costs at
/// most one bounded album lookup per distinct source/version. The exact played
/// row wins for a track card (therefore its disc cover); an album card falls
/// back to the first available disc/collection cover in that physical copy.
fn backfill_recent_artwork(
albums: &mut [crate::recently_qt::RecentAlbum],
tracks: &mut [crate::recently_qt::RecentTrack],
) {
use std::collections::HashMap;
let mut cache: HashMap<(String, String), Vec<qbz_library::LocalTrack>> = HashMap::new();
let mut rows_for = |source: &str, album_id: &str| -> Vec<qbz_library::LocalTrack> {
let source = match source {
"" if album_id.starts_with("plex:") => "plex",
"" if album_id.starts_with("jellyfin:") => "jellyfin",
"" if album_id.starts_with("subsonic:") => "subsonic",
other => other,
};
cache.entry((source.to_string(), album_id.to_string())).or_insert_with(|| {
let mut rows = match source {
"local" => crate::local_albums::fetch_album_tracks_blocking(album_id),
"plex" => crate::local_plex::album_tracks(album_id),
"jellyfin" | "subsonic" => {
crate::media_servers_qt::album_tracks(album_id).unwrap_or_default()
}
_ => Vec::new(),
};
crate::local_playback::fill_missing_covers(&mut rows);
rows
}).clone()
};
let queue_art = |row: &qbz_library::LocalTrack| {
crate::local_playback::local_queue_track(row)
.artwork_url
.unwrap_or_default()
};
for track in tracks.iter_mut() {
if !track.artwork_url.is_empty() && !track.album_artwork_url.is_empty() {
continue;
}
let rows = rows_for(&track.source, &track.album_id);
let row_id = track.id.parse::<u64>().ok().map(|id| id as i64);
let exact = row_id.and_then(|id| rows.iter().find(|row| row.id == id));
let candidate = exact
.filter(|row| row.artwork_path.as_deref().is_some_and(|art| !art.is_empty()))
.or_else(|| {
rows.iter()
.find(|row| row.artwork_path.as_deref().is_some_and(|art| !art.is_empty()))
});
if let Some(row) = candidate {
let art = queue_art(row);
if track.artwork_url.is_empty() {
track.artwork_url = art.clone();
}
if track.album_artwork_url.is_empty() {
track.album_artwork_url = art;
}
}
}
for album in albums.iter_mut().filter(|album| album.artwork_url.is_empty()) {
let rows = rows_for(&album.source, &album.id);
if let Some(row) = rows
.iter()
.find(|row| row.artwork_path.as_deref().is_some_and(|art| !art.is_empty()))
{
album.artwork_url = queue_art(row);
}
}
}
/// All sections any Discover tab can render, in construction order (the
/// per-tab assembly clones from here). Ids are the DiscoverySectionId keys;
/// "mostStreamed#album" is the EDITOR-tab variant (album carousel — the
@@ -423,8 +513,20 @@ fn build_candidates(
} = p;
let mut out: Vec<HomeSection> = Vec::new();
push_albums(&mut out, "newReleases", qbz_i18n::t("New Releases"), "/discover/newReleases", containers.new_releases);
push_albums(&mut out, "pressAwards", qbz_i18n::t("Press Accolades"), "/discover/pressAward", containers.press_awards);
push_albums(
&mut out,
"newReleases",
qbz_i18n::t("New Releases"),
"/discover/newReleases",
containers.new_releases,
);
push_albums(
&mut out,
"pressAwards",
qbz_i18n::t("Press Accolades"),
"/discover/pressAward",
containers.press_awards,
);
// Pinned rail (phase 11) — the ORDERING SLOT only: the rows travel on
// `pinnedJson` (see `publish_pinned`), so the section is pushed
@@ -604,7 +706,13 @@ fn build_candidates(
"/discover/albumOfTheWeek",
containers.album_of_the_week,
);
push_albums(&mut out, "qobuzissimes", qbz_i18n::t("Qobuzissimes"), "/discover/qobuzissims", containers.qobuzissims);
push_albums(
&mut out,
"qobuzissimes",
qbz_i18n::t("Qobuzissimes"),
"/discover/qobuzissims",
containers.qobuzissims,
);
// Personalized rails (self-hide while empty, 1:1 Slint).
if !favorite_albums.is_empty() {
@@ -744,7 +852,10 @@ fn order_by_prefs(
.flat_map(|t| prefs.tab(t).iter().map(|p| p.id.as_str()))
.collect();
for s in candidates {
if !s.id.contains('#') && !known.contains(s.id.as_str()) && !gated.iter().any(|g| g.id == s.id) {
if !s.id.contains('#')
&& !known.contains(s.id.as_str())
&& !gated.iter().any(|g| g.id == s.id)
{
gated.push(s.clone());
}
}
@@ -802,7 +913,10 @@ fn set_playlist_tag_catalog(tags: Vec<PlaylistTagRow>) {
fn publish_playlist_tags() {
let doc = PlaylistTagDoc {
tags: PLAYLIST_TAGS.lock().map(|t| t.clone()).unwrap_or_default(),
selected: PLAYLIST_TAG_SEL.lock().map(|s| s.clone()).unwrap_or_default(),
selected: PLAYLIST_TAG_SEL
.lock()
.map(|s| s.clone())
.unwrap_or_default(),
};
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
crate::home_bridge::ui(move |mut b| {
@@ -842,9 +956,7 @@ pub(crate) fn republish_playlist_tags() {
/// Discover configurator mutates (`discover_config_qt`).
pub(crate) fn load_prefs() -> qbz_app::settings::discover_prefs::DiscoverPrefs {
crate::sidebar_qt::user_dir()
.and_then(|dir| {
qbz_app::settings::discover_prefs::DiscoverPrefsStore::new_at(&dir).ok()
})
.and_then(|dir| qbz_app::settings::discover_prefs::DiscoverPrefsStore::new_at(&dir).ok())
.map(|store| store.load())
.unwrap_or_else(qbz_app::settings::discover_prefs::default_prefs)
}
@@ -865,13 +977,34 @@ fn assemble(
// was this port's simplification and it lost the point of the feature). One
// store read for all three tabs.
let sizes = crate::discover_config_qt::rail_sizes();
let home = order_by_prefs(candidates, prefs, DiscoveryTab::Home, "mostStreamed", true, &sizes);
let editor = order_by_prefs(candidates, prefs, DiscoveryTab::EditorPicks, "mostStreamed#album", false, &sizes);
let home = order_by_prefs(
candidates,
prefs,
DiscoveryTab::Home,
"mostStreamed",
true,
&sizes,
);
let editor = order_by_prefs(
candidates,
prefs,
DiscoveryTab::EditorPicks,
"mostStreamed#album",
false,
&sizes,
);
// Keep empty recent-history ordering slots in For You. QML self-hides the
// placeholder, matching the reference, but retaining the descriptor lets
// a targeted recent-rails update reveal it after the first play without a
// full Home document republish.
let for_you = order_by_prefs(candidates, prefs, DiscoveryTab::ForYou, "mostStreamed", false, &sizes);
let for_you = order_by_prefs(
candidates,
prefs,
DiscoveryTab::ForYou,
"mostStreamed",
false,
&sizes,
);
DiscoverSections {
home,
editor,
@@ -938,7 +1071,8 @@ pub(crate) fn attach_card_art(cards: &mut Vec<HomeCard>) -> Vec<String> {
pub(crate) fn publish(sections: &DiscoverSections) {
let home_json = serde_json::to_string(&sections.home).unwrap_or_else(|_| "[]".to_string());
let editor_json = serde_json::to_string(&sections.editor).unwrap_or_else(|_| "[]".to_string());
let for_you_json = serde_json::to_string(&sections.for_you).unwrap_or_else(|_| "[]".to_string());
let for_you_json =
serde_json::to_string(&sections.for_you).unwrap_or_else(|_| "[]".to_string());
crate::home_bridge::ui(move |mut b| {
b.as_mut()
.set_home_sections_json(QString::from(home_json.as_str()));
@@ -980,7 +1114,8 @@ fn push_recent_sections(sections: &[HomeSection]) {
}
let json = recent_sections_json(sections);
crate::home_bridge::ui(move |mut b| {
b.as_mut().set_recent_rails_json(QString::from(json.as_str()));
b.as_mut()
.set_recent_rails_json(QString::from(json.as_str()));
});
}
@@ -1258,9 +1393,19 @@ where
.collect();
let personalized = Personalized {
favorite_albums: fav_albums.iter().take(18).cloned().map(map_flat_album).collect(),
favorite_albums: fav_albums
.iter()
.take(18)
.cloned()
.map(map_flat_album)
.collect(),
release_watch,
top_artists: fav_artists.iter().take(18).cloned().map(map_fav_artist).collect(),
top_artists: fav_artists
.iter()
.take(18)
.cloned()
.map(map_fav_artist)
.collect(),
rediscover,
to_follow,
similar_title: match seed.as_ref() {
@@ -1318,7 +1463,12 @@ pub(crate) fn map_album(album: DiscoverAlbum) -> HomeCard {
album
.dates
.as_ref()
.and_then(|d| d.original.as_ref().or(d.download.as_ref()).or(d.stream.as_ref()))
.and_then(|d| {
d.original
.as_ref()
.or(d.download.as_ref())
.or(d.stream.as_ref())
})
.map(|s| s.as_str()),
);
let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref());
@@ -1517,7 +1667,10 @@ pub(crate) fn quality_tier_from_depth(bit_depth: Option<u32>) -> &'static str {
/// Bare exact-quality detail from parts, Hz- or kHz-tolerant (quality.rs
/// `detail`): "24-bit / 96 kHz".
pub(crate) fn quality_detail_from_parts(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
pub(crate) fn quality_detail_from_parts(
bit_depth: Option<u32>,
sample_rate: Option<f64>,
) -> String {
let hi_res = matches!(bit_depth, Some(depth) if depth >= 24);
let depth = bit_depth.unwrap_or(if hi_res { 24 } else { 16 });
let rate = sample_rate.unwrap_or(if hi_res { 96.0 } else { 44.1 });
@@ -1721,7 +1874,11 @@ fn map_recent_track(t: crate::recently_qt::RecentTrack) -> HomeCard {
HomeCard {
// TRACK ids here, not album ids — the `slimTracks` rail is the one
// place a HomeCard row is a track.
is_favorite: t.id.parse::<u64>().map(crate::fav_cache_qt::contains_track).unwrap_or(false),
is_favorite: t
.id
.parse::<u64>()
.map(crate::fav_cache_qt::contains_track)
.unwrap_or(false),
id: t.id,
title: t.title,
artist: t.subtitle,
@@ -1827,7 +1984,11 @@ where
let Some(id) = seed else {
return Vec::new();
};
match runtime.core().get_similar_artists(id, SIMILAR_PER_SEED, 0).await {
match runtime
.core()
.get_similar_artists(id, SIMILAR_PER_SEED, 0)
.await
{
Ok(page) => page.items,
Err(e) => {
log::warn!("[qbz-qt] similar artists fetch failed (seed {id}): {e}");
@@ -1845,7 +2006,11 @@ async fn fetch_to_follow<A>(runtime: &Arc<AppRuntime<A>>, fav_artists: &[Artist]
where
A: FrontendAdapter + Send + Sync + 'static,
{
let seeds: Vec<u64> = fav_artists.iter().take(ARTIST_SEEDS).map(|a| a.id).collect();
let seeds: Vec<u64> = fav_artists
.iter()
.take(ARTIST_SEEDS)
.map(|a| a.id)
.collect();
if seeds.is_empty() {
return Vec::new();
}
@@ -1944,8 +2109,14 @@ mod tests {
"24-bit / 192 kHz"
);
// Missing fields fall back per tier (hi-res: 24/96, cd: 16/44.1).
assert_eq!(quality_label(Some(&audio(Some(24), None))), "Hi-Res: 24-bit / 96 kHz");
assert_eq!(quality_detail(Some(&audio(None, Some(44.1)))), "16-bit / 44.1 kHz");
assert_eq!(
quality_label(Some(&audio(Some(24), None))),
"Hi-Res: 24-bit / 96 kHz"
);
assert_eq!(
quality_detail(Some(&audio(None, Some(44.1)))),
"16-bit / 44.1 kHz"
);
}
#[test]
@@ -1960,12 +2131,18 @@ mod tests {
assert_eq!(pick_ribbon(Some(&[])), (String::new(), String::new()));
// 151 wins over everything.
assert_eq!(
pick_ribbon(Some(&[award(Some("88"), "Qobuzissime"), award(Some("151"), "AOTW")])),
pick_ribbon(Some(&[
award(Some("88"), "Qobuzissime"),
award(Some("151"), "AOTW")
])),
("AOTW".to_string(), "albumOfTheWeek".to_string())
);
// 88 wins over a generic press award.
assert_eq!(
pick_ribbon(Some(&[award(Some("1"), "Press X"), award(Some("88"), "Qobuzissime")])),
pick_ribbon(Some(&[
award(Some("1"), "Press X"),
award(Some("88"), "Qobuzissime")
])),
("Qobuzissime".to_string(), "qobuzissime".to_string())
);
// Otherwise the LAST award is the press ribbon.
+1
View File
@@ -246,6 +246,7 @@ const MASTERS: &[(&str, &str)] = &[
("move", include_str!("../qml/assets/icons/primary/move.svg")),
("mp3", include_str!("../qml/assets/icons/primary/mp3.svg")),
("music-library-2", include_str!("../qml/assets/icons/primary/music-library-2.svg")),
("music-note-slider", include_str!("../qml/assets/icons/primary/music-note-slider.svg")),
("music", include_str!("../qml/assets/icons/primary/music.svg")),
("network", include_str!("../qml/assets/icons/primary/network.svg")),
("panel-left", include_str!("../qml/assets/icons/primary/panel-left.svg")),
+156 -61
View File
@@ -35,7 +35,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use qbz_app::settings::local_favorites::{LocalFavoritesService, DB_FILE_NAME};
use qbz_app::settings::local_favorites::{LocalFavItem, LocalFavoritesService, DB_FILE_NAME};
use qbz_app::shell::AppRuntime;
use qbz_app::user_data::UserDataPaths;
use qbz_core::LoggingAdapter;
@@ -103,11 +103,7 @@ pub struct FeedItem {
/// Liveable exception to `not_streamable`: a complete offline copy still
/// plays after Qobuz withdraws the catalog row. Zero is omitted so the
/// high-cardinality feed only pays for tracks that actually carry state.
#[serde(
default,
rename = "cacheStatus",
skip_serializing_if = "is_zero_i32"
)]
#[serde(default, rename = "cacheStatus", skip_serializing_if = "is_zero_i32")]
pub cache_status: i32,
pub genre: String,
pub year: String,
@@ -130,11 +126,7 @@ pub struct FeedItem {
/// (`image_rectangle`) instead of a member-track cover. The card then
/// renders it CONTAIN — those graphics are landscape and cropping
/// butchers them (Tauri `QobuzPlaylistCard`: `object-fit: contain`).
#[serde(
rename = "playlistOwnImage",
default,
skip_serializing_if = "is_false"
)]
#[serde(rename = "playlistOwnImage", default, skip_serializing_if = "is_false")]
pub playlist_own_image: bool,
/// Recency proxy in [0,1]; 0 = most-recently added (per source).
pub added_rank: f32,
@@ -231,6 +223,46 @@ fn local_favorites_list() -> Vec<qbz_app::settings::local_favorites::LocalFavIte
.unwrap_or_default()
}
/// O(1) heart lookup shared by the Local Library row mappers. Keeping this
/// against the same service as the mixed Library feed means a heart never
/// disagrees between those two surfaces after navigation or restart.
pub(crate) fn is_local_favorite(kind: &str, id: &str) -> bool {
LOCAL_FAVS
.get()
.and_then(|service| service.lock().ok())
.is_some_and(|service| service.is_favorite(kind, id))
}
/// Toggle a fully described Local Library item. Unlike [`toggle_local`], this
/// does not try to rediscover the snapshot in the mixed Library feed: the
/// Local Library card itself is authoritative for logical album ids that the
/// feed may not currently contain.
pub(crate) fn toggle_local_favorite_snapshot(item: LocalFavItem) -> Option<bool> {
let service = LOCAL_FAVS.get()?.lock().ok()?;
let current = service.is_favorite(&item.kind, &item.id);
if current {
if let Err(error) = service.unfavorite(&item.kind, &item.id) {
log::error!(
"[qbz-qt] local unfavorite ({}:{}) failed: {error}",
item.kind,
item.id
);
return Some(current);
}
Some(false)
} else {
if let Err(error) = service.favorite(&item) {
log::error!(
"[qbz-qt] local favorite ({}:{}) failed: {error}",
item.kind,
item.id
);
return Some(current);
}
Some(true)
}
}
// ---------------------------------------------------------------------------
// Fetch
// ---------------------------------------------------------------------------
@@ -299,7 +331,8 @@ pub async fn warm_favorites_cache(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
})
.collect();
let n = ids.len();
let _ = tokio::task::spawn_blocking(move || crate::fav_cache_qt::set_all_albums(ids)).await;
let _ =
tokio::task::spawn_blocking(move || crate::fav_cache_qt::set_all_albums(ids)).await;
log::info!("[qbz-qt] favorites cache warmed: {n} albums");
}
Err(e) => log::warn!("[qbz-qt] favorites cache album warm failed: {e}"),
@@ -333,7 +366,10 @@ async fn warm_numeric(
}
}
fn parse_items<T: serde::de::DeserializeOwned>(items: Vec<serde_json::Value>, what: &str) -> Vec<T> {
fn parse_items<T: serde::de::DeserializeOwned>(
items: Vec<serde_json::Value>,
what: &str,
) -> Vec<T> {
items
.into_iter()
.filter_map(|v| match serde_json::from_value::<T>(v) {
@@ -380,7 +416,11 @@ fn map_track(track: Track) -> FeedItem {
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default();
let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default();
let album_id = track
.album
.as_ref()
.map(|a| a.id.clone())
.unwrap_or_default();
let genre = track
.album
.as_ref()
@@ -443,7 +483,12 @@ fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem {
let date = album
.dates
.as_ref()
.and_then(|d| d.original.clone().or(d.download.clone()).or(d.stream.clone()))
.and_then(|d| {
d.original
.clone()
.or(d.download.clone())
.or(d.stream.clone())
})
.or(album.release_date_original.clone());
let artist = if !album.artist.name.is_empty() {
album.artist.name
@@ -591,7 +636,12 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
// Pin badge state from the per-user store (see `map_artist`).
is_pinned: crate::sidebar_qt::is_pinned("playlist", &playlist.id.to_string()),
kind: "playlist".into(),
group: if is_following { "following" } else { "favorites" }.into(),
group: if is_following {
"following"
} else {
"favorites"
}
.into(),
source: "qobuz".into(),
id: playlist.id.to_string(),
title: playlist.name.clone(),
@@ -654,16 +704,32 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
// individually for the perf report.
let t = Instant::now();
let (raw_tracks, tracks_total) = fetch_favorites(runtime, "tracks").await?;
log::info!("[qbz-qt][perf] favorites tracks fetch: {:?} ({} items)", t.elapsed(), raw_tracks.len());
log::info!(
"[qbz-qt][perf] favorites tracks fetch: {:?} ({} items)",
t.elapsed(),
raw_tracks.len()
);
let t = Instant::now();
let (raw_albums, albums_total) = fetch_favorites(runtime, "albums").await?;
log::info!("[qbz-qt][perf] favorites albums fetch: {:?} ({} items)", t.elapsed(), raw_albums.len());
log::info!(
"[qbz-qt][perf] favorites albums fetch: {:?} ({} items)",
t.elapsed(),
raw_albums.len()
);
let t = Instant::now();
let (raw_artists, artists_total) = fetch_favorites(runtime, "artists").await?;
log::info!("[qbz-qt][perf] favorites artists fetch: {:?} ({} items)", t.elapsed(), raw_artists.len());
log::info!(
"[qbz-qt][perf] favorites artists fetch: {:?} ({} items)",
t.elapsed(),
raw_artists.len()
);
let t = Instant::now();
let (raw_labels, labels_total) = fetch_favorites(runtime, "labels").await?;
log::info!("[qbz-qt][perf] favorites labels fetch: {:?} ({} items)", t.elapsed(), raw_labels.len());
log::info!(
"[qbz-qt][perf] favorites labels fetch: {:?} ({} items)",
t.elapsed(),
raw_labels.len()
);
// Playlists (owned + followed + locally hearted).
let t = Instant::now();
@@ -715,13 +781,23 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
}
}
let playlists_total = pl_favorites.len() as i64;
log::info!("[qbz-qt][perf] playlists fetch: {:?} ({} fav / {} following)", t.elapsed(), pl_favorites.len(), following.len());
log::info!(
"[qbz-qt][perf] playlists fetch: {:?} ({} fav / {} following)",
t.elapsed(),
pl_favorites.len(),
following.len()
);
// Purchases (group "purchases") — best-effort: a purchase-less account
// returns empty; a failure must not sink the library.
let t = Instant::now();
let (purchase_albums, purchase_tracks) = fetch_purchases(runtime).await;
log::info!("[qbz-qt][perf] purchases fetch: {:?} ({} albums / {} tracks)", t.elapsed(), purchase_albums.len(), purchase_tracks.len());
log::info!(
"[qbz-qt][perf] purchases fetch: {:?} ({} albums / {} tracks)",
t.elapsed(),
purchase_albums.len(),
purchase_tracks.len()
);
log::info!("[qbz-qt][perf] total fetch wall: {:?}", t_fetch.elapsed());
@@ -791,22 +867,24 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
let labels: Vec<FavLabel> = parse_items(raw_labels, "label");
let n = labels.len();
for (i, l) in labels.into_iter().enumerate() {
feed.push(FeedItem {
kind: "label".into(),
group: "following".into(),
source: "qobuz".into(),
subtitle: match l.albums_count {
Some(c) if c > 0 => format!("{c} albums"),
_ => String::new(),
},
id: l.id.to_string(),
title: l.name,
image_url: extract_label_image(l.image.as_ref()),
is_favorite: true,
added_rank: rank(i, n),
..Default::default()
}
.keyed());
feed.push(
FeedItem {
kind: "label".into(),
group: "following".into(),
source: "qobuz".into(),
subtitle: match l.albums_count {
Some(c) if c > 0 => format!("{c} albums"),
_ => String::new(),
},
id: l.id.to_string(),
title: l.name,
image_url: extract_label_image(l.image.as_ref()),
is_favorite: true,
added_rank: rank(i, n),
..Default::default()
}
.keyed(),
);
}
// Local + Plex layer (show-local default ON; it bypasses the three Qobuz
@@ -828,20 +906,22 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
let locals = local_favorites_list();
let n = locals.len();
for (i, lf) in locals.into_iter().enumerate() {
feed.push(FeedItem {
kind: lf.kind,
group: "local".into(),
source: lf.source,
subtitle: lf.subtitle,
artist: lf.artist.clone(),
image_url: lf.artwork_url,
is_favorite: true,
added_rank: rank(i, n),
id: lf.id,
title: lf.title,
..Default::default()
}
.keyed());
feed.push(
FeedItem {
kind: lf.kind,
group: "local".into(),
source: lf.source,
subtitle: lf.subtitle,
artist: lf.artist.clone(),
image_url: lf.artwork_url,
is_favorite: true,
added_rank: rank(i, n),
id: lf.id,
title: lf.title,
..Default::default()
}
.keyed(),
);
}
}
@@ -1003,8 +1083,7 @@ fn all_local_feed_blocking() -> Vec<FeedItem> {
// wins (it is the one with albums behind it). ---
let local_names: Vec<String> = with_db(|db| {
db.get_artists_with_filter(
/* include_qobuz_downloads */ true,
/* exclude_network_folders */ false,
/* include_qobuz_downloads */ true, /* exclude_network_folders */ false,
)
})
.unwrap_or_default()
@@ -1148,8 +1227,7 @@ async fn fetch_purchases(
else {
return (Vec::new(), Vec::new());
};
let filtered =
qbz_offline_cache::purchases_service::filter_purchase_response(response, "");
let filtered = qbz_offline_cache::purchases_service::filter_purchase_response(response, "");
let albums = filtered
.albums
.items
@@ -1191,7 +1269,13 @@ async fn fetch_purchases(
let (img, alb, aid) = t
.album
.as_ref()
.map(|a| (a.image.best().cloned().unwrap_or_default(), a.title.clone(), a.id.clone()))
.map(|a| {
(
a.image.best().cloned().unwrap_or_default(),
a.title.clone(),
a.id.clone(),
)
})
.unwrap_or_default();
let tier = if t.hires { "hires" } else { "cd" };
FeedItem {
@@ -1814,8 +1898,15 @@ pub(crate) fn feed_track_to_queue(item: &FeedItem) -> Option<QueueTrack> {
/// re-reports the real duration once the track resolves).
fn duration_secs(mmss: &str) -> u64 {
let mut parts = mmss.split(':');
parts.next().and_then(|m| m.parse::<u64>().ok()).unwrap_or(0) * 60
+ parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
parts
.next()
.and_then(|m| m.parse::<u64>().ok())
.unwrap_or(0)
* 60
+ parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0)
}
/// `playback.rs::order_by_visible` (:3408-3428), ported: build the queue in
@@ -1840,7 +1931,9 @@ fn order_by_visible(
.filter_map(|id| by_id.get(id.as_str()).copied())
.filter_map(feed_track_to_queue)
.collect();
let idx = ordered.iter().position(|t| t.id.to_string() == clicked_id)?;
let idx = ordered
.iter()
.position(|t| t.id.to_string() == clicked_id)?;
Some((ordered, idx))
}
@@ -2043,7 +2136,9 @@ mod tests {
// starting the queue at the wrong row.
assert!(order_by_visible(&feed, &visible, "2").is_none());
// A local row is unresolvable even when it IS visible.
assert!(order_by_visible(&feed, &["/home/u/x.flac".to_string()], "/home/u/x.flac").is_none());
assert!(
order_by_visible(&feed, &["/home/u/x.flac".to_string()], "/home/u/x.flac").is_none()
);
// Nothing visible at all.
assert!(order_by_visible(&feed, &[], "1").is_none());
}
+43 -2
View File
@@ -60,6 +60,35 @@ impl ArtistFamilyAliases {
}
}
/// Canonical display names credited by one album. This is the presentation
/// companion to [`album_matches_artist_with_aliases`]: fold repeated
/// `collection • contributor` families to their corpus-backed root and
/// deduplicate by normalized identity while preserving first display
/// spelling. Deliberately do not split `AC/DC` or `Simon & Garfunkel`: the
/// artist rail treats those full display names as acts, and this facet must
/// expose the same identities rather than manufacture phantom contributors.
pub fn album_credit_names(
artist: &str,
all_artists: &str,
aliases: &ArtistFamilyAliases,
) -> Vec<String> {
let mut out = Vec::<String>::new();
let mut seen = HashSet::<String>::new();
let raw = all_artists.split(',').chain(std::iter::once(artist));
for value in raw {
let value = value.trim();
if value.is_empty() {
continue;
}
let display = aliases.canonical_display(value).unwrap_or(value);
let key = normalize_artist(display);
if !key.is_empty() && seen.insert(key) {
out.push(display.to_string());
}
}
out
}
// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
@@ -365,7 +394,11 @@ pub fn merge_artists(
sources.push(variant.source);
}
}
let source = if sources.len() == 1 { sources[0] } else { "mixed" };
let source = if sources.len() == 1 {
sources[0]
} else {
"mixed"
};
// If no album-credit document is available (notably a server-only
// profile without library.db), counts from distinct sources are
// disjoint because their album ids are namespaced. Within one source,
@@ -495,7 +528,10 @@ mod tests {
#[test]
fn normalize_preserves_non_ascii_scripts() {
assert_eq!(normalize_artist("新世紀エヴァンゲリオン"), "新世紀エヴァンゲリオン");
assert_eq!(
normalize_artist("新世紀エヴァンゲリオン"),
"新世紀エヴァンゲリオン"
);
assert_eq!(normalize_artist("林原めぐみ"), "林原めぐみ");
}
@@ -749,6 +785,11 @@ mod tests {
&normalize_artist(family),
&aliases,
));
assert_eq!(album_credit_names(&shiro, &megumi, &aliases), [family]);
assert_eq!(
album_credit_names("AC/DC", "Simon & Garfunkel", &aliases),
["Simon & Garfunkel", "AC/DC"]
);
assert!(!album_matches_artist_with_aliases(
"Alice • Bob",
"",
+125 -17
View File
@@ -94,7 +94,11 @@ pub fn session_is_cd() -> bool {
/// Where to put a rip. A folder chooser, and the answer is never guessed.
pub(crate) async fn pick_folder_for_rip() -> Option<String> {
pick_dir(&qbz_i18n::t("Where should the ripped album go?"), dirs::audio_dir()).await
pick_dir(
&qbz_i18n::t("Where should the ripped album go?"),
dirs::audio_dir(),
)
.await
}
/// The one folder chooser, for every caller that needs one.
@@ -116,7 +120,9 @@ pub(crate) async fn pick_folder_for_rip() -> Option<String> {
/// thread on macOS, and every caller here is on a worker. The async one posts
/// itself to the right thread, which is also what the Settings path does.
async fn pick_dir(title: &str, start: Option<PathBuf>) -> Option<String> {
let start = start.or_else(dirs::home_dir).unwrap_or_else(|| PathBuf::from("/"));
let start = start
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("/"));
let handle = rfd::AsyncFileDialog::new()
.set_title(title)
.set_directory(&start)
@@ -211,9 +217,9 @@ pub(crate) fn apply_editor_update(
.replace_tracks_for_session(expected_session, edited)
.map_err(|error| qbz_library::LibraryError::Metadata(error.to_string()))?
.ok_or_else(|| {
qbz_library::LibraryError::Metadata(
qbz_i18n::t("The ephemeral session changed; reopen the metadata editor."),
)
qbz_library::LibraryError::Metadata(qbz_i18n::t(
"The ephemeral session changed; reopen the metadata editor.",
))
})?;
let name = if Path::new(expected_session).is_dir() {
folder_display_name(expected_session)
@@ -240,7 +246,14 @@ fn album_tracks(group_key: &str) -> Vec<LocalTrack> {
/// publish, exactly like every other Local Library surface.
#[derive(Serialize)]
struct EphemeralDoc {
/// Folder/device name retained as the medium identity.
name: String,
/// Consistent tagged album/collection title, else `name`.
title: String,
/// `name` when `title` came from track metadata and adds useful context;
/// empty for a mixed folder or when both strings are effectively equal.
#[serde(rename = "folderContext")]
folder_context: String,
path: String,
#[serde(rename = "trackCount")]
track_count: usize,
@@ -289,12 +302,61 @@ fn folder_display_name(path: &str) -> String {
.unwrap_or_else(|| path.to_string())
}
fn normalized_title(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn consistent_title(values: impl Iterator<Item = String>) -> Option<String> {
let mut first: Option<(String, String)> = None;
for value in values {
let display = value.trim();
if display.is_empty() || display.eq_ignore_ascii_case("Unknown Album") {
return None;
}
let normalized = normalized_title(display);
match first.as_ref() {
Some((_, expected)) if expected != &normalized => return None,
Some(_) => {}
None => first = Some((display.to_string(), normalized)),
}
}
first.map(|(display, _)| display)
}
/// Infer a real collection title only when every playable row agrees. The
/// group title is preferred because it intentionally removes bare "Disc N"
/// suffixes; the raw album tag is a conservative fallback. A user's folder of
/// unrelated tracks therefore keeps the folder name instead of inventing an
/// album from the first row.
fn consistent_collection_title(tracks: &[LocalTrack]) -> Option<String> {
if tracks.is_empty() {
return None;
}
consistent_title(tracks.iter().map(|track| track.album_group_title.clone())).or_else(|| {
consistent_title(
tracks.iter().map(|track| {
qbz_library::MetadataExtractor::strip_disc_suffix_public(&track.album)
}),
)
})
}
/// Group the scanned rows into album blocks (sorted by title) and register
/// every cover in the shared art index, so the pane's covers ride the SAME
/// windowed `artworkWindow` -> `localArtworkReady` path as the grids.
/// Cheap (no I/O) but it takes the local-state lock — never call it on the Qt
/// thread.
fn build_doc(name: &str, path: &str, tracks: &[LocalTrack]) -> EphemeralDoc {
let collection_title = consistent_collection_title(tracks);
let title = collection_title.clone().unwrap_or_else(|| name.to_string());
let folder_context = collection_title
.filter(|album| normalized_title(album) != normalized_title(name))
.map(|_| name.to_string())
.unwrap_or_default();
// BTreeMap keeps a deterministic key order; scan order is preserved
// inside each group (the scanner already sorted album/disc/track/title).
let mut groups: BTreeMap<String, Vec<&LocalTrack>> = BTreeMap::new();
@@ -430,6 +492,8 @@ fn build_doc(name: &str, path: &str, tracks: &[LocalTrack]) -> EphemeralDoc {
});
EphemeralDoc {
name: name.to_string(),
title,
folder_context,
path: path.to_string(),
track_count: tracks.len(),
total_duration: crate::local_rows::total_duration(
@@ -460,11 +524,7 @@ const LABEL_MAX: usize = 24;
/// multi-album folder, or one whose tags gave no title, falls back to the
/// medium's display name, which is exactly what the pane's own header shows.
fn display_label(doc: &EphemeralDoc) -> String {
let raw = if !doc.multi_album && doc.albums.len() == 1 && !doc.albums[0].title.is_empty() {
doc.albums[0].title.as_str()
} else {
doc.name.as_str()
};
let raw = doc.title.as_str();
if raw.is_empty() {
return qbz_i18n::t("Media");
}
@@ -642,7 +702,12 @@ pub fn adopt_tracks(label: &str, tracks: Vec<LocalTrack>) {
/// different track count than the disc in the drive (a hidden track, a
/// mixed-mode disc), and pairing by position without checking is how track 5
/// gets track 6's name.
pub fn apply_naming(album: &str, album_artist: &str, year: Option<u32>, titles: &[(String, String)]) {
pub fn apply_naming(
album: &str,
album_artist: &str,
year: Option<u32>,
titles: &[(String, String)],
) {
let Some(label) = STATE.current_folder_path() else {
return;
};
@@ -676,7 +741,11 @@ pub fn apply_naming(album: &str, album_artist: &str, year: Option<u32>, titles:
// The LABEL follows the album — it is what the tab, the nav flyout and the
// pane header all read, and leaving it on the old name would make the
// correction look like it half-applied.
let name = if album.is_empty() { label.as_str() } else { album };
let name = if album.is_empty() {
label.as_str()
} else {
album
};
log::info!("[qbz-qt] ephemeral: renamed to {name:?}");
publish_doc(&build_doc(name, name, &tracks), false);
}
@@ -785,6 +854,8 @@ async fn scan(runtime: Option<Runtime>, path: String) {
publish_doc(
&EphemeralDoc {
name: name.clone(),
title: name.clone(),
folder_context: String::new(),
path: path.clone(),
track_count: 0,
total_duration: String::new(),
@@ -794,7 +865,8 @@ async fn scan(runtime: Option<Runtime>, path: String) {
true,
);
let scan_path = path.clone();
let result = tokio::task::spawn_blocking(move || STATE.open_folder(Path::new(&scan_path))).await;
let result =
tokio::task::spawn_blocking(move || STATE.open_folder(Path::new(&scan_path))).await;
match result {
Ok(Ok(res)) => {
log::info!(
@@ -926,7 +998,11 @@ mod shuffle_tests {
shuffle_in_place(&mut v);
let mut seen = v.clone();
seen.sort_unstable();
assert_eq!(seen, (0..10).collect::<Vec<_>>(), "shuffle lost or duplicated items");
assert_eq!(
seen,
(0..10).collect::<Vec<_>>(),
"shuffle lost or duplicated items"
);
firsts[v[0]] += 1;
}
// Generous bounds: this guards against a PINNED position, not against
@@ -1007,7 +1083,10 @@ async fn play_rows(runtime: &Runtime, tracks: Vec<LocalTrack>, start: usize) {
// the seam resolves these rows with no arm of their own, and `audible_qt`
// keeps the path comparison for every source.
if let Err(e) = crate::audible_qt::play_queue_track(runtime, &first).await {
log::error!("[qbz-qt] ephemeral play: track {} not playable: {e}", first.id);
log::error!(
"[qbz-qt] ephemeral play: track {} not playable: {e}",
first.id
);
}
crate::playback_qt::refresh_now_playing(runtime).await;
}
@@ -1017,8 +1096,15 @@ mod display_label_tests {
use super::*;
fn doc(name: &str, albums: Vec<(&str, &str)>, multi: bool) -> EphemeralDoc {
let title = if !multi && albums.len() == 1 && !albums[0].0.is_empty() {
albums[0].0.to_string()
} else {
name.to_string()
};
EphemeralDoc {
name: name.to_string(),
title,
folder_context: String::new(),
path: "/tmp/x".to_string(),
track_count: 0,
total_duration: String::new(),
@@ -1044,7 +1130,11 @@ mod display_label_tests {
fn one_album_is_named_by_its_album() {
// The usual case, and the reason the chain starts here: a folder IS
// an album, and so is a disc.
let d = doc("Pink Floyd London Live 8 Reunion 2005", vec![("Live 8", "Pink Floyd")], false);
let d = doc(
"Pink Floyd London Live 8 Reunion 2005",
vec![("Live 8", "Pink Floyd")],
false,
);
assert_eq!(display_label(&d), "Live 8");
}
@@ -1068,7 +1158,7 @@ mod display_label_tests {
}
#[test]
fn a_long_name_is_elided_by_CHARACTERS_not_bytes() {
fn a_long_name_is_elided_by_characters_not_bytes() {
// The elision exists because QbzTabBar sizes a segment to its text.
let long = "Symphony No. 9 in D minor, Op. 125 — Choral";
let out = display_label(&doc(long, vec![], false));
@@ -1083,6 +1173,24 @@ mod display_label_tests {
assert_eq!(out.chars().count(), LABEL_MAX);
}
#[test]
fn consistent_collection_title_requires_every_track_to_agree() {
let track = |album: &str| LocalTrack {
album: album.to_string(),
album_group_title: album.to_string(),
..Default::default()
};
let one_album = vec![track("Sorceress"), track(" Sorceress ")];
assert_eq!(
consistent_collection_title(&one_album).as_deref(),
Some("Sorceress")
);
let mix = vec![track("Sorceress"), track("Death Magnetic")];
assert!(consistent_collection_title(&mix).is_none());
assert!(consistent_collection_title(&[LocalTrack::default()]).is_none());
}
#[test]
fn a_name_exactly_at_the_cap_is_left_alone() {
let exact: String = "a".repeat(LABEL_MAX);
+10 -7
View File
@@ -54,20 +54,23 @@
// --- rows ------------------------------------------------------------------
pub use crate::local_rows::{
album_key, artist_key, badge_source, folder_key, tier_of, to_json, track_key, AlbumRow, ArtistRow, FolderDetail, LocalCounts, SubfolderRow, TrackRow, TreeNode,
album_key, artist_key, badge_source, folder_key, tier_of, to_json, track_key, AlbumRow,
ArtistRow, FolderDetail, LocalCounts, SubfolderRow, TrackRow, TreeNode,
};
// --- state / prefs ---------------------------------------------------------
pub use crate::local_state::{
album_mode, begin_tracks_load, commit_tracks_page, counts, has_library, set_album_mode,
set_tracks_group, set_tracks_query, set_tracks_sort, state, tracks_generation, tracks_group,
tracks_has_more, tracks_sort, with_db, TrackSourceOffsets, TracksLoadRequest,
set_tracks_filter, set_tracks_group, set_tracks_query, set_tracks_sort, state, tracks_filter,
tracks_generation, tracks_group, tracks_has_more, tracks_sort, with_db, TrackSourceOffsets,
TracksLoadRequest,
};
// --- queries ---------------------------------------------------------------
pub use crate::local_albums::{
fetch_album_tracks_blocking, load_album_detail_blocking, load_albums_blocking,
load_artists_blocking, load_counts_blocking, load_folders_blocking, load_tracks_page_blocking,
fetch_album_tracks_blocking, load_album_detail_filtered_blocking,
load_albums_blocking, load_artists_blocking, load_counts_blocking, load_folders_blocking,
load_tracks_page_blocking,
};
// --- folder tree -----------------------------------------------------------
@@ -81,8 +84,8 @@ pub use crate::local_artwork::{fetch_plex_misses, resolve_window_blocking, Artwo
// --- playback --------------------------------------------------------------
pub use crate::local_playback::{
enqueue, local_queue_track, play_album, play_current_if_local, LocalPlay, play_folder, play_folder_track,
play_tracks_visible,
enqueue, enqueue_album_filtered, local_queue_track, play_album, play_album_filtered,
play_current_if_local, play_folder, play_folder_track, play_tracks_visible, LocalPlay,
};
// --- plex ------------------------------------------------------------------
+33 -11
View File
@@ -186,7 +186,10 @@ fn parse_url(normalized: &str) -> Option<(String, String, Option<String>)> {
if !authority[idx + 1..].is_empty()
&& authority[idx + 1..].chars().all(|c| c.is_ascii_digit()) =>
{
(authority[..idx].to_string(), Some(authority[idx + 1..].to_string()))
(
authority[..idx].to_string(),
Some(authority[idx + 1..].to_string()),
)
}
_ => (authority.to_string(), None),
};
@@ -244,7 +247,10 @@ pub fn resolve_base_url(server_url: &str) -> String {
if scheme != "http" && scheme != "https" {
return String::new();
}
format!("{scheme}://{host}:{}", port.unwrap_or_else(|| "32400".into()))
format!(
"{scheme}://{host}:{}",
port.unwrap_or_else(|| "32400".into())
)
}
// ---------------------------------------------------------------------------
@@ -352,6 +358,7 @@ pub fn map_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> LocalTrack {
bit_depth: t.bit_depth,
sample_rate: t.sample_rate as f64,
artwork_path: t.artwork_path,
collection_artwork_path: t.collection_artwork_path,
source: Some("plex".to_string()),
..Default::default()
}
@@ -372,12 +379,23 @@ pub fn search_tracks_page(
offset: u64,
limit: u64,
sort: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> Vec<LocalTrack> {
qbz_plex::plex_cache_search_tracks_page(query.trim().to_string(), offset, limit, sort)
.unwrap_or_default()
.into_iter()
.map(map_cached_to_local_track)
.collect()
qbz_plex::plex_cache_search_tracks_page_filtered(
query.trim().to_string(),
offset,
limit,
sort,
formats,
other_formats,
quality_tiers,
)
.unwrap_or_default()
.into_iter()
.map(map_cached_to_local_track)
.collect()
}
/// One Plex album's tracks, by the legacy content hash (`plex:<hash>`) or the
@@ -575,9 +593,10 @@ async fn sync_inner() -> Result<usize, String> {
let base = cfg.base_url.clone();
let token = cfg.token.clone();
let sections = qbz_plex::plex_get_music_sections(base.trim().to_string(), token.trim().to_string())
.await
.map_err(|e| e.to_string())?;
let sections =
qbz_plex::plex_get_music_sections(base.trim().to_string(), token.trim().to_string())
.await
.map_err(|e| e.to_string())?;
let machine_id = settings().machine_id;
let server_id = (!machine_id.is_empty()).then_some(machine_id);
@@ -719,6 +738,9 @@ async fn sync_inner() -> Result<usize, String> {
break;
}
}
log::info!("[qbz-qt] plex sync: {total} tracks across {} sections", selected.len());
log::info!(
"[qbz-qt] plex sync: {total} tracks across {} sections",
selected.len()
);
Ok(total)
}
+29 -9
View File
@@ -139,6 +139,31 @@ pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, albu
// return here would have made the switch dead all over again, just for a
// smaller set of users.
maybe_notify(track, title, album);
push_metadata(track, title, album);
let Some(mc) = handle() else {
return;
};
mc.set_playback(
qbz_media_controls::PlaybackStatus::Playing,
Some(Duration::ZERO),
);
}
/// Republish only the metadata after an asynchronous cover download lands.
///
/// The normal track-edge publisher also raises a notification and resets the
/// optimistic MPRIS position to zero. Neither belongs to an artwork completion:
/// this narrower seam lets the de-duplication key observe the newly resolved
/// local cover without altering playback state or notifying twice.
pub(crate) fn refresh_now_playing_artwork(
track: &qbz_models::QueueTrack,
title: &str,
album: &str,
) {
push_metadata(track, title, album);
}
fn push_metadata(track: &qbz_models::QueueTrack, title: &str, album: &str) {
let Some(mc) = handle() else {
return;
};
@@ -151,8 +176,7 @@ pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, albu
title: title.to_string(),
artist: track.artist.clone(),
album: album.to_string(),
duration: (track.duration_secs > 0)
.then(|| Duration::from_secs(track.duration_secs)),
duration: (track.duration_secs > 0).then(|| Duration::from_secs(track.duration_secs)),
art_url: art,
// `xesam:url` (#658): the public Qobuz link for catalog rows, None
// for local/plex (`qbz-media-controls/src/types.rs:30-36`).
@@ -171,10 +195,6 @@ pub(crate) fn push_now_playing(track: &qbz_models::QueueTrack, title: &str, albu
track.duration_secs
);
}
mc.set_playback(
qbz_media_controls::PlaybackStatus::Playing,
Some(Duration::ZERO),
);
}
/// The no-track arm: reset the de-dupe (so replaying the SAME track after a
@@ -337,9 +357,9 @@ fn artwork_ref_for(track: &qbz_models::QueueTrack) -> qbz_models::ArtworkRef {
// (and the offline cache lookup below then hits the very file the
// now-playing feed already downloaded at that tier).
match artwork {
qbz_models::ArtworkRef::Remote(u) => qbz_models::ArtworkRef::Remote(
qbz_models::qobuz_cover_at_px(&u, 600).unwrap_or(u),
),
qbz_models::ArtworkRef::Remote(u) => {
qbz_models::ArtworkRef::Remote(qbz_models::qobuz_cover_at_px(&u, 600).unwrap_or(u))
}
other => other,
}
}
+34 -14
View File
@@ -226,7 +226,8 @@ pub async fn probe(kind: MediaServerKind, url: &str) -> Result<String, String> {
// proves a Subsonic server answered at this address, and that is
// exactly what the test is for.
let creds = qbz_subsonic::Credentials::new("", "", "probe");
let client = qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
let client =
qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
match client.ping().await {
Ok(i) => Ok(format!("{} {}", i.kind, i.version)),
// A protocol-level error means a Subsonic server IS there.
@@ -257,10 +258,9 @@ pub async fn connect(
// The DeviceId must be the PERSISTED one: authenticating under a
// fresh id every time revokes the previous token and litters the
// server's device list. `init_for_user` minted it.
let session =
qbz_jellyfin::authenticate(url, &cfg.device_id, username, password)
.await
.map_err(|e| e.to_string())?;
let session = qbz_jellyfin::authenticate(url, &cfg.device_id, username, password)
.await
.map_err(|e| e.to_string())?;
let info = qbz_jellyfin::probe(url).await.ok();
cfg.base_url = qbz_jellyfin::normalize_base_url(url);
cfg.token = session.access_token;
@@ -274,7 +274,8 @@ pub async fn connect(
}
MediaServerKind::Subsonic => {
let creds = qbz_subsonic::Credentials::new(username, password, &cfg.salt);
let client = qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
let client =
qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
let info = client.ping().await.map_err(|e| e.to_string())?;
if !info.open_subsonic {
// Not fatal: the library still lists and still plays. But
@@ -357,6 +358,7 @@ pub fn cached_to_local_track(t: qbz_media_cache::CachedTrack) -> qbz_library::Lo
sample_rate: t.sample_rate_hz.unwrap_or(0) as f64,
year: t.year,
artwork_path: t.artwork_token,
collection_artwork_path: t.collection_artwork_token,
source: Some(t.source),
..Default::default()
}
@@ -442,7 +444,10 @@ pub fn cached_track_counts() -> (i64, i64) {
.with(|c| qbz_media_cache::count(c, source).unwrap_or(0) as i64)
.unwrap_or(0)
};
(count(MediaServerKind::Jellyfin), count(MediaServerKind::Subsonic))
(
count(MediaServerKind::Jellyfin),
count(MediaServerKind::Subsonic),
)
}
pub fn cached_track_count() -> i64 {
@@ -467,8 +472,7 @@ pub fn cached_artists() -> Vec<(&'static str, qbz_media_cache::CachedArtist)> {
qbz_source::registry().subsonic().cache(),
),
};
if let Some(rows) =
handle.with(|c| qbz_media_cache::artists(c, source).unwrap_or_default())
if let Some(rows) = handle.with(|c| qbz_media_cache::artists(c, source).unwrap_or_default())
{
out.extend(rows.into_iter().map(|row| (kind.as_str(), row)));
}
@@ -483,6 +487,9 @@ pub fn search_tracks_page(
offset: u64,
limit: u64,
sort: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> Vec<qbz_library::LocalTrack> {
let Some(kind) = MediaServerKind::from_word(source_word) else {
return Vec::new();
@@ -502,8 +509,18 @@ pub fn search_tracks_page(
};
let rows = handle
.with(|c| {
qbz_media_cache::search_page(c, source, query, offset, limit, sort)
.unwrap_or_default()
qbz_media_cache::search_page_filtered(
c,
source,
query,
offset,
limit,
sort,
formats,
other_formats,
quality_tiers,
)
.unwrap_or_default()
})
.unwrap_or_default();
if source == qbz_media_cache::RemoteSource::Jellyfin {
@@ -532,8 +549,8 @@ pub fn search_tracks(query: &str, limit: Option<u32>) -> Vec<qbz_library::LocalT
qbz_source::registry().subsonic().cache(),
),
};
if let Some(rows) = handle
.with(|c| qbz_media_cache::search(c, source, query, limit).unwrap_or_default())
if let Some(rows) =
handle.with(|c| qbz_media_cache::search(c, source, query, limit).unwrap_or_default())
{
out.extend(rows.into_iter().map(cached_to_local_track));
}
@@ -555,7 +572,10 @@ mod tests {
let b = random_hex(16);
assert_eq!(a.len(), 16);
assert_eq!(random_hex(8).len(), 8);
assert_ne!(a, b, "two draws collided — every install would share a session");
assert_ne!(
a, b,
"two draws collided — every install would share a session"
);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
}
+96 -24
View File
@@ -34,7 +34,7 @@
//! flipped: it deliberately does not see unchanged rows, so every one of
//! them looks stale.
use std::collections::{HashSet, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
@@ -410,6 +410,22 @@ async fn sync_jellyfin_inner(cfg: MediaServerSettings, full: bool) -> Result<Syn
}
fn jellyfin_row(t: &qbz_jellyfin::JellyfinTrack, server_id: &str, library_id: &str) -> CachedTrack {
// Preserve the addressable ITEM together with the optional cache-busting
// tag. Per-item art is the disc/track layer and therefore outranks the
// MusicAlbum image. A missing album tag is not a missing cover: Jellyfin
// serves `/Items/{albumId}/Images/Primary` without one.
let artwork_token = t
.item_image_tag
.as_ref()
.filter(|_| !t.id.is_empty())
.map(|tag| format!("{}/{}", t.id, tag));
let collection_artwork_token = (!t.album_id.is_empty()).then(|| {
format!(
"{}/{}",
t.album_id,
t.album_image_tag.as_deref().unwrap_or_default()
)
});
CachedTrack {
id: 0,
source: "jellyfin".into(),
@@ -425,6 +441,7 @@ fn jellyfin_row(t: &qbz_jellyfin::JellyfinTrack, server_id: &str, library_id: &s
disc_number: t.disc_number,
duration_ms: t.duration_ms,
year: t.year,
genres: t.genres.clone(),
genre: t.genre.clone(),
container: t.container.clone(),
codec: t.codec.clone(),
@@ -432,14 +449,8 @@ fn jellyfin_row(t: &qbz_jellyfin::JellyfinTrack, server_id: &str, library_id: &s
sample_rate_hz: t.sample_rate_hz,
channels: t.channels,
bitrate_kbps: t.bitrate_bps.map(|b| b / 1000),
// `<albumId>/<tag>`, because a Jellyfin image tag alone is not
// addressable — the url hangs off the ITEM and the tag only versions
// it. `JellyfinSource::artwork_token` splits it back apart.
artwork_token: t
.album_image_tag
.as_ref()
.filter(|_| !t.album_id.is_empty())
.map(|tag| format!("{}/{}", t.album_id, tag)),
artwork_token,
collection_artwork_token,
size_bytes: None,
}
}
@@ -775,6 +786,37 @@ async fn sync_subsonic_inner(
let pass = async {
match mode {
qbz_subsonic::SweepMode::Search3 => {
// `search3` returns song/disc coverArt tokens. Fetch the small
// album listing as enrichment so collection art survives as a
// separate fallback instead of being guessed from disc one.
let mut collection_art = HashMap::<String, String>::new();
let mut album_offset = 0u32;
loop {
match client.album_page(album_offset).await {
Ok(albums) => {
let page_len = albums.len() as u32;
for album in albums {
if let Some(token) = album.cover_art {
collection_art.insert(album.id, token);
}
}
album_offset = album_offset.saturating_add(page_len);
if page_len < qbz_subsonic::PAGE_SIZE {
break;
}
}
Err(error) => {
// Collection art is enrichment; a server that can
// enumerate songs must not lose its usable catalog
// merely because getAlbumList2 is unavailable.
log::warn!(
"[media-sync] source=subsonic collection artwork unavailable: {error}"
);
collection_art.clear();
break;
}
}
}
let mut offset = 0u32;
let mut page_number = 0u64;
loop {
@@ -796,7 +838,15 @@ async fn sync_subsonic_inner(
}
let page_len = u32::try_from(page.len())
.map_err(|_| "subsonic page length overflow".to_string())?;
let rows: Vec<CachedTrack> = page.iter().map(subsonic_row).collect();
let rows: Vec<CachedTrack> = page
.iter()
.map(|track| {
subsonic_row(
track,
collection_art.get(&track.album_id).map(String::as_str),
)
})
.collect();
saved += write_generation_rows(
RemoteSource::Subsonic,
generation,
@@ -825,7 +875,7 @@ async fn sync_subsonic_inner(
return Err("subsonic sync cancelled".to_string());
}
let album_page = client
.album_ids(album_offset)
.album_page(album_offset)
.await
.map_err(|e| e.to_string())?;
if album_page.is_empty() {
@@ -833,7 +883,7 @@ async fn sync_subsonic_inner(
}
let page_len = u32::try_from(album_page.len())
.map_err(|_| "subsonic album page length overflow".to_string())?;
for album_id in album_page {
for album in album_page {
if !source_epoch_is_current(RemoteSource::Subsonic, sync_epoch) {
return Err("subsonic sync cancelled".to_string());
}
@@ -841,10 +891,13 @@ async fn sync_subsonic_inner(
// Skipping it and pruning would reinterpret a server
// outage as deletion of every track in that album.
let tracks = client
.album_tracks(&album_id)
.album_tracks(&album.id)
.await
.map_err(|e| e.to_string())?;
let rows: Vec<CachedTrack> = tracks.iter().map(subsonic_row).collect();
let rows: Vec<CachedTrack> = tracks
.iter()
.map(|track| subsonic_row(track, album.cover_art.as_deref()))
.collect();
saved += write_generation_rows(
RemoteSource::Subsonic,
generation,
@@ -900,7 +953,10 @@ async fn sync_subsonic_inner(
finish_subsonic(cfg, saved, pruned, sync_epoch)
}
fn subsonic_row(t: &qbz_subsonic::SubsonicTrack) -> CachedTrack {
fn subsonic_row(
t: &qbz_subsonic::SubsonicTrack,
collection_artwork_token: Option<&str>,
) -> CachedTrack {
CachedTrack {
id: 0,
source: "subsonic".into(),
@@ -916,6 +972,7 @@ fn subsonic_row(t: &qbz_subsonic::SubsonicTrack) -> CachedTrack {
disc_number: t.disc_number,
duration_ms: t.duration_ms,
year: t.year,
genres: t.genres.clone(),
genre: t.genre.clone(),
container: t.suffix.clone(),
codec: t.content_type.clone(),
@@ -925,6 +982,7 @@ fn subsonic_row(t: &qbz_subsonic::SubsonicTrack) -> CachedTrack {
bitrate_kbps: t.bitrate_kbps,
// The OPAQUE coverArt id, verbatim. Never parsed, never built.
artwork_token: t.cover_art.clone(),
collection_artwork_token: collection_artwork_token.map(str::to_string),
size_bytes: t.size,
}
}
@@ -1157,10 +1215,10 @@ mod tests {
assert!(!source_epoch_is_current(RemoteSource::Subsonic, epoch));
}
/// A Jellyfin art token is only useful with the item it hangs off, so a row
/// with a tag and no album id must carry NO token rather than a broken one.
/// An item image wins over album art. Album art remains addressable by id
/// when Jellyfin omits its optional cache-busting tag.
#[test]
fn a_jellyfin_art_token_needs_its_album_id() {
fn jellyfin_art_tokens_keep_item_and_tagless_album_fallbacks() {
let mut t = qbz_jellyfin::JellyfinTrack {
id: "i".into(),
title: String::new(),
@@ -1172,6 +1230,7 @@ mod tests {
disc_number: None,
duration_ms: 0,
year: None,
genres: Vec::new(),
genre: None,
container: String::new(),
codec: None,
@@ -1180,17 +1239,28 @@ mod tests {
channels: None,
bitrate_bps: None,
album_image_tag: Some("tag".into()),
item_image_tag: None,
server_path: None,
};
assert_eq!(
jellyfin_row(&t, "srv", "lib").artwork_token.as_deref(),
Some("alb/tag")
);
let row = jellyfin_row(&t, "srv", "lib");
assert_eq!(row.artwork_token, None);
assert_eq!(row.collection_artwork_token.as_deref(), Some("alb/tag"));
t.album_id = String::new();
assert_eq!(jellyfin_row(&t, "srv", "lib").artwork_token, None);
let row = jellyfin_row(&t, "srv", "lib");
assert_eq!(row.artwork_token, None);
assert_eq!(row.collection_artwork_token, None);
t.album_id = "alb".into();
t.album_image_tag = None;
assert_eq!(jellyfin_row(&t, "srv", "lib").artwork_token, None);
assert_eq!(
jellyfin_row(&t, "srv", "lib")
.collection_artwork_token
.as_deref(),
Some("alb/")
);
t.item_image_tag = Some("disc-tag".into());
let row = jellyfin_row(&t, "srv", "lib");
assert_eq!(row.artwork_token.as_deref(), Some("i/disc-tag"));
assert_eq!(row.collection_artwork_token.as_deref(), Some("alb/"));
}
/// Bitrate crosses the boundary in different units: Jellyfin reports bits
@@ -1208,6 +1278,7 @@ mod tests {
disc_number: None,
duration_ms: 0,
year: None,
genres: Vec::new(),
genre: None,
container: String::new(),
codec: None,
@@ -1216,6 +1287,7 @@ mod tests {
channels: None,
bitrate_bps: Some(3_120_281),
album_image_tag: None,
item_image_tag: None,
server_path: None,
};
assert_eq!(jellyfin_row(&jf, "s", "l").bitrate_kbps, Some(3120));
+43 -10
View File
@@ -28,7 +28,10 @@
//! - **Plex rows are PREPENDED and the track section is a plain `take(cap)`,**
//! so three Plex matches can starve the local-file tracks out of the section
//! entirely.
//! - **No folder-cover fallback.** Row builders read `artwork_path` only.
//! - **Artwork is resolved before mapping.** Current scans persist
//! embedded/disc/collection art in that order; this bounded search window
//! also runs the queue-time folder resolver so rows indexed by an older
//! build get the same per-disc result without requiring a rescan.
use std::collections::HashSet;
@@ -52,13 +55,25 @@ pub(crate) struct LocalCaps {
impl LocalCaps {
/// Normal profile (Qobuz present): compact on-device block.
pub const NORMAL: LocalCaps = LocalCaps { albums: 3, artists: 2, tracks: 3 };
pub const NORMAL: LocalCaps = LocalCaps {
albums: 3,
artists: 2,
tracks: 3,
};
/// Expanded profile (offline / not signed in → wider on-device block).
pub const EXPANDED: LocalCaps = LocalCaps { albums: 8, artists: 4, tracks: 8 };
pub const EXPANDED: LocalCaps = LocalCaps {
albums: 8,
artists: 4,
tracks: 8,
};
/// `expand` is offline OR an unauthenticated session.
pub fn for_session(expand: bool) -> LocalCaps {
if expand { Self::EXPANDED } else { Self::NORMAL }
if expand {
Self::EXPANDED
} else {
Self::NORMAL
}
}
/// How many raw local TRACK rows to fetch so the grouped album section can
@@ -384,8 +399,7 @@ pub(crate) fn append_immersive_local_albums(
/// reference on the surface being ported is this contract's job; sweeping the
/// rest is not.
fn exclude_network_folders_now() -> bool {
crate::offline_fwd::engine().status().connectivity
== qbz_app::offline_mode::Connectivity::Down
crate::offline_fwd::engine().status().connectivity == qbz_app::offline_mode::Connectivity::Down
}
/// Fetch up to `limit` local-library tracks matching `query`, off the calling
@@ -439,11 +453,20 @@ pub(crate) async fn load_cortinilla_local(
// it always was; a media-server mirror can hold 50k rows and this runs
// on every keystroke of the cortinilla, so it takes the same limit the
// caller asked the local query for.
merged.extend(crate::media_servers_qt::search_tracks(q.trim(), Some(limit as u32)));
merged.extend(crate::media_servers_qt::search_tracks(
q.trim(),
Some(limit as u32),
));
if !merged.is_empty() {
merged.append(&mut rows);
rows = merged;
}
// Search is an artwork-bearing surface too. Keep the result consistent
// with Library Explorer and playback for pre-migration rows: a cover
// in the track's disc directory wins over a stale collection cover,
// while collection art remains the fallback. The window is bounded by
// `limit`, and this closure is already off the async/UI threads.
crate::local_playback::fill_missing_covers(&mut rows);
rows
})
.await
@@ -495,8 +518,14 @@ mod tests {
!album_titles.contains(&"Uroboric Forms"),
"a track-title match must not pull its album in: {album_titles:?}"
);
assert!(album_titles.contains(&"The Number of the Beast"), "album-artist match");
assert!(album_titles.contains(&"Iron Canciones"), "album-title match");
assert!(
album_titles.contains(&"The Number of the Beast"),
"album-artist match"
);
assert!(
album_titles.contains(&"Iron Canciones"),
"album-title match"
);
let (artists, _) = derive_local_artist_rows(&rows, 10, "Iro");
let names: Vec<&str> = artists.iter().map(|r| r.title.as_str()).collect();
@@ -542,6 +571,10 @@ mod tests {
fn an_empty_query_filters_nothing() {
let rows = vec![track("A", "B", "C")];
let (albums, _) = derive_local_album_rows(&rows, 10, "");
assert_eq!(albums.len(), 1, "an empty needle must not empty the section");
assert_eq!(
albums.len(),
1,
"an empty needle must not empty the section"
);
}
}