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