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

+2
View File
@@ -6638,6 +6638,7 @@ name = "qbz-media-cache"
version = "2.0.2"
dependencies = [
"rusqlite",
"serde_json",
"tempfile",
]
@@ -6777,6 +6778,7 @@ dependencies = [
"reqwest",
"rusqlite",
"serde",
"serde_json",
]
[[package]]
+38 -2
View File
@@ -42,6 +42,7 @@
//! is the 2.1 scope; both are supported by the server and neither is exercised
//! by anything today, so shipping them untested would be inventing surface.
use std::collections::HashMap;
use std::time::Duration;
use serde::Deserialize;
@@ -164,6 +165,9 @@ pub struct JellyfinTrack {
pub disc_number: Option<u32>,
pub duration_ms: u64,
pub year: Option<u32>,
/// Every genre Jellyfin supplied, in server order. `genre` remains the
/// compatibility primary value for older cache readers.
pub genres: Vec<String>,
pub genre: Option<String>,
/// Container as the server names it (`flac`, `mp3`, `ape`).
pub container: String,
@@ -175,8 +179,13 @@ pub struct JellyfinTrack {
pub channels: Option<u32>,
pub bitrate_bps: Option<u32>,
/// The album's primary-image tag. Absent on 1053 of 4924 measured rows
/// (21 %) — a blank cover is the common case here, not an edge case.
/// (21 %). Its absence does not mean the album has no image: Jellyfin's
/// primary-image endpoint remains addressable by `album_id` without a tag.
pub album_image_tag: Option<String>,
/// This audio item's own primary-image tag. Jellyfin/Navidrome can expose
/// a different embedded or folder cover for each disc while still grouping
/// every row under one `MusicAlbum`; this must outrank the collection art.
pub item_image_tag: Option<String>,
/// The server's own path. Real, unlike Subsonic's synthesised one, but QBZ
/// never opens it: the file lives on the server. Kept for diagnostics only.
pub server_path: Option<String>,
@@ -267,6 +276,8 @@ struct AudioDto {
#[serde(default)]
album_primary_image_tag: Option<String>,
#[serde(default)]
image_tags: HashMap<String, String>,
#[serde(default)]
media_sources: Vec<MediaSourceDto>,
}
@@ -343,6 +354,23 @@ impl AudioDto {
.find(|s| s.kind == "Audio");
let artist = self.artists.first().cloned().unwrap_or_default();
let album_artist = self.album_artist.clone().unwrap_or_else(|| artist.clone());
let genres = self
.genres
.into_iter()
.map(|genre| genre.trim().to_string())
.filter(|genre| !genre.is_empty())
.fold(Vec::<String>::new(), |mut out, genre| {
if !out.iter().any(|value| value.eq_ignore_ascii_case(&genre)) {
out.push(genre);
}
out
});
let item_image_tag = self
.image_tags
.iter()
.find(|(kind, _)| kind.eq_ignore_ascii_case("primary"))
.map(|(_, tag)| tag.trim().to_string())
.filter(|tag| !tag.is_empty());
JellyfinTrack {
id: self.id,
title: self.name,
@@ -358,7 +386,8 @@ impl AudioDto {
disc_number: self.parent_index_number,
duration_ms: JellyfinTrack::ticks_to_ms(self.run_time_ticks.unwrap_or(0)),
year: self.production_year,
genre: self.genres.into_iter().next(),
genre: genres.first().cloned(),
genres,
container: self.container.unwrap_or_default(),
codec: audio.as_ref().and_then(|a| a.codec.clone()),
// A lossy codec reports no bit depth. Preserved as `None` rather
@@ -368,6 +397,7 @@ impl AudioDto {
channels: audio.as_ref().and_then(|a| a.channels),
bitrate_bps: audio.as_ref().and_then(|a| a.bit_rate),
album_image_tag: self.album_primary_image_tag,
item_image_tag,
server_path: self.path,
}
}
@@ -829,8 +859,10 @@ mod tests {
"ParentIndexNumber": 1,
"RunTimeTicks": 3484800000i64,
"ProductionYear": 1988,
"Genres": ["Thrash Metal", "Heavy Metal", "thrash metal"],
"Container": "flac",
"AlbumPrimaryImageTag": "748607df",
"ImageTags": { "Primary": "disc-specific" },
"MediaSources": [{
"MediaStreams": [
{ "Type": "EmbeddedImage", "Codec": "mjpeg", "BitDepth": 8 },
@@ -853,6 +885,9 @@ mod tests {
assert_eq!(t.duration_ms, 348_480);
assert_eq!(t.track_number, Some(6));
assert_eq!(t.disc_number, Some(1));
assert_eq!(t.genres, ["Thrash Metal", "Heavy Metal"]);
assert_eq!(t.genre.as_deref(), Some("Thrash Metal"));
assert_eq!(t.item_image_tag.as_deref(), Some("disc-specific"));
}
#[test]
@@ -989,6 +1024,7 @@ mod tests {
assert_eq!(t.track_number, None);
assert_eq!(t.duration_ms, 0);
assert_eq!(t.album_image_tag, None);
assert_eq!(t.item_image_tag, None);
}
/// An artist-less row falls back to the ALBUM artist rather than rendering
+105 -7
View File
@@ -12,6 +12,11 @@ pub struct CueSheet {
pub file_path: String,
/// Referenced audio file (resolved to absolute path)
pub audio_file: String,
/// Distinct audio files named by FILE directives. QBZ only expands a
/// sheet into virtual tracks when this contains exactly one entry; a
/// multi-file CUE describes files that are already split and is treated
/// as a sidecar instead of a second copy of the album.
audio_files: Vec<String>,
/// Album title
pub title: Option<String>,
/// Album performer/artist
@@ -20,6 +25,22 @@ pub struct CueSheet {
pub tracks: Vec<CueTrack>,
}
impl CueSheet {
/// Whether every track in this sheet addresses one shared audio image.
///
/// `cue_to_tracks` models virtual ranges inside a single decoder source.
/// A CUE with several distinct FILE directives is a metadata sidecar for
/// already-split files and must not enter that path.
pub fn is_single_file_image(&self) -> bool {
self.audio_files.len() == 1
}
/// Number of distinct audio files referenced by the sheet.
pub fn audio_file_count(&self) -> usize {
self.audio_files.len()
}
}
/// A track within a CUE sheet
#[derive(Debug, Clone)]
pub struct CueTrack {
@@ -83,6 +104,7 @@ impl CueParser {
let mut sheet = CueSheet {
file_path: cue_path.to_string_lossy().to_string(),
audio_file: String::new(),
audio_files: Vec::new(),
title: None,
performer: None,
tracks: Vec::new(),
@@ -103,11 +125,16 @@ impl CueParser {
if line.to_uppercase().starts_with("FILE ") {
if let Some(filename) = Self::extract_quoted(line) {
// Resolve path relative to CUE file
if let Some(parent) = cue_path.parent() {
let audio_path = parent.join(&filename);
sheet.audio_file = audio_path.to_string_lossy().to_string();
let resolved = if let Some(parent) = cue_path.parent() {
parent.join(&filename).to_string_lossy().into_owned()
} else {
sheet.audio_file = filename;
filename
};
if sheet.audio_file.is_empty() {
sheet.audio_file.clone_from(&resolved);
}
if !sheet.audio_files.contains(&resolved) {
sheet.audio_files.push(resolved);
}
}
}
@@ -180,7 +207,11 @@ impl CueParser {
));
}
log::info!("Parsed CUE: {} tracks", sheet.tracks.len());
log::info!(
"Parsed CUE: {} tracks, {} audio file(s)",
sheet.tracks.len(),
sheet.audio_file_count()
);
Ok(sheet)
}
@@ -212,6 +243,13 @@ pub fn cue_to_tracks(
format: AudioFormat,
properties: &AudioProperties,
) -> Vec<LocalTrack> {
// This converter expresses every row as a time range in one decoder
// source. Feeding it a multi-file CUE used to bind all rows to the last
// FILE directive and publish zero-duration duplicates of the split files.
if !cue.is_single_file_image() || audio_duration_secs == 0 {
return Vec::new();
}
let mut tracks = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -225,13 +263,27 @@ pub fn cue_to_tracks(
for (i, cue_track) in cue.tracks.iter().enumerate() {
// Calculate end time (next track's start or audio end)
let end_secs = if i + 1 < cue.tracks.len() {
let requested_end = if i + 1 < cue.tracks.len() {
cue.tracks[i + 1].start_secs
} else {
audio_duration_secs as f64
};
let end_secs = requested_end.min(audio_duration_secs as f64);
let duration = (end_secs - cue_track.start_secs).max(0.0) as u64;
let range_secs = end_secs - cue_track.start_secs;
if !range_secs.is_finite() || range_secs <= 0.0 {
log::warn!(
"Ignoring unplayable CUE track {}: start={} end={} file={}",
cue_track.number,
cue_track.start_secs,
end_secs,
cue.audio_file
);
continue;
}
// A valid sub-second range is still playable; keep its visible
// duration non-zero while cue_start/end retain frame precision.
let duration = range_secs.ceil().max(1.0) as u64;
tracks.push(LocalTrack {
id: 0,
@@ -253,6 +305,7 @@ pub fn cue_to_tracks(
disc_number: inferred_disc,
year: None,
genre: None,
genres: Vec::new(),
catalog_number: None,
duration_secs: duration,
format: format.clone(),
@@ -264,6 +317,7 @@ pub fn cue_to_tracks(
cue_start_secs: Some(cue_track.start_secs),
cue_end_secs: Some(end_secs),
artwork_path: None,
collection_artwork_path: None,
last_modified: 0,
indexed_at: now,
source: None,
@@ -307,4 +361,48 @@ mod tests {
assert_eq!(CueParser::extract_track_number("TRACK 01 AUDIO"), Some(1));
assert_eq!(CueParser::extract_track_number("TRACK 12 AUDIO"), Some(12));
}
#[test]
fn multi_file_cue_is_a_split_file_sidecar_not_an_image() {
let cue = CueParser::parse_content(
"PERFORMER \"Opeth\"\n\
TITLE \"Sorceress\"\n\
FILE \"01. Persephone.flac\" WAVE\n\
TRACK 01 AUDIO\n\
TITLE \"Persephone\"\n\
INDEX 01 00:00:00\n\
FILE \"02. Sorceress.flac\" WAVE\n\
TRACK 02 AUDIO\n\
TITLE \"Sorceress\"\n\
INDEX 01 00:00:00\n",
Path::new("/music/Sorceress/disc.cue"),
)
.unwrap();
assert_eq!(cue.audio_file_count(), 2);
assert!(!cue.is_single_file_image());
assert!(
cue_to_tracks(&cue, 240, AudioFormat::Flac, &AudioProperties::default()).is_empty()
);
}
#[test]
fn invalid_or_out_of_bounds_ranges_are_not_published() {
let cue = CueParser::parse_content(
"FILE \"album.flac\" WAVE\n\
TRACK 01 AUDIO\n\
TITLE \"Playable\"\n\
INDEX 01 00:00:00\n\
TRACK 02 AUDIO\n\
TITLE \"Past EOF\"\n\
INDEX 01 10:00:00\n",
Path::new("/music/album.cue"),
)
.unwrap();
let tracks = cue_to_tracks(&cue, 300, AudioFormat::Flac, &AudioProperties::default());
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].title, "Playable");
assert_eq!(tracks[0].duration_secs, 300);
}
}
File diff suppressed because it is too large. Load diff
+55 -6
View File
@@ -38,6 +38,10 @@ use crate::{
};
use serde::Serialize;
fn should_expand_cue(cue: &crate::CueSheet) -> bool {
cue.is_single_file_image()
}
fn apply_sidecar_override(
track: &mut LocalTrack,
cache: &mut HashMap<PathBuf, Option<AlbumTagSidecar>>,
@@ -179,6 +183,20 @@ impl EphemeralLibraryState {
for cue_path in &scan.cue_files {
match CueParser::parse(cue_path) {
Ok(mut cue) => {
// A multi-file CUE is a sidecar for audio that has
// already been split (usually one FILE per TRACK). The
// regular audio loop below is authoritative in that
// layout. Expanding it as though it were one image would
// duplicate every row and bind the virtual entries to an
// arbitrary backing file.
if !should_expand_cue(&cue) {
log::info!(
"[ephemeral] treating multi-file CUE as sidecar ({} files): {}",
cue.audio_file_count(),
cue_path.display()
);
continue;
}
let audio_path_raw = Path::new(&cue.audio_file).to_path_buf();
let canonical = std::fs::canonicalize(&audio_path_raw)
.unwrap_or_else(|_| audio_path_raw.clone());
@@ -258,9 +276,10 @@ impl EphemeralLibraryState {
let mut found =
MetadataExtractor::extract_artwork(&canonical, &artwork_cache);
if found.is_none() {
if let Some(folder_art) =
MetadataExtractor::find_folder_artwork(&canonical, cue.title.as_deref())
{
if let Some(folder_art) = MetadataExtractor::find_folder_artwork(
&canonical,
cue.title.as_deref(),
) {
found = MetadataExtractor::cache_artwork_file(
Path::new(&folder_art),
&artwork_cache,
@@ -323,8 +342,8 @@ impl EphemeralLibraryState {
sc.spawn(move || {
part.iter()
.map(|f| {
let canonical = std::fs::canonicalize(f)
.unwrap_or_else(|_| f.clone());
let canonical =
std::fs::canonicalize(f).unwrap_or_else(|_| f.clone());
(canonical, MetadataExtractor::extract(f))
})
.collect::<Vec<_>>()
@@ -613,8 +632,9 @@ impl Default for EphemeralLibraryState {
}
#[cfg(test)]
mod editor_tests {
mod tests {
use super::*;
use std::fs;
fn track_at(path: &Path) -> LocalTrack {
LocalTrack {
@@ -627,6 +647,35 @@ mod editor_tests {
}
}
#[test]
fn ephemeral_loader_only_expands_single_image_cues() {
let temp = tempfile::tempdir().unwrap();
let single_path = temp.path().join("single.cue");
fs::write(
&single_path,
"FILE \"album.flac\" WAVE\n\
TRACK 01 AUDIO\n\
INDEX 01 00:00:00\n\
TRACK 02 AUDIO\n\
INDEX 01 03:00:00\n",
)
.unwrap();
let multi_path = temp.path().join("split.cue");
fs::write(
&multi_path,
"FILE \"01. First.flac\" WAVE\n\
TRACK 01 AUDIO\n\
INDEX 01 00:00:00\n\
FILE \"02. Second.flac\" WAVE\n\
TRACK 02 AUDIO\n\
INDEX 01 00:00:00\n",
)
.unwrap();
assert!(should_expand_cue(&CueParser::parse(&single_path).unwrap()));
assert!(!should_expand_cue(&CueParser::parse(&multi_path).unwrap()));
}
#[test]
fn sidecar_overrides_are_visible_to_ephemeral_scans() {
let temp = tempfile::tempdir().unwrap();
+40 -8
View File
@@ -1,7 +1,7 @@
//! Metadata extraction for audio files
use lofty::prelude::*;
use lofty::config::ParseOptions;
use lofty::prelude::*;
use lofty::probe::Probe;
use lofty::tag::ItemKey;
use std::fs;
@@ -43,13 +43,30 @@ impl MetadataExtractor {
/// First non-empty string for `key` across all of the file's tags
/// (primary first). When several tags disagree, the primary tag wins —
/// deterministic, and matches what other players show.
fn string_across_tags(
tagged_file: &lofty::file::TaggedFile,
key: &ItemKey,
) -> Option<String> {
fn string_across_tags(tagged_file: &lofty::file::TaggedFile, key: &ItemKey) -> Option<String> {
Self::string_from_tags(Self::tags_primary_first(tagged_file), key)
}
/// Every non-empty value for `key` across all tags, preserving primary-tag
/// order and deduplicating case-insensitively. This is intentionally
/// separate from the first-value fallback used by scalar metadata.
fn strings_across_tags(tagged_file: &lofty::file::TaggedFile, key: &ItemKey) -> Vec<String> {
let mut values = Vec::<String>::new();
for tag in Self::tags_primary_first(tagged_file) {
for value in tag.get_strings(key.clone()) {
let value = value.trim();
if !value.is_empty()
&& !values
.iter()
.any(|existing| existing.eq_ignore_ascii_case(value))
{
values.push(value.to_string());
}
}
}
values
}
/// Pure core of [`Self::string_across_tags`]: first non-empty, trimmed
/// value for `key` yielded by the tag iterator (already in priority
/// order). The empty check lives INSIDE the find_map so a blank value in
@@ -675,6 +692,7 @@ impl MetadataExtractor {
.unwrap_or_else(|| "Unknown Album".to_string());
let (album_group_key, album_group_title) =
Self::album_group_info(file_path, Some(album_title.as_str()));
let genres = Self::strings_across_tags(&tagged_file, &ItemKey::Genre);
LocalTrack {
id: 0,
@@ -694,7 +712,8 @@ impl MetadataExtractor {
.and_then(|d| if d > 0 { Some(d) } else { None })
.or(inferred_disc),
year: Self::year_across_tags(&tagged_file),
genre: Self::string_across_tags(&tagged_file, &ItemKey::Genre),
genre: genres.first().cloned(),
genres,
catalog_number: Self::string_across_tags(&tagged_file, &ItemKey::CatalogNumber),
duration_secs,
format,
@@ -706,6 +725,7 @@ impl MetadataExtractor {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -736,6 +756,7 @@ impl MetadataExtractor {
disc_number: inferred_disc,
year: None,
genre: None,
genres: Vec::new(),
catalog_number: None,
duration_secs,
format,
@@ -747,6 +768,7 @@ impl MetadataExtractor {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -795,6 +817,7 @@ impl MetadataExtractor {
.unwrap_or_else(|| "Unknown Album".to_string());
let (album_group_key, album_group_title) =
Self::album_group_info(file_path, Some(album_title.as_str()));
let genres = tags.genre.iter().cloned().collect();
Ok(LocalTrack {
id: 0,
@@ -813,6 +836,7 @@ impl MetadataExtractor {
disc_number: tags.disc_number.filter(|d| *d > 0).or(inferred_disc),
year: tags.year.and_then(|y| u32::try_from(y).ok()),
genre: tags.genre.clone(),
genres,
catalog_number: None,
duration_secs: info.duration_secs(),
format: AudioFormat::Dsd,
@@ -826,6 +850,7 @@ impl MetadataExtractor {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified,
indexed_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -992,6 +1017,7 @@ impl MetadataExtractor {
/// path and changes no stored value.
pub fn folder_artwork_in_dir(dir: &Path) -> Option<String> {
let mut best: Option<(u32, PathBuf)> = None;
let mut candidates = Vec::new();
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
if !path.is_file() {
@@ -1005,14 +1031,14 @@ impl MetadataExtractor {
if !Self::is_supported_artwork_ext(&ext) {
continue;
}
candidates.push(path.clone());
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
// Same vocabulary the scanner scores with, simplified: an exact
// well-known name wins, a name that merely contains one is a
// fallback, anything else is ignored.
// well-known name wins and a name containing one is a fallback.
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
let score = if EXACT.contains(&stem.as_str()) {
2
@@ -1026,6 +1052,12 @@ impl MetadataExtractor {
}
}
best.map(|(_, p)| p.to_string_lossy().to_string())
.or_else(|| {
// A single image in a disc directory is unambiguous even when
// it is named after the movement rather than `cover.jpg`.
(candidates.len() == 1)
.then(|| candidates[0].to_string_lossy().to_string())
})
}
pub fn find_folder_artwork(
+19
View File
@@ -53,6 +53,10 @@ pub struct LocalTrack {
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
/// Complete ordered genre set. `genre` remains the compatibility primary
/// value for old databases and single-value tag writers.
#[serde(default)]
pub genres: Vec<String>,
pub catalog_number: Option<String>,
// Audio properties
@@ -70,6 +74,10 @@ pub struct LocalTrack {
// Artwork
pub artwork_path: Option<String>,
/// Album/collection artwork kept apart from a track or disc cover.
/// Consumers resolve artwork from the most specific layer outward.
#[serde(default)]
pub collection_artwork_path: Option<String>,
// Indexing
pub last_modified: i64,
@@ -104,6 +112,7 @@ impl Default for LocalTrack {
disc_number: None,
year: None,
genre: None,
genres: Vec::new(),
catalog_number: None,
duration_secs: 0,
format: AudioFormat::Unknown,
@@ -115,6 +124,7 @@ impl Default for LocalTrack {
cue_start_secs: None,
cue_end_secs: None,
artwork_path: None,
collection_artwork_path: None,
last_modified: 0,
indexed_at: 0,
source: None,
@@ -154,7 +164,16 @@ pub struct LocalAlbum {
pub all_artists: String,
pub year: Option<u32>,
pub catalog_number: Option<String>,
/// Union of every track/version genre represented by this album.
#[serde(default)]
pub genres: Vec<String>,
pub artwork_path: Option<String>,
/// Authoritative source that owns `artwork_path` when an album row is a
/// frontend-side logical group. It may differ from `source`: the
/// best-audio copy can be coverless while another physical copy supplies
/// the group's representative artwork.
#[serde(default)]
pub artwork_source: Option<String>,
pub track_count: u32,
pub total_duration_secs: u64,
pub format: AudioFormat,
+173 -12
View File
@@ -143,6 +143,10 @@ enum PreparedAction {
Reuse,
Extract(ExtractTask),
Preserve(String),
/// A valid CUE with several FILE directives describes already-split
/// audio. Keep the CUE scan record but publish no virtual tracks and
/// retire rows produced by older single-file-only parsing.
IgnoreMultiFileCue,
SkipCueAudio,
}
@@ -339,31 +343,44 @@ fn apply_sidecar_override(
fn decorate_tracks(tracks: &mut [LocalTrack], artwork_cache: &Path, caches: &mut PassCaches) {
for track in tracks {
apply_sidecar_override(track, &mut caches.sidecars);
let album_key = if track.album_group_key.trim().is_empty() {
Path::new(&track.file_path)
.parent()
.unwrap_or_else(|| Path::new(&track.file_path))
.to_string_lossy()
.into_owned()
let audio_path = PathBuf::from(&track.file_path);
let own_dir = audio_path.parent().unwrap_or(audio_path.as_path());
let collection_key = if track.album_group_key.trim().is_empty() {
own_dir.to_string_lossy().into_owned()
} else {
track.album_group_key.clone()
};
let audio_path = PathBuf::from(&track.file_path);
let album_hint = if track.album_group_title.trim().is_empty() {
track.album.as_str()
} else {
track.album_group_title.as_str()
};
let artwork = caches.artwork.get_or_insert_with(album_key, || {
let embedded = MetadataExtractor::extract_artwork(&audio_path, artwork_cache);
embedded.or_else(|| {
// Artwork is track/disc metadata, not merely an album-card property.
// Resolve from the narrowest scope outwards so a box set cannot stamp
// Disc 01's cover onto every queue row and media-control surface:
// embedded tag -> this disc's folder -> collection root -> none.
let embedded_key = format!("embedded:{}", audio_path.to_string_lossy());
let embedded = caches.artwork.get_or_insert_with(embedded_key, || {
MetadataExtractor::extract_artwork(&audio_path, artwork_cache)
});
let disc_key = format!("disc-folder:{}", own_dir.to_string_lossy());
let disc_file = caches.artwork.get_or_insert_with(disc_key, || {
MetadataExtractor::folder_artwork_in_dir(own_dir).and_then(|path| {
MetadataExtractor::cache_artwork_file(Path::new(&path), artwork_cache)
})
});
let collection_art_key = format!("collection:{collection_key}");
let collection = caches
.artwork
.get_or_insert_with(collection_art_key, || {
MetadataExtractor::find_folder_artwork(&audio_path, Some(album_hint)).and_then(
|folder_art| {
MetadataExtractor::cache_artwork_file(Path::new(&folder_art), artwork_cache)
},
)
})
});
});
let artwork = embedded.or(disc_file).or(collection);
track.artwork_path = artwork;
}
}
@@ -735,10 +752,15 @@ fn commit_batch(
file_path: file.prepared.canonical_path.clone(),
error: error.clone(),
}),
PreparedAction::IgnoreMultiFileCue => {
remove_obsolete_cue_tracks(db, &file.prepared.canonical_path, &[])?;
metrics.extracted = metrics.extracted.saturating_add(1);
}
PreparedAction::SkipCueAudio => {}
}
let successful = matches!(file.prepared.action, PreparedAction::Reuse)
|| matches!(file.prepared.action, PreparedAction::IgnoreMultiFileCue)
|| matches!(file.tracks.as_ref(), Some(Ok(_)));
if let Some(value) = file.prepared.fingerprint.as_ref() {
// A present but unreadable file receives a negative scan row.
@@ -902,6 +924,39 @@ fn prepare_file(
let (action, dependency, current_cue_audio_path) = match parsed {
Ok(mut cue) => {
cue.file_path = canonical_path.clone();
if !cue.is_single_file_image() {
// Multi-file sheets are useful metadata sidecars but
// the referenced split audio files are the playable,
// independently tagged sources. Marking this as a
// successful observation also clears any virtual CUE
// rows left by the old parser while allowing the audio
// phase to index every real file normally.
let dependency =
format!("multi-file-sidecar-v1|files={}", cue.audio_file_count());
let action = if previous_cue_audio_path.is_none()
&& scan_file_matches(
db,
folder.id,
&canonical_path,
ScanFileKind::Cue,
&value,
&dependency,
)? {
PreparedAction::Reuse
} else {
PreparedAction::IgnoreMultiFileCue
};
return Ok(PreparedFile {
traversal_path: traversal_path.to_string_lossy().into_owned(),
canonical_path,
kind: ScanFileKind::Cue,
fingerprint: Some(value),
dependency,
current_cue_audio_path: None,
previous_cue_audio_path,
action,
});
}
let audio = normalize_path(Path::new(&cue.audio_file));
cue.audio_file = audio.to_string_lossy().into_owned();
let audio_path = cue.audio_file.clone();
@@ -1717,6 +1772,65 @@ mod tests {
fs::write(path, contents).unwrap();
}
fn write_multi_file_cue(path: &Path, tracks: &[(&str, &str)]) {
let mut contents = String::from("PERFORMER \"Fixture Artist\"\nTITLE \"Fixture Album\"\n");
for (number, (file, title)) in tracks.iter().enumerate() {
contents.push_str(&format!(
"FILE \"{}\" WAVE\n TRACK {:02} AUDIO\n TITLE \"{}\"\n INDEX 01 00:00:00\n",
file,
number + 1,
title
));
}
fs::write(path, contents).unwrap();
}
fn write_artwork(path: &Path, rgb: [u8; 3]) {
image::RgbImage::from_pixel(2, 2, image::Rgb(rgb))
.save(path)
.unwrap();
}
#[test]
fn artwork_resolution_prefers_each_disc_then_collection() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("Box Set");
let disc_one = root.join("Disc 01 - First");
let disc_two = root.join("Disc 02 - Second");
let disc_three = root.join("Disc 03 - No Own Art");
for directory in [&disc_one, &disc_two, &disc_three] {
fs::create_dir_all(directory).unwrap();
fs::write(directory.join("01.flac"), b"not-a-real-audio-file").unwrap();
}
write_artwork(&root.join("cover.png"), [10, 10, 10]);
write_artwork(&disc_one.join("cover.png"), [200, 10, 10]);
// A uniquely named image is still authoritative for its own disc.
write_artwork(&disc_two.join("TV Series Soundtrack 02.png"), [10, 200, 10]);
let track = |disc: u32, directory: &Path| LocalTrack {
file_path: directory.join("01.flac").to_string_lossy().into_owned(),
album: "Box Set".to_string(),
album_group_key: root.to_string_lossy().into_owned(),
album_group_title: "Box Set".to_string(),
disc_number: Some(disc),
..Default::default()
};
let mut tracks = vec![
track(1, &disc_one),
track(2, &disc_two),
track(3, &disc_three),
];
let mut caches = PassCaches::default();
decorate_tracks(&mut tracks, temp.path(), &mut caches);
let one = tracks[0].artwork_path.as_deref().unwrap();
let two = tracks[1].artwork_path.as_deref().unwrap();
let three = tracks[2].artwork_path.as_deref().unwrap();
assert_ne!(one, two, "two distinct disc covers collapsed");
assert_ne!(one, three, "disc 1 leaked into the collection fallback");
assert_ne!(two, three, "disc 2 leaked into the collection fallback");
}
#[test]
fn unchanged_files_skip_extraction_and_changed_file_keeps_its_id() {
let (temp, db, root_id, root) = fixture();
@@ -1834,6 +1948,53 @@ mod tests {
assert!(plain[0].2.is_none());
}
#[test]
fn multi_file_cue_retires_virtual_rows_and_indexes_only_split_audio() {
let (temp, db, root_id, root) = fixture();
let first_path = root.join("01. First.flac");
let second_path = root.join("02. Second.flac");
fs::write(&first_path, b"fixture first audio").unwrap();
fs::write(&second_path, b"fixture second audio").unwrap();
let cue_path = root.join("leftover.cue");
write_multi_file_cue(
&cue_path,
&[("01. First.flac", "First"), ("02. Second.flac", "Second")],
);
// Simulate rows produced by the legacy parser, which attached every
// virtual entry to one arbitrary FILE from the sheet.
for (index, title) in ["Legacy First", "Legacy Second"].iter().enumerate() {
let mut stale = LocalTrack::default();
stale.file_path = second_path.to_string_lossy().into_owned();
stale.title = (*title).to_string();
stale.artist = "Fixture Artist".to_string();
stale.album = "Fixture Album".to_string();
stale.album_group_key = root.to_string_lossy().into_owned();
stale.album_group_title = stale.album.clone();
stale.cue_file_path = Some(cue_path.to_string_lossy().into_owned());
stale.cue_start_secs = Some(index as f64);
db.insert_track(&stale).unwrap();
}
let cancel = Arc::new(AtomicBool::new(false));
let extraction = FakeExtraction::new(Arc::clone(&cancel));
run_fake(&db, root_id, &temp, &cancel, &extraction);
// Only the two real files require extraction; the valid multi-file
// CUE is observed as a sidecar and never reaches the CUE backend.
assert_eq!(extraction.calls.load(AtomicOrdering::SeqCst), 2);
let rows = cue_rows(&db);
assert_eq!(rows.len(), 2);
assert!(rows.iter().all(|(_, _, cue)| cue.is_none()));
let paths = track_rows(&db)
.into_iter()
.map(|(_, path)| path)
.collect::<HashSet<_>>();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&first_path.to_string_lossy().into_owned()));
assert!(paths.contains(&second_path.to_string_lossy().into_owned()));
}
#[test]
fn root_prefix_treats_sql_wildcards_as_plain_path_characters() {
let temp = tempfile::tempdir().unwrap();
+1
View File
@@ -7,6 +7,7 @@ description = "One SQLite cache for every remote media server QBZ reads (Jellyfi
[dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
serde_json.workspace = true
[dev-dependencies]
tempfile = "3"
+236 -27
View File
@@ -165,6 +165,9 @@ pub struct CachedTrack {
pub disc_number: Option<u32>,
pub duration_ms: u64,
pub year: Option<u32>,
/// Complete genre set when the protocol exposes one. The singular field
/// remains the compatibility primary value and migration fallback.
pub genres: Vec<String>,
pub genre: Option<String>,
pub container: String,
pub codec: Option<String>,
@@ -178,6 +181,10 @@ pub struct CachedTrack {
/// Subsonic `coverArt` id. Never a URL: a URL embeds credentials and a
/// size, and both change while the token does not.
pub artwork_token: Option<String>,
/// Album/collection artwork kept separately from the item token. Servers
/// can expose a box cover and different artwork for each disc; combining
/// those layers makes one overwrite the other during synchronization.
pub collection_artwork_token: Option<String>,
pub size_bytes: Option<u64>,
}
@@ -282,6 +289,7 @@ pub fn init_schema(conn: &Connection) -> Result<()> {
duration_ms INTEGER NOT NULL DEFAULT 0,
year INTEGER,
genre TEXT,
genres_json TEXT NOT NULL DEFAULT '[]',
container TEXT NOT NULL DEFAULT '',
codec TEXT,
bit_depth INTEGER,
@@ -289,6 +297,7 @@ pub fn init_schema(conn: &Connection) -> Result<()> {
channels INTEGER,
bitrate_kbps INTEGER,
artwork_token TEXT,
collection_artwork_token TEXT,
size_bytes INTEGER,
observed_generation INTEGER NOT NULL DEFAULT 0,
quality_hydrated INTEGER NOT NULL DEFAULT 0,
@@ -320,9 +329,11 @@ pub fn init_schema(conn: &Connection) -> Result<()> {
// Forward-only additive migration for caches created before incremental
// source generations and deferred Jellyfin quality existed.
for column in [
"genres_json TEXT NOT NULL DEFAULT '[]'",
"observed_generation INTEGER NOT NULL DEFAULT 0",
"quality_hydrated INTEGER NOT NULL DEFAULT 0",
"quality_retry_at INTEGER NOT NULL DEFAULT 0",
"collection_artwork_token TEXT",
] {
let statement = format!("ALTER TABLE remote_cache_tracks ADD COLUMN {column}");
let _ = conn.execute(&statement, []);
@@ -354,6 +365,14 @@ fn row_to_track(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedTrack> {
let id = RemoteSource::from_word(&source)
.map(|s| s.namespace(rowid))
.unwrap_or(rowid);
let genre: Option<String> = row.get("genre")?;
let mut genres = serde_json::from_str::<Vec<String>>(&row.get::<_, String>("genres_json")?)
.unwrap_or_default();
if genres.is_empty() {
if let Some(value) = genre.as_ref().filter(|value| !value.trim().is_empty()) {
genres.push(value.trim().to_string());
}
}
Ok(CachedTrack {
id,
source,
@@ -369,7 +388,8 @@ fn row_to_track(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedTrack> {
disc_number: row.get::<_, Option<i64>>("disc_number")?.map(|v| v as u32),
duration_ms: row.get::<_, i64>("duration_ms")? as u64,
year: row.get::<_, Option<i64>>("year")?.map(|v| v as u32),
genre: row.get("genre")?,
genres,
genre,
container: row.get("container")?,
codec: row.get("codec")?,
bit_depth: row.get::<_, Option<i64>>("bit_depth")?.map(|v| v as u32),
@@ -379,14 +399,30 @@ fn row_to_track(row: &rusqlite::Row<'_>) -> rusqlite::Result<CachedTrack> {
channels: row.get::<_, Option<i64>>("channels")?.map(|v| v as u32),
bitrate_kbps: row.get::<_, Option<i64>>("bitrate_kbps")?.map(|v| v as u32),
artwork_token: row.get("artwork_token")?,
collection_artwork_token: row.get("collection_artwork_token")?,
size_bytes: row.get::<_, Option<i64>>("size_bytes")?.map(|v| v as u64),
})
}
const SELECT: &str = "SELECT id, source, item_id, server_id, library_id, title, artist, \
album_artist, album, album_id, track_number, disc_number, duration_ms, year, genre, \
album_artist, album, album_id, track_number, disc_number, duration_ms, year, genre, genres_json, \
container, codec, bit_depth, sample_rate_hz, channels, bitrate_kbps, artwork_token, \
size_bytes FROM remote_cache_tracks";
collection_artwork_token, size_bytes FROM remote_cache_tracks";
fn genres_json(track: &CachedTrack) -> String {
let mut genres = Vec::<String>::new();
for value in track.genres.iter().chain(track.genre.iter()) {
let value = value.trim();
if !value.is_empty()
&& !genres
.iter()
.any(|existing| existing.eq_ignore_ascii_case(value))
{
genres.push(value.to_string());
}
}
serde_json::to_string(&genres).unwrap_or_else(|_| "[]".to_string())
}
/// UPSERT a batch of tracks in ONE transaction.
///
@@ -411,25 +447,28 @@ pub fn save_tracks(
.prepare(
"INSERT INTO remote_cache_tracks
(source, item_id, server_id, library_id, title, artist, album_artist, album,
album_id, track_number, disc_number, duration_ms, year, genre, container,
album_id, track_number, disc_number, duration_ms, year, genre, genres_json, container,
codec, bit_depth, sample_rate_hz, channels, bitrate_kbps, artwork_token,
size_bytes, quality_hydrated, quality_retry_at, updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,1,0,?23)
collection_artwork_token, size_bytes, quality_hydrated, quality_retry_at, updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,1,0,?25)
ON CONFLICT(source, item_id) DO UPDATE SET
server_id=excluded.server_id, library_id=excluded.library_id,
title=excluded.title, artist=excluded.artist,
album_artist=excluded.album_artist, album=excluded.album,
album_id=excluded.album_id, track_number=excluded.track_number,
disc_number=excluded.disc_number, duration_ms=excluded.duration_ms,
year=excluded.year, genre=excluded.genre, container=excluded.container,
year=excluded.year, genre=excluded.genre,
genres_json=excluded.genres_json, container=excluded.container,
codec=excluded.codec, bit_depth=excluded.bit_depth,
sample_rate_hz=excluded.sample_rate_hz, channels=excluded.channels,
bitrate_kbps=excluded.bitrate_kbps, artwork_token=excluded.artwork_token,
collection_artwork_token=excluded.collection_artwork_token,
size_bytes=excluded.size_bytes, quality_hydrated=1,
quality_retry_at=0, updated_at=excluded.updated_at",
)
.map_err(map_err("prepare insert"))?;
for t in tracks {
let genres_json = genres_json(t);
stmt.execute(params![
source.as_str(),
t.item_id,
@@ -445,6 +484,7 @@ pub fn save_tracks(
t.duration_ms as i64,
t.year.map(|v| v as i64),
t.genre,
genres_json,
t.container,
t.codec,
t.bit_depth.map(|v| v as i64),
@@ -452,6 +492,7 @@ pub fn save_tracks(
t.channels.map(|v| v as i64),
t.bitrate_kbps.map(|v| v as i64),
t.artwork_token,
t.collection_artwork_token,
t.size_bytes.map(|v| v as i64),
ts,
])
@@ -574,11 +615,11 @@ fn save_source_generation_page(
.prepare(
"INSERT INTO remote_cache_tracks
(source,item_id,server_id,library_id,title,artist,album_artist,album,
album_id,track_number,disc_number,duration_ms,year,genre,container,
album_id,track_number,disc_number,duration_ms,year,genre,genres_json,container,
codec,bit_depth,sample_rate_hz,channels,bitrate_kbps,artwork_token,
size_bytes,observed_generation,quality_hydrated,quality_retry_at,updated_at)
collection_artwork_token,size_bytes,observed_generation,quality_hydrated,quality_retry_at,updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,
?16,?17,?18,?19,?20,?21,?22,?23,?24,0,?25)
?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,0,?27)
ON CONFLICT(source,item_id) DO UPDATE SET
server_id=excluded.server_id,library_id=excluded.library_id,
title=excluded.title,artist=excluded.artist,
@@ -586,6 +627,7 @@ fn save_source_generation_page(
album_id=excluded.album_id,track_number=excluded.track_number,
disc_number=excluded.disc_number,duration_ms=excluded.duration_ms,
year=excluded.year,genre=excluded.genre,
genres_json=excluded.genres_json,
container=CASE WHEN excluded.quality_hydrated=1
THEN excluded.container
ELSE COALESCE(NULLIF(excluded.container,''),remote_cache_tracks.container)
@@ -600,13 +642,16 @@ fn save_source_generation_page(
THEN excluded.channels ELSE remote_cache_tracks.channels END,
bitrate_kbps=CASE WHEN excluded.quality_hydrated=1
THEN excluded.bitrate_kbps ELSE remote_cache_tracks.bitrate_kbps END,
artwork_token=excluded.artwork_token,size_bytes=excluded.size_bytes,
artwork_token=excluded.artwork_token,
collection_artwork_token=excluded.collection_artwork_token,
size_bytes=excluded.size_bytes,
observed_generation=excluded.observed_generation,
quality_hydrated=excluded.quality_hydrated,
quality_retry_at=0,updated_at=excluded.updated_at",
)
.map_err(map_err("prepare essential page"))?;
for track in tracks {
let genres_json = genres_json(track);
stmt.execute(params![
source.as_str(),
track.item_id,
@@ -622,6 +667,7 @@ fn save_source_generation_page(
track.duration_ms.min(i64::MAX as u64) as i64,
track.year.map(|value| value as i64),
track.genre,
genres_json,
track.container,
track.codec,
track.bit_depth.map(|value| value as i64),
@@ -629,6 +675,7 @@ fn save_source_generation_page(
track.channels.map(|value| value as i64),
track.bitrate_kbps.map(|value| value as i64),
track.artwork_token,
track.collection_artwork_token,
track
.size_bytes
.map(|value| value.min(i64::MAX as u64) as i64),
@@ -1005,6 +1052,20 @@ pub fn search_page(
offset: u64,
limit: u64,
sort: &str,
) -> Result<Vec<CachedTrack>> {
search_page_filtered(conn, source, needle, offset, limit, sort, &[], false, &[])
}
pub fn search_page_filtered(
conn: &Connection,
source: RemoteSource,
needle: &str,
offset: u64,
limit: u64,
sort: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> Result<Vec<CachedTrack>> {
let like = format!("%{}%", needle.trim());
let order = match sort {
@@ -1019,10 +1080,18 @@ pub fn search_page(
"added-desc" => "album COLLATE NOCASE, disc_number, track_number, id",
_ => "album COLLATE NOCASE, COALESCE(NULLIF(album_artist, ''), artist) COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE, id",
};
let media_filter = cached_track_media_filter_sql(
"container",
"bit_depth",
"sample_rate_hz",
formats,
other_formats,
quality_tiers,
);
let mut stmt = conn
.prepare(&format!(
"{SELECT} WHERE source = ?1 AND (?2 = '' OR title LIKE ?3 OR artist LIKE ?3 \
OR album LIKE ?3 OR album_artist LIKE ?3) \
OR album LIKE ?3 OR album_artist LIKE ?3) {media_filter} \
ORDER BY {order} LIMIT ?4 OFFSET ?5"
))
.map_err(map_err("prepare search"))?;
@@ -1042,6 +1111,63 @@ pub fn search_page(
.map_err(map_err("read search"))
}
fn cached_track_media_filter_sql(
format_col: &str,
depth_col: &str,
rate_col: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> String {
let known = ["flac", "alac", "ape", "wav", "mp3", "aac"];
let selected = known
.iter()
.copied()
.filter(|value| {
formats
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(value))
})
.collect::<Vec<_>>();
let mut clauses = Vec::new();
if !selected.is_empty() || other_formats {
let mut values = selected
.into_iter()
.map(|value| format!("LOWER(COALESCE({format_col},''))='{value}'"))
.collect::<Vec<_>>();
if other_formats {
values.push(format!(
"LOWER(COALESCE({format_col},'')) NOT IN ('flac','alac','ape','wav','mp3','aac')"
));
}
clauses.push(format!("AND ({})", values.join(" OR ")));
}
let hires = quality_tiers.iter().any(|value| value == "hires");
let cd = quality_tiers.iter().any(|value| value == "cd");
let lossy = quality_tiers.iter().any(|value| value == "lossy");
if hires || cd || lossy {
let khz = format!(
"CASE WHEN COALESCE({rate_col},0)>=1000 THEN COALESCE({rate_col},0)/1000.0 ELSE COALESCE({rate_col},0) END"
);
let mut values = Vec::new();
if hires {
values.push(format!(
"(LOWER(COALESCE({format_col},'')) IN ('dsd','dsf','dff') OR COALESCE({depth_col},0)>=24)"
));
}
if cd {
values.push(format!(
"(LOWER(COALESCE({format_col},'')) NOT IN ('mp3','dsd','dsf','dff') AND (({depth_col} IS NOT NULL AND {depth_col}<24) OR ({depth_col} IS NULL AND {khz}>=44.1)))"
));
}
if lossy {
values.push(format!("LOWER(COALESCE({format_col},''))='mp3'"));
}
clauses.push(format!("AND ({})", values.join(" OR ")));
}
clauses.join(" ")
}
/// Artist aggregates for one remote source, including track credits and
/// distinct album artists.
pub fn artists(conn: &Connection, source: RemoteSource) -> Result<Vec<CachedArtist>> {
@@ -1202,19 +1328,42 @@ mod tests {
// ── Storage ────────────────────────────────────────────────────────────
#[test]
fn a_saved_track_round_trips_by_both_of_its_ids() {
let mut c = db();
save_tracks(
&mut c,
RemoteSource::Jellyfin,
&[track("srv-1", "alb", Some(1), Some(3))],
fn existing_cache_adds_the_collection_artwork_layer_in_place() {
let c = db();
c.execute(
"ALTER TABLE remote_cache_tracks DROP COLUMN collection_artwork_token",
[],
)
.unwrap();
init_schema(&c).unwrap();
let present = c
.prepare("PRAGMA table_info(remote_cache_tracks)")
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.filter_map(|row| row.ok())
.any(|column| column == "collection_artwork_token");
assert!(present);
}
#[test]
fn a_saved_track_round_trips_by_both_of_its_ids() {
let mut c = db();
let mut row = track("srv-1", "alb", Some(1), Some(3));
row.artwork_token = Some("disc-cover".into());
row.collection_artwork_token = Some("box-cover".into());
save_tracks(&mut c, RemoteSource::Jellyfin, &[row]).unwrap();
let by_item = track_by_item_id(&c, RemoteSource::Jellyfin, "srv-1")
.unwrap()
.expect("by item id");
assert_eq!(by_item.title, "t-srv-1");
assert_eq!(by_item.bit_depth, Some(24));
assert_eq!(by_item.artwork_token.as_deref(), Some("disc-cover"));
assert_eq!(
by_item.collection_artwork_token.as_deref(),
Some("box-cover")
);
assert_eq!(
RemoteSource::of_id(by_item.id),
Some(RemoteSource::Jellyfin)
@@ -1226,6 +1375,27 @@ mod tests {
assert_eq!(by_id, by_item);
}
#[test]
fn every_remote_genre_round_trips_in_server_order() {
let mut c = db();
let mut row = track("genres", "album", Some(1), Some(1));
row.genre = Some("Progressive Rock".into());
row.genres = vec![
"Progressive Rock".into(),
"Art Rock".into(),
"Psychedelic".into(),
];
save_tracks(&mut c, RemoteSource::Jellyfin, &[row]).unwrap();
let stored = track_by_item_id(&c, RemoteSource::Jellyfin, "genres")
.unwrap()
.unwrap();
assert_eq!(
stored.genres,
["Progressive Rock", "Art Rock", "Psychedelic"]
);
assert_eq!(stored.genre.as_deref(), Some("Progressive Rock"));
}
/// THE reason this is an UPSERT. A published row id lives inside queue
/// entries and `session.db`; a re-scan that minted a new one would leave a
/// playing track pointing at nothing.
@@ -1382,6 +1552,53 @@ mod tests {
assert_eq!(offset, 1_201);
}
#[test]
fn filtered_remote_pages_are_stable_and_do_not_leak_other_formats() {
let mut c = db();
let rows = (0..41)
.map(|i| {
let mut row = track(&format!("fmt-{i:02}"), "Album", Some(1), Some(i + 1));
row.title = format!("Track {i:02}");
if i % 2 == 0 {
row.container = "mp3".into();
row.codec = Some("mp3".into());
row.bit_depth = None;
row.sample_rate_hz = Some(44_100);
}
row
})
.collect::<Vec<_>>();
save_tracks(&mut c, RemoteSource::Subsonic, &rows).unwrap();
let formats = vec!["mp3".to_string()];
let qualities = vec!["lossy".to_string()];
let mut offset = 0;
let mut ids = std::collections::HashSet::new();
loop {
let page = search_page_filtered(
&c,
RemoteSource::Subsonic,
"",
offset,
7,
"title-asc",
&formats,
false,
&qualities,
)
.unwrap();
if page.is_empty() {
break;
}
for row in &page {
assert_eq!(row.container, "mp3");
assert!(ids.insert(row.id), "duplicate filtered id {}", row.id);
}
offset += page.len() as u64;
}
assert_eq!(ids.len(), 21);
}
#[test]
fn remote_artist_group_uses_the_performing_artist() {
let mut c = db();
@@ -1393,15 +1610,7 @@ mod tests {
alpha.album_artist = "Zed Album Artist".into();
save_tracks(&mut c, RemoteSource::Jellyfin, &[zed, alpha]).unwrap();
let grouped = search_page(
&c,
RemoteSource::Jellyfin,
"",
0,
10,
"group-artist",
)
.unwrap();
let grouped = search_page(&c, RemoteSource::Jellyfin, "", 0, 10, "group-artist").unwrap();
assert_eq!(grouped[0].artist, "Alpha Performer");
let album_artist_sorted =
+29 -14
View File
@@ -93,7 +93,10 @@ pub fn shuffle_items(mut items: Vec<MixtapeCollectionItem>) -> Vec<MixtapeCollec
pub fn next_item_index(queue: &[CoreQueueTrack], current: usize) -> Option<usize> {
let boundary_of = |i: usize| -> Option<&str> {
queue.get(i).and_then(|track| {
track.source_item_id_hint.as_deref().or(track.album_id.as_deref())
track
.source_item_id_hint
.as_deref()
.or(track.album_id.as_deref())
})
};
let current_boundary = boundary_of(current)?;
@@ -127,9 +130,7 @@ pub fn previous_item_index(
let current_boundary = boundary_of(current)?;
let mut item_start = current;
while item_start > 0
&& boundary_of(item_start - 1) == Some(current_boundary.clone())
{
while item_start > 0 && boundary_of(item_start - 1) == Some(current_boundary.clone()) {
item_start -= 1;
}
@@ -236,7 +237,10 @@ pub async fn resolve_qobuz_album(
// Build QueueTrack from each track. We have the parent Album in scope so
// we can fill artwork / album title / album artist even when the track's
// own `album` field is absent (shallow responses inside albums/get).
let album_artwork = album.image.large.clone()
let album_artwork = album
.image
.large
.clone()
.or_else(|| album.image.extralarge.clone())
.or_else(|| album.image.thumbnail.clone());
let album_title = album.title.clone();
@@ -376,7 +380,10 @@ pub fn resolve_plex_album_tracks(group_key: &str) -> Result<Vec<CoreQueueTrack>,
group_key
));
}
Ok(tracks.iter().map(plex_cached_track_to_queue_track).collect())
Ok(tracks
.iter()
.map(plex_cached_track_to_queue_track)
.collect())
}
// ── Local track (synchronous) ──
@@ -436,7 +443,12 @@ pub fn track_to_queue_track_from_api(track: &ApiTrack) -> CoreQueueTrack {
.as_ref()
.and_then(|a| a.image.large.clone())
.or_else(|| track.album.as_ref().and_then(|a| a.image.thumbnail.clone()))
.or_else(|| track.album.as_ref().and_then(|a| a.image.extralarge.clone()));
.or_else(|| {
track
.album
.as_ref()
.and_then(|a| a.image.extralarge.clone())
});
let artist = track
.performer
.as_ref()
@@ -495,7 +507,10 @@ pub fn plex_cached_track_to_queue_track(track: &qbz_plex::PlexCachedTrack) -> Co
album: track.album.clone(),
album_version: None,
duration_secs: track.duration_secs,
artwork_url: track.artwork_path.clone(),
artwork_url: track
.artwork_path
.clone()
.or_else(|| track.collection_artwork_path.clone()),
hires: track.bit_depth.map(|d| d > 16).unwrap_or(false),
bit_depth: track.bit_depth,
sample_rate: sample_rate_khz,
@@ -657,7 +672,10 @@ mod tests {
assert_eq!(tracks[0].source_item_id_hint.as_deref(), Some("a-1"));
assert_eq!(tracks[2].source_item_id_hint.as_deref(), Some("a-1"));
assert_eq!(tracks[3].source_item_id_hint.as_deref(), Some("t-99"));
assert_eq!(tracks[4].source_item_id_hint.as_deref(), Some("al-local-xyz"));
assert_eq!(
tracks[4].source_item_id_hint.as_deref(),
Some("al-local-xyz")
);
}
#[tokio::test]
@@ -668,8 +686,7 @@ mod tests {
item(2, ItemType::Album, AlbumSource::Qobuz, "a-3", 3),
];
let tracks =
resolve_collection_tracks(items, CollectionPlayMode::AlbumShuffle, &MockResolver)
.await;
resolve_collection_tracks(items, CollectionPlayMode::AlbumShuffle, &MockResolver).await;
assert_eq!(tracks.len(), 9);
// Each album's tracks must be contiguous (no interleaving).
let mut i = 0;
@@ -682,9 +699,7 @@ mod tests {
h
);
seen.insert(h.clone());
while i < tracks.len()
&& tracks[i].source_item_id_hint.as_deref() == Some(&h)
{
while i < tracks.len() && tracks[i].source_item_id_hint.as_deref() == Some(&h) {
i += 1;
}
}
+1
View File
@@ -14,6 +14,7 @@ reqwest = { workspace = true }
# DTO serialization (camelCase envelopes shared with the frontend)
serde = { workspace = true }
serde_json.workspace = true
# Platform data dir (plex_cache.db location)
dirs = "6"
+335 -42
View File
@@ -38,7 +38,12 @@ pub struct PlexTrack {
pub artist: Option<String>,
pub album: Option<String>,
pub duration_ms: Option<u64>,
/// Track/item thumb. Plex may expose a disc-specific cover here.
pub artwork_path: Option<String>,
/// Parent album thumb, kept separately so item art can win without losing
/// a stable collection fallback.
#[serde(default)]
pub collection_artwork_path: Option<String>,
pub part_key: Option<String>,
pub container: Option<String>,
pub codec: Option<String>,
@@ -49,6 +54,9 @@ pub struct PlexTrack {
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub year: Option<u32>,
/// Every `<Genre>` Plex supplied. `genre` remains the compatibility
/// primary value for older callers and cache rows.
pub genres: Vec<String>,
pub genre: Option<String>,
/// `parentRatingKey` — the Plex album this track belongs to. Distinct per
/// physical album/edition, so it separates two albums that share the same
@@ -159,6 +167,7 @@ pub struct PlexCachedTrack {
pub bit_depth: Option<u32>,
pub sample_rate: u32,
pub artwork_path: Option<String>,
pub collection_artwork_path: Option<String>,
pub source: String,
pub album_key: String,
pub track_number: Option<u32>,
@@ -211,6 +220,7 @@ struct TrackBuilder {
album: Option<String>,
duration_ms: Option<u64>,
artwork_path: Option<String>,
collection_artwork_path: Option<String>,
part_key: Option<String>,
container: Option<String>,
codec: Option<String>,
@@ -221,6 +231,7 @@ struct TrackBuilder {
track_number: Option<u32>,
disc_number: Option<u32>,
year: Option<u32>,
genres: Vec<String>,
genre: Option<String>,
parent_rating_key: Option<String>,
}
@@ -236,6 +247,33 @@ fn now_epoch_secs() -> i64 {
.unwrap_or(0)
}
fn plex_genres_json(track: &PlexTrack) -> String {
let mut genres = Vec::<String>::new();
for value in track.genres.iter().chain(track.genre.iter()) {
let value = value.trim();
if !value.is_empty()
&& !genres
.iter()
.any(|existing| existing.eq_ignore_ascii_case(value))
{
genres.push(value.to_string());
}
}
serde_json::to_string(&genres).unwrap_or_else(|_| "[]".to_string())
}
fn plex_genres_from_json(raw: Option<&str>, primary: Option<&str>) -> Vec<String> {
let mut genres = raw
.and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
.unwrap_or_default();
if genres.is_empty() {
if let Some(value) = primary.filter(|value| !value.trim().is_empty()) {
genres.push(value.trim().to_string());
}
}
genres
}
fn open_plex_cache_db() -> Result<Connection, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
@@ -271,6 +309,7 @@ fn open_plex_cache_db() -> Result<Connection, String> {
album TEXT,
duration_ms INTEGER,
artwork_path TEXT,
collection_artwork_path TEXT,
part_key TEXT,
container TEXT,
codec TEXT,
@@ -278,6 +317,7 @@ fn open_plex_cache_db() -> Result<Connection, String> {
bitrate_kbps INTEGER,
sampling_rate_hz INTEGER,
bit_depth INTEGER,
genres_json TEXT NOT NULL DEFAULT '[]',
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plex_cache_tracks_section ON plex_cache_tracks(section_key);
@@ -305,6 +345,8 @@ fn open_plex_cache_db() -> Result<Connection, String> {
"album_key TEXT",
"year INTEGER",
"genre TEXT",
"genres_json TEXT NOT NULL DEFAULT '[]'",
"collection_artwork_path TEXT",
// parent_rating_key (2026-06-08): the Plex album a track belongs to, so
// two same-title albums no longer interleave in the album view. NULL on
// pre-migration rows (and rows synced before this column) — a re-sync
@@ -401,6 +443,23 @@ fn build_plex_client() -> Result<reqwest::Client, String> {
.clone()
}
/// Non-secret client identity Plex expects on media-part requests.
///
/// Metadata and full-body playback already use [`build_plex_client`], which
/// installs this profile as default headers. Progressive playback resolves the
/// URL here but performs the byte transfer in the shared player transport, so
/// it must carry the same profile with the resolved location. The token stays
/// in the redacted query URL; none of these values is a credential.
pub fn plex_media_request_headers() -> Vec<(String, String)> {
vec![
("X-Plex-Product".into(), "QBZ".into()),
("X-Plex-Version".into(), "0.1-poc".into()),
("X-Plex-Device".into(), "QBZ Desktop".into()),
("X-Plex-Platform".into(), std::env::consts::OS.into()),
("X-Plex-Client-Identifier".into(), "qbz-plex-lan-poc".into()),
]
}
/// Build the LAN Plex client with a caller-chosen request timeout. Quality
/// hydration uses a short (~5s) bound per metadata call so a single dead/slow
/// `rating_key` cannot stall the batch (matches the Svelte 5000 ms per-call
@@ -711,12 +770,27 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
parse_u32(iso.and_then(|s| s.get(..4).map(|s| s.to_string())))
});
// Genre may appear either as an attribute or as a nested <Genre tag="..."/>.
let genre_inline = get_attr(start_tag, "genre");
let genre_nested = collect_start_tags(inner_xml, "Genre")
.into_iter()
.find_map(|tag| get_attr(&tag, "tag"));
let genre = genre_inline.or(genre_nested);
// Genre may appear either as an attribute or as several nested
// `<Genre tag="..."/>` rows. Preserve all of them; the old singular field
// remains the first value for compatibility.
let mut genres = Vec::<String>::new();
if let Some(value) = get_attr(start_tag, "genre") {
if !value.trim().is_empty() {
genres.push(value.trim().to_string());
}
}
for tag in collect_start_tags(inner_xml, "Genre") {
if let Some(value) = get_attr(&tag, "tag").filter(|value| !value.trim().is_empty()) {
let value = value.trim().to_string();
if !genres
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&value))
{
genres.push(value);
}
}
}
let genre = genres.first().cloned();
let mut t = TrackBuilder {
rating_key: get_attr(start_tag, "ratingKey"),
@@ -725,12 +799,16 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
.or_else(|| get_attr(start_tag, "originalTitle")),
album: get_attr(start_tag, "parentTitle"),
duration_ms: parse_u64(get_attr(start_tag, "duration")),
artwork_path: get_attr(start_tag, "thumb")
.or_else(|| get_attr(start_tag, "parentThumb"))
.or_else(|| get_attr(start_tag, "grandparentThumb")),
// Preserve the specificity hierarchy instead of flattening it here.
// A track/disc thumb may differ from the parent album cover; callers
// choose item -> collection -> placeholder for playback, while album
// aggregation chooses collection -> item.
artwork_path: get_attr(start_tag, "thumb"),
collection_artwork_path: get_attr(start_tag, "parentThumb"),
track_number: parse_u32(get_attr(start_tag, "index")),
disc_number: parse_u32(get_attr(start_tag, "parentIndex")),
year,
genres,
genre,
parent_rating_key: get_attr(start_tag, "parentRatingKey"),
..Default::default()
@@ -810,6 +888,7 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
album: t.album,
duration_ms: t.duration_ms,
artwork_path: t.artwork_path,
collection_artwork_path: t.collection_artwork_path,
part_key: t.part_key,
container: t.container,
codec: t.codec,
@@ -820,6 +899,7 @@ fn parse_track_block(start_tag: &str, inner_xml: &str) -> Option<PlexTrack> {
track_number: t.track_number,
disc_number: t.disc_number,
year: t.year,
genres: t.genres,
genre: t.genre,
parent_rating_key: t.parent_rating_key,
})
@@ -1270,7 +1350,7 @@ pub fn plex_cache_get_tracks(
.prepare(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre, parent_rating_key
year, genre, genres_json, parent_rating_key, collection_artwork_path
FROM plex_cache_tracks
WHERE section_key = ?1
ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE
@@ -1290,6 +1370,7 @@ pub fn plex_cache_get_tracks(
.map(|v| decode_xml_entities(v.trim())),
duration_ms: row.get(4)?,
artwork_path: row.get(5)?,
collection_artwork_path: row.get(19)?,
part_key: row.get(6)?,
container: row.get(7)?,
codec: row.get(8)?,
@@ -1300,8 +1381,12 @@ pub fn plex_cache_get_tracks(
track_number: row.get(13)?,
disc_number: row.get(14)?,
year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32),
genres: plex_genres_from_json(
row.get::<_, Option<String>>(17)?.as_deref(),
row.get::<_, Option<String>>(16)?.as_deref(),
),
genre: row.get(16)?,
parent_rating_key: row.get(17)?,
parent_rating_key: row.get(18)?,
})
})
.map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?;
@@ -1313,7 +1398,7 @@ pub fn plex_cache_get_tracks(
.prepare(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre, parent_rating_key
year, genre, genres_json, parent_rating_key, collection_artwork_path
FROM plex_cache_tracks
ORDER BY updated_at DESC
LIMIT ?1",
@@ -1332,6 +1417,7 @@ pub fn plex_cache_get_tracks(
.map(|v| decode_xml_entities(v.trim())),
duration_ms: row.get(4)?,
artwork_path: row.get(5)?,
collection_artwork_path: row.get(19)?,
part_key: row.get(6)?,
container: row.get(7)?,
codec: row.get(8)?,
@@ -1342,8 +1428,12 @@ pub fn plex_cache_get_tracks(
track_number: row.get(13)?,
disc_number: row.get(14)?,
year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32),
genres: plex_genres_from_json(
row.get::<_, Option<String>>(17)?.as_deref(),
row.get::<_, Option<String>>(16)?.as_deref(),
),
genre: row.get(16)?,
parent_rating_key: row.get(17)?,
parent_rating_key: row.get(18)?,
})
})
.map_err(|e| format!("Failed to query Plex cache tracks: {}", e))?;
@@ -1374,7 +1464,7 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, artwork_path, part_key, container,
codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth, track_number, disc_number,
year, genre, parent_rating_key
year, genre, genres_json, parent_rating_key, collection_artwork_path
FROM plex_cache_tracks
WHERE rating_key IN ({})",
placeholders
@@ -1396,6 +1486,7 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
.map(|v| decode_xml_entities(v.trim())),
duration_ms: row.get(4)?,
artwork_path: row.get(5)?,
collection_artwork_path: row.get(19)?,
part_key: row.get(6)?,
container: row.get(7)?,
codec: row.get(8)?,
@@ -1406,8 +1497,12 @@ pub fn plex_cache_get_tracks_by_keys(rating_keys: &[String]) -> Result<Vec<PlexT
track_number: row.get(13)?,
disc_number: row.get(14)?,
year: row.get::<_, Option<i64>>(15)?.map(|v| v as u32),
genres: plex_genres_from_json(
row.get::<_, Option<String>>(17)?.as_deref(),
row.get::<_, Option<String>>(16)?.as_deref(),
),
genre: row.get(16)?,
parent_rating_key: row.get(17)?,
parent_rating_key: row.get(18)?,
})
})
.map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?;
@@ -1422,10 +1517,16 @@ pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
let conn = open_plex_cache_db()?;
let mut stmt = conn
.prepare(
"SELECT artist, album, duration_ms, artwork_path, container, sampling_rate_hz, bit_depth, year, genre
"SELECT artist, album, duration_ms, artwork_path, collection_artwork_path,
container, sampling_rate_hz, bit_depth, year, genre
FROM plex_cache_tracks",
)
.map_err(|e| format!("Failed to prepare Plex cache album aggregation query: {}", e))?;
.map_err(|e| {
format!(
"Failed to prepare Plex cache album aggregation query: {}",
e
)
})?;
let rows = stmt
.query_map([], |row| {
@@ -1435,10 +1536,11 @@ pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<i64>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<String>>(9)?,
))
})
.map_err(|e| {
@@ -1456,6 +1558,7 @@ pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
album_opt,
duration_ms_opt,
artwork_path,
collection_artwork_path,
container,
sampling_rate_hz_opt,
bit_depth_opt,
@@ -1472,6 +1575,9 @@ pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
.unwrap_or_else(|| "Unknown Album".to_string());
let album = normalize_album_title(Some(&artist), &album_raw);
let album_key = plex_album_key(&artist, &album);
let representative_artwork = collection_artwork_path
.filter(|path| !path.is_empty())
.or_else(|| artwork_path.clone().filter(|path| !path.is_empty()));
let entry = grouped
.entry(album_key.clone())
@@ -1479,7 +1585,7 @@ pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
id: album_key.clone(),
title: album.clone(),
artist: artist.clone(),
artwork_path: artwork_path.clone(),
artwork_path: representative_artwork.clone(),
track_count: 0,
total_duration_secs: 0,
format: container.clone().unwrap_or_else(|| "flac".to_string()),
@@ -1502,8 +1608,8 @@ pub fn plex_cache_get_albums() -> Result<Vec<PlexCachedAlbum>, String> {
if let Some(duration_ms) = duration_ms_opt {
entry.total_duration_secs += (duration_ms as u64) / 1000;
}
if entry.artwork_path.is_none() && artwork_path.is_some() {
entry.artwork_path = artwork_path;
if entry.artwork_path.is_none() {
entry.artwork_path = representative_artwork;
}
if let Some(container_value) = container {
if entry.format == "flac" || entry.format.is_empty() {
@@ -1565,7 +1671,9 @@ pub fn plex_cache_count_tracks() -> Result<usize, String> {
pub fn plex_cache_get_artists() -> Result<Vec<PlexCachedArtist>, String> {
let conn = open_plex_cache_db()?;
let mut stmt = conn
.prepare("SELECT artist, album, artwork_path FROM plex_cache_tracks")
.prepare(
"SELECT artist, album, artwork_path, collection_artwork_path FROM plex_cache_tracks",
)
.map_err(|e| {
format!(
"Failed to prepare Plex cache artist aggregation query: {}",
@@ -1579,6 +1687,7 @@ pub fn plex_cache_get_artists() -> Result<Vec<PlexCachedArtist>, String> {
row.get::<_, Option<String>>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(|e| {
@@ -1599,7 +1708,7 @@ pub fn plex_cache_get_artists() -> Result<Vec<PlexCachedArtist>, String> {
let mut grouped: HashMap<String, Acc> = HashMap::new();
for row in rows {
let (artist_opt, album_opt, artwork_path) =
let (artist_opt, album_opt, artwork_path, collection_artwork_path) =
row.map_err(|e| format!("Failed to read Plex cache artist row: {}", e))?;
let artist = artist_opt
.map(|v| decode_xml_entities(v.trim()))
@@ -1621,7 +1730,10 @@ pub fn plex_cache_get_artists() -> Result<Vec<PlexCachedArtist>, String> {
entry.albums.insert(album_key);
entry.track_count += 1;
if entry.artwork_path.is_none() {
if let Some(p) = artwork_path.filter(|p| !p.is_empty()) {
if let Some(p) = artwork_path
.filter(|p| !p.is_empty())
.or_else(|| collection_artwork_path.filter(|p| !p.is_empty()))
{
entry.artwork_path = Some(p);
}
}
@@ -1659,7 +1771,8 @@ fn plex_cache_get_album_tracks_in(
.unwrap_or(("album_key", album_key));
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key,
collection_artwork_path
FROM plex_cache_tracks
WHERE {column} = ?1
ORDER BY disc_number, track_number, title COLLATE NOCASE"
@@ -1683,6 +1796,7 @@ fn plex_cache_get_album_tracks_in(
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<i64>>(10)?,
row.get::<_, Option<String>>(11)?,
row.get::<_, Option<String>>(12)?,
))
})
.map_err(|e| format!("Failed to query Plex cache album tracks: {}", e))?;
@@ -1702,6 +1816,7 @@ fn plex_cache_get_album_tracks_in(
track_number_opt,
disc_number_opt,
parent_rating_key,
collection_artwork_path,
) = row.map_err(|e| format!("Failed to read Plex cache album track row: {}", e))?;
let artist = artist_opt
.map(|v| decode_xml_entities(v.trim()))
@@ -1723,6 +1838,7 @@ fn plex_cache_get_album_tracks_in(
bit_depth: bit_depth_opt.map(|v| v as u32),
sample_rate: sampling_rate_opt.map(|v| v as u32).unwrap_or(44100),
artwork_path,
collection_artwork_path,
source: "plex".to_string(),
album_key: album_key.to_string(),
track_number: track_number_opt.map(|v| v as u32),
@@ -1750,16 +1866,51 @@ pub fn plex_cache_search_tracks_page(
limit: u64,
sort: &str,
) -> Result<Vec<PlexCachedTrack>, String> {
let conn = open_plex_cache_db()?;
plex_cache_search_tracks_page_in(&conn, &query, offset, limit, sort)
plex_cache_search_tracks_page_filtered(query, offset, limit, sort, &[], false, &[])
}
pub fn plex_cache_search_tracks_page_filtered(
query: String,
offset: u64,
limit: u64,
sort: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> Result<Vec<PlexCachedTrack>, String> {
let conn = open_plex_cache_db()?;
plex_cache_search_tracks_page_filtered_in(
&conn,
&query,
offset,
limit,
sort,
formats,
other_formats,
quality_tiers,
)
}
#[cfg(test)]
fn plex_cache_search_tracks_page_in(
conn: &Connection,
query: &str,
offset: u64,
limit: u64,
sort: &str,
) -> Result<Vec<PlexCachedTrack>, String> {
plex_cache_search_tracks_page_filtered_in(conn, query, offset, limit, sort, &[], false, &[])
}
fn plex_cache_search_tracks_page_filtered_in(
conn: &Connection,
query: &str,
offset: u64,
limit: u64,
sort: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> Result<Vec<PlexCachedTrack>, String> {
let order = match sort {
"title-asc" => "sort_title COLLATE NOCASE, sort_artist COLLATE NOCASE, rating_key",
@@ -1772,11 +1923,20 @@ fn plex_cache_search_tracks_page_in(
"added-desc" => "sort_album COLLATE NOCASE, disc_number, track_number, rating_key",
_ => "sort_album COLLATE NOCASE, sort_artist COLLATE NOCASE, disc_number, track_number, sort_title COLLATE NOCASE, rating_key",
};
let media_filter = cached_track_media_filter_sql(
"container",
"bit_depth",
"sampling_rate_hz",
formats,
other_formats,
quality_tiers,
);
let needle = format!("%{}%", query.to_lowercase());
let mut stmt = conn
.prepare(&format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, year,
collection_artwork_path,
TRIM(title) AS sort_title,
COALESCE(NULLIF(TRIM(artist), ''), 'Unknown Artist') AS sort_artist,
CASE
@@ -1799,10 +1959,11 @@ fn plex_cache_search_tracks_page_in(
ELSE COALESCE(NULLIF(TRIM(album), ''), 'Unknown Album')
END AS sort_album
FROM plex_cache_tracks
WHERE ?1 = '' OR
WHERE (?1 = '' OR
lower(title) LIKE ?2 OR
lower(COALESCE(artist, '')) LIKE ?2 OR
lower(COALESCE(album, '')) LIKE ?2
lower(COALESCE(album, '')) LIKE ?2) \
{media_filter}
ORDER BY {order}
LIMIT ?3 OFFSET ?4"
))
@@ -1825,6 +1986,7 @@ fn plex_cache_search_tracks_page_in(
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<i64>>(10)?,
row.get::<_, Option<i64>>(11)?,
row.get::<_, Option<String>>(12)?,
))
},
)
@@ -1845,6 +2007,7 @@ fn plex_cache_search_tracks_page_in(
track_number_opt,
disc_number_opt,
year_opt,
collection_artwork_path,
) = row.map_err(|e| format!("Failed to read Plex cache search row: {}", e))?;
let artist = artist_opt
.map(|v| decode_xml_entities(v.trim()))
@@ -1866,6 +2029,7 @@ fn plex_cache_search_tracks_page_in(
bit_depth: bit_depth_opt.map(|v| v as u32),
sample_rate: sampling_rate_opt.map(|v| v as u32).unwrap_or(44100),
artwork_path,
collection_artwork_path,
source: "plex".to_string(),
album_key: plex_album_key(&artist, &album),
track_number: track_number_opt.map(|v| v as u32),
@@ -1878,6 +2042,63 @@ fn plex_cache_search_tracks_page_in(
Ok(tracks)
}
fn cached_track_media_filter_sql(
format_col: &str,
depth_col: &str,
rate_col: &str,
formats: &[String],
other_formats: bool,
quality_tiers: &[String],
) -> String {
let known = ["flac", "alac", "ape", "wav", "mp3", "aac"];
let selected = known
.iter()
.copied()
.filter(|value| {
formats
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(value))
})
.collect::<Vec<_>>();
let mut clauses = Vec::new();
if !selected.is_empty() || other_formats {
let mut values = selected
.into_iter()
.map(|value| format!("LOWER(COALESCE({format_col},''))='{value}'"))
.collect::<Vec<_>>();
if other_formats {
values.push(format!(
"LOWER(COALESCE({format_col},'')) NOT IN ('flac','alac','ape','wav','mp3','aac')"
));
}
clauses.push(format!("AND ({})", values.join(" OR ")));
}
let hires = quality_tiers.iter().any(|value| value == "hires");
let cd = quality_tiers.iter().any(|value| value == "cd");
let lossy = quality_tiers.iter().any(|value| value == "lossy");
if hires || cd || lossy {
let khz = format!(
"CASE WHEN COALESCE({rate_col},0)>=1000 THEN COALESCE({rate_col},0)/1000.0 ELSE COALESCE({rate_col},0) END"
);
let mut values = Vec::new();
if hires {
values.push(format!(
"(LOWER(COALESCE({format_col},'')) IN ('dsd','dsf','dff') OR COALESCE({depth_col},0)>=24)"
));
}
if cd {
values.push(format!(
"(LOWER(COALESCE({format_col},'')) NOT IN ('mp3','dsd','dsf','dff') AND (({depth_col} IS NOT NULL AND {depth_col}<24) OR ({depth_col} IS NULL AND {khz}>=44.1)))"
));
}
if lossy {
values.push(format!("LOWER(COALESCE({format_col},''))='mp3'"));
}
clauses.push(format!("AND ({})", values.join(" OR ")));
}
clauses.join(" ")
}
/// Bulk by-rating-key lookup in the `PlexCachedTrack` shape (synthetic
/// playback id + normalized album + content-hash album key — the same
/// row mapping `plex_cache_search_tracks` produces, so a row resolved
@@ -1898,7 +2119,8 @@ pub fn plex_cache_get_cached_tracks_by_keys(
.join(",");
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key,
collection_artwork_path
FROM plex_cache_tracks
WHERE rating_key IN ({})",
placeholders
@@ -1921,6 +2143,7 @@ pub fn plex_cache_get_cached_tracks_by_keys(
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<i64>>(10)?,
row.get::<_, Option<String>>(11)?,
row.get::<_, Option<String>>(12)?,
))
})
.map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?;
@@ -1939,6 +2162,7 @@ pub fn plex_cache_get_cached_tracks_by_keys(
track_number_opt,
disc_number_opt,
parent_rating_key,
collection_artwork_path,
) = row.map_err(|e| format!("Failed to read Plex cache by-keys row: {}", e))?;
let artist = artist_opt
.map(|v| decode_xml_entities(v.trim()))
@@ -1960,6 +2184,7 @@ pub fn plex_cache_get_cached_tracks_by_keys(
bit_depth: bit_depth_opt.map(|v| v as u32),
sample_rate: sampling_rate_opt.map(|v| v as u32).unwrap_or(44100),
artwork_path,
collection_artwork_path,
source: "plex".to_string(),
album_key: plex_album_key(&artist, &album),
track_number: track_number_opt.map(|v| v as u32),
@@ -2112,17 +2337,19 @@ fn apply_section_page_in(
.prepare_cached(
"INSERT INTO plex_cache_tracks(
rating_key,section_key,server_id,title,artist,album,duration_ms,artwork_path,
part_key,container,codec,channels,bitrate_kbps,sampling_rate_hz,bit_depth,
track_number,disc_number,album_key,year,genre,parent_rating_key,updated_at,
collection_artwork_path,part_key,container,codec,channels,bitrate_kbps,
sampling_rate_hz,bit_depth,
track_number,disc_number,album_key,year,genre,genres_json,parent_rating_key,updated_at,
sync_generation
) VALUES (
?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,
?19,?20,?21,?22,?23
?19,?20,?21,?22,?23,?24,?25
)
ON CONFLICT(rating_key) DO UPDATE SET
section_key=excluded.section_key,server_id=excluded.server_id,
title=excluded.title,artist=excluded.artist,album=excluded.album,
duration_ms=excluded.duration_ms,artwork_path=excluded.artwork_path,
collection_artwork_path=excluded.collection_artwork_path,
part_key=excluded.part_key,
container=COALESCE(excluded.container,plex_cache_tracks.container),
codec=COALESCE(excluded.codec,plex_cache_tracks.codec),
@@ -2134,11 +2361,13 @@ fn apply_section_page_in(
bit_depth=COALESCE(excluded.bit_depth,plex_cache_tracks.bit_depth),
track_number=excluded.track_number,disc_number=excluded.disc_number,
album_key=excluded.album_key,year=excluded.year,genre=excluded.genre,
genres_json=excluded.genres_json,
parent_rating_key=excluded.parent_rating_key,updated_at=excluded.updated_at,
sync_generation=excluded.sync_generation",
)
.map_err(|e| format!("Failed to prepare Plex page upsert: {}", e))?;
for track in &page.tracks {
let genres_json = plex_genres_json(track);
let artist = track
.artist
.as_deref()
@@ -2162,6 +2391,7 @@ fn apply_section_page_in(
track.album,
track.duration_ms.map(|value| value as i64),
track.artwork_path,
track.collection_artwork_path,
track.part_key,
track.container,
track.codec,
@@ -2174,6 +2404,7 @@ fn apply_section_page_in(
plex_album_key(artist, &album),
track.year.map(|value| value as i64),
track.genre,
genres_json,
track.parent_rating_key,
now,
generation_i64,
@@ -2406,9 +2637,10 @@ pub fn plex_cache_save_tracks(
tx.execute(
"INSERT INTO plex_cache_tracks
(rating_key, section_key, server_id, title, artist, album, duration_ms, artwork_path,
part_key, container, codec, channels, bitrate_kbps, sampling_rate_hz, bit_depth,
track_number, disc_number, album_key, year, genre, parent_rating_key, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
collection_artwork_path, part_key, container, codec, channels, bitrate_kbps,
sampling_rate_hz, bit_depth,
track_number, disc_number, album_key, year, genre, genres_json, parent_rating_key, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
params![
track.rating_key,
section_key,
@@ -2418,6 +2650,7 @@ pub fn plex_cache_save_tracks(
track.album,
track.duration_ms.map(|v| v as i64),
track.artwork_path,
track.collection_artwork_path,
track.part_key,
container,
track.codec,
@@ -2430,6 +2663,7 @@ pub fn plex_cache_save_tracks(
track_album_key,
track.year.map(|v| v as i64),
track.genre.clone(),
plex_genres_json(track),
track.parent_rating_key.clone(),
now,
],
@@ -2646,6 +2880,9 @@ pub struct PlexPartLocation {
pub content_type: Option<String>,
pub sampling_rate_hz: Option<u32>,
pub bit_depth: Option<u32>,
/// The non-secret request identity required when another crate fetches
/// `part_url` instead of using qbz-plex's preconfigured client.
pub request_headers: Vec<(String, String)>,
}
/// Resolve the direct-play part URL for a Plex track WITHOUT downloading the
@@ -2697,6 +2934,7 @@ pub async fn plex_resolve_part_url(
content_type: track.container.clone(),
sampling_rate_hz: track.sampling_rate_hz,
bit_depth: track.bit_depth,
request_headers: plex_media_request_headers(),
})
}
@@ -2733,7 +2971,8 @@ mod tests {
"CREATE TABLE plex_cache_tracks (
rating_key TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT,
album TEXT, duration_ms INTEGER, container TEXT, bit_depth INTEGER,
sampling_rate_hz INTEGER, artwork_path TEXT, track_number INTEGER,
sampling_rate_hz INTEGER, artwork_path TEXT, collection_artwork_path TEXT,
track_number INTEGER,
disc_number INTEGER, year INTEGER
);",
)
@@ -2775,6 +3014,7 @@ mod tests {
album TEXT,
duration_ms INTEGER,
artwork_path TEXT,
collection_artwork_path TEXT,
part_key TEXT,
container TEXT,
codec TEXT,
@@ -2787,6 +3027,7 @@ mod tests {
album_key TEXT,
year INTEGER,
genre TEXT,
genres_json TEXT NOT NULL DEFAULT '[]',
parent_rating_key TEXT,
updated_at INTEGER NOT NULL,
sync_generation INTEGER NOT NULL DEFAULT 0
@@ -2815,6 +3056,7 @@ mod tests {
album: Some(format!("Album {:04}", index / 10)),
duration_ms: Some(180_000),
artwork_path: Some(format!("/library/metadata/{}/thumb", index + 1)),
collection_artwork_path: Some(format!("/library/metadata/album-{}/thumb", index / 10)),
part_key: Some(format!("/library/parts/{}/file.flac", index + 1)),
container: with_quality.then(|| "flac".to_string()),
codec: with_quality.then(|| "flac".to_string()),
@@ -2825,6 +3067,7 @@ mod tests {
track_number: Some((index % 10 + 1) as u32),
disc_number: Some(1),
year: Some(2026),
genres: vec!["Fixture".to_string()],
genre: Some("Fixture".to_string()),
parent_rating_key: Some(format!("album-{}", index / 10)),
}
@@ -2870,11 +3113,13 @@ mod tests {
#[test]
fn parses_tracks_with_stream_audio_metadata() {
let xml = r#"<MediaContainer>
<Track ratingKey="42" title="Song" grandparentTitle="Artist" parentTitle="Album" duration="123000" thumb="/library/metadata/42/thumb/1">
<Track ratingKey="42" title="Song" grandparentTitle="Artist" parentTitle="Album" duration="123000" thumb="/library/metadata/42/thumb/1" parentThumb="/library/metadata/album-9/thumb/1" genre="Progressive Rock">
<Media container="flac">
<Part key="/library/parts/999/file.flac"/>
<Stream streamType="2" codecType="audio" codec="flac" channels="2" samplingRate="96000" bitDepth="24" bitrate="3120"/>
</Media>
<Genre tag="Art Rock"/>
<Genre tag="progressive rock"/>
</Track>
</MediaContainer>"#;
let tracks = parse_tracks(xml, Some(10));
@@ -2888,8 +3133,56 @@ mod tests {
tracks[0].artwork_path.as_deref(),
Some("/library/metadata/42/thumb/1")
);
assert_eq!(
tracks[0].collection_artwork_path.as_deref(),
Some("/library/metadata/album-9/thumb/1")
);
assert_eq!(tracks[0].sampling_rate_hz, Some(96000));
assert_eq!(tracks[0].bit_depth, Some(24));
assert_eq!(tracks[0].genres, ["Progressive Rock", "Art Rock"]);
assert_eq!(tracks[0].genre.as_deref(), Some("Progressive Rock"));
}
#[test]
fn plex_filtered_query_applies_format_and_quality_before_pagination() {
let conn = search_db(17);
conn.execute(
"UPDATE plex_cache_tracks SET container='mp3',bit_depth=NULL,sampling_rate_hz=44100
WHERE CAST(rating_key AS INTEGER) % 2 = 1",
[],
)
.unwrap();
let formats = vec!["mp3".to_string()];
let qualities = vec!["lossy".to_string()];
let first = plex_cache_search_tracks_page_filtered_in(
&conn,
"",
0,
4,
"title-asc",
&formats,
false,
&qualities,
)
.unwrap();
let second = plex_cache_search_tracks_page_filtered_in(
&conn,
"",
4,
8,
"title-asc",
&formats,
false,
&qualities,
)
.unwrap();
let ids = first
.iter()
.chain(&second)
.map(|row| row.rating_key.clone())
.collect::<std::collections::HashSet<_>>();
assert_eq!(ids.len(), 9);
assert!(first.iter().chain(&second).all(|row| row.format == "mp3"));
}
#[test]
@@ -3169,8 +3462,7 @@ mod tests {
)
.unwrap();
let grouped =
plex_cache_search_tracks_page_in(&conn, "", 0, 10, "group-artist").unwrap();
let grouped = plex_cache_search_tracks_page_in(&conn, "", 0, 10, "group-artist").unwrap();
assert_eq!(grouped[0].artist, "Alpha Performer");
assert_eq!(grouped[1].artist, "Zed Performer");
}
@@ -3182,7 +3474,8 @@ mod tests {
"CREATE TABLE plex_cache_tracks (
rating_key TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT,
album TEXT, duration_ms INTEGER, container TEXT, bit_depth INTEGER,
sampling_rate_hz INTEGER, artwork_path TEXT, track_number INTEGER,
sampling_rate_hz INTEGER, artwork_path TEXT, collection_artwork_path TEXT,
track_number INTEGER,
disc_number INTEGER, album_key TEXT, parent_rating_key TEXT
);
INSERT INTO plex_cache_tracks
+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"
);
}
}
+96 -31
View File
@@ -194,9 +194,11 @@ impl Source for JellyfinSource {
.and_then(|r| r.ok())
.flatten()
{
Some(t) => {
Ok(MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, &t.item_id))
}
Some(t) => Ok(MediaRef::new(
SourceId::JELLYFIN,
ItemKind::Track,
&t.item_id,
)),
// The row is gone from the cache: a named error at the
// moment of the mistake, not a 404 from the server two
// layers down.
@@ -245,20 +247,26 @@ impl Source for JellyfinSource {
}
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
let Some(token) = self
.rows(item)
.iter()
.find_map(|t| t.artwork_token.clone().filter(|s| !s.is_empty()))
else {
let rows = self.rows(item);
let collection_first = item.kind == ItemKind::Album;
let find = |collection: bool| {
rows.iter().find_map(|track| {
let token = if collection {
&track.collection_artwork_token
} else {
&track.artwork_token
};
token.clone().filter(|value| !value.is_empty())
})
};
let Some(token) = (if collection_first {
find(true).or_else(|| find(false))
} else {
find(false).or_else(|| find(true))
}) else {
return ArtRef::None;
};
// The album id is what the image hangs off; the token only versions it.
let album_id = self
.rows(item)
.first()
.map(|t| t.album_id.clone())
.unwrap_or_default();
self.image_ref(&album_id, Some(&token), size)
self.artwork_token(&token, size)
}
fn artwork_token(&self, token: &str, size: ArtSize) -> ArtRef {
@@ -267,14 +275,17 @@ impl Source for JellyfinSource {
// has both; this arm exists so a producer that only kept the tag gets
// an honest `None` rather than a broken URL.
//
// A producer that stamped `"<albumId>/<tag>"` — which is what the Local
// Library row mapper will do — is addressable, so that shape resolves.
// A producer that stamped `"<itemId>/<tag>"` — where item may be the
// audio row for disc art or its MusicAlbum for collection art — is
// addressable. An empty tag is valid and means "do not cache-bust".
let token = token.trim();
if token.is_empty() {
return ArtRef::None;
}
match token.split_once('/') {
Some((album_id, tag)) => self.image_ref(album_id, Some(tag), size),
Some((item_id, tag)) => {
self.image_ref(item_id, (!tag.trim().is_empty()).then_some(tag), size)
}
None => ArtRef::None,
}
}
@@ -307,6 +318,7 @@ impl Source for JellyfinSource {
duration_secs: track.duration_secs,
start_secs: 0,
log_tag: "JELLYFIN",
request_headers: Vec::new(),
})
}
@@ -443,10 +455,10 @@ mod tests {
#[test]
fn it_never_claims_another_sources_id() {
for foreign in [
(1i64 << 40) | 44_440, // Plex
(1i64 << 40) | 44_440, // Plex
RemoteSource::Subsonic.namespace(7),
(1i64 << 48) + 10, // ephemeral
2954, // a local_tracks rowid
(1i64 << 48) + 10, // ephemeral
2954, // a local_tracks rowid
] {
assert!(
!recognises(&RawRef {
@@ -458,7 +470,11 @@ mod tests {
);
}
// A Qobuz album id, and a local group key.
assert!(!recognises(&RawRef::new("qobuz", ItemKind::Album, "0060254702523")));
assert!(!recognises(&RawRef::new(
"qobuz",
ItemKind::Album,
"0060254702523"
)));
assert!(!recognises(&RawRef {
kind: Some(ItemKind::Album),
id: "HIT ME HARD AND SOFT|Billie Eilish".into(),
@@ -478,7 +494,10 @@ mod tests {
id: "jellyfin:alb-1".into(),
..Default::default()
};
assert!(recognises(&raw), "an unlabelled prefixed key was not recognised");
assert!(
recognises(&raw),
"an unlabelled prefixed key was not recognised"
);
let m = s.claim(&raw).unwrap().unwrap();
assert_eq!(m.id(), "alb-1", "the prefix was carried inward");
// A bare id with the source word still works.
@@ -501,7 +520,13 @@ mod tests {
})
.unwrap()
.unwrap_err();
assert!(matches!(err, SourceError::BadIdShape { by: SourceId::JELLYFIN, .. }));
assert!(matches!(
err,
SourceError::BadIdShape {
by: SourceId::JELLYFIN,
..
}
));
}
/// ...and it must not answer for the NEIGHBOUR's prefix.
@@ -531,7 +556,13 @@ mod tests {
})
.unwrap()
.unwrap_err();
assert!(matches!(err, SourceError::BadIdShape { by: SourceId::JELLYFIN, .. }));
assert!(matches!(
err,
SourceError::BadIdShape {
by: SourceId::JELLYFIN,
..
}
));
}
#[test]
@@ -546,7 +577,13 @@ mod tests {
})
.unwrap()
.unwrap_err();
assert!(matches!(err, SourceError::Unsupported { by: SourceId::JELLYFIN, .. }));
assert!(matches!(
err,
SourceError::Unsupported {
by: SourceId::JELLYFIN,
..
}
));
}
// ── artwork ────────────────────────────────────────────────────────────
@@ -559,7 +596,10 @@ mod tests {
match s.artwork_token("alb-1/deadbeef", ArtSize::Card) {
ArtRef::Fetch { url, cache_key } => {
assert_eq!(url, cache_key, "a stable url should key on itself");
assert!(!url.contains("tok"), "the access token leaked into a cover url");
assert!(
!url.contains("tok"),
"the access token leaked into a cover url"
);
assert!(url.contains("/Items/alb-1/Images/Primary"));
assert!(url.contains("tag=deadbeef"));
assert!(url.contains("maxWidth=256"));
@@ -571,6 +611,13 @@ mod tests {
ArtRef::Fetch { url, .. } => assert!(url.contains("maxWidth=1024")),
other => panic!("expected Fetch, got {other:?}"),
}
match s.artwork_token("alb-1/", ArtSize::Card) {
ArtRef::Fetch { url, .. } => {
assert!(url.contains("/Items/alb-1/Images/Primary"));
assert!(!url.contains("tag="));
}
other => panic!("expected tagless Fetch, got {other:?}"),
}
}
/// Disconnected is UNAVAILABLE, never None: the art exists, the server does
@@ -587,7 +634,10 @@ mod tests {
connected().artwork_token("bare-tag", ArtSize::Card),
ArtRef::None
));
assert!(matches!(connected().artwork_token("", ArtSize::Card), ArtRef::None));
assert!(matches!(
connected().artwork_token("", ArtSize::Card),
ArtRef::None
));
}
// ── playback ───────────────────────────────────────────────────────────
@@ -606,7 +656,10 @@ mod tests {
..
} => {
assert!(url.contains("/Audio/srv-abc/stream?static=true"));
assert!(!url.contains("audioCodec"), "a codec parameter IS a transcode");
assert!(
!url.contains("audioCodec"),
"a codec parameter IS a transcode"
);
assert_eq!(play_id, 42);
assert_eq!(duration_secs, 188);
assert_eq!(log_tag, "JELLYFIN");
@@ -620,7 +673,13 @@ mod tests {
let s = JellyfinSource::new();
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Track, "x");
let err = s.playback(&item, &qt(0, 0)).await.unwrap_err();
assert!(matches!(err, SourceError::NotConfigured { by: SourceId::JELLYFIN, .. }));
assert!(matches!(
err,
SourceError::NotConfigured {
by: SourceId::JELLYFIN,
..
}
));
}
/// An ALBUM cannot be played directly — the caller expands it to tracks
@@ -630,7 +689,13 @@ mod tests {
let s = connected();
let item = MediaRef::new(SourceId::JELLYFIN, ItemKind::Album, "alb");
let err = s.playback(&item, &qt(0, 0)).await.unwrap_err();
assert!(matches!(err, SourceError::Unsupported { by: SourceId::JELLYFIN, .. }));
assert!(matches!(
err,
SourceError::Unsupported {
by: SourceId::JELLYFIN,
..
}
));
}
/// A disabled toggle is the same as no credentials — the source must not
+72 -11
View File
@@ -205,11 +205,23 @@ impl PlexSource {
}
}
fn preferred_art(item: &MediaRef, rows: &[PlexCachedTrack]) -> Option<String> {
rows.iter().find_map(|track| {
let item_art = track.artwork_path.clone().filter(|path| !path.is_empty());
let collection_art = track
.collection_artwork_path
.clone()
.filter(|path| !path.is_empty());
if item.kind() == ItemKind::Track {
item_art.or(collection_art)
} else {
collection_art.or(item_art)
}
})
}
fn memoize_art(&self, item: &MediaRef, rows: &[PlexCachedTrack]) {
let Some(path) = rows
.iter()
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
else {
let Some(path) = Self::preferred_art(item, rows) else {
return;
};
if let Ok(mut memo) = self.art.write() {
@@ -394,8 +406,7 @@ impl Source for PlexSource {
None => {
let rows = Self::cached_rows(item);
self.memoize_art(item, &rows);
rows.iter()
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
Self::preferred_art(item, &rows)
}
}
};
@@ -483,6 +494,7 @@ impl Source for PlexSource {
duration_secs: track.duration_secs,
start_secs: 0,
log_tag: "PLEX",
request_headers: loc.request_headers,
}),
Err(e) => Err(SourceError::Backend {
by: SourceId::PLEX,
@@ -512,6 +524,58 @@ mod tests {
use super::*;
use crate::meta::SourceBadge;
fn cached_art_row(item_art: Option<&str>, collection_art: Option<&str>) -> PlexCachedTrack {
PlexCachedTrack {
id: 1,
rating_key: "1".into(),
title: "Track".into(),
artist: "Artist".into(),
album: "Album".into(),
duration_secs: 1,
format: "flac".into(),
bit_depth: Some(16),
sample_rate: 44_100,
artwork_path: item_art.map(str::to_string),
collection_artwork_path: collection_art.map(str::to_string),
source: "plex".into(),
album_key: "plex:album".into(),
track_number: Some(1),
disc_number: Some(1),
year: Some(2026),
parent_rating_key: Some("album".into()),
}
}
#[test]
fn track_art_prefers_the_disc_then_falls_back_to_the_collection() {
let item = MediaRef::new(SourceId::PLEX, ItemKind::Track, "1");
let row = cached_art_row(Some("/disc/thumb"), Some("/collection/thumb"));
assert_eq!(
PlexSource::preferred_art(&item, std::slice::from_ref(&row)).as_deref(),
Some("/disc/thumb")
);
let row = cached_art_row(None, Some("/collection/thumb"));
assert_eq!(
PlexSource::preferred_art(&item, &[row]).as_deref(),
Some("/collection/thumb")
);
}
#[test]
fn album_art_prefers_the_collection_then_falls_back_to_an_item() {
let item = MediaRef::new(SourceId::PLEX, ItemKind::Album, "plex:album");
let row = cached_art_row(Some("/disc/thumb"), Some("/collection/thumb"));
assert_eq!(
PlexSource::preferred_art(&item, std::slice::from_ref(&row)).as_deref(),
Some("/collection/thumb")
);
let row = cached_art_row(Some("/disc/thumb"), None);
assert_eq!(
PlexSource::preferred_art(&item, &[row]).as_deref(),
Some("/disc/thumb")
);
}
/// The stub stands in for the Plex cache, so the pure ladder is testable
/// with no `plex_cache.db` on disk.
fn no_cache(_masked: u64) -> Option<String> {
@@ -541,11 +605,8 @@ mod tests {
);
// Non-numeric server keys are legal and must survive untouched.
assert_eq!(
PlexSource::rating_key_with(
&track_ref("999", Some("/library/metadata/771")),
no_cache
)
.unwrap(),
PlexSource::rating_key_with(&track_ref("999", Some("/library/metadata/771")), no_cache)
.unwrap(),
"/library/metadata/771"
);
}
+44 -14
View File
@@ -104,10 +104,7 @@ impl CacheHandle {
/// Run `f` against the cache with WRITE access. Same handle — SQLite
/// serialises writers itself and this one is behind a mutex anyway.
pub fn with_mut<R>(
&self,
f: impl FnOnce(&mut rusqlite::Connection) -> R,
) -> Option<R> {
pub fn with_mut<R>(&self, f: impl FnOnce(&mut rusqlite::Connection) -> R) -> Option<R> {
let mut guard = self.conn.lock().ok()?;
if guard.is_none() {
let path = self.path.lock().ok()?.clone()?;
@@ -145,11 +142,17 @@ pub(crate) fn cached_to_queue_track(t: &CachedTrack, source: SourceId) -> QueueT
album: t.album.clone(),
album_version: None,
duration_secs: t.duration_ms / 1000,
// The RAW artwork token, not a URL. A URL embeds credentials and a
// size; the token is stable, and `artwork_token` is what turns it into
// something fetchable at the moment it is needed. Storing the URL here
// is how a queue row ends up with a stale token in it.
artwork_url: t.artwork_token.clone(),
// A source-tagged RAW token, not a URL. The vendor token is stable,
// while credentials and requested size are resolved only when a
// consumer needs bytes. The source tag is load-bearing: recent-track
// playback reaches this mapper directly, with no LocalTrack adapter
// available to add it later, and an untagged opaque token would be
// misclassified as a local filesystem path by NPB/MPRIS artwork.
artwork_url: t
.artwork_token
.clone()
.or_else(|| t.collection_artwork_token.clone())
.map(|token| format!("{}:{token}", source.as_str())),
hires: t.bit_depth.map(|d| d > 16).unwrap_or(false),
bit_depth: t.bit_depth,
// kHz — the `QueueTrack` convention, NOT Hz.
@@ -208,7 +211,9 @@ pub(crate) fn meta_of_rows(
// The tier only needs to know whether it is lossy; the
// container name itself is display data.
if f.container.eq_ignore_ascii_case("mp3")
|| f.codec.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("mp3"))
|| f.codec
.as_deref()
.is_some_and(|c| c.eq_ignore_ascii_case("mp3"))
{
"MP3"
} else {
@@ -267,7 +272,10 @@ mod tests {
assert!(qt.hires);
assert_eq!(qt.source.as_deref(), Some("jellyfin"));
assert!(qt.is_local, "a remote row must skip the Qobuz tier walk");
assert!(qt.streamable, "an unstreamable row is dropped from the queue");
assert!(
qt.streamable,
"an unstreamable row is dropped from the queue"
);
}
/// The queue carries the NAMESPACED id and the SERVER's id, not one or the
@@ -281,15 +289,37 @@ mod tests {
assert_eq!(qt.source_item_id_hint.as_deref(), Some("srv-abc"));
}
/// The RAW token, never a URL: a URL embeds credentials and a size, and a
/// queue row outlives both.
/// The source-tagged RAW token, never a URL: a URL embeds credentials and
/// a size, and a queue row outlives both. The tag keeps an opaque server
/// id from being mistaken for a local path by consumers outside this crate.
#[test]
fn the_queue_row_holds_the_art_token_not_a_url() {
let qt = cached_to_queue_track(&row(), SourceId::JELLYFIN);
assert_eq!(qt.artwork_url.as_deref(), Some("748607df"));
assert_eq!(qt.artwork_url.as_deref(), Some("jellyfin:748607df"));
assert!(!qt.artwork_url.unwrap().contains("http"));
}
#[test]
fn the_queue_prefers_disc_art_then_falls_back_to_collection_art() {
let mut with_disc = row();
with_disc.artwork_token = Some("disc-02".into());
with_disc.collection_artwork_token = Some("box-cover".into());
assert_eq!(
cached_to_queue_track(&with_disc, SourceId::JELLYFIN)
.artwork_url
.as_deref(),
Some("jellyfin:disc-02")
);
with_disc.artwork_token = None;
assert_eq!(
cached_to_queue_track(&with_disc, SourceId::JELLYFIN)
.artwork_url
.as_deref(),
Some("jellyfin:box-cover")
);
}
#[test]
fn album_meta_sums_the_album_and_reads_the_first_row() {
let mut a = row();
+57 -18
View File
@@ -191,9 +191,11 @@ impl Source for SubsonicSource {
.and_then(|r| r.ok())
.flatten()
{
Some(t) => {
Ok(MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, &t.item_id))
}
Some(t) => Ok(MediaRef::new(
SourceId::SUBSONIC,
ItemKind::Track,
&t.item_id,
)),
None => Err(SourceError::NotFound {
by: SourceId::SUBSONIC,
kind: ItemKind::Track,
@@ -238,11 +240,23 @@ impl Source for SubsonicSource {
}
fn artwork(&self, item: &MediaRef, size: ArtSize) -> ArtRef {
match self
.rows(item)
.iter()
.find_map(|t| t.artwork_token.clone().filter(|s| !s.is_empty()))
{
let rows = self.rows(item);
let collection_first = item.kind == ItemKind::Album;
let find = |collection: bool| {
rows.iter().find_map(|track| {
let token = if collection {
&track.collection_artwork_token
} else {
&track.artwork_token
};
token.clone().filter(|value| !value.is_empty())
})
};
match if collection_first {
find(true).or_else(|| find(false))
} else {
find(false).or_else(|| find(true))
} {
Some(token) => self.artwork_token(&token, size),
None => ArtRef::None,
}
@@ -298,6 +312,7 @@ impl Source for SubsonicSource {
duration_secs: track.duration_secs,
start_secs: 0,
log_tag: "SUBSONIC",
request_headers: Vec::new(),
})
}
@@ -385,10 +400,10 @@ mod tests {
#[test]
fn it_never_claims_another_sources_id() {
for foreign in [
(1i64 << 40) | 44_440, // Plex
RemoteSource::Jellyfin.namespace(7), // the neighbour
(1i64 << 48) + 10, // ephemeral
2954, // a local rowid
(1i64 << 40) | 44_440, // Plex
RemoteSource::Jellyfin.namespace(7), // the neighbour
(1i64 << 48) + 10, // ephemeral
2954, // a local rowid
] {
assert!(
!recognises(&RawRef {
@@ -413,7 +428,10 @@ mod tests {
id: "subsonic:al-abc".into(),
..Default::default()
};
assert!(recognises(&raw), "an unlabelled prefixed key was not recognised");
assert!(
recognises(&raw),
"an unlabelled prefixed key was not recognised"
);
let m = s.claim(&raw).unwrap().unwrap();
assert_eq!(m.id(), "al-abc", "the prefix was carried inward");
// A bare id with the source word still works.
@@ -436,7 +454,13 @@ mod tests {
})
.unwrap()
.unwrap_err();
assert!(matches!(err, SourceError::BadIdShape { by: SourceId::SUBSONIC, .. }));
assert!(matches!(
err,
SourceError::BadIdShape {
by: SourceId::SUBSONIC,
..
}
));
}
/// ...and it must not answer for the NEIGHBOUR's prefix.
@@ -480,9 +504,15 @@ mod tests {
ArtRef::Fetch { url, cache_key } => {
assert_ne!(url, cache_key, "the url must not be the cache key here");
assert_eq!(cache_key, "subsonic:al-abc_59fec8ff");
assert!(!cache_key.contains("t="), "a credential reached the cache key");
assert!(
!cache_key.contains("t="),
"a credential reached the cache key"
);
assert!(url.contains("getCoverArt.view"));
assert!(url.contains("t="), "an unauthenticated cover request is refused");
assert!(
url.contains("t="),
"an unauthenticated cover request is refused"
);
assert!(url.contains("size=256"));
}
other => panic!("expected Fetch, got {other:?}"),
@@ -507,7 +537,10 @@ mod tests {
SubsonicSource::new().artwork_token("al-x", ArtSize::Card),
ArtRef::Unavailable(_)
));
assert!(matches!(connected().artwork_token("", ArtSize::Card), ArtRef::None));
assert!(matches!(
connected().artwork_token("", ArtSize::Card),
ArtRef::None
));
}
#[tokio::test]
@@ -541,7 +574,13 @@ mod tests {
let s = SubsonicSource::new();
let item = MediaRef::new(SourceId::SUBSONIC, ItemKind::Track, "x");
let err = s.playback(&item, &qt(0, 0)).await.unwrap_err();
assert!(matches!(err, SourceError::NotConfigured { by: SourceId::SUBSONIC, .. }));
assert!(matches!(
err,
SourceError::NotConfigured {
by: SourceId::SUBSONIC,
..
}
));
}
/// THE BARCODE THAT BROKE FOUR ALBUMS AT ONCE (2026-08-22).
+14 -4
View File
@@ -161,6 +161,7 @@ async fn jellyfin_walks_from_a_sweep_to_real_audio() {
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(),
@@ -168,12 +169,19 @@ async fn jellyfin_walks_from_a_sweep_to_real_audio() {
sample_rate_hz: t.sample_rate_hz,
channels: t.channels,
bitrate_kbps: t.bitrate_bps.map(|b| b / 1000),
// The Local Library row mapper stamps `<albumId>/<tag>` so the tag
// has something to hang off — see `JellyfinSource::artwork_token`.
// Item art preserves a disc-specific cover. The album endpoint is
// still addressable without its optional cache-busting tag.
artwork_token: t
.album_image_tag
.item_image_tag
.as_ref()
.map(|tag| format!("{}/{}", t.album_id, tag)),
.map(|tag| format!("{}/{}", t.id, tag)),
collection_artwork_token: (!t.album_id.is_empty()).then(|| {
format!(
"{}/{}",
t.album_id,
t.album_image_tag.as_deref().unwrap_or_default()
)
}),
size_bytes: None,
})
.collect();
@@ -330,6 +338,7 @@ async fn subsonic_walks_from_a_sweep_to_real_audio() {
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(),
@@ -339,6 +348,7 @@ async fn subsonic_walks_from_a_sweep_to_real_audio() {
bitrate_kbps: t.bitrate_kbps,
// The OPAQUE coverArt id, verbatim.
artwork_token: t.cover_art.clone(),
collection_artwork_token: None,
size_bytes: t.size,
})
.collect();
+88 -24
View File
@@ -100,7 +100,10 @@ pub enum SubsonicError {
/// `code 70`.
NotFound(String),
/// Any other `<error code=…>` the server returned, WITH the 200 it came in.
Api { code: i64, message: String },
Api {
code: i64,
message: String,
},
/// The body was neither a JSON envelope nor an XML one this client
/// understands.
Decode(String),
@@ -332,6 +335,15 @@ pub struct MusicFolder {
pub name: String,
}
/// One album-list row. Its `coverArt` token is the collection cover and must
/// not be inferred from a song token: Navidrome can expose a separate cover
/// for every disc inside the same album.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubsonicAlbum {
pub id: String,
pub cover_art: Option<String>,
}
/// One song, flattened into the shape the cache stores. Field names are QBZ's.
#[derive(Debug, Clone, PartialEq)]
pub struct SubsonicTrack {
@@ -346,6 +358,9 @@ pub struct SubsonicTrack {
pub disc_number: Option<u32>,
pub duration_ms: u64,
pub year: Option<u32>,
/// The OpenSubsonic song payload currently exposes one genre. Keep it as
/// a collection so downstream models do not collapse multi-genre sources.
pub genres: Vec<String>,
pub genre: Option<String>,
/// `flac`, `mp3`, … as the server names it.
pub suffix: String,
@@ -412,6 +427,8 @@ impl From<SongDto> for SubsonicTrack {
fn from(s: SongDto) -> Self {
let artist = s.artist.unwrap_or_default();
let album_artist = s.display_album_artist.unwrap_or_else(|| artist.clone());
let genre = s.genre.filter(|genre| !genre.trim().is_empty());
let genres = genre.iter().cloned().collect();
SubsonicTrack {
id: s.id,
title: s.title,
@@ -427,7 +444,8 @@ impl From<SongDto> for SubsonicTrack {
disc_number: s.disc_number,
duration_ms: s.duration.unwrap_or(0) * 1000,
year: s.year,
genre: s.genre,
genre,
genres,
suffix: s.suffix.unwrap_or_default(),
content_type: s.content_type,
// 0 means "not applicable" on this wire, not "zero bits".
@@ -624,33 +642,25 @@ impl SubsonicClient {
songs_of(r.get("searchResult3"))
}
/// One page of album ids, for the PORTABLE sweep.
/// One page of albums, including their collection artwork tokens.
///
/// `size` is capped at 500 by the server whatever is asked, so this always
/// pages.
pub async fn album_ids(&self, offset: u32) -> Result<Vec<String>> {
pub async fn album_page(&self, offset: u32) -> Result<Vec<SubsonicAlbum>> {
let r = self
.get(
"getAlbumList2.view",
&format!("&type=alphabeticalByName&size={PAGE_SIZE}&offset={offset}"),
)
.await?;
let Some(albums) = r
.get("albumList2")
.and_then(|a| a.get("album"))
.and_then(|a| a.as_array())
else {
return Ok(Vec::new());
};
albums
.iter()
.map(|album| {
album
.get("id")
.map(json_id)
.ok_or_else(|| SubsonicError::Decode("album row has no id".to_string()))
})
.collect()
albums_of(r.get("albumList2"))
}
/// Compatibility projection for callers that only need identifiers.
pub async fn album_ids(&self, offset: u32) -> Result<Vec<String>> {
self.album_page(offset)
.await
.map(|albums| albums.into_iter().map(|album| album.id).collect())
}
/// The tracks of one album (`getAlbum.view`).
@@ -751,16 +761,46 @@ fn songs_of(container: Option<&serde_json::Value>) -> Result<Vec<SubsonicTrack>>
.collect()
}
fn albums_of(container: Option<&serde_json::Value>) -> Result<Vec<SubsonicAlbum>> {
let Some(albums) = container
.and_then(|album_list| album_list.get("album"))
.and_then(|albums| albums.as_array())
else {
return Ok(Vec::new());
};
albums
.iter()
.map(|album| {
let id = album
.get("id")
.map(json_id)
.ok_or_else(|| SubsonicError::Decode("album row has no id".to_string()))?;
let cover_art = album
.get("coverArt")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_string);
Ok(SubsonicAlbum { id, cover_art })
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_urls_get_a_scheme_and_the_rest_segment() {
assert_eq!(normalize_base_url("192.168.0.69:4533"), "http://192.168.0.69:4533/rest");
assert_eq!(
normalize_base_url("192.168.0.69:4533"),
"http://192.168.0.69:4533/rest"
);
assert_eq!(normalize_base_url("http://h:4533/"), "http://h:4533/rest");
// Already pointed at the API: not doubled.
assert_eq!(normalize_base_url("http://h:4533/rest"), "http://h:4533/rest");
assert_eq!(
normalize_base_url("http://h:4533/rest"),
"http://h:4533/rest"
);
assert_eq!(normalize_base_url(""), "");
}
@@ -823,7 +863,10 @@ mod tests {
let body = br#"{"subsonic-response":{"status":"failed","error":{"code":40,"message":"Wrong username or password"}}}"#;
assert_eq!(parse_envelope(body), Err(SubsonicError::Unauthorized));
let missing = br#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"Song not found"}}}"#;
assert!(matches!(parse_envelope(missing), Err(SubsonicError::NotFound(_))));
assert!(matches!(
parse_envelope(missing),
Err(SubsonicError::NotFound(_))
));
}
#[test]
@@ -857,6 +900,21 @@ mod tests {
// ── Mapping ────────────────────────────────────────────────────────────
#[test]
fn album_listing_keeps_collection_art_separate_from_song_art() {
let payload = serde_json::json!({
"album": [
{"id": "box", "coverArt": "al-box-cover"},
{"id": 42}
]
});
let albums = albums_of(Some(&payload)).unwrap();
assert_eq!(albums[0].id, "box");
assert_eq!(albums[0].cover_art.as_deref(), Some("al-box-cover"));
assert_eq!(albums[1].id, "42");
assert_eq!(albums[1].cover_art, None);
}
/// The measured hi-res row. `duration` is SECONDS on the wire and
/// milliseconds in QBZ, which is exactly the kind of unit slip that shows
/// up as a progress bar running 1000× too fast.
@@ -870,6 +928,7 @@ mod tests {
"albumId": "alb1",
"displayAlbumArtist": "Pink Floyd",
"track": 3, "discNumber": 1, "duration": 188, "year": 1967,
"genre": "Psychedelic Rock",
"suffix": "flac", "contentType": "audio/flac",
"bitDepth": 24, "samplingRate": 192000, "channelCount": 2,
"coverArt": "al-alb1_59fec8ff", "size": 137389889u64
@@ -880,6 +939,8 @@ mod tests {
assert_eq!(t.duration_ms, 188_000, "seconds were not converted to ms");
assert_eq!(t.cover_art.as_deref(), Some("al-alb1_59fec8ff"));
assert_eq!(t.size, Some(137_389_889));
assert_eq!(t.genres, ["Psychedelic Rock"]);
assert_eq!(t.genre.as_deref(), Some("Psychedelic Rock"));
}
/// The wire reports **0** for a lossy track's bit depth (Jellyfin reports
@@ -997,7 +1058,10 @@ mod tests {
let c = Credentials::new("admin", "pw", "abc");
let u = cover_url("http://h:4533", &c, "al-abc_1:2", IMAGE_PX);
assert!(u.contains("getCoverArt.view"));
assert!(u.contains("t="), "an unauthenticated cover request is refused");
assert!(
u.contains("t="),
"an unauthenticated cover request is refused"
);
assert!(u.contains("size=256"));
// The id is opaque and can contain `:` — it must be encoded, not pasted.
assert!(u.contains("id=al-abc_1%3A2"), "{u}");