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            credits: Default::default(),
924        }
925    }
926
927    /// The queue is locked while it is still exactly the playlist, and stops
928    /// being the moment it is not. Everything else about following follows from
929    /// this one answer.
930    #[test]
931    fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
932        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
933
934        let dir = tempfile::tempdir().unwrap();
935        let db = Database::open(&dir.path().join("koan.db")).unwrap();
936        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
937        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
938
939        let id =
940            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
941                .unwrap();
942        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
943
944        let state = SharedPlayerState::new();
945        let queued = |entry: Option<i64>| PlaylistItem {
946            uid: None,
947            id: QueueItemId::new(),
948            db_id: Some(a),
949            playlist_entry_id: entry,
950            path: dir.path().join("a.flac"),
951            title: "A".into(),
952            artist: "Artist".into(),
953            album_artist: "Artist".into(),
954            album: "Album".into(),
955            year: None,
956            codec: None,
957            track_number: None,
958            disc: None,
959            duration_ms: None,
960            state: ItemState::Ready,
961            played: false,
962        };
963
964        assert_eq!(
965            queue_lock(&db, &state),
966            None,
967            "an empty queue is not locked"
968        );
969
970        state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
971        assert_eq!(
972            queue_lock(&db, &state),
973            Some(QueueLock::Playlist(id)),
974            "the queue is the playlist"
975        );
976
977        // Something that never came from the playlist — played next, dropped
978        // in.
979        state.add_items(vec![queued(None)]);
980        assert_eq!(queue_lock(&db, &state), None);
981    }
982
983    /// Reordering the queue by hand ends the lock, which is the whole point of
984    /// deriving it: there is no flag anyone has to remember to clear.
985    #[test]
986    fn rearranging_the_queue_ends_the_lock() {
987        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
988
989        let dir = tempfile::tempdir().unwrap();
990        let db = Database::open(&dir.path().join("koan.db")).unwrap();
991        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
992        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
993        let id =
994            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
995                .unwrap();
996        let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
997
998        let state = SharedPlayerState::new();
999        let items: Vec<PlaylistItem> = entries
1000            .iter()
1001            .map(|entry| PlaylistItem {
1002                uid: None,
1003                id: QueueItemId::new(),
1004                db_id: Some(a),
1005                playlist_entry_id: Some(*entry),
1006                path: dir.path().join("a.flac"),
1007                title: "A".into(),
1008                artist: "Artist".into(),
1009                album_artist: "Artist".into(),
1010                album: "Album".into(),
1011                year: None,
1012                codec: None,
1013                track_number: None,
1014                disc: None,
1015                duration_ms: None,
1016                state: ItemState::Ready,
1017                played: false,
1018            })
1019            .collect();
1020        let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1021        state.add_items(items);
1022        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
1023
1024        state.reorder_to(&[ids[1], ids[0]]);
1025        assert_eq!(
1026            queue_lock(&db, &state),
1027            None,
1028            "same tracks, different order — no longer the playlist"
1029        );
1030
1031        // And the playlist catching up locks it again. Nothing had to be reset.
1032        queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
1033        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
1034    }
1035
1036    /// One playlist edit followed by the queue is one step of the queue's undo,
1037    /// and a removal that leaves the order as it was sends no reorder.
1038    #[test]
1039    fn following_an_edit_is_one_undo_step() {
1040        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
1041
1042        let dir = tempfile::tempdir().unwrap();
1043        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1044        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
1045        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
1046        let c = upsert_track(&db.conn, &meta("C", &dir.path().join("c.flac"))).unwrap();
1047        let id =
1048            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
1049                .unwrap();
1050        let entries = queries::add_tracks(&db.conn, id, &[a, b, c]).unwrap();
1051
1052        let state = SharedPlayerState::new();
1053        let items: Vec<PlaylistItem> = entries
1054            .iter()
1055            .map(|entry| PlaylistItem {
1056                uid: None,
1057                id: QueueItemId::new(),
1058                db_id: Some(a),
1059                playlist_entry_id: Some(*entry),
1060                path: dir.path().join("a.flac"),
1061                title: "A".into(),
1062                artist: "Artist".into(),
1063                album_artist: "Artist".into(),
1064                album: "Album".into(),
1065                year: None,
1066                codec: None,
1067                track_number: None,
1068                disc: None,
1069                duration_ms: None,
1070                state: ItemState::Ready,
1071                played: false,
1072            })
1073            .collect();
1074        let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
1075        state.add_items(items);
1076
1077        let follow = |was_locked| {
1078            let sent = std::cell::RefCell::new(Vec::new());
1079            follow_edit(&db, &state, id, was_locked, &|cmd| {
1080                sent.borrow_mut().push(cmd)
1081            });
1082            sent.into_inner()
1083        };
1084
1085        assert!(follow(true).is_empty(), "nothing changed, nothing sent");
1086
1087        queries::remove_entries(&db.conn, id, &[entries[1]]).unwrap();
1088        let sent = follow(true);
1089        assert!(
1090            matches!(
1091                sent.as_slice(),
1092                [
1093                    PlayerCommand::BeginUndoBatch,
1094                    PlayerCommand::RemoveFromPlaylistBatch(gone),
1095                    PlayerCommand::EndUndoBatch,
1096                ] if gone == &[ids[1]]
1097            ),
1098            "{sent:?}"
1099        );
1100
1101        state.remove_item(ids[1]);
1102        queries::reorder_entries(&db.conn, id, &[entries[2], entries[0]]).unwrap();
1103        let sent = follow(true);
1104        assert!(
1105            matches!(
1106                sent.as_slice(),
1107                [
1108                    PlayerCommand::BeginUndoBatch,
1109                    PlayerCommand::ReorderPlaylist(order),
1110                    PlayerCommand::EndUndoBatch,
1111                ] if order == &[ids[2], ids[0]]
1112            ),
1113            "{sent:?}"
1114        );
1115    }
1116
1117    /// A record needs no provenance: it *is* an ordered set of tracks, so the
1118    /// queue being that record is a question about what the queue holds. Which
1119    /// is why it survives a relaunch, where nothing remembers what was played.
1120    #[test]
1121    fn a_queue_holding_exactly_one_record_is_locked_to_it() {
1122        use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
1123
1124        let dir = tempfile::tempdir().unwrap();
1125        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1126        let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
1127        let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
1128        let album_id = queries::get_track_row(&db.conn, a)
1129            .unwrap()
1130            .unwrap()
1131            .album_id
1132            .unwrap();
1133
1134        let state = SharedPlayerState::new();
1135        let queued = |track: i64| PlaylistItem {
1136            uid: None,
1137            id: QueueItemId::new(),
1138            db_id: Some(track),
1139            playlist_entry_id: None,
1140            path: dir.path().join("a.flac"),
1141            title: "A".into(),
1142            artist: "Artist".into(),
1143            album_artist: "Artist".into(),
1144            album: "Album".into(),
1145            year: None,
1146            codec: None,
1147            track_number: None,
1148            disc: None,
1149            duration_ms: None,
1150            state: ItemState::Ready,
1151            played: false,
1152        };
1153
1154        state.add_items(vec![queued(a)]);
1155        assert_eq!(
1156            queue_lock(&db, &state),
1157            None,
1158            "half a record is not the record"
1159        );
1160
1161        state.add_items(vec![queued(b)]);
1162        assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
1163    }
1164
1165    #[test]
1166    fn export_writes_what_is_on_disk_and_counts_what_is_not() {
1167        let dir = tempfile::tempdir().unwrap();
1168        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1169
1170        let present = dir.path().join("here.flac");
1171        std::fs::write(&present, b"x").unwrap();
1172        let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
1173        let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
1174
1175        let id =
1176            queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
1177                .unwrap();
1178        queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
1179
1180        let dest = dir.path().join("evening.m3u8");
1181        let summary = export_m3u8(&db, id, &dest).unwrap();
1182        assert_eq!((summary.written, summary.skipped), (1, 1));
1183
1184        let written = std::fs::read_to_string(&dest).unwrap();
1185        assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
1186        assert!(written.contains("#EXTINF:185,Artist - Here"));
1187        assert!(written.contains(&present.display().to_string()));
1188        assert!(!written.contains("gone.flac"));
1189    }
1190
1191    /// A Subsonic server holding playlists and nothing else, one request per
1192    /// connection. Each `changed` is a counter, so every write moves it.
1193    #[derive(Default)]
1194    struct Server {
1195        lists: Vec<(String, Vec<String>, u32)>,
1196        fetches: usize,
1197        creates_without_id: usize,
1198        /// `getPlaylists` answers with none, as a server answering wrongly.
1199        hide_listing: bool,
1200    }
1201
1202    fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
1203        use std::io::{BufRead, Write};
1204        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1205        let url = format!("http://{}", listener.local_addr().unwrap());
1206        std::thread::spawn(move || {
1207            for stream in listener.incoming().flatten() {
1208                let server = server.clone();
1209                std::thread::spawn(move || {
1210                    let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
1211                    let mut request = String::new();
1212                    reader.read_line(&mut request).unwrap();
1213                    let mut line = String::new();
1214                    while reader.read_line(&mut line).unwrap_or(0) > 2 {
1215                        line.clear();
1216                    }
1217                    let target = request.split_whitespace().nth(1).unwrap_or("");
1218                    let (path, query) = target.split_once('?').unwrap_or((target, ""));
1219                    let params: Vec<(&str, &str)> = query
1220                        .split('&')
1221                        .filter_map(|kv| kv.split_once('='))
1222                        .collect();
1223                    let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
1224                    let body = respond(&server, path.rsplit('/').next().unwrap(), &params, param);
1225                    let mut stream = stream;
1226                    let _ = write!(
1227                        stream,
1228                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
1229                        body.len()
1230                    );
1231                });
1232            }
1233        });
1234        url
1235    }
1236
1237    fn respond<'a>(
1238        server: &parking_lot::Mutex<Server>,
1239        endpoint: &str,
1240        params: &[(&str, &'a str)],
1241        param: impl Fn(&str) -> Option<&'a str>,
1242    ) -> String {
1243        let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
1244            format!(
1245                r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
1246                songs.len()
1247            )
1248        };
1249        let full = |list: &(String, Vec<String>, u32)| {
1250            let entries: Vec<String> = list
1251                .1
1252                .iter()
1253                .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
1254                .collect();
1255            format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
1256        };
1257        let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
1258        match endpoint {
1259            "getPlaylists" => {
1260                let server = server.lock();
1261                let lists: Vec<String> = server
1262                    .lists
1263                    .iter()
1264                    .filter(|_| !server.hide_listing)
1265                    .map(|l| format!("{{{}}}", summary(l)))
1266                    .collect();
1267                ok(format!(
1268                    r#","playlists":{{"playlist":[{}]}}"#,
1269                    lists.join(",")
1270                ))
1271            }
1272            "getPlaylist" => {
1273                let mut server = server.lock();
1274                server.fetches += 1;
1275                match server
1276                    .lists
1277                    .iter()
1278                    .find(|l| Some(l.0.as_str()) == param("id"))
1279                {
1280                    Some(list) => ok(format!(r#","playlist":{}"#, full(list))),
1281                    None => r#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"not found"}}}"#.to_string(),
1282                }
1283            }
1284            "createPlaylist" => {
1285                let songs: Vec<String> = params
1286                    .iter()
1287                    .filter(|(k, _)| *k == "songId")
1288                    .map(|(_, v)| v.to_string())
1289                    .collect();
1290                // Wide enough for two pushes to overlap if nothing stops them.
1291                std::thread::sleep(std::time::Duration::from_millis(50));
1292                let mut server = server.lock();
1293                let id = match param("playlistId") {
1294                    Some(id) => id.to_string(),
1295                    None => {
1296                        server.creates_without_id += 1;
1297                        format!("p{}", server.lists.len() + 1)
1298                    }
1299                };
1300                server.lists.retain(|l| l.0 != id);
1301                let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
1302                server.lists.push((id, songs, changed));
1303                ok(format!(
1304                    r#","playlist":{}"#,
1305                    full(server.lists.last().unwrap())
1306                ))
1307            }
1308            _ => ok(String::new()),
1309        }
1310    }
1311
1312    fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
1313        TrackMeta {
1314            path: None,
1315            source: "remote".into(),
1316            remote_id: Some(remote_id.into()),
1317            album: title.into(),
1318            ..meta(title, Path::new(""))
1319        }
1320    }
1321
1322    fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
1323        queries::playlist_entries(&db.conn, id)
1324            .unwrap()
1325            .into_iter()
1326            .map(|e| (e.id, e.track.id))
1327            .collect()
1328    }
1329
1330    #[test]
1331    fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
1332        let dir = tempfile::tempdir().unwrap();
1333        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1334        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1335        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1336        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1337
1338        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1339            lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
1340            ..Default::default()
1341        }));
1342        let url = serve(server.clone());
1343        let client = SubsonicClient::new(&url, "u", "pw");
1344
1345        let first = reconcile_playlists(&db, &client, &url, "u");
1346        assert_eq!(first.pulled, 1);
1347        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1348            .unwrap()
1349            .unwrap()
1350            .id;
1351        assert_eq!(
1352            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1353            [r1, r2]
1354        );
1355
1356        // A local file joins; the push carries only what the server can name.
1357        queries::add_tracks(&db.conn, id, &[local]).unwrap();
1358        let second = reconcile_playlists(&db, &client, &url, "u");
1359        assert_eq!((second.pushed, second.pulled), (1, 0));
1360        assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
1361
1362        let before = entries(&db, id);
1363        assert_eq!(before.len(), 3);
1364        let fetches = server.lock().fetches;
1365        let third = reconcile_playlists(&db, &client, &url, "u");
1366        assert_eq!((third.pushed, third.pulled), (0, 0));
1367        assert_eq!(
1368            server.lock().fetches,
1369            fetches,
1370            "nothing moved, nothing fetched"
1371        );
1372        assert_eq!(entries(&db, id), before);
1373
1374        // An edit on the server comes down without the local file or the
1375        // surviving entry's id.
1376        {
1377            let mut server = server.lock();
1378            server.lists[0].1 = vec!["s2".into()];
1379            server.lists[0].2 += 1;
1380        }
1381        let fourth = reconcile_playlists(&db, &client, &url, "u");
1382        assert_eq!(fourth.pulled, 1);
1383        assert_eq!(entries(&db, id), [before[1], before[2]]);
1384    }
1385
1386    /// Forgetting the server takes its tracks out of every playlist; signing
1387    /// back in and syncing the library brings them back, and the playlists
1388    /// are pulled whole again even though the server's copies never changed.
1389    #[test]
1390    fn a_forgotten_server_refills_its_playlists_on_the_next_sync() {
1391        let dir = tempfile::tempdir().unwrap();
1392        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1393        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1394        upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1395
1396        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1397            lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
1398            ..Default::default()
1399        }));
1400        let url = serve(server.clone());
1401        let client = SubsonicClient::new(&url, "u", "pw");
1402        reconcile_playlists(&db, &client, &url, "u");
1403        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1404            .unwrap()
1405            .unwrap()
1406            .id;
1407        assert_eq!(entries(&db, id).len(), 2);
1408
1409        crate::helpers::forget_remote(&db).unwrap();
1410        assert!(entries(&db, id).is_empty());
1411
1412        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1413        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1414        let sync = reconcile_playlists(&db, &client, &url, "u");
1415        assert_eq!((sync.pulled, sync.pushed), (1, 0));
1416        assert_eq!(
1417            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1418            [r1, r2]
1419        );
1420        assert_eq!(
1421            server.lock().lists[0].1,
1422            ["s1", "s2"],
1423            "the server copy is untouched"
1424        );
1425    }
1426
1427    /// A song on the server's copy that the library does not have is left
1428    /// out of the local copy and put back, in place, by every push: nothing
1429    /// here could have removed it.
1430    #[test]
1431    fn a_server_song_the_library_lacks_is_never_dropped() {
1432        let dir = tempfile::tempdir().unwrap();
1433        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1434        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1435        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1436
1437        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1438            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1439            ..Default::default()
1440        }));
1441        let url = serve(server.clone());
1442        let client = SubsonicClient::new(&url, "u", "pw");
1443        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1444        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1445            .unwrap()
1446            .unwrap()
1447            .id;
1448        assert_eq!(
1449            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1450            [r1]
1451        );
1452
1453        queries::add_tracks(&db.conn, id, &[r2]).unwrap();
1454        assert!(push(&db, &client, &account_key(&url, "u"), id).is_ok());
1455        assert_eq!(server.lock().lists[0].1, ["s1", "s9", "s2"]);
1456
1457        // The library catches up, and the server's copy comes down whole.
1458        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1459        server.lock().lists[0].2 += 1;
1460        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1461        assert_eq!(
1462            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1463            [r1, r9, r2]
1464        );
1465    }
1466
1467    fn missing(db: &Database, id: i64) -> Option<Vec<String>> {
1468        queries::playlist_server_songs(&db.conn, id)
1469            .unwrap()
1470            .map(|r| r.missing)
1471    }
1472
1473    /// Songs a pulled playlist named before the library had them fill in, in
1474    /// the server's order, on the sync after the library gains them, without
1475    /// the server being asked for the playlist again.
1476    #[test]
1477    fn songs_the_library_lacked_fill_in_when_they_arrive() {
1478        let dir = tempfile::tempdir().unwrap();
1479        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1480        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1481        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1482
1483        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1484            lists: vec![(
1485                "p1".into(),
1486                vec!["s1".into(), "s9".into(), "s2".into(), "s8".into()],
1487                1,
1488            )],
1489            ..Default::default()
1490        }));
1491        let url = serve(server.clone());
1492        let client = SubsonicClient::new(&url, "u", "pw");
1493        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1494        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1495            .unwrap()
1496            .unwrap()
1497            .id;
1498        let before = entries(&db, id);
1499        assert_eq!(before.iter().map(|e| e.1).collect::<Vec<_>>(), [r1, r2]);
1500        assert_eq!(missing(&db, id).unwrap(), ["s9", "s8"]);
1501
1502        // One of the two arrives: it goes in its place, the entries already
1503        // there keep theirs, and the other is still missing.
1504        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1505        let fetches = server.lock().fetches;
1506        let sync = reconcile_playlists(&db, &client, &url, "u");
1507        assert_eq!((sync.pulled, sync.pushed), (0, 0));
1508        let now = entries(&db, id);
1509        assert_eq!(now.iter().map(|e| e.1).collect::<Vec<_>>(), [r1, r9, r2]);
1510        assert_eq!((now[0], now[2]), (before[0], before[1]));
1511        assert_eq!(missing(&db, id).unwrap(), ["s8"]);
1512
1513        let r8 = upsert_track(&db.conn, &remote_meta("Eight", "s8")).unwrap();
1514        reconcile_playlists(&db, &client, &url, "u");
1515        assert_eq!(
1516            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1517            [r1, r9, r2, r8]
1518        );
1519        assert_eq!(missing(&db, id), None);
1520        assert_eq!(server.lock().fetches, fetches, "the server was not asked");
1521
1522        // Filling in took the server's copy, which is no edit to push.
1523        let sync = reconcile_playlists(&db, &client, &url, "u");
1524        assert_eq!((sync.pulled, sync.pushed), (0, 0));
1525        assert!(
1526            !queries::get_playlist(&db.conn, id)
1527                .unwrap()
1528                .unwrap()
1529                .unsynced()
1530        );
1531    }
1532
1533    /// An edit on the server replaces what is missing with what its new copy
1534    /// lacks.
1535    #[test]
1536    fn a_server_edit_replaces_the_missing_songs() {
1537        let dir = tempfile::tempdir().unwrap();
1538        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1539        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1540
1541        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1542            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1543            ..Default::default()
1544        }));
1545        let url = serve(server.clone());
1546        let client = SubsonicClient::new(&url, "u", "pw");
1547        reconcile_playlists(&db, &client, &url, "u");
1548        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1549            .unwrap()
1550            .unwrap()
1551            .id;
1552        assert_eq!(missing(&db, id).unwrap(), ["s9"]);
1553
1554        {
1555            let mut server = server.lock();
1556            server.lists[0].1 = vec!["s8".into(), "s1".into()];
1557            server.lists[0].2 += 1;
1558        }
1559        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1560        assert_eq!(missing(&db, id).unwrap(), ["s8"]);
1561
1562        // The song only the old copy named is not wanted any more.
1563        upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1564        reconcile_playlists(&db, &client, &url, "u");
1565        assert_eq!(
1566            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1567            [r1]
1568        );
1569
1570        {
1571            let mut server = server.lock();
1572            server.lists[0].1 = vec!["s1".into()];
1573            server.lists[0].2 += 1;
1574        }
1575        reconcile_playlists(&db, &client, &url, "u");
1576        assert_eq!(missing(&db, id), None);
1577    }
1578
1579    /// A playlist edited here before its missing songs arrive keeps the edit,
1580    /// and the push neither drops those songs from the server nor stops them
1581    /// filling in here.
1582    #[test]
1583    fn a_local_edit_keeps_the_missing_songs() {
1584        let dir = tempfile::tempdir().unwrap();
1585        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1586        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1587        let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1588
1589        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1590            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1591            ..Default::default()
1592        }));
1593        let url = serve(server.clone());
1594        let client = SubsonicClient::new(&url, "u", "pw");
1595        reconcile_playlists(&db, &client, &url, "u");
1596        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1597            .unwrap()
1598            .unwrap()
1599            .id;
1600        queries::add_tracks(&db.conn, id, &[r2]).unwrap();
1601
1602        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1603        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pushed, 1);
1604        assert_eq!(server.lock().lists[0].1, ["s1", "s9", "s2"]);
1605        assert_eq!(
1606            entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1607            [r1, r9, r2]
1608        );
1609        assert_eq!(missing(&db, id), None);
1610    }
1611
1612    /// A missing song added here by hand once the library has it is pushed
1613    /// once, not once for the server's copy and again for this one.
1614    #[test]
1615    fn a_missing_song_added_here_is_pushed_once() {
1616        let dir = tempfile::tempdir().unwrap();
1617        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1618        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1619
1620        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1621            lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1622            ..Default::default()
1623        }));
1624        let url = serve(server.clone());
1625        let client = SubsonicClient::new(&url, "u", "pw");
1626        reconcile_playlists(&db, &client, &url, "u");
1627        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1628            .unwrap()
1629            .unwrap()
1630            .id;
1631
1632        let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1633        queries::add_tracks(&db.conn, id, &[r9]).unwrap();
1634        assert_eq!(reconcile_playlists(&db, &client, &url, "u").pushed, 1);
1635        assert_eq!(server.lock().lists[0].1, ["s1", "s9"]);
1636        assert_eq!(missing(&db, id), None);
1637    }
1638
1639    #[test]
1640    fn unknown_songs_go_back_after_the_song_before_them() {
1641        let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1642        let server = ids(&["x", "a", "y", "b", "z", "c"]);
1643        let known = [false, true, false, true, false, true];
1644        // `b` removed and `d` added here: `z` follows `a`, the nearest
1645        // survivor before it.
1646        assert_eq!(
1647            keep_unknown(&server, &known, ids(&["c", "a", "d"])),
1648            ids(&["x", "c", "a", "y", "z", "d"])
1649        );
1650        assert_eq!(
1651            keep_unknown(&server, &known, Vec::new()),
1652            ids(&["x", "y", "z"])
1653        );
1654    }
1655
1656    /// A copy holding entries none of which the server can name is not
1657    /// pushed as an empty list over the server's. One emptied on purpose is.
1658    #[test]
1659    fn only_an_empty_playlist_empties_the_server_copy() {
1660        let dir = tempfile::tempdir().unwrap();
1661        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1662        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1663        let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1664
1665        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1666            lists: vec![("p1".into(), vec!["s1".into()], 1)],
1667            ..Default::default()
1668        }));
1669        let url = serve(server.clone());
1670        let client = SubsonicClient::new(&url, "u", "pw");
1671        let account = account_key(&url, "u");
1672        reconcile_playlists(&db, &client, &url, "u");
1673        let id = queries::playlist_by_remote_id(&db.conn, "p1")
1674            .unwrap()
1675            .unwrap()
1676            .id;
1677
1678        let only = entries(&db, id);
1679        queries::add_tracks(&db.conn, id, &[local]).unwrap();
1680        queries::remove_entries(&db.conn, id, &[only[0].0]).unwrap();
1681        assert!(push(&db, &client, &account, id).is_err());
1682        assert_eq!(server.lock().lists[0].1, ["s1"]);
1683
1684        let rest = entries(&db, id);
1685        queries::remove_entries(&db.conn, id, &[rest[0].0]).unwrap();
1686        assert!(push(&db, &client, &account, id).is_ok());
1687        assert!(server.lock().lists[0].1.is_empty());
1688    }
1689
1690    /// A playlist missing from the listing is deleted here only once the
1691    /// server says it does not have it: a listing that comes back empty
1692    /// deletes nothing.
1693    #[test]
1694    fn only_a_playlist_the_server_says_is_gone_is_deleted() {
1695        let dir = tempfile::tempdir().unwrap();
1696        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1697        upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1698
1699        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1700            lists: vec![
1701                ("p1".into(), vec!["s1".into()], 1),
1702                ("p2".into(), vec!["s1".into()], 2),
1703            ],
1704            ..Default::default()
1705        }));
1706        let url = serve(server.clone());
1707        let client = SubsonicClient::new(&url, "u", "pw");
1708        reconcile_playlists(&db, &client, &url, "u");
1709        let held = || {
1710            queries::list_playlists(&db.conn, queries::LOCAL_USER)
1711                .unwrap()
1712                .len()
1713        };
1714        assert_eq!(held(), 2);
1715
1716        server.lock().hide_listing = true;
1717        reconcile_playlists(&db, &client, &url, "u");
1718        assert_eq!(held(), 2, "nothing deleted on an empty listing");
1719
1720        {
1721            let mut server = server.lock();
1722            server.hide_listing = false;
1723            server.lists.remove(0);
1724        }
1725        reconcile_playlists(&db, &client, &url, "u");
1726        assert_eq!(held(), 1, "one deleted on the server is deleted here");
1727    }
1728
1729    #[test]
1730    fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let db = Database::open(&dir.path().join("koan.db")).unwrap();
1733        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1734        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1735        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1736        queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
1737            .unwrap();
1738
1739        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1740        let url = serve(server.clone());
1741        let client = SubsonicClient::new(&url, "u", "pw");
1742        let sync = reconcile_playlists(&db, &client, &url, "u");
1743
1744        let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
1745        assert_eq!(list.track_count, 1);
1746        assert_eq!(sync.pushed, 1);
1747        assert_eq!(list.remote_id.as_deref(), Some("p1"));
1748    }
1749
1750    #[test]
1751    fn concurrent_pushes_create_one_server_playlist() {
1752        let dir = tempfile::tempdir().unwrap();
1753        let path = dir.path().join("koan.db");
1754        let db = Database::open(&path).unwrap();
1755        let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1756        let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1757        queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1758
1759        let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1760        let url = serve(server.clone());
1761        let pushes: Vec<_> = (0..2)
1762            .map(|_| {
1763                let (path, url) = (path.clone(), url.clone());
1764                std::thread::spawn(move || {
1765                    let db = Database::open(&path).unwrap();
1766                    let client = SubsonicClient::new(&url, "u", "pw");
1767                    push(&db, &client, &account_key(&url, "u"), id)
1768                })
1769            })
1770            .collect();
1771        for p in pushes {
1772            p.join().unwrap().unwrap();
1773        }
1774        assert_eq!(server.lock().creates_without_id, 1);
1775        assert_eq!(server.lock().lists.len(), 1);
1776    }
1777}