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(&db.conn, &summary.name, summary.comment.as_deref())
144 {
145 Ok(id) => id,
146 Err(e) => {
147 log::warn!("could not store playlist {}: {e}", summary.name);
148 continue;
149 }
150 }
151 }
152 };
153
154 let _ = queries::rename_playlist(&db.conn, id, &summary.name);
155 let _ = queries::set_playlist_remote(
156 &db.conn,
157 id,
158 &summary.id,
159 summary.owner.as_deref(),
160 summary.public,
161 summary.changed.as_deref(),
162 );
163
164 let remote_song_ids: Vec<String> = full.entry.iter().map(|s| s.id.clone()).collect();
165 let track_ids: Vec<i64> = queries::track_ids_for_remote_ids(&db.conn, &remote_song_ids)
166 .unwrap_or_default()
167 .into_iter()
168 .flatten()
169 .collect();
170 if let Err(e) = queries::set_playlist_tracks(&db.conn, id, &track_ids) {
171 log::warn!(
172 "could not store playlist contents for {}: {e}",
173 summary.name
174 );
175 continue;
176 }
177 let _ = queries::set_playlist_remote(
181 &db.conn,
182 id,
183 &summary.id,
184 summary.owner.as_deref(),
185 summary.public,
186 summary.changed.as_deref(),
187 );
188 out.pulled += 1;
189 }
190
191 for local in queries::list_playlists(&db.conn).unwrap_or_default() {
194 if let Some(remote_id) = &local.remote_id
195 && !seen_remote_ids.contains(remote_id)
196 {
197 let _ = queries::delete_playlist(&db.conn, local.id);
198 }
199 }
200
201 for local in queries::playlists_without_remote(&db.conn).unwrap_or_default() {
202 if push(db, client, local.id, None).is_ok() {
203 out.pushed += 1;
204 }
205 }
206
207 out
208}
209
210fn push(
216 db: &Database,
217 client: &SubsonicClient,
218 id: i64,
219 remote_id: Option<&str>,
220) -> Result<(), ()> {
221 let Ok(Some(local)) = queries::get_playlist(&db.conn, id) else {
222 return Err(());
223 };
224 let song_ids = queries::remote_ids_for_playlist(&db.conn, id).unwrap_or_default();
225
226 if remote_id.is_none() && song_ids.is_empty() {
229 return Err(());
230 }
231
232 if let Some(remote_id) = remote_id
239 && let Err(e) = client.update_playlist(
240 remote_id,
241 Some(&local.name),
242 local.comment.as_deref(),
243 Some(local.public),
244 )
245 {
246 log::warn!(
247 "could not rename playlist '{}' on the server: {e}",
248 local.name
249 );
250 }
251
252 match client.create_playlist(remote_id, &local.name, &song_ids) {
253 Ok(created) => {
254 let new_id = created
255 .as_ref()
256 .map(|c| c.playlist.id.clone())
257 .or_else(|| remote_id.map(str::to_string));
258 if let Some(new_id) = new_id {
259 let changed = created.as_ref().and_then(|c| c.playlist.changed.clone());
260 let owner = created.as_ref().and_then(|c| c.playlist.owner.clone());
261 let _ = queries::set_playlist_remote(
262 &db.conn,
263 id,
264 &new_id,
265 owner.as_deref(),
266 local.public,
267 changed.as_deref(),
268 );
269 }
270 Ok(())
271 }
272 Err(e) => {
273 log::warn!(
274 "could not push playlist '{}' to the server: {e}",
275 local.name
276 );
277 Err(())
278 }
279 }
280}
281
282fn newer(local: &str, remote: Option<&str>) -> bool {
289 let Some(remote) = remote else { return true };
290 let normalise = |s: &str| s.replace(' ', "T").trim_end_matches('Z').to_string();
291 normalise(local) > normalise(remote)
292}
293
294pub fn push_to_remote(id: i64) {
305 let cfg = Config::load().unwrap_or_default();
306 if !cfg.remote.enabled {
307 return;
308 }
309 let Some(client) = subsonic_client(&cfg) else {
310 return;
311 };
312 std::thread::Builder::new()
313 .name("koan-playlist-sync".into())
314 .spawn(move || {
315 let Ok(db) = Database::open_default() else {
316 return;
317 };
318 let remote_id = queries::get_playlist(&db.conn, id)
319 .ok()
320 .flatten()
321 .and_then(|p| p.remote_id);
322 let _ = push(&db, &client, id, remote_id.as_deref());
323 })
324 .ok();
325}
326
327pub fn delete_on_remote(remote_id: String) {
329 let cfg = Config::load().unwrap_or_default();
330 if !cfg.remote.enabled {
331 return;
332 }
333 let Some(client) = subsonic_client(&cfg) else {
334 return;
335 };
336 std::thread::Builder::new()
337 .name("koan-playlist-sync".into())
338 .spawn(move || {
339 if let Err(e) = client.delete_playlist(&remote_id) {
340 log::warn!("could not delete playlist {remote_id} on the server: {e}");
341 }
342 })
343 .ok();
344}
345
346#[derive(Debug, Default, Clone, Copy)]
348pub struct ExportSummary {
349 pub written: usize,
350 pub skipped: usize,
353}
354
355pub fn export_m3u8(
362 db: &Database,
363 playlist_id: i64,
364 dest: &Path,
365) -> Result<ExportSummary, std::io::Error> {
366 let name = queries::get_playlist(&db.conn, playlist_id)
367 .ok()
368 .flatten()
369 .map(|p| p.name)
370 .unwrap_or_default();
371 let tracks = queries::playlist_tracks(&db.conn, playlist_id).unwrap_or_default();
372
373 let mut out = ExportSummary::default();
374 let mut file = std::fs::File::create(dest)?;
375 writeln!(file, "#EXTM3U")?;
376 if !name.is_empty() {
377 writeln!(file, "#PLAYLIST:{name}")?;
378 }
379
380 for track in &tracks {
381 let path = track
382 .path
383 .as_deref()
384 .or(track.cached_path.as_deref())
385 .map(PathBuf::from)
386 .filter(|p| p.exists());
387 let Some(path) = path else {
388 out.skipped += 1;
389 continue;
390 };
391 let seconds = track.duration_ms.unwrap_or(0) / 1000;
392 writeln!(
393 file,
394 "#EXTINF:{seconds},{} - {}",
395 track.artist_name, track.title
396 )?;
397 writeln!(file, "{}", path.display())?;
398 out.written += 1;
399 }
400
401 Ok(out)
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::db::queries::{TrackMeta, upsert_track};
408
409 fn meta(title: &str, path: &Path) -> TrackMeta {
410 TrackMeta {
411 title: title.into(),
412 artist: "Artist".into(),
413 album_artist: Some("Artist".into()),
414 album: "Album".into(),
415 date: None,
416 disc: None,
417 track_number: None,
418 genre: None,
419 label: None,
420 duration_ms: Some(185_000),
421 codec: Some("FLAC".into()),
422 sample_rate: None,
423 bit_depth: None,
424 channels: None,
425 bitrate: None,
426 size_bytes: None,
427 mtime: None,
428 path: Some(path.to_string_lossy().into_owned()),
429 source: "local".into(),
430 remote_id: None,
431 remote_url: None,
432 album_remote_id: None,
433 artist_remote_id: None,
434 mbid: None,
435 album_mbid: None,
436 album_added_at: None,
437 }
438 }
439
440 #[test]
444 fn a_queue_is_locked_only_while_it_is_still_the_playlist() {
445 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
446
447 let dir = tempfile::tempdir().unwrap();
448 let db = Database::open(&dir.path().join("koan.db")).unwrap();
449 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
450 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
451
452 let id = queries::create_playlist(&db.conn, "Evening", None).unwrap();
453 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
454
455 let state = SharedPlayerState::new();
456 let queued = |entry: Option<i64>| PlaylistItem {
457 id: QueueItemId::new(),
458 db_id: Some(a),
459 playlist_entry_id: entry,
460 path: dir.path().join("a.flac"),
461 title: "A".into(),
462 artist: "Artist".into(),
463 album_artist: "Artist".into(),
464 album: "Album".into(),
465 year: None,
466 codec: None,
467 track_number: None,
468 disc: None,
469 duration_ms: None,
470 state: ItemState::Ready,
471 };
472
473 assert_eq!(
474 queue_lock(&db, &state),
475 None,
476 "an empty queue is not locked"
477 );
478
479 state.add_items(vec![queued(Some(entries[0])), queued(Some(entries[1]))]);
480 assert_eq!(
481 queue_lock(&db, &state),
482 Some(QueueLock::Playlist(id)),
483 "the queue is the playlist"
484 );
485
486 state.add_items(vec![queued(None)]);
489 assert_eq!(queue_lock(&db, &state), None);
490 }
491
492 #[test]
495 fn rearranging_the_queue_ends_the_lock() {
496 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
497
498 let dir = tempfile::tempdir().unwrap();
499 let db = Database::open(&dir.path().join("koan.db")).unwrap();
500 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
501 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
502 let id = queries::create_playlist(&db.conn, "Evening", None).unwrap();
503 let entries = queries::add_tracks(&db.conn, id, &[a, b]).unwrap();
504
505 let state = SharedPlayerState::new();
506 let items: Vec<PlaylistItem> = entries
507 .iter()
508 .map(|entry| PlaylistItem {
509 id: QueueItemId::new(),
510 db_id: Some(a),
511 playlist_entry_id: Some(*entry),
512 path: dir.path().join("a.flac"),
513 title: "A".into(),
514 artist: "Artist".into(),
515 album_artist: "Artist".into(),
516 album: "Album".into(),
517 year: None,
518 codec: None,
519 track_number: None,
520 disc: None,
521 duration_ms: None,
522 state: ItemState::Ready,
523 })
524 .collect();
525 let ids: Vec<QueueItemId> = items.iter().map(|i| i.id).collect();
526 state.add_items(items);
527 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
528
529 state.reorder_to(&[ids[1], ids[0]]);
530 assert_eq!(
531 queue_lock(&db, &state),
532 None,
533 "same tracks, different order — no longer the playlist"
534 );
535
536 queries::reorder_entries(&db.conn, id, &[entries[1], entries[0]]).unwrap();
538 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Playlist(id)));
539 }
540
541 #[test]
545 fn a_queue_holding_exactly_one_record_is_locked_to_it() {
546 use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
547
548 let dir = tempfile::tempdir().unwrap();
549 let db = Database::open(&dir.path().join("koan.db")).unwrap();
550 let a = upsert_track(&db.conn, &meta("A", &dir.path().join("a.flac"))).unwrap();
551 let b = upsert_track(&db.conn, &meta("B", &dir.path().join("b.flac"))).unwrap();
552 let album_id = queries::get_track_row(&db.conn, a)
553 .unwrap()
554 .unwrap()
555 .album_id
556 .unwrap();
557
558 let state = SharedPlayerState::new();
559 let queued = |track: i64| PlaylistItem {
560 id: QueueItemId::new(),
561 db_id: Some(track),
562 playlist_entry_id: None,
563 path: dir.path().join("a.flac"),
564 title: "A".into(),
565 artist: "Artist".into(),
566 album_artist: "Artist".into(),
567 album: "Album".into(),
568 year: None,
569 codec: None,
570 track_number: None,
571 disc: None,
572 duration_ms: None,
573 state: ItemState::Ready,
574 };
575
576 state.add_items(vec![queued(a)]);
577 assert_eq!(
578 queue_lock(&db, &state),
579 None,
580 "half a record is not the record"
581 );
582
583 state.add_items(vec![queued(b)]);
584 assert_eq!(queue_lock(&db, &state), Some(QueueLock::Album(album_id)));
585 }
586
587 #[test]
588 fn export_writes_what_is_on_disk_and_counts_what_is_not() {
589 let dir = tempfile::tempdir().unwrap();
590 let db = Database::open(&dir.path().join("koan.db")).unwrap();
591
592 let present = dir.path().join("here.flac");
593 std::fs::write(&present, b"x").unwrap();
594 let here = upsert_track(&db.conn, &meta("Here", &present)).unwrap();
595 let gone = upsert_track(&db.conn, &meta("Gone", &dir.path().join("gone.flac"))).unwrap();
596
597 let id = queries::create_playlist(&db.conn, "Evening", None).unwrap();
598 queries::add_tracks(&db.conn, id, &[here, gone]).unwrap();
599
600 let dest = dir.path().join("evening.m3u8");
601 let summary = export_m3u8(&db, id, &dest).unwrap();
602 assert_eq!((summary.written, summary.skipped), (1, 1));
603
604 let written = std::fs::read_to_string(&dest).unwrap();
605 assert!(written.starts_with("#EXTM3U\n#PLAYLIST:Evening\n"));
606 assert!(written.contains("#EXTINF:185,Artist - Here"));
607 assert!(written.contains(&present.display().to_string()));
608 assert!(!written.contains("gone.flac"));
609 }
610}