Skip to main content

koan_core/
playlists.rs

1//! Playlists beyond the database: keeping them in step with the server, and
2//! writing them out as files.
3//!
4//! The database module owns what a playlist *is*. This owns what happens to it
5//! next — which is either a Subsonic call or an M3U8 on disk.
6
7use 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
17/// The queue, and the playlist or record it is still exactly.
18///
19/// While the two match, the queue *follows* a playlist: an edit there is an
20/// edit here, quietly. The moment you rearrange the queue yourself, add to it,
21/// or let radio extend it, they stop matching and the playlist becomes a
22/// document you are editing rather than the thing you are listening to.
23///
24/// A record cannot be edited, so locking to one buys no following — only the
25/// ability to say what you are listening to, which is worth saying.
26///
27/// Derived rather than tracked, which is the whole reason it is simple. There
28/// is no flag to keep in sync, nothing to persist and nothing to migrate — and
29/// it cannot get stuck, because a queue that stops matching stops being locked
30/// and one that happens to match again is locked again. Playing a playlist
31/// shuffled scrambles the order on purpose, so that queue is not locked, which
32/// is the right answer rather than a special case.
33pub 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    // Every item has to have come from the same playlist. One that did not —
40    // played next, dropped in, found by radio — is the queue having diverged.
41    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    // A record needs no provenance of its own: an album *is* an ordered set of
50    // tracks in the library, so the queue being that album is a question about
51    // the tracks it holds. Which means this works for a queue restored from a
52    // previous session, where nothing remembers where it came from.
53    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/// What the queue still is, when it is still something.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum QueueLock {
72    Playlist(i64),
73    Album(i64),
74}
75
76/// What a reconciliation did.
77#[derive(Debug, Default, Clone, Copy)]
78pub struct PlaylistSync {
79    /// Playlists taken from the server, new or updated.
80    pub pulled: usize,
81    /// Playlists sent to the server, new or updated.
82    pub pushed: usize,
83}
84
85/// Reconcile playlists with the server, both directions.
86///
87/// Unlike favourites, a playlist has an order and a server that records when it
88/// last changed — so this is last-writer-wins on `changed`, not a union. Local
89/// edits push the moment they happen, so a local copy ahead of the server's
90/// means a push that never got out (koan was offline, the server was down), and
91/// that is exactly the case where ours should win.
92///
93/// Playlists that have never been to the server are created there. Ones the
94/// server no longer has are dropped locally: deleting a playlist on Navidrome
95/// and having it reappear on the next sync would make deletion impossible.
96pub 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    // Only playlists this user owns are ours to write back. A public playlist
108    // belonging to someone else is still worth having locally, but pushing our
109    // copy of it would be editing their playlist.
110    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        // `set_playlist_tracks` stamps the local copy as changed, which would
182        // make the next sync think we were ahead of the server. We are not:
183        // this *is* the server's copy.
184        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    // A playlist we hold a server id for that the server no longer lists was
196    // deleted there.
197    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
216/// Send a playlist's name and contents to the server, in order.
217///
218/// `createPlaylist` with a `playlistId` replaces the contents wholesale, which
219/// is the only Subsonic call that can express a reorder — so a push is always
220/// the whole list rather than a diff.
221fn 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    // A playlist made entirely of local files has nothing the server could
233    // point at. Creating an empty one there would be worse than not creating it.
234    if remote_id.is_none() && song_ids.is_empty() {
235        return Err(());
236    }
237
238    // The name has to travel on its own. Navidrome's `createPlaylist` with a
239    // `playlistId` replaces the songs and ignores the `name` it is handed, so a
240    // rename pushed that way reached the server and changed nothing — which is
241    // exactly what it looked like from the outside. `updatePlaylist` is the
242    // call that carries metadata; `createPlaylist` is the one that carries
243    // order. A push needs both.
244    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
288/// Whether `local` was changed after the server's copy was.
289///
290/// Both are ISO 8601 in UTC — SQLite's `datetime('now')` on our side, the
291/// server's own stamp on theirs — near enough that comparing the digits works,
292/// once SQLite's space is made a `T`. A server that sends no timestamp at all
293/// cannot be shown to be newer, so ours wins and the push settles it.
294fn 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
300/// Push a playlist to the server in the background, if there is one.
301///
302/// Fire and forget on its own thread, the way favourites are: the local copy is
303/// already written, and a slow server should not hold up the edit that caused
304/// this. A failure leaves the local copy newer than the server's, which is
305/// exactly what [`reconcile_playlists`] resolves on the next sync.
306///
307/// The thread takes its own connection rather than borrowing the caller's:
308/// a `rusqlite::Connection` is not `Sync`, and the answer has to be written
309/// back — the new server id — so it needs one of its own.
310pub 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            // The upstream server has one account, and it is the local user's.
328            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
339/// Delete a playlist on the server. Nothing to do for one that never went.
340pub 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/// What an export wrote, and what it could not.
359#[derive(Debug, Default, Clone, Copy)]
360pub struct ExportSummary {
361    pub written: usize,
362    /// Tracks with no file on this machine. A playlist file is a list of
363    /// paths, and a remote track that has never been downloaded has none.
364    pub skipped: usize,
365}
366
367/// Write a playlist as an extended M3U8.
368///
369/// Absolute paths, UTF-8, `#EXTINF` per entry — the format every player still
370/// reads. Remote tracks that have not been downloaded are left out rather than
371/// written as stream URLs: a Subsonic stream URL carries the credentials that
372/// authorise it, and a playlist file is something people mail to each other.
373pub 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    /// The queue is locked while it is still exactly the playlist, and stops
453    /// being the moment it is not. Everything else about following follows from
454    /// this one answer.
455    #[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        // Something that never came from the playlist — played next, dropped
501        // in, found by radio.
502        state.add_items(vec![queued(None)]);
503        assert_eq!(queue_lock(&db, &state), None);
504    }
505
506    /// Reordering the queue by hand ends the lock, which is the whole point of
507    /// deriving it: there is no flag anyone has to remember to clear.
508    #[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        // And the playlist catching up locks it again. Nothing had to be reset.
553        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    /// A record needs no provenance: it *is* an ordered set of tracks, so the
558    /// queue being that record is a question about what the queue holds. Which
559    /// is why it survives a relaunch, where nothing remembers what was played.
560    #[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}