Skip to main content

koan_core/db/queries/
play_queues.rs

1use rusqlite::{Connection, OptionalExtension, params};
2
3// A play queue a Subsonic client saved for its account, to resume on another
4// device or after a restart: one per account. kōan's own apps keep theirs on
5// the device and move it between devices over the link; they neither read
6// nor write this.
7//
8// Its entries are rows that name tracks, so a track merge moves them and a
9// track removed from the library takes its entries with it. The current entry
10// is held by its place in the order as saved, so entries removed before it do
11// not move it onto another track; when it is removed itself, the next one that
12// remains is current, or the first.
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct PlayQueue {
16    /// The queue's tracks, in order. A track may appear more than once.
17    pub track_ids: Vec<i64>,
18    /// Index into `track_ids` of the current entry.
19    pub current: usize,
20    pub position_ms: i64,
21    /// Seconds since the epoch.
22    pub changed_at: i64,
23    /// The client that saved it, as it named itself.
24    pub changed_by: String,
25}
26
27fn now() -> i64 {
28    std::time::SystemTime::now()
29        .duration_since(std::time::UNIX_EPOCH)
30        .unwrap_or_default()
31        .as_secs() as i64
32}
33
34/// Replace `user`'s play queue. `current` indexes `track_ids`; an empty list
35/// clears the queue.
36pub fn save_play_queue(
37    conn: &Connection,
38    user: i64,
39    track_ids: &[i64],
40    current: Option<usize>,
41    position_ms: i64,
42    changed_by: &str,
43) -> rusqlite::Result<()> {
44    let tx = conn.unchecked_transaction()?;
45    tx.prepare_cached("DELETE FROM play_queue_entries WHERE user_id = ?1")?
46        .execute([user])?;
47    if track_ids.is_empty() {
48        tx.prepare_cached("DELETE FROM play_queues WHERE user_id = ?1")?
49            .execute([user])?;
50        return tx.commit();
51    }
52    {
53        let mut insert = tx.prepare_cached(
54            "INSERT INTO play_queue_entries (user_id, position, track_id) VALUES (?1, ?2, ?3)",
55        )?;
56        for (position, track) in track_ids.iter().enumerate() {
57            insert.execute(params![user, position as i64, track])?;
58        }
59    }
60    tx.prepare_cached(
61        "INSERT INTO play_queues (user_id, current_position, position_ms, changed_at, changed_by)
62         VALUES (?1, ?2, ?3, ?4, ?5)
63         ON CONFLICT (user_id) DO UPDATE SET
64             current_position = excluded.current_position,
65             position_ms = excluded.position_ms,
66             changed_at = excluded.changed_at,
67             changed_by = excluded.changed_by",
68    )?
69    .execute(params![
70        user,
71        current.map(|c| c as i64),
72        position_ms.max(0),
73        now(),
74        changed_by
75    ])?;
76    tx.commit()
77}
78
79/// `user`'s play queue, as far as its tracks are still in the library.
80/// `None` when none was saved, or every track has gone.
81pub fn play_queue(conn: &Connection, user: i64) -> rusqlite::Result<Option<PlayQueue>> {
82    let Some((current_position, position_ms, changed_at, changed_by)) = conn
83        .prepare_cached(
84            "SELECT current_position, position_ms, changed_at, changed_by
85             FROM play_queues WHERE user_id = ?1",
86        )?
87        .query_row([user], |r| {
88            Ok((
89                r.get::<_, Option<i64>>(0)?,
90                r.get::<_, i64>(1)?,
91                r.get::<_, i64>(2)?,
92                r.get::<_, String>(3)?,
93            ))
94        })
95        .optional()?
96    else {
97        return Ok(None);
98    };
99    let entries: Vec<(i64, i64)> = conn
100        .prepare_cached(
101            "SELECT position, track_id FROM play_queue_entries
102             WHERE user_id = ?1 ORDER BY position",
103        )?
104        .query_map([user], |r| Ok((r.get(0)?, r.get(1)?)))?
105        .collect::<rusqlite::Result<_>>()?;
106    if entries.is_empty() {
107        return Ok(None);
108    }
109    Ok(Some(PlayQueue {
110        current: current_position
111            .and_then(|at| entries.iter().position(|(position, _)| *position >= at))
112            .unwrap_or(0),
113        track_ids: entries.into_iter().map(|(_, track)| track).collect(),
114        position_ms,
115        changed_at,
116        changed_by,
117    }))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    const ALICE: i64 = 7;
125
126    /// A library of three tracks, ids 1 to 3.
127    fn conn() -> Connection {
128        let conn = Connection::open_in_memory().unwrap();
129        conn.pragma_update(None, "foreign_keys", "on").unwrap();
130        crate::db::schema::create_tables(&conn).unwrap();
131        conn.execute_batch("INSERT INTO tracks (id, title) VALUES (1, 'a'), (2, 'b'), (3, 'c');")
132            .unwrap();
133        conn
134    }
135
136    #[test]
137    fn a_queue_saves_and_reads_back_with_its_repeats() {
138        let conn = &conn();
139        save_play_queue(conn, ALICE, &[1, 2, 1], Some(2), 1500, "Feishin").unwrap();
140        let q = play_queue(conn, ALICE).unwrap().unwrap();
141        assert_eq!(q.track_ids, [1, 2, 1]);
142        assert_eq!(q.current, 2, "the second 1, not the first");
143        assert_eq!((q.position_ms, q.changed_by.as_str()), (1500, "Feishin"));
144        assert_eq!(
145            play_queue(conn, ALICE + 1).unwrap(),
146            None,
147            "one per account"
148        );
149
150        save_play_queue(conn, ALICE, &[], None, 0, "Feishin").unwrap();
151        assert_eq!(play_queue(conn, ALICE).unwrap(), None);
152    }
153
154    #[test]
155    fn a_track_gone_from_the_library_drops_out_and_the_current_one_holds() {
156        let conn = &conn();
157        save_play_queue(conn, ALICE, &[1, 2, 3], Some(2), 0, "x").unwrap();
158        conn.execute("DELETE FROM tracks WHERE id = 2", []).unwrap();
159        let q = play_queue(conn, ALICE).unwrap().unwrap();
160        assert_eq!(q.track_ids, [1, 3]);
161        assert_eq!(q.current, 1, "still on 3");
162
163        // The current one gone: the next that remains, else the first.
164        save_play_queue(conn, ALICE, &[1, 3, 1], Some(1), 0, "x").unwrap();
165        conn.execute("DELETE FROM tracks WHERE id = 3", []).unwrap();
166        let q = play_queue(conn, ALICE).unwrap().unwrap();
167        assert_eq!((q.track_ids.as_slice(), q.current), ([1, 1].as_slice(), 1));
168        save_play_queue(conn, ALICE, &[1, 1], None, 0, "x").unwrap();
169        assert_eq!(play_queue(conn, ALICE).unwrap().unwrap().current, 0);
170    }
171}