1use 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#[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
38const HISTORY_DEPTH: usize = 100;
40
41pub struct Before {
43 id: i64,
44 entries: Vec<(i64, i64)>,
45}
46
47impl PlaylistHistory {
48 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 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 pub fn undo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
71 self.step(conn, id, true)
72 }
73
74 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
137pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum QueueLock {
191 Playlist(i64),
192 Album(i64),
193}
194
195#[derive(Debug, Default, Clone, Copy)]
197pub struct PlaylistSync {
198 pub pulled: usize,
200 pub pushed: usize,
202}
203
204pub fn locked_to(db: &Database, state: &SharedPlayerState, playlist_id: i64) -> bool {
206 queue_lock(db, state) == Some(QueueLock::Playlist(playlist_id))
207}
208
209pub 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 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 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 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 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 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
297pub 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 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 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 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 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 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
491fn 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
538fn account_key(url: &str, username: &str) -> String {
540 format!("{username}@{}", url.trim_end_matches('/'))
541}
542
543fn 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
561fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
567 let lock = push_lock(id);
568 let _held = lock.lock();
569 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 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 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 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 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
730fn 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
760fn 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
773pub 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 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
813pub 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#[derive(Debug, Default, Clone, Copy)]
834pub struct ExportSummary {
835 pub written: usize,
836 pub skipped: usize,
839}
840
841pub 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 #[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 state.add_items(vec![queued(None)]);
979 assert_eq!(queue_lock(&db, &state), None);
980 }
981
982 #[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 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 #[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 #[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 #[derive(Default)]
1193 struct Server {
1194 lists: Vec<(String, Vec<String>, u32)>,
1195 fetches: usize,
1196 creates_without_id: usize,
1197 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(), ¶ms, 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 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 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 {
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 #[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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 #[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}