1use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use crate::config::Config;
11use crate::db::connection::Database;
12use crate::db::queries;
13use crate::helpers::subsonic_client;
14use crate::player::state::SharedPlayerState;
15use crate::remote::client::SubsonicClient;
16
17pub fn queue_lock(db: &Database, state: &SharedPlayerState) -> Option<QueueLock> {
33 let (items, _) = state.snapshot_playlist();
34 if items.is_empty() {
35 return None;
36 }
37
38 let entry_ids: Vec<i64> = items.iter().filter_map(|i| i.playlist_entry_id).collect();
41 if entry_ids.len() == items.len()
42 && let Ok(Some(playlist_id)) = queries::playlist_of_entry(&db.conn, entry_ids[0])
43 && queries::playlist_entry_ids(&db.conn, playlist_id).is_ok_and(|ids| ids == entry_ids)
44 {
45 return Some(QueueLock::Playlist(playlist_id));
46 }
47
48 let track_ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
53 if track_ids.len() != items.len() {
54 return None;
55 }
56 let album_id = queries::get_track_row(&db.conn, track_ids[0])
57 .ok()
58 .flatten()?
59 .album_id?;
60 let album: Vec<i64> = queries::tracks_for_album(&db.conn, album_id)
61 .ok()?
62 .into_iter()
63 .map(|t| t.id)
64 .collect();
65 (album == track_ids).then_some(QueueLock::Album(album_id))
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum QueueLock {
71 Playlist(i64),
72 Album(i64),
73}
74
75#[derive(Debug, Default, Clone, Copy)]
77pub struct PlaylistSync {
78 pub pulled: usize,
80 pub pushed: usize,
82}
83
84pub fn reconcile_playlists(
98 db: &Database,
99 client: &SubsonicClient,
100 url: &str,
101 username: &str,
102) -> PlaylistSync {
103 let mut out = PlaylistSync::default();
104 let account = account_key(url, username);
105
106 let remote = match client.get_playlists() {
107 Ok(lists) => lists,
108 Err(e) => {
109 log::warn!("could not fetch playlists from the server: {e}");
110 return out;
111 }
112 };
113
114 match queries::detach_playlists_from_other_accounts(&db.conn, &account) {
115 Ok(0) => {}
116 Ok(n) => log::info!("{n} playlists belonged to another server; keeping them as local"),
117 Err(e) => {
118 log::warn!("could not check which server playlists belong to: {e}");
119 return out;
120 }
121 }
122
123 let mut seen_remote_ids = Vec::new();
127
128 for summary in &remote {
129 seen_remote_ids.push(summary.id.clone());
130 let local = queries::playlist_by_remote_id(&db.conn, &summary.id)
131 .ok()
132 .flatten();
133 let ours = summary
134 .owner
135 .as_deref()
136 .is_none_or(|owner| owner == username);
137
138 if let Some(local) = &local {
139 let server_moved = summary.changed.as_deref() != local.remote_changed.as_deref();
140 let local_moved = ours && local.unsynced();
141 if local_moved
142 && (!server_moved || newer(&local.changed_at, summary.changed.as_deref()))
143 {
144 if push(db, client, &account, local.id).is_ok() {
145 out.pushed += 1;
146 }
147 continue;
148 }
149 if !server_moved && !local_moved {
150 continue;
151 }
152 }
153
154 let full = match client.get_playlist(&summary.id) {
155 Ok(full) => full,
156 Err(e) => {
157 log::warn!("could not fetch playlist {}: {e}", summary.id);
158 continue;
159 }
160 };
161
162 let id = match local {
163 Some(local) => local.id,
164 None => {
165 match queries::create_playlist(
166 &db.conn,
167 queries::LOCAL_USER,
168 &summary.name,
169 summary.comment.as_deref(),
170 ) {
171 Ok(id) => id,
172 Err(e) => {
173 log::warn!("could not store playlist {}: {e}", summary.name);
174 continue;
175 }
176 }
177 }
178 };
179
180 let _ = queries::rename_playlist(&db.conn, id, &summary.name);
181 let _ = queries::set_playlist_remote(
182 &db.conn,
183 id,
184 &summary.id,
185 summary.owner.as_deref(),
186 summary.public,
187 &account,
188 );
189
190 let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
191 let track_ids: Vec<i64> = queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids)
192 .unwrap_or_default()
193 .into_iter()
194 .flatten()
195 .collect();
196 if let Err(e) = queries::merge_server_tracks(&db.conn, id, &track_ids) {
197 log::warn!(
198 "could not store playlist contents for {}: {e}",
199 summary.name
200 );
201 continue;
202 }
203 let _ = queries::mark_playlist_synced(&db.conn, id, summary.changed.as_deref(), None);
206 out.pulled += 1;
207 }
208
209 for local in queries::list_playlists(&db.conn, queries::LOCAL_USER).unwrap_or_default() {
212 if let Some(remote_id) = &local.remote_id
213 && !seen_remote_ids.contains(remote_id)
214 {
215 let _ = queries::delete_playlist(&db.conn, local.id);
216 }
217 }
218
219 for local in
220 queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
221 {
222 if push(db, client, &account, local.id).is_ok() {
223 out.pushed += 1;
224 }
225 }
226
227 out
228}
229
230fn account_key(url: &str, username: &str) -> String {
232 format!("{username}@{}", url.trim_end_matches('/'))
233}
234
235fn push_lock(id: i64) -> std::sync::Arc<parking_lot::Mutex<()>> {
242 static LOCKS: std::sync::OnceLock<
243 parking_lot::Mutex<std::collections::HashMap<i64, std::sync::Arc<parking_lot::Mutex<()>>>>,
244 > = std::sync::OnceLock::new();
245 LOCKS
246 .get_or_init(Default::default)
247 .lock()
248 .entry(id)
249 .or_default()
250 .clone()
251}
252
253fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
259 let lock = push_lock(id);
260 let _held = lock.lock();
261 if queries::detach_playlists_from_other_accounts(&db.conn, account).is_err() {
263 return Err(());
264 }
265
266 let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
267 return Err(());
268 };
269 let remote_id = local.remote_id.as_deref();
270 let song_ids = queries::remote_ids_for_playlist(&db.conn, id).unwrap_or_default();
271
272 if remote_id.is_none() && song_ids.is_empty() {
275 return Err(());
276 }
277
278 if let Some(remote_id) = remote_id
284 && let Err(e) = client.update_playlist(
285 remote_id,
286 Some(&local.name),
287 local.comment.as_deref(),
288 Some(local.public),
289 )
290 {
291 log::warn!(
292 "could not rename playlist '{}' on the server: {e}",
293 local.name
294 );
295 }
296
297 match client.create_playlist(remote_id, &local.name, &song_ids) {
298 Ok(created) => {
299 let new_id = created
300 .as_ref()
301 .map(|c| c.playlist.id.clone())
302 .or_else(|| remote_id.map(str::to_string));
303 if let Some(new_id) = new_id {
304 let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
305 let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
306 let _ = queries::set_playlist_remote(
307 &db.conn,
308 id,
309 &new_id,
310 owner.as_deref(),
311 local.public,
312 account,
313 );
314 let _ = queries::mark_playlist_synced(
315 &db.conn,
316 id,
317 changed.as_deref(),
318 Some(local.revision),
319 );
320 }
321 Ok(())
322 }
323 Err(e) => {
324 log::warn!(
325 "could not push playlist '{}' to the server: {e}",
326 local.name
327 );
328 Err(())
329 }
330 }
331}
332
333fn newer(local: &str, remote: Option<&str>) -> bool {
341 let Some(remote) = remote else { return true };
342 let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
343 normalise(local) > normalise(remote)
344}
345
346pub fn push_to_remote(id: i64) {
357 let cfg = Config::load().unwrap_or_default();
358 if !cfg.remote.enabled {
359 return;
360 }
361 let Some(client) = subsonic_client(&cfg) else {
362 return;
363 };
364 let account = account_key(&cfg.remote.url, &cfg.remote.username);
365 std::thread::Builder::new()
366 .name("koan-playlist-sync".into())
367 .spawn(move || {
368 let Ok(db) = crate::db::pool::shared().get() else {
369 return;
370 };
371 let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
372 return;
373 };
374 if !matches!(
376 queries::auth::is_local_user(&db.conn, list.user_id),
377 Ok(true)
378 ) {
379 return;
380 }
381 let _ = push(&db, &client, &account, id);
382 })
383 .ok();
384}
385
386pub fn delete_on_remote(remote_id: String) {
388 let cfg = Config::load().unwrap_or_default();
389 if !cfg.remote.enabled {
390 return;
391 }
392 let Some(client) = subsonic_client(&cfg) else {
393 return;
394 };
395 std::thread::Builder::new()
396 .name("koan-playlist-sync".into())
397 .spawn(move || {
398 if let Err(e) = client.delete_playlist(&remote_id) {
399 log::warn!("could not delete playlist {remote_id} on the server: {e}");
400 }
401 })
402 .ok();
403}
404
405#[derive(Debug, Default, Clone, Copy)]
407pub struct ExportSummary {
408 pub written: usize,
409 pub skipped: usize,
412}
413
414pub fn export_m3u8(
421 db: &Database,
422 playlist_id: i64,
423 dest: &Path,
424) -> Result<ExportSummary, std::io::Error> {
425 let name = queries::get_playlist(&db.conn, playlist_id)
426 .ok()
427 .flatten()
428 .map(|p| p.name)
429 .unwrap_or_default();
430 let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
431
432 let mut out = ExportSummary::default();
433 let mut file = std::fs::File::create(dest)?;
434 writeln!(file, "#EXTM3U")?;
435 if !name.is_empty() {
436 writeln!(file, "#PLAYLIST:{name}")?;
437 }
438
439 for track in &tracks {
440 let path = track
441 .path
442 .as_deref()
443 .or(track.cached_path.as_deref())
444 .map(PathBuf::from)
445 .filter(|p| p.exists());
446 let Some(path) = path else {
447 out.skipped += 1;
448 continue;
449 };
450 let seconds = track.duration_ms.unwrap_or(0) / 1000;
451 writeln!(
452 file,
453 "#EXTINF:{seconds},{} - {}",
454 track.artist_name, track.title
455 )?;
456 writeln!(file, "{}", path.display())?;
457 out.written += 1;
458 }
459
460 Ok(out)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::db::queries::{TrackMeta, upsert_track};
467
468 fn meta(title: &str, path: &Path) -> TrackMeta {
469 TrackMeta {
470 title: title.into(),
471 artist: "Artist".into(),
472 album_artist: Some("Artist".into()),
473 album: "Album".into(),
474 date: None,
475 disc: None,
476 track_number: None,
477 genre: None,
478 label: None,
479 duration_ms: Some(185_000),
480 codec: Some("FLAC".into()),
481 sample_rate: None,
482 bit_depth: None,
483 channels: None,
484 bitrate: None,
485 size_bytes: None,
486 mtime: None,
487 path: Some(path.to_string_lossy().into_owned()),
488 source: "local".into(),
489 remote_id: None,
490 remote_url: None,
491 album_remote_id: None,
492 artist_remote_id: None,
493 mbid: None,
494 album_mbid: None,
495 album_added_at: None,
496 }
497 }
498
499 #[test]
503 fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
504 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
505
506 let dir = tempfile::tempdir().unwrap();
507 let db = Database::open(&dir.path().join("koan.db")).unwrap();
508 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
509 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
510
511 let id =
512 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
513 .unwrap();
514 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
515
516 let state = SharedPlayerState::new();
517 let queued = |entry: Option<i64>| PlaylistItem {
518 id: QueueItemId::new(),
519 db_id: Some(a),
520 playlist_entry_id: entry,
521 path: dir.path().join("a.flac"),
522 title: "A".into(),
523 artist: "Artist".into(),
524 album_artist: "Artist".into(),
525 album: "Album".into(),
526 year: None,
527 codec: None,
528 track_number: None,
529 disc: None,
530 duration_ms: None,
531 state: ItemState::Ready,
532 };
533
534 assert_eq!(
535 queue_lock(&db, &state),
536 None,
537 "an empty queue is not locked"
538 );
539
540 state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
541 assert_eq!(
542 queue_lock(&db, &state),
543 Some(QueueLock::Playlist(id)),
544 "the queue is the playlist"
545 );
546
547 state.add_items(vec![queued(None)]);
550 assert_eq!(queue_lock(&db, &state), None);
551 }
552
553 #[test]
556 fn rearranging_the_queue_ends_the_lock() {
557 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
558
559 let dir = tempfile::tempdir().unwrap();
560 let db = Database::open(&dir.path().join("koan.db")).unwrap();
561 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
562 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
563 let id =
564 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
565 .unwrap();
566 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
567
568 let state = SharedPlayerState::new();
569 let items: Vec<PlaylistItem> = entries
570 .iter()
571 .map(|entry| PlaylistItem {
572 id: QueueItemId::new(),
573 db_id: Some(a),
574 playlist_entry_id: Some(*entry),
575 path: dir.path().join("a.flac"),
576 title: "A".into(),
577 artist: "Artist".into(),
578 album_artist: "Artist".into(),
579 album: "Album".into(),
580 year: None,
581 codec: None,
582 track_number: None,
583 disc: None,
584 duration_ms: None,
585 state: ItemState::Ready,
586 })
587 .collect();
588 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
589 state.add_items(items);
590 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
591
592 state.reorder_to(&[ids[1], ids[0]]);
593 assert_eq!(
594 queue_lock(&db, &state),
595 None,
596 "same tracks, different order — no longer the playlist"
597 );
598
599 queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
601 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
602 }
603
604 #[test]
608 fn a_queue_holding_exactly_one_record_is_locked_to_it() {
609 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
610
611 let dir = tempfile::tempdir().unwrap();
612 let db = Database::open(&dir.path().join("koan.db")).unwrap();
613 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
614 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
615 let album_id = queries::get_track_row(&db.conn, a)
616 .unwrap()
617 .unwrap()
618 .album_id
619 .unwrap();
620
621 let state = SharedPlayerState::new();
622 let queued = |track: i64| PlaylistItem {
623 id: QueueItemId::new(),
624 db_id: Some(track),
625 playlist_entry_id: None,
626 path: dir.path().join("a.flac"),
627 title: "A".into(),
628 artist: "Artist".into(),
629 album_artist: "Artist".into(),
630 album: "Album".into(),
631 year: None,
632 codec: None,
633 track_number: None,
634 disc: None,
635 duration_ms: None,
636 state: ItemState::Ready,
637 };
638
639 state.add_items(vec![queued(a)]);
640 assert_eq!(
641 queue_lock(&db, &state),
642 None,
643 "half a record is not the record"
644 );
645
646 state.add_items(vec![queued(b)]);
647 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
648 }
649
650 #[test]
651 fn export_writes_what_is_on_disk_and_counts_what_is_not() {
652 let dir = tempfile::tempdir().unwrap();
653 let db = Database::open(&dir.path().join("koan.db")).unwrap();
654
655 let present = dir.path().join("here.flac");
656 std::fs::write(&present, b"x").unwrap();
657 let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
658 let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
659
660 let id =
661 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
662 .unwrap();
663 queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
664
665 let dest = dir.path().join("evening.m3u8");
666 let summary = export_m3u8(&db, id, &dest).unwrap();
667 assert_eq!((summary.written, summary.skipped), (1, 1));
668
669 let written = std::fs::read_to_string(&dest).unwrap();
670 assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
671 assert!(written.contains("#EXTINF:185,Artist - Here"));
672 assert!(written.contains(&present.display().to_string()));
673 assert!(!written.contains("gone.flac"));
674 }
675
676 #[derive(Default)]
679 struct Server {
680 lists: Vec<(String, Vec<String>, u32)>,
681 fetches: usize,
682 creates_without_id: usize,
683 }
684
685 fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
686 use std::io::{BufRead, Write};
687 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
688 let url = format!("http://{}", listener.local_addr().unwrap());
689 std::thread::spawn(move || {
690 for stream in listener.incoming().flatten() {
691 let server = server.clone();
692 std::thread::spawn(move || {
693 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
694 let mut request = String::new();
695 reader.read_line(&mut request).unwrap();
696 let mut line = String::new();
697 while reader.read_line(&mut line).unwrap_or(0) > 2 {
698 line.clear();
699 }
700 let target = request.split_whitespace().nth(1).unwrap_or("");
701 let (path, query) = target.split_once('?').unwrap_or((target, ""));
702 let params: Vec<(&str, &str)> = query
703 .split('&')
704 .filter_map(|kv| kv.split_once('='))
705 .collect();
706 let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
707 let body = respond(&server, path.rsplit('/').next().unwrap(), ¶ms, param);
708 let mut stream = stream;
709 let _ = write!(
710 stream,
711 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
712 body.len()
713 );
714 });
715 }
716 });
717 url
718 }
719
720 fn respond<'a>(
721 server: &parking_lot::Mutex<Server>,
722 endpoint: &str,
723 params: &[(&str, &'a str)],
724 param: impl Fn(&str) -> Option<&'a str>,
725 ) -> String {
726 let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
727 format!(
728 r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
729 songs.len()
730 )
731 };
732 let full = |list: &(String, Vec<String>, u32)| {
733 let entries: Vec<String> = list
734 .1
735 .iter()
736 .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
737 .collect();
738 format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
739 };
740 let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
741 match endpoint {
742 "getPlaylists" => {
743 let lists: Vec<String> = server
744 .lock()
745 .lists
746 .iter()
747 .map(|l| format!("{{{}}}", summary(l)))
748 .collect();
749 ok(format!(
750 r#","playlists":{{"playlist":[{}]}}"#,
751 lists.join(",")
752 ))
753 }
754 "getPlaylist" => {
755 let mut server = server.lock();
756 server.fetches += 1;
757 let list = server
758 .lists
759 .iter()
760 .find(|l| Some(l.0.as_str()) == param("id"));
761 ok(format!(r#","playlist":{}"#, full(list.unwrap())))
762 }
763 "createPlaylist" => {
764 let songs: Vec<String> = params
765 .iter()
766 .filter(|(k, _)| *k == "songId")
767 .map(|(_, v)| v.to_string())
768 .collect();
769 std::thread::sleep(std::time::Duration::from_millis(50));
771 let mut server = server.lock();
772 let id = match param("playlistId") {
773 Some(id) => id.to_string(),
774 None => {
775 server.creates_without_id += 1;
776 format!("p{}", server.lists.len() + 1)
777 }
778 };
779 server.lists.retain(|l| l.0 != id);
780 let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
781 server.lists.push((id, songs, changed));
782 ok(format!(
783 r#","playlist":{}"#,
784 full(server.lists.last().unwrap())
785 ))
786 }
787 _ => ok(String::new()),
788 }
789 }
790
791 fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
792 TrackMeta {
793 path: None,
794 source: "remote".into(),
795 remote_id: Some(remote_id.into()),
796 album: title.into(),
797 ..meta(title, Path::new(""))
798 }
799 }
800
801 fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
802 queries::playlist_entries(&db.conn, id)
803 .unwrap()
804 .into_iter()
805 .map(|e| (e.id, e.track.id))
806 .collect()
807 }
808
809 #[test]
810 fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
811 let dir = tempfile::tempdir().unwrap();
812 let db = Database::open(&dir.path().join("koan.db")).unwrap();
813 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
814 let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
815 let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
816
817 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
818 lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
819 ..Default::default()
820 }));
821 let url = serve(server.clone());
822 let client = SubsonicClient::new(&url, "u", "pw");
823
824 let first = reconcile_playlists(&db, &client, &url, "u");
825 assert_eq!(first.pulled, 1);
826 let id = queries::playlist_by_remote_id(&db.conn, "p1")
827 .unwrap()
828 .unwrap()
829 .id;
830 assert_eq!(
831 entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
832 [r1, r2]
833 );
834
835 queries::add_tracks(&db.conn, id, &[local]).unwrap();
837 let second = reconcile_playlists(&db, &client, &url, "u");
838 assert_eq!((second.pushed, second.pulled), (1, 0));
839 assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
840
841 let before = entries(&db, id);
842 assert_eq!(before.len(), 3);
843 let fetches = server.lock().fetches;
844 let third = reconcile_playlists(&db, &client, &url, "u");
845 assert_eq!((third.pushed, third.pulled), (0, 0));
846 assert_eq!(
847 server.lock().fetches,
848 fetches,
849 "nothing moved, nothing fetched"
850 );
851 assert_eq!(entries(&db, id), before);
852
853 {
856 let mut server = server.lock();
857 server.lists[0].1 = vec!["s2".into()];
858 server.lists[0].2 += 1;
859 }
860 let fourth = reconcile_playlists(&db, &client, &url, "u");
861 assert_eq!(fourth.pulled, 1);
862 assert_eq!(entries(&db, id), [before[1], before[2]]);
863 }
864
865 #[test]
866 fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
867 let dir = tempfile::tempdir().unwrap();
868 let db = Database::open(&dir.path().join("koan.db")).unwrap();
869 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
870 let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
871 queries::add_tracks(&db.conn, id, &[r1]).unwrap();
872 queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
873 .unwrap();
874
875 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
876 let url = serve(server.clone());
877 let client = SubsonicClient::new(&url, "u", "pw");
878 let sync = reconcile_playlists(&db, &client, &url, "u");
879
880 let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
881 assert_eq!(list.track_count, 1);
882 assert_eq!(sync.pushed, 1);
883 assert_eq!(list.remote_id.as_deref(), Some("p1"));
884 }
885
886 #[test]
887 fn concurrent_pushes_create_one_server_playlist() {
888 let dir = tempfile::tempdir().unwrap();
889 let path = dir.path().join("koan.db");
890 let db = Database::open(&path).unwrap();
891 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
892 let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
893 queries::add_tracks(&db.conn, id, &[r1]).unwrap();
894
895 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
896 let url = serve(server.clone());
897 let pushes: Vec<_> = (0..2)
898 .map(|_| {
899 let (path, url) = (path.clone(), url.clone());
900 std::thread::spawn(move || {
901 let db = Database::open(&path).unwrap();
902 let client = SubsonicClient::new(&url, "u", "pw");
903 push(&db, &client, &account_key(&url, "u"), id)
904 })
905 })
906 .collect();
907 for p in pushes {
908 p.join().unwrap().unwrap();
909 }
910 assert_eq!(server.lock().creates_without_id, 1);
911 assert_eq!(server.lock().lists.len(), 1);
912 }
913}