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:
5 files changed
+107
-20
No files matched your search
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user