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> {
34 let (items, _) = state.snapshot_playlist();
35 if items.is_empty() {
36 return None;
37 }
38
39 let entry_ids: Vec<i64> = items.iter().filter_map(|i| i.playlist_entry_id).collect();
42 if entry_ids.len() == items.len()
43 && let Ok(Some(playlist_id)) = queries::playlist_of_entry(&db.conn, entry_ids[0])
44 && queries::playlist_entry_ids(&db.conn, playlist_id).is_ok_and(|ids| ids == entry_ids)
45 {
46 return Some(QueueLock::Playlist(playlist_id));
47 }
48
49 let track_ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
54 if track_ids.len() != items.len() {
55 return None;
56 }
57 let album_id = queries::get_track_row(&db.conn, track_ids[0])
58 .ok()
59 .flatten()?
60 .album_id?;
61 let album: Vec<i64> = queries::tracks_for_album(&db.conn, album_id)
62 .ok()?
63 .into_iter()
64 .map(|t| t.id)
65 .collect();
66 (album == track_ids).then_some(QueueLock::Album(album_id))
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum QueueLock {
72 Playlist(i64),
73 Album(i64),
74}
75
76#[derive(Debug, Default, Clone, Copy)]
78pub struct PlaylistSync {
79 pub pulled: usize,
81 pub pushed: usize,
83}
84
85pub fn reconcile_playlists(db: &Database, client: &SubsonicClient, username: &str) -> PlaylistSync {
97 let mut out = PlaylistSync::default();
98
99 let remote = match client.get_playlists() {
100 Ok(lists) => lists,
101 Err(e) => {
102 log::warn!("could not fetch playlists from the server: {e}");
103 return out;
104 }
105 };
106
107 let mut seen_remote_ids = Vec::new();
111
112 for summary in &remote {
113 seen_remote_ids.push(summary.id.clone());
114 let local = queries::playlist_by_remote_id(&db.conn, &summary.id)
115 .ok()
116 .flatten();
117 let ours = summary
118 .owner
119 .as_deref()
120 .is_none_or(|owner| owner == username);
121
122 if let Some(local) = &local
123 && ours
124 && newer(&local.changed_at, summary.changed.as_deref())
125 {
126 if push(db, client, local.id, Some(&summary.id)).is_ok() {
127 out.pushed += 1;
128 }
129 continue;
130 }
131
132 let full = match client.get_playlist(&summary.id) {
133 Ok(full) => full,
134 Err(e) => {
135 log::warn!("could not fetch playlist {}: {e}", summary.id);
136 continue;
137 }
138 };
139
140 let id = match local {
141 Some(local) => local.id,
142 None => {
143 match queries::create_playlist(
144 &db.conn,
145 queries::LOCAL_USER,
146 &summary.name,
147 summary.comment.as_deref(),
148 ) {
149 Ok(id) => id,
150 Err(e) => {
151 log::warn!("could not store playlist {}: {e}", summary.name);
152 continue;
153 }
154 }
155 }
156 };
157
158 let _ = queries::rename_playlist(&db.conn, id, &summary.name);
159 let _ = queries::set_playlist_remote(
160 &db.conn,
161 id,
162 &summary.id,
163 summary.owner.as_deref(),
164 summary.public,
165 summary.changed.as_deref(),
166 );
167
168 let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
169 let track_ids: Vec<i64> = queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids)
170 .unwrap_or_default()
171 .into_iter()
172 .flatten()
173 .collect();
174 if let Err(e) = queries::set_playlist_tracks(&db.conn, id, &track_ids) {
175 log::warn!(
176 "could not store playlist contents for {}: {e}",
177 summary.name
178 );
179 continue;
180 }
181 let _ = queries::set_playlist_remote(
185 &db.conn,
186 id,
187 &summary.id,
188 summary.owner.as_deref(),
189 summary.public,
190 summary.changed.as_deref(),
191 );
192 out.pulled += 1;
193 }
194
195 for local in queries::list_playlists(&db.conn, queries::LOCAL_USER).unwrap_or_default() {
198 if let Some(remote_id) = &local.remote_id
199 && !seen_remote_ids.contains(remote_id)
200 {
201 let _ = queries::delete_playlist(&db.conn, local.id);
202 }
203 }
204
205 for local in
206 queries::playlists_without_remote(&db.conn, queries::LOCAL_USER).unwrap_or_default()
207 {
208 if push(db, client, local.id, None).is_ok() {
209 out.pushed += 1;
210 }
211 }
212
213 out
214}
215
216fn push(
222 db: &Database,
223 client: &SubsonicClient,
224 id: i64,
225 remote_id: Option<&str>,
226) -> Result<(), ()> {
227 let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
228 return Err(());
229 };
230 let song_ids = queries::remote_ids_for_playlist(&db.conn, id).unwrap_or_default();
231
232 if remote_id.is_none() && song_ids.is_empty() {
235 return Err(());
236 }
237
238 if let Some(remote_id) = remote_id
245 && let Err(e) = client.update_playlist(
246 remote_id,
247 Some(&local.name),
248 local.comment.as_deref(),
249 Some(local.public),
250 )
251 {
252 log::warn!(
253 "could not rename playlist '{}' on the server: {e}",
254 local.name
255 );
256 }
257
258 match client.create_playlist(remote_id, &local.name, &song_ids) {
259 Ok(created) => {
260 let new_id = created
261 .as_ref()
262 .map(|c| c.playlist.id.clone())
263 .or_else(|| remote_id.map(str::to_string));
264 if let Some(new_id) = new_id {
265 let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
266 let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
267 let _ = queries::set_playlist_remote(
268 &db.conn,
269 id,
270 &new_id,
271 owner.as_deref(),
272 local.public,
273 changed.as_deref(),
274 );
275 }
276 Ok(())
277 }
278 Err(e) => {
279 log::warn!(
280 "could not push playlist '{}' to the server: {e}",
281 local.name
282 );
283 Err(())
284 }
285 }
286}
287
288fn newer(local: &str, remote: Option<&str>) -> bool {
295 let Some(remote) = remote else { return true };
296 let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
297 normalise(local) > normalise(remote)
298}
299
300pub fn push_to_remote(id: i64) {
311 let cfg = Config::load().unwrap_or_default();
312 if !cfg.remote.enabled {
313 return;
314 }
315 let Some(client) = subsonic_client(&cfg) else {
316 return;
317 };
318 std::thread::Builder::new()
319 .name("koan-playlist-sync".into())
320 .spawn(move || {
321 let Ok(db) = crate::db::pool::shared().get() else {
322 return;
323 };
324 let Ok(Some(list)) = queries::get_playlist(&db.conn, id) else {
325 return;
326 };
327 if !matches!(
329 queries::auth::is_local_user(&db.conn, list.user_id),
330 Ok(true)
331 ) {
332 return;
333 }
334 let _ = push(&db, &client, id, list.remote_id.as_deref());
335 })
336 .ok();
337}
338
339pub fn delete_on_remote(remote_id: String) {
341 let cfg = Config::load().unwrap_or_default();
342 if !cfg.remote.enabled {
343 return;
344 }
345 let Some(client) = subsonic_client(&cfg) else {
346 return;
347 };
348 std::thread::Builder::new()
349 .name("koan-playlist-sync".into())
350 .spawn(move || {
351 if let Err(e) = client.delete_playlist(&remote_id) {
352 log::warn!("could not delete playlist {remote_id} on the server: {e}");
353 }
354 })
355 .ok();
356}
357
358#[derive(Debug, Default, Clone, Copy)]
360pub struct ExportSummary {
361 pub written: usize,
362 pub skipped: usize,
365}
366
367pub fn export_m3u8(
374 db: &Database,
375 playlist_id: i64,
376 dest: &Path,
377) -> Result<ExportSummary, std::io::Error> {
378 let name = queries::get_playlist(&db.conn, playlist_id)
379 .ok()
380 .flatten()
381 .map(|p| p.name)
382 .unwrap_or_default();
383 let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
384
385 let mut out = ExportSummary::default();
386 let mut file = std::fs::File::create(dest)?;
387 writeln!(file, "#EXTM3U")?;
388 if !name.is_empty() {
389 writeln!(file, "#PLAYLIST:{name}")?;
390 }
391
392 for track in &tracks {
393 let path = track
394 .path
395 .as_deref()
396 .or(track.cached_path.as_deref())
397 .map(PathBuf::from)
398 .filter(|p| p.exists());
399 let Some(path) = path else {
400 out.skipped += 1;
401 continue;
402 };
403 let seconds = track.duration_ms.unwrap_or(0) / 1000;
404 writeln!(
405 file,
406 "#EXTINF:{seconds},{} - {}",
407 track.artist_name, track.title
408 )?;
409 writeln!(file, "{}", path.display())?;
410 out.written += 1;
411 }
412
413 Ok(out)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::db::queries::{TrackMeta, upsert_track};
420
421 fn meta(title: &str, path: &Path) -> TrackMeta {
422 TrackMeta {
423 title: title.into(),
424 artist: "Artist".into(),
425 album_artist: Some("Artist".into()),
426 album: "Album".into(),
427 date: None,
428 disc: None,
429 track_number: None,
430 genre: None,
431 label: None,
432 duration_ms: Some(185_000),
433 codec: Some("FLAC".into()),
434 sample_rate: None,
435 bit_depth: None,
436 channels: None,
437 bitrate: None,
438 size_bytes: None,
439 mtime: None,
440 path: Some(path.to_string_lossy().into_owned()),
441 source: "local".into(),
442 remote_id: None,
443 remote_url: None,
444 album_remote_id: None,
445 artist_remote_id: None,
446 mbid: None,
447 album_mbid: None,
448 album_added_at: None,
449 }
450 }
451
452 #[test]
456 fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
457 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
458
459 let dir = tempfile::tempdir().unwrap();
460 let db = Database::open(&dir.path().join("koan.db")).unwrap();
461 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
462 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
463
464 let id =
465 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
466 .unwrap();
467 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
468
469 let state = SharedPlayerState::new();
470 let queued = |entry: Option<i64>| PlaylistItem {
471 id: QueueItemId::new(),
472 db_id: Some(a),
473 playlist_entry_id: entry,
474 path: dir.path().join("a.flac"),
475 title: "A".into(),
476 artist: "Artist".into(),
477 album_artist: "Artist".into(),
478 album: "Album".into(),
479 year: None,
480 codec: None,
481 track_number: None,
482 disc: None,
483 duration_ms: None,
484 state: ItemState::Ready,
485 };
486
487 assert_eq!(
488 queue_lock(&db, &state),
489 None,
490 "an empty queue is not locked"
491 );
492
493 state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
494 assert_eq!(
495 queue_lock(&db, &state),
496 Some(QueueLock::Playlist(id)),
497 "the queue is the playlist"
498 );
499
500 state.add_items(vec![queued(None)]);
503 assert_eq!(queue_lock(&db, &state), None);
504 }
505
506 #[test]
509 fn rearranging_the_queue_ends_the_lock() {
510 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
511
512 let dir = tempfile::tempdir().unwrap();
513 let db = Database::open(&dir.path().join("koan.db")).unwrap();
514 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
515 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
516 let id =
517 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
518 .unwrap();
519 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
520
521 let state = SharedPlayerState::new();
522 let items: Vec<PlaylistItem> = entries
523 .iter()
524 .map(|entry| PlaylistItem {
525 id: QueueItemId::new(),
526 db_id: Some(a),
527 playlist_entry_id: Some(*entry),
528 path: dir.path().join("a.flac"),
529 title: "A".into(),
530 artist: "Artist".into(),
531 album_artist: "Artist".into(),
532 album: "Album".into(),
533 year: None,
534 codec: None,
535 track_number: None,
536 disc: None,
537 duration_ms: None,
538 state: ItemState::Ready,
539 })
540 .collect();
541 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
542 state.add_items(items);
543 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
544
545 state.reorder_to(&[ids[1], ids[0]]);
546 assert_eq!(
547 queue_lock(&db, &state),
548 None,
549 "same tracks, different order — no longer the playlist"
550 );
551
552 queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
554 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
555 }
556
557 #[test]
561 fn a_queue_holding_exactly_one_record_is_locked_to_it() {
562 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
563
564 let dir = tempfile::tempdir().unwrap();
565 let db = Database::open(&dir.path().join("koan.db")).unwrap();
566 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
567 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
568 let album_id = queries::get_track_row(&db.conn, a)
569 .unwrap()
570 .unwrap()
571 .album_id
572 .unwrap();
573
574 let state = SharedPlayerState::new();
575 let queued = |track: i64| PlaylistItem {
576 id: QueueItemId::new(),
577 db_id: Some(track),
578 playlist_entry_id: None,
579 path: dir.path().join("a.flac"),
580 title: "A".into(),
581 artist: "Artist".into(),
582 album_artist: "Artist".into(),
583 album: "Album".into(),
584 year: None,
585 codec: None,
586 track_number: None,
587 disc: None,
588 duration_ms: None,
589 state: ItemState::Ready,
590 };
591
592 state.add_items(vec![queued(a)]);
593 assert_eq!(
594 queue_lock(&db, &state),
595 None,
596 "half a record is not the record"
597 );
598
599 state.add_items(vec![queued(b)]);
600 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
601 }
602
603 #[test]
604 fn export_writes_what_is_on_disk_and_counts_what_is_not() {
605 let dir = tempfile::tempdir().unwrap();
606 let db = Database::open(&dir.path().join("koan.db")).unwrap();
607
608 let present = dir.path().join("here.flac");
609 std::fs::write(&present, b"x").unwrap();
610 let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
611 let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
612
613 let id =
614 queries::create_playlist(&db.conn, crate::db::queries::LOCAL_USER, "Evening", None)
615 .unwrap();
616 queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
617
618 let dest = dir.path().join("evening.m3u8");
619 let summary = export_m3u8(&db, id, &dest).unwrap();
620 assert_eq!((summary.written, summary.skipped), (1, 1));
621
622 let written = std::fs::read_to_string(&dest).unwrap();
623 assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
624 assert!(written.contains("#EXTINF:185,Artist - Here"));
625 assert!(written.contains(&present.display().to_string()));
626 assert!(!written.contains("gone.flac"));
627 }
628}