Skip to main content

koan_core/db/queries/
snapshots.rs

1use rusqlite::Connection;
2
3use super::playback_state::PersistedQueueItem;
4
5/// A named queue snapshot.
6#[derive(Debug, Clone)]
7pub struct QueueSnapshot {
8    pub id: i64,
9    pub name: String,
10    pub items: Vec<PersistedQueueItem>,
11    pub cursor_path: Option<String>,
12    pub position_ms: u64,
13    pub created_at: String,
14}
15
16/// Summary for listing (without deserializing queue_json).
17#[derive(Debug, Clone)]
18pub struct QueueSnapshotSummary {
19    pub id: i64,
20    pub name: String,
21    pub track_count: usize,
22    pub cursor_path: Option<String>,
23    pub position_ms: u64,
24    pub created_at: String,
25}
26
27/// Save the current queue as a named snapshot. Overwrites if name exists.
28pub fn save_snapshot(
29    conn: &Connection,
30    name: &str,
31    items: &[PersistedQueueItem],
32    cursor_path: Option<&str>,
33    position_ms: u64,
34) -> rusqlite::Result<i64> {
35    let json = serde_json::to_string(items).unwrap_or_else(|_| "[]".into());
36    conn.execute(
37        "INSERT INTO queue_snapshots (name, queue_json, cursor_path, position_ms, created_at)
38         VALUES (?1, ?2, ?3, ?4, datetime('now'))
39         ON CONFLICT(name) DO UPDATE SET
40           queue_json = ?2, cursor_path = ?3, position_ms = ?4, created_at = datetime('now')",
41        rusqlite::params![name, json, cursor_path, position_ms as i64],
42    )?;
43    Ok(conn.last_insert_rowid())
44}
45
46/// Load a named snapshot. Returns None if not found.
47pub fn load_snapshot(conn: &Connection, name: &str) -> rusqlite::Result<Option<QueueSnapshot>> {
48    let result = conn.query_row(
49        "SELECT id, name, queue_json, cursor_path, position_ms, created_at
50         FROM queue_snapshots WHERE name = ?1",
51        [name],
52        |row| {
53            let id: i64 = row.get(0)?;
54            let name: String = row.get(1)?;
55            let json: String = row.get(2)?;
56            let cursor_path: Option<String> = row.get(3)?;
57            let position_ms: i64 = row.get(4)?;
58            let created_at: String = row.get(5)?;
59            Ok((id, name, json, cursor_path, position_ms as u64, created_at))
60        },
61    );
62
63    match result {
64        Ok((id, name, json, cursor_path, position_ms, created_at)) => {
65            let items: Vec<PersistedQueueItem> = serde_json::from_str(&json).unwrap_or_default();
66            Ok(Some(QueueSnapshot {
67                id,
68                name,
69                items,
70                cursor_path,
71                position_ms,
72                created_at,
73            }))
74        }
75        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
76        Err(e) => Err(e),
77    }
78}
79
80/// List all snapshots (summary only — doesn't deserialize queue_json).
81pub fn list_snapshots(conn: &Connection) -> rusqlite::Result<Vec<QueueSnapshotSummary>> {
82    let mut stmt = conn.prepare(
83        "SELECT id, name, queue_json, cursor_path, position_ms, created_at
84         FROM queue_snapshots ORDER BY created_at DESC",
85    )?;
86    let rows = stmt.query_map([], |row| {
87        let id: i64 = row.get(0)?;
88        let name: String = row.get(1)?;
89        let json: String = row.get(2)?;
90        let cursor_path: Option<String> = row.get(3)?;
91        let position_ms: i64 = row.get(4)?;
92        let created_at: String = row.get(5)?;
93        let track_count: usize = serde_json::from_str::<Vec<serde_json::Value>>(&json)
94            .map(|v| v.len())
95            .unwrap_or(0);
96        Ok(QueueSnapshotSummary {
97            id,
98            name,
99            track_count,
100            cursor_path,
101            position_ms: position_ms as u64,
102            created_at,
103        })
104    })?;
105    rows.collect()
106}
107
108/// Delete a named snapshot. Returns true if a row was deleted.
109pub fn delete_snapshot(conn: &Connection, name: &str) -> rusqlite::Result<bool> {
110    let deleted = conn.execute("DELETE FROM queue_snapshots WHERE name = ?1", [name])?;
111    Ok(deleted > 0)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn test_conn() -> Connection {
119        let conn = Connection::open_in_memory().unwrap();
120        conn.pragma_update(None, "foreign_keys", "on").unwrap();
121        crate::db::schema::create_tables(&conn).unwrap();
122        conn
123    }
124
125    fn sample_items() -> Vec<PersistedQueueItem> {
126        vec![
127            PersistedQueueItem {
128                path: "/music/track1.flac".into(),
129                title: "Track 1".into(),
130                artist: "Artist".into(),
131                album_artist: "Artist".into(),
132                album: "Album".into(),
133                year: Some("2024".into()),
134                codec: Some("FLAC".into()),
135                track_number: Some(1),
136                disc: Some(1),
137                duration_ms: Some(240_000),
138                db_id: None,
139            },
140            PersistedQueueItem {
141                path: "/music/track2.flac".into(),
142                title: "Track 2".into(),
143                artist: "Artist".into(),
144                album_artist: "Artist".into(),
145                album: "Album".into(),
146                year: Some("2024".into()),
147                codec: Some("FLAC".into()),
148                track_number: Some(2),
149                disc: Some(1),
150                duration_ms: Some(180_000),
151                db_id: None,
152            },
153        ]
154    }
155
156    #[test]
157    fn save_and_load_snapshot() {
158        let conn = test_conn();
159        let items = sample_items();
160
161        save_snapshot(&conn, "techno", &items, Some("/music/track1.flac"), 42_000).unwrap();
162
163        let snap = load_snapshot(&conn, "techno").unwrap().unwrap();
164        assert_eq!(snap.name, "techno");
165        assert_eq!(snap.items.len(), 2);
166        assert_eq!(snap.items[0].title, "Track 1");
167        assert_eq!(snap.cursor_path.as_deref(), Some("/music/track1.flac"));
168        assert_eq!(snap.position_ms, 42_000);
169    }
170
171    #[test]
172    fn save_overwrites_existing() {
173        let conn = test_conn();
174        let items = sample_items();
175
176        save_snapshot(&conn, "mix", &items, None, 0).unwrap();
177        save_snapshot(&conn, "mix", &items[..1], Some("/music/track1.flac"), 999).unwrap();
178
179        let snap = load_snapshot(&conn, "mix").unwrap().unwrap();
180        assert_eq!(snap.items.len(), 1);
181        assert_eq!(snap.position_ms, 999);
182    }
183
184    #[test]
185    fn load_nonexistent_returns_none() {
186        let conn = test_conn();
187        assert!(load_snapshot(&conn, "nope").unwrap().is_none());
188    }
189
190    #[test]
191    fn list_snapshots_returns_all() {
192        let conn = test_conn();
193        let items = sample_items();
194
195        save_snapshot(&conn, "first", &items, None, 0).unwrap();
196        save_snapshot(&conn, "second", &items[..1], None, 100).unwrap();
197
198        let list = list_snapshots(&conn).unwrap();
199        assert_eq!(list.len(), 2);
200        let names: Vec<&str> = list.iter().map(|s| s.name.as_str()).collect();
201        assert!(names.contains(&"first"));
202        assert!(names.contains(&"second"));
203        let second = list.iter().find(|s| s.name == "second").unwrap();
204        assert_eq!(second.track_count, 1);
205    }
206
207    #[test]
208    fn delete_snapshot_works() {
209        let conn = test_conn();
210        save_snapshot(&conn, "doomed", &sample_items(), None, 0).unwrap();
211
212        assert!(delete_snapshot(&conn, "doomed").unwrap());
213        assert!(!delete_snapshot(&conn, "doomed").unwrap()); // already gone
214        assert!(load_snapshot(&conn, "doomed").unwrap().is_none());
215    }
216}