1use rusqlite::Connection;
2
3use super::playback_state::PersistedQueueItem;
4
5#[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#[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
27pub 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
46pub 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
80pub 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
108pub 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()); assert!(load_snapshot(&conn, "doomed").unwrap().is_none());
215 }
216}