Skip to main content

koan_core/
playlists.rs

1//! Playlists beyond the database: keeping them in step with the server, and
2//! writing them out as files.
3//!
4//! The database module owns what a playlist *is*. This owns what happens to it
5//! next — which is either a Subsonic call or an M3U8 on disk.
6
7use std::collections::HashMap;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use crate::config::Config;
12use crate::db::connection::{Database, DbError};
13use crate::db::queries;
14use crate::helpers::subsonic_client;
15use crate::player::state::SharedPlayerState;
16use crate::remote::client::SubsonicClient;
17
18/// Each playlist's earlier states, for undo and redo.
19///
20/// The queue keeps its own (`player::undo`); this is the same for playlists,
21/// held by whoever edits them. An edit records the playlist as it was first;
22/// undoing puts that back exactly, entry ids included, so a queue following
23/// the playlist stays locked to it. Per playlist, so undoing in one never
24/// reaches into another, and in memory: it lasts the session, as the queue's
25/// does.
26#[derive(Default)]
27pub struct PlaylistHistory {
28    steps: parking_lot::Mutex<HashMap<i64, Steps>>,
29}
30
31#[derive(Default)]
32struct Steps {
33    undo: Vec<Vec<(i64, i64)>>,
34    redo: Vec<Vec<(i64, i64)>>,
35}
36
37/// How many edits back a playlist can go, as the queue's history.
38const HISTORY_DEPTH: usize = 100;
39
40impl PlaylistHistory {
41    /// Before an edit: the playlist as it is now. A new edit forgets what had
42    /// been undone.
43    pub fn record(&self, conn: &rusqlite::Connection, id: i64) -> Result<(), DbError> {
44        let before = queries::entry_snapshot(conn, id)?;
45        let mut steps = self.steps.lock();
46        let steps = steps.entry(id).or_default();
47        steps.undo.push(before);
48        if steps.undo.len() > HISTORY_DEPTH {
49            steps.undo.remove(0);
50        }
51        steps.redo.clear();
52        Ok(())
53    }
54
55    /// Back one edit. Whether there was one.
56    pub fn undo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
57        self.step(conn, id, true)
58    }
59
60    /// Forward one undone edit. Whether there was one.
61    pub fn redo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
62        self.step(conn, id, false)
63    }
64
65    fn step(&self, conn: &rusqlite::Connection, id: i64, back: bool) -> Result<bool, DbError> {
66        let mut all = self.steps.lock();
67        let steps = all.entry(id).or_default();
68        let target = if back {
69            steps.undo.pop()
70        } else {
71            steps.redo.pop()
72        };
73        let Some(target) = target else {
74            return Ok(false);
75        };
76        let now = queries::entry_snapshot(conn, id)?;
77        queries::restore_entries(conn, id, &target)?;
78        if back {
79            steps.redo.push(now)
80        } else {
81            steps.undo.push(now)
82        }
83        Ok(true)
84    }
85}
86
87#[cfg(test)]
88mod history_tests {
89    use super::*;
90    use crate::db::queries::{LOCAL_USER, sample_meta, upsert_track};
91
92    #[test]
93    fn undo_and_redo_walk_a_playlists_edits() {
94        let conn = rusqlite::Connection::open_in_memory().unwrap();
95        conn.pragma_update(None, "foreign_keys", "on").unwrap();
96        crate::db::schema::create_tables(&conn).unwrap();
97        let (a, b) = (
98            upsert_track(&conn, &sample_meta("A", "Artist", "X")).unwrap(),
99            upsert_track(&conn, &sample_meta("B", "Artist", "X")).unwrap(),
100        );
101        let id = queries::create_playlist(&conn, LOCAL_USER, "Mix", None).unwrap();
102        let history = PlaylistHistory::default();
103
104        history.record(&conn, id).unwrap();
105        queries::add_tracks(&conn, id, &[a, b]).unwrap();
106        let full = queries::entry_snapshot(&conn, id).unwrap();
107        history.record(&conn, id).unwrap();
108        queries::remove_entries(&conn, id, &[full[0].0]).unwrap();
109
110        assert!(history.undo(&conn, id).unwrap());
111        assert_eq!(queries::entry_snapshot(&conn, id).unwrap(), full);
112        assert!(history.undo(&conn, id).unwrap());
113        assert!(queries::entry_snapshot(&conn, id).unwrap().is_empty());
114        assert!(!history.undo(&conn, id).unwrap(), "nothing further back");
115
116        assert!(history.redo(&conn, id).unwrap());
117        assert_eq!(queries::entry_snapshot(&conn, id).unwrap(), full);
118    }
119}
120
121/// The queue, and the playlist or record it is still exactly.
122///
123/// While the two match, the queue *follows* a playlist: an edit there is an
124/// edit here, quietly. The moment you rearrange the queue yourself or add to
125/// it, they stop matching and the playlist becomes a document you are editing
126/// rather than the thing you are listening to.
127///
128/// A record cannot be edited, so locking to one buys no following — only the
129/// ability to say what you are listening to, which is worth saying.
130///
131/// Derived rather than tracked: there is no flag to keep in sync, nothing to
132/// persist and nothing to migrate, and it cannot get stuck, because a queue
133/// that stops matching stops being locked and one that happens to match again
134/// is locked again. Playing a playlist shuffled scrambles the order on purpose,
135/// so that queue is not locked.
136pub fn queue_lock(db: &Database, state: &SharedPlayerState) -> Option<QueueLock> {
137    let (items, _) = state.snapshot_playlist();
138    if items.is_empty() {
139        return None;
140    }
141
142    // Every item has to have come from the same playlist. One that did not —
143    // played next, dropped in — is the queue having diverged.
144    let entry_ids: Vec<i64> = items.iter().filter_map(|i| i.playlist_entry_id).collect();
145    if entry_ids.len() == items.len()
146        && let Ok(Some(playlist_id)) = queries::playlist_of_entry(&db.conn, entry_ids[0])
147        && queries::playlist_entry_ids(&db.conn, playlist_id).is_ok_and(|ids| ids == entry_ids)
148    {
149        return Some(QueueLock::Playlist(playlist_id));
150    }
151
152    // A record needs no provenance of its own: an album *is* an ordered set of
153    // tracks in the library, so the queue being that album is a question about
154    // the tracks it holds. Which means this works for a queue restored from a
155    // previous session, where nothing remembers where it came from.
156    let track_ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
157    if track_ids.len() != items.len() {
158        return None;
159    }
160    let album_id = queries::get_track_row(&db.conn, track_ids[0])
161        .ok()
162        .flatten()?
163        .album_id?;
164    let album: Vec<i64> = queries::tracks_for_album(&db.conn, album_id)
165        .ok()?
166        .into_iter()
167        .map(|t| t.id)
168        .collect();
169    (album == track_ids).then_some(QueueLock::Album(album_id))
170}
171
172/// What the queue still is, when it is still something.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum QueueLock {
175    Playlist(i64),
176    Album(i64),
177}
178
179/// What a reconciliation did.
180#[derive(Debug, Default, Clone, Copy)]
181pub struct PlaylistSync {
182    /// Playlists taken from the server, new or updated.
183    pub pulled: usize,
184    /// Playlists sent to the server, new or updated.
185    pub pushed: usize,
186}
187
188/// Reconcile playlists with the server, both directions.
189///
190/// Unlike favourites, a playlist has an order, so this is not a union. Each
191/// side's change is judged against its own record: the server's `changed`
192/// against the stamp it had at the last sync, the local `changed_at` against
193/// the one that sync covered. Only one side moved: that side wins. Both moved:
194/// last writer wins on `changed`. Neither: nothing is fetched at all.
195///
196/// Playlists that have never been to the server are created there. Ones the
197/// server no longer has are dropped locally: deleting a playlist on Navidrome
198/// and having it reappear on the next sync would make deletion impossible.
199/// Playlists tied to another server or account are first made local again, so
200/// signing in somewhere else never reads them as deleted.
201pub fn reconcile_playlists(
202    db: &Database,
203    client: &SubsonicClient,
204    url: &str,
205    username: &str,
206) -> PlaylistSync {
207    let mut out = PlaylistSync::default();
208    let account = account_key(url, username);
209
210    let remote = match client.get_playlists() {
211        Ok(lists) => lists,
212        Err(e) => {
213            log::warn!("could not fetch playlists from the server: {e}");
214            return out;
215        }
216    };
217
218    match queries::detach_playlists_from_other_accounts(&db.conn, &account) {
219        Ok(0) => {}
220        Ok(n) => log::info!("{n} playlists belonged to another server; keeping them as local"),
221        Err(e) => {
222            log::warn!("could not check which server playlists belong to: {e}");
223            return out;
224        }
225    }
226
227    // Only playlists this user owns are ours to write back. A public playlist
228    // belonging to someone else is still worth having locally, but pushing our
229    // copy of it would be editing their playlist.
230    let mut seen_remote_ids = Vec::new();
231
232    for summary in &remote {
233        seen_remote_ids.push(summary.id.clone());
234        let local = queries::playlist_by_remote_id(&db.conn, &summary.id)
235            .ok()
236            .flatten();
237        let ours = summary
238            .owner
239            .as_deref()
240            .is_none_or(|owner| owner == username);
241
242        if let Some(local) = &local {
243            let server_moved = summary.changed.as_deref() != local.remote_changed.as_deref();
244            let local_moved = ours && local.unsynced();
245            if local_moved
246                && (!server_moved || newer(&local.changed_at, summary.changed.as_deref()))
247            {
248                if push(db, client, &account, local.id).is_ok() {
249                    out.pushed += 1;
250                }
251                continue;
252            }
253            if !server_moved && !local_moved {
254                continue;
255            }
256        }
257
258        let full = match client.get_playlist(&summary.id) {
259            Ok(full) => full,
260            Err(e) => {
261                log::warn!("could not fetch playlist {}: {e}", summary.id);
262                continue;
263            }
264        };
265
266        let id = match local {
267            Some(local) => local.id,
268            None => {
269                match queries::create_playlist(
270                    &db.conn,
271                    queries::LOCAL_USER,
272                    &summary.name,
273                    summary.comment.as_deref(),
274                ) {
275                    Ok(id) => id,
276                    Err(e) => {
277                        log::warn!("could not store playlist {}: {e}", summary.name);
278                        continue;
279                    }
280                }
281            }
282        };
283
284        let _ = queries::rename_playlist(&db.conn, id, &summary.name);
285        let _ = queries::set_playlist_remote(
286            &db.conn,
287            id,
288            &summary.id,
289            summary.owner.as_deref(),
290            summary.public,
291            &account,
292        );
293
294        let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
295        let track_ids: Vec<i64> = queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids)
296            .unwrap_or_default()
297            .into_iter()
298            .flatten()
299            .collect();
300        if let Err(e) = queries::merge_server_tracks(&db.conn, id, &track_ids) {
301            log::warn!(
302                "could not store playlist contents for {}: {e}",
303                summary.name
304            );
305            continue;
306        }
307        // The rename stamped the local copy as changed; this is the server's
308        // copy, so what is here now is what the server has.
309        let _ = queries::mark_playlist_synced(&db.conn, id, summary.changed.as_deref(), None);
310        out.pulled += 1;
311    }
312
313    // A playlist we hold a server id for that the server no longer lists was
314    // deleted there.
315    for local in queries::list_playlists(&db.conn, queries::LOCAL_USER).unwrap_or_default() {
316        if let Some(remote_id) = &local.remote_id
317            && !seen_remote_ids.contains(remote_id)
318        {
319            let _ = queries::delete_playlist(&db.conn, local.id);
320        }
321    }
322
323    for local in
324        queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
325    {
326        if push(db, client, &account, local.id).is_ok() {
327            out.pushed += 1;
328        }
329    }
330
331    out
332}
333
334/// What a playlist's `remote_id` is relative to: one account on one server.
335fn account_key(url: &str, username: &str) -> String {
336    format!("{username}@{}", url.trim_end_matches('/'))
337}
338
339/// One lock per playlist, held for the length of a push.
340///
341/// Pushes are fired from every edit, and two in flight at once would race: both
342/// could see no server id and create two playlists there, or land out of
343/// order and leave the server holding the older contents. Held, each push
344/// reads the row only once the one before it has written its answer back.
345fn push_lock(id: i64) -> std::sync::Arc<parking_lot::Mutex<()>> {
346    static LOCKS: std::sync::OnceLock<
347        parking_lot::Mutex<std::collections::HashMap<i64, std::sync::Arc<parking_lot::Mutex<()>>>>,
348    > = std::sync::OnceLock::new();
349    LOCKS
350        .get_or_init(Default::default)
351        .lock()
352        .entry(id)
353        .or_default()
354        .clone()
355}
356
357/// Send a playlist's name and contents to the server, in order.
358///
359/// `createPlaylist` with a `playlistId` replaces the contents wholesale, which
360/// is the only Subsonic call that can express a reorder — so a push is always
361/// the whole list rather than a diff.
362fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
363    let lock = push_lock(id);
364    let _held = lock.lock();
365    // A server id from another account names someone else's playlist here.
366    if queries::detach_playlists_from_other_accounts(&db.conn, account).is_err() {
367        return Err(());
368    }
369
370    let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
371        return Err(());
372    };
373    let remote_id = local.remote_id.as_deref();
374    let song_ids = queries::remote_ids_for_playlist(&db.conn, id).unwrap_or_default();
375
376    // A playlist made entirely of local files has nothing the server could
377    // point at. Creating an empty one there would be worse than not creating it.
378    if remote_id.is_none() && song_ids.is_empty() {
379        return Err(());
380    }
381
382    // The name has to travel on its own. Navidrome's `createPlaylist` with a
383    // `playlistId` replaces the songs and ignores the `name` it is handed, so a
384    // rename pushed that way changes nothing. `updatePlaylist` is the call that
385    // carries metadata; `createPlaylist` is the one that carries order. A push
386    // needs both.
387    if let Some(remote_id) = remote_id
388        && let Err(e) = client.update_playlist(
389            remote_id,
390            Some(&local.name),
391            local.comment.as_deref(),
392            Some(local.public),
393        )
394    {
395        log::warn!(
396            "could not rename playlist '{}' on the server: {e}",
397            local.name
398        );
399    }
400
401    match client.create_playlist(remote_id, &local.name, &song_ids) {
402        Ok(created) => {
403            let new_id = created
404                .as_ref()
405                .map(|c| c.playlist.id.clone())
406                .or_else(|| remote_id.map(str::to_string));
407            if let Some(new_id) = new_id {
408                let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
409                let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
410                let _ = queries::set_playlist_remote(
411                    &db.conn,
412                    id,
413                    &new_id,
414                    owner.as_deref(),
415                    local.public,
416                    account,
417                );
418                let _ = queries::mark_playlist_synced(
419                    &db.conn,
420                    id,
421                    changed.as_deref(),
422                    Some(local.revision),
423                );
424            }
425            Ok(())
426        }
427        Err(e) => {
428            log::warn!(
429                "could not push playlist '{}' to the server: {e}",
430                local.name
431            );
432            Err(())
433        }
434    }
435}
436
437/// Whether `local` was changed after the server's copy was.
438///
439/// Only consulted when both sides changed since the last sync. Both are
440/// ISO 8601 in UTC — SQLite's `datetime('now')` on our side, the server's own
441/// stamp on theirs — near enough that comparing the digits works, once
442/// SQLite's space is made a `T`. A server that sends no timestamp at all
443/// cannot be shown to be newer, so ours wins and the push settles it.
444fn newer(local: &str, remote: Option<&str>) -> bool {
445    let Some(remote) = remote else { return true };
446    let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
447    normalise(local) > normalise(remote)
448}
449
450/// Push a playlist to the server in the background, if there is one.
451///
452/// Fire and forget on its own thread, the way favourites are: the local copy is
453/// already written, and a slow server should not hold up the edit that caused
454/// this. A failure leaves the local copy unsynced, which is exactly what
455/// [`reconcile_playlists`] resolves on the next sync.
456///
457/// The thread takes its own connection rather than borrowing the caller's:
458/// a `rusqlite::Connection` is not `Sync`, and the answer has to be written
459/// back — the new server id — so it needs one of its own.
460pub fn push_to_remote(id: i64) {
461    let cfg = Config::load().unwrap_or_default();
462    if !cfg.remote.enabled {
463        return;
464    }
465    let Some(client) = subsonic_client(&cfg) else {
466        return;
467    };
468    let account = account_key(&cfg.remote.url, &cfg.remote.username);
469    std::thread::Builder::new()
470        .name("koan-playlist-sync".into())
471        .spawn(move || {
472            let Ok(db) = crate::db::pool::shared().get() else {
473                return;
474            };
475            let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
476                return;
477            };
478            // The upstream server has one account, and it is the local user's.
479            if !matches!(
480                queries::auth::is_local_user(&db.conn, list.user_id),
481                Ok(true)
482            ) {
483                return;
484            }
485            let _ = push(&db, &client, &account, id);
486        })
487        .ok();
488}
489
490/// Delete a playlist on the server. Nothing to do for one that never went.
491pub fn delete_on_remote(remote_id: String) {
492    let cfg = Config::load().unwrap_or_default();
493    if !cfg.remote.enabled {
494        return;
495    }
496    let Some(client) = subsonic_client(&cfg) else {
497        return;
498    };
499    std::thread::Builder::new()
500        .name("koan-playlist-sync".into())
501        .spawn(move || {
502            if let Err(e) = client.delete_playlist(&remote_id) {
503                log::warn!("could not delete playlist {remote_id} on the server: {e}");
504            }
505        })
506        .ok();
507}
508
509/// What an export wrote, and what it could not.
510#[derive(Debug, Default, Clone, Copy)]
511pub struct ExportSummary {
512    pub written: usize,
513    /// Tracks with no file on this machine. A playlist file is a list of
514    /// paths, and a remote track that has never been downloaded has none.
515    pub skipped: usize,
516}
517
518/// Write a playlist as an extended M3U8.
519///
520/// Absolute paths, UTF-8, `#EXTINF` per entry — the format every player still
521/// reads. Remote tracks that have not been downloaded are left out rather than
522/// written as stream URLs: a Subsonic stream URL carries the credentials that
523/// authorise it, and a playlist file is something people mail to each other.
524pub fn export_m3u8(
525    db: &Database,
526    playlist_id: i64,
527    dest: &Path,
528) -> Result<ExportSummary, std::io::Error> {
529    let name = queries::get_playlist(&db.conn, playlist_id)
530        .ok()
531        .flatten()
532        .map(|p| p.name)
533        .unwrap_or_default();
534    let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
535
536    let mut out = ExportSummary::default();
537    let mut file = std::fs::File::create(dest)?;
538    writeln!(file, "#EXTM3U")?;
539    if !name.is_empty() {
540        writeln!(file, "#PLAYLIST:{name}")?;
541    }
542
543    for track in &tracks {
544        let path = track
545            .path
546            .as_deref()
547            .or(track.cached_path.as_deref())
548            .map(PathBuf::from)
549            .filter(|p| p.exists());
550        let Some(path) = path else {
551            out.skipped += 1;
552            continue;
553        };
554        let seconds = track.duration_ms.unwrap_or(0) / 1000;
555        writeln!(
556            file,
557            "#EXTINF:{seconds},{} - {}",
558            track.artist_name, track.title
559        )?;
560        writeln!(file, "{}", path.display())?;
561        out.written += 1;
562    }
563
564    Ok(out)
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::db::queries::{TrackMeta, upsert_track};
571
572    fn meta(title: &str, path: &Path) -> TrackMeta {
573        TrackMeta {
574            title: title.into(),
575            artist: "Artist".into(),
576            album_artist: Some("Artist".into()),
577            album: "Album".into(),
578            date: None,
579            disc: None,
580            track_number: None,
581            genre: None,
582            label: None,
583            duration_ms: Some(185_000),
584            codec: Some("FLAC".into()),
585            sample_rate: None,
586            bit_depth: None,
587            channels: None,
588            bitrate: None,
589            size_bytes: None,
590            mtime: None,
591            path: Some(path.to_string_lossy().into_owned()),
592            source: "local".into(),
593            remote_id: None,
594            remote_url: None,
595            album_remote_id: None,
596            artist_remote_id: None,
597            mbid: None,
598            album_mbid: None,
599            album_added_at: None,
600        }
601    }
602
603    /// The queue is locked while it is still exactly the playlist, and stops
604    /// being the moment it is not. Everything else about following follows from
605    /// this one answer.
606    #[test]
607    fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
608        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
609
610        let dir = tempfile::tempdir().unwrap();
611        let db = Database::open(&dir.path().join("koan.db")).unwrap();
612        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
613        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
614
615        let id =
616            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
617                .unwrap();
618        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
619
620        let state = SharedPlayerState::new();
621        let queued = |entry: Option<i64>| PlaylistItem {
622            id: QueueItemId::new(),
623            db_id: Some(a),
624            playlist_entry_id: entry,
625            path: dir.path().join("a.flac"),
626            title: "A".into(),
627            artist: "Artist".into(),
628            album_artist: "Artist".into(),
629            album: "Album".into(),
630            year: None,
631            codec: None,
632            track_number: None,
633            disc: None,
634            duration_ms: None,
635            state: ItemState::Ready,
636            pre_shuffle: None,
637        };
638
639        assert_eq!(
640            queue_lock(&db, &state),
641            None,
642            "an empty queue is not locked"
643        );
644
645        state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
646        assert_eq!(
647            queue_lock(&db, &state),
648            Some(QueueLock::Playlist(id)),
649            "the queue is the playlist"
650        );
651
652        // Something that never came from the playlist — played next, dropped
653        // in.
654        state.add_items(vec![queued(None)]);
655        assert_eq!(queue_lock(&db, &state), None);
656    }
657
658    /// Reordering the queue by hand ends the lock, which is the whole point of
659    /// deriving it: there is no flag anyone has to remember to clear.
660    #[test]
661    fn rearranging_the_queue_ends_the_lock() {
662        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
663
664        let dir = tempfile::tempdir().unwrap();
665        let db = Database::open(&dir.path().join("koan.db")).unwrap();
666        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
667        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
668        let id =
669            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
670                .unwrap();
671        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
672
673        let state = SharedPlayerState::new();
674        let items: Vec<PlaylistItem> = entries
675            .iter()
676            .map(|entry| PlaylistItem {
677                id: QueueItemId::new(),
678                db_id: Some(a),
679                playlist_entry_id: Some(*entry),
680                path: dir.path().join("a.flac"),
681                title: "A".into(),
682                artist: "Artist".into(),
683                album_artist: "Artist".into(),
684                album: "Album".into(),
685                year: None,
686                codec: None,
687                track_number: None,
688                disc: None,
689                duration_ms: None,
690                state: ItemState::Ready,
691                pre_shuffle: None,
692            })
693            .collect();
694        let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
695        state.add_items(items);
696        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
697
698        state.reorder_to(&[ids[1], ids[0]]);
699        assert_eq!(
700            queue_lock(&db, &state),
701            None,
702            "same tracks, different order — no longer the playlist"
703        );
704
705        // And the playlist catching up locks it again. Nothing had to be reset.
706        queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
707        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
708    }
709
710    /// A record needs no provenance: it *is* an ordered set of tracks, so the
711    /// queue being that record is a question about what the queue holds. Which
712    /// is why it survives a relaunch, where nothing remembers what was played.
713    #[test]
714    fn a_queue_holding_exactly_one_record_is_locked_to_it() {
715        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
716
717        let dir = tempfile::tempdir().unwrap();
718        let db = Database::open(&dir.path().join("koan.db")).unwrap();
719        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
720        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
721        let album_id = queries::get_track_row(&db.conn, a)
722            .unwrap()
723            .unwrap()
724            .album_id
725            .unwrap();
726
727        let state = SharedPlayerState::new();
728        let queued = |track: i64| PlaylistItem {
729            id: QueueItemId::new(),
730            db_id: Some(track),
731            playlist_entry_id: None,
732            path: dir.path().join("a.flac"),
733            title: "A".into(),
734            artist: "Artist".into(),
735            album_artist: "Artist".into(),
736            album: "Album".into(),
737            year: None,
738            codec: None,
739            track_number: None,
740            disc: None,
741            duration_ms: None,
742            state: ItemState::Ready,
743            pre_shuffle: None,
744        };
745
746        state.add_items(vec![queued(a)]);
747        assert_eq!(
748            queue_lock(&db, &state),
749            None,
750            "half a record is not the record"
751        );
752
753        state.add_items(vec![queued(b)]);
754        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
755    }
756
757    #[test]
758    fn export_writes_what_is_on_disk_and_counts_what_is_not() {
759        let dir = tempfile::tempdir().unwrap();
760        let db = Database::open(&dir.path().join("koan.db")).unwrap();
761
762        let present = dir.path().join("here.flac");
763        std::fs::write(&present, b"x").unwrap();
764        let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
765        let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
766
767        let id =
768            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
769                .unwrap();
770        queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
771
772        let dest = dir.path().join("evening.m3u8");
773        let summary = export_m3u8(&db, id, &dest).unwrap();
774        assert_eq!((summary.written, summary.skipped), (1, 1));
775
776        let written = std::fs::read_to_string(&dest).unwrap();
777        assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
778        assert!(written.contains("#EXTINF:185,Artist - Here"));
779        assert!(written.contains(&present.display().to_string()));
780        assert!(!written.contains("gone.flac"));
781    }
782
783    /// A Subsonic server holding playlists and nothing else, one request per
784    /// connection. Each `changed` is a counter, so every write moves it.
785    #[derive(Default)]
786    struct Server {
787        lists: Vec<(String, Vec<String>, u32)>,
788        fetches: usize,
789        creates_without_id: usize,
790    }
791
792    fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
793        use std::io::{BufRead, Write};
794        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
795        let url = format!("http://{}", listener.local_addr().unwrap());
796        std::thread::spawn(move || {
797            for stream in listener.incoming().flatten() {
798                let server = server.clone();
799                std::thread::spawn(move || {
800                    let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
801                    let mut request = String::new();
802                    reader.read_line(&mut request).unwrap();
803                    let mut line = String::new();
804                    while reader.read_line(&mut line).unwrap_or(0) > 2 {
805                        line.clear();
806                    }
807                    let target = request.split_whitespace().nth(1).unwrap_or("");
808                    let (path, query) = target.split_once('?').unwrap_or((target, ""));
809                    let params: Vec<(&str, &str)> = query
810                        .split('&')
811                        .filter_map(|kv| kv.split_once('='))
812                        .collect();
813                    let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
814                    let body = respond(&server, path.rsplit('/').next().unwrap(), &params, param);
815                    let mut stream = stream;
816                    let _ = write!(
817                        stream,
818                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
819                        body.len()
820                    );
821                });
822            }
823        });
824        url
825    }
826
827    fn respond<'a>(
828        server: &parking_lot::Mutex<Server>,
829        endpoint: &str,
830        params: &[(&str, &'a str)],
831        param: impl Fn(&str) -> Option<&'a str>,
832    ) -> String {
833        let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
834            format!(
835                r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
836                songs.len()
837            )
838        };
839        let full = |list: &(String, Vec<String>, u32)| {
840            let entries: Vec<String> = list
841                .1
842                .iter()
843                .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
844                .collect();
845            format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
846        };
847        let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
848        match endpoint {
849            "getPlaylists" => {
850                let lists: Vec<String> = server
851                    .lock()
852                    .lists
853                    .iter()
854                    .map(|l| format!("{{{}}}", summary(l)))
855                    .collect();
856                ok(format!(
857                    r#","playlists":{{"playlist":[{}]}}"#,
858                    lists.join(",")
859                ))
860            }
861            "getPlaylist" => {
862                let mut server = server.lock();
863                server.fetches += 1;
864                let list = server
865                    .lists
866                    .iter()
867                    .find(|l| Some(l.0.as_str()) == param("id"));
868                ok(format!(r#","playlist":{}"#, full(list.unwrap())))
869            }
870            "createPlaylist" => {
871                let songs: Vec<String> = params
872                    .iter()
873                    .filter(|(k, _)| *k == "songId")
874                    .map(|(_, v)| v.to_string())
875                    .collect();
876                // Wide enough for two pushes to overlap if nothing stops them.
877                std::thread::sleep(std::time::Duration::from_millis(50));
878                let mut server = server.lock();
879                let id = match param("playlistId") {
880                    Some(id) => id.to_string(),
881                    None => {
882                        server.creates_without_id += 1;
883                        format!("p{}", server.lists.len() + 1)
884                    }
885                };
886                server.lists.retain(|l| l.0 != id);
887                let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
888                server.lists.push((id, songs, changed));
889                ok(format!(
890                    r#","playlist":{}"#,
891                    full(server.lists.last().unwrap())
892                ))
893            }
894            _ => ok(String::new()),
895        }
896    }
897
898    fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
899        TrackMeta {
900            path: None,
901            source: "remote".into(),
902            remote_id: Some(remote_id.into()),
903            album: title.into(),
904            ..meta(title, Path::new(""))
905        }
906    }
907
908    fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
909        queries::playlist_entries(&db.conn, id)
910            .unwrap()
911            .into_iter()
912            .map(|e| (e.id, e.track.id))
913            .collect()
914    }
915
916    #[test]
917    fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
918        let dir = tempfile::tempdir().unwrap();
919        let db = Database::open(&dir.path().join("koan.db")).unwrap();
920        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
921        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
922        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
923
924        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
925            lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
926            ..Default::default()
927        }));
928        let url = serve(server.clone());
929        let client = SubsonicClient::new(&url, "u", "pw");
930
931        let first = reconcile_playlists(&db, &client, &url, "u");
932        assert_eq!(first.pulled, 1);
933        let id = queries::playlist_by_remote_id(&db.conn, "p1")
934            .unwrap()
935            .unwrap()
936            .id;
937        assert_eq!(
938            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
939            [r1, r2]
940        );
941
942        // A local file joins; the push carries only what the server can name.
943        queries::add_tracks(&db.conn, id, &[local]).unwrap();
944        let second = reconcile_playlists(&db, &client, &url, "u");
945        assert_eq!((second.pushed, second.pulled), (1, 0));
946        assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
947
948        let before = entries(&db, id);
949        assert_eq!(before.len(), 3);
950        let fetches = server.lock().fetches;
951        let third = reconcile_playlists(&db, &client, &url, "u");
952        assert_eq!((third.pushed, third.pulled), (0, 0));
953        assert_eq!(
954            server.lock().fetches,
955            fetches,
956            "nothing moved, nothing fetched"
957        );
958        assert_eq!(entries(&db, id), before);
959
960        // An edit on the server comes down without the local file or the
961        // surviving entry's id.
962        {
963            let mut server = server.lock();
964            server.lists[0].1 = vec!["s2".into()];
965            server.lists[0].2 += 1;
966        }
967        let fourth = reconcile_playlists(&db, &client, &url, "u");
968        assert_eq!(fourth.pulled, 1);
969        assert_eq!(entries(&db, id), [before[1], before[2]]);
970    }
971
972    #[test]
973    fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
974        let dir = tempfile::tempdir().unwrap();
975        let db = Database::open(&dir.path().join("koan.db")).unwrap();
976        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
977        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
978        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
979        queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
980            .unwrap();
981
982        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
983        let url = serve(server.clone());
984        let client = SubsonicClient::new(&url, "u", "pw");
985        let sync = reconcile_playlists(&db, &client, &url, "u");
986
987        let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
988        assert_eq!(list.track_count, 1);
989        assert_eq!(sync.pushed, 1);
990        assert_eq!(list.remote_id.as_deref(), Some("p1"));
991    }
992
993    #[test]
994    fn concurrent_pushes_create_one_server_playlist() {
995        let dir = tempfile::tempdir().unwrap();
996        let path = dir.path().join("koan.db");
997        let db = Database::open(&path).unwrap();
998        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
999        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1000        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1001
1002        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1003        let url = serve(server.clone());
1004        let pushes: Vec<_> = (0..2)
1005            .map(|_| {
1006                let (path, url) = (path.clone(), url.clone());
1007                std::thread::spawn(move || {
1008                    let db = Database::open(&path).unwrap();
1009                    let client = SubsonicClient::new(&url, "u", "pw");
1010                    push(&db, &client, &account_key(&url, "u"), id)
1011                })
1012            })
1013            .collect();
1014        for p in pushes {
1015            p.join().unwrap().unwrap();
1016        }
1017        assert_eq!(server.lock().creates_without_id, 1);
1018        assert_eq!(server.lock().lists.len(), 1);
1019    }
1020}