1use std::collections::HashMap;
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::state::SharedPlayerState;
16use crate::remote::client::SubsonicClient;
17
18#[derive(Default)]
27pub struct PlaylistHistory {
28 steps: parking_lot::Mutex<HashMap<i64, Steps>>,
29}
30
31#[derive(Default)]
32struct Steps {
33 undo: Vec<Vec<(i64, i64)>>,
34 redo: Vec<Vec<(i64, i64)>>,
35}
36
37const HISTORY_DEPTH: usize = 100;
39
40impl PlaylistHistory {
41 pub fn record(&self, conn: &rusqlite::Connection, id: i64) -> Result<(), DbError> {
44 let before = queries::entry_snapshot(conn, id)?;
45 let mut steps = self.steps.lock();
46 let steps = steps.entry(id).or_default();
47 steps.undo.push(before);
48 if steps.undo.len() > HISTORY_DEPTH {
49 steps.undo.remove(0);
50 }
51 steps.redo.clear();
52 Ok(())
53 }
54
55 pub fn undo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
57 self.step(conn, id, true)
58 }
59
60 pub fn redo(&self, conn: &rusqlite::Connection, id: i64) -> Result<bool, DbError> {
62 self.step(conn, id, false)
63 }
64
65 fn step(&self, conn: &rusqlite::Connection, id: i64, back: bool) -> Result<bool, DbError> {
66 let mut all = self.steps.lock();
67 let steps = all.entry(id).or_default();
68 let target = if back {
69 steps.undo.pop()
70 } else {
71 steps.redo.pop()
72 };
73 let Some(target) = target else {
74 return Ok(false);
75 };
76 let now = queries::entry_snapshot(conn, id)?;
77 queries::restore_entries(conn, id, &target)?;
78 if back {
79 steps.redo.push(now)
80 } else {
81 steps.undo.push(now)
82 }
83 Ok(true)
84 }
85}
86
87#[cfg(test)]
88mod history_tests {
89 use super::*;
90 use crate::db::queries::{LOCAL_USER, sample_meta, upsert_track};
91
92 #[test]
93 fn undo_and_redo_walk_a_playlists_edits() {
94 let conn = rusqlite::Connection::open_in_memory().unwrap();
95 conn.pragma_update(None, "foreign_keys", "on").unwrap();
96 crate::db::schema::create_tables(&conn).unwrap();
97 let (a, b) = (
98 upsert_track(&conn, &sample_meta("A", "Artist", "X")).unwrap(),
99 upsert_track(&conn, &sample_meta("B", "Artist", "X")).unwrap(),
100 );
101 let id = queries::create_playlist(&conn, LOCAL_USER, "Mix", None).unwrap();
102 let history = PlaylistHistory::default();
103
104 history.record(&conn, id).unwrap();
105 queries::add_tracks(&conn, id, &[a, b]).unwrap();
106 let full = queries::entry_snapshot(&conn, id).unwrap();
107 history.record(&conn, id).unwrap();
108 queries::remove_entries(&conn, id, &[full[0].0]).unwrap();
109
110 assert!(history.undo(&conn, id).unwrap());
111 assert_eq!(queries::entry_snapshot(&conn, id).unwrap(), full);
112 assert!(history.undo(&conn, id).unwrap());
113 assert!(queries::entry_snapshot(&conn, id).unwrap().is_empty());
114 assert!(!history.undo(&conn, id).unwrap(), "nothing further back");
115
116 assert!(history.redo(&conn, id).unwrap());
117 assert_eq!(queries::entry_snapshot(&conn, id).unwrap(), full);
118 }
119}
120
121pub fn queue_lock(db: &Database, state: &SharedPlayerState) -> Option<QueueLock> {
137 let (items, _) = state.snapshot_playlist();
138 if items.is_empty() {
139 return None;
140 }
141
142 let entry_ids: Vec<i64> = items.iter().filter_map(|i| i.playlist_entry_id).collect();
145 if entry_ids.len() == items.len()
146 && let Ok(Some(playlist_id)) = queries::playlist_of_entry(&db.conn, entry_ids[0])
147 && queries::playlist_entry_ids(&db.conn, playlist_id).is_ok_and(|ids| ids == entry_ids)
148 {
149 return Some(QueueLock::Playlist(playlist_id));
150 }
151
152 let track_ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
157 if track_ids.len() != items.len() {
158 return None;
159 }
160 let album_id = queries::get_track_row(&db.conn, track_ids[0])
161 .ok()
162 .flatten()?
163 .album_id?;
164 let album: Vec<i64> = queries::tracks_for_album(&db.conn, album_id)
165 .ok()?
166 .into_iter()
167 .map(|t| t.id)
168 .collect();
169 (album == track_ids).then_some(QueueLock::Album(album_id))
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum QueueLock {
175 Playlist(i64),
176 Album(i64),
177}
178
179#[derive(Debug, Default, Clone, Copy)]
181pub struct PlaylistSync {
182 pub pulled: usize,
184 pub pushed: usize,
186}
187
188pub fn reconcile_playlists(
202 db: &Database,
203 client: &SubsonicClient,
204 url: &str,
205 username: &str,
206) -> PlaylistSync {
207 let mut out = PlaylistSync::default();
208 let account = account_key(url, username);
209
210 let remote = match client.get_playlists() {
211 Ok(lists) => lists,
212 Err(e) => {
213 log::warn!("could not fetch playlists from the server: {e}");
214 return out;
215 }
216 };
217
218 match queries::detach_playlists_from_other_accounts(&db.conn, &account) {
219 Ok(0) => {}
220 Ok(n) => log::info!("{n} playlists belonged to another server; keeping them as local"),
221 Err(e) => {
222 log::warn!("could not check which server playlists belong to: {e}");
223 return out;
224 }
225 }
226
227 let mut seen_remote_ids = Vec::new();
231
232 for summary in &remote {
233 seen_remote_ids.push(summary.id.clone());
234 let local = queries::playlist_by_remote_id(&db.conn, &summary.id)
235 .ok()
236 .flatten();
237 let ours = summary
238 .owner
239 .as_deref()
240 .is_none_or(|owner| owner == username);
241
242 if let Some(local) = &local {
243 let server_moved = summary.changed.as_deref() != local.remote_changed.as_deref();
244 let local_moved = ours && local.unsynced();
245 if local_moved
246 && (!server_moved || newer(&local.changed_at, summary.changed.as_deref()))
247 {
248 if push(db, client, &account, local.id).is_ok() {
249 out.pushed += 1;
250 }
251 continue;
252 }
253 if !server_moved && !local_moved {
254 continue;
255 }
256 }
257
258 let full = match client.get_playlist(&summary.id) {
259 Ok(full) => full,
260 Err(e) => {
261 log::warn!("could not fetch playlist {}: {e}", summary.id);
262 continue;
263 }
264 };
265
266 let id = match local {
267 Some(local) => local.id,
268 None => {
269 match queries::create_playlist(
270 &db.conn,
271 queries::LOCAL_USER,
272 &summary.name,
273 summary.comment.as_deref(),
274 ) {
275 Ok(id) => id,
276 Err(e) => {
277 log::warn!("could not store playlist {}: {e}", summary.name);
278 continue;
279 }
280 }
281 }
282 };
283
284 let _ = queries::rename_playlist(&db.conn, id, &summary.name);
285 let _ = queries::set_playlist_remote(
286 &db.conn,
287 id,
288 &summary.id,
289 summary.owner.as_deref(),
290 summary.public,
291 &account,
292 );
293
294 let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
295 let track_ids: Vec<i64> = queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids)
296 .unwrap_or_default()
297 .into_iter()
298 .flatten()
299 .collect();
300 if let Err(e) = queries::merge_server_tracks(&db.conn, id, &track_ids) {
301 log::warn!(
302 "could not store playlist contents for {}: {e}",
303 summary.name
304 );
305 continue;
306 }
307 let _ = queries::mark_playlist_synced(&db.conn, id, summary.changed.as_deref(), None);
310 out.pulled += 1;
311 }
312
313 for local in queries::list_playlists(&db.conn, queries::LOCAL_USER).unwrap_or_default() {
316 if let Some(remote_id) = &local.remote_id
317 && !seen_remote_ids.contains(remote_id)
318 {
319 let _ = queries::delete_playlist(&db.conn, local.id);
320 }
321 }
322
323 for local in
324 queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
325 {
326 if push(db, client, &account, local.id).is_ok() {
327 out.pushed += 1;
328 }
329 }
330
331 out
332}
333
334fn account_key(url: &str, username: &str) -> String {
336 format!("{username}@{}", url.trim_end_matches('/'))
337}
338
339fn push_lock(id: i64) -> std::sync::Arc<parking_lot::Mutex<()>> {
346 static LOCKS: std::sync::OnceLock<
347 parking_lot::Mutex<std::collections::HashMap<i64, std::sync::Arc<parking_lot::Mutex<()>>>>,
348 > = std::sync::OnceLock::new();
349 LOCKS
350 .get_or_init(Default::default)
351 .lock()
352 .entry(id)
353 .or_default()
354 .clone()
355}
356
357fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
363 let lock = push_lock(id);
364 let _held = lock.lock();
365 if queries::detach_playlists_from_other_accounts(&db.conn, account).is_err() {
367 return Err(());
368 }
369
370 let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
371 return Err(());
372 };
373 let remote_id = local.remote_id.as_deref();
374 let song_ids = queries::remote_ids_for_playlist(&db.conn, id).unwrap_or_default();
375
376 if remote_id.is_none() && song_ids.is_empty() {
379 return Err(());
380 }
381
382 if let Some(remote_id) = remote_id
388 && let Err(e) = client.update_playlist(
389 remote_id,
390 Some(&local.name),
391 local.comment.as_deref(),
392 Some(local.public),
393 )
394 {
395 log::warn!(
396 "could not rename playlist '{}' on the server: {e}",
397 local.name
398 );
399 }
400
401 match client.create_playlist(remote_id, &local.name, &song_ids) {
402 Ok(created) => {
403 let new_id = created
404 .as_ref()
405 .map(|c| c.playlist.id.clone())
406 .or_else(|| remote_id.map(str::to_string));
407 if let Some(new_id) = new_id {
408 let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
409 let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
410 let _ = queries::set_playlist_remote(
411 &db.conn,
412 id,
413 &new_id,
414 owner.as_deref(),
415 local.public,
416 account,
417 );
418 let _ = queries::mark_playlist_synced(
419 &db.conn,
420 id,
421 changed.as_deref(),
422 Some(local.revision),
423 );
424 }
425 Ok(())
426 }
427 Err(e) => {
428 log::warn!(
429 "could not push playlist '{}' to the server: {e}",
430 local.name
431 );
432 Err(())
433 }
434 }
435}
436
437fn newer(local: &str, remote: Option<&str>) -> bool {
445 let Some(remote) = remote else { return true };
446 let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
447 normalise(local) > normalise(remote)
448}
449
450pub fn push_to_remote(id: i64) {
461 let cfg = Config::load().unwrap_or_default();
462 if !cfg.remote.enabled {
463 return;
464 }
465 let Some(client) = subsonic_client(&cfg) else {
466 return;
467 };
468 let account = account_key(&cfg.remote.url, &cfg.remote.username);
469 std::thread::Builder::new()
470 .name("koan-playlist-sync".into())
471 .spawn(move || {
472 let Ok(db) = crate::db::pool::shared().get() else {
473 return;
474 };
475 let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
476 return;
477 };
478 if !matches!(
480 queries::auth::is_local_user(&db.conn, list.user_id),
481 Ok(true)
482 ) {
483 return;
484 }
485 let _ = push(&db, &client, &account, id);
486 })
487 .ok();
488}
489
490pub fn delete_on_remote(remote_id: String) {
492 let cfg = Config::load().unwrap_or_default();
493 if !cfg.remote.enabled {
494 return;
495 }
496 let Some(client) = subsonic_client(&cfg) else {
497 return;
498 };
499 std::thread::Builder::new()
500 .name("koan-playlist-sync".into())
501 .spawn(move || {
502 if let Err(e) = client.delete_playlist(&remote_id) {
503 log::warn!("could not delete playlist {remote_id} on the server: {e}");
504 }
505 })
506 .ok();
507}
508
509#[derive(Debug, Default, Clone, Copy)]
511pub struct ExportSummary {
512 pub written: usize,
513 pub skipped: usize,
516}
517
518pub fn export_m3u8(
525 db: &Database,
526 playlist_id: i64,
527 dest: &Path,
528) -> Result<ExportSummary, std::io::Error> {
529 let name = queries::get_playlist(&db.conn, playlist_id)
530 .ok()
531 .flatten()
532 .map(|p| p.name)
533 .unwrap_or_default();
534 let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
535
536 let mut out = ExportSummary::default();
537 let mut file = std::fs::File::create(dest)?;
538 writeln!(file, "#EXTM3U")?;
539 if !name.is_empty() {
540 writeln!(file, "#PLAYLIST:{name}")?;
541 }
542
543 for track in &tracks {
544 let path = track
545 .path
546 .as_deref()
547 .or(track.cached_path.as_deref())
548 .map(PathBuf::from)
549 .filter(|p| p.exists());
550 let Some(path) = path else {
551 out.skipped += 1;
552 continue;
553 };
554 let seconds = track.duration_ms.unwrap_or(0) / 1000;
555 writeln!(
556 file,
557 "#EXTINF:{seconds},{} - {}",
558 track.artist_name, track.title
559 )?;
560 writeln!(file, "{}", path.display())?;
561 out.written += 1;
562 }
563
564 Ok(out)
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::db::queries::{TrackMeta, upsert_track};
571
572 fn meta(title: &str, path: &Path) -> TrackMeta {
573 TrackMeta {
574 title: title.into(),
575 artist: "Artist".into(),
576 album_artist: Some("Artist".into()),
577 album: "Album".into(),
578 date: None,
579 disc: None,
580 track_number: None,
581 genre: None,
582 label: None,
583 duration_ms: Some(185_000),
584 codec: Some("FLAC".into()),
585 sample_rate: None,
586 bit_depth: None,
587 channels: None,
588 bitrate: None,
589 size_bytes: None,
590 mtime: None,
591 path: Some(path.to_string_lossy().into_owned()),
592 source: "local".into(),
593 remote_id: None,
594 remote_url: None,
595 album_remote_id: None,
596 artist_remote_id: None,
597 mbid: None,
598 album_mbid: None,
599 album_added_at: None,
600 }
601 }
602
603 #[test]
607 fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
608 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
609
610 let dir = tempfile::tempdir().unwrap();
611 let db = Database::open(&dir.path().join("koan.db")).unwrap();
612 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
613 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
614
615 let id =
616 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
617 .unwrap();
618 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
619
620 let state = SharedPlayerState::new();
621 let queued = |entry: Option<i64>| PlaylistItem {
622 id: QueueItemId::new(),
623 db_id: Some(a),
624 playlist_entry_id: entry,
625 path: dir.path().join("a.flac"),
626 title: "A".into(),
627 artist: "Artist".into(),
628 album_artist: "Artist".into(),
629 album: "Album".into(),
630 year: None,
631 codec: None,
632 track_number: None,
633 disc: None,
634 duration_ms: None,
635 state: ItemState::Ready,
636 pre_shuffle: None,
637 };
638
639 assert_eq!(
640 queue_lock(&db, &state),
641 None,
642 "an empty queue is not locked"
643 );
644
645 state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
646 assert_eq!(
647 queue_lock(&db, &state),
648 Some(QueueLock::Playlist(id)),
649 "the queue is the playlist"
650 );
651
652 state.add_items(vec![queued(None)]);
655 assert_eq!(queue_lock(&db, &state), None);
656 }
657
658 #[test]
661 fn rearranging_the_queue_ends_the_lock() {
662 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
663
664 let dir = tempfile::tempdir().unwrap();
665 let db = Database::open(&dir.path().join("koan.db")).unwrap();
666 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
667 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
668 let id =
669 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
670 .unwrap();
671 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
672
673 let state = SharedPlayerState::new();
674 let items: Vec<PlaylistItem> = entries
675 .iter()
676 .map(|entry| PlaylistItem {
677 id: QueueItemId::new(),
678 db_id: Some(a),
679 playlist_entry_id: Some(*entry),
680 path: dir.path().join("a.flac"),
681 title: "A".into(),
682 artist: "Artist".into(),
683 album_artist: "Artist".into(),
684 album: "Album".into(),
685 year: None,
686 codec: None,
687 track_number: None,
688 disc: None,
689 duration_ms: None,
690 state: ItemState::Ready,
691 pre_shuffle: None,
692 })
693 .collect();
694 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
695 state.add_items(items);
696 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
697
698 state.reorder_to(&[ids[1], ids[0]]);
699 assert_eq!(
700 queue_lock(&db, &state),
701 None,
702 "same tracks, different order — no longer the playlist"
703 );
704
705 queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
707 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
708 }
709
710 #[test]
714 fn a_queue_holding_exactly_one_record_is_locked_to_it() {
715 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
716
717 let dir = tempfile::tempdir().unwrap();
718 let db = Database::open(&dir.path().join("koan.db")).unwrap();
719 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
720 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
721 let album_id = queries::get_track_row(&db.conn, a)
722 .unwrap()
723 .unwrap()
724 .album_id
725 .unwrap();
726
727 let state = SharedPlayerState::new();
728 let queued = |track: i64| PlaylistItem {
729 id: QueueItemId::new(),
730 db_id: Some(track),
731 playlist_entry_id: None,
732 path: dir.path().join("a.flac"),
733 title: "A".into(),
734 artist: "Artist".into(),
735 album_artist: "Artist".into(),
736 album: "Album".into(),
737 year: None,
738 codec: None,
739 track_number: None,
740 disc: None,
741 duration_ms: None,
742 state: ItemState::Ready,
743 pre_shuffle: None,
744 };
745
746 state.add_items(vec![queued(a)]);
747 assert_eq!(
748 queue_lock(&db, &state),
749 None,
750 "half a record is not the record"
751 );
752
753 state.add_items(vec![queued(b)]);
754 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
755 }
756
757 #[test]
758 fn export_writes_what_is_on_disk_and_counts_what_is_not() {
759 let dir = tempfile::tempdir().unwrap();
760 let db = Database::open(&dir.path().join("koan.db")).unwrap();
761
762 let present = dir.path().join("here.flac");
763 std::fs::write(&present, b"x").unwrap();
764 let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
765 let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
766
767 let id =
768 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
769 .unwrap();
770 queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
771
772 let dest = dir.path().join("evening.m3u8");
773 let summary = export_m3u8(&db, id, &dest).unwrap();
774 assert_eq!((summary.written, summary.skipped), (1, 1));
775
776 let written = std::fs::read_to_string(&dest).unwrap();
777 assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
778 assert!(written.contains("#EXTINF:185,Artist - Here"));
779 assert!(written.contains(&present.display().to_string()));
780 assert!(!written.contains("gone.flac"));
781 }
782
783 #[derive(Default)]
786 struct Server {
787 lists: Vec<(String, Vec<String>, u32)>,
788 fetches: usize,
789 creates_without_id: usize,
790 }
791
792 fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
793 use std::io::{BufRead, Write};
794 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
795 let url = format!("http://{}", listener.local_addr().unwrap());
796 std::thread::spawn(move || {
797 for stream in listener.incoming().flatten() {
798 let server = server.clone();
799 std::thread::spawn(move || {
800 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
801 let mut request = String::new();
802 reader.read_line(&mut request).unwrap();
803 let mut line = String::new();
804 while reader.read_line(&mut line).unwrap_or(0) > 2 {
805 line.clear();
806 }
807 let target = request.split_whitespace().nth(1).unwrap_or("");
808 let (path, query) = target.split_once('?').unwrap_or((target, ""));
809 let params: Vec<(&str, &str)> = query
810 .split('&')
811 .filter_map(|kv| kv.split_once('='))
812 .collect();
813 let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
814 let body = respond(&server, path.rsplit('/').next().unwrap(), ¶ms, param);
815 let mut stream = stream;
816 let _ = write!(
817 stream,
818 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
819 body.len()
820 );
821 });
822 }
823 });
824 url
825 }
826
827 fn respond<'a>(
828 server: &parking_lot::Mutex<Server>,
829 endpoint: &str,
830 params: &[(&str, &'a str)],
831 param: impl Fn(&str) -> Option<&'a str>,
832 ) -> String {
833 let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
834 format!(
835 r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
836 songs.len()
837 )
838 };
839 let full = |list: &(String, Vec<String>, u32)| {
840 let entries: Vec<String> = list
841 .1
842 .iter()
843 .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
844 .collect();
845 format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
846 };
847 let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
848 match endpoint {
849 "getPlaylists" => {
850 let lists: Vec<String> = server
851 .lock()
852 .lists
853 .iter()
854 .map(|l| format!("{{{}}}", summary(l)))
855 .collect();
856 ok(format!(
857 r#","playlists":{{"playlist":[{}]}}"#,
858 lists.join(",")
859 ))
860 }
861 "getPlaylist" => {
862 let mut server = server.lock();
863 server.fetches += 1;
864 let list = server
865 .lists
866 .iter()
867 .find(|l| Some(l.0.as_str()) == param("id"));
868 ok(format!(r#","playlist":{}"#, full(list.unwrap())))
869 }
870 "createPlaylist" => {
871 let songs: Vec<String> = params
872 .iter()
873 .filter(|(k, _)| *k == "songId")
874 .map(|(_, v)| v.to_string())
875 .collect();
876 std::thread::sleep(std::time::Duration::from_millis(50));
878 let mut server = server.lock();
879 let id = match param("playlistId") {
880 Some(id) => id.to_string(),
881 None => {
882 server.creates_without_id += 1;
883 format!("p{}", server.lists.len() + 1)
884 }
885 };
886 server.lists.retain(|l| l.0 != id);
887 let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
888 server.lists.push((id, songs, changed));
889 ok(format!(
890 r#","playlist":{}"#,
891 full(server.lists.last().unwrap())
892 ))
893 }
894 _ => ok(String::new()),
895 }
896 }
897
898 fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
899 TrackMeta {
900 path: None,
901 source: "remote".into(),
902 remote_id: Some(remote_id.into()),
903 album: title.into(),
904 ..meta(title, Path::new(""))
905 }
906 }
907
908 fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
909 queries::playlist_entries(&db.conn, id)
910 .unwrap()
911 .into_iter()
912 .map(|e| (e.id, e.track.id))
913 .collect()
914 }
915
916 #[test]
917 fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
918 let dir = tempfile::tempdir().unwrap();
919 let db = Database::open(&dir.path().join("koan.db")).unwrap();
920 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
921 let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
922 let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
923
924 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
925 lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
926 ..Default::default()
927 }));
928 let url = serve(server.clone());
929 let client = SubsonicClient::new(&url, "u", "pw");
930
931 let first = reconcile_playlists(&db, &client, &url, "u");
932 assert_eq!(first.pulled, 1);
933 let id = queries::playlist_by_remote_id(&db.conn, "p1")
934 .unwrap()
935 .unwrap()
936 .id;
937 assert_eq!(
938 entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
939 [r1, r2]
940 );
941
942 queries::add_tracks(&db.conn, id, &[local]).unwrap();
944 let second = reconcile_playlists(&db, &client, &url, "u");
945 assert_eq!((second.pushed, second.pulled), (1, 0));
946 assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
947
948 let before = entries(&db, id);
949 assert_eq!(before.len(), 3);
950 let fetches = server.lock().fetches;
951 let third = reconcile_playlists(&db, &client, &url, "u");
952 assert_eq!((third.pushed, third.pulled), (0, 0));
953 assert_eq!(
954 server.lock().fetches,
955 fetches,
956 "nothing moved, nothing fetched"
957 );
958 assert_eq!(entries(&db, id), before);
959
960 {
963 let mut server = server.lock();
964 server.lists[0].1 = vec!["s2".into()];
965 server.lists[0].2 += 1;
966 }
967 let fourth = reconcile_playlists(&db, &client, &url, "u");
968 assert_eq!(fourth.pulled, 1);
969 assert_eq!(entries(&db, id), [before[1], before[2]]);
970 }
971
972 #[test]
973 fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
974 let dir = tempfile::tempdir().unwrap();
975 let db = Database::open(&dir.path().join("koan.db")).unwrap();
976 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
977 let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
978 queries::add_tracks(&db.conn, id, &[r1]).unwrap();
979 queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
980 .unwrap();
981
982 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
983 let url = serve(server.clone());
984 let client = SubsonicClient::new(&url, "u", "pw");
985 let sync = reconcile_playlists(&db, &client, &url, "u");
986
987 let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
988 assert_eq!(list.track_count, 1);
989 assert_eq!(sync.pushed, 1);
990 assert_eq!(list.remote_id.as_deref(), Some("p1"));
991 }
992
993 #[test]
994 fn concurrent_pushes_create_one_server_playlist() {
995 let dir = tempfile::tempdir().unwrap();
996 let path = dir.path().join("koan.db");
997 let db = Database::open(&path).unwrap();
998 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
999 let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1000 queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1001
1002 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1003 let url = serve(server.clone());
1004 let pushes: Vec<_> = (0..2)
1005 .map(|_| {
1006 let (path, url) = (path.clone(), url.clone());
1007 std::thread::spawn(move || {
1008 let db = Database::open(&path).unwrap();
1009 let client = SubsonicClient::new(&url, "u", "pw");
1010 push(&db, &client, &account_key(&url, "u"), id)
1011 })
1012 })
1013 .collect();
1014 for p in pushes {
1015 p.join().unwrap().unwrap();
1016 }
1017 assert_eq!(server.lock().creates_without_id, 1);
1018 assert_eq!(server.lock().lists.len(), 1);
1019 }
1020}