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, SubsonicError};
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/// Missing from the listing is not enough for that, since a listing can come
200/// back short or empty: the server is asked for each one, and only one it
201/// answers "not found" for (error 70) is dropped. Playlists tied to another
202/// server or account are first made local again, so signing in somewhere else
203/// never reads them as deleted.
204///
205/// A server copy can name songs the library does not have — ones a library
206/// sync has not reached, or ones the server lists in no album. The local copy
207/// holds the rest; a push puts those songs back where they were (see
208/// [`keep_unknown`]), so koan never deletes on the server an entry it could
209/// not show.
210pub fn reconcile_playlists(
211    db: &Database,
212    client: &SubsonicClient,
213    url: &str,
214    username: &str,
215) -> PlaylistSync {
216    let mut out = PlaylistSync::default();
217    let account = account_key(url, username);
218
219    let remote = match client.get_playlists() {
220        Ok(lists) => lists,
221        Err(e) => {
222            log::warn!("could not fetch playlists from the server: {e}");
223            return out;
224        }
225    };
226
227    match queries::detach_playlists_from_other_accounts(&db.conn, &account) {
228        Ok(0) => {}
229        Ok(n) => log::info!("{n} playlists belonged to another server; keeping them as local"),
230        Err(e) => {
231            log::warn!("could not check which server playlists belong to: {e}");
232            return out;
233        }
234    }
235
236    // Only playlists this user owns are ours to write back. A public playlist
237    // belonging to someone else is still worth having locally, but pushing our
238    // copy of it would be editing their playlist.
239    let mut seen_remote_ids = Vec::new();
240
241    for summary in &remote {
242        seen_remote_ids.push(summary.id.clone());
243        let local = match queries::playlist_by_remote_id(&db.conn, &summary.id) {
244            Ok(local) => local,
245            Err(e) => {
246                log::warn!("could not look up playlist {}: {e}", summary.name);
247                continue;
248            }
249        };
250        // A read-only playlist (a smart one on the server) is pulled and
251        // never pushed, whoever owns it.
252        let ours = !summary.readonly
253            && summary
254                .owner
255                .as_deref()
256                .is_none_or(|owner| owner == username);
257
258        if let Some(local) = &local {
259            let server_moved = summary.changed.as_deref() != local.remote_changed.as_deref();
260            let local_moved = ours && local.unsynced();
261            if local_moved
262                && (!server_moved || newer(&local.changed_at, summary.changed.as_deref()))
263            {
264                if push(db, client, &account, local.id).is_ok() {
265                    out.pushed += 1;
266                }
267                continue;
268            }
269            if !server_moved && !local_moved {
270                continue;
271            }
272        }
273
274        let full = match client.get_playlist(&summary.id) {
275            Ok(full) => full,
276            Err(e) => {
277                log::warn!("could not fetch playlist {}: {e}", summary.id);
278                continue;
279            }
280        };
281
282        let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
283        let track_ids = match queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids) {
284            Ok(ids) => ids,
285            Err(e) => {
286                log::warn!("could not read playlist {}: {e}", summary.name);
287                continue;
288            }
289        };
290        let unknown = track_ids.iter().filter(|t| t.is_none()).count();
291        if unknown > 0 {
292            log::info!(
293                "playlist {}: {unknown} of {} songs are not in the library; \
294                 they stay on the server",
295                summary.name,
296                track_ids.len()
297            );
298        }
299        let track_ids: Vec<i64> = track_ids.into_iter().flatten().collect();
300
301        // All of it or none: a copy renamed but not refilled, or refilled but
302        // not marked synced, would push back as an edit nobody made.
303        let stored = queries::atomically(&db.conn, || {
304            let id = match &local {
305                Some(local) => local.id,
306                None => queries::create_playlist(
307                    &db.conn,
308                    queries::LOCAL_USER,
309                    &summary.name,
310                    summary.comment.as_deref(),
311                )?,
312            };
313            queries::rename_playlist(&db.conn, id, &summary.name)?;
314            queries::set_playlist_readonly(&db.conn, id, summary.readonly)?;
315            queries::set_playlist_remote(
316                &db.conn,
317                id,
318                &summary.id,
319                summary.owner.as_deref(),
320                summary.public,
321                &account,
322            )?;
323            queries::merge_server_tracks(&db.conn, id, &track_ids)?;
324            // The rename stamped the local copy as changed; this is the
325            // server's copy, so what is here now is what the server has.
326            queries::mark_playlist_synced(&db.conn, id, summary.changed.as_deref(), None)
327        });
328        match stored {
329            Ok(()) => out.pulled += 1,
330            Err(e) => log::warn!("could not store playlist {}: {e}", summary.name),
331        }
332    }
333
334    // A playlist we hold a server id for that the server no longer lists was
335    // deleted there.
336    let held = match queries::list_playlists(&db.conn, queries::LOCAL_USER) {
337        Ok(lists) => lists,
338        Err(e) => {
339            log::warn!("could not list playlists to check for deletions: {e}");
340            Vec::new()
341        }
342    };
343    for local in &held {
344        let Some(remote_id) = local.remote_id.as_deref() else {
345            continue;
346        };
347        if seen_remote_ids.iter().any(|seen| seen == remote_id) {
348            continue;
349        }
350        match client.get_playlist(remote_id) {
351            Err(SubsonicError::Api { code: 70, .. }) => {
352                if let Err(e) = queries::delete_playlist(&db.conn, local.id) {
353                    log::warn!("could not delete playlist {}: {e}", local.name);
354                }
355            }
356            Ok(_) => log::info!(
357                "playlist {} was missing from the listing but still exists",
358                local.name
359            ),
360            Err(e) => log::warn!(
361                "could not confirm playlist {} was deleted ({e}); keeping it",
362                local.name
363            ),
364        }
365    }
366
367    for local in
368        queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
369    {
370        if push(db, client, &account, local.id).is_ok() {
371            out.pushed += 1;
372        }
373    }
374
375    out
376}
377
378/// What a playlist's `remote_id` is relative to: one account on one server.
379fn account_key(url: &str, username: &str) -> String {
380    format!("{username}@{}", url.trim_end_matches('/'))
381}
382
383/// One lock per playlist, held for the length of a push.
384///
385/// Pushes are fired from every edit, and two in flight at once would race: both
386/// could see no server id and create two playlists there, or land out of
387/// order and leave the server holding the older contents. Held, each push
388/// reads the row only once the one before it has written its answer back.
389fn push_lock(id: i64) -> std::sync::Arc<parking_lot::Mutex<()>> {
390    static LOCKS: std::sync::OnceLock<
391        parking_lot::Mutex<std::collections::HashMap<i64, std::sync::Arc<parking_lot::Mutex<()>>>>,
392    > = std::sync::OnceLock::new();
393    LOCKS
394        .get_or_init(Default::default)
395        .lock()
396        .entry(id)
397        .or_default()
398        .clone()
399}
400
401/// Send a playlist's name and contents to the server, in order.
402///
403/// `createPlaylist` with a `playlistId` replaces the contents wholesale, which
404/// is the only Subsonic call that can express a reorder — so a push is always
405/// the whole list rather than a diff.
406fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
407    let lock = push_lock(id);
408    let _held = lock.lock();
409    // A server id from another account names someone else's playlist here.
410    if queries::detach_playlists_from_other_accounts(&db.conn, account).is_err() {
411        return Err(());
412    }
413
414    let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
415        return Err(());
416    };
417    // A smart playlist here, or a read-only one there: neither side's copy
418    // is for the other to overwrite.
419    if local.readonly {
420        return Err(());
421    }
422    let remote_id = local.remote_id.as_deref();
423    let song_ids = match queries::remote_ids_for_playlist(&db.conn, id) {
424        Ok(ids) => ids,
425        Err(e) => {
426            log::warn!("could not read playlist '{}' to push it: {e}", local.name);
427            return Err(());
428        }
429    };
430
431    // A playlist made entirely of local files has nothing the server could
432    // point at. Creating an empty one there would be worse than not creating
433    // it, and emptying one there is only right when it is empty here too.
434    if song_ids.is_empty() && (remote_id.is_none() || local.track_count > 0) {
435        if remote_id.is_some() {
436            log::warn!(
437                "playlist '{}' holds nothing the server has; not emptying the server's copy",
438                local.name
439            );
440        }
441        return Err(());
442    }
443
444    // The push replaces the server's copy wholesale, so a song there that the
445    // library does not know would be deleted by it without anyone having
446    // chosen to: this copy never held it. Those go back in where they were.
447    let song_ids = match remote_id {
448        None => song_ids,
449        Some(remote_id) => {
450            let server = client
451                .get_playlist(remote_id)
452                .map_err(|e| e.to_string())
453                .and_then(|full| {
454                    let ids: Vec<String> = full.entry.into_iter().map(|s| s.id).collect();
455                    let known = queries::track_ids_for_remote_ids(&db.conn, &ids)
456                        .map_err(|e| e.to_string())?;
457                    Ok((ids, known))
458                });
459            match server {
460                Ok((ids, known)) => {
461                    let known: Vec<bool> = known.iter().map(Option::is_some).collect();
462                    keep_unknown(&ids, &known, song_ids)
463                }
464                Err(e) => {
465                    log::warn!(
466                        "could not read playlist '{}' on the server before pushing: {e}",
467                        local.name
468                    );
469                    return Err(());
470                }
471            }
472        }
473    };
474
475    // The name has to travel on its own. Navidrome's `createPlaylist` with a
476    // `playlistId` replaces the songs and ignores the `name` it is handed, so a
477    // rename pushed that way changes nothing. `updatePlaylist` is the call that
478    // carries metadata; `createPlaylist` is the one that carries order. A push
479    // needs both.
480    if let Some(remote_id) = remote_id
481        && let Err(e) = client.update_playlist(
482            remote_id,
483            Some(&local.name),
484            local.comment.as_deref(),
485            Some(local.public),
486        )
487    {
488        log::warn!(
489            "could not rename playlist '{}' on the server: {e}",
490            local.name
491        );
492    }
493
494    match client.create_playlist(remote_id, &local.name, &song_ids) {
495        Ok(created) => {
496            let new_id = created
497                .as_ref()
498                .map(|c| c.playlist.id.clone())
499                .or_else(|| remote_id.map(str::to_string));
500            if let Some(new_id) = new_id {
501                let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
502                let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
503                let recorded = queries::atomically(&db.conn, || {
504                    queries::set_playlist_remote(
505                        &db.conn,
506                        id,
507                        &new_id,
508                        owner.as_deref(),
509                        local.public,
510                        account,
511                    )?;
512                    queries::mark_playlist_synced(
513                        &db.conn,
514                        id,
515                        changed.as_deref(),
516                        Some(local.revision),
517                    )
518                });
519                if let Err(e) = recorded {
520                    log::warn!(
521                        "playlist '{}' was pushed but its server id was not recorded: {e}",
522                        local.name
523                    );
524                    return Err(());
525                }
526            }
527            Ok(())
528        }
529        Err(e) => {
530            log::warn!(
531                "could not push playlist '{}' to the server: {e}",
532                local.name
533            );
534            Err(())
535        }
536    }
537}
538
539/// `local`, the song ids a push sends, with the songs of the server's copy
540/// `server` that the library does not know (`known` false) put back: each
541/// after the nearest song before it on the server that `local` still holds,
542/// or first when there is none. The library never held them, so no edit here
543/// can have removed them.
544fn keep_unknown(server: &[String], known: &[bool], local: Vec<String>) -> Vec<String> {
545    let mut after: HashMap<&str, Vec<String>> = HashMap::new();
546    let mut head = Vec::new();
547    let mut anchor: Option<&str> = None;
548    for (id, &known) in server.iter().zip(known) {
549        if known {
550            if local.iter().any(|l| l == id) {
551                anchor = Some(id);
552            }
553        } else {
554            match anchor {
555                Some(a) => after.entry(a).or_default().push(id.clone()),
556                None => head.push(id.clone()),
557            }
558        }
559    }
560    let mut out = head;
561    for id in local {
562        let follow = after.remove(id.as_str());
563        out.push(id);
564        out.extend(follow.into_iter().flatten());
565    }
566    out
567}
568
569/// Whether `local` was changed after the server's copy was.
570///
571/// Only consulted when both sides changed since the last sync. Both are
572/// ISO 8601 in UTC — SQLite's `datetime('now')` on our side, the server's own
573/// stamp on theirs — near enough that comparing the digits works, once
574/// SQLite's space is made a `T`. A server that sends no timestamp at all
575/// cannot be shown to be newer, so ours wins and the push settles it.
576fn newer(local: &str, remote: Option<&str>) -> bool {
577    let Some(remote) = remote else { return true };
578    let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
579    normalise(local) > normalise(remote)
580}
581
582/// Push a playlist to the server in the background, if there is one.
583///
584/// Fire and forget on its own thread, the way favourites are: the local copy is
585/// already written, and a slow server should not hold up the edit that caused
586/// this. A failure leaves the local copy unsynced, which is exactly what
587/// [`reconcile_playlists`] resolves on the next sync.
588///
589/// The thread takes its own connection rather than borrowing the caller's:
590/// a `rusqlite::Connection` is not `Sync`, and the answer has to be written
591/// back — the new server id — so it needs one of its own.
592pub fn push_to_remote(id: i64) {
593    let cfg = Config::load().unwrap_or_default();
594    if !cfg.remote.enabled {
595        return;
596    }
597    let Some(client) = subsonic_client(&cfg) else {
598        return;
599    };
600    let account = account_key(&cfg.remote.url, &cfg.remote.username);
601    std::thread::Builder::new()
602        .name("koan-playlist-sync".into())
603        .spawn(move || {
604            let Ok(db) = crate::db::pool::shared().get() else {
605                return;
606            };
607            let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
608                return;
609            };
610            // The upstream server has one account, and it is the local user's.
611            if !matches!(
612                queries::auth::is_local_user(&db.conn, list.user_id),
613                Ok(true)
614            ) {
615                return;
616            }
617            let _ = push(&db, &client, &account, id);
618        })
619        .ok();
620}
621
622/// Delete a playlist on the server. Nothing to do for one that never went.
623pub fn delete_on_remote(remote_id: String) {
624    let cfg = Config::load().unwrap_or_default();
625    if !cfg.remote.enabled {
626        return;
627    }
628    let Some(client) = subsonic_client(&cfg) else {
629        return;
630    };
631    std::thread::Builder::new()
632        .name("koan-playlist-sync".into())
633        .spawn(move || {
634            if let Err(e) = client.delete_playlist(&remote_id) {
635                log::warn!("could not delete playlist {remote_id} on the server: {e}");
636            }
637        })
638        .ok();
639}
640
641/// What an export wrote, and what it could not.
642#[derive(Debug, Default, Clone, Copy)]
643pub struct ExportSummary {
644    pub written: usize,
645    /// Tracks with no file on this machine. A playlist file is a list of
646    /// paths, and a remote track that has never been downloaded has none.
647    pub skipped: usize,
648}
649
650/// Write a playlist as an extended M3U8.
651///
652/// Absolute paths, UTF-8, `#EXTINF` per entry — the format every player still
653/// reads. Remote tracks that have not been downloaded are left out rather than
654/// written as stream URLs: a Subsonic stream URL carries the credentials that
655/// authorise it, and a playlist file is something people mail to each other.
656pub fn export_m3u8(
657    db: &Database,
658    playlist_id: i64,
659    dest: &Path,
660) -> Result<ExportSummary, std::io::Error> {
661    let name = queries::get_playlist(&db.conn, playlist_id)
662        .ok()
663        .flatten()
664        .map(|p| p.name)
665        .unwrap_or_default();
666    let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
667
668    let mut out = ExportSummary::default();
669    let mut file = std::fs::File::create(dest)?;
670    writeln!(file, "#EXTM3U")?;
671    if !name.is_empty() {
672        writeln!(file, "#PLAYLIST:{name}")?;
673    }
674
675    for track in &tracks {
676        let path = track
677            .path
678            .as_deref()
679            .or(track.cached_path.as_deref())
680            .map(PathBuf::from)
681            .filter(|p| p.exists());
682        let Some(path) = path else {
683            out.skipped += 1;
684            continue;
685        };
686        let seconds = track.duration_ms.unwrap_or(0) / 1000;
687        writeln!(
688            file,
689            "#EXTINF:{seconds},{} - {}",
690            track.artist_name, track.title
691        )?;
692        writeln!(file, "{}", path.display())?;
693        out.written += 1;
694    }
695
696    Ok(out)
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use crate::db::queries::{TrackMeta, upsert_track};
703
704    fn meta(title: &str, path: &Path) -> TrackMeta {
705        TrackMeta {
706            title: title.into(),
707            artist: "Artist".into(),
708            album_artist: Some("Artist".into()),
709            album: "Album".into(),
710            date: None,
711            disc: None,
712            track_number: None,
713            genre: None,
714            label: None,
715            duration_ms: Some(185_000),
716            codec: Some("FLAC".into()),
717            sample_rate: None,
718            bit_depth: None,
719            channels: None,
720            bitrate: None,
721            size_bytes: None,
722            mtime: None,
723            path: Some(path.to_string_lossy().into_owned()),
724            source: "local".into(),
725            remote_id: None,
726            remote_url: None,
727            album_remote_id: None,
728            artist_remote_id: None,
729            mbid: None,
730            album_mbid: None,
731            album_added_at: None,
732        }
733    }
734
735    /// The queue is locked while it is still exactly the playlist, and stops
736    /// being the moment it is not. Everything else about following follows from
737    /// this one answer.
738    #[test]
739    fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
740        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
741
742        let dir = tempfile::tempdir().unwrap();
743        let db = Database::open(&dir.path().join("koan.db")).unwrap();
744        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
745        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
746
747        let id =
748            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
749                .unwrap();
750        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
751
752        let state = SharedPlayerState::new();
753        let queued = |entry: Option<i64>| PlaylistItem {
754            id: QueueItemId::new(),
755            db_id: Some(a),
756            playlist_entry_id: entry,
757            path: dir.path().join("a.flac"),
758            title: "A".into(),
759            artist: "Artist".into(),
760            album_artist: "Artist".into(),
761            album: "Album".into(),
762            year: None,
763            codec: None,
764            track_number: None,
765            disc: None,
766            duration_ms: None,
767            state: ItemState::Ready,
768            played: false,
769        };
770
771        assert_eq!(
772            queue_lock(&db, &state),
773            None,
774            "an empty queue is not locked"
775        );
776
777        state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
778        assert_eq!(
779            queue_lock(&db, &state),
780            Some(QueueLock::Playlist(id)),
781            "the queue is the playlist"
782        );
783
784        // Something that never came from the playlist — played next, dropped
785        // in.
786        state.add_items(vec![queued(None)]);
787        assert_eq!(queue_lock(&db, &state), None);
788    }
789
790    /// Reordering the queue by hand ends the lock, which is the whole point of
791    /// deriving it: there is no flag anyone has to remember to clear.
792    #[test]
793    fn rearranging_the_queue_ends_the_lock() {
794        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
795
796        let dir = tempfile::tempdir().unwrap();
797        let db = Database::open(&dir.path().join("koan.db")).unwrap();
798        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
799        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
800        let id =
801            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
802                .unwrap();
803        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
804
805        let state = SharedPlayerState::new();
806        let items: Vec<PlaylistItem> = entries
807            .iter()
808            .map(|entry| PlaylistItem {
809                id: QueueItemId::new(),
810                db_id: Some(a),
811                playlist_entry_id: Some(*entry),
812                path: dir.path().join("a.flac"),
813                title: "A".into(),
814                artist: "Artist".into(),
815                album_artist: "Artist".into(),
816                album: "Album".into(),
817                year: None,
818                codec: None,
819                track_number: None,
820                disc: None,
821                duration_ms: None,
822                state: ItemState::Ready,
823                played: false,
824            })
825            .collect();
826        let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
827        state.add_items(items);
828        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
829
830        state.reorder_to(&[ids[1], ids[0]]);
831        assert_eq!(
832            queue_lock(&db, &state),
833            None,
834            "same tracks, different order — no longer the playlist"
835        );
836
837        // And the playlist catching up locks it again. Nothing had to be reset.
838        queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
839        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
840    }
841
842    /// A record needs no provenance: it *is* an ordered set of tracks, so the
843    /// queue being that record is a question about what the queue holds. Which
844    /// is why it survives a relaunch, where nothing remembers what was played.
845    #[test]
846    fn a_queue_holding_exactly_one_record_is_locked_to_it() {
847        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
848
849        let dir = tempfile::tempdir().unwrap();
850        let db = Database::open(&dir.path().join("koan.db")).unwrap();
851        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
852        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
853        let album_id = queries::get_track_row(&db.conn, a)
854            .unwrap()
855            .unwrap()
856            .album_id
857            .unwrap();
858
859        let state = SharedPlayerState::new();
860        let queued = |track: i64| PlaylistItem {
861            id: QueueItemId::new(),
862            db_id: Some(track),
863            playlist_entry_id: None,
864            path: dir.path().join("a.flac"),
865            title: "A".into(),
866            artist: "Artist".into(),
867            album_artist: "Artist".into(),
868            album: "Album".into(),
869            year: None,
870            codec: None,
871            track_number: None,
872            disc: None,
873            duration_ms: None,
874            state: ItemState::Ready,
875            played: false,
876        };
877
878        state.add_items(vec![queued(a)]);
879        assert_eq!(
880            queue_lock(&db, &state),
881            None,
882            "half a record is not the record"
883        );
884
885        state.add_items(vec![queued(b)]);
886        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
887    }
888
889    #[test]
890    fn export_writes_what_is_on_disk_and_counts_what_is_not() {
891        let dir = tempfile::tempdir().unwrap();
892        let db = Database::open(&dir.path().join("koan.db")).unwrap();
893
894        let present = dir.path().join("here.flac");
895        std::fs::write(&present, b"x").unwrap();
896        let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
897        let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
898
899        let id =
900            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
901                .unwrap();
902        queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
903
904        let dest = dir.path().join("evening.m3u8");
905        let summary = export_m3u8(&db, id, &dest).unwrap();
906        assert_eq!((summary.written, summary.skipped), (1, 1));
907
908        let written = std::fs::read_to_string(&dest).unwrap();
909        assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
910        assert!(written.contains("#EXTINF:185,Artist - Here"));
911        assert!(written.contains(&present.display().to_string()));
912        assert!(!written.contains("gone.flac"));
913    }
914
915    /// A Subsonic server holding playlists and nothing else, one request per
916    /// connection. Each `changed` is a counter, so every write moves it.
917    #[derive(Default)]
918    struct Server {
919        lists: Vec<(String, Vec<String>, u32)>,
920        fetches: usize,
921        creates_without_id: usize,
922        /// `getPlaylists` answers with none, as a server answering wrongly.
923        hide_listing: bool,
924    }
925
926    fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
927        use std::io::{BufRead, Write};
928        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
929        let url = format!("http://{}", listener.local_addr().unwrap());
930        std::thread::spawn(move || {
931            for stream in listener.incoming().flatten() {
932                let server = server.clone();
933                std::thread::spawn(move || {
934                    let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
935                    let mut request = String::new();
936                    reader.read_line(&mut request).unwrap();
937                    let mut line = String::new();
938                    while reader.read_line(&mut line).unwrap_or(0) > 2 {
939                        line.clear();
940                    }
941                    let target = request.split_whitespace().nth(1).unwrap_or("");
942                    let (path, query) = target.split_once('?').unwrap_or((target, ""));
943                    let params: Vec<(&str, &str)> = query
944                        .split('&')
945                        .filter_map(|kv| kv.split_once('='))
946                        .collect();
947                    let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
948                    let body = respond(&server, path.rsplit('/').next().unwrap(), &params, param);
949                    let mut stream = stream;
950                    let _ = write!(
951                        stream,
952                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
953                        body.len()
954                    );
955                });
956            }
957        });
958        url
959    }
960
961    fn respond<'a>(
962        server: &parking_lot::Mutex<Server>,
963        endpoint: &str,
964        params: &[(&str, &'a str)],
965        param: impl Fn(&str) -> Option<&'a str>,
966    ) -> String {
967        let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
968            format!(
969                r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
970                songs.len()
971            )
972        };
973        let full = |list: &(String, Vec<String>, u32)| {
974            let entries: Vec<String> = list
975                .1
976                .iter()
977                .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
978                .collect();
979            format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
980        };
981        let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
982        match endpoint {
983            "getPlaylists" => {
984                let server = server.lock();
985                let lists: Vec<String> = server
986                    .lists
987                    .iter()
988                    .filter(|_| !server.hide_listing)
989                    .map(|l| format!("{{{}}}", summary(l)))
990                    .collect();
991                ok(format!(
992                    r#","playlists":{{"playlist":[{}]}}"#,
993                    lists.join(",")
994                ))
995            }
996            "getPlaylist" => {
997                let mut server = server.lock();
998                server.fetches += 1;
999                match server
1000                    .lists
1001                    .iter()
1002                    .find(|l| Some(l.0.as_str()) == param("id"))
1003                {
1004                    Some(list) => ok(format!(r#","playlist":{}"#, full(list))),
1005                    None => r#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"not found"}}}"#.to_string(),
1006                }
1007            }
1008            "createPlaylist" => {
1009                let songs: Vec<String> = params
1010                    .iter()
1011                    .filter(|(k, _)| *k == "songId")
1012                    .map(|(_, v)| v.to_string())
1013                    .collect();
1014                // Wide enough for two pushes to overlap if nothing stops them.
1015                std::thread::sleep(std::time::Duration::from_millis(50));
1016                let mut server = server.lock();
1017                let id = match param("playlistId") {
1018                    Some(id) => id.to_string(),
1019                    None => {
1020                        server.creates_without_id += 1;
1021                        format!("p{}", server.lists.len() + 1)
1022                    }
1023                };
1024                server.lists.retain(|l| l.0 != id);
1025                let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
1026                server.lists.push((id, songs, changed));
1027                ok(format!(
1028                    r#","playlist":{}"#,
1029                    full(server.lists.last().unwrap())
1030                ))
1031            }
1032            _ => ok(String::new()),
1033        }
1034    }
1035
1036    fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
1037        TrackMeta {
1038            path: None,
1039            source: "remote".into(),
1040            remote_id: Some(remote_id.into()),
1041            album: title.into(),
1042            ..meta(title, Path::new(""))
1043        }
1044    }
1045
1046    fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
1047        queries::playlist_entries(&db.conn, id)
1048            .unwrap()
1049            .into_iter()
1050            .map(|e| (e.id, e.track.id))
1051            .collect()
1052    }
1053
1054    #[test]
1055    fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
1056        let dir = tempfile::tempdir().unwrap();
1057        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1058        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1059        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1060        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1061
1062        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1063            lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
1064            ..Default::default()
1065        }));
1066        let url = serve(server.clone());
1067        let client = SubsonicClient::new(&url, "u", "pw");
1068
1069        let first = reconcile_playlists(&db, &client, &url, "u");
1070        assert_eq!(first.pulled, 1);
1071        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1072            .unwrap()
1073            .unwrap()
1074            .id;
1075        assert_eq!(
1076            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1077            [r1, r2]
1078        );
1079
1080        // A local file joins; the push carries only what the server can name.
1081        queries::add_tracks(&db.conn, id, &[local]).unwrap();
1082        let second = reconcile_playlists(&db, &client, &url, "u");
1083        assert_eq!((second.pushed, second.pulled), (1, 0));
1084        assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
1085
1086        let before = entries(&db, id);
1087        assert_eq!(before.len(), 3);
1088        let fetches = server.lock().fetches;
1089        let third = reconcile_playlists(&db, &client, &url, "u");
1090        assert_eq!((third.pushed, third.pulled), (0, 0));
1091        assert_eq!(
1092            server.lock().fetches,
1093            fetches,
1094            "nothing moved, nothing fetched"
1095        );
1096        assert_eq!(entries(&db, id), before);
1097
1098        // An edit on the server comes down without the local file or the
1099        // surviving entry's id.
1100        {
1101            let mut server = server.lock();
1102            server.lists[0].1 = vec!["s2".into()];
1103            server.lists[0].2 += 1;
1104        }
1105        let fourth = reconcile_playlists(&db, &client, &url, "u");
1106        assert_eq!(fourth.pulled, 1);
1107        assert_eq!(entries(&db, id), [before[1], before[2]]);
1108    }
1109
1110    /// A song on the server's copy that the library does not have is left
1111    /// out of the local copy and put back, in place, by every push: nothing
1112    /// here could have removed it.
1113    #[test]
1114    fn a_server_song_the_library_lacks_is_never_dropped() {
1115        let dir = tempfile::tempdir().unwrap();
1116        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1117        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1118        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1119
1120        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1121            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1122            ..Default::default()
1123        }));
1124        let url = serve(server.clone());
1125        let client = SubsonicClient::new(&url, "u", "pw");
1126        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1127        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1128            .unwrap()
1129            .unwrap()
1130            .id;
1131        assert_eq!(
1132            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1133            [r1]
1134        );
1135
1136        queries::add_tracks(&db.conn, id, &[r2]).unwrap();
1137        assert!(push(&db, &client, &account_key(&url, "u"), id).is_ok());
1138        assert_eq!(server.lock().lists[0].1, ["s1", "s9", "s2"]);
1139
1140        // The library catches up, and the server's copy comes down whole.
1141        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1142        server.lock().lists[0].2 += 1;
1143        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1144        assert_eq!(
1145            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1146            [r1, r9, r2]
1147        );
1148    }
1149
1150    #[test]
1151    fn unknown_songs_go_back_after_the_song_before_them() {
1152        let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1153        let server = ids(&["x", "a", "y", "b", "z", "c"]);
1154        let known = [false, true, false, true, false, true];
1155        // `b` removed and `d` added here: `z` follows `a`, the nearest
1156        // survivor before it.
1157        assert_eq!(
1158            keep_unknown(&server, &known, ids(&["c", "a", "d"])),
1159            ids(&["x", "c", "a", "y", "z", "d"])
1160        );
1161        assert_eq!(
1162            keep_unknown(&server, &known, Vec::new()),
1163            ids(&["x", "y", "z"])
1164        );
1165    }
1166
1167    /// A copy holding entries none of which the server can name is not
1168    /// pushed as an empty list over the server's. One emptied on purpose is.
1169    #[test]
1170    fn only_an_empty_playlist_empties_the_server_copy() {
1171        let dir = tempfile::tempdir().unwrap();
1172        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1173        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1174        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1175
1176        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1177            lists: vec![("p1".into(), vec!["s1".into()], 1)],
1178            ..Default::default()
1179        }));
1180        let url = serve(server.clone());
1181        let client = SubsonicClient::new(&url, "u", "pw");
1182        let account = account_key(&url, "u");
1183        reconcile_playlists(&db, &client, &url, "u");
1184        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1185            .unwrap()
1186            .unwrap()
1187            .id;
1188
1189        let only = entries(&db, id);
1190        queries::add_tracks(&db.conn, id, &[local]).unwrap();
1191        queries::remove_entries(&db.conn, id, &[only[0].0]).unwrap();
1192        assert!(push(&db, &client, &account, id).is_err());
1193        assert_eq!(server.lock().lists[0].1, ["s1"]);
1194
1195        let rest = entries(&db, id);
1196        queries::remove_entries(&db.conn, id, &[rest[0].0]).unwrap();
1197        assert!(push(&db, &client, &account, id).is_ok());
1198        assert!(server.lock().lists[0].1.is_empty());
1199    }
1200
1201    /// A playlist missing from the listing is deleted here only once the
1202    /// server says it does not have it: a listing that comes back empty
1203    /// deletes nothing.
1204    #[test]
1205    fn only_a_playlist_the_server_says_is_gone_is_deleted() {
1206        let dir = tempfile::tempdir().unwrap();
1207        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1208        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1209
1210        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1211            lists: vec![
1212                ("p1".into(), vec!["s1".into()], 1),
1213                ("p2".into(), vec!["s1".into()], 2),
1214            ],
1215            ..Default::default()
1216        }));
1217        let url = serve(server.clone());
1218        let client = SubsonicClient::new(&url, "u", "pw");
1219        reconcile_playlists(&db, &client, &url, "u");
1220        let held = || {
1221            queries::list_playlists(&db.conn, queries::LOCAL_USER)
1222                .unwrap()
1223                .len()
1224        };
1225        assert_eq!(held(), 2);
1226
1227        server.lock().hide_listing = true;
1228        reconcile_playlists(&db, &client, &url, "u");
1229        assert_eq!(held(), 2, "nothing deleted on an empty listing");
1230
1231        {
1232            let mut server = server.lock();
1233            server.hide_listing = false;
1234            server.lists.remove(0);
1235        }
1236        reconcile_playlists(&db, &client, &url, "u");
1237        assert_eq!(held(), 1, "one deleted on the server is deleted here");
1238    }
1239
1240    #[test]
1241    fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
1242        let dir = tempfile::tempdir().unwrap();
1243        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1244        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1245        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1246        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1247        queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
1248            .unwrap();
1249
1250        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1251        let url = serve(server.clone());
1252        let client = SubsonicClient::new(&url, "u", "pw");
1253        let sync = reconcile_playlists(&db, &client, &url, "u");
1254
1255        let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
1256        assert_eq!(list.track_count, 1);
1257        assert_eq!(sync.pushed, 1);
1258        assert_eq!(list.remote_id.as_deref(), Some("p1"));
1259    }
1260
1261    #[test]
1262    fn concurrent_pushes_create_one_server_playlist() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let path = dir.path().join("koan.db");
1265        let db = Database::open(&path).unwrap();
1266        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1267        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1268        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1269
1270        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1271        let url = serve(server.clone());
1272        let pushes: Vec<_> = (0..2)
1273            .map(|_| {
1274                let (path, url) = (path.clone(), url.clone());
1275                std::thread::spawn(move || {
1276                    let db = Database::open(&path).unwrap();
1277                    let client = SubsonicClient::new(&url, "u", "pw");
1278                    push(&db, &client, &account_key(&url, "u"), id)
1279                })
1280            })
1281            .collect();
1282        for p in pushes {
1283            p.join().unwrap().unwrap();
1284        }
1285        assert_eq!(server.lock().creates_without_id, 1);
1286        assert_eq!(server.lock().lists.len(), 1);
1287    }
1288}