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, SubsonicError};
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(
211 db: &Database,
212 client: &SubsonicClient,
213 url: &str,
214 username: &str,
215) -> PlaylistSync {
216 let mut out = PlaylistSync::default();
217 let account = account_key(url, username);
218
219 let remote = match client.get_playlists() {
220 Ok(lists) => lists,
221 Err(e) => {
222 log::warn!("could not fetch playlists from the server: {e}");
223 return out;
224 }
225 };
226
227 match queries::detach_playlists_from_other_accounts(&db.conn, &account) {
228 Ok(0) => {}
229 Ok(n) => log::info!("{n} playlists belonged to another server; keeping them as local"),
230 Err(e) => {
231 log::warn!("could not check which server playlists belong to: {e}");
232 return out;
233 }
234 }
235
236 let mut seen_remote_ids = Vec::new();
240
241 for summary in &remote {
242 seen_remote_ids.push(summary.id.clone());
243 let local = match queries::playlist_by_remote_id(&db.conn, &summary.id) {
244 Ok(local) => local,
245 Err(e) => {
246 log::warn!("could not look up playlist {}: {e}", summary.name);
247 continue;
248 }
249 };
250 let ours = !summary.readonly
253 && summary
254 .owner
255 .as_deref()
256 .is_none_or(|owner| owner == username);
257
258 if let Some(local) = &local {
259 let server_moved = summary.changed.as_deref() != local.remote_changed.as_deref();
260 let local_moved = ours && local.unsynced();
261 if local_moved
262 && (!server_moved || newer(&local.changed_at, summary.changed.as_deref()))
263 {
264 if push(db, client, &account, local.id).is_ok() {
265 out.pushed += 1;
266 }
267 continue;
268 }
269 if !server_moved && !local_moved {
270 continue;
271 }
272 }
273
274 let full = match client.get_playlist(&summary.id) {
275 Ok(full) => full,
276 Err(e) => {
277 log::warn!("could not fetch playlist {}: {e}", summary.id);
278 continue;
279 }
280 };
281
282 let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
283 let track_ids = match queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids) {
284 Ok(ids) => ids,
285 Err(e) => {
286 log::warn!("could not read playlist {}: {e}", summary.name);
287 continue;
288 }
289 };
290 let unknown = track_ids.iter().filter(|t| t.is_none()).count();
291 if unknown > 0 {
292 log::info!(
293 "playlist {}: {unknown} of {} songs are not in the library; \
294 they stay on the server",
295 summary.name,
296 track_ids.len()
297 );
298 }
299 let track_ids: Vec<i64> = track_ids.into_iter().flatten().collect();
300
301 let stored = queries::atomically(&db.conn, || {
304 let id = match &local {
305 Some(local) => local.id,
306 None => queries::create_playlist(
307 &db.conn,
308 queries::LOCAL_USER,
309 &summary.name,
310 summary.comment.as_deref(),
311 )?,
312 };
313 queries::rename_playlist(&db.conn, id, &summary.name)?;
314 queries::set_playlist_readonly(&db.conn, id, summary.readonly)?;
315 queries::set_playlist_remote(
316 &db.conn,
317 id,
318 &summary.id,
319 summary.owner.as_deref(),
320 summary.public,
321 &account,
322 )?;
323 queries::merge_server_tracks(&db.conn, id, &track_ids)?;
324 queries::mark_playlist_synced(&db.conn, id, summary.changed.as_deref(), None)
327 });
328 match stored {
329 Ok(()) => out.pulled += 1,
330 Err(e) => log::warn!("could not store playlist {}: {e}", summary.name),
331 }
332 }
333
334 let held = match queries::list_playlists(&db.conn, queries::LOCAL_USER) {
337 Ok(lists) => lists,
338 Err(e) => {
339 log::warn!("could not list playlists to check for deletions: {e}");
340 Vec::new()
341 }
342 };
343 for local in &held {
344 let Some(remote_id) = local.remote_id.as_deref() else {
345 continue;
346 };
347 if seen_remote_ids.iter().any(|seen| seen == remote_id) {
348 continue;
349 }
350 match client.get_playlist(remote_id) {
351 Err(SubsonicError::Api { code: 70, .. }) => {
352 if let Err(e) = queries::delete_playlist(&db.conn, local.id) {
353 log::warn!("could not delete playlist {}: {e}", local.name);
354 }
355 }
356 Ok(_) => log::info!(
357 "playlist {} was missing from the listing but still exists",
358 local.name
359 ),
360 Err(e) => log::warn!(
361 "could not confirm playlist {} was deleted ({e}); keeping it",
362 local.name
363 ),
364 }
365 }
366
367 for local in
368 queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
369 {
370 if push(db, client, &account, local.id).is_ok() {
371 out.pushed += 1;
372 }
373 }
374
375 out
376}
377
378fn account_key(url: &str, username: &str) -> String {
380 format!("{username}@{}", url.trim_end_matches('/'))
381}
382
383fn push_lock(id: i64) -> std::sync::Arc<parking_lot::Mutex<()>> {
390 static LOCKS: std::sync::OnceLock<
391 parking_lot::Mutex<std::collections::HashMap<i64, std::sync::Arc<parking_lot::Mutex<()>>>>,
392 > = std::sync::OnceLock::new();
393 LOCKS
394 .get_or_init(Default::default)
395 .lock()
396 .entry(id)
397 .or_default()
398 .clone()
399}
400
401fn push(db: &Database, client: &SubsonicClient, account: &str, id: i64) -> Result<(), ()> {
407 let lock = push_lock(id);
408 let _held = lock.lock();
409 if queries::detach_playlists_from_other_accounts(&db.conn, account).is_err() {
411 return Err(());
412 }
413
414 let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
415 return Err(());
416 };
417 if local.readonly {
420 return Err(());
421 }
422 let remote_id = local.remote_id.as_deref();
423 let song_ids = match queries::remote_ids_for_playlist(&db.conn, id) {
424 Ok(ids) => ids,
425 Err(e) => {
426 log::warn!("could not read playlist '{}' to push it: {e}", local.name);
427 return Err(());
428 }
429 };
430
431 if song_ids.is_empty() && (remote_id.is_none() || local.track_count > 0) {
435 if remote_id.is_some() {
436 log::warn!(
437 "playlist '{}' holds nothing the server has; not emptying the server's copy",
438 local.name
439 );
440 }
441 return Err(());
442 }
443
444 let song_ids = match remote_id {
448 None => song_ids,
449 Some(remote_id) => {
450 let server = client
451 .get_playlist(remote_id)
452 .map_err(|e| e.to_string())
453 .and_then(|full| {
454 let ids: Vec<String> = full.entry.into_iter().map(|s| s.id).collect();
455 let known = queries::track_ids_for_remote_ids(&db.conn, &ids)
456 .map_err(|e| e.to_string())?;
457 Ok((ids, known))
458 });
459 match server {
460 Ok((ids, known)) => {
461 let known: Vec<bool> = known.iter().map(Option::is_some).collect();
462 keep_unknown(&ids, &known, song_ids)
463 }
464 Err(e) => {
465 log::warn!(
466 "could not read playlist '{}' on the server before pushing: {e}",
467 local.name
468 );
469 return Err(());
470 }
471 }
472 }
473 };
474
475 if let Some(remote_id) = remote_id
481 && let Err(e) = client.update_playlist(
482 remote_id,
483 Some(&local.name),
484 local.comment.as_deref(),
485 Some(local.public),
486 )
487 {
488 log::warn!(
489 "could not rename playlist '{}' on the server: {e}",
490 local.name
491 );
492 }
493
494 match client.create_playlist(remote_id, &local.name, &song_ids) {
495 Ok(created) => {
496 let new_id = created
497 .as_ref()
498 .map(|c| c.playlist.id.clone())
499 .or_else(|| remote_id.map(str::to_string));
500 if let Some(new_id) = new_id {
501 let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
502 let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
503 let recorded = queries::atomically(&db.conn, || {
504 queries::set_playlist_remote(
505 &db.conn,
506 id,
507 &new_id,
508 owner.as_deref(),
509 local.public,
510 account,
511 )?;
512 queries::mark_playlist_synced(
513 &db.conn,
514 id,
515 changed.as_deref(),
516 Some(local.revision),
517 )
518 });
519 if let Err(e) = recorded {
520 log::warn!(
521 "playlist '{}' was pushed but its server id was not recorded: {e}",
522 local.name
523 );
524 return Err(());
525 }
526 }
527 Ok(())
528 }
529 Err(e) => {
530 log::warn!(
531 "could not push playlist '{}' to the server: {e}",
532 local.name
533 );
534 Err(())
535 }
536 }
537}
538
539fn keep_unknown(server: &[String], known: &[bool], local: Vec<String>) -> Vec<String> {
545 let mut after: HashMap<&str, Vec<String>> = HashMap::new();
546 let mut head = Vec::new();
547 let mut anchor: Option<&str> = None;
548 for (id, &known) in server.iter().zip(known) {
549 if known {
550 if local.iter().any(|l| l == id) {
551 anchor = Some(id);
552 }
553 } else {
554 match anchor {
555 Some(a) => after.entry(a).or_default().push(id.clone()),
556 None => head.push(id.clone()),
557 }
558 }
559 }
560 let mut out = head;
561 for id in local {
562 let follow = after.remove(id.as_str());
563 out.push(id);
564 out.extend(follow.into_iter().flatten());
565 }
566 out
567}
568
569fn newer(local: &str, remote: Option<&str>) -> bool {
577 let Some(remote) = remote else { return true };
578 let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
579 normalise(local) > normalise(remote)
580}
581
582pub fn push_to_remote(id: i64) {
593 let cfg = Config::load().unwrap_or_default();
594 if !cfg.remote.enabled {
595 return;
596 }
597 let Some(client) = subsonic_client(&cfg) else {
598 return;
599 };
600 let account = account_key(&cfg.remote.url, &cfg.remote.username);
601 std::thread::Builder::new()
602 .name("koan-playlist-sync".into())
603 .spawn(move || {
604 let Ok(db) = crate::db::pool::shared().get() else {
605 return;
606 };
607 let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
608 return;
609 };
610 if !matches!(
612 queries::auth::is_local_user(&db.conn, list.user_id),
613 Ok(true)
614 ) {
615 return;
616 }
617 let _ = push(&db, &client, &account, id);
618 })
619 .ok();
620}
621
622pub fn delete_on_remote(remote_id: String) {
624 let cfg = Config::load().unwrap_or_default();
625 if !cfg.remote.enabled {
626 return;
627 }
628 let Some(client) = subsonic_client(&cfg) else {
629 return;
630 };
631 std::thread::Builder::new()
632 .name("koan-playlist-sync".into())
633 .spawn(move || {
634 if let Err(e) = client.delete_playlist(&remote_id) {
635 log::warn!("could not delete playlist {remote_id} on the server: {e}");
636 }
637 })
638 .ok();
639}
640
641#[derive(Debug, Default, Clone, Copy)]
643pub struct ExportSummary {
644 pub written: usize,
645 pub skipped: usize,
648}
649
650pub fn export_m3u8(
657 db: &Database,
658 playlist_id: i64,
659 dest: &Path,
660) -> Result<ExportSummary, std::io::Error> {
661 let name = queries::get_playlist(&db.conn, playlist_id)
662 .ok()
663 .flatten()
664 .map(|p| p.name)
665 .unwrap_or_default();
666 let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
667
668 let mut out = ExportSummary::default();
669 let mut file = std::fs::File::create(dest)?;
670 writeln!(file, "#EXTM3U")?;
671 if !name.is_empty() {
672 writeln!(file, "#PLAYLIST:{name}")?;
673 }
674
675 for track in &tracks {
676 let path = track
677 .path
678 .as_deref()
679 .or(track.cached_path.as_deref())
680 .map(PathBuf::from)
681 .filter(|p| p.exists());
682 let Some(path) = path else {
683 out.skipped += 1;
684 continue;
685 };
686 let seconds = track.duration_ms.unwrap_or(0) / 1000;
687 writeln!(
688 file,
689 "#EXTINF:{seconds},{} - {}",
690 track.artist_name, track.title
691 )?;
692 writeln!(file, "{}", path.display())?;
693 out.written += 1;
694 }
695
696 Ok(out)
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use crate::db::queries::{TrackMeta, upsert_track};
703
704 fn meta(title: &str, path: &Path) -> TrackMeta {
705 TrackMeta {
706 title: title.into(),
707 artist: "Artist".into(),
708 album_artist: Some("Artist".into()),
709 album: "Album".into(),
710 date: None,
711 disc: None,
712 track_number: None,
713 genre: None,
714 label: None,
715 duration_ms: Some(185_000),
716 codec: Some("FLAC".into()),
717 sample_rate: None,
718 bit_depth: None,
719 channels: None,
720 bitrate: None,
721 size_bytes: None,
722 mtime: None,
723 path: Some(path.to_string_lossy().into_owned()),
724 source: "local".into(),
725 remote_id: None,
726 remote_url: None,
727 album_remote_id: None,
728 artist_remote_id: None,
729 mbid: None,
730 album_mbid: None,
731 album_added_at: None,
732 }
733 }
734
735 #[test]
739 fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
740 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
741
742 let dir = tempfile::tempdir().unwrap();
743 let db = Database::open(&dir.path().join("koan.db")).unwrap();
744 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
745 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
746
747 let id =
748 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
749 .unwrap();
750 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
751
752 let state = SharedPlayerState::new();
753 let queued = |entry: Option<i64>| PlaylistItem {
754 id: QueueItemId::new(),
755 db_id: Some(a),
756 playlist_entry_id: entry,
757 path: dir.path().join("a.flac"),
758 title: "A".into(),
759 artist: "Artist".into(),
760 album_artist: "Artist".into(),
761 album: "Album".into(),
762 year: None,
763 codec: None,
764 track_number: None,
765 disc: None,
766 duration_ms: None,
767 state: ItemState::Ready,
768 played: false,
769 };
770
771 assert_eq!(
772 queue_lock(&db, &state),
773 None,
774 "an empty queue is not locked"
775 );
776
777 state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
778 assert_eq!(
779 queue_lock(&db, &state),
780 Some(QueueLock::Playlist(id)),
781 "the queue is the playlist"
782 );
783
784 state.add_items(vec![queued(None)]);
787 assert_eq!(queue_lock(&db, &state), None);
788 }
789
790 #[test]
793 fn rearranging_the_queue_ends_the_lock() {
794 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
795
796 let dir = tempfile::tempdir().unwrap();
797 let db = Database::open(&dir.path().join("koan.db")).unwrap();
798 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
799 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
800 let id =
801 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
802 .unwrap();
803 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
804
805 let state = SharedPlayerState::new();
806 let items: Vec<PlaylistItem> = entries
807 .iter()
808 .map(|entry| PlaylistItem {
809 id: QueueItemId::new(),
810 db_id: Some(a),
811 playlist_entry_id: Some(*entry),
812 path: dir.path().join("a.flac"),
813 title: "A".into(),
814 artist: "Artist".into(),
815 album_artist: "Artist".into(),
816 album: "Album".into(),
817 year: None,
818 codec: None,
819 track_number: None,
820 disc: None,
821 duration_ms: None,
822 state: ItemState::Ready,
823 played: false,
824 })
825 .collect();
826 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
827 state.add_items(items);
828 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
829
830 state.reorder_to(&[ids[1], ids[0]]);
831 assert_eq!(
832 queue_lock(&db, &state),
833 None,
834 "same tracks, different order — no longer the playlist"
835 );
836
837 queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
839 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
840 }
841
842 #[test]
846 fn a_queue_holding_exactly_one_record_is_locked_to_it() {
847 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
848
849 let dir = tempfile::tempdir().unwrap();
850 let db = Database::open(&dir.path().join("koan.db")).unwrap();
851 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
852 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
853 let album_id = queries::get_track_row(&db.conn, a)
854 .unwrap()
855 .unwrap()
856 .album_id
857 .unwrap();
858
859 let state = SharedPlayerState::new();
860 let queued = |track: i64| PlaylistItem {
861 id: QueueItemId::new(),
862 db_id: Some(track),
863 playlist_entry_id: None,
864 path: dir.path().join("a.flac"),
865 title: "A".into(),
866 artist: "Artist".into(),
867 album_artist: "Artist".into(),
868 album: "Album".into(),
869 year: None,
870 codec: None,
871 track_number: None,
872 disc: None,
873 duration_ms: None,
874 state: ItemState::Ready,
875 played: false,
876 };
877
878 state.add_items(vec![queued(a)]);
879 assert_eq!(
880 queue_lock(&db, &state),
881 None,
882 "half a record is not the record"
883 );
884
885 state.add_items(vec![queued(b)]);
886 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
887 }
888
889 #[test]
890 fn export_writes_what_is_on_disk_and_counts_what_is_not() {
891 let dir = tempfile::tempdir().unwrap();
892 let db = Database::open(&dir.path().join("koan.db")).unwrap();
893
894 let present = dir.path().join("here.flac");
895 std::fs::write(&present, b"x").unwrap();
896 let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
897 let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
898
899 let id =
900 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
901 .unwrap();
902 queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
903
904 let dest = dir.path().join("evening.m3u8");
905 let summary = export_m3u8(&db, id, &dest).unwrap();
906 assert_eq!((summary.written, summary.skipped), (1, 1));
907
908 let written = std::fs::read_to_string(&dest).unwrap();
909 assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
910 assert!(written.contains("#EXTINF:185,Artist - Here"));
911 assert!(written.contains(&present.display().to_string()));
912 assert!(!written.contains("gone.flac"));
913 }
914
915 #[derive(Default)]
918 struct Server {
919 lists: Vec<(String, Vec<String>, u32)>,
920 fetches: usize,
921 creates_without_id: usize,
922 hide_listing: bool,
924 }
925
926 fn serve(server: std::sync::Arc<parking_lot::Mutex<Server>>) -> String {
927 use std::io::{BufRead, Write};
928 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
929 let url = format!("http://{}", listener.local_addr().unwrap());
930 std::thread::spawn(move || {
931 for stream in listener.incoming().flatten() {
932 let server = server.clone();
933 std::thread::spawn(move || {
934 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
935 let mut request = String::new();
936 reader.read_line(&mut request).unwrap();
937 let mut line = String::new();
938 while reader.read_line(&mut line).unwrap_or(0) > 2 {
939 line.clear();
940 }
941 let target = request.split_whitespace().nth(1).unwrap_or("");
942 let (path, query) = target.split_once('?').unwrap_or((target, ""));
943 let params: Vec<(&str, &str)> = query
944 .split('&')
945 .filter_map(|kv| kv.split_once('='))
946 .collect();
947 let param = |k: &str| params.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
948 let body = respond(&server, path.rsplit('/').next().unwrap(), ¶ms, param);
949 let mut stream = stream;
950 let _ = write!(
951 stream,
952 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
953 body.len()
954 );
955 });
956 }
957 });
958 url
959 }
960
961 fn respond<'a>(
962 server: &parking_lot::Mutex<Server>,
963 endpoint: &str,
964 params: &[(&str, &'a str)],
965 param: impl Fn(&str) -> Option<&'a str>,
966 ) -> String {
967 let summary = |(id, songs, changed): &(String, Vec<String>, u32)| {
968 format!(
969 r#""id":"{id}","name":"{id}","owner":"u","songCount":{},"changed":"{changed}""#,
970 songs.len()
971 )
972 };
973 let full = |list: &(String, Vec<String>, u32)| {
974 let entries: Vec<String> = list
975 .1
976 .iter()
977 .map(|s| format!(r#"{{"id":"{s}","title":"{s}"}}"#))
978 .collect();
979 format!(r#"{{{},"entry":[{}]}}"#, summary(list), entries.join(","))
980 };
981 let ok = |inner: String| format!(r#"{{"subsonic-response":{{"status":"ok"{inner}}}}}"#);
982 match endpoint {
983 "getPlaylists" => {
984 let server = server.lock();
985 let lists: Vec<String> = server
986 .lists
987 .iter()
988 .filter(|_| !server.hide_listing)
989 .map(|l| format!("{{{}}}", summary(l)))
990 .collect();
991 ok(format!(
992 r#","playlists":{{"playlist":[{}]}}"#,
993 lists.join(",")
994 ))
995 }
996 "getPlaylist" => {
997 let mut server = server.lock();
998 server.fetches += 1;
999 match server
1000 .lists
1001 .iter()
1002 .find(|l| Some(l.0.as_str()) == param("id"))
1003 {
1004 Some(list) => ok(format!(r#","playlist":{}"#, full(list))),
1005 None => r#"{"subsonic-response":{"status":"failed","error":{"code":70,"message":"not found"}}}"#.to_string(),
1006 }
1007 }
1008 "createPlaylist" => {
1009 let songs: Vec<String> = params
1010 .iter()
1011 .filter(|(k, _)| *k == "songId")
1012 .map(|(_, v)| v.to_string())
1013 .collect();
1014 std::thread::sleep(std::time::Duration::from_millis(50));
1016 let mut server = server.lock();
1017 let id = match param("playlistId") {
1018 Some(id) => id.to_string(),
1019 None => {
1020 server.creates_without_id += 1;
1021 format!("p{}", server.lists.len() + 1)
1022 }
1023 };
1024 server.lists.retain(|l| l.0 != id);
1025 let changed = server.lists.iter().map(|l| l.2).max().unwrap_or(0) + 100;
1026 server.lists.push((id, songs, changed));
1027 ok(format!(
1028 r#","playlist":{}"#,
1029 full(server.lists.last().unwrap())
1030 ))
1031 }
1032 _ => ok(String::new()),
1033 }
1034 }
1035
1036 fn remote_meta(title: &str, remote_id: &str) -> TrackMeta {
1037 TrackMeta {
1038 path: None,
1039 source: "remote".into(),
1040 remote_id: Some(remote_id.into()),
1041 album: title.into(),
1042 ..meta(title, Path::new(""))
1043 }
1044 }
1045
1046 fn entries(db: &Database, id: i64) -> Vec<(i64, i64)> {
1047 queries::playlist_entries(&db.conn, id)
1048 .unwrap()
1049 .into_iter()
1050 .map(|e| (e.id, e.track.id))
1051 .collect()
1052 }
1053
1054 #[test]
1055 fn a_sync_keeps_local_only_entries_and_fetches_nothing_unchanged() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let db = Database::open(&dir.path().join("koan.db")).unwrap();
1058 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1059 let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1060 let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1061
1062 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1063 lists: vec![("p1".into(), vec!["s1".into(), "s2".into()], 1)],
1064 ..Default::default()
1065 }));
1066 let url = serve(server.clone());
1067 let client = SubsonicClient::new(&url, "u", "pw");
1068
1069 let first = reconcile_playlists(&db, &client, &url, "u");
1070 assert_eq!(first.pulled, 1);
1071 let id = queries::playlist_by_remote_id(&db.conn, "p1")
1072 .unwrap()
1073 .unwrap()
1074 .id;
1075 assert_eq!(
1076 entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1077 [r1, r2]
1078 );
1079
1080 queries::add_tracks(&db.conn, id, &[local]).unwrap();
1082 let second = reconcile_playlists(&db, &client, &url, "u");
1083 assert_eq!((second.pushed, second.pulled), (1, 0));
1084 assert_eq!(server.lock().lists[0].1, ["s1", "s2"]);
1085
1086 let before = entries(&db, id);
1087 assert_eq!(before.len(), 3);
1088 let fetches = server.lock().fetches;
1089 let third = reconcile_playlists(&db, &client, &url, "u");
1090 assert_eq!((third.pushed, third.pulled), (0, 0));
1091 assert_eq!(
1092 server.lock().fetches,
1093 fetches,
1094 "nothing moved, nothing fetched"
1095 );
1096 assert_eq!(entries(&db, id), before);
1097
1098 {
1101 let mut server = server.lock();
1102 server.lists[0].1 = vec!["s2".into()];
1103 server.lists[0].2 += 1;
1104 }
1105 let fourth = reconcile_playlists(&db, &client, &url, "u");
1106 assert_eq!(fourth.pulled, 1);
1107 assert_eq!(entries(&db, id), [before[1], before[2]]);
1108 }
1109
1110 #[test]
1114 fn a_server_song_the_library_lacks_is_never_dropped() {
1115 let dir = tempfile::tempdir().unwrap();
1116 let db = Database::open(&dir.path().join("koan.db")).unwrap();
1117 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1118 let r2 = upsert_track(&db.conn, &remote_meta("Two", "s2")).unwrap();
1119
1120 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1121 lists: vec![("p1".into(), vec!["s1".into(), "s9".into()], 1)],
1122 ..Default::default()
1123 }));
1124 let url = serve(server.clone());
1125 let client = SubsonicClient::new(&url, "u", "pw");
1126 assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1127 let id = queries::playlist_by_remote_id(&db.conn, "p1")
1128 .unwrap()
1129 .unwrap()
1130 .id;
1131 assert_eq!(
1132 entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1133 [r1]
1134 );
1135
1136 queries::add_tracks(&db.conn, id, &[r2]).unwrap();
1137 assert!(push(&db, &client, &account_key(&url, "u"), id).is_ok());
1138 assert_eq!(server.lock().lists[0].1, ["s1", "s9", "s2"]);
1139
1140 let r9 = upsert_track(&db.conn, &remote_meta("Nine", "s9")).unwrap();
1142 server.lock().lists[0].2 += 1;
1143 assert_eq!(reconcile_playlists(&db, &client, &url, "u").pulled, 1);
1144 assert_eq!(
1145 entries(&db, id).iter().map(|e| e.1).collect::<Vec<_>>(),
1146 [r1, r9, r2]
1147 );
1148 }
1149
1150 #[test]
1151 fn unknown_songs_go_back_after_the_song_before_them() {
1152 let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1153 let server = ids(&["x", "a", "y", "b", "z", "c"]);
1154 let known = [false, true, false, true, false, true];
1155 assert_eq!(
1158 keep_unknown(&server, &known, ids(&["c", "a", "d"])),
1159 ids(&["x", "c", "a", "y", "z", "d"])
1160 );
1161 assert_eq!(
1162 keep_unknown(&server, &known, Vec::new()),
1163 ids(&["x", "y", "z"])
1164 );
1165 }
1166
1167 #[test]
1170 fn only_an_empty_playlist_empties_the_server_copy() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let db = Database::open(&dir.path().join("koan.db")).unwrap();
1173 upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1174 let local = upsert_track(&db.conn, &meta("Here", &dir.path().join("l.flac"))).unwrap();
1175
1176 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1177 lists: vec![("p1".into(), vec!["s1".into()], 1)],
1178 ..Default::default()
1179 }));
1180 let url = serve(server.clone());
1181 let client = SubsonicClient::new(&url, "u", "pw");
1182 let account = account_key(&url, "u");
1183 reconcile_playlists(&db, &client, &url, "u");
1184 let id = queries::playlist_by_remote_id(&db.conn, "p1")
1185 .unwrap()
1186 .unwrap()
1187 .id;
1188
1189 let only = entries(&db, id);
1190 queries::add_tracks(&db.conn, id, &[local]).unwrap();
1191 queries::remove_entries(&db.conn, id, &[only[0].0]).unwrap();
1192 assert!(push(&db, &client, &account, id).is_err());
1193 assert_eq!(server.lock().lists[0].1, ["s1"]);
1194
1195 let rest = entries(&db, id);
1196 queries::remove_entries(&db.conn, id, &[rest[0].0]).unwrap();
1197 assert!(push(&db, &client, &account, id).is_ok());
1198 assert!(server.lock().lists[0].1.is_empty());
1199 }
1200
1201 #[test]
1205 fn only_a_playlist_the_server_says_is_gone_is_deleted() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let db = Database::open(&dir.path().join("koan.db")).unwrap();
1208 upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1209
1210 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server {
1211 lists: vec![
1212 ("p1".into(), vec!["s1".into()], 1),
1213 ("p2".into(), vec!["s1".into()], 2),
1214 ],
1215 ..Default::default()
1216 }));
1217 let url = serve(server.clone());
1218 let client = SubsonicClient::new(&url, "u", "pw");
1219 reconcile_playlists(&db, &client, &url, "u");
1220 let held = || {
1221 queries::list_playlists(&db.conn, queries::LOCAL_USER)
1222 .unwrap()
1223 .len()
1224 };
1225 assert_eq!(held(), 2);
1226
1227 server.lock().hide_listing = true;
1228 reconcile_playlists(&db, &client, &url, "u");
1229 assert_eq!(held(), 2, "nothing deleted on an empty listing");
1230
1231 {
1232 let mut server = server.lock();
1233 server.hide_listing = false;
1234 server.lists.remove(0);
1235 }
1236 reconcile_playlists(&db, &client, &url, "u");
1237 assert_eq!(held(), 1, "one deleted on the server is deleted here");
1238 }
1239
1240 #[test]
1241 fn another_account_keeps_the_playlists_and_pushes_them_as_new() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let db = Database::open(&dir.path().join("koan.db")).unwrap();
1244 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1245 let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1246 queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1247 queries::set_playlist_remote(&db.conn, id, "old-1", Some("u"), false, "u@http://old")
1248 .unwrap();
1249
1250 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1251 let url = serve(server.clone());
1252 let client = SubsonicClient::new(&url, "u", "pw");
1253 let sync = reconcile_playlists(&db, &client, &url, "u");
1254
1255 let list = queries::get_playlist(&db.conn, id).unwrap().expect("kept");
1256 assert_eq!(list.track_count, 1);
1257 assert_eq!(sync.pushed, 1);
1258 assert_eq!(list.remote_id.as_deref(), Some("p1"));
1259 }
1260
1261 #[test]
1262 fn concurrent_pushes_create_one_server_playlist() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let path = dir.path().join("koan.db");
1265 let db = Database::open(&path).unwrap();
1266 let r1 = upsert_track(&db.conn, &remote_meta("One", "s1")).unwrap();
1267 let id = queries::create_playlist(&db.conn, queries::LOCAL_USER, "Road", None).unwrap();
1268 queries::add_tracks(&db.conn, id, &[r1]).unwrap();
1269
1270 let server = std::sync::Arc::new(parking_lot::Mutex::new(Server::default()));
1271 let url = serve(server.clone());
1272 let pushes: Vec<_> = (0..2)
1273 .map(|_| {
1274 let (path, url) = (path.clone(), url.clone());
1275 std::thread::spawn(move || {
1276 let db = Database::open(&path).unwrap();
1277 let client = SubsonicClient::new(&url, "u", "pw");
1278 push(&db, &client, &account_key(&url, "u"), id)
1279 })
1280 })
1281 .collect();
1282 for p in pushes {
1283 p.join().unwrap().unwrap();
1284 }
1285 assert_eq!(server.lock().creates_without_id, 1);
1286 assert_eq!(server.lock().lists.len(), 1);
1287 }
1288}