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, HashSet};
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::commands::PlayerCommand;
16use crate::player::state::{QueueItemId, SharedPlayerState};
17use crate::remote::client::{SubsonicClient, SubsonicError};
18
19/// Each playlist's earlier states, for undo and redo.
20///
21/// The queue keeps its own (`player::undo`); this is the same for playlists,
22/// held by whoever edits them. An edit records the playlist as it was first;
23/// undoing puts that back exactly, entry ids included, so a queue following
24/// the playlist stays locked to it. Per playlist, so undoing in one never
25/// reaches into another, and in memory: it lasts the session, as the queue's
26/// does.
27#[derive(Default)]
28pub struct PlaylistHistory {
29    steps: parking_lot::Mutex<HashMap<i64, Steps>>,
30}
31
32#[derive(Default)]
33struct Steps {
34    undo: Vec<Vec<(i64, i64)>>,
35    redo: Vec<Vec<(i64, i64)>>,
36}
37
38/// How many edits back a playlist can go, as the queue's history.
39const HISTORY_DEPTH: usize = 100;
40
41/// A playlist as it was before an edit, recorded once the edit has landed.
42pub struct Before {
43    id: i64,
44    entries: Vec<(i64, i64)>,
45}
46
47impl PlaylistHistory {
48    /// The playlist as it is now, taken before an edit.
49    pub fn before(conn: &rusqlite::Connection, id: i64) -> Result<Before, DbError> {
50        Ok(Before {
51            id,
52            entries: queries::entry_snapshot(conn, id)?,
53        })
54    }
55
56    /// After an edit has landed: the playlist as it was before. An edit that
57    /// failed is never recorded, so undo has nothing to step over. A new edit
58    /// forgets what had been undone.
59    pub fn record(&self, before: Before) {
60        let mut steps = self.steps.lock();
61        let steps = steps.entry(before.id).or_default();
62        steps.undo.push(before.entries);
63        if steps.undo.len() > HISTORY_DEPTH {
64            steps.undo.remove(0);
65        }
66        steps.redo.clear();
67    }
68
69    /// Back one edit. Whether there was one.
70    pub fn undo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
71        self.step(conn, id, true)
72    }
73
74    /// Forward one undone edit. Whether there was one.
75    pub fn redo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
76        self.step(conn, id, false)
77    }
78
79    fn step(&self, conn: &rusqlite::Connection, id: i64, back: bool) -> Result<bool, DbError> {
80        let mut all = self.steps.lock();
81        let steps = all.entry(id).or_default();
82        let target = if back {
83            steps.undo.pop()
84        } else {
85            steps.redo.pop()
86        };
87        let Some(target) = target else {
88            return Ok(false);
89        };
90        let now = queries::entry_snapshot(conn, id)?;
91        queries::restore_entries(conn, id, &target)?;
92        if back {
93            steps.redo.push(now)
94        } else {
95            steps.undo.push(now)
96        }
97        Ok(true)
98    }
99}
100
101#[cfg(test)]
102mod history_tests {
103    use super::*;
104    use crate::db::queries::{LOCAL_USER, sample_meta, upsert_track};
105
106    #[test]
107    fn undo_and_redo_walk_a_playlists_edits() {
108        let conn = rusqlite::Connection::open_in_memory().unwrap();
109        conn.pragma_update(None, "foreign_keys", "on").unwrap();
110        crate::db::schema::create_tables(&conn).unwrap();
111        let (a, b) = (
112            upsert_track(&conn, &sample_meta("A", "Artist", "X")).unwrap(),
113            upsert_track(&conn, &sample_meta("B", "Artist", "X")).unwrap(),
114        );
115        let id = queries::create_playlist(&conn, LOCAL_USER, "Mix", None).unwrap();
116        let history = PlaylistHistory::default();
117
118        let before = PlaylistHistory::before(&conn, id).unwrap();
119        queries::add_tracks(&conn, id, &[a, b]).unwrap();
120        history.record(before);
121        let full = queries::entry_snapshot(&conn, id).unwrap();
122        let before = PlaylistHistory::before(&conn, id).unwrap();
123        queries::remove_entries(&conn, id, &[full[0].0]).unwrap();
124        history.record(before);
125
126        assert!(history.undo(&conn, id).unwrap());
127        assert_eq!(queries::entry_snapshot(&conn, id).unwrap(), full);
128        assert!(history.undo(&conn, id).unwrap());
129        assert!(queries::entry_snapshot(&conn, id).unwrap().is_empty());
130        assert!(!history.undo(&conn, id).unwrap(), "nothing further back");
131
132        assert!(history.redo(&conn, id).unwrap());
133        assert_eq!(queries::entry_snapshot(&conn, id).unwrap(), full);
134    }
135}
136
137/// The queue, and the playlist or record it is still exactly.
138///
139/// While the two match, the queue *follows* a playlist: an edit there is an
140/// edit here, quietly. The moment you rearrange the queue yourself or add to
141/// it, they stop matching and the playlist becomes a document you are editing
142/// rather than the thing you are listening to.
143///
144/// A record cannot be edited, so locking to one buys no following — only the
145/// ability to say what you are listening to, which is worth saying.
146///
147/// Derived rather than tracked: there is no flag to keep in sync, nothing to
148/// persist and nothing to migrate, and it cannot get stuck, because a queue
149/// that stops matching stops being locked and one that happens to match again
150/// is locked again. Playing a playlist shuffled scrambles the order on purpose,
151/// so that queue is not locked.
152pub fn queue_lock(db: &Database, state: &SharedPlayerState) -> Option<QueueLock> {
153    let (items, _) = state.snapshot_playlist();
154    if items.is_empty() {
155        return None;
156    }
157
158    // Every item has to have come from the same playlist. One that did not —
159    // played next, dropped in — is the queue having diverged.
160    let entry_ids: Vec<i64> = items.iter().filter_map(|i| i.playlist_entry_id).collect();
161    if entry_ids.len() == items.len()
162        && let Ok(Some(playlist_id)) = queries::playlist_of_entry(&db.conn, entry_ids[0])
163        && queries::playlist_entry_ids(&db.conn, playlist_id).is_ok_and(|ids| ids == entry_ids)
164    {
165        return Some(QueueLock::Playlist(playlist_id));
166    }
167
168    // A record needs no provenance of its own: an album *is* an ordered set of
169    // tracks in the library, so the queue being that album is a question about
170    // the tracks it holds. Which means this works for a queue restored from a
171    // previous session, where nothing remembers where it came from.
172    let track_ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
173    if track_ids.len() != items.len() {
174        return None;
175    }
176    let album_id = queries::get_track_row(&db.conn, track_ids[0])
177        .ok()
178        .flatten()?
179        .album_id?;
180    let album: Vec<i64> = queries::tracks_for_album(&db.conn, album_id)
181        .ok()?
182        .into_iter()
183        .map(|t| t.id)
184        .collect();
185    (album == track_ids).then_some(QueueLock::Album(album_id))
186}
187
188/// What the queue still is, when it is still something.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum QueueLock {
191    Playlist(i64),
192    Album(i64),
193}
194
195/// What a reconciliation did.
196#[derive(Debug, Default, Clone, Copy)]
197pub struct PlaylistSync {
198    /// Playlists taken from the server, new or updated.
199    pub pulled: usize,
200    /// Playlists sent to the server, new or updated.
201    pub pushed: usize,
202}
203
204/// Whether the queue is still exactly this playlist.
205pub fn locked_to(db: &Database, state: &SharedPlayerState, playlist_id: i64) -> bool {
206    queue_lock(db, state) == Some(QueueLock::Playlist(playlist_id))
207}
208
209/// Make the queue follow a playlist that has just been edited.
210///
211/// Only when the queue was still exactly that playlist beforehand —
212/// `was_locked` is read *before* the edit lands, because afterwards the two
213/// no longer match and every queue would look diverged.
214///
215/// The edit is applied to the queue rather than the queue being rebuilt
216/// from the playlist: entries that survived keep their queue items, and
217/// with them their ids, what has played, what is mid-download and what the
218/// cursor is pointing at.
219pub fn follow_edit(
220    db: &Database,
221    state: &SharedPlayerState,
222    playlist_id: i64,
223    was_locked: bool,
224    send: &dyn Fn(PlayerCommand),
225) {
226    if !was_locked {
227        return;
228    }
229    let Ok(entries) = queries::playlist_entries(&db.conn, playlist_id) else {
230        return;
231    };
232    let (items, _) = state.snapshot_playlist();
233    let mut cmds = Vec::new();
234
235    // Queue items whose entry has gone from the playlist.
236    let live: std::collections::HashSet<i64> = entries.iter().map(|e| e.id).collect();
237    let doomed: Vec<QueueItemId> = items
238        .iter()
239        .filter(|i| i.playlist_entry_id.is_none_or(|e| !live.contains(&e)))
240        .map(|i| i.id)
241        .collect();
242    if !doomed.is_empty() {
243        cmds.push(PlayerCommand::RemoveFromPlaylistBatch(doomed.clone()));
244    }
245
246    // Entries the queue has never seen.
247    let known: HashMap<i64, QueueItemId> = items
248        .iter()
249        .filter_map(|i| i.playlist_entry_id.map(|e| (e, i.id)))
250        .collect();
251    let missing: Vec<&queries::PlaylistEntry> = entries
252        .iter()
253        .filter(|e| !known.contains_key(&e.id))
254        .collect();
255    let mut added = HashMap::new();
256    // The queue's order once the removals and additions have landed.
257    let mut landed: Vec<QueueItemId> = items
258        .iter()
259        .map(|i| i.id)
260        .filter(|id| !doomed.contains(id))
261        .collect();
262    if !missing.is_empty() {
263        let track_ids: Vec<i64> = missing.iter().map(|e| e.track.id).collect();
264        let rows = queries::tracks_by_ids(&db.conn, &track_ids).unwrap_or_default();
265        let mut new_items = crate::helpers::playlist_items_for_tracks(db, &rows);
266        for (item, entry) in new_items.iter_mut().zip(&missing) {
267            item.playlist_entry_id = Some(entry.id);
268            added.insert(entry.id, item.id);
269        }
270        if !new_items.is_empty() {
271            landed.extend(new_items.iter().map(|i| i.id));
272            cmds.push(PlayerCommand::AddToPlaylist(new_items));
273        }
274    }
275
276    // Then the order, which is what puts the new arrivals in their places —
277    // they were appended, because that is the only thing an add can do.
278    let order: Vec<QueueItemId> = entries
279        .iter()
280        .filter_map(|e| known.get(&e.id).or_else(|| added.get(&e.id)).copied())
281        .collect();
282    if !order.is_empty() && order != landed {
283        cmds.push(PlayerCommand::ReorderPlaylist(order));
284    }
285
286    // One playlist edit is one step of the queue's undo.
287    if cmds.is_empty() {
288        return;
289    }
290    send(PlayerCommand::BeginUndoBatch);
291    for cmd in cmds {
292        send(cmd);
293    }
294    send(PlayerCommand::EndUndoBatch);
295}
296
297/// Reconcile playlists with the server, both directions.
298///
299/// Unlike favourites, a playlist has an order, so this is not a union. Each
300/// side's change is judged against its own record: the server's `changed`
301/// against the stamp it had at the last sync, the local `changed_at` against
302/// the one that sync covered. Only one side moved: that side wins. Both moved:
303/// last writer wins on `changed`. Neither: nothing is fetched at all.
304///
305/// Playlists that have never been to the server are created there. Ones the
306/// server no longer has are dropped locally: deleting a playlist on Navidrome
307/// and having it reappear on the next sync would make deletion impossible.
308/// Missing from the listing is not enough for that, since a listing can come
309/// back short or empty: the server is asked for each one, and only one it
310/// answers "not found" for (error 70) is dropped. Playlists tied to another
311/// server or account are first made local again, so signing in somewhere else
312/// never reads them as deleted.
313///
314/// A server copy can name songs the library does not have — ones a library
315/// sync has not reached, or ones the server lists in no album. The local copy
316/// holds the rest; a push puts those songs back where they were (see
317/// [`keep_unknown`]), so koan never deletes on the server an entry it could
318/// not show, and the songs fill in here once the library has them (see
319/// [`fill_missing_songs`]).
320pub fn reconcile_playlists(
321    db: &Database,
322    client: &SubsonicClient,
323    url: &str,
324    username: &str,
325) -> PlaylistSync {
326    let mut out = PlaylistSync::default();
327    let account = account_key(url, username);
328
329    let remote = match client.get_playlists() {
330        Ok(lists) => lists,
331        Err(e) => {
332            log::warn!("could not fetch playlists from the server: {e}");
333            return out;
334        }
335    };
336
337    match queries::detach_playlists_from_other_accounts(&db.conn, &account) {
338        Ok(0) => {}
339        Ok(n) => log::info!("{n} playlists belonged to another server; keeping them as local"),
340        Err(e) => {
341            log::warn!("could not check which server playlists belong to: {e}");
342            return out;
343        }
344    }
345
346    // Only playlists this user owns are ours to write back. A public playlist
347    // belonging to someone else is still worth having locally, but pushing our
348    // copy of it would be editing their playlist.
349    let mut seen_remote_ids = Vec::new();
350
351    for summary in &remote {
352        seen_remote_ids.push(summary.id.clone());
353        let local = match queries::playlist_by_remote_id(&db.conn, &summary.id) {
354            Ok(local) => local,
355            Err(e) => {
356                log::warn!("could not look up playlist {}: {e}", summary.name);
357                continue;
358            }
359        };
360        // A read-only playlist (a smart one on the server) is pulled and
361        // never pushed, whoever owns it.
362        let ours = !summary.readonly
363            && summary
364                .owner
365                .as_deref()
366                .is_none_or(|owner| owner == username);
367
368        if let Some(local) = &local {
369            let server_moved = summary.changed.as_deref() != local.remote_changed.as_deref();
370            let local_moved = ours && local.unsynced();
371            if local_moved
372                && (!server_moved || newer(&local.changed_at, summary.changed.as_deref()))
373            {
374                if push(db, client, &account, local.id).is_ok() {
375                    out.pushed += 1;
376                }
377                continue;
378            }
379            if !server_moved && !local_moved {
380                continue;
381            }
382        }
383
384        let full = match client.get_playlist(&summary.id) {
385            Ok(full) => full,
386            Err(e) => {
387                log::warn!("could not fetch playlist {}: {e}", summary.id);
388                continue;
389            }
390        };
391
392        let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
393        let track_ids = match queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids) {
394            Ok(ids) => ids,
395            Err(e) => {
396                log::warn!("could not read playlist {}: {e}", summary.name);
397                continue;
398            }
399        };
400        let unknown = track_ids.iter().filter(|t| t.is_none()).count();
401        if unknown > 0 {
402            log::info!(
403                "playlist {}: {unknown} of {} songs are not in the library; \
404                 they stay on the server",
405                summary.name,
406                track_ids.len()
407            );
408        }
409        let record = queries::ServerSongs::from_lookup(remote_song_ids, &track_ids);
410        let track_ids: Vec<i64> = track_ids.into_iter().flatten().collect();
411
412        // All of it or none: a copy renamed but not refilled, or refilled but
413        // not marked synced, would push back as an edit nobody made.
414        let stored = queries::atomically(&db.conn, || {
415            let id = match &local {
416                Some(local) => local.id,
417                None => queries::create_playlist(
418                    &db.conn,
419                    queries::LOCAL_USER,
420                    &summary.name,
421                    summary.comment.as_deref(),
422                )?,
423            };
424            queries::rename_playlist(&db.conn, id, &summary.name)?;
425            queries::set_playlist_readonly(&db.conn, id, summary.readonly)?;
426            queries::set_playlist_remote(
427                &db.conn,
428                id,
429                &summary.id,
430                summary.owner.as_deref(),
431                summary.public,
432                &account,
433            )?;
434            queries::merge_server_tracks(&db.conn, id, &track_ids)?;
435            queries::set_playlist_server_songs(&db.conn, id, &record)?;
436            // The rename stamped the local copy as changed; this is the
437            // server's copy, so what is here now is what the server has.
438            queries::mark_playlist_synced(&db.conn, id, summary.changed.as_deref(), None)
439        });
440        match stored {
441            Ok(()) => out.pulled += 1,
442            Err(e) => log::warn!("could not store playlist {}: {e}", summary.name),
443        }
444    }
445
446    // A playlist we hold a server id for that the server no longer lists was
447    // deleted there.
448    let held = match queries::list_playlists(&db.conn, queries::LOCAL_USER) {
449        Ok(lists) => lists,
450        Err(e) => {
451            log::warn!("could not list playlists to check for deletions: {e}");
452            Vec::new()
453        }
454    };
455    for local in &held {
456        let Some(remote_id) = local.remote_id.as_deref() else {
457            continue;
458        };
459        if seen_remote_ids.iter().any(|seen| seen == remote_id) {
460            continue;
461        }
462        match client.get_playlist(remote_id) {
463            Err(SubsonicError::Api { code: 70, .. }) => {
464                if let Err(e) = queries::delete_playlist(&db.conn, local.id) {
465                    log::warn!("could not delete playlist {}: {e}", local.name);
466                }
467            }
468            Ok(_) => log::info!(
469                "playlist {} was missing from the listing but still exists",
470                local.name
471            ),
472            Err(e) => log::warn!(
473                "could not confirm playlist {} was deleted ({e}); keeping it",
474                local.name
475            ),
476        }
477    }
478
479    for local in
480        queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
481    {
482        if push(db, client, &account, local.id).is_ok() {
483            out.pushed += 1;
484        }
485    }
486
487    fill_missing_songs(db);
488    out
489}
490
491/// Put the songs a server copy named that the library lacked into their
492/// places once the library has them.
493///
494/// The record (see [`queries::ServerSongs`]) is the server's copy as of the
495/// last pull or push; a playlist whose copy has moved on either side since is
496/// pulled or pushed afresh, which records again. So this needs no request:
497/// what the server holds is already known, only the library has changed.
498///
499/// A playlist edited here since is left alone: the edit is what its next push
500/// sends, and the push records afresh. The check is made under the push lock
501/// and in the write transaction, so no edit or push lands between it and the
502/// refill.
503fn fill_missing_songs(db: &Database) {
504    let ids = match queries::playlists_missing_songs(&db.conn) {
505        Ok(ids) => ids,
506        Err(e) => {
507            log::warn!("could not read the playlists missing songs: {e}");
508            return;
509        }
510    };
511    for id in ids {
512        let lock = push_lock(id);
513        let _held = lock.lock();
514        let filled = queries::atomically(&db.conn, || -> Result<(), DbError> {
515            let synced = queries::get_playlist(&db.conn, id)?.is_some_and(|p| !p.unsynced());
516            let Some(record) = queries::playlist_server_songs(&db.conn, id)?.filter(|_| synced)
517            else {
518                return Ok(());
519            };
520            let track_ids = queries::track_ids_for_remote_ids(&db.conn, &record.songs)?;
521            let now = queries::ServerSongs::from_lookup(record.songs.clone(), &track_ids);
522            let still: HashSet<&str> = now.missing.iter().map(String::as_str).collect();
523            if record.missing.iter().any(|s| !still.contains(s.as_str())) {
524                let track_ids: Vec<i64> = track_ids.into_iter().flatten().collect();
525                queries::merge_server_tracks(&db.conn, id, &track_ids)?;
526            }
527            if now != record {
528                queries::set_playlist_server_songs(&db.conn, id, &now)?;
529            }
530            Ok(())
531        });
532        if let Err(e) = filled {
533            log::warn!("could not fill in playlist {id}: {e}");
534        }
535    }
536}
537
538/// What a playlist's `remote_id` is relative to: one account on one server.
539fn account_key(url: &str, username: &str) -> String {
540    format!("{username}@{}", url.trim_end_matches('/'))
541}
542
543/// One lock per playlist, held for the length of a push.
544///
545/// Pushes are fired from every edit, and two in flight at once would race: both
546/// could see no server id and create two playlists there, or land out of
547/// order and leave the server holding the older contents. Held, each push
548/// reads the row only once the one before it has written its answer back.
549fn push_lock(id: i64) -> std::sync::Arc<parking_lot::Mutex<()>> {
550    static LOCKS: std::sync::OnceLock<
551        parking_lot::Mutex<std::collections::HashMap<i64, std::sync::Arc<parking_lot::Mutex<()>>>>,
552    > = std::sync::OnceLock::new();
553    LOCKS
554        .get_or_init(Default::default)
555        .lock()
556        .entry(id)
557        .or_default()
558        .clone()
559}
560
561/// Send a playlist's name and contents to the server, in order.
562///
563/// `createPlaylist` with a `playlistId` replaces the contents wholesale, which
564/// is the only Subsonic call that can express a reorder — so a push is always
565/// the whole list rather than a diff.
566fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
567    let lock = push_lock(id);
568    let _held = lock.lock();
569    // A server id from another account names someone else's playlist here.
570    if queries::detach_playlists_from_other_accounts(&db.conn, account).is_err() {
571        return Err(());
572    }
573
574    let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
575        return Err(());
576    };
577    // A smart playlist here, or a read-only one there: neither side's copy
578    // is for the other to overwrite.
579    if local.readonly {
580        return Err(());
581    }
582    let remote_id = local.remote_id.as_deref();
583    let song_ids = match queries::remote_ids_for_playlist(&db.conn, id) {
584        Ok(ids) => ids,
585        Err(e) => {
586            log::warn!("could not read playlist '{}' to push it: {e}", local.name);
587            return Err(());
588        }
589    };
590
591    // A playlist made entirely of local files has nothing the server could
592    // point at. Creating an empty one there would be worse than not creating
593    // it, and emptying one there is only right when it is empty here too.
594    if song_ids.is_empty() && (remote_id.is_none() || local.track_count > 0) {
595        if remote_id.is_some() {
596            log::warn!(
597                "playlist '{}' holds nothing the server has; not emptying the server's copy",
598                local.name
599            );
600        }
601        return Err(());
602    }
603
604    // The push replaces the server's copy wholesale, so a song there that
605    // this copy never held would be deleted by it without anyone having
606    // chosen to. That is one the library does not know, or one it learnt of
607    // after this copy was pulled without it. Those go back in where they were.
608    let record = match queries::playlist_server_songs(&db.conn, id) {
609        Ok(record) => record.unwrap_or_default(),
610        Err(e) => {
611            log::warn!("could not read playlist '{}' to push it: {e}", local.name);
612            return Err(());
613        }
614    };
615    let held: HashSet<String> = song_ids.iter().cloned().collect();
616    let never_held: HashSet<&str> = record
617        .missing
618        .iter()
619        .filter(|s| !held.contains(*s))
620        .map(String::as_str)
621        .collect();
622    let song_ids = match remote_id {
623        None => song_ids,
624        Some(remote_id) => {
625            let server = client
626                .get_playlist(remote_id)
627                .map_err(|e| e.to_string())
628                .and_then(|full| {
629                    let ids: Vec<String> = full.entry.into_iter().map(|s| s.id).collect();
630                    let known = queries::track_ids_for_remote_ids(&db.conn, &ids)
631                        .map_err(|e| e.to_string())?;
632                    Ok((ids, known))
633                });
634            match server {
635                Ok((ids, known)) => {
636                    let known: Vec<bool> = ids
637                        .iter()
638                        .zip(&known)
639                        .map(|(id, track)| track.is_some() && !never_held.contains(id.as_str()))
640                        .collect();
641                    keep_unknown(&ids, &known, song_ids)
642                }
643                Err(e) => {
644                    log::warn!(
645                        "could not read playlist '{}' on the server before pushing: {e}",
646                        local.name
647                    );
648                    return Err(());
649                }
650            }
651        }
652    };
653
654    // The name has to travel on its own. Navidrome's `createPlaylist` with a
655    // `playlistId` replaces the songs and ignores the `name` it is handed, so a
656    // rename pushed that way changes nothing. `updatePlaylist` is the call that
657    // carries metadata; `createPlaylist` is the one that carries order. A push
658    // needs both.
659    if let Some(remote_id) = remote_id
660        && let Err(e) = client.update_playlist(
661            remote_id,
662            Some(&local.name),
663            local.comment.as_deref(),
664            Some(local.public),
665        )
666    {
667        log::warn!(
668            "could not rename playlist '{}' on the server: {e}",
669            local.name
670        );
671    }
672
673    match client.create_playlist(remote_id, &local.name, &song_ids) {
674        Ok(created) => {
675            let new_id = created
676                .as_ref()
677                .map(|c| c.playlist.id.clone())
678                .or_else(|| remote_id.map(str::to_string));
679            if let Some(new_id) = new_id {
680                let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
681                let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
682                let recorded = queries::atomically(&db.conn, || {
683                    queries::set_playlist_remote(
684                        &db.conn,
685                        id,
686                        &new_id,
687                        owner.as_deref(),
688                        local.public,
689                        account,
690                    )?;
691                    queries::set_playlist_server_songs(
692                        &db.conn,
693                        id,
694                        &queries::ServerSongs {
695                            missing: song_ids
696                                .iter()
697                                .filter(|s| !held.contains(*s))
698                                .cloned()
699                                .collect(),
700                            songs: song_ids.clone(),
701                        },
702                    )?;
703                    queries::mark_playlist_synced(
704                        &db.conn,
705                        id,
706                        changed.as_deref(),
707                        Some(local.revision),
708                    )
709                });
710                if let Err(e) = recorded {
711                    log::warn!(
712                        "playlist '{}' was pushed but its server id was not recorded: {e}",
713                        local.name
714                    );
715                    return Err(());
716                }
717            }
718            Ok(())
719        }
720        Err(e) => {
721            log::warn!(
722                "could not push playlist '{}' to the server: {e}",
723                local.name
724            );
725            Err(())
726        }
727    }
728}
729
730/// `local`, the song ids a push sends, with the songs of the server's copy
731/// `server` that the library does not know (`known` false) put back: each
732/// after the nearest song before it on the server that `local` still holds,
733/// or first when there is none. The library never held them, so no edit here
734/// can have removed them.
735fn keep_unknown(server: &[String], known: &[bool], local: Vec<String>) -> Vec<String> {
736    let mut after: HashMap<&str, Vec<String>> = HashMap::new();
737    let mut head = Vec::new();
738    let mut anchor: Option<&str> = None;
739    for (id, &known) in server.iter().zip(known) {
740        if known {
741            if local.iter().any(|l| l == id) {
742                anchor = Some(id);
743            }
744        } else {
745            match anchor {
746                Some(a) => after.entry(a).or_default().push(id.clone()),
747                None => head.push(id.clone()),
748            }
749        }
750    }
751    let mut out = head;
752    for id in local {
753        let follow = after.remove(id.as_str());
754        out.push(id);
755        out.extend(follow.into_iter().flatten());
756    }
757    out
758}
759
760/// Whether `local` was changed after the server's copy was.
761///
762/// Only consulted when both sides changed since the last sync. Both are
763/// ISO 8601 in UTC — SQLite's `datetime('now')` on our side, the server's own
764/// stamp on theirs — near enough that comparing the digits works, once
765/// SQLite's space is made a `T`. A server that sends no timestamp at all
766/// cannot be shown to be newer, so ours wins and the push settles it.
767fn newer(local: &str, remote: Option<&str>) -> bool {
768    let Some(remote) = remote else { return true };
769    let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
770    normalise(local) > normalise(remote)
771}
772
773/// Push a playlist to the server in the background, if there is one.
774///
775/// Fire and forget on its own thread, the way favourites are: the local copy is
776/// already written, and a slow server should not hold up the edit that caused
777/// this. A failure leaves the local copy unsynced, which is exactly what
778/// [`reconcile_playlists`] resolves on the next sync.
779///
780/// The thread takes its own connection rather than borrowing the caller's:
781/// a `rusqlite::Connection` is not `Sync`, and the answer has to be written
782/// back — the new server id — so it needs one of its own.
783pub fn push_to_remote(id: i64) {
784    let cfg = Config::load().unwrap_or_default();
785    if !cfg.remote.enabled {
786        return;
787    }
788    let Some(client) = subsonic_client(&cfg) else {
789        return;
790    };
791    let account = account_key(&cfg.remote.url, &cfg.remote.username);
792    std::thread::Builder::new()
793        .name("koan-playlist-sync".into())
794        .spawn(move || {
795            let Ok(db) = crate::db::pool::shared().get() else {
796                return;
797            };
798            let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
799                return;
800            };
801            // The upstream server has one account, and it is the local user's.
802            if !matches!(
803                queries::auth::is_local_user(&db.conn, list.user_id),
804                Ok(true)
805            ) {
806                return;
807            }
808            let _ = push(&db, &client, &account, id);
809        })
810        .ok();
811}
812
813/// Delete a playlist on the server. Nothing to do for one that never went.
814pub fn delete_on_remote(remote_id: String) {
815    let cfg = Config::load().unwrap_or_default();
816    if !cfg.remote.enabled {
817        return;
818    }
819    let Some(client) = subsonic_client(&cfg) else {
820        return;
821    };
822    std::thread::Builder::new()
823        .name("koan-playlist-sync".into())
824        .spawn(move || {
825            if let Err(e) = client.delete_playlist(&remote_id) {
826                log::warn!("could not delete playlist {remote_id} on the server: {e}");
827            }
828        })
829        .ok();
830}
831
832/// What an export wrote, and what it could not.
833#[derive(Debug, Default, Clone, Copy)]
834pub struct ExportSummary {
835    pub written: usize,
836    /// Tracks with no file on this machine. A playlist file is a list of
837    /// paths, and a remote track that has never been downloaded has none.
838    pub skipped: usize,
839}
840
841/// Write a playlist as an extended M3U8.
842///
843/// Absolute paths, UTF-8, `#EXTINF` per entry — the format every player still
844/// reads. Remote tracks that have not been downloaded are left out rather than
845/// written as stream URLs: a Subsonic stream URL carries the credentials that
846/// authorise it, and a playlist file is something people mail to each other.
847pub fn export_m3u8(
848    db: &Database,
849    playlist_id: i64,
850    dest: &Path,
851) -> Result<ExportSummary, std::io::Error> {
852    let name = queries::get_playlist(&db.conn, playlist_id)
853        .ok()
854        .flatten()
855        .map(|p| p.name)
856        .unwrap_or_default();
857    let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
858
859    let mut out = ExportSummary::default();
860    let mut file = std::fs::File::create(dest)?;
861    writeln!(file, "#EXTM3U")?;
862    if !name.is_empty() {
863        writeln!(file, "#PLAYLIST:{name}")?;
864    }
865
866    for track in &tracks {
867        let path = track
868            .path
869            .as_deref()
870            .or(track.cached_path.as_deref())
871            .map(PathBuf::from)
872            .filter(|p| p.exists());
873        let Some(path) = path else {
874            out.skipped += 1;
875            continue;
876        };
877        let seconds = track.duration_ms.unwrap_or(0) / 1000;
878        writeln!(
879            file,
880            "#EXTINF:{seconds},{} - {}",
881            track.artist_name, track.title
882        )?;
883        writeln!(file, "{}", path.display())?;
884        out.written += 1;
885    }
886
887    Ok(out)
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use crate::db::queries::{TrackMeta, upsert_track};
894
895    fn meta(title: &str, path: &Path) -> TrackMeta {
896        TrackMeta {
897            title: title.into(),
898            artist: "Artist".into(),
899            album_artist: Some("Artist".into()),
900            album: "Album".into(),
901            date: None,
902            disc: None,
903            track_number: None,
904            genre: None,
905            label: None,
906            duration_ms: Some(185_000),
907            codec: Some("FLAC".into()),
908            sample_rate: None,
909            bit_depth: None,
910            channels: None,
911            bitrate: None,
912            size_bytes: None,
913            mtime: None,
914            path: Some(path.to_string_lossy().into_owned()),
915            source: "local".into(),
916            remote_id: None,
917            remote_url: None,
918            album_remote_id: None,
919            artist_remote_id: None,
920            mbid: None,
921            album_mbid: None,
922            album_added_at: None,
923        }
924    }
925
926    /// The queue is locked while it is still exactly the playlist, and stops
927    /// being the moment it is not. Everything else about following follows from
928    /// this one answer.
929    #[test]
930    fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
931        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
932
933        let dir = tempfile::tempdir().unwrap();
934        let db = Database::open(&dir.path().join("koan.db")).unwrap();
935        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
936        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
937
938        let id =
939            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
940                .unwrap();
941        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
942
943        let state = SharedPlayerState::new();
944        let queued = |entry: Option<i64>| PlaylistItem {
945            uid: None,
946            id: QueueItemId::new(),
947            db_id: Some(a),
948            playlist_entry_id: entry,
949            path: dir.path().join("a.flac"),
950            title: "A".into(),
951            artist: "Artist".into(),
952            album_artist: "Artist".into(),
953            album: "Album".into(),
954            year: None,
955            codec: None,
956            track_number: None,
957            disc: None,
958            duration_ms: None,
959            state: ItemState::Ready,
960            played: false,
961        };
962
963        assert_eq!(
964            queue_lock(&db, &state),
965            None,
966            "an empty queue is not locked"
967        );
968
969        state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
970        assert_eq!(
971            queue_lock(&db, &state),
972            Some(QueueLock::Playlist(id)),
973            "the queue is the playlist"
974        );
975
976        // Something that never came from the playlist — played next, dropped
977        // in.
978        state.add_items(vec![queued(None)]);
979        assert_eq!(queue_lock(&db, &state), None);
980    }
981
982    /// Reordering the queue by hand ends the lock, which is the whole point of
983    /// deriving it: there is no flag anyone has to remember to clear.
984    #[test]
985    fn rearranging_the_queue_ends_the_lock() {
986        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
987
988        let dir = tempfile::tempdir().unwrap();
989        let db = Database::open(&dir.path().join("koan.db")).unwrap();
990        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
991        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
992        let id =
993            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
994                .unwrap();
995        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
996
997        let state = SharedPlayerState::new();
998        let items: Vec<PlaylistItem> = entries
999            .iter()
1000            .map(|entry| PlaylistItem {
1001                uid: None,
1002                id: QueueItemId::new(),
1003                db_id: Some(a),
1004                playlist_entry_id: Some(*entry),
1005                path: dir.path().join("a.flac"),
1006                title: "A".into(),
1007                artist: "Artist".into(),
1008                album_artist: "Artist".into(),
1009                album: "Album".into(),
1010                year: None,
1011                codec: None,
1012                track_number: None,
1013                disc: None,
1014                duration_ms: None,
1015                state: ItemState::Ready,
1016                played: false,
1017            })
1018            .collect();
1019        let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1020        state.add_items(items);
1021        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
1022
1023        state.reorder_to(&[ids[1], ids[0]]);
1024        assert_eq!(
1025            queue_lock(&db, &state),
1026            None,
1027            "same tracks, different order — no longer the playlist"
1028        );
1029
1030        // And the playlist catching up locks it again. Nothing had to be reset.
1031        queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
1032        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
1033    }
1034
1035    /// One playlist edit followed by the queue is one step of the queue's undo,
1036    /// and a removal that leaves the order as it was sends no reorder.
1037    #[test]
1038    fn following_an_edit_is_one_undo_step() {
1039        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
1040
1041        let dir = tempfile::tempdir().unwrap();
1042        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1043        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
1044        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
1045        let c = upsert_track(&db.conn, &meta("C", &dir.path().join("c.flac"))).unwrap();
1046        let id =
1047            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
1048                .unwrap();
1049        let entries = queries::add_tracks(&db.conn, id, &[a, b, c]).unwrap();
1050
1051        let state = SharedPlayerState::new();
1052        let items: Vec<PlaylistItem> = entries
1053            .iter()
1054            .map(|entry| PlaylistItem {
1055                uid: None,
1056                id: QueueItemId::new(),
1057                db_id: Some(a),
1058                playlist_entry_id: Some(*entry),
1059                path: dir.path().join("a.flac"),
1060                title: "A".into(),
1061                artist: "Artist".into(),
1062                album_artist: "Artist".into(),
1063                album: "Album".into(),
1064                year: None,
1065                codec: None,
1066                track_number: None,
1067                disc: None,
1068                duration_ms: None,
1069                state: ItemState::Ready,
1070                played: false,
1071            })
1072            .collect();
1073        let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1074        state.add_items(items);
1075
1076        let follow = |was_locked| {
1077            let sent = std::cell::RefCell::new(Vec::new());
1078            follow_edit(&db, &state, id, was_locked, &|cmd| {
1079                sent.borrow_mut().push(cmd)
1080            });
1081            sent.into_inner()
1082        };
1083
1084        assert!(follow(true).is_empty(), "nothing changed, nothing sent");
1085
1086        queries::remove_entries(&db.conn, id, &[entries[1]]).unwrap();
1087        let sent = follow(true);
1088        assert!(
1089            matches!(
1090                sent.as_slice(),
1091                [
1092                    PlayerCommand::BeginUndoBatch,
1093                    PlayerCommand::RemoveFromPlaylistBatch(gone),
1094                    PlayerCommand::EndUndoBatch,
1095                ] if gone == &[ids[1]]
1096            ),
1097            "{sent:?}"
1098        );
1099
1100        state.remove_item(ids[1]);
1101        queries::reorder_entries(&db.conn, id, &[entries[2], entries[0]]).unwrap();
1102        let sent = follow(true);
1103        assert!(
1104            matches!(
1105                sent.as_slice(),
1106                [
1107                    PlayerCommand::BeginUndoBatch,
1108                    PlayerCommand::ReorderPlaylist(order),
1109                    PlayerCommand::EndUndoBatch,
1110                ] if order == &[ids[2], ids[0]]
1111            ),
1112            "{sent:?}"
1113        );
1114    }
1115
1116    /// A record needs no provenance: it *is* an ordered set of tracks, so the
1117    /// queue being that record is a question about what the queue holds. Which
1118    /// is why it survives a relaunch, where nothing remembers what was played.
1119    #[test]
1120    fn a_queue_holding_exactly_one_record_is_locked_to_it() {
1121        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
1122
1123        let dir = tempfile::tempdir().unwrap();
1124        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1125        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
1126        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
1127        let album_id = queries::get_track_row(&db.conn, a)
1128            .unwrap()
1129            .unwrap()
1130            .album_id
1131            .unwrap();
1132
1133        let state = SharedPlayerState::new();
1134        let queued = |track: i64| PlaylistItem {
1135            uid: None,
1136            id: QueueItemId::new(),
1137            db_id: Some(track),
1138            playlist_entry_id: None,
1139            path: dir.path().join("a.flac"),
1140            title: "A".into(),
1141            artist: "Artist".into(),
1142            album_artist: "Artist".into(),
1143            album: "Album".into(),
1144            year: None,
1145            codec: None,
1146            track_number: None,
1147            disc: None,
1148            duration_ms: None,
1149            state: ItemState::Ready,
1150            played: false,
1151        };
1152
1153        state.add_items(vec![queued(a)]);
1154        assert_eq!(
1155            queue_lock(&db, &state),
1156            None,
1157            "half a record is not the record"
1158        );
1159
1160        state.add_items(vec![queued(b)]);
1161        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
1162    }
1163
1164    #[test]
1165    fn export_writes_what_is_on_disk_and_counts_what_is_not() {
1166        let dir = tempfile::tempdir().unwrap();
1167        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1168
1169        let present = dir.path().join("here.flac");
1170        std::fs::write(&present, b"x").unwrap();
1171        let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
1172        let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
1173
1174        let id =
1175            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
1176                .unwrap();
1177        queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
1178
1179        let dest = dir.path().join("evening.m3u8");
1180        let summary = export_m3u8(&db, id, &dest).unwrap();
1181        assert_eq!((summary.written, summary.skipped), (1, 1));
1182
1183        let written = std::fs::read_to_string(&dest).unwrap();
1184        assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
1185        assert!(written.contains("#EXTINF:185,Artist - Here"));
1186        assert!(written.contains(&present.display().to_string()));
1187        assert!(!written.contains("gone.flac"));
1188    }
1189
1190    /// A Subsonic server holding playlists and nothing else, one request per
1191    /// connection. Each `changed` is a counter, so every write moves it.
1192    #[derive(Default)]
1193    struct Server {
1194        lists: Vec<(String, Vec<String>, u32)>,
1195        fetches: usize,
1196        creates_without_id: usize,
1197        /// `getPlaylists` answers with none, as a server answering wrongly.
1198        hide_listing: bool,
1199    }
1200
1201    fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
1202        use std::io::{BufRead, Write};
1203        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1204        let url = format!("http://{}", listener.local_addr().unwrap());
1205        std::thread::spawn(move || {
1206            for stream in listener.incoming().flatten() {
1207                let server = server.clone();
1208                std::thread::spawn(move || {
1209                    let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
1210                    let mut request = String::new();
1211                    reader.read_line(&mut request).unwrap();
1212                    let mut line = String::new();
1213                    while reader.read_line(&mut line).unwrap_or(0) > 2 {
1214                        line.clear();
1215                    }
1216                    let target = request.split_whitespace().nth(1).unwrap_or("");
1217                    let (path, query) = target.split_once('?').unwrap_or((target, ""));
1218                    let params: Vec<(&str, &str)> = query
1219                        .split('&')
1220                        .filter_map(|kv| kv.split_once('='))
1221                        .collect();
1222                    let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
1223                    let body = respond(&server, path.rsplit('/').next().unwrap(), &params, param);
1224                    let mut stream = stream;
1225                    let _ = write!(
1226                        stream,
1227                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
1228                        body.len()
1229                    );
1230                });
1231            }
1232        });
1233        url
1234    }
1235
1236    fn respond<'a>(
1237        server: &parking_lot::Mutex<Server>,
1238        endpoint: &str,
1239        params: &[(&str, &'a str)],
1240        param: impl Fn(&str) -> Option<&'a str>,
1241    ) -> String {
1242        let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
1243            format!(
1244                r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
1245                songs.len()
1246            )
1247        };
1248        let full = |list: &(String, Vec<String>, u32)| {
1249            let entries: Vec<String> = list
1250                .1
1251                .iter()
1252                .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
1253                .collect();
1254            format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
1255        };
1256        let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
1257        match endpoint {
1258            "getPlaylists" => {
1259                let server = server.lock();
1260                let lists: Vec<String> = server
1261                    .lists
1262                    .iter()
1263                    .filter(|_| !server.hide_listing)
1264                    .map(|l| format!("{{{}}}", summary(l)))
1265                    .collect();
1266                ok(format!(
1267                    r#","playlists":{{"playlist":[{}]}}"#,
1268                    lists.join(",")
1269                ))
1270            }
1271            "getPlaylist" => {
1272                let mut server = server.lock();
1273                server.fetches += 1;
1274                match server
1275                    .lists
1276                    .iter()
1277                    .find(|l| Some(l.0.as_str()) == param("id"))
1278                {
1279                    Some(list) => ok(format!(r#","playlist":{}"#, full(list))),
1280                    None => r#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"not found"}}}"#.to_string(),
1281                }
1282            }
1283            "createPlaylist" => {
1284                let songs: Vec<String> = params
1285                    .iter()
1286                    .filter(|(k, _)| *k == "songId")
1287                    .map(|(_, v)| v.to_string())
1288                    .collect();
1289                // Wide enough for two pushes to overlap if nothing stops them.
1290                std::thread::sleep(std::time::Duration::from_millis(50));
1291                let mut server = server.lock();
1292                let id = match param("playlistId") {
1293                    Some(id) => id.to_string(),
1294                    None => {
1295                        server.creates_without_id += 1;
1296                        format!("p{}", server.lists.len() + 1)
1297                    }
1298                };
1299                server.lists.retain(|l| l.0 != id);
1300                let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
1301                server.lists.push((id, songs, changed));
1302                ok(format!(
1303                    r#","playlist":{}"#,
1304                    full(server.lists.last().unwrap())
1305                ))
1306            }
1307            _ => ok(String::new()),
1308        }
1309    }
1310
1311    fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
1312        TrackMeta {
1313            path: None,
1314            source: "remote".into(),
1315            remote_id: Some(remote_id.into()),
1316            album: title.into(),
1317            ..meta(title, Path::new(""))
1318        }
1319    }
1320
1321    fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
1322        queries::playlist_entries(&db.conn, id)
1323            .unwrap()
1324            .into_iter()
1325            .map(|e| (e.id, e.track.id))
1326            .collect()
1327    }
1328
1329    #[test]
1330    fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
1331        let dir = tempfile::tempdir().unwrap();
1332        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1333        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1334        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1335        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1336
1337        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1338            lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
1339            ..Default::default()
1340        }));
1341        let url = serve(server.clone());
1342        let client = SubsonicClient::new(&url, "u", "pw");
1343
1344        let first = reconcile_playlists(&db, &client, &url, "u");
1345        assert_eq!(first.pulled, 1);
1346        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1347            .unwrap()
1348            .unwrap()
1349            .id;
1350        assert_eq!(
1351            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1352            [r1, r2]
1353        );
1354
1355        // A local file joins; the push carries only what the server can name.
1356        queries::add_tracks(&db.conn, id, &[local]).unwrap();
1357        let second = reconcile_playlists(&db, &client, &url, "u");
1358        assert_eq!((second.pushed, second.pulled), (1, 0));
1359        assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
1360
1361        let before = entries(&db, id);
1362        assert_eq!(before.len(), 3);
1363        let fetches = server.lock().fetches;
1364        let third = reconcile_playlists(&db, &client, &url, "u");
1365        assert_eq!((third.pushed, third.pulled), (0, 0));
1366        assert_eq!(
1367            server.lock().fetches,
1368            fetches,
1369            "nothing moved, nothing fetched"
1370        );
1371        assert_eq!(entries(&db, id), before);
1372
1373        // An edit on the server comes down without the local file or the
1374        // surviving entry's id.
1375        {
1376            let mut server = server.lock();
1377            server.lists[0].1 = vec!["s2".into()];
1378            server.lists[0].2 += 1;
1379        }
1380        let fourth = reconcile_playlists(&db, &client, &url, "u");
1381        assert_eq!(fourth.pulled, 1);
1382        assert_eq!(entries(&db, id), [before[1], before[2]]);
1383    }
1384
1385    /// Forgetting the server takes its tracks out of every playlist; signing
1386    /// back in and syncing the library brings them back, and the playlists
1387    /// are pulled whole again even though the server's copies never changed.
1388    #[test]
1389    fn a_forgotten_server_refills_its_playlists_on_the_next_sync() {
1390        let dir = tempfile::tempdir().unwrap();
1391        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1392        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1393        upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1394
1395        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1396            lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
1397            ..Default::default()
1398        }));
1399        let url = serve(server.clone());
1400        let client = SubsonicClient::new(&url, "u", "pw");
1401        reconcile_playlists(&db, &client, &url, "u");
1402        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1403            .unwrap()
1404            .unwrap()
1405            .id;
1406        assert_eq!(entries(&db, id).len(), 2);
1407
1408        crate::helpers::forget_remote(&db).unwrap();
1409        assert!(entries(&db, id).is_empty());
1410
1411        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1412        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1413        let sync = reconcile_playlists(&db, &client, &url, "u");
1414        assert_eq!((sync.pulled, sync.pushed), (1, 0));
1415        assert_eq!(
1416            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1417            [r1, r2]
1418        );
1419        assert_eq!(
1420            server.lock().lists[0].1,
1421            ["s1", "s2"],
1422            "the server copy is untouched"
1423        );
1424    }
1425
1426    /// A song on the server's copy that the library does not have is left
1427    /// out of the local copy and put back, in place, by every push: nothing
1428    /// here could have removed it.
1429    #[test]
1430    fn a_server_song_the_library_lacks_is_never_dropped() {
1431        let dir = tempfile::tempdir().unwrap();
1432        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1433        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1434        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1435
1436        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1437            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1438            ..Default::default()
1439        }));
1440        let url = serve(server.clone());
1441        let client = SubsonicClient::new(&url, "u", "pw");
1442        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1443        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1444            .unwrap()
1445            .unwrap()
1446            .id;
1447        assert_eq!(
1448            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1449            [r1]
1450        );
1451
1452        queries::add_tracks(&db.conn, id, &[r2]).unwrap();
1453        assert!(push(&db, &client, &account_key(&url, "u"), id).is_ok());
1454        assert_eq!(server.lock().lists[0].1, ["s1", "s9", "s2"]);
1455
1456        // The library catches up, and the server's copy comes down whole.
1457        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1458        server.lock().lists[0].2 += 1;
1459        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1460        assert_eq!(
1461            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1462            [r1, r9, r2]
1463        );
1464    }
1465
1466    fn missing(db: &Database, id: i64) -> Option<Vec<String>> {
1467        queries::playlist_server_songs(&db.conn, id)
1468            .unwrap()
1469            .map(|r| r.missing)
1470    }
1471
1472    /// Songs a pulled playlist named before the library had them fill in, in
1473    /// the server's order, on the sync after the library gains them, without
1474    /// the server being asked for the playlist again.
1475    #[test]
1476    fn songs_the_library_lacked_fill_in_when_they_arrive() {
1477        let dir = tempfile::tempdir().unwrap();
1478        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1479        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1480        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1481
1482        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1483            lists: vec![(
1484                "p1".into(),
1485                vec!["s1".into(), "s9".into(), "s2".into(), "s8".into()],
1486                1,
1487            )],
1488            ..Default::default()
1489        }));
1490        let url = serve(server.clone());
1491        let client = SubsonicClient::new(&url, "u", "pw");
1492        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1493        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1494            .unwrap()
1495            .unwrap()
1496            .id;
1497        let before = entries(&db, id);
1498        assert_eq!(before.iter().map(|e| e.1).collect::<Vec<_>>(), [r1, r2]);
1499        assert_eq!(missing(&db, id).unwrap(), ["s9", "s8"]);
1500
1501        // One of the two arrives: it goes in its place, the entries already
1502        // there keep theirs, and the other is still missing.
1503        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1504        let fetches = server.lock().fetches;
1505        let sync = reconcile_playlists(&db, &client, &url, "u");
1506        assert_eq!((sync.pulled, sync.pushed), (0, 0));
1507        let now = entries(&db, id);
1508        assert_eq!(now.iter().map(|e| e.1).collect::<Vec<_>>(), [r1, r9, r2]);
1509        assert_eq!((now[0], now[2]), (before[0], before[1]));
1510        assert_eq!(missing(&db, id).unwrap(), ["s8"]);
1511
1512        let r8 = upsert_track(&db.conn, &remote_meta("Eight", "s8")).unwrap();
1513        reconcile_playlists(&db, &client, &url, "u");
1514        assert_eq!(
1515            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1516            [r1, r9, r2, r8]
1517        );
1518        assert_eq!(missing(&db, id), None);
1519        assert_eq!(server.lock().fetches, fetches, "the server was not asked");
1520
1521        // Filling in took the server's copy, which is no edit to push.
1522        let sync = reconcile_playlists(&db, &client, &url, "u");
1523        assert_eq!((sync.pulled, sync.pushed), (0, 0));
1524        assert!(
1525            !queries::get_playlist(&db.conn, id)
1526                .unwrap()
1527                .unwrap()
1528                .unsynced()
1529        );
1530    }
1531
1532    /// An edit on the server replaces what is missing with what its new copy
1533    /// lacks.
1534    #[test]
1535    fn a_server_edit_replaces_the_missing_songs() {
1536        let dir = tempfile::tempdir().unwrap();
1537        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1538        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1539
1540        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1541            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1542            ..Default::default()
1543        }));
1544        let url = serve(server.clone());
1545        let client = SubsonicClient::new(&url, "u", "pw");
1546        reconcile_playlists(&db, &client, &url, "u");
1547        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1548            .unwrap()
1549            .unwrap()
1550            .id;
1551        assert_eq!(missing(&db, id).unwrap(), ["s9"]);
1552
1553        {
1554            let mut server = server.lock();
1555            server.lists[0].1 = vec!["s8".into(), "s1".into()];
1556            server.lists[0].2 += 1;
1557        }
1558        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1559        assert_eq!(missing(&db, id).unwrap(), ["s8"]);
1560
1561        // The song only the old copy named is not wanted any more.
1562        upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1563        reconcile_playlists(&db, &client, &url, "u");
1564        assert_eq!(
1565            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1566            [r1]
1567        );
1568
1569        {
1570            let mut server = server.lock();
1571            server.lists[0].1 = vec!["s1".into()];
1572            server.lists[0].2 += 1;
1573        }
1574        reconcile_playlists(&db, &client, &url, "u");
1575        assert_eq!(missing(&db, id), None);
1576    }
1577
1578    /// A playlist edited here before its missing songs arrive keeps the edit,
1579    /// and the push neither drops those songs from the server nor stops them
1580    /// filling in here.
1581    #[test]
1582    fn a_local_edit_keeps_the_missing_songs() {
1583        let dir = tempfile::tempdir().unwrap();
1584        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1585        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1586        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1587
1588        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1589            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1590            ..Default::default()
1591        }));
1592        let url = serve(server.clone());
1593        let client = SubsonicClient::new(&url, "u", "pw");
1594        reconcile_playlists(&db, &client, &url, "u");
1595        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1596            .unwrap()
1597            .unwrap()
1598            .id;
1599        queries::add_tracks(&db.conn, id, &[r2]).unwrap();
1600
1601        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1602        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pushed, 1);
1603        assert_eq!(server.lock().lists[0].1, ["s1", "s9", "s2"]);
1604        assert_eq!(
1605            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1606            [r1, r9, r2]
1607        );
1608        assert_eq!(missing(&db, id), None);
1609    }
1610
1611    /// A missing song added here by hand once the library has it is pushed
1612    /// once, not once for the server's copy and again for this one.
1613    #[test]
1614    fn a_missing_song_added_here_is_pushed_once() {
1615        let dir = tempfile::tempdir().unwrap();
1616        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1617        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1618
1619        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1620            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1621            ..Default::default()
1622        }));
1623        let url = serve(server.clone());
1624        let client = SubsonicClient::new(&url, "u", "pw");
1625        reconcile_playlists(&db, &client, &url, "u");
1626        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1627            .unwrap()
1628            .unwrap()
1629            .id;
1630
1631        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1632        queries::add_tracks(&db.conn, id, &[r9]).unwrap();
1633        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pushed, 1);
1634        assert_eq!(server.lock().lists[0].1, ["s1", "s9"]);
1635        assert_eq!(missing(&db, id), None);
1636    }
1637
1638    #[test]
1639    fn unknown_songs_go_back_after_the_song_before_them() {
1640        let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1641        let server = ids(&["x", "a", "y", "b", "z", "c"]);
1642        let known = [false, true, false, true, false, true];
1643        // `b` removed and `d` added here: `z` follows `a`, the nearest
1644        // survivor before it.
1645        assert_eq!(
1646            keep_unknown(&server, &known, ids(&["c", "a", "d"])),
1647            ids(&["x", "c", "a", "y", "z", "d"])
1648        );
1649        assert_eq!(
1650            keep_unknown(&server, &known, Vec::new()),
1651            ids(&["x", "y", "z"])
1652        );
1653    }
1654
1655    /// A copy holding entries none of which the server can name is not
1656    /// pushed as an empty list over the server's. One emptied on purpose is.
1657    #[test]
1658    fn only_an_empty_playlist_empties_the_server_copy() {
1659        let dir = tempfile::tempdir().unwrap();
1660        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1661        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1662        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1663
1664        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1665            lists: vec![("p1".into(), vec!["s1".into()], 1)],
1666            ..Default::default()
1667        }));
1668        let url = serve(server.clone());
1669        let client = SubsonicClient::new(&url, "u", "pw");
1670        let account = account_key(&url, "u");
1671        reconcile_playlists(&db, &client, &url, "u");
1672        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1673            .unwrap()
1674            .unwrap()
1675            .id;
1676
1677        let only = entries(&db, id);
1678        queries::add_tracks(&db.conn, id, &[local]).unwrap();
1679        queries::remove_entries(&db.conn, id, &[only[0].0]).unwrap();
1680        assert!(push(&db, &client, &account, id).is_err());
1681        assert_eq!(server.lock().lists[0].1, ["s1"]);
1682
1683        let rest = entries(&db, id);
1684        queries::remove_entries(&db.conn, id, &[rest[0].0]).unwrap();
1685        assert!(push(&db, &client, &account, id).is_ok());
1686        assert!(server.lock().lists[0].1.is_empty());
1687    }
1688
1689    /// A playlist missing from the listing is deleted here only once the
1690    /// server says it does not have it: a listing that comes back empty
1691    /// deletes nothing.
1692    #[test]
1693    fn only_a_playlist_the_server_says_is_gone_is_deleted() {
1694        let dir = tempfile::tempdir().unwrap();
1695        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1696        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1697
1698        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1699            lists: vec![
1700                ("p1".into(), vec!["s1".into()], 1),
1701                ("p2".into(), vec!["s1".into()], 2),
1702            ],
1703            ..Default::default()
1704        }));
1705        let url = serve(server.clone());
1706        let client = SubsonicClient::new(&url, "u", "pw");
1707        reconcile_playlists(&db, &client, &url, "u");
1708        let held = || {
1709            queries::list_playlists(&db.conn, queries::LOCAL_USER)
1710                .unwrap()
1711                .len()
1712        };
1713        assert_eq!(held(), 2);
1714
1715        server.lock().hide_listing = true;
1716        reconcile_playlists(&db, &client, &url, "u");
1717        assert_eq!(held(), 2, "nothing deleted on an empty listing");
1718
1719        {
1720            let mut server = server.lock();
1721            server.hide_listing = false;
1722            server.lists.remove(0);
1723        }
1724        reconcile_playlists(&db, &client, &url, "u");
1725        assert_eq!(held(), 1, "one deleted on the server is deleted here");
1726    }
1727
1728    #[test]
1729    fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
1730        let dir = tempfile::tempdir().unwrap();
1731        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1732        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1733        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1734        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1735        queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
1736            .unwrap();
1737
1738        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1739        let url = serve(server.clone());
1740        let client = SubsonicClient::new(&url, "u", "pw");
1741        let sync = reconcile_playlists(&db, &client, &url, "u");
1742
1743        let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
1744        assert_eq!(list.track_count, 1);
1745        assert_eq!(sync.pushed, 1);
1746        assert_eq!(list.remote_id.as_deref(), Some("p1"));
1747    }
1748
1749    #[test]
1750    fn concurrent_pushes_create_one_server_playlist() {
1751        let dir = tempfile::tempdir().unwrap();
1752        let path = dir.path().join("koan.db");
1753        let db = Database::open(&path).unwrap();
1754        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1755        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1756        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1757
1758        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1759        let url = serve(server.clone());
1760        let pushes: Vec<_> = (0..2)
1761            .map(|_| {
1762                let (path, url) = (path.clone(), url.clone());
1763                std::thread::spawn(move || {
1764                    let db = Database::open(&path).unwrap();
1765                    let client = SubsonicClient::new(&url, "u", "pw");
1766                    push(&db, &client, &account_key(&url, "u"), id)
1767                })
1768            })
1769            .collect();
1770        for p in pushes {
1771            p.join().unwrap().unwrap();
1772        }
1773        assert_eq!(server.lock().creates_without_id, 1);
1774        assert_eq!(server.lock().lists.len(), 1);
1775    }
1776}