feat(qt): mixed playlists, page fade, category filter, per-context recents

Six parity fronts against the Slint/Tauri references, plus the applied-filters
tooltip the app never had.

MIXED PLAYLISTS — the bug. A mixed playlist is a QOBUZ playlist carrying local
and Plex rows through library.db's playlist_local_tracks / playlist_plex_tracks
sidecar tables. This port could WRITE those rows (the picker) and COUNT them
(the Playlist Manager badge), but playlist_qt::load built its rows exclusively
from the Qobuz API response, so a track added to a playlist never appeared on
the page it was added to. Two comments elsewhere claimed the merge already
happened; they were describing the reference. Ported literally:
read_sidecar_rows_blocking (healing + bulk Plex resolve) and interleave_rows
(absolute-slot claim, same-slot collisions all emit), merged in load, with the
merged queue adopted as the open-detail snapshot.

Routing had to split with it. local_playlist_qt::open_id() was the one
predicate for every action, and a mixed detail adopts that same snapshot — so
it now means "a local:<uuid> detail OR a mixed one", which is right for
play/shuffle/row-play and wrong for the writes. Remove and reorder route on the
new local_detail_open() instead; without that, removing a local row from a
Qobuz playlist wrote into the local-playlist repo, found nothing, and reported
success. Removal by source is a third arm: the Qobuz arm parses the display id
as a u64 playlist_track_id, and a local row's display id is a library.db rowid
in the same numeric space. Reorder is refused on a mixed detail for the same
collision, in Rust and in the view.

PAGE FADE. 300 ms OpacityAnimator on the mounted page, OutCubic, honouring
reduceMotion. Zero added latency: the router's Loader is synchronous, the UI
thread is frozen through the mount and no frame exists in which a fade-OUT
could be shown, so the page snaps transparent in the same turn as the rebuild
and only the reveal is animated. An Animator and not a NumberAnimation because
it runs on the render thread — measured 0.53 vs 0.11 of progress with the GUI
thread blocked — and not on the shared pulse because the pulse arrives on the
very thread the mount blocks.

The first cut faded a veil coloured from the pane, and that was wrong in a way
that only shows in the configuration the app actually runs in: AppShell paints
the pane surface-main@0.22 while the ambient background is on, so a veil at
full opacity covered 22% of the page and the fade read as a flicker. Fading the
page has no such failure mode and keeps the ambient art visible.

SPLASH COPY. The login screen was already 1:1 (18 msgids, verified by diff).
The splash was missing the legal disclaimer the reference pins to the bottom of
the screen, drew "QBZ" / "QOBUZ PLAYER" as bare literals (English in all eight
locales), and hardcoded the dark palette — black on the other 34 themes.

CATEGORY FILTER on the Home playlists rail. Slugs now travel per card and the
offered set comes from the same /discover/index response, so the filter is
client-side over the cached 40 with no extra request. The selection lives in
Rust because HomeView is destroyed on every navigation; the reference keeps its
own for the same reason. New trigger + popup, and a `leading` slot on
QbzSectionHeader to hang it off the title line.

APPLIED-FILTERS TOOLTIP. A third QbzTooltip mode taking [{group, values}],
below the control with an upward caret. Overflow is staged summary and never
scroll — the overlay is enabled:false by construction, so a scrollable tooltip
could not be scrolled. Wired where a filter is INVISIBLE (collapsed to a count
or a glyph): the genre chip in four hosts, the Home tags, the Local Library and
Purchases funnels, the Artist Scene genres.

RECENTS BY CONTEXT. QueueTrack already carried context_kind and exactly one
function threw it away: record_queue_track wrote both album histories on every
track start whatever the user had put on, so one 40-track playlist filled a
24-entry album rail with that playlist's contents. album_play_events gains an
additive context_kind column (the other frontend names its columns on insert,
so it writes '' and '' counts as an album — no history reinterpreted), plus a
new playlist_play_history.db and a "Recently Played Playlists" rail, default on.
A separate file rather than a field in recently_played.json, which is a
destructive round trip between frontends: serde drops what it does not know and
the next write puts the truncated object back.

ITEMS PER CAROUSEL, recovered from Tauri and never ported to Slint. It lives in
a NEW TABLE of discover_prefs.db, not in the prefs blob: to_json rebuilds the
object from the keys the running binary knows and save overwrites the whole
column, so a new key would survive only until the other build's next toggle.
Default is uncapped, i.e. today's behaviour.

Two DiscoverySectionId arms were added to the frozen Slint tree, obliged by the
shared enum's exhaustive matches — same two-line follow MostPlayedAlbums took.
Not verifiable here: compiling that crate pulls qbz-ui.

Also fixes two recommendations_qt tests that had been red since c02e7713d,
which turned the two Weekly rows into ordering slots that deliberately do not
self-hide while the assertions kept the pre-split behaviour. Verified by stash
that they fail without any of this change: the code was right, the tests stale.

Eight locales updated. Release build clean, offscreen smoke gate green,
qbz-qt 421/421 and qbz-app 215/215.

cc
This commit is contained in:
vicrodh committed 2026-08-19 07:00:35 -06:00
1 parent 047db8ea9f
commit 26ceeab828
41 files changed
+2896 -96

No files matched your search

@@ -52,7 +52,23 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
updated_at INTEGER NOT NULL
);
"#,
)
)?;
// PLAYBACK CONTEXT of the event — "album" | "playlist" | "" (unknown).
//
// Additive, and it has to be: this .db is SHARED with the Slint build,
// whose tree is frozen and whose INSERT names its columns explicitly
// (`INSERT INTO album_play_events (album_id, occurred_at)`), so an extra
// column with a DEFAULT is invisible to it and it simply writes `''`.
// Every legacy row is `''` too, and the ranking query treats `''` as
// "album" — so no history is lost or reinterpreted by this migration.
//
// SQLite has no `ADD COLUMN IF NOT EXISTS`; running it and swallowing the
// duplicate-column error is the idiom, and it is cheaper than a
// `PRAGMA table_info` round trip on every open.
let _ = conn.execute_batch(
"ALTER TABLE album_play_events ADD COLUMN context_kind TEXT NOT NULL DEFAULT '';",
);
Ok(())
}
fn open_db() -> Option<Connection> {
@@ -103,6 +119,11 @@ pub struct AlbumPlayMeta<'a> {
pub quality_label: &'a str,
pub year: &'a str,
pub source: &'a str,
/// What the user was PLAYING FROM when this event happened: `"album"`,
/// `"playlist"`, or `""` when nothing was stamped. NOT the same axis as
/// [`Self::source`], which is where the audio comes from (qobuz / local /
/// plex) — an album can be local AND played from a playlist.
pub context_kind: &'a str,
}
/// One ranked album for the "Most Played Albums" rail / View-all page.
@@ -124,8 +145,9 @@ pub struct AlbumPlayRow {
/// Internal so tests can drive it against an in-memory connection.
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
if let Err(e) = conn.execute(
"INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)",
params![m.album_id, now],
"INSERT INTO album_play_events (album_id, occurred_at, context_kind) \
VALUES (?, ?, ?)",
params![m.album_id, now, m.context_kind],
) {
log::warn!("[qbz-slint] album_play_history insert event failed: {e}");
}
@@ -174,6 +196,13 @@ fn query_on(conn: &Connection, limit: Option<u32>) -> Vec<AlbumPlayRow> {
JOIN (
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
FROM album_play_events
-- Only plays that were ABOUT the album. A playlist of 40 tracks
-- from 40 albums used to add one play to each of them, which is
-- how "Most Played Albums" came to be a list of whatever playlist
-- the user had on. `''` is a legacy row or one written by the
-- other frontend, and is counted — dropping it would erase
-- history that is almost entirely genuine album listening.
WHERE context_kind IN ('', 'album')
GROUP BY album_id
) p ON p.album_id = m.album_id
ORDER BY p.plays DESC, p.last_at DESC
@@ -249,6 +278,7 @@ mod tests {
quality_label: "Hi-Res",
year: "2024",
source: "qobuz",
context_kind: "album",
}
}
+103 -8
View File
@@ -88,6 +88,16 @@ pub enum DiscoverySectionId {
/// "Most Played Albums" — top albums by local play count
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
MostPlayedAlbums,
/// "Recently Played Playlists" — the playlists the user actually chose to
/// play, by last play (`qbz_app::settings::playlist_play_history`).
///
/// Its sibling `RecentlyPlayedAlbums` used to absorb these: every track
/// start wrote its ALBUM into the recent list whatever the user had put on,
/// so playing one 40-track playlist filled a 24-entry album rail with that
/// playlist's contents. Plays are separated by context now, and this is
/// where the playlist half surfaces. Home + For You, default ON — it is the
/// answer to a question the album rail was silently answering wrong.
RecentlyPlayedPlaylists,
}
impl DiscoverySectionId {
@@ -115,6 +125,7 @@ impl DiscoverySectionId {
ArtistSpotlight => "artistSpotlight",
Pinned => "pinned",
MostPlayedAlbums => "mostPlayedAlbums",
RecentlyPlayedPlaylists => "recentlyPlayedPlaylists",
}
}
@@ -142,6 +153,7 @@ impl DiscoverySectionId {
"artistSpotlight" => ArtistSpotlight,
"pinned" => Pinned,
"mostPlayedAlbums" => MostPlayedAlbums,
"recentlyPlayedPlaylists" => RecentlyPlayedPlaylists,
_ => return None,
})
}
@@ -192,6 +204,7 @@ pub fn default_prefs() -> DiscoverPrefs {
pref(PressAwards, true),
pref(QobuzPlaylists, true),
pref(RecentlyPlayedAlbums, true),
pref(RecentlyPlayedPlaylists, true),
pref(ContinueListening, true),
pref(IdealDiscography, true),
pref(MostStreamed, true),
@@ -223,6 +236,7 @@ pub fn default_prefs() -> DiscoverPrefs {
pref(RadioStations, true),
pref(ContinueListening, true),
pref(RecentlyPlayedAlbums, true),
pref(RecentlyPlayedPlaylists, true),
pref(TopArtists, true),
pref(FavoriteAlbums, true),
pref(SimilarAlbums, true),
@@ -418,6 +432,20 @@ pub fn reconcile_list(persisted: Option<&Vec<Value>>, fallback: &[SectionPref])
// SQLite store
// ---------------------------------------------------------------------------
/// The rail sizes the UI offers, IN THE ORDER THE UI OFFERS THEM — a selector
/// sends back an index into this array, so reordering it silently changes what
/// every existing user has selected. Append, never reorder.
///
/// `0` means "no cap" and is the DEFAULT on purpose: before this setting
/// existed the editorial rails were not truncated at all
/// (`home_qt::order_by_prefs` pushed every item through), so any other default
/// would shrink the Discover page of every user who upgrades without ever
/// asking them.
pub const RAIL_SIZE_PRESETS: [i64; 5] = [10, 15, 20, 25, 0];
/// The default rail size — `0`, i.e. today's uncapped behaviour.
pub const DEFAULT_RAIL_SIZE: i64 = 0;
pub struct DiscoverPrefsStore {
conn: Connection,
}
@@ -448,6 +476,36 @@ impl DiscoverPrefsStore {
)
.map_err(|e| format!("Failed to initialize discover prefs: {}", e))?;
// Rail size lives in its OWN TABLE, not as a key inside `prefs_json`,
// and that is a data-safety decision rather than a stylistic one.
//
// `DiscoverPrefs::to_json` rebuilds the blob FROM SCRATCH out of the
// keys the running binary knows, and `save` overwrites the whole
// column with it. This .db is SHARED with the Slint build, whose tree
// is frozen and will not be recompiled — so the installed Slint binary
// has the old `to_json` permanently. A new key inside the blob would
// therefore survive exactly until the user touched any toggle over
// there, and then vanish, with nothing logged anywhere.
//
// A separate table cannot be hit that way: the old binary never names
// it in any statement, and `CREATE TABLE IF NOT EXISTS` is idempotent
// in whichever build opens the file first. WAL + busy_timeout are
// already set above, so two binaries on one file is the existing
// design, not something this adds.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS discover_rail_size (
id INTEGER PRIMARY KEY CHECK (id = 1),
rail_size INTEGER NOT NULL
);",
)
.map_err(|e| format!("Failed to create discover rail size table: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO discover_rail_size (id, rail_size) VALUES (1, ?1)",
params![DEFAULT_RAIL_SIZE],
)
.map_err(|e| format!("Failed to initialize discover rail size: {}", e))?;
Ok(Self { conn })
}
@@ -480,6 +538,39 @@ impl DiscoverPrefsStore {
}
}
/// How many items each Discover rail may show. `0` = no cap.
///
/// Never an error to the caller: a missing row, an old file without the
/// table, or a value outside the presets all fall back to the default.
pub fn load_rail_size(&self) -> i64 {
self.conn
.query_row(
"SELECT rail_size FROM discover_rail_size WHERE id = 1",
[],
|row| row.get::<_, i64>(0),
)
.ok()
.filter(|n| RAIL_SIZE_PRESETS.contains(n))
.unwrap_or(DEFAULT_RAIL_SIZE)
}
/// Persist the rail size (upsert row 1). Values outside the presets are
/// rejected rather than stored, so `load_rail_size` and the UI's index can
/// never disagree about what is selected.
pub fn save_rail_size(&self, n: i64) -> Result<(), String> {
if !RAIL_SIZE_PRESETS.contains(&n) {
return Err(format!("rail size {n} is not one of {RAIL_SIZE_PRESETS:?}"));
}
self.conn
.execute(
"INSERT INTO discover_rail_size (id, rail_size) VALUES (1, ?1)
ON CONFLICT(id) DO UPDATE SET rail_size = excluded.rail_size",
params![n],
)
.map_err(|e| format!("Failed to save discover rail size: {}", e))?;
Ok(())
}
/// Persist the whole prefs blob (upsert row 1).
pub fn save(&self, prefs: &DiscoverPrefs) -> Result<(), String> {
self.conn
@@ -525,18 +616,20 @@ mod tests {
#[test]
fn defaults_match_spec_exactly() {
let d = default_prefs();
// home: 15 entries, first 8 ON (Tauri sectionPrefs.ts + Slint `pinned`
// + the local mostPlayedAlbums, default off).
// home: 16 entries, first 9 ON (Tauri sectionPrefs.ts + Slint `pinned`
// + the local mostPlayedAlbums, default off, + recentlyPlayedPlaylists
// sitting right after its album sibling, default on).
assert_eq!(
ids(&d.home),
vec![
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
RecentlyPlayedPlaylists,
ContinueListening, IdealDiscography, MostStreamed, Pinned,
QobuzMixes, ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists,
FavoriteAlbums, MostPlayedAlbums,
]
);
assert_eq!(d.enabled_count(DiscoveryTab::Home), 8);
assert_eq!(d.enabled_count(DiscoveryTab::Home), 9);
assert!(d.is_enabled(DiscoveryTab::Home, MostStreamed));
assert!(!d.is_enabled(DiscoveryTab::Home, Qobuzissimes));
// editorPicks: 7 entries, all ON.
@@ -545,13 +638,15 @@ mod tests {
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
);
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
// forYou: 14 entries, qobuzMixes first, pinned second; the 13
// Tauri+Slint ones ON, mostPlayedAlbums (local addition) OFF.
assert_eq!(d.for_you.len(), 14);
// forYou: 15 entries, qobuzMixes first, pinned second; the 13
// Tauri+Slint ones plus recentlyPlayedPlaylists ON, mostPlayedAlbums
// (local addition) OFF.
assert_eq!(d.for_you.len(), 15);
assert_eq!(d.for_you[0].id, QobuzMixes);
assert_eq!(d.for_you[1].id, Pinned);
assert_eq!(d.for_you[13].id, MostPlayedAlbums);
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 13);
assert_eq!(d.for_you[6].id, RecentlyPlayedPlaylists);
assert_eq!(d.for_you[14].id, MostPlayedAlbums);
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 14);
}
// --- Group 2: reconcile_list ---
+1
View File
@@ -10,6 +10,7 @@ pub mod local_favorites;
pub mod payload_cache;
pub mod pinned_items;
pub mod playback;
pub mod playlist_play_history;
pub mod plex;
pub mod album_play_history;
pub mod reco_store;
@@ -0,0 +1,371 @@
//! Local PLAYLIST play history — source of truth for the "Recently Played
//! Playlists" rail.
//!
//! The structural twin of [`crate::settings::album_play_history`], and it
//! exists because that module answered the wrong question. Playing a playlist
//! of 40 tracks drawn from 40 albums used to write 40 ALBUM plays, so
//! "Recently Played" became a list of whatever playlist was on and the
//! playlist itself was recorded nowhere. Contexts are now separated at the
//! write edge (`recently_qt::record_queue_track` reads `QueueTrack
//! ::context_kind`), and this is where the playlist half lands.
//!
//! # Why a NEW file and not a column somewhere
//!
//! `recently_played.json` is a DESTRUCTIVE round trip between the two
//! frontends: the Slint build deserializes it into its own structs, serde
//! drops the fields it does not know, and its next write puts the truncated
//! object back. Anything Qt added there would disappear the first time the
//! other build played a track, intermittently and with nothing logged. A .db
//! the frozen tree has never heard of cannot be destroyed that way — it never
//! opens it.
//!
//! One event per TRACK-START, exactly like the album store (see its header):
//! the de-dup for "recently played" happens in the `GROUP BY`, so listening to
//! forty tracks of one playlist yields ONE card with `plays = 40`.
//!
//! SQLite is opened lazily and every read/write swallows its errors into a
//! `log::warn!`; a fresh user with no .db yet gets an empty list, so the rail
//! self-hides.
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection};
static DB: OnceLock<Mutex<Option<Connection>>> = OnceLock::new();
fn db_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("playlist_play_history.db"))
}
/// Create the tables + index on a fresh connection (shared by the lazy opener
/// and the in-memory test connections).
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS playlist_play_events (
playlist_id TEXT NOT NULL,
occurred_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS playlist_play_events_pl
ON playlist_play_events(playlist_id);
CREATE TABLE IF NOT EXISTS playlist_meta (
playlist_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
owner_id TEXT NOT NULL DEFAULT '',
artwork_url TEXT NOT NULL DEFAULT '',
track_count INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
"#,
)
}
fn open_db() -> Option<Connection> {
let path = db_path()?;
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = match Connection::open(&path) {
Ok(c) => c,
Err(e) => {
log::warn!("[qbz] playlist_play_history open failed: {e}");
return None;
}
};
// ADR-002: WAL, so a read from the render path never blocks on a write.
let _ = conn.execute_batch(
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;",
);
if let Err(e) = init_schema(&conn) {
log::warn!("[qbz] playlist_play_history schema failed: {e}");
return None;
}
Some(conn)
}
fn with_db<T, F>(f: F) -> Option<T>
where
F: FnOnce(&Connection) -> Option<T>,
{
let cell = DB.get_or_init(|| Mutex::new(open_db()));
let guard = cell.lock().ok()?;
let conn = guard.as_ref()?;
f(conn)
}
/// Playlist metadata captured at play time (refreshed on every play, so
/// renames and cover changes converge).
pub struct PlaylistPlayMeta<'a> {
/// The Qobuz numeric id as text, or `"local:<uuid>"` for a first-class
/// local playlist. It is whatever `PlayContext::playlist(..)` stamped, so
/// the event and the card can never key differently.
pub playlist_id: &'a str,
pub title: &'a str,
pub owner: &'a str,
pub owner_id: &'a str,
pub artwork_url: &'a str,
pub track_count: u32,
/// `"qobuz"` | `"local"` — where the playlist itself lives, NOT where its
/// audio comes from (a Qobuz playlist can hold local rows).
pub source: &'a str,
}
/// One playlist row for the rail / a View-all page.
#[derive(Clone, Default, Debug, PartialEq)]
pub struct PlaylistPlayRow {
pub playlist_id: String,
pub title: String,
pub owner: String,
pub owner_id: String,
pub artwork_url: String,
pub track_count: u32,
pub source: String,
pub plays: u32,
}
/// Upsert the meta WITHOUT recording a play.
///
/// The two halves are separate on purpose. The meta is known by the view that
/// starts playback (it has the header in hand); the event is written at the
/// track-start edge, which only ever sees a `QueueTrack` and its
/// `context_id`. Splitting them keeps the edge from having to resolve a
/// playlist id back into a title on every single track change.
fn upsert_meta_on(conn: &Connection, m: &PlaylistPlayMeta, now: i64) {
if let Err(e) = conn.execute(
r#"
INSERT INTO playlist_meta
(playlist_id, title, owner, owner_id, artwork_url,
track_count, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(playlist_id) DO UPDATE SET
title = excluded.title,
owner = excluded.owner,
owner_id = excluded.owner_id,
artwork_url = excluded.artwork_url,
track_count = excluded.track_count,
source = excluded.source,
updated_at = excluded.updated_at
"#,
params![
m.playlist_id,
m.title,
m.owner,
m.owner_id,
m.artwork_url,
m.track_count,
m.source,
now
],
) {
log::warn!("[qbz] playlist_play_history upsert meta failed: {e}");
}
}
fn record_event_on(conn: &Connection, playlist_id: &str, now: i64) {
if let Err(e) = conn.execute(
"INSERT INTO playlist_play_events (playlist_id, occurred_at) VALUES (?, ?)",
params![playlist_id, now],
) {
log::warn!("[qbz] playlist_play_history insert event failed: {e}");
}
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Remember what a playlist IS, so a later play event can render a card.
/// Called by the views that start playback from a playlist. No-op on an empty
/// id.
pub fn record_playlist_meta(m: PlaylistPlayMeta) {
if m.playlist_id.is_empty() {
return;
}
let now = now_secs();
with_db(|conn| {
upsert_meta_on(conn, &m, now);
Some(())
});
}
/// Record one play event. Called at the track-start edge when the track's
/// playback context is a playlist. No-op on an empty id.
pub fn record_playlist_play(playlist_id: &str) {
if playlist_id.is_empty() {
return;
}
let now = now_secs();
with_db(|conn| {
record_event_on(conn, playlist_id, now);
Some(())
});
}
/// `order_desc` is the ORDER BY body — the only difference between "recently
/// played" and "most played", which is why both come out of one query.
fn query_on(conn: &Connection, order: &str, limit: Option<u32>) -> Vec<PlaylistPlayRow> {
let sql = format!(
r#"
SELECT m.playlist_id, m.title, m.owner, m.owner_id, m.artwork_url,
m.track_count, m.source, p.plays
FROM playlist_meta m
JOIN (
SELECT playlist_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
FROM playlist_play_events
GROUP BY playlist_id
) p ON p.playlist_id = m.playlist_id
ORDER BY {order}
{}
"#,
limit.map(|n| format!("LIMIT {n}")).unwrap_or_default()
);
let out = (|| -> Option<Vec<PlaylistPlayRow>> {
let mut stmt = conn.prepare(&sql).ok()?;
let rows = stmt
.query_map([], |row| {
Ok(PlaylistPlayRow {
playlist_id: row.get(0)?,
title: row.get(1)?,
owner: row.get(2)?,
owner_id: row.get(3)?,
artwork_url: row.get(4)?,
track_count: row.get::<_, i64>(5)? as u32,
source: row.get(6)?,
plays: row.get::<_, i64>(7)? as u32,
})
})
.ok()?;
Some(rows.flatten().collect())
})();
out.unwrap_or_default()
}
/// The `limit` most recently played playlists (the rail).
pub fn recent_playlists(limit: u32) -> Vec<PlaylistPlayRow> {
with_db(|conn| Some(query_on(conn, "p.last_at DESC", Some(limit)))).unwrap_or_default()
}
/// Every played playlist, most recent first (a "View all" page).
pub fn all_recent_playlists() -> Vec<PlaylistPlayRow> {
with_db(|conn| Some(query_on(conn, "p.last_at DESC", None))).unwrap_or_default()
}
/// Ranked by play count. Not wired to a rail — the owner asked for one rail,
/// and this falls out of the same table for free when a second one is wanted.
#[allow(dead_code)]
pub fn top_playlists(limit: u32) -> Vec<PlaylistPlayRow> {
with_db(|conn| Some(query_on(conn, "p.plays DESC, p.last_at DESC", Some(limit))))
.unwrap_or_default()
}
/// Drop every trace of the given playlists — the parity of
/// `recently_qt::prune_albums`, for a playlist the user deleted or unfollowed.
/// Returns how many meta rows went.
pub fn prune_playlists(ids: &[String]) -> usize {
if ids.is_empty() {
return 0;
}
with_db(|conn| {
let mut gone = 0usize;
for id in ids {
let _ = conn.execute(
"DELETE FROM playlist_play_events WHERE playlist_id = ?",
params![id],
);
gone += conn
.execute("DELETE FROM playlist_meta WHERE playlist_id = ?", params![id])
.unwrap_or(0);
}
Some(gone)
})
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn meta<'a>(id: &'a str, title: &'a str) -> PlaylistPlayMeta<'a> {
PlaylistPlayMeta {
playlist_id: id,
title,
owner: "Someone",
owner_id: "42",
artwork_url: "http://art",
track_count: 12,
source: "qobuz",
}
}
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init_schema(&c).unwrap();
c
}
#[test]
fn recent_orders_by_last_play_not_by_count() {
let c = mem();
// A is played far more, B far more RECENTLY. "Recently played" must
// put B first — that is the whole difference from "most played".
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
upsert_meta_on(&c, &meta("B", "Playlist B"), 100);
for i in 0..30 {
record_event_on(&c, "A", 100 + i);
}
record_event_on(&c, "B", 9_000);
let recent = query_on(&c, "p.last_at DESC", Some(10));
assert_eq!(recent.len(), 2);
assert_eq!(recent[0].playlist_id, "B");
assert_eq!(recent[1].playlist_id, "A");
assert_eq!(recent[1].plays, 30);
let top = query_on(&c, "p.plays DESC, p.last_at DESC", Some(10));
assert_eq!(top[0].playlist_id, "A");
}
#[test]
fn one_card_per_playlist_however_many_tracks() {
let c = mem();
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
for i in 0..40 {
record_event_on(&c, "A", 100 + i);
}
let recent = query_on(&c, "p.last_at DESC", None);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].plays, 40);
}
#[test]
fn meta_without_a_play_does_not_show() {
// The JOIN is what enforces it: opening a playlist upserts nothing on
// its own, and even if it did, a card with no play is not "recently
// played".
let c = mem();
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
assert!(query_on(&c, "p.last_at DESC", None).is_empty());
}
#[test]
fn prune_removes_events_and_meta() {
let c = mem();
upsert_meta_on(&c, &meta("A", "Playlist A"), 100);
upsert_meta_on(&c, &meta("B", "Playlist B"), 100);
record_event_on(&c, "A", 100);
record_event_on(&c, "B", 100);
let _ = c.execute("DELETE FROM playlist_play_events WHERE playlist_id = 'A'", []);
let _ = c.execute("DELETE FROM playlist_meta WHERE playlist_id = 'A'", []);
let left = query_on(&c, "p.last_at DESC", None);
assert_eq!(left.len(), 1);
assert_eq!(left[0].playlist_id, "B");
}
}