feat(slint): playlist snapshot (names+membership) and offline favorites rail
Qobuz playlist snapshot, captured opportunistically with zero new API traffic: every online sidebar/manager list load upserts id+name+owner+ track_count for the user's playlists; opening a playlist detail online full-replaces its membership track ids (refusing playlists never listed, so viewed public playlists don't land). Offline consumers: synthesized sidebar/manager entries resolve the persisted name (killing the cold-start 'Playlist (N local)' fallback when a snapshot exists), and the mixed- playlist offline detail now renders the snapshot tracks that are playable (snapshot intersect cached intersect grace) from the offline-cache index - snapshot block first in snapshot order, sidecar local/plex block after. D11 availability widens: a playlist with zero sidecar rows but cached snapshot tracks now shows offline. 4 repo tests. Offline favorites rail: the Favorites placeholder gains an 'Available offline (N)' block - disk-cached favorite ids intersected with the offline-cache READY index and LocalLibrary qobuz_download copies, compact rows with the offline cover chain, click plays the rail as a queue through the cache tier. Shared OfflinePlaceholder gains an @children slot; only the Favorites mount injects the rail. Single commit: the two features interleave in main.rs/state wiring and ship together to keep every commit compiling. cc
This commit is contained in:
17 files changed
+1154
-95
No files matched your search
@@ -52,6 +52,10 @@ impl LibraryDatabase {
|
||||
// same database file. Idempotent CREATE IF NOT EXISTS.
|
||||
crate::local_playlists::init_schema(&db.conn)
|
||||
.map_err(|e| LibraryError::Database(format!("local_playlists schema: {}", e)))?;
|
||||
// Qobuz playlist snapshot (offline-mode B7/B8) — names + membership
|
||||
// captured opportunistically while online. Idempotent.
|
||||
crate::qobuz_playlist_snapshot::init_schema(&db.conn)
|
||||
.map_err(|e| LibraryError::Database(format!("qobuz_playlist_snapshot schema: {}", e)))?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -4696,6 +4700,33 @@ impl LibraryDatabase {
|
||||
|
||||
// === Qobuz Downloads Integration ===
|
||||
|
||||
/// All offline-copy rows: `source = 'qobuz_download'` with a real Qobuz
|
||||
/// id — the same set the Local Library "Offline" source filter shows.
|
||||
/// Read-only; used by the offline favorites rail (B9) to find favorite
|
||||
/// tracks that are playable without Qobuz.
|
||||
pub fn get_qobuz_download_tracks(&self) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM local_tracks \
|
||||
WHERE source = 'qobuz_download' AND qobuz_track_id IS NOT NULL \
|
||||
ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, track_number",
|
||||
Self::TRACK_COLUMNS
|
||||
);
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| Self::row_to_track(row))
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
for track in rows {
|
||||
tracks.push(track.map_err(|e| LibraryError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Check if a track exists by Qobuz track ID
|
||||
pub fn track_exists_by_qobuz_id(&self, qobuz_track_id: u64) -> Result<bool, LibraryError> {
|
||||
let count: i64 = self
|
||||
|
||||
@@ -33,6 +33,7 @@ mod cue_parser;
|
||||
mod database;
|
||||
pub mod ephemeral;
|
||||
pub mod local_playlists;
|
||||
pub mod qobuz_playlist_snapshot;
|
||||
mod errors;
|
||||
mod metadata;
|
||||
mod models;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Local snapshot of the user's QOBUZ playlists (offline-mode port, B7/B8).
|
||||
//!
|
||||
//! Spec D11 left an HONEST LIMIT: playlist names and membership live only in
|
||||
//! the Qobuz API, so offline a mixed playlist falls back to a synthesized
|
||||
//! "Playlist (N local)" name and shows zero Qobuz rows. This module stores a
|
||||
//! point-in-time snapshot captured opportunistically from data the app
|
||||
//! ALREADY fetches while online (no new API traffic):
|
||||
//!
|
||||
//! - NAMES: every user-playlist list load (sidebar / playlist manager)
|
||||
//! upserts id + name (+ owner, track_count) for ALL listed playlists —
|
||||
//! cheap names-only rows, no track membership.
|
||||
//! - MEMBERSHIP: opening a playlist DETAIL online full-replaces its snapshot
|
||||
//! track ids (the detail fetch already returns the full track list).
|
||||
//! Membership is recorded ONLY for playlists already captured by the
|
||||
//! names producer (the user's own list) — a merely-viewed public playlist
|
||||
//! never lands in the snapshot, so the offline surfaces stay the user's.
|
||||
//!
|
||||
//! Rows are point-in-time: offline consumers show them as-is (no staleness
|
||||
//! UI in v1); `snapped_at` is stamped for the future.
|
||||
//!
|
||||
//! All functions take `&Connection` (the local_playlists idiom): no Tauri
|
||||
//! state, no async runtime — testable with in-memory SQLite.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Result};
|
||||
|
||||
/// One snapshot header row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SnapshotHeader {
|
||||
pub qobuz_playlist_id: u64,
|
||||
pub name: String,
|
||||
pub owner: Option<String>,
|
||||
/// The playlist's TOTAL Qobuz track count at snapshot time (not the
|
||||
/// offline-playable subset).
|
||||
pub track_count: Option<u32>,
|
||||
/// Unix ms when this header was last written.
|
||||
pub snapped_at: i64,
|
||||
}
|
||||
|
||||
/// Names-producer input (one listed playlist).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SnapshotNameEntry {
|
||||
pub qobuz_playlist_id: u64,
|
||||
pub name: String,
|
||||
pub owner: Option<String>,
|
||||
pub track_count: Option<u32>,
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// Create the snapshot tables. Idempotent (`IF NOT EXISTS`), run by
|
||||
/// `LibraryDatabase::open` next to the rest of the schema.
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS qobuz_playlist_snapshot (
|
||||
qobuz_playlist_id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner TEXT,
|
||||
track_count INTEGER,
|
||||
snapped_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qobuz_playlist_snapshot_tracks (
|
||||
qobuz_playlist_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
track_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qobuz_playlist_snapshot_tracks
|
||||
ON qobuz_playlist_snapshot_tracks(qobuz_playlist_id, position);
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
/// NAMES producer: upsert a header row for every listed playlist. Never
|
||||
/// touches the membership table. Stamps `snapped_at` = now on each row.
|
||||
pub fn upsert_names(conn: &Connection, entries: &[SnapshotNameEntry]) -> Result<()> {
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ts = now_ms();
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO qobuz_playlist_snapshot
|
||||
(qobuz_playlist_id, name, owner, track_count, snapped_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(qobuz_playlist_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
owner = excluded.owner,
|
||||
track_count = excluded.track_count,
|
||||
snapped_at = excluded.snapped_at",
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
e.qobuz_playlist_id as i64,
|
||||
e.name,
|
||||
e.owner,
|
||||
e.track_count,
|
||||
ts
|
||||
])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MEMBERSHIP producer: full-replace the snapshot track ids of ONE playlist
|
||||
/// (detail load) and refresh its header (name / owner / track_count /
|
||||
/// snapped_at). Returns `false` (writing NOTHING) when the playlist has no
|
||||
/// header row — i.e. it was never captured by the names producer, so it is
|
||||
/// not one of the user's listed playlists.
|
||||
pub fn replace_tracks(
|
||||
conn: &Connection,
|
||||
qobuz_playlist_id: u64,
|
||||
name: &str,
|
||||
owner: Option<&str>,
|
||||
track_ids: &[u64],
|
||||
) -> Result<bool> {
|
||||
let updated = conn.execute(
|
||||
"UPDATE qobuz_playlist_snapshot
|
||||
SET name = ?2, owner = ?3, track_count = ?4, snapped_at = ?5
|
||||
WHERE qobuz_playlist_id = ?1",
|
||||
params![
|
||||
qobuz_playlist_id as i64,
|
||||
name,
|
||||
owner,
|
||||
track_ids.len() as u32,
|
||||
now_ms()
|
||||
],
|
||||
)?;
|
||||
if updated == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM qobuz_playlist_snapshot_tracks WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO qobuz_playlist_snapshot_tracks (qobuz_playlist_id, position, track_id)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
)?;
|
||||
for (pos, tid) in track_ids.iter().enumerate() {
|
||||
stmt.execute(params![qobuz_playlist_id as i64, pos as i64, *tid as i64])?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn row_to_header(r: &rusqlite::Row) -> Result<SnapshotHeader> {
|
||||
Ok(SnapshotHeader {
|
||||
qobuz_playlist_id: r.get::<_, i64>("qobuz_playlist_id")? as u64,
|
||||
name: r.get("name")?,
|
||||
owner: r.get("owner")?,
|
||||
track_count: r.get("track_count")?,
|
||||
snapped_at: r.get("snapped_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// One snapshot header, or None.
|
||||
pub fn get_header(conn: &Connection, qobuz_playlist_id: u64) -> Result<Option<SnapshotHeader>> {
|
||||
conn.query_row(
|
||||
"SELECT qobuz_playlist_id, name, owner, track_count, snapped_at
|
||||
FROM qobuz_playlist_snapshot WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
row_to_header,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// All snapshot headers.
|
||||
pub fn all_headers(conn: &Connection) -> Result<Vec<SnapshotHeader>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT qobuz_playlist_id, name, owner, track_count, snapped_at
|
||||
FROM qobuz_playlist_snapshot",
|
||||
)?;
|
||||
let mut out = Vec::new();
|
||||
for r in stmt.query_map([], row_to_header)? {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One playlist's snapshot track ids in snapshot (position) order.
|
||||
pub fn track_ids(conn: &Connection, qobuz_playlist_id: u64) -> Result<Vec<u64>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT track_id FROM qobuz_playlist_snapshot_tracks
|
||||
WHERE qobuz_playlist_id = ?1 ORDER BY position",
|
||||
)?;
|
||||
let mut out = Vec::new();
|
||||
for r in stmt.query_map(params![qobuz_playlist_id as i64], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})? {
|
||||
out.push(r? as u64);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// playlist id -> snapshot track ids in position order, for every playlist
|
||||
/// with membership rows (availability intersection, B8).
|
||||
pub fn all_track_ids(conn: &Connection) -> Result<HashMap<u64, Vec<u64>>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT qobuz_playlist_id, track_id FROM qobuz_playlist_snapshot_tracks
|
||||
ORDER BY qobuz_playlist_id, position",
|
||||
)?;
|
||||
let mut out: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||
for r in stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as u64))
|
||||
})? {
|
||||
let (pid, tid) = r?;
|
||||
out.entry(pid).or_default().push(tid);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn conn() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
fn name_entry(id: u64, name: &str, count: u32) -> SnapshotNameEntry {
|
||||
SnapshotNameEntry {
|
||||
qobuz_playlist_id: id,
|
||||
name: name.to_string(),
|
||||
owner: Some("me".to_string()),
|
||||
track_count: Some(count),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_header_and_tracks() {
|
||||
let c = conn();
|
||||
upsert_names(&c, &[name_entry(42, "Road Trip", 3)]).unwrap();
|
||||
let wrote = replace_tracks(&c, 42, "Road Trip", Some("me"), &[30, 10, 20]).unwrap();
|
||||
assert!(wrote);
|
||||
|
||||
let h = get_header(&c, 42).unwrap().unwrap();
|
||||
assert_eq!(h.name, "Road Trip");
|
||||
assert_eq!(h.owner.as_deref(), Some("me"));
|
||||
assert_eq!(h.track_count, Some(3));
|
||||
assert!(h.snapped_at > 0);
|
||||
|
||||
// Snapshot order preserved, not sorted by id.
|
||||
assert_eq!(track_ids(&c, 42).unwrap(), vec![30, 10, 20]);
|
||||
let all = all_track_ids(&c).unwrap();
|
||||
assert_eq!(all.get(&42).unwrap(), &vec![30, 10, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_is_full_replace() {
|
||||
let c = conn();
|
||||
upsert_names(&c, &[name_entry(7, "Mix", 3)]).unwrap();
|
||||
replace_tracks(&c, 7, "Mix", None, &[1, 2, 3]).unwrap();
|
||||
replace_tracks(&c, 7, "Mix renamed", None, &[9]).unwrap();
|
||||
|
||||
assert_eq!(track_ids(&c, 7).unwrap(), vec![9]);
|
||||
let h = get_header(&c, 7).unwrap().unwrap();
|
||||
assert_eq!(h.name, "Mix renamed");
|
||||
assert_eq!(h.track_count, Some(1));
|
||||
// No leftover rows from the first write.
|
||||
let total: i64 = c
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM qobuz_playlist_snapshot_tracks",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(total, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_only_rows_without_tracks() {
|
||||
let c = conn();
|
||||
upsert_names(&c, &[name_entry(1, "A", 10), name_entry(2, "B", 0)]).unwrap();
|
||||
|
||||
assert_eq!(all_headers(&c).unwrap().len(), 2);
|
||||
assert!(track_ids(&c, 1).unwrap().is_empty());
|
||||
assert!(all_track_ids(&c).unwrap().is_empty());
|
||||
|
||||
// Re-upserting updates the name in place without creating track rows.
|
||||
upsert_names(&c, &[name_entry(1, "A renamed", 11)]).unwrap();
|
||||
let h = get_header(&c, 1).unwrap().unwrap();
|
||||
assert_eq!(h.name, "A renamed");
|
||||
assert_eq!(h.track_count, Some(11));
|
||||
assert!(track_ids(&c, 1).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_refuses_unknown_playlist() {
|
||||
let c = conn();
|
||||
// No names row -> the detail producer writes NOTHING (a merely
|
||||
// viewed public playlist must not land in the snapshot).
|
||||
let wrote = replace_tracks(&c, 99, "Someone's list", None, &[1, 2]).unwrap();
|
||||
assert!(!wrote);
|
||||
assert!(get_header(&c, 99).unwrap().is_none());
|
||||
assert!(track_ids(&c, 99).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user