fix(qt): Date added sorts by real dates in the library, and playlist sorts order instead of reversing

Library > Releases (and All) "Date added" was position-based: every source
got a rank of index / len and the merge interleaved sources by that
relative rank, so a purchase from yesterday could sit behind favourites
from months ago. The favourites API carries favorited_at (never modelled)
and purchases purchased_at; local favourites keep their own stamp. Rows now
carry addedAt, the merge and release-sort.js order by it newest first, and
only undated rows fall back to the rank, always after the dated ones.

The playlist view's Default and Date added never sorted: they reversed
whatever order the rows were in (after a Title sort, a reversed title
order), which is why flipping the direction took two clicks and switching
fields kept the old order (#782). Rows now carry their loaded position —
Qobuz's insertion order is its added order — Default and Date added sort
by it, and the direction toggle reverses a canonical order.

cc
This commit is contained in:
vicrodh committed 2026-09-13 14:47:11 -06:00
1 parent 0674b2adeb
commit 83c12c62c5
5 files changed
+107 -20

No files matched your search

+10
View File
@@ -515,6 +515,9 @@ pub fn qobuz_cover_at_px(url: &str, px: u32) -> Option<String> {
pub struct Track {
#[serde(default)]
pub id: u64,
/// Favourites-list rows only (unix seconds); see `Album::favorited_at`.
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub favorited_at: Option<i64>,
#[serde(default)]
pub title: String,
/// Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix",
@@ -723,6 +726,10 @@ impl AlbumSummary {
pub struct Album {
#[serde(default)]
pub id: String,
/// When the user favourited it — `favorite/getUserFavorites` items carry
/// it (unix seconds); every other album shape leaves it `None`.
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub favorited_at: Option<i64>,
#[serde(default)]
pub title: String,
#[serde(default)]
@@ -902,6 +909,9 @@ pub struct TracksContainer {
pub struct Artist {
#[serde(default)]
pub id: u64,
/// Favourites-list rows only (unix seconds); see `Album::favorited_at`.
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub favorited_at: Option<i64>,
#[serde(default)]
pub name: String,
pub image: Option<ImageSet>,
+8 -1
View File
@@ -68,7 +68,14 @@ function compareItems(a, b, mode) {
var field = fieldOf(mode), asc = ascending(mode)
if (field === "release-date") return compareDates(a, b, !asc)
if (field === "date") {
// Existing library feed recency proxy: lower rank is more recent.
// The REAL date when the row has one (`addedAt`: the catalog's
// favorited_at, a purchase's purchased_at, a local favourite's own
// stamp); a dated row always precedes an undated one; undated rows
// fall back to the per-source recency proxy (lower rank = newer).
// Total and transitive, so Array.sort cannot scramble it.
var at = Number(a.addedAt || 0), bt = Number(b.addedAt || 0)
if (at && bt) return (bt - at) * (asc ? -1 : 1)
if (at || bt) return at ? -1 : 1
var ar = a.added_rank === undefined ? a._feedOrder : a.added_rank
var br = b.added_rank === undefined ? b._feedOrder : b.added_rank
return (Number(ar || 0) - Number(br || 0)) * (asc ? -1 : 1)
+59 -8
View File
@@ -198,6 +198,31 @@ pub struct FeedItem {
pub playlist_own_image: bool,
/// Recency proxy in [0,1]; 0 = most-recently added (per source).
pub added_rank: f32,
/// When the row entered the library, unix seconds: the catalog's
/// `favorited_at`, a purchase's `purchased_at`, a local favourite's own
/// stamp. 0 = unknown, and unknown sorts LAST under "Date added" — the
/// rank above only orders rows that have no date at all (2026-09-13).
#[serde(default, rename = "addedAt", skip_serializing_if = "is_zero_i64")]
pub added_at: i64,
}
fn is_zero_i64(value: &i64) -> bool {
*value == 0
}
/// "Date added" order for the merged feed: a dated row before an undated
/// one, dated rows newest first, undated rows by the per-source rank. Total
/// and transitive, so it is safe as a `sort_by` comparator.
pub(crate) fn recency_order(a: &FeedItem, b: &FeedItem) -> std::cmp::Ordering {
match (a.added_at > 0, b.added_at > 0) {
(true, true) => b.added_at.cmp(&a.added_at),
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(false, false) => a
.added_rank
.partial_cmp(&b.added_rank)
.unwrap_or(std::cmp::Ordering::Equal),
}
}
/// `skip_serializing_if` predicate for the default-false flags above.
@@ -1150,9 +1175,11 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
let unavailable_releases = unavailable_release_ids(runtime, &tracks).await;
let n = tracks.len();
for (i, track) in tracks.into_iter().enumerate() {
let favorited_at = track.favorited_at.unwrap_or(0);
let mut item = map_track(track);
item.release_unavailable = unavailable_releases.contains(&item.album_id);
item.added_rank = rank(i, n);
item.added_at = favorited_at;
feed.push(item);
}
let albums: Vec<Album> = parse_items(raw_albums, "album");
@@ -1160,15 +1187,19 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
let n = albums.len();
for (i, album) in albums.into_iter().enumerate() {
let ready = ready_album_tracks.get(&album.id).copied().unwrap_or(0);
let favorited_at = album.favorited_at.unwrap_or(0);
let mut item = map_album(album, ready);
item.added_rank = rank(i, n);
item.added_at = favorited_at;
feed.push(item);
}
let artists: Vec<Artist> = parse_items(raw_artists, "artist");
let n = artists.len();
for (i, item) in artists.into_iter().map(map_artist).enumerate() {
let mut item = item;
for (i, artist) in artists.into_iter().enumerate() {
let favorited_at = artist.favorited_at.unwrap_or(0);
let mut item = map_artist(artist);
item.added_rank = rank(i, n);
item.added_at = favorited_at;
feed.push(item);
}
let n = pl_favorites.len();
@@ -1297,6 +1328,7 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
is_favorite: true,
source_unavailable,
added_rank: rank(i, n),
added_at: lf.favorited_at,
id: lf.id,
title: lf.title,
..Default::default()
@@ -1306,12 +1338,10 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
}
}
// Merge by recency proxy (stable — equal ranks keep source order).
feed.sort_by(|a, b| {
a.added_rank
.partial_cmp(&b.added_rank)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Merge newest first by the REAL date where a row has one (favourites,
// purchases and local favourites all do), the per-source rank proxy for
// the rest (stable — equal keys keep source order).
feed.sort_by(recency_order);
let all_total = feed.len() as i64;
log::info!(
"[qbz-qt][perf] map+merge: {:?} ({} feed items)",
@@ -1630,6 +1660,7 @@ fn map_purchased_album(a: qbz_models::PurchaseAlbum) -> FeedItem {
release_sort_key: qbz_text_utils::dates::release_sort_key(
a.release_date_original.as_deref(),
),
added_at: a.purchased_at.unwrap_or(0),
label: a.label.as_ref().map(|label| label.name.clone()).unwrap_or_default(),
duration_secs: a.duration.unwrap_or(0),
track_count: a.tracks_count,
@@ -2859,6 +2890,26 @@ mod tests {
assert!(error.contains("repeated a page at offset 500"));
}
#[test]
fn favourite_albums_carry_their_added_date_and_dated_rows_sort_first() {
let album: Album = serde_json::from_value(serde_json::json!({
"id":"fav", "title":"Hearted", "favorited_at": 1_757_700_000
}))
.unwrap();
assert_eq!(album.favorited_at, Some(1_757_700_000));
let mut dated = map_album(album, 0);
dated.added_at = 1_757_700_000;
assert_eq!(serde_json::to_value(&dated).unwrap()["addedAt"], 1_757_700_000);
let mut older = dated.clone();
older.added_at = 1_700_000_000;
let mut undated = dated.clone();
undated.added_at = 0;
undated.added_rank = 0.0;
assert_eq!(recency_order(&dated, &older), std::cmp::Ordering::Less, "newest first");
assert_eq!(recency_order(&undated, &older), std::cmp::Ordering::Greater, "undated last");
assert!(serde_json::to_value(&undated).unwrap().get("addedAt").is_none());
}
#[test]
fn album_sort_key_prefers_original_over_digital_reissue_dates() {
let album: Album = serde_json::from_value(serde_json::json!({
+4
View File
@@ -806,6 +806,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
} => {
let art = artwork_path.clone().unwrap_or_default();
let row = PlaylistTrackRow {
position: 0,
featured: Vec::new(),
id: track_id.to_string(),
playlist_track_id: *track_id,
@@ -881,6 +882,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
)
.unwrap_or_default();
let row = PlaylistTrackRow {
position: 0,
featured: Vec::new(),
id: queue.id.to_string(),
playlist_track_id: queue.id,
@@ -932,6 +934,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
.unwrap_or_else(|| path.clone());
(
PlaylistTrackRow {
position: 0,
featured: Vec::new(),
id: path.clone(),
title: name,
@@ -945,6 +948,7 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option<QueueT
}
RowItem::Unresolved { kind, reference } => (
PlaylistTrackRow {
position: 0,
featured: Vec::new(),
id: format!("{kind}:{reference}"),
title: qbz_i18n::t("Unavailable track"),
+26 -11
View File
@@ -67,6 +67,10 @@ pub struct PlaylistTrackRow {
/// playlists — what remove_tracks_from_playlist takes).
#[serde(rename = "playlistTrackId")]
pub playlist_track_id: u64,
/// The row's slot in the LOADED order (the API's insertion order — what
/// "Default" restores and "Date added" reads, 2026-09-13). Not serialised.
#[serde(skip)]
pub position: i32,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
@@ -554,7 +558,7 @@ pub fn teardown() {
/// `Option`: `delete_by_id`'s ownership test would otherwise read
/// "unauthenticated" as "owns nothing", which is right, and "owns playlist 0",
/// which is not.
fn current_user_id() -> Option<u64> {
pub(crate) fn current_user_id() -> Option<u64> {
match USER_ID.load(std::sync::atomic::Ordering::SeqCst) {
0 => None,
id => Some(id),
@@ -865,6 +869,7 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow {
.map(|p| (p.name, p.id.to_string()))
.unwrap_or_default();
PlaylistTrackRow {
position: 0,
featured: crate::album_qt::featured_list(track.performers.as_deref(), &artist, &track.title, &[]),
// Heart state at build time, from the favourite-id cache — the same
// O(1) read `album_qt` / `artist_qt` / `label_qt` rows use. It was
@@ -1305,6 +1310,7 @@ pub async fn load(
doc.covers = covers;
doc.has_custom_cover = has_custom_cover;
doc.tracks = rows;
stamp_positions(&mut doc.tracks);
doc.track_count = track_count;
doc.total_duration = total_duration;
doc.is_owner = is_owner;
@@ -1373,6 +1379,13 @@ pub async fn load(
// Sort + search (PlaylistActions.set-sort / filter_tracks)
// ---------------------------------------------------------------------------
/// Record the loaded order once, before any sort touches the rows.
pub(crate) fn stamp_positions(rows: &mut [PlaylistTrackRow]) {
for (i, row) in rows.iter_mut().enumerate() {
row.position = i as i32;
}
}
fn apply_sort(doc: &mut PlaylistDoc, field: &str, asc: bool) {
doc.sort_field = field.to_string();
doc.sort_asc = asc;
@@ -1397,16 +1410,16 @@ fn apply_sort(doc: &mut PlaylistDoc, field: &str, asc: bool) {
// the interleave computed, which is the order the user actually chose.
"custom" if !doc.is_mixed => apply_custom_order(doc),
"custom" => {}
// "default" / "added": the API insertion order is the natural
// order; "added" starts newest-first (asc=false reverses).
_ => {}
// "default" / "added": the API insertion order IS the added order
// (a track is appended when it is added), so both sort by the slot
// stamped at load — an actual order, not a reversal of whatever the
// rows happened to be in after a Title sort (issue: the direction
// toggle needed two clicks and switching fields kept the old order).
_ => doc.tracks.sort_by_key(|t| t.position),
}
// Canonical ascending, then reverse for the other direction
// (library_all.rs derive; default/added keep model order).
if matches!(field, "title" | "artist" | "album" | "duration") && !asc {
doc.tracks.reverse();
}
if field == "added" && asc {
// Canonical ascending (oldest-added first for default/added), then
// reverse for the other direction. Custom keeps its stored order.
if field != "custom" && !asc {
doc.tracks.reverse();
}
}
@@ -1482,10 +1495,12 @@ pub fn set_sort(field: &str) {
let doc = with_doc(|d| {
// Re-pick flips direction for the sortable fields; a new field
// resets to its natural default (Library All parity).
// A new field starts at its natural direction: "added" newest
// first, everything else ascending.
let asc = if d.sort_field == field {
!d.sort_asc
} else {
!matches!(field, "added")
field != "added"
};
apply_sort(d, field, asc);
(d.clone(), d.is_local_playlist)