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 credits: Default::default(),
924 }
925 }
926
927 #[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 state.add_items(vec![queued(None)]);
980 assert_eq!(queue_lock(&db, &state), None);
981 }
982
983 #[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 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 #[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 #[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 #[derive(Default)]
1194 struct Server {
1195 lists: Vec<(String, Vec<String>, u32)>,
1196 fetches: usize,
1197 creates_without_id: usize,
1198 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(), ¶ms, 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 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 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 {
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 #[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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 #[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}