From d4cdb30e8a1dde6674c8799ef31f66283aa107dd Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:14:26 +0200 Subject: [PATCH 1/6] feat(sources): playlist writes and ISRC for Qobuz and Subsonic --- src/core/source.rs | 18 ++ src/infra/qobuz/mod.rs | 324 +++++++++++++++++++++++++++++++++++- src/infra/qobuz/types.rs | 7 + src/infra/subsonic/mod.rs | 265 ++++++++++++++++++++++++++++- src/infra/subsonic/types.rs | 20 ++- tools/gates.count | 2 +- 6 files changed, 628 insertions(+), 8 deletions(-) diff --git a/src/core/source.rs b/src/core/source.rs index af0f4b17..332ea577 100644 --- a/src/core/source.rs +++ b/src/core/source.rs @@ -128,6 +128,14 @@ impl Source { pub fn supports_like(&self) -> bool { matches!(self, Source::Spotify) } + + /// Whether playlists of this source can take part in a cross-source mirror. + pub fn supports_playlist_sync(&self) -> bool { + matches!( + self, + Source::Spotify | Source::Subsonic | Source::YouTube | Source::Qobuz + ) + } } #[cfg(test)] @@ -200,6 +208,16 @@ mod tests { assert!(!Source::Qobuz.supports_like()); } + #[test] + fn playlist_sync_is_on_for_the_four_writable_sources() { + assert!(Source::Spotify.supports_playlist_sync()); + assert!(Source::Subsonic.supports_playlist_sync()); + assert!(Source::YouTube.supports_playlist_sync()); + assert!(Source::Qobuz.supports_playlist_sync()); + assert!(!Source::Local.supports_playlist_sync()); + assert!(!Source::Radio.supports_playlist_sync()); + } + #[test] fn config_str_round_trips_every_source() { for source in Source::ALL { diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index 974a1ebc..fe800ecf 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -24,11 +24,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context, Result}; use reqwest::Client; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, IgnoredAny}; use tokio::sync::Mutex; use crate::core::plugin_api::{ArtistRef, PlaylistInfo, SearchResults, TrackInfo}; -use crate::core::source::{MediaSource, Searcher}; +use crate::core::source::{MediaSource, PlaylistWriter, Searcher}; use crate::infra::audio::LocalPlayer; use stream::cmaf::InitSegment; use stream::download; @@ -105,6 +105,12 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const PAGE_LIMIT: u32 = 500; const MAX_ITEMS: usize = 10_000; const SEARCH_LIMIT: u32 = 20; +/// Ids per playlist write call. +const WRITE_CHUNK: usize = 50; +/// Playlist writes go out like every endpoint but the two stream-session ones: +/// GET, unsigned. Both flip here if Qobuz rejects that shape. +const WRITE_METHOD: Method = Method::Get; +const SIGN_PLAYLIST_WRITES: bool = false; /// Renew the stream session this long before `expires_at`. const SESSION_MARGIN_SECS: u64 = 60; @@ -185,6 +191,8 @@ pub struct QobuzSource { app_id: String, secret: String, token: String, + /// API root, with its trailing slash; tests point it at a loopback listener. + base: String, http: Client, } @@ -198,10 +206,19 @@ impl QobuzSource { app_id: app_id.into(), secret: secret.into(), token: token.into(), + base: API_BASE.to_string(), http: shared_qobuz_client(), } } + #[cfg(test)] + fn with_base(base: impl Into) -> Self { + QobuzSource { + base: base.into(), + ..QobuzSource::new("app-id", "secret", "token") + } + } + /// One API call; `signed` adds `request_ts` and `request_sig` over `args`. async fn request( &self, @@ -221,7 +238,7 @@ impl QobuzSource { sign::request_sig(endpoint, &sig_args, ts, &self.secret), )); } - let url = format!("{API_BASE}{endpoint}"); + let url = format!("{}{endpoint}", self.base); let mut request = match method { Method::Get => self.http.get(&url).query(&query), Method::PostForm => self.http.post(&url).form(&query), @@ -253,7 +270,13 @@ impl QobuzSource { let excerpt: String = body.chars().take(120).collect(); return Err(anyhow!("{endpoint} returned HTTP {status}: {excerpt}")); } - serde_json::from_str(&body).with_context(|| format!("{endpoint} response parse")) + // A playlist write may acknowledge with no body at all. + let body = if body.trim().is_empty() { + "null" + } else { + body.as_str() + }; + serde_json::from_str(body).with_context(|| format!("{endpoint} response parse")) } async fn get(&self, endpoint: &str, args: &[(&str, String)]) -> Result { @@ -491,6 +514,49 @@ impl QobuzSource { }) .await } + + // ------------------------------------------------------------------------- + // Playlist writes + // ------------------------------------------------------------------------- + + /// One playlist write, per [`WRITE_METHOD`] and [`SIGN_PLAYLIST_WRITES`]. + async fn playlist_write( + &self, + endpoint: &str, + args: &[(&str, String)], + ) -> Result { + self + .request(WRITE_METHOD, endpoint, args, SIGN_PLAYLIST_WRITES, None) + .await + } + + /// Every track of a playlist with its item ids, which `listing_tracks` drops. + async fn playlist_items(&self, playlist_id: &str) -> Result> { + let listing = Listing::Playlist(playlist_id.to_string()); + let listing = &listing; + paginate(|offset| async move { + let (page, _) = self.listing_page(listing, offset).await?; + let total = page.total as usize; + Ok((page.items, total)) + }) + .await + } + + /// Create a private playlist and return its id. + #[allow(dead_code)] // The sync engine is the first caller. + pub async fn create_playlist(&self, name: &str) -> Result { + let created: types::Playlist = self + .playlist_write( + "playlist/create", + &[ + ("name", name.to_string()), + ("is_public", "false".to_string()), + ("is_collaborative", "false".to_string()), + ], + ) + .await?; + Ok(created.id) + } } /// Collect every page of a listing; `fetch(offset)` returns one page's items @@ -522,6 +588,27 @@ pub fn track_id_from_uri(uri: &str) -> Result<&str> { .ok_or_else(|| anyhow!("Not a qobuz track URI: {}", uri)) } +/// Strip the `qobuz:playlist:` prefix; favorites and albums are not writable. +fn playlist_id_from_uri(uri: &str) -> Result<&str> { + uri + .strip_prefix(PLAYLIST_PREFIX) + .ok_or_else(|| anyhow!("Not a qobuz playlist URI: {}", uri)) +} + +/// A `qobuz:track:` URI or a bare id. +fn track_id_of(uri: &str) -> &str { + uri.strip_prefix(TRACK_PREFIX).unwrap_or(uri) +} + +/// The playlist item ids of `track_ids`, in playlist order. +fn item_ids_for(items: &[types::Track], track_ids: &[&str]) -> Vec { + items + .iter() + .filter(|t| track_ids.contains(&t.id.as_str())) + .filter_map(|t| t.playlist_track_id.clone()) + .collect() +} + fn listing_from_uri(uri: &str) -> Result { if uri == FAVORITES_URI { Ok(Listing::Favorites) @@ -679,9 +766,116 @@ impl Searcher for QobuzSource { } } +impl PlaylistWriter for QobuzSource { + /// Append tracks, [`WRITE_CHUNK`] ids per call; Qobuz dedupes server-side. + async fn add_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()> { + let playlist_id = playlist_id_from_uri(playlist_uri)?; + let ids: Vec<&str> = track_uris + .iter() + .map(|uri| track_id_of(uri.as_str())) + .collect(); + for chunk in ids.chunks(WRITE_CHUNK) { + let _: IgnoredAny = self + .playlist_write( + "playlist/addTracks", + &[ + ("playlist_id", playlist_id.to_string()), + ("track_ids", chunk.join(",")), + ("no_duplicate", "true".to_string()), + ], + ) + .await?; + } + Ok(()) + } + + /// Remove tracks by playlist item id; the playlist is read first to map them. + async fn remove_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()> { + let playlist_id = playlist_id_from_uri(playlist_uri)?; + if track_uris.is_empty() { + return Ok(()); + } + let items = self.playlist_items(playlist_id).await?; + let wanted: Vec<&str> = track_uris + .iter() + .map(|uri| track_id_of(uri.as_str())) + .collect(); + for chunk in item_ids_for(&items, &wanted).chunks(WRITE_CHUNK) { + let _: IgnoredAny = self + .playlist_write( + "playlist/deleteTracks", + &[ + ("playlist_id", playlist_id.to_string()), + ("playlist_track_ids", chunk.join(",")), + ], + ) + .await?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::net::TcpListener; + + /// Serve `responses` in order, collecting each request line and body. + async fn serve( + responses: Vec<(&'static str, &'static str)>, + ) -> (String, tokio::task::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}/", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { + let mut seen = Vec::new(); + for (status, payload) in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let (read_half, mut write_half) = stream.split(); + let mut reader = BufReader::new(read_half); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await.unwrap(); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = value.trim().parse().unwrap_or(0); + } + if line == "\r\n" || line.is_empty() { + break; + } + } + let mut body = vec![0u8; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).await.unwrap(); + } + seen.push(( + request_line.trim_end().to_string(), + String::from_utf8_lossy(&body).to_string(), + )); + write_half + .write_all( + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", + payload.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + write_half.flush().await.unwrap(); + } + seen + }); + (base, handle) + } + + /// Request line and body joined, so a param assertion holds under either + /// [`WRITE_METHOD`]. + fn sent(entry: &(String, String)) -> String { + format!("{} {}", entry.0, entry.1) + } const USER_PLAYLISTS: &str = r#"{ "playlists": { @@ -734,6 +928,22 @@ mod tests { const FILE_URL: &str = r#"{ "url_template": "https://cdn/$SEGMENT$.m4s", "n_segments": 3, "key": "p.d3JhcHBlZA.aXY", "mime_type": "audio/flac", "format_id": 27 }"#; + const PLAYLIST_ITEMS: &str = r#"{ + "id": 111, "name": "Morning", + "tracks": { + "offset": 0, "limit": 500, "total": 3, + "items": [ + { "id": 5001, "title": "A", "isrc": "GBAAA0000001", "playlist_track_id": 90001 }, + { "id": 5002, "title": "B", "playlist_track_id": 90002 }, + { "id": 5001, "title": "A", "isrc": "GBAAA0000001", "playlist_track_id": 90003 } + ] + } + }"#; + + const CREATED_PLAYLIST: &str = r#"{ "id": 777, "name": "Mirror", "is_public": false }"#; + + const WRITE_OK: &str = r#"{ "status": "success" }"#; + #[test] fn user_playlists_map_to_playlist_info() { let page: types::UserPlaylists = serde_json::from_str(USER_PLAYLISTS).unwrap(); @@ -836,6 +1046,112 @@ mod tests { assert!(!session.is_valid_at(1_000 - SESSION_MARGIN_SECS)); } + #[test] + fn playlist_items_carry_isrc_and_their_playlist_track_id() { + let playlist: types::Playlist = serde_json::from_str(PLAYLIST_ITEMS).unwrap(); + let items = playlist.tracks.unwrap().items; + assert_eq!(items[0].isrc.as_deref(), Some("GBAAA0000001")); + assert_eq!(items[0].playlist_track_id.as_deref(), Some("90001")); + assert_eq!(items[1].isrc, None); + assert_eq!(items[2].playlist_track_id.as_deref(), Some("90003")); + let legacy: types::Playlist = serde_json::from_str(PLAYLIST_TRACKS).unwrap(); + let legacy = legacy.tracks.unwrap(); + assert_eq!(legacy.items[0].isrc, None); + assert_eq!(legacy.items[0].playlist_track_id, None); + } + + #[test] + fn item_ids_map_track_ids_to_playlist_item_ids() { + let playlist: types::Playlist = serde_json::from_str(PLAYLIST_ITEMS).unwrap(); + let items = playlist.tracks.unwrap().items; + assert_eq!(item_ids_for(&items, &["5001"]), vec!["90001", "90003"]); + assert_eq!(item_ids_for(&items, &["5002"]), vec!["90002"]); + assert!(item_ids_for(&items, &["9999"]).is_empty()); + } + + #[test] + fn playlist_writes_accept_a_track_uri_or_a_bare_id() { + assert_eq!(track_id_of("qobuz:track:5001"), "5001"); + assert_eq!(track_id_of("5001"), "5001"); + } + + #[test] + fn playlist_writes_reject_favorites_and_album_uris() { + assert_eq!(playlist_id_from_uri("qobuz:playlist:111").unwrap(), "111"); + assert!(playlist_id_from_uri(FAVORITES_URI).is_err()); + assert!(playlist_id_from_uri("qobuz:album:x").is_err()); + } + + #[tokio::test] + async fn create_playlist_sends_an_unsigned_private_playlist() { + let (base, server) = serve(vec![("200 OK", CREATED_PLAYLIST)]).await; + let id = QobuzSource::with_base(base) + .create_playlist("Mirror") + .await + .unwrap(); + assert_eq!(id, "777"); + let seen = server.await.unwrap(); + assert!(seen[0].0.contains("/playlist/create")); + assert!(sent(&seen[0]).contains("name=Mirror")); + assert!(sent(&seen[0]).contains("is_public=false")); + assert!(sent(&seen[0]).contains("is_collaborative=false")); + assert!(!sent(&seen[0]).contains("request_sig")); + } + + #[tokio::test] + async fn add_tracks_sends_a_comma_list_in_chunks_of_fifty() { + let (base, server) = serve(vec![("200 OK", WRITE_OK), ("200 OK", WRITE_OK)]).await; + let uris: Vec = (0..51).map(|i| format!("qobuz:track:{i}")).collect(); + QobuzSource::with_base(base) + .add_tracks("qobuz:playlist:111", &uris) + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 2); + assert!(seen[0].0.contains("/playlist/addTracks")); + assert!(sent(&seen[0]).contains("playlist_id=111")); + assert!(sent(&seen[0]).contains("no_duplicate=true")); + assert!(sent(&seen[0]).contains("track_ids=0%2C1%2C2%2C")); + assert_eq!(sent(&seen[0]).matches("%2C").count(), 49); + assert!(sent(&seen[1]).contains("track_ids=50")); + assert_eq!(sent(&seen[1]).matches("%2C").count(), 0); + } + + #[tokio::test] + async fn add_tracks_accepts_an_empty_success_body() { + let (base, server) = serve(vec![("200 OK", "")]).await; + QobuzSource::with_base(base) + .add_tracks("qobuz:playlist:111", &["qobuz:track:1".to_string()]) + .await + .unwrap(); + assert_eq!(server.await.unwrap().len(), 1); + } + + #[tokio::test] + async fn adding_no_tracks_makes_no_request() { + QobuzSource::with_base("http://127.0.0.1:1/") + .add_tracks("qobuz:playlist:111", &[]) + .await + .unwrap(); + } + + #[tokio::test] + async fn remove_tracks_reads_the_playlist_then_deletes_item_ids() { + let (base, server) = serve(vec![("200 OK", PLAYLIST_ITEMS), ("200 OK", WRITE_OK)]).await; + QobuzSource::with_base(base) + .remove_tracks("qobuz:playlist:111", &["qobuz:track:5001".to_string()]) + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 2); + assert!(seen[0].0.starts_with("GET /playlist/get?")); + assert!(seen[0].0.contains("playlist_id=111")); + assert!(seen[0].0.contains("extra=tracks")); + assert!(seen[1].0.contains("/playlist/deleteTracks")); + assert!(sent(&seen[1]).contains("playlist_id=111")); + assert!(sent(&seen[1]).contains("playlist_track_ids=90001%2C90003")); + } + /// Live end to end: scrape the bundle, start a session, stream one track /// through the shared sink while it downloads, and seek ahead. Needs /// `SPOTATUI_QOBUZ_TOKEN` (and `SPOTATUI_QOBUZ_TEST_FORMAT`, default 27). Run: diff --git a/src/infra/qobuz/types.rs b/src/infra/qobuz/types.rs index 680e95f9..a05c7d86 100644 --- a/src/infra/qobuz/types.rs +++ b/src/infra/qobuz/types.rs @@ -124,6 +124,13 @@ pub struct Track { pub streamable: bool, #[serde(default)] pub parental_warning: bool, + /// Read by the sync matcher; no production reader yet. + #[allow(dead_code)] + #[serde(default)] + pub isrc: Option, + /// The per-playlist item id `playlist/deleteTracks` takes. + #[serde(default, deserialize_with = "de_opt_id")] + pub playlist_track_id: Option, } #[derive(Debug, Deserialize)] diff --git a/src/infra/subsonic/mod.rs b/src/infra/subsonic/mod.rs index 0ac8a3a1..34f24480 100644 --- a/src/infra/subsonic/mod.rs +++ b/src/infra/subsonic/mod.rs @@ -38,7 +38,7 @@ use reqwest::Client; use crate::core::plugin_api::{ AlbumInfo, ArtistInfo, ArtistRef, PlaylistInfo, SearchResults, TrackInfo, }; -use crate::core::source::{MediaSource, Searcher}; +use crate::core::source::{MediaSource, PlaylistWriter, Searcher}; use crate::infra::audio::LocalPlayer; use types::{SubsonicEnvelope, SubsonicResponse}; @@ -52,6 +52,8 @@ const CLIENT_NAME: &str = "spotatui"; const PLAYLIST_PREFIX: &str = "subsonic:playlist:"; const TRACK_PREFIX: &str = "subsonic:track:"; +/// Repeated params per `updatePlaylist` call; they all ride in one request line. +const WRITE_CHUNK: usize = 50; /// Cap on establishing the TCP+TLS connection. A server that never completes the /// handshake (captive portal, half-open TCP) fails fast instead of hanging the @@ -357,6 +359,21 @@ fn playlist_id_from_uri(uri: &str) -> Result<&str> { .ok_or_else(|| anyhow!("Not a subsonic playlist URI: {}", uri)) } +/// A `subsonic:track:` URI or a bare id. +fn track_id_of(uri: &str) -> &str { + uri.strip_prefix(TRACK_PREFIX).unwrap_or(uri) +} + +/// The positions of `track_ids` in the playlist, ascending. +fn song_indices_for(entries: &[types::SubsonicSong], track_ids: &[&str]) -> Vec { + entries + .iter() + .enumerate() + .filter(|(_, s)| track_ids.contains(&s.id.as_str())) + .map(|(index, _)| index) + .collect() +} + impl From<&types::SubsonicPlaylist> for PlaylistInfo { fn from(p: &types::SubsonicPlaylist) -> Self { PlaylistInfo { @@ -462,6 +479,77 @@ fn artist_to_artist_info(a: &types::SubsonicArtist) -> ArtistInfo { } } +// --------------------------------------------------------------------------- +// Playlist writes +// --------------------------------------------------------------------------- + +impl SubsonicSource { + /// Create a playlist and return its new id. + pub async fn create_playlist(&self, name: &str) -> Result { + let url = Self::append_param( + &self.endpoint_url("createPlaylist.view"), + "name", + &url_encode(name), + ); + let created = self.fetch(&url).await?.playlist.ok_or_else(|| { + anyhow!("createPlaylist returned no id; the playlist itself may have been created") + })?; + Ok(created.id) + } +} + +impl PlaylistWriter for SubsonicSource { + /// Append tracks, [`WRITE_CHUNK`] `songIdToAdd` params per call. + async fn add_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()> { + let id = playlist_id_from_uri(playlist_uri)?; + for chunk in track_uris.chunks(WRITE_CHUNK) { + let mut url = Self::append_param( + &self.endpoint_url("updatePlaylist.view"), + "playlistId", + &url_encode(id), + ); + for uri in chunk { + url = Self::append_param(&url, "songIdToAdd", &url_encode(track_id_of(uri))); + } + self.fetch(&url).await?; + } + Ok(()) + } + + /// Remove tracks by position, highest first so earlier ones never shift. + async fn remove_tracks(&self, playlist_uri: &str, track_uris: &[String]) -> Result<()> { + let id = playlist_id_from_uri(playlist_uri)?; + if track_uris.is_empty() { + return Ok(()); + } + let read = Self::append_param( + &self.endpoint_url("getPlaylist.view"), + "id", + &url_encode(id), + ); + let detail = self + .fetch(&read) + .await? + .playlist + .ok_or_else(|| anyhow!("No playlist in getPlaylist response"))?; + let wanted: Vec<&str> = track_uris.iter().map(|uri| track_id_of(uri)).collect(); + let mut indices = song_indices_for(&detail.entry, &wanted); + indices.reverse(); + for chunk in indices.chunks(WRITE_CHUNK) { + let mut url = Self::append_param( + &self.endpoint_url("updatePlaylist.view"), + "playlistId", + &url_encode(id), + ); + for index in chunk { + url = Self::append_param(&url, "songIndexToRemove", &index.to_string()); + } + self.fetch(&url).await?; + } + Ok(()) + } +} + // --------------------------------------------------------------------------- // Trait implementations // --------------------------------------------------------------------------- @@ -555,6 +643,54 @@ fn url_encode(s: &str) -> String { mod tests { use super::*; use crate::infra::subsonic::types::SubsonicEnvelope; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::TcpListener; + + /// Serve `responses` in order, collecting each request target. Subsonic + /// writes carry everything in the query string, so no body is read. + async fn serve( + responses: Vec<(&'static str, &'static str)>, + ) -> (String, tokio::task::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { + let mut seen = Vec::new(); + for (status, payload) in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let (read_half, mut write_half) = stream.split(); + let mut reader = BufReader::new(read_half); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await.unwrap(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + } + seen.push( + request_line + .split_whitespace() + .nth(1) + .unwrap_or("") + .to_string(), + ); + write_half + .write_all( + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", + payload.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + write_half.flush().await.unwrap(); + } + seen + }); + (base, handle) + } /// Live end-to-end smoke test against the public Navidrome demo server. /// Ignored by default (hits the network); run with: @@ -727,7 +863,8 @@ mod tests { "album": "Weightless", "albumId": "alb1", "duration": 469, - "trackNumber": 1 + "trackNumber": 1, + "isrc": ["GBAYE0601498"] }, { "id": "102", @@ -782,6 +919,49 @@ mod tests { } }"#; + const SCALAR_ISRC: &str = r#" + { + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "playlist": { + "id": "7", + "name": "Mirror", + "songCount": 1, + "entry": [{ "id": "101", "title": "A", "isrc": "GBAYE0601498" }] + } + } + }"#; + + const DUPLICATE_ENTRIES: &str = r#" + { + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "playlist": { + "id": "7", + "name": "Mirror", + "songCount": 3, + "entry": [ + { "id": "101", "title": "A" }, + { "id": "102", "title": "B" }, + { "id": "101", "title": "A" } + ] + } + } + }"#; + + const CREATE_PLAYLIST: &str = r#" + { + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "playlist": { "id": "42", "name": "Road Trip", "owner": "alice", "songCount": 0 } + } + }"#; + + const UPDATE_OK: &str = r#"{"subsonic-response":{"status":"ok","version":"1.16.1"}}"#; + // ------------------------------------------------------------------------- // JSON parsing tests // ------------------------------------------------------------------------- @@ -926,4 +1106,85 @@ mod tests { assert_eq!(salt.len(), 12); assert!(salt.chars().all(|c| c.is_ascii_alphanumeric())); } + + #[test] + fn songs_carry_isrc_when_the_server_is_opensubsonic() { + let envelope: SubsonicEnvelope = serde_json::from_str(GET_PLAYLIST).unwrap(); + let entries = envelope.response.playlist.unwrap().entry; + assert_eq!(entries[0].isrc, vec!["GBAYE0601498".to_string()]); + assert!(entries[1].isrc.is_empty()); + } + + #[test] + fn a_scalar_isrc_parses_instead_of_killing_the_response() { + let envelope: SubsonicEnvelope = serde_json::from_str(SCALAR_ISRC).unwrap(); + let entries = envelope.response.playlist.unwrap().entry; + assert_eq!(entries[0].isrc, vec!["GBAYE0601498".to_string()]); + } + + #[test] + fn song_indices_map_ids_to_every_position() { + let envelope: SubsonicEnvelope = serde_json::from_str(DUPLICATE_ENTRIES).unwrap(); + let entries = envelope.response.playlist.unwrap().entry; + assert_eq!(song_indices_for(&entries, &["101"]), vec![0, 2]); + assert_eq!(song_indices_for(&entries, &["102"]), vec![1]); + assert!(song_indices_for(&entries, &["999"]).is_empty()); + } + + #[tokio::test] + async fn create_playlist_encodes_the_name_and_returns_the_new_id() { + let (base, server) = serve(vec![("200 OK", CREATE_PLAYLIST)]).await; + let id = SubsonicSource::new(base, "u", "p") + .create_playlist("Road Trip") + .await + .unwrap(); + assert_eq!(id, "42"); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 1); + assert!(seen[0].starts_with("/rest/createPlaylist.view?")); + assert!(seen[0].contains("u=u&t=")); + assert!(seen[0].contains("&name=Road+Trip")); + } + + #[tokio::test] + async fn add_tracks_sends_one_song_id_to_add_per_track() { + let (base, server) = serve(vec![("200 OK", UPDATE_OK)]).await; + SubsonicSource::new(base, "u", "p") + .add_tracks( + "subsonic:playlist:7", + &["subsonic:track:101".to_string(), "102".to_string()], + ) + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 1); + assert!(seen[0].starts_with("/rest/updatePlaylist.view?")); + assert!(seen[0].contains("&playlistId=7")); + assert!(seen[0].contains("&songIdToAdd=101")); + assert!(seen[0].contains("&songIdToAdd=102")); + } + + #[tokio::test] + async fn remove_tracks_reads_the_playlist_then_removes_the_highest_index_first() { + let (base, server) = serve(vec![("200 OK", DUPLICATE_ENTRIES), ("200 OK", UPDATE_OK)]).await; + SubsonicSource::new(base, "u", "p") + .remove_tracks("subsonic:playlist:7", &["subsonic:track:101".to_string()]) + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 2); + assert!(seen[0].starts_with("/rest/getPlaylist.view?")); + assert!(seen[0].contains("&id=7")); + assert!(seen[1].starts_with("/rest/updatePlaylist.view?")); + assert!(seen[1].contains("&playlistId=7")); + assert!(seen[1].contains("&songIndexToRemove=2&songIndexToRemove=0")); + } + + #[tokio::test] + async fn adding_no_tracks_makes_no_request() { + SubsonicSource::new("http://127.0.0.1:1", "u", "p") + .add_tracks("subsonic:playlist:7", &[]) + .await + .unwrap(); + } } diff --git a/src/infra/subsonic/types.rs b/src/infra/subsonic/types.rs index 0590f970..15f9cc3f 100644 --- a/src/infra/subsonic/types.rs +++ b/src/infra/subsonic/types.rs @@ -8,7 +8,23 @@ //! The structs here are **private** to the subsonic module; callers work with //! the domain types in [`crate::core::plugin_api`]. -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; + +/// `isrc` is a list on OpenSubsonic, a bare string on some servers, absent on +/// older ones. +fn de_one_or_many<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + Ok(match Option::::deserialize(d)? { + Some(OneOrMany::One(s)) => vec![s], + Some(OneOrMany::Many(v)) => v, + None => Vec::new(), + }) +} // --------------------------------------------------------------------------- // Top-level envelope @@ -130,6 +146,8 @@ pub struct SubsonicSong { pub year: Option, #[serde(rename = "coverArt", default)] pub cover_art: Option, + #[serde(default, deserialize_with = "de_one_or_many")] + pub isrc: Vec, } #[derive(Debug, Deserialize)] diff --git a/tools/gates.count b/tools/gates.count index d13c1f74..50b97c1b 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -15,4 +15,4 @@ view_writes_outside_tui = 12 # target 0 (producers outside tui/ and co pub_fields_on_app = 139 # target 1 (App.view stays public for the frontend; the rest go through App methods) direct_playback_context_reads = 90 # target 0 (readers of App::current_playback_context outside the ownership resolver and the snapshot builder, which are excluded) action_refs_in_tui_handlers = 183 # adoption: may only rise -test_attribute_total = 1883 # adoption: may only rise +test_attribute_total = 1900 # adoption: may only rise From c29405801e4ea90f69dbee936309fb484c9020c9 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:22:12 +0200 Subject: [PATCH 2/6] feat(sync): playlist sync across sources --- .github/copilot-instructions.md | 3 +- AGENTS.md | 3 +- CLAUDE.md | 3 +- README.md | 1 + docs/README.md | 1 + docs/playlist-sync.md | 195 ++++ src/cli/mod.rs | 2 + src/cli/sync.rs | 74 ++ src/core/action/apply.rs | 6 + src/core/action/mod.rs | 13 +- src/core/action/tests.rs | 86 ++ src/core/app/construction.rs | 12 + src/core/app/library.rs | 4 + src/core/app/mod.rs | 11 + src/core/app/playlist_sync.rs | 262 +++++ src/core/app/playlists.rs | 30 + src/core/app/route.rs | 6 + src/core/app/view.rs | 6 + src/core/mod.rs | 1 + src/core/playlist_sync/mod.rs | 948 +++++++++++++++ src/core/playlist_sync/store.rs | 327 ++++++ src/core/plugin_api.rs | 1 + src/core/requirement.rs | 23 + src/infra/mod.rs | 1 + src/infra/network/mod.rs | 59 + src/infra/network/requests.rs | 1 - src/infra/playlist_sync/mod.rs | 465 ++++++++ src/infra/playlist_sync/run.rs | 1552 +++++++++++++++++++++++++ src/infra/playlist_sync/spotify.rs | 675 +++++++++++ src/infra/playlist_sync/youtube.rs | 417 +++++++ src/infra/qobuz/dispatch.rs | 11 + src/infra/qobuz/mod.rs | 127 +- src/infra/qobuz/types.rs | 3 +- src/infra/subsonic/dispatch.rs | 24 +- src/infra/subsonic/mod.rs | 149 ++- src/infra/youtube/mod.rs | 30 + src/runtime/bootstrap.rs | 32 +- src/runtime/cli.rs | 32 +- src/runtime/startup.rs | 4 + src/tui/handlers/common_key_events.rs | 1 + src/tui/handlers/dialog.rs | 118 +- src/tui/handlers/mod.rs | 4 + src/tui/handlers/playlist.rs | 35 + src/tui/handlers/playlist_sync.rs | 105 ++ src/tui/keymap.rs | 27 + src/tui/ui/mod.rs | 5 + src/tui/ui/playlist_sync.rs | 241 ++++ src/tui/ui/popups.rs | 136 +++ tools/gates.count | 4 +- 49 files changed, 6218 insertions(+), 58 deletions(-) create mode 100644 docs/playlist-sync.md create mode 100644 src/cli/sync.rs create mode 100644 src/core/app/playlist_sync.rs create mode 100644 src/core/playlist_sync/mod.rs create mode 100644 src/core/playlist_sync/store.rs create mode 100644 src/infra/playlist_sync/mod.rs create mode 100644 src/infra/playlist_sync/run.rs create mode 100644 src/infra/playlist_sync/spotify.rs create mode 100644 src/infra/playlist_sync/youtube.rs create mode 100644 src/tui/handlers/playlist_sync.rs create mode 100644 src/tui/ui/playlist_sync.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5d7a1cb4..991420e3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -428,7 +428,7 @@ same predicate the row uses, so the two cannot disagree. ### Config & on-disk files -Five files, five owners - a value that changes as the app runs goes in state, +Six files, six owners - a value that changes as the app runs goes in state, never config: | File | Owner | Contents | @@ -437,6 +437,7 @@ never config: | `client.yml` (config dir) | `core/config.rs` | Spotify app credentials | | `state.yml` (state dir) | `core/state.rs` | machine-written runtime values | | `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | +| `playlist_sync.yml` (state dir) | `core/playlist_sync/store.rs` | playlist links + match cache | | `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | - All paths resolve through `core/paths.rs`, never `dirs::` directly. diff --git a/AGENTS.md b/AGENTS.md index b067d5c6..5ff3377d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -430,7 +430,7 @@ same predicate the row uses, so the two cannot disagree. ### Config & on-disk files -Five files, five owners - a value that changes as the app runs goes in state, +Six files, six owners - a value that changes as the app runs goes in state, never config: | File | Owner | Contents | @@ -439,6 +439,7 @@ never config: | `client.yml` (config dir) | `core/config.rs` | Spotify app credentials | | `state.yml` (state dir) | `core/state.rs` | machine-written runtime values | | `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | +| `playlist_sync.yml` (state dir) | `core/playlist_sync/store.rs` | playlist links + match cache | | `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | - All paths resolve through `core/paths.rs`, never `dirs::` directly. diff --git a/CLAUDE.md b/CLAUDE.md index d64567c5..85153e06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -430,7 +430,7 @@ same predicate the row uses, so the two cannot disagree. ### Config & on-disk files -Five files, five owners - a value that changes as the app runs goes in state, +Six files, six owners - a value that changes as the app runs goes in state, never config: | File | Owner | Contents | @@ -439,6 +439,7 @@ never config: | `client.yml` (config dir) | `core/config.rs` | Spotify app credentials | | `state.yml` (state dir) | `core/state.rs` | machine-written runtime values | | `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | +| `playlist_sync.yml` (state dir) | `core/playlist_sync/store.rs` | playlist links + match cache | | `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | - All paths resolve through `core/paths.rs`, never `dirs::` directly. diff --git a/README.md b/README.md index 109521a1..de090e10 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ A community-maintained, actively developed fork of [spotify-tui](https://github. - **Synced lyrics.** Line-by-line lyrics that follow playback. - **Real-time audio visualizer.** A system-wide FFT visualizer (press `v`) that reacts to whatever is playing. - **Cross-source play queue.** Press `z` on any track to queue it — the queue plays across every source before your current context resumes. +- **[Playlist sync](docs/playlist-sync.md).** Link one playlist as the master and mirror it onto Spotify, Qobuz, Subsonic or YouTube. Runs at startup and from `spotatui sync`. - **[Lua plugins](#plugins).** Extend spotatui with event hooks, commands, keybindings, popups, and theming. - **Listening history & recap.** spotatui keeps a local play history and can generate a shareable HTML recap (`spotatui history recap`). - **Full CLI.** Most of what the UI does is scriptable — playback, search, playlists, shell completions. Run `spotatui --help`. diff --git a/docs/README.md b/docs/README.md index 8323588a..94b08ab0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ Detailed documentation for [spotatui](https://github.com/LargeModGames/spotatui) - **[Themes](themes.md)** - Built-in presets and custom color schemes - **[Native Streaming](native-streaming.md)** - Play music directly without the Spotify app - **[Scripting](scripting.md)** - Lua plugin API for extending spotatui +- **[Playlist sync](playlist-sync.md)** - Mirror one playlist onto other sources ## Development diff --git a/docs/playlist-sync.md b/docs/playlist-sync.md new file mode 100644 index 00000000..977fed75 --- /dev/null +++ b/docs/playlist-sync.md @@ -0,0 +1,195 @@ +# Playlist Sync + +Playlist sync keeps one playlist copied onto other sources. You pick a **master** +playlist and one or more **mirrors**, and every run makes the mirrors hold the +same tracks as the master. + +Spotify, Qobuz, Subsonic and YouTube can each be a master or a mirror. Local +Files and Internet Radio cannot be linked: a local playlist is a directory on +disk, and radio has stations rather than playlists. + +## What a link is + +A **link** is one master playlist plus its mirrors: + +- The master is the playlist you actually edit, on your phone or in any client. +- Each mirror is a playlist on another source that the sync writes. Put a mirror + on a different source from the master; mirroring a source onto itself has + nothing to resolve. +- Each mirror keeps its own match cache, so adding a second mirror never makes + the first one search again. +- A link with no mirrors is skipped. + +Links live in `playlist_sync.yml`, described under [The file](#the-file). + +## Master wins + +The sync is one way. On every run: + +- Tracks added to the master are appended to each mirror, in master order. +- Tracks removed from the master are removed from each mirror. +- A track the sync never added is never touched. Add what you like to a mirror + by hand and the sync leaves it alone. +- A track the sync did add and you then deleted on the mirror comes back on the + next run. The master is the truth. + +Nothing is ever copied back from a mirror onto the master. + +## How tracks are matched + +For each master track the sync searches the mirror source and takes the first +candidate that is the same recording: + +1. **ISRC exact.** The recording code both tracks carry, compared with case and + separators ignored. On Spotify the ISRC is searched first, so a match costs + one call. +2. **Title, first artist and duration.** Title and first artist have to be equal + once lowercased with punctuation collapsed, and the two durations have to be + within two seconds. A trailing `(feat. X)`, `[Remastered]` or ` - Radio Edit` + on the master title is forgiven, both in the search and in the comparison; + the duration keeps a different edition apart. A candidate that reports no + duration matches on title and artist alone. +3. **Nothing else.** A live version or a cover carrying the same title is left + unmatched rather than silently substituted. + +YouTube carries no ISRC, so it has its own rule. The video title has to contain +the master title, with a trailing `(feat. X)` or ` - Radio Edit` suffix on the +master forgiven. A video on the artist's own channel (spaces, case and symbols +in the channel name ignored) wins when its known duration is within three +seconds. Failing that, a video on any other channel is taken only when both +durations are known and within two seconds, which is what a re-upload of the +same audio looks like. + +Every resolved pair is remembered per mirror, so a re-run costs no searches for +tracks it has already placed. Only what changed on the master costs calls. The +first YouTube run on a long playlist is the slow one: it shells out to `yt-dlp` +once per track, and the status line counts the progress. + +## Unmatched tracks + +A master track with no mirror track is listed per mirror on the Playlist sync +screen, with one of three reasons: + +| Reason | Meaning | +|--------|---------| +| No candidate | The mirror source returned nothing that is the same recording | +| Not syncable | The master track cannot be mirrored at all, such as a local file in a Spotify playlist or a podcast episode | +| Search failed | The search itself failed, and the message is what the source said | + +The list is rebuilt on every run, so a track that becomes available simply stops +appearing, and a failed search is tried again next run. A track with no +candidate is searched again only on a run you start yourself (`s` on the sync +screen, or the CLI); the startup run keeps last time's verdict, so a long +unmatched list does not cost a search per track on every launch. + +## Making a link + +In the sidebar, highlight the playlist that is the master and press `m`. A +picker lists the other sources that can take a mirror: the ones compiled into +this build, with Spotify only while a session exists and Subsonic only with a +server configured. Enter looks for a playlist of yours with the master's name +on that source and adopts it, or creates an empty one when there is none; +then it records the link and starts a run. An adopted playlist keeps every +track it already has: the run pairs them with the master by ISRC, title and +duration before it searches anything, adds what is missing, and only ever +removes tracks the sync itself added. Enter also opens the sync screen, where the run's progress shows. Press +`m` on the same master again to add a second mirror to the same link. + +The **Playlist sync** row in the Library block of the sidebar opens the sync +screen at any time; the row sits below Stats, so move the cursor down inside +the Library block to reach it. The screen shows one row per link, and for the +highlighted link each mirror's counts, its last run, and the unmatched tracks +with their reason. With the screen focused, `s` runs every link now and `D` +removes the highlighted link after a confirmation; the mirror playlists stay +where they are. The help menu (`?`) lists both keys under "Playlist sync". + +## Running a sync + +Three triggers, and no timer: + +- **TUI startup.** A run starts in the background when spotatui launches, so the + interface stays usable while it works. +- **The Playlist sync screen.** `s` runs every link on demand. +- **The CLI.** `spotatui sync`, below. + +One run at a time: a second trigger while a run is in flight answers +`Playlist sync already running`. A finished run posts one line, for example +`Playlist sync: 3 added, 1 removed, 2 unmatched`. + +A mirror receives its tracks in batches of ten as they resolve, and the file +is saved after every batch, so a run cut short by quitting continues where it +stopped on the next start. A YouTube mirror is the slow one: every unresolved +track costs one `yt-dlp` search of several seconds, so a long playlist takes +minutes on its first run. The status bar counts the progress every ten tracks. + +## The CLI + +```bash +spotatui sync # every link +spotatui sync --link "Road Trip" # one link +spotatui sync --dry-run # report the changes, write nothing +``` + +`--link` takes the master playlist name, trimmed and ignoring case, or the link +id exactly. + +`--dry-run` does every read and every search and prints what it would do. It +writes nothing at all, not even the match cache, so the next real run repeats +those searches. + +The command needs no Spotify login when no link uses Spotify, so a Qobuz to +Subsonic link syncs on a machine with no Spotify session (a fresh install +still asks for the Spotify app credentials once, like every command). A link +whose source is not connected, not logged in, not configured, or not compiled +into your build is **skipped** with a message, and a skip is not a failure. + +Exit code: zero when every link ran or was skipped, non-zero when a link failed, +for instance when a write was rejected or a rate limit stopped the run. + +## The file + +Links and their match caches live in `playlist_sync.yml` in the app state +directory: `$XDG_STATE_HOME/spotatui/playlist_sync.yml` when `XDG_STATE_HOME` is +set to an absolute path, or `~/.local/state/spotatui/playlist_sync.yml` when it +is unset or not absolute. `SPOTATUI_PLAYLIST_SYNC_PATH` overrides the whole path. + +A missing file means no links. A malformed file is reported and never +overwritten, so a hand edit that went wrong stays yours to fix. + +You can write a link by hand. Everything a run fills in (`matches`, `unmatched`, +`last_run`) is optional: + +```yaml +version: 1 +links: + - id: road-trip + master: + source: Spotify + playlist_uri: "spotify:playlist:37i9dQZF1DX0XUsuxWHRQd" + name: Road Trip + mirrors: + - endpoint: + source: Qobuz + playlist_uri: "qobuz:playlist:24601" + name: Road Trip + - endpoint: + source: Subsonic + playlist_uri: "subsonic:playlist:42" + name: Road Trip +``` + +- `id` is any string, unique in the file. It is what `--link` matches exactly. +- `source` is one of `Spotify`, `Qobuz`, `Subsonic`, `YouTube`, spelled exactly + like that. +- `playlist_uri` is the URI spotatui uses for that playlist: + `spotify:playlist:`, `qobuz:playlist:`, `subsonic:playlist:`, or + `youtube:playlist:` from `youtube_playlists.yml`. +- `name` is a label only, used in the report and by `--link`. + +## Notes + +- **One writer at a time.** `spotatui sync` and a running spotatui are separate + processes, and the file is saved whole. The last one to save wins and there is + no lock file, so do not run the CLI while the app is syncing. +- **A YouTube mirror is local only.** It is the `youtube_playlists.yml` file on + this machine, not a playlist in a YouTube account, so your phone never sees it. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 641d1e0b..12adfa42 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ mod history; mod mcp; #[cfg(feature = "scripting")] mod plugin; +mod sync; #[cfg(feature = "self-update")] mod update; mod util; @@ -16,6 +17,7 @@ pub use self::history::{handle_history_matches, history_subcommand}; pub use self::mcp::mcp_subcommand; #[cfg(feature = "scripting")] pub use self::plugin::{handle_plugin_command, plugin_subcommand}; +pub use self::sync::{sync_args, sync_subcommand}; use cli_app::CliApp; pub use handle::handle_matches; #[cfg(feature = "self-update")] diff --git a/src/cli/sync.rs b/src/cli/sync.rs new file mode 100644 index 00000000..5a1f45b0 --- /dev/null +++ b/src/cli/sync.rs @@ -0,0 +1,74 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; + +/// What `spotatui sync` was asked to do. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SyncArgs { + /// A master playlist name or a link id; `None` runs every link. + pub link: Option, + pub dry_run: bool, +} + +pub fn sync_subcommand() -> Command { + Command::new("sync") + .about("Sync your linked playlists across sources") + .arg( + Arg::new("link") + .long("link") + .value_name("NAME") + .help("Sync only the link with this master playlist name or id"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Report what would change without writing anything"), + ) +} + +/// Read the parsed arguments off the matches. +pub fn sync_args(matches: &ArgMatches) -> SyncArgs { + SyncArgs { + link: matches.get_one::("link").cloned(), + dry_run: matches.get_flag("dry-run"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + let matches = Command::new("spotatui") + .subcommand(sync_subcommand()) + .try_get_matches_from(args)?; + Ok(sync_args( + matches + .subcommand_matches("sync") + .expect("the sync subcommand"), + )) + } + + #[test] + fn sync_takes_an_optional_link_and_a_dry_run_flag() { + assert_eq!( + parse(&["spotatui", "sync"]).expect("a plain sync parses"), + SyncArgs { + link: None, + dry_run: false, + } + ); + assert_eq!( + parse(&["spotatui", "sync", "--link", "Late Night", "--dry-run"]) + .expect("both arguments parse"), + SyncArgs { + link: Some("Late Night".to_string()), + dry_run: true, + } + ); + } + + #[test] + fn sync_rejects_an_unknown_flag() { + assert!(parse(&["spotatui", "sync", "--force"]).is_err()); + } +} diff --git a/src/core/action/apply.rs b/src/core/action/apply.rs index a84c9b33..0a5837ec 100644 --- a/src/core/action/apply.rs +++ b/src/core/action/apply.rs @@ -139,6 +139,12 @@ impl App { } } Action::DeletePlaylist(uri) => self.dispatch(IoEvent::DeleteYouTubePlaylist(uri)), + Action::OpenPlaylistSyncPicker => self.begin_playlist_sync_picker(), + Action::LinkPlaylistTo(source) => self.link_playlist_to(source), + Action::RunPlaylistSync => self.dispatch(IoEvent::RunPlaylistSync { + retry_unmatched: true, + }), + Action::RemovePlaylistSyncLink(id) => self.dispatch(IoEvent::RemovePlaylistSyncLink(id)), Action::ToggleSaveTrack(uri) => self.dispatch(IoEvent::ToggleSaveTrack(uri)), Action::ToggleSaveCurrentItem => self.toggle_save_current_item(), Action::SaveAlbum(id) => self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(id)), diff --git a/src/core/action/mod.rs b/src/core/action/mod.rs index 3684b83b..2aaa3916 100644 --- a/src/core/action/mod.rs +++ b/src/core/action/mod.rs @@ -186,6 +186,14 @@ pub enum Action { /// Delete a local `youtube:playlist:` playlist; Spotify playlists leave /// through [`Action::UnfollowPlaylist`]. DeletePlaylist(String), + /// Open the picker that mirrors the highlighted sidebar playlist onto another source. + OpenPlaylistSyncPicker, + /// The picker's Enter: create the mirror playlist on `Source`, link it, and sync it. + LinkPlaylistTo(Source), + /// Run every playlist-sync link now. + RunPlaylistSync, + /// Forget one link by id; the mirror playlists stay. + RemovePlaylistSyncLink(String), /// Save or unsave a track by bare base62 id or `spotify:track:` URI; the /// network layer accepts both. ToggleSaveTrack(String), @@ -507,6 +515,7 @@ pub enum LibraryTarget { RecentlyPlayed, Friends, Stats, + PlaylistSync, LikedSongs, Albums, Artists, @@ -520,11 +529,12 @@ pub enum LibraryTarget { impl LibraryTarget { /// Every target, in the order of `library_row_requirements()`. #[cfg(test)] - pub const ALL: [LibraryTarget; 10] = [ + pub const ALL: [LibraryTarget; 11] = [ LibraryTarget::Discover, LibraryTarget::RecentlyPlayed, LibraryTarget::Friends, LibraryTarget::Stats, + LibraryTarget::PlaylistSync, LibraryTarget::LikedSongs, LibraryTarget::Albums, LibraryTarget::Artists, @@ -540,6 +550,7 @@ impl LibraryTarget { LibraryTarget::RecentlyPlayed => "Recently Played", LibraryTarget::Friends => "Friends", LibraryTarget::Stats => "Stats", + LibraryTarget::PlaylistSync => "Playlist sync", LibraryTarget::LikedSongs => "Liked Songs", LibraryTarget::Albums => "Albums", LibraryTarget::Artists => "Artists", diff --git a/src/core/action/tests.rs b/src/core/action/tests.rs index b310485a..0dd9d7ec 100644 --- a/src/core/action/tests.rs +++ b/src/core/action/tests.rs @@ -2009,6 +2009,91 @@ fn open_remove_track_dialog_youtube_routes_the_local_edit() { assert!(rx.try_recv().is_err()); } +// --- playlist sync --- + +/// A Qobuz sidebar scope with one playlist highlighted. +fn app_with_qobuz_playlist() -> (App, Receiver) { + let (mut app, rx) = app_with_channel(); + app.active_source = Source::Qobuz; + app.qobuz_playlists = vec![PlaylistInfo { + uri: "qobuz:playlist:9".to_string(), + ..playlist_info("9", "Mine", "owner", false) + }]; + (app.with_sidebar_playlist(0), rx) +} + +#[test] +fn open_playlist_sync_picker_pushes_the_picker_dialog_for_a_qobuz_playlist() { + let (mut app, _rx) = app_with_qobuz_playlist(); + + app.apply(Action::OpenPlaylistSyncPicker); + + assert_eq!(app.get_current_route().id, RouteId::Dialog); + assert_eq!( + app.get_current_route().active_block, + ActiveBlock::Dialog(DialogContext::PlaylistSyncPicker) + ); + assert_eq!( + app + .pending_playlist_sync_master() + .map(|master| master.playlist_uri.as_str()), + Some("qobuz:playlist:9") + ); +} + +#[test] +fn open_playlist_sync_picker_refuses_a_local_playlist() { + let (mut app, _rx) = app_with_channel(); + app.active_source = Source::Local; + app.local_playlists = vec![PlaylistInfo { + uri: "file:///music/Jazz".to_string(), + ..playlist_info("jazz", "Jazz", "local", false) + }]; + let mut app = app.with_sidebar_playlist(0); + let before = app.get_current_route().id.clone(); + + app.apply(Action::OpenPlaylistSyncPicker); + + assert_eq!(app.get_current_route().id, before); + assert!(app.pending_playlist_sync_master().is_none()); + assert!(app + .status_message() + .is_some_and(|text| text.contains("Highlight a playlist"))); +} + +#[test] +fn link_playlist_to_dispatches_link_playlist_with_the_pending_master() { + let (mut app, rx) = app_with_qobuz_playlist(); + app.apply(Action::OpenPlaylistSyncPicker); + + app.apply(Action::LinkPlaylistTo(Source::YouTube)); + + assert!(matches!( + rx.try_recv(), + Ok(IoEvent::LinkPlaylist(master, Source::YouTube)) if master.playlist_uri == "qobuz:playlist:9" + )); + assert!(app.pending_playlist_sync_master().is_none()); +} + +#[test] +fn run_and_remove_playlist_sync_dispatch_their_events() { + let (mut app, rx) = app_with_channel(); + + app.apply(Action::RunPlaylistSync); + app.apply(Action::RemovePlaylistSyncLink("abc".to_string())); + + assert!(matches!( + rx.try_recv(), + Ok(IoEvent::RunPlaylistSync { + retry_unmatched: true + }) + )); + assert!(matches!( + rx.try_recv(), + Ok(IoEvent::RemovePlaylistSyncLink(id)) if id == "abc" + )); +} + // --- library sections --- #[test] @@ -2152,6 +2237,7 @@ fn every_library_target_has_one_sidebar_row_with_a_distinct_label() { | LibraryTarget::RecentlyPlayed | LibraryTarget::Friends | LibraryTarget::Stats + | LibraryTarget::PlaylistSync | LibraryTarget::LikedSongs | LibraryTarget::Albums | LibraryTarget::Artists diff --git a/src/core/app/construction.rs b/src/core/app/construction.rs index dbfa3cf3..39fa9272 100644 --- a/src/core/app/construction.rs +++ b/src/core/app/construction.rs @@ -209,6 +209,11 @@ impl Default for App { last_friends_refresh_at: Instant::now(), create_playlist_tracks: Vec::new(), create_playlist_search_results: Vec::new(), + playlist_sync_in_flight: false, + playlist_sync_links: Vec::new(), + playlist_sync_last_report: None, + pending_playlist_sync_master: None, + pending_playlist_sync_remove: None, pending_plugin_commands: Vec::new(), plugin_data_generations: PluginDataGenerations::default(), plugin_screens: std::collections::BTreeMap::new(), @@ -238,6 +243,13 @@ impl App { self } + /// This app with sidebar playlist row `index` highlighted, for tests. + #[cfg(test)] + pub(crate) fn with_sidebar_playlist(mut self, index: usize) -> App { + self.view.selected_playlist_index = Some(index); + self + } + /// This app with a Spotify playback context, for tests. #[cfg(all(test, feature = "tui"))] pub(crate) fn with_playback(mut self, context: CurrentPlaybackContext) -> App { diff --git a/src/core/app/library.rs b/src/core/app/library.rs index 61b86bba..825838a3 100644 --- a/src/core/app/library.rs +++ b/src/core/app/library.rs @@ -13,6 +13,7 @@ pub(crate) fn library_row_requirements() -> &'static [(LibraryTarget, Requiremen ), (LibraryTarget::Friends, Requirement::None), (LibraryTarget::Stats, Requirement::None), + (LibraryTarget::PlaylistSync, Requirement::None), ( LibraryTarget::LikedSongs, Requirement::Source(Source::Spotify), @@ -312,6 +313,9 @@ impl App { self.reload_stats(); self.push_navigation_stack(RouteId::Stats, ActiveBlock::Stats); } + LibraryTarget::PlaylistSync => { + self.push_navigation_stack(RouteId::PlaylistSync, ActiveBlock::PlaylistSync); + } LibraryTarget::LikedSongs => { self.reset_saved_tracks_view(); self.dispatch(IoEvent::GetCurrentSavedTracks(None)); diff --git a/src/core/app/mod.rs b/src/core/app/mod.rs index 72d0c113..a4fe10e2 100644 --- a/src/core/app/mod.rs +++ b/src/core/app/mod.rs @@ -88,6 +88,7 @@ mod playback_routing; pub(crate) use playback_routing::{PlaybackOwner, NOTHING_PLAYING_STATUS}; mod playlist_folders; mod playlist_pages; +mod playlist_sync; mod playlists; mod plugins; mod queue; @@ -559,6 +560,16 @@ pub struct App { // Create Playlist form state pub create_playlist_tracks: Vec, pub create_playlist_search_results: Vec, + /// Whether a playlist-sync run owns the single slot right now. + playlist_sync_in_flight: bool, + /// The links as the last run loaded them, for the sync screen. + playlist_sync_links: Vec, + /// The last finished run. + playlist_sync_last_report: Option, + /// The playlist the open mirror picker is for. + pending_playlist_sync_master: Option, + /// The link id the open remove-link confirm is for. + pending_playlist_sync_remove: Option, /// Commands queued by keybindings for the scripting engine to run. pub pending_plugin_commands: Vec, /// Per-domain write counters driving async plugin data reads (see diff --git a/src/core/app/playlist_sync.rs b/src/core/app/playlist_sync.rs new file mode 100644 index 00000000..0a23ca4b --- /dev/null +++ b/src/core/app/playlist_sync.rs @@ -0,0 +1,262 @@ +use super::*; + +impl App { + /// Claim the single sync slot; `false` when a run already owns it. + pub fn begin_playlist_sync(&mut self) -> bool { + if self.playlist_sync_in_flight { + return false; + } + self.playlist_sync_in_flight = true; + true + } + + /// Release the slot and keep the run's report. + pub fn finish_playlist_sync(&mut self, report: crate::core::playlist_sync::SyncReport) { + self.playlist_sync_in_flight = false; + self.playlist_sync_last_report = Some(report); + } + + /// Replace the link snapshot the sync screen reads. + pub fn set_playlist_sync_links(&mut self, links: Vec) { + self.playlist_sync_links = links; + self.view.playlist_sync_selected_link = self + .view + .playlist_sync_selected_link + .min(self.playlist_sync_links.len().saturating_sub(1)); + } + + /// The links as the last run loaded them. + pub fn playlist_sync_links(&self) -> &[crate::core::playlist_sync::Link] { + &self.playlist_sync_links + } + + /// The last finished run. + pub fn playlist_sync_last_report(&self) -> Option<&crate::core::playlist_sync::SyncReport> { + self.playlist_sync_last_report.as_ref() + } + + /// Whether a run owns the slot right now. + pub fn playlist_sync_in_flight(&self) -> bool { + self.playlist_sync_in_flight + } + + /// The link under the sync screen's cursor. + pub fn selected_playlist_sync_link(&self) -> Option<&crate::core::playlist_sync::Link> { + self + .playlist_sync_links + .get(self.view.playlist_sync_selected_link) + } + + /// The playlist the open mirror picker is for. + pub fn pending_playlist_sync_master(&self) -> Option<&crate::core::playlist_sync::Endpoint> { + self.pending_playlist_sync_master.as_ref() + } + + /// The sources the mirror picker offers: compiled in, able to sync, reachable + /// from this session, not the master's own, and not already mirrored. + pub fn playlist_sync_picker_sources(&self) -> Vec { + let Some(master) = self.pending_playlist_sync_master.as_ref() else { + return Vec::new(); + }; + let mirrored: Vec = self + .playlist_sync_links + .iter() + .find(|link| { + link.master.source == master.source && link.master.playlist_uri == master.playlist_uri + }) + .map(|link| link.mirrors.iter().map(|m| m.endpoint.source).collect()) + .unwrap_or_default(); + let subsonic_configured = self + .user_config + .behavior + .subsonic_url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()); + Source::ALL + .into_iter() + .filter(|source| *source != master.source && source.supports_playlist_sync()) + .filter(|source| !mirrored.contains(source)) + .filter(|source| crate::infra::playlist_sync::missing_sync_feature(*source).is_none()) + .filter(|source| match source { + Source::Spotify => self.spotify_connected, + Source::Subsonic => subsonic_configured, + Source::Qobuz | Source::YouTube | Source::Local | Source::Radio => true, + }) + .collect() + } + + /// The id the open remove-link confirm is for. + pub fn pending_playlist_sync_remove(&self) -> Option<&str> { + self.pending_playlist_sync_remove.as_deref() + } + + /// `D` on the sync screen: confirm removing the highlighted link. + pub fn begin_remove_playlist_sync_link(&mut self) { + let Some(link) = self.selected_playlist_sync_link() else { + self.set_status_message("No playlist link is highlighted", 4); + return; + }; + let (id, name) = (link.id.clone(), link.master.name.clone()); + self.clear_dialog_state(); + self.pending_playlist_sync_remove = Some(id); + self.view.dialog = Some(name); + self.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::RemovePlaylistSyncLinkConfirm), + ); + self.set_current_route_state( + Some(ActiveBlock::Dialog( + DialogContext::RemovePlaylistSyncLinkConfirm, + )), + None, + ); + } + + /// `m` on a sidebar playlist: open the mirror picker for it. + pub fn begin_playlist_sync_picker(&mut self) { + let Some(master) = self.selected_sidebar_playlist_endpoint() else { + self.set_status_message("Highlight a playlist to mirror it", 4); + return; + }; + let availability = self.availability(Requirement::Capability(Capability::PlaylistSync)); + if let Some(hint) = availability.hint() { + self.set_status_message(format!("Playlist sync: {hint}"), 4); + return; + } + self.clear_dialog_state(); + self.pending_playlist_sync_master = Some(master); + if self.playlist_sync_picker_sources().is_empty() { + self.pending_playlist_sync_master = None; + self.set_status_message("No other source can take a mirror of this playlist", 4); + return; + } + self.push_navigation_stack( + RouteId::Dialog, + ActiveBlock::Dialog(DialogContext::PlaylistSyncPicker), + ); + self.set_current_route_state( + Some(ActiveBlock::Dialog(DialogContext::PlaylistSyncPicker)), + None, + ); + } + + /// The picker's Enter: hand the master and the chosen source to the runner. + pub fn link_playlist_to(&mut self, mirror: Source) { + let Some(master) = self.pending_playlist_sync_master.take() else { + return; + }; + self.set_status_message( + format!("Mirroring {} onto {}", master.name, mirror.label()), + 8, + ); + self.dispatch(IoEvent::LinkPlaylist(master, mirror)); + } +} + +#[cfg(test)] +mod tests { + use crate::core::app::test_support::*; + use crate::core::playlist_sync::{Endpoint, Link, LinkReport, SyncReport}; + use crate::core::source::Source; + + fn endpoint(source: Source, uri: &str) -> Endpoint { + Endpoint { + source, + playlist_uri: uri.to_string(), + name: "Mine".to_string(), + } + } + + fn link(id: &str, uri: &str) -> Link { + Link { + id: id.to_string(), + master: endpoint(Source::Spotify, uri), + mirrors: Vec::new(), + } + } + + #[test] + fn a_second_playlist_sync_cannot_begin_until_the_first_finishes() { + let mut app = make_app_simple(); + + assert!(app.begin_playlist_sync()); + assert!(!app.begin_playlist_sync()); + assert!(!app.begin_playlist_sync()); + + app.finish_playlist_sync(SyncReport::default()); + assert!(app.begin_playlist_sync()); + } + + #[test] + fn finishing_a_run_keeps_its_report() { + let mut app = make_app_simple(); + assert!(app.playlist_sync_last_report().is_none()); + assert!(app.playlist_sync_links().is_empty()); + + let report = SyncReport { + links: vec![LinkReport::new("abc123", "Road Trip")], + error: Some("429 rate limited".to_string()), + dry_run: false, + }; + assert!(app.begin_playlist_sync()); + app.finish_playlist_sync(report.clone()); + + assert_eq!(app.playlist_sync_last_report(), Some(&report)); + } + + #[test] + fn the_picker_offers_every_other_reachable_source() { + let mut app = make_app_simple(); + app.spotify_connected = true; + app.user_config.behavior.subsonic_url = Some("http://x".to_string()); + app.pending_playlist_sync_master = Some(endpoint(Source::Qobuz, "qobuz:playlist:9")); + + let sources = app.playlist_sync_picker_sources(); + + assert!(!sources.contains(&Source::Qobuz)); + assert!(!sources.contains(&Source::Local)); + assert!(!sources.contains(&Source::Radio)); + assert!(sources.contains(&Source::Spotify)); + assert_eq!( + sources.contains(&Source::Subsonic), + cfg!(feature = "subsonic") + ); + assert_eq!( + sources.contains(&Source::YouTube), + cfg!(feature = "youtube") + ); + } + + #[test] + fn the_picker_drops_spotify_without_a_session_and_subsonic_without_a_url() { + let mut app = make_app_simple(); + app.spotify_connected = false; + app.user_config.behavior.subsonic_url = None; + app.pending_playlist_sync_master = Some(endpoint(Source::Qobuz, "qobuz:playlist:9")); + + let sources = app.playlist_sync_picker_sources(); + + assert!(!sources.contains(&Source::Spotify)); + assert!(!sources.contains(&Source::Subsonic)); + } + + #[test] + fn the_selected_link_follows_the_view_cursor() { + let mut app = make_app_simple(); + app.set_playlist_sync_links(vec![ + link("aaa", "spotify:playlist:a"), + link("bbb", "spotify:playlist:b"), + ]); + + app.view.playlist_sync_selected_link = 1; + assert_eq!( + app + .selected_playlist_sync_link() + .map(|link| link.id.as_str()), + Some("bbb") + ); + + app.view.playlist_sync_selected_link = 5; + assert!(app.selected_playlist_sync_link().is_none()); + } +} diff --git a/src/core/app/playlists.rs b/src/core/app/playlists.rs index 54971aca..9598b4c3 100644 --- a/src/core/app/playlists.rs +++ b/src/core/app/playlists.rs @@ -58,6 +58,9 @@ impl App { self.view.dialog = None; self.view.confirm = false; self.pending_keybinding_persist = None; + self.pending_playlist_sync_master = None; + self.pending_playlist_sync_remove = None; + self.view.playlist_sync_picker_index = 0; self.clear_playlist_track_dialog_state(); } @@ -401,6 +404,33 @@ impl App { .and_then(|playlist| playlist.id.clone()) } + /// The highlighted sidebar playlist as a sync endpoint, for the active source. + pub fn selected_sidebar_playlist_endpoint(&self) -> Option { + let index = self.view.selected_playlist_index?; + let playlist = match self.active_source { + Source::Spotify => { + let display_index = index.checked_sub(1)?; + match self.get_playlist_display_item_at(display_index)? { + PlaylistFolderItem::Playlist { index, .. } => self.all_playlists.get(*index)?, + PlaylistFolderItem::Folder(_) | PlaylistFolderItem::CommunityPin => return None, + } + } + // The Qobuz sidebar also lists the favorites row and favorite albums. + Source::Qobuz => self + .qobuz_playlists + .get(index) + .filter(|playlist| playlist.uri.starts_with("qobuz:playlist:"))?, + Source::Subsonic => self.subsonic_playlists.get(index)?, + Source::YouTube => self.youtube_playlists.get(index)?, + Source::Local | Source::Radio => return None, + }; + Some(crate::core::playlist_sync::Endpoint { + source: self.active_source, + playlist_uri: playlist.uri.clone(), + name: playlist.name.clone(), + }) + } + /// The highlighted search-result playlist's Spotify id. pub fn selected_search_result_playlist_id(&self) -> Option { let playlists = self.search_results.playlists.as_ref()?; diff --git a/src/core/app/route.rs b/src/core/app/route.rs index a435428f..c0623db9 100644 --- a/src/core/app/route.rs +++ b/src/core/app/route.rs @@ -35,6 +35,10 @@ pub enum DialogContext { /// Confirm deleting a local YouTube playlist (sidebar `D` under the /// YouTube source). YouTubePlaylistWindow, + /// Pick the source a highlighted playlist is mirrored onto. + PlaylistSyncPicker, + /// Confirm removing the highlighted playlist-sync link (`D` on the sync screen). + RemovePlaylistSyncLinkConfirm, } #[derive(Clone, Copy, PartialEq, Debug)] @@ -76,6 +80,7 @@ pub enum ActiveBlock { Friends, LocalBrowser, Stats, + PlaylistSync, /// The AI DJ screen's prompt + transcript. #[cfg(feature = "ai-dj")] AiDj, @@ -130,6 +135,7 @@ pub enum RouteId { #[cfg_attr(not(feature = "local-files"), allow(dead_code))] LocalBrowser, Stats, + PlaylistSync, #[cfg(feature = "ai-dj")] AiDj, /// A plugin-registered custom screen, keyed by its registered name. diff --git a/src/core/app/view.rs b/src/core/app/view.rs index fe924c25..15528e6b 100644 --- a/src/core/app/view.rs +++ b/src/core/app/view.rs @@ -126,6 +126,12 @@ pub struct ViewState { /// Selected index in the Stats screen's Top Tracks list pub stats_selected_track: usize, + // Playlist sync screen and its picker + /// Cursor in the link list. + pub playlist_sync_selected_link: usize, + /// Cursor in the mirror picker dialog. + pub playlist_sync_picker_index: usize, + // Sort menu state /// Whether the sort menu popup is visible pub sort_menu_visible: bool, diff --git a/src/core/mod.rs b/src/core/mod.rs index 388b302f..5c43585e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -34,6 +34,7 @@ pub mod pagination; pub mod paths; #[cfg_attr(not(feature = "tui"), allow(dead_code))] pub mod persisted_playback; +pub mod playlist_sync; pub mod plugin_api; pub mod queue; #[cfg_attr(not(feature = "tui"), allow(dead_code))] diff --git a/src/core/playlist_sync/mod.rs b/src/core/playlist_sync/mod.rs new file mode 100644 index 00000000..1eba52c4 --- /dev/null +++ b/src/core/playlist_sync/mod.rs @@ -0,0 +1,948 @@ +//! The pure playlist-sync engine: the link model, the per-mirror diff and the +//! track matcher. `plan` and `pick_candidate` take scalars and return plain +//! data, so every rule is unit tested without a network or a clock. The master +//! wins: a matched track deleted on the mirror comes back, and a track the sync +//! never added is never touched. `store` is the only part that touches the disk. + +pub mod store; + +use crate::core::source::Source; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Duration window for a title match, widened by the sources that report whole seconds. +const DURATION_TOLERANCE_MS: u64 = 2_000; + +/// One track as a source reports it, reduced to what matching needs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncTrack { + /// The source-native track id, which is what a match is stored by. + pub key: String, + pub isrc: Option, + pub title: String, + /// The first credited artist only. + pub artist: String, + pub duration_ms: Option, +} + +/// One master playlist and the mirrors it is synced onto. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Link { + /// A locally generated id, stable across playlist renames. + pub id: String, + pub master: Endpoint, + #[serde(default)] + pub mirrors: Vec, +} + +/// One playlist on one source. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Endpoint { + pub source: Source, + pub playlist_uri: String, + /// The playlist name as it read when the link was made. + pub name: String, +} + +/// One mirror of a link, with the match cache a re-run reuses. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Mirror { + pub endpoint: Endpoint, + /// Master track key to mirror track key, ordered so the saved file is stable. + #[serde(default)] + pub matches: BTreeMap, + #[serde(default)] + pub unmatched: Vec, + /// RFC 3339 stamp of the last finished run. + #[serde(default)] + pub last_run: Option, +} + +/// A master track the last run could not place on a mirror. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Unmatched { + pub master_key: String, + pub title: String, + pub artist: String, + pub reason: UnmatchReason, +} + +/// Why a master track has no mirror track. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum UnmatchReason { + /// The mirror source returned nothing that is the same recording. + NoCandidate, + /// The track cannot be mirrored at all, such as a local file or an episode. + NotSyncable, + /// The search itself failed; the message is what the source said. + SearchFailed(String), +} + +/// What one mirror needs in order to catch up with its master. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct SyncPlan { + /// Master tracks with no match entry: search the mirror for each. + pub to_resolve: Vec, + /// Matched pairs whose mirror track is absent, one per mirror key: (master key, mirror key). + pub to_add: Vec<(String, String)>, + /// Mirror keys to delete, deduped and on the mirror; the caller drops any it matched this run. + pub to_remove: Vec, + /// Master keys to forget, whether or not a mirror track is removed with them. + pub stale: Vec, +} + +impl Link { + /// Unmatched tracks across every mirror, for the sync screen's link row. + #[cfg_attr(not(feature = "tui"), allow(dead_code))] + pub fn unmatched_count(&self) -> usize { + self + .mirrors + .iter() + .map(|mirror| mirror.unmatched.len()) + .sum() + } + + /// Whether `filter` names this link: its id exactly, or its master name, ignoring case. + pub fn matches_filter(&self, filter: &str) -> bool { + let filter = filter.trim(); + self.id == filter || self.master.name.trim().eq_ignore_ascii_case(filter) + } +} + +impl Mirror { + /// An empty mirror at `endpoint`. + pub fn new(endpoint: Endpoint) -> Self { + Mirror { + endpoint, + matches: BTreeMap::new(), + unmatched: Vec::new(), + last_run: None, + } + } + + /// Remember that `master_key` resolved to `mirror_key` on this mirror. + pub fn record_match(&mut self, master_key: &str, mirror_key: &str) { + self + .matches + .insert(master_key.to_string(), mirror_key.to_string()); + } + + /// Forget the match entries a [`SyncPlan`] marked stale. + pub fn drop_stale(&mut self, stale: &[String]) { + for master_key in stale { + self.matches.remove(master_key); + } + } + + /// Close a run: replace the unmatched list and stamp `at` as the last run. + pub fn finish_run(&mut self, unmatched: Vec, at: String) { + self.unmatched = unmatched; + self.last_run = Some(at); + } +} + +impl Unmatched { + /// Record `track` as unresolved on a mirror. + pub fn new(track: &SyncTrack, reason: UnmatchReason) -> Self { + Unmatched { + master_key: track.key.clone(), + title: track.title.clone(), + artist: track.artist.clone(), + reason, + } + } +} + +/// Diff one mirror against its master: what to search, add, remove and forget. +pub fn plan( + master: &[SyncTrack], + mirror_keys: &[String], + matches: &BTreeMap, +) -> SyncPlan { + let present: BTreeSet<&str> = mirror_keys.iter().map(String::as_str).collect(); + let mut planned: BTreeSet<&str> = BTreeSet::new(); + let mut added: BTreeSet<&str> = BTreeSet::new(); + let mut out = SyncPlan::default(); + + for track in master { + if !planned.insert(track.key.as_str()) { + continue; + } + let Some(mirror_key) = matches.get(&track.key) else { + out.to_resolve.push(track.clone()); + continue; + }; + if !present.contains(mirror_key.as_str()) && added.insert(mirror_key.as_str()) { + out.to_add.push((track.key.clone(), mirror_key.clone())); + } + } + + let wanted: BTreeSet<&str> = matches + .iter() + .filter(|(master_key, _)| planned.contains(master_key.as_str())) + .map(|(_, mirror_key)| mirror_key.as_str()) + .collect(); + let mut dropped: BTreeSet<&str> = BTreeSet::new(); + for (master_key, mirror_key) in matches { + if planned.contains(master_key.as_str()) { + continue; + } + out.stale.push(master_key.clone()); + if wanted.contains(mirror_key.as_str()) + || !present.contains(mirror_key.as_str()) + || !dropped.insert(mirror_key.as_str()) + { + continue; + } + out.to_remove.push(mirror_key.clone()); + } + + out +} + +/// Index of the candidate that is the same recording as `target`, if any. +pub fn pick_candidate(target: &SyncTrack, candidates: &[SyncTrack]) -> Option { + let wanted_isrc = normalized_isrc_of(target); + let isrc_hit = wanted_isrc.as_deref().and_then(|isrc| { + candidates + .iter() + .position(|candidate| normalized_isrc_of(candidate).as_deref() == Some(isrc)) + }); + if isrc_hit.is_some() { + return isrc_hit; + } + + let title = normalize_text(&target.title); + if title.is_empty() { + return None; + } + let stripped = normalize_text(&strip_title_suffix(&target.title)); + let artist = normalize_text(&target.artist); + candidates.iter().position(|candidate| { + let listed = normalize_text(&candidate.title); + (listed == title || (!stripped.is_empty() && listed == stripped)) + && normalize_text(&candidate.artist) == artist + && durations_match(target.duration_ms, candidate.duration_ms) + }) +} + +/// The title without its trailing `(feat. X)`, `[Remastered]` or ` - Radio Edit` +/// suffixes, which another catalog often drops; the duration check keeps a +/// different edition apart. +pub fn strip_title_suffix(title: &str) -> String { + let mut out = title.trim(); + loop { + if out.ends_with([')', ']']) { + if let Some(open) = out.rfind(['(', '[']).filter(|open| *open > 0) { + out = out[..open].trim_end(); + continue; + } + } + if let Some(dash) = out.rfind(" - ").filter(|dash| *dash > 0) { + out = out[..dash].trim_end(); + continue; + } + return out.to_string(); + } +} + +/// Uppercase an ISRC and drop its separators, so `gb-aaa-00-00001` is canonical. +pub fn normalize_isrc(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .map(|ch| ch.to_ascii_uppercase()) + .collect() +} + +/// Lowercase, every non-alphanumeric run collapsed to one space, already trimmed. +pub fn normalize_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut pending_space = false; + for ch in value.chars() { + if ch.is_alphanumeric() { + if pending_space && !out.is_empty() { + out.push(' '); + } + pending_space = false; + out.extend(ch.to_lowercase()); + } else { + pending_space = true; + } + } + out +} + +/// A track's ISRC in canonical form, with a blank one read as absent. +fn normalized_isrc_of(track: &SyncTrack) -> Option { + track + .isrc + .as_deref() + .map(normalize_isrc) + .filter(|isrc| !isrc.is_empty()) +} + +/// Whether two durations are one recording's; an absent one never vetoes. +fn durations_match(left: Option, right: Option) -> bool { + match (left, right) { + (Some(left), Some(right)) => left.abs_diff(right) <= DURATION_TOLERANCE_MS, + _ => true, + } +} + +/// What one run did, per link and in total. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SyncReport { + pub links: Vec, + /// A failure that stopped the whole run, such as a rate limit. + pub error: Option, + pub dry_run: bool, +} + +/// What one link did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LinkReport { + #[cfg_attr(not(feature = "tui"), allow(dead_code))] + pub id: String, + /// The master playlist name, which is what the user types after `--link`. + pub name: String, + pub added: usize, + pub removed: usize, + pub unmatched: usize, + pub outcome: LinkOutcome, +} + +/// How far a link or one of its mirrors got. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum LinkOutcome { + #[default] + Ran, + /// Nothing was attempted and nothing is wrong, such as a source not logged in. + Skipped(String), + /// Something the source did answer went wrong; the text is what it said. + Failed(String), +} + +/// What one mirror did, folded into its link's report. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MirrorRun { + pub added: usize, + pub removed: usize, + pub unmatched: usize, + pub outcome: LinkOutcome, +} + +impl SyncReport { + /// Tracks added across every link. + pub fn added(&self) -> usize { + self.links.iter().map(|link| link.added).sum() + } + + /// Tracks removed across every link. + pub fn removed(&self) -> usize { + self.links.iter().map(|link| link.removed).sum() + } + + /// Master tracks left unplaced across every link. + pub fn unmatched(&self) -> usize { + self.links.iter().map(|link| link.unmatched).sum() + } + + /// Whether the run itself failed or any link did; the CLI's exit signal. + pub fn failed(&self) -> bool { + self.error.is_some() + || self + .links + .iter() + .any(|link| matches!(link.outcome, LinkOutcome::Failed(_))) + } + + /// The one-line summary a status bar shows. + pub fn summary(&self) -> String { + if let Some(error) = &self.error { + return format!("Playlist sync failed: {error}"); + } + if self.links.is_empty() { + return "Playlist sync: no links".to_string(); + } + let dry = if self.dry_run { " (dry run)" } else { "" }; + format!( + "Playlist sync{dry}: {} added, {} removed, {} unmatched", + self.added(), + self.removed(), + self.unmatched() + ) + } + + /// The summary plus one line per link, for the CLI. + pub fn printable(&self) -> String { + let mut out = self.summary(); + for link in &self.links { + out.push('\n'); + out.push_str(&link.line()); + } + out + } +} + +impl LinkReport { + /// An untouched report for one link. + pub fn new(id: &str, name: &str) -> Self { + LinkReport { + id: id.to_string(), + name: name.to_string(), + ..LinkReport::default() + } + } + + /// Fold one mirror in: counts add up, a failure outranks a skip. + pub fn absorb(&mut self, run: MirrorRun) { + self.added += run.added; + self.removed += run.removed; + self.unmatched += run.unmatched; + self.note(run.outcome); + } + + /// Record an outcome for the link itself, keeping the worse of the two. + pub fn note(&mut self, outcome: LinkOutcome) { + if outcome.rank() > self.outcome.rank() { + self.outcome = outcome; + } + } + + /// This link's line in the CLI report. + pub fn line(&self) -> String { + match &self.outcome { + LinkOutcome::Ran => format!( + "{}: {} added, {} removed, {} unmatched", + self.name, self.added, self.removed, self.unmatched + ), + LinkOutcome::Skipped(why) => format!("{}: skipped ({why})", self.name), + LinkOutcome::Failed(why) => format!("{}: failed ({why})", self.name), + } + } +} + +impl LinkOutcome { + /// How bad this outcome is; a fold keeps the highest and the first text of that rank. + fn rank(&self) -> u8 { + match self { + LinkOutcome::Ran => 0, + LinkOutcome::Skipped(_) => 1, + LinkOutcome::Failed(_) => 2, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(key: &str, title: &str, artist: &str, duration_ms: u64) -> SyncTrack { + SyncTrack { + key: key.to_string(), + isrc: None, + title: title.to_string(), + artist: artist.to_string(), + duration_ms: Some(duration_ms), + } + } + + fn with_isrc(mut track: SyncTrack, isrc: &str) -> SyncTrack { + track.isrc = Some(isrc.to_string()); + track + } + + fn without_duration(mut track: SyncTrack) -> SyncTrack { + track.duration_ms = None; + track + } + + fn keys(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + fn cache(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(master, mirror)| (master.to_string(), mirror.to_string())) + .collect() + } + + fn mirror_of(source: Source, uri: &str) -> Mirror { + Mirror { + endpoint: Endpoint { + source, + playlist_uri: uri.to_string(), + name: "Mirror".to_string(), + }, + matches: BTreeMap::new(), + unmatched: Vec::new(), + last_run: None, + } + } + + #[test] + fn a_matched_track_present_on_the_mirror_is_a_no_op() { + let master = vec![track("m1", "Creep", "Radiohead", 238_000)]; + let out = plan(&master, &keys(&["x1"]), &cache(&[("m1", "x1")])); + assert_eq!(out, SyncPlan::default()); + } + + #[test] + fn a_matched_track_missing_from_the_mirror_is_added_again() { + let master = vec![track("m1", "Creep", "Radiohead", 238_000)]; + let out = plan(&master, &keys(&[]), &cache(&[("m1", "x1")])); + assert_eq!(out.to_add, vec![("m1".to_string(), "x1".to_string())]); + assert!(out.to_resolve.is_empty()); + assert!(out.to_remove.is_empty()); + assert!(out.stale.is_empty()); + } + + #[test] + fn a_master_track_with_no_match_entry_goes_to_resolve() { + let master = vec![track("m1", "Creep", "Radiohead", 238_000)]; + let out = plan(&master, &keys(&["x9"]), &cache(&[])); + assert_eq!(out.to_resolve, master); + assert!(out.to_add.is_empty()); + assert!(out.to_remove.is_empty()); + assert!(out.stale.is_empty()); + } + + #[test] + fn a_dropped_master_track_is_removed_from_the_mirror_and_forgotten() { + let master = vec![track("m1", "Creep", "Radiohead", 238_000)]; + let out = plan( + &master, + &keys(&["x1", "x2", "x9"]), + &cache(&[("m1", "x1"), ("m2", "x2"), ("m3", "x3")]), + ); + assert_eq!(out.stale, keys(&["m2", "m3"])); + assert_eq!(out.to_remove, keys(&["x2"])); + assert!(out.to_add.is_empty()); + assert!(out.to_resolve.is_empty()); + } + + #[test] + fn a_duplicate_master_key_is_planned_once() { + let first = track("m1", "Creep", "Radiohead", 238_000); + let second = track("m2", "Idioteque", "Radiohead", 290_000); + let master = vec![first.clone(), first.clone(), second.clone()]; + + let out = plan(&master, &keys(&[]), &cache(&[])); + assert_eq!(out.to_resolve, vec![first, second]); + + let out = plan(&master, &keys(&[]), &cache(&[("m1", "x1"), ("m2", "x2")])); + assert_eq!( + out.to_add, + vec![ + ("m1".to_string(), "x1".to_string()), + ("m2".to_string(), "x2".to_string()), + ] + ); + } + + #[test] + fn to_add_keeps_master_order() { + let master = vec![ + track("m3", "Third", "A", 100_000), + track("m1", "First", "A", 100_000), + track("m2", "Second", "A", 100_000), + ]; + let out = plan( + &master, + &keys(&[]), + &cache(&[("m1", "x1"), ("m2", "x2"), ("m3", "x3")]), + ); + assert_eq!( + out.to_add, + vec![ + ("m3".to_string(), "x3".to_string()), + ("m1".to_string(), "x1".to_string()), + ("m2".to_string(), "x2".to_string()), + ] + ); + } + + #[test] + fn a_mirror_key_two_master_tracks_share_is_added_once() { + let master = vec![ + track("m1", "Creep", "Radiohead", 238_000), + track("m2", "Creep", "Radiohead", 238_000), + ]; + let out = plan(&master, &keys(&[]), &cache(&[("m1", "x1"), ("m2", "x1")])); + assert_eq!(out.to_add, vec![("m1".to_string(), "x1".to_string())]); + assert!(out.to_remove.is_empty()); + assert!(out.stale.is_empty()); + } + + #[test] + fn a_mirror_track_two_master_tracks_share_survives_one_removal() { + let master = vec![track("m2", "Creep", "Radiohead", 238_000)]; + let out = plan( + &master, + &keys(&["x1"]), + &cache(&[("m1", "x1"), ("m2", "x1")]), + ); + assert_eq!(out.stale, keys(&["m1"])); + assert!(out.to_remove.is_empty()); + assert!(out.to_add.is_empty()); + + let out = plan(&[], &keys(&["x1"]), &cache(&[("m1", "x1"), ("m2", "x1")])); + assert_eq!(out.stale, keys(&["m1", "m2"])); + assert_eq!(out.to_remove, keys(&["x1"])); + } + + #[test] + fn a_mirror_key_listed_twice_is_removed_once() { + let out = plan(&[], &keys(&["x1", "x1"]), &cache(&[("m1", "x1")])); + assert_eq!(out.stale, keys(&["m1"])); + assert_eq!(out.to_remove, keys(&["x1"])); + + let master = vec![track("m1", "Creep", "Radiohead", 238_000)]; + let out = plan(&master, &keys(&["x1", "x1"]), &cache(&[("m1", "x1")])); + assert_eq!(out, SyncPlan::default()); + } + + #[test] + fn an_equal_isrc_wins_over_an_earlier_title_match() { + let target = with_isrc(track("m1", "Creep", "Radiohead", 238_000), "GBAAA0000001"); + let candidates = vec![ + track("x1", "Creep", "Radiohead", 238_000), + with_isrc( + track("x2", "Creep (Acoustic)", "Radiohead", 240_000), + "GBAAA0000001", + ), + ]; + assert_eq!(pick_candidate(&target, &candidates), Some(1)); + } + + #[test] + fn the_first_of_two_equal_isrc_candidates_wins() { + let target = with_isrc(track("m1", "Creep", "Radiohead", 238_000), "GBAAA0000001"); + let candidates = vec![ + track("x0", "Creep", "Radiohead", 238_000), + with_isrc( + track("x1", "Creep (Single)", "Radiohead", 238_000), + "GBAAA0000001", + ), + with_isrc(track("x2", "Creep", "Radiohead", 238_000), "GBAAA0000001"), + ]; + assert_eq!(pick_candidate(&target, &candidates), Some(1)); + } + + #[test] + fn an_isrc_with_dashes_spaces_and_lowercase_matches_its_canonical_form() { + assert_eq!(normalize_isrc(" gb-aaa-00-00001 "), "GBAAA0000001"); + let target = with_isrc( + track("m1", "Creep", "Radiohead", 238_000), + "gb-aaa 00-00001", + ); + let candidates = vec![with_isrc( + track("x1", "Other", "Nobody", 10_000), + "GBAAA0000001", + )]; + assert_eq!(pick_candidate(&target, &candidates), Some(0)); + } + + #[test] + fn an_empty_isrc_is_treated_as_absent() { + let target = with_isrc(track("m1", "Creep", "Radiohead", 238_000), " "); + + let candidates = vec![with_isrc( + track("x1", "Karma Police", "Radiohead", 260_000), + "", + )]; + assert_eq!(pick_candidate(&target, &candidates), None); + + let candidates = vec![with_isrc(track("x1", "Creep", "Radiohead", 238_000), "")]; + assert_eq!(pick_candidate(&target, &candidates), Some(0)); + } + + #[test] + fn a_differing_isrc_still_allows_a_title_and_artist_match() { + let target = with_isrc(track("m1", "Creep", "Radiohead", 238_000), "GBAAA0000001"); + let candidates = vec![with_isrc( + track("x1", "Creep", "Radiohead", 238_000), + "USZZZ9999999", + )]; + assert_eq!(pick_candidate(&target, &candidates), Some(0)); + } + + #[test] + fn a_title_and_artist_match_inside_the_duration_window_is_a_hit() { + let target = track("m1", "Creep", "Radiohead", 238_000); + let candidates = vec![ + track("x1", "Creep", "Coldplay", 238_000), + track("x2", "Creep", "Radiohead", 239_900), + ]; + assert_eq!(pick_candidate(&target, &candidates), Some(1)); + assert_eq!(pick_candidate(&target, &[]), None); + } + + #[test] + fn a_live_version_with_a_longer_duration_does_not_match() { + let target = track("m1", "Creep", "Radiohead", 238_000); + assert_eq!( + pick_candidate(&target, &[track("x1", "Creep", "Radiohead", 258_000)]), + None + ); + + let mut edge = track("x2", "Creep", "Radiohead", 240_000); + assert_eq!(pick_candidate(&target, &[edge.clone()]), Some(0)); + edge.duration_ms = Some(240_001); + assert_eq!(pick_candidate(&target, &[edge]), None); + } + + #[test] + fn a_missing_duration_on_either_side_does_not_block_a_match() { + let target = track("m1", "Creep", "Radiohead", 238_000); + let candidate = without_duration(track("x1", "Creep", "Radiohead", 0)); + assert_eq!(pick_candidate(&target, &[candidate]), Some(0)); + + let blind = without_duration(track("m1", "Creep", "Radiohead", 0)); + let live = track("x1", "Creep", "Radiohead", 258_000); + assert_eq!(pick_candidate(&blind, &[live]), Some(0)); + } + + #[test] + fn punctuation_casing_and_unicode_do_not_block_a_match() { + let target = track("m1", "Don\u{2019}t Stop Me Now!", "Queen", 210_000); + let candidates = vec![track("x1", " don't stop me NOW ", "queen", 210_500)]; + assert_eq!(pick_candidate(&target, &candidates), Some(0)); + + let target = track("m2", "J\u{f3}ga", "Bj\u{f6}rk", 305_000); + let candidates = vec![track("x2", "J\u{d3}GA", "BJ\u{d6}RK", 305_000)]; + assert_eq!(pick_candidate(&target, &candidates), Some(0)); + } + + #[test] + fn a_feat_suffix_only_the_master_carries_is_forgiven_within_the_duration_window() { + let target = track("m1", "Nice For What (feat. Big Freedia)", "Drake", 210_000); + let candidates = vec![track("x1", "Nice For What", "Drake", 210_000)]; + assert_eq!(pick_candidate(&target, &candidates), Some(0)); + + let other_edition = vec![track("x1", "Nice For What", "Drake", 240_000)]; + assert_eq!(pick_candidate(&target, &other_edition), None); + + assert_eq!(strip_title_suffix("No Sleep (feat. Bonn)"), "No Sleep"); + assert_eq!(strip_title_suffix("Tsunami - Radio Edit"), "Tsunami"); + assert_eq!( + strip_title_suffix("Sooraj Dooba Hain (From \"Roy\") [Remastered]"), + "Sooraj Dooba Hain" + ); + assert_eq!(strip_title_suffix("(Untitled)"), "(Untitled)"); + } + + #[test] + fn a_blank_title_never_matches() { + let target = track("m1", " ", "", 238_000); + let candidates = vec![track("x1", "", "", 238_000)]; + assert_eq!(pick_candidate(&target, &candidates), None); + } + + #[test] + fn normalize_text_lowercases_and_collapses_every_gap() { + assert_eq!( + normalize_text(" ***Weird Fishes / Arpeggi*** "), + "weird fishes arpeggi" + ); + assert_eq!(normalize_text("!!!"), ""); + } + + #[test] + fn record_match_and_drop_stale_maintain_the_match_cache() { + let mut mirror = mirror_of(Source::Qobuz, "qobuz:playlist:1"); + mirror.record_match("m1", "x1"); + mirror.record_match("m2", "x2"); + mirror.record_match("m1", "x9"); + assert_eq!(mirror.matches, cache(&[("m1", "x9"), ("m2", "x2")])); + + mirror.drop_stale(&keys(&["m2", "absent"])); + assert_eq!(mirror.matches, cache(&[("m1", "x9")])); + } + + #[test] + fn finish_run_replaces_the_unmatched_list_and_stamps_the_run() { + let mut mirror = mirror_of(Source::Subsonic, "subsonic:playlist:7"); + let missing = track("m1", "Creep", "Radiohead", 238_000); + + mirror.finish_run( + vec![Unmatched::new(&missing, UnmatchReason::NoCandidate)], + "2026-09-16T10:00:00Z".to_string(), + ); + assert_eq!(mirror.unmatched.len(), 1); + assert_eq!(mirror.unmatched[0].master_key, "m1"); + assert_eq!(mirror.unmatched[0].title, "Creep"); + assert_eq!(mirror.unmatched[0].reason, UnmatchReason::NoCandidate); + + mirror.finish_run(Vec::new(), "2026-09-17T10:00:00Z".to_string()); + assert!(mirror.unmatched.is_empty()); + assert_eq!(mirror.last_run.as_deref(), Some("2026-09-17T10:00:00Z")); + } + + #[test] + fn unmatched_count_sums_every_mirror() { + let missing = track("m1", "Creep", "Radiohead", 238_000); + + let mut first = mirror_of(Source::Qobuz, "qobuz:playlist:1"); + first.finish_run( + vec![Unmatched::new(&missing, UnmatchReason::NotSyncable)], + "2026-09-16T10:00:00Z".to_string(), + ); + + let mut second = mirror_of(Source::Subsonic, "subsonic:playlist:7"); + second.finish_run( + vec![ + Unmatched::new(&missing, UnmatchReason::NoCandidate), + Unmatched::new(&missing, UnmatchReason::SearchFailed("429".to_string())), + ], + "2026-09-16T10:00:00Z".to_string(), + ); + + let link = Link { + id: "abc".to_string(), + master: Endpoint { + source: Source::Spotify, + playlist_uri: "spotify:playlist:1".to_string(), + name: "Mine".to_string(), + }, + mirrors: vec![first, second], + }; + assert_eq!(link.unmatched_count(), 3); + } + + #[test] + fn a_link_report_folds_its_mirrors_and_a_failure_outranks_a_skip() { + let mut report = LinkReport::new("abc123", "Road Trip"); + assert_eq!(report.id, "abc123"); + assert_eq!(report.outcome, LinkOutcome::Ran); + + report.absorb(MirrorRun { + added: 2, + removed: 1, + unmatched: 3, + outcome: LinkOutcome::Ran, + }); + report.absorb(MirrorRun { + added: 1, + removed: 0, + unmatched: 1, + outcome: LinkOutcome::Skipped("Qobuz is not logged in".to_string()), + }); + assert_eq!(report.added, 3); + assert_eq!(report.removed, 1); + assert_eq!(report.unmatched, 4); + assert_eq!( + report.outcome, + LinkOutcome::Skipped("Qobuz is not logged in".to_string()) + ); + + report.note(LinkOutcome::Skipped("a later skip".to_string())); + assert_eq!( + report.outcome, + LinkOutcome::Skipped("Qobuz is not logged in".to_string()) + ); + + report.note(LinkOutcome::Failed("429 rate limited".to_string())); + report.note(LinkOutcome::Skipped("too late".to_string())); + assert_eq!( + report.outcome, + LinkOutcome::Failed("429 rate limited".to_string()) + ); + } + + #[test] + fn a_report_line_names_the_counts_a_skip_or_a_failure() { + let mut ran = LinkReport::new("abc123", "Road Trip"); + ran.absorb(MirrorRun { + added: 3, + removed: 1, + unmatched: 2, + outcome: LinkOutcome::Ran, + }); + assert_eq!(ran.line(), "Road Trip: 3 added, 1 removed, 2 unmatched"); + + let mut skipped = LinkReport::new("def456", "Focus"); + skipped.note(LinkOutcome::Skipped("Spotify is not connected".to_string())); + assert_eq!(skipped.line(), "Focus: skipped (Spotify is not connected)"); + + let mut failed = LinkReport::new("ghi789", "Gym"); + failed.note(LinkOutcome::Failed("429 rate limited".to_string())); + assert_eq!(failed.line(), "Gym: failed (429 rate limited)"); + } + + #[test] + fn the_summary_totals_every_link_and_a_run_error_wins() { + let empty = SyncReport::default(); + assert_eq!(empty.summary(), "Playlist sync: no links"); + assert!(!empty.failed()); + + let mut first = LinkReport::new("abc123", "Road Trip"); + first.absorb(MirrorRun { + added: 3, + removed: 1, + unmatched: 2, + outcome: LinkOutcome::Ran, + }); + let mut second = LinkReport::new("def456", "Focus"); + second.note(LinkOutcome::Skipped("Qobuz is not logged in".to_string())); + + let mut report = SyncReport { + links: vec![first, second], + error: None, + dry_run: false, + }; + assert_eq!(report.added(), 3); + assert_eq!(report.removed(), 1); + assert_eq!(report.unmatched(), 2); + assert_eq!( + report.summary(), + "Playlist sync: 3 added, 1 removed, 2 unmatched" + ); + assert!(!report.failed()); + assert_eq!( + report.printable(), + [ + "Playlist sync: 3 added, 1 removed, 2 unmatched", + "Road Trip: 3 added, 1 removed, 2 unmatched", + "Focus: skipped (Qobuz is not logged in)", + ] + .join("\n") + ); + + report.dry_run = true; + assert_eq!( + report.summary(), + "Playlist sync (dry run): 3 added, 1 removed, 2 unmatched" + ); + + report.links[1].note(LinkOutcome::Failed("the write was refused".to_string())); + assert!(report.failed()); + + report.error = Some("429 rate limited".to_string()); + assert_eq!(report.summary(), "Playlist sync failed: 429 rate limited"); + assert!(report.failed()); + } + + #[test] + fn a_link_filter_matches_the_id_exactly_and_the_name_case_insensitively() { + let link = Link { + id: "abc123".to_string(), + master: Endpoint { + source: Source::Spotify, + playlist_uri: "spotify:playlist:1".to_string(), + name: " Road Trip ".to_string(), + }, + mirrors: Vec::new(), + }; + + assert!(link.matches_filter("abc123")); + assert!(link.matches_filter(" abc123 ")); + assert!(link.matches_filter("road trip")); + assert!(link.matches_filter(" ROAD TRIP ")); + assert!(!link.matches_filter("ABC123")); + assert!(!link.matches_filter("Road")); + assert!(!link.matches_filter("")); + } +} diff --git a/src/core/playlist_sync/store.rs b/src/core/playlist_sync/store.rs new file mode 100644 index 00000000..df499d8a --- /dev/null +++ b/src/core/playlist_sync/store.rs @@ -0,0 +1,327 @@ +//! The playlist-sync state file: `playlist_sync.yml` in the app state dir. +//! +//! Whole-file load and save. A missing or blank file is an empty link list; a +//! malformed one is an error the caller reports, and the file is left exactly +//! as the user wrote it. The save writes a sibling tempfile and renames it, so +//! a crash mid-write cannot leave a half-written file behind. + +use super::{Endpoint, Link, Mirror}; +use anyhow::{anyhow, Context, Result}; +use rand::RngExt; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const FILE_NAME: &str = "playlist_sync.yml"; + +/// Environment override for the playlist-sync file location (used by tests, and +/// available to users who keep their state elsewhere). +pub const PATH_ENV: &str = "SPOTATUI_PLAYLIST_SYNC_PATH"; + +/// The schema this build writes, and the highest it can read. +fn file_version() -> u32 { + 1 +} + +/// The whole on-disk file. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlaylistSyncFile { + #[serde(default = "file_version")] + pub version: u32, + #[serde(default)] + pub links: Vec, +} + +impl Default for PlaylistSyncFile { + fn default() -> Self { + PlaylistSyncFile { + version: file_version(), + links: Vec::new(), + } + } +} + +impl PlaylistSyncFile { + /// Register `mirror` under `master`: on the link that already has this master, + /// else on a new link. Returns the link id. + pub fn link(&mut self, master: Endpoint, mirror: Endpoint) -> String { + if let Some(existing) = self.links.iter_mut().find(|link| { + link.master.source == master.source && link.master.playlist_uri == master.playlist_uri + }) { + existing.mirrors.push(Mirror::new(mirror)); + return existing.id.clone(); + } + let id = self.new_link_id(); + self.links.push(Link { + id: id.clone(), + master, + mirrors: vec![Mirror::new(mirror)], + }); + id + } + + /// Twelve lowercase base-36 characters no other link uses. + fn new_link_id(&self) -> String { + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + loop { + let id: String = (0..12) + .map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char) + .collect(); + if !self.links.iter().any(|link| link.id == id) { + return id; + } + } + } + + /// Drop the link with `id`; `false` when there is none. + pub fn remove_link(&mut self, id: &str) -> bool { + let before = self.links.len(); + self.links.retain(|link| link.id != id); + self.links.len() != before + } +} + +/// Location of the file: `$SPOTATUI_PLAYLIST_SYNC_PATH` when set, else +/// `/playlist_sync.yml`. +pub fn default_path() -> Result { + default_path_with( + std::env::var(PATH_ENV).ok(), + crate::core::paths::app_state_dir(), + ) +} + +/// [`default_path`] with the environment value and the state dir passed in. +fn default_path_with(env_value: Option, state_dir: Option) -> Result { + if let Some(path) = env_value.filter(|value| !value.trim().is_empty()) { + return Ok(PathBuf::from(path)); + } + state_dir + .map(|dir| dir.join(FILE_NAME)) + .ok_or_else(|| anyhow!("cannot resolve the spotatui state directory")) +} + +/// Load the file: missing or blank is an empty link list, malformed is an error. +pub fn load(path: &Path) -> Result { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(PlaylistSyncFile::default()), + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + }; + if contents.trim().is_empty() { + return Ok(PlaylistSyncFile::default()); + } + let file: PlaylistSyncFile = serde_yaml::from_str(&contents) + .with_context(|| format!("malformed playlist sync file: {}", path.display()))?; + if file.version > file_version() { + return Err(anyhow!( + "{} was written by a newer spotatui (file version {})", + path.display(), + file.version + )); + } + Ok(file) +} + +/// Save the whole file through a process-unique tempfile and a rename. +pub fn save(path: &Path, file: &PlaylistSyncFile) -> Result<()> { + let yaml = serde_yaml::to_string(file).context("serializing playlist sync links")?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + crate::core::auth::write_private_file_atomic(path, yaml.as_bytes()) + .with_context(|| format!("writing {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::playlist_sync::{Endpoint, Mirror, UnmatchReason, Unmatched}; + use crate::core::source::Source; + use std::collections::BTreeMap; + + fn endpoint(source: Source, uri: &str) -> Endpoint { + Endpoint { + source, + playlist_uri: uri.to_string(), + name: "Road Trip".to_string(), + } + } + + fn sample_link() -> Link { + let mut mirror = Mirror { + endpoint: Endpoint { + source: Source::Qobuz, + playlist_uri: "qobuz:playlist:99".to_string(), + name: "Road Trip".to_string(), + }, + matches: BTreeMap::new(), + unmatched: Vec::new(), + last_run: None, + }; + mirror.record_match("spotify:track:a", "1234"); + mirror.finish_run( + vec![ + Unmatched { + master_key: "spotify:track:b".to_string(), + title: "Creep".to_string(), + artist: "Radiohead".to_string(), + reason: UnmatchReason::NoCandidate, + }, + Unmatched { + master_key: "spotify:track:c".to_string(), + title: "Nude".to_string(), + artist: "Radiohead".to_string(), + reason: UnmatchReason::SearchFailed("429 rate limited".to_string()), + }, + ], + "2026-09-16T10:00:00Z".to_string(), + ); + Link { + id: "abc123".to_string(), + master: Endpoint { + source: Source::Spotify, + playlist_uri: "spotify:playlist:1".to_string(), + name: "Road Trip".to_string(), + }, + mirrors: vec![mirror], + } + } + + #[test] + fn missing_file_is_an_empty_link_list() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + assert_eq!(load(&path).unwrap(), PlaylistSyncFile::default()); + assert!(load(&path).unwrap().links.is_empty()); + } + + #[test] + fn a_blank_file_is_an_empty_link_list() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + std::fs::write(&path, " \n").unwrap(); + assert_eq!(load(&path).unwrap(), PlaylistSyncFile::default()); + } + + #[test] + fn save_then_load_round_trips_a_link_with_matches_and_unmatched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state").join(FILE_NAME); + let file = PlaylistSyncFile { + version: file_version(), + links: vec![sample_link()], + }; + + save(&path, &file).unwrap(); + assert_eq!(load(&path).unwrap(), file); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); + } + + #[test] + fn malformed_file_is_an_error_and_leaves_the_file_alone() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let broken = "links: [ this is not : valid"; + std::fs::write(&path, broken).unwrap(); + + assert!(load(&path).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), broken); + } + + #[test] + fn a_file_from_a_newer_version_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + std::fs::write(&path, "version: 99\nlinks: []\n").unwrap(); + assert!(load(&path).is_err()); + } + + #[test] + fn a_hand_written_file_without_a_version_loads_as_version_one() { + // A hand-edited file omits the version and every field a run fills in. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let yaml = r#" +links: + - id: abc + master: + source: Spotify + playlist_uri: "spotify:playlist:1" + name: Mine + mirrors: + - endpoint: + source: Qobuz + playlist_uri: "qobuz:playlist:9" + name: Mine + - id: def + master: + source: Subsonic + playlist_uri: "subsonic:playlist:7" + name: Other +"#; + std::fs::write(&path, yaml).unwrap(); + + let file = load(&path).unwrap(); + assert_eq!(file.version, file_version()); + let mirror = &file.links[0].mirrors[0]; + assert!(mirror.matches.is_empty()); + assert!(mirror.unmatched.is_empty()); + assert_eq!(mirror.last_run, None); + assert!(file.links[1].mirrors.is_empty()); + } + + #[test] + fn link_appends_a_mirror_to_the_existing_master_or_opens_a_new_link() { + let mut file = PlaylistSyncFile::default(); + let master = endpoint(Source::Spotify, "spotify:playlist:1"); + let other = endpoint(Source::Spotify, "spotify:playlist:2"); + let qobuz = endpoint(Source::Qobuz, "qobuz:playlist:9"); + let subsonic = endpoint(Source::Subsonic, "subsonic:playlist:7"); + + let first = file.link(master.clone(), qobuz); + let second = file.link(master, subsonic); + let third = file.link(other, endpoint(Source::Qobuz, "qobuz:playlist:8")); + + assert_eq!(first, second); + assert_ne!(first, third); + assert_eq!(first.len(), 12); + assert_eq!(third.len(), 12); + assert_eq!(file.links.len(), 2); + assert_eq!(file.links[0].mirrors.len(), 2); + assert_eq!(file.links[1].mirrors.len(), 1); + assert!(file.links[0].mirrors[0].matches.is_empty()); + assert_eq!(file.links[0].mirrors[0].last_run, None); + } + + #[test] + fn remove_link_drops_only_the_named_link() { + let mut file = PlaylistSyncFile { + version: file_version(), + links: vec![sample_link()], + }; + + assert!(!file.remove_link("no-such-link")); + assert_eq!(file.links.len(), 1); + assert!(file.remove_link("abc123")); + assert!(file.links.is_empty()); + } + + #[test] + fn the_env_override_wins_and_a_blank_one_falls_back_to_the_state_dir() { + let state = PathBuf::from("state-dir"); + + let overridden = default_path_with(Some("other/links.yml".to_string()), Some(state.clone())); + assert_eq!(overridden.unwrap(), PathBuf::from("other/links.yml")); + + let blank = default_path_with(Some(" ".to_string()), Some(state.clone())); + assert_eq!(blank.unwrap(), state.join(FILE_NAME)); + + let unset = default_path_with(None, Some(state.clone())); + assert_eq!(unset.unwrap(), state.join(FILE_NAME)); + + assert!(default_path_with(None, None).is_err()); + } +} diff --git a/src/core/plugin_api.rs b/src/core/plugin_api.rs index b534c9f4..5682d4fb 100644 --- a/src/core/plugin_api.rs +++ b/src/core/plugin_api.rs @@ -649,6 +649,7 @@ pub fn route_name(route: &crate::core::app::Route) -> String { RouteId::Friends => "friends", RouteId::LocalBrowser => "local_browser", RouteId::Stats => "stats", + RouteId::PlaylistSync => "playlist_sync", #[cfg(feature = "ai-dj")] RouteId::AiDj => "ai_dj", RouteId::PluginScreen(name) => return format!("plugin:{name}"), diff --git a/src/core/requirement.rs b/src/core/requirement.rs index 240dde96..15015c64 100644 --- a/src/core/requirement.rs +++ b/src/core/requirement.rs @@ -12,6 +12,7 @@ pub enum Capability { Search, PlaylistWrite, Like, + PlaylistSync, } impl Capability { @@ -20,6 +21,7 @@ impl Capability { Capability::Search => source.supports_search(), Capability::PlaylistWrite => source.supports_playlist_write(), Capability::Like => source.supports_like(), + Capability::PlaylistSync => source.supports_playlist_sync(), } } } @@ -160,6 +162,27 @@ mod tests { ); } + #[test] + fn playlist_sync_needs_a_session_on_spotify_and_a_capable_source_elsewhere() { + let sync = Requirement::Capability(Capability::PlaylistSync); + assert_eq!( + availability(sync, Source::Spotify, false), + Availability::NeedsSpotify + ); + assert_eq!( + availability(sync, Source::Spotify, true), + Availability::Available + ); + assert_eq!( + availability(sync, Source::Qobuz, false), + Availability::Available + ); + assert_eq!( + availability(sync, Source::Local, true), + Availability::NotForSource(Source::Local) + ); + } + #[test] fn a_source_requirement_is_the_scope_and_for_spotify_the_session_too() { let radio = Requirement::Source(Source::Radio); diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 61c107e9..235e2a6d 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -20,6 +20,7 @@ pub mod mpris; pub mod network; #[cfg(feature = "streaming")] pub mod player; +pub mod playlist_sync; #[cfg(feature = "qobuz")] pub mod qobuz; pub mod queue; diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index 173dd856..c3cc5e21 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -19,6 +19,7 @@ use crate::core::app::{App, PlaybackOwner, SPOTIFY_NOT_CONNECTED_STATUS}; use crate::core::auth; use crate::core::config::{ClientConfig, NCSPOT_CLIENT_ID}; use crate::core::plugin_api::{ShowInfo, TrackInfo}; +use crate::core::source::Source; use crate::infra::redirect_uri::{bind_callback_listener, serve_spotify_callback}; use anyhow::anyhow; use rspotify::model::{ @@ -292,6 +293,15 @@ pub enum IoEvent { /// Remove a video (bare id or `youtube:` URI) from a local YouTube playlist. #[cfg_attr(not(feature = "youtube"), allow(dead_code))] RemoveTrackFromYouTubePlaylist(String, String), + /// Run the configured playlist-sync links on a detached task. + RunPlaylistSync { + /// Search again for tracks the last run found no candidate for. + retry_unmatched: bool, + }, + /// Create a mirror of the first endpoint's playlist on the source, link it and sync it. + LinkPlaylist(crate::core::playlist_sync::Endpoint, Source), + /// Forget one playlist-sync link by id; the mirror playlists stay. + RemovePlaylistSyncLink(String), /// Start an in-TUI Spotify OAuth login: open the browser and spawn the callback /// server. Dispatched from the `d` source picker when Spotify is unconfigured. /// Runs without a Spotify session (bypasses the auth gate). @@ -568,6 +578,9 @@ impl Network { | IoEvent::DeleteYouTubePlaylist(_) | IoEvent::AddTrackToYouTubePlaylist(..) | IoEvent::RemoveTrackFromYouTubePlaylist(..) + | IoEvent::RunPlaylistSync { .. } + | IoEvent::LinkPlaylist(..) + | IoEvent::RemovePlaylistSyncLink(_) ) } @@ -1095,6 +1108,26 @@ impl Network { | IoEvent::DeleteYouTubePlaylist(_) | IoEvent::AddTrackToYouTubePlaylist(..) | IoEvent::RemoveTrackFromYouTubePlaylist(..) => {} + IoEvent::RunPlaylistSync { retry_unmatched } => { + crate::infra::playlist_sync::spawn_run( + self.spotify.clone(), + self.token_cache_path.clone(), + Arc::clone(&self.app), + retry_unmatched, + ); + } + IoEvent::LinkPlaylist(master, mirror) => { + crate::infra::playlist_sync::spawn_link( + self.spotify.clone(), + self.token_cache_path.clone(), + Arc::clone(&self.app), + master, + mirror, + ); + } + IoEvent::RemovePlaylistSyncLink(id) => { + crate::infra::playlist_sync::spawn_remove_link(Arc::clone(&self.app), id); + } }; { @@ -1966,6 +1999,32 @@ mod tests { } } + #[test] + fn the_playlist_sync_events_bypass_auth_and_are_neither_service_lane_nor_transport() { + let master = crate::core::playlist_sync::Endpoint { + source: Source::Spotify, + playlist_uri: "spotify:playlist:1".to_string(), + name: "Road Trip".to_string(), + }; + for event in [ + IoEvent::RunPlaylistSync { + retry_unmatched: true, + }, + IoEvent::LinkPlaylist(master, Source::Qobuz), + IoEvent::RemovePlaylistSyncLink("aaa".to_string()), + ] { + assert!(Network::event_bypasses_spotify_auth(&event)); + assert!( + !Network::runs_on_service_lane(&event), + "the service lane builds its `Network` with no Spotify client to hand the run" + ); + assert!( + !Network::event_is_transport(&event), + "a sync drives no sink, so it is never deferred or replayed" + ); + } + } + #[tokio::test] async fn pre_event_auth_failure_clears_loading_state() { let expired_token_without_refresh = Token { diff --git a/src/infra/network/requests.rs b/src/infra/network/requests.rs index 4284fca6..ed274367 100644 --- a/src/infra/network/requests.rs +++ b/src/infra/network/requests.rs @@ -625,7 +625,6 @@ pub fn is_rate_limited_error(e: &anyhow::Error) -> bool { text.contains("429") || text.contains("Too Many Requests") || text.contains("Too many requests") } -#[allow(dead_code)] pub fn is_transient_network_error(e: &anyhow::Error) -> bool { let text = e.to_string().to_lowercase(); text.contains("error sending request for url") diff --git a/src/infra/playlist_sync/mod.rs b/src/infra/playlist_sync/mod.rs new file mode 100644 index 00000000..76550bfd --- /dev/null +++ b/src/infra/playlist_sync/mod.rs @@ -0,0 +1,465 @@ +//! Cross-source playlist sync: opening a client per endpoint and running the +//! engine's plan against it. Compiled unconditionally; the Spotify arm is always +//! present and the other three follow their source's feature, so a slim build +//! reduces to Spotify-to-Spotify links. + +mod run; +mod spotify; +#[cfg(feature = "youtube")] +mod youtube; + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use rspotify::AuthCodePkceSpotify; +use tokio::sync::Mutex; + +use crate::core::app::App; +use crate::core::playlist_sync::{Endpoint, SyncReport, SyncTrack}; +use crate::core::source::Source; +use crate::infra::network::IoEvent; + +/// Candidates asked of a mirror's catalog for one master track. +#[cfg(any(feature = "qobuz", feature = "subsonic"))] +const CANDIDATE_LIMIT: u32 = 10; + +/// Everything a run needs from boot in order to open clients. +pub struct SyncContext { + spotify: Option, + token_cache_path: PathBuf, + app: Arc>, +} + +impl SyncContext { + /// The boot pieces a run opens its clients from. + pub fn new( + spotify: Option, + token_cache_path: PathBuf, + app: Arc>, + ) -> Self { + SyncContext { + spotify, + token_cache_path, + app, + } + } + + /// The `App` handle the run writes its status messages through. + pub(crate) fn app(&self) -> &Arc> { + &self.app + } +} + +/// One playlist as a source reports it: the tracks a sync can move, and the +/// items it never can, such as a local file or an episode. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct PlaylistRead { + pub tracks: Vec, + pub not_syncable: Vec, +} + +impl PlaylistRead { + /// The keys of the syncable tracks, in playlist order. + pub(crate) fn keys(&self) -> Vec { + self.tracks.iter().map(|track| track.key.clone()).collect() + } +} + +impl From> for PlaylistRead { + /// A source with nothing unsyncable; `not_syncable` stays empty. + fn from(tracks: Vec) -> Self { + PlaylistRead { + tracks, + not_syncable: Vec::new(), + } + } +} + +/// One playlist endpoint a run reads and writes, in [`SyncTrack`] terms. +pub(crate) trait SyncClient { + /// The URI of this user's own playlist named `name`, if one exists. + async fn find_playlist(&self, name: &str) -> Result>; + + /// A new, empty, private playlist named `name`; returns its URI on this source. + async fn create_playlist(&self, name: &str) -> Result; + + /// Every track of `playlist_uri`, split into what can sync and what cannot. + async fn read_playlist(&self, playlist_uri: &str) -> Result; + + /// The track on this source that is `target`, searched and picked here. + async fn resolve(&self, target: &SyncTrack) -> Result>; + + /// Append `tracks` to `playlist_uri`, in the order given. + async fn add(&self, playlist_uri: &str, tracks: &[SyncTrack]) -> Result<()>; + + /// Remove every occurrence of each key from `playlist_uri`. + async fn remove(&self, playlist_uri: &str, keys: &[String]) -> Result<()>; +} + +/// Opens one client per endpoint; the production implementor is [`SyncContext`]. +pub(crate) trait SyncClients { + type Client: SyncClient; + + /// A client for `endpoint`, or why this build or this session cannot open one. + async fn open(&self, endpoint: &Endpoint) -> Result; +} + +impl SyncClients for SyncContext { + type Client = SourceClient; + + async fn open(&self, endpoint: &Endpoint) -> Result { + for_source(endpoint, self).await + } +} + +/// The one client type a run uses; the three optional arms follow their source's feature. +pub(crate) enum SourceClient { + Spotify(Box), + #[cfg(feature = "qobuz")] + Qobuz(crate::infra::qobuz::QobuzSource), + #[cfg(feature = "subsonic")] + Subsonic(crate::infra::subsonic::SubsonicSource), + #[cfg(feature = "youtube")] + YouTube(youtube::YouTubeSyncClient), +} + +impl SyncClient for SourceClient { + async fn find_playlist(&self, name: &str) -> Result> { + match self { + SourceClient::Spotify(client) => client.find_playlist(name).await, + #[cfg(feature = "qobuz")] + SourceClient::Qobuz(source) => Ok(named_playlist( + &crate::core::source::MediaSource::playlists(source).await?, + "qobuz:playlist:", + name, + )), + #[cfg(feature = "subsonic")] + SourceClient::Subsonic(source) => Ok(named_playlist( + &crate::core::source::MediaSource::playlists(source).await?, + "subsonic:playlist:", + name, + )), + #[cfg(feature = "youtube")] + SourceClient::YouTube(client) => client.find_playlist(name).await, + } + } + + async fn create_playlist(&self, name: &str) -> Result { + match self { + SourceClient::Spotify(client) => client.create_playlist(name).await, + #[cfg(feature = "qobuz")] + SourceClient::Qobuz(source) => Ok(format!( + "qobuz:playlist:{}", + source.create_playlist(name).await? + )), + #[cfg(feature = "subsonic")] + SourceClient::Subsonic(source) => Ok(format!( + "subsonic:playlist:{}", + source.create_playlist(name).await? + )), + #[cfg(feature = "youtube")] + SourceClient::YouTube(client) => client.create_playlist(name).await, + } + } + + async fn read_playlist(&self, playlist_uri: &str) -> Result { + match self { + SourceClient::Spotify(client) => client.read_playlist(playlist_uri).await, + #[cfg(feature = "qobuz")] + SourceClient::Qobuz(source) => Ok(source.sync_playlist_tracks(playlist_uri).await?.into()), + #[cfg(feature = "subsonic")] + SourceClient::Subsonic(source) => Ok(source.sync_playlist_tracks(playlist_uri).await?.into()), + #[cfg(feature = "youtube")] + SourceClient::YouTube(client) => client.read_playlist(playlist_uri).await, + } + } + + async fn resolve(&self, target: &SyncTrack) -> Result> { + match self { + SourceClient::Spotify(client) => client.resolve(target).await, + #[cfg(feature = "qobuz")] + SourceClient::Qobuz(source) => { + let found = source + .sync_search(&catalog_query(target), CANDIDATE_LIMIT) + .await?; + Ok( + crate::core::playlist_sync::pick_candidate(target, &found) + .map(|index| found[index].clone()), + ) + } + #[cfg(feature = "subsonic")] + SourceClient::Subsonic(source) => { + let found = source + .sync_search(&catalog_query(target), CANDIDATE_LIMIT) + .await?; + Ok( + crate::core::playlist_sync::pick_candidate(target, &found) + .map(|index| found[index].clone()), + ) + } + #[cfg(feature = "youtube")] + SourceClient::YouTube(client) => client.resolve(target).await, + } + } + + async fn add(&self, playlist_uri: &str, tracks: &[SyncTrack]) -> Result<()> { + match self { + SourceClient::Spotify(client) => client.add(playlist_uri, tracks).await, + #[cfg(feature = "qobuz")] + SourceClient::Qobuz(source) => { + crate::core::source::PlaylistWriter::add_tracks(source, playlist_uri, &keys_of(tracks)) + .await + } + #[cfg(feature = "subsonic")] + SourceClient::Subsonic(source) => { + crate::core::source::PlaylistWriter::add_tracks(source, playlist_uri, &keys_of(tracks)) + .await + } + #[cfg(feature = "youtube")] + SourceClient::YouTube(client) => client.add(playlist_uri, tracks).await, + } + } + + async fn remove(&self, playlist_uri: &str, keys: &[String]) -> Result<()> { + match self { + SourceClient::Spotify(client) => client.remove(playlist_uri, keys).await, + #[cfg(feature = "qobuz")] + SourceClient::Qobuz(source) => { + crate::core::source::PlaylistWriter::remove_tracks(source, playlist_uri, keys).await + } + #[cfg(feature = "subsonic")] + SourceClient::Subsonic(source) => { + crate::core::source::PlaylistWriter::remove_tracks(source, playlist_uri, keys).await + } + #[cfg(feature = "youtube")] + SourceClient::YouTube(client) => client.remove(playlist_uri, keys).await, + } + } +} + +/// Whether two playlist names are the same, trimmed and ignoring case. +pub(crate) fn same_name(left: &str, right: &str) -> bool { + left.trim().eq_ignore_ascii_case(right.trim()) +} + +/// The first listed playlist under `prefix` named `name`, as a URI. +#[cfg(any(feature = "qobuz", feature = "subsonic"))] +fn named_playlist( + listing: &[crate::core::plugin_api::PlaylistInfo], + prefix: &str, + name: &str, +) -> Option { + listing + .iter() + .find(|playlist| playlist.uri.starts_with(prefix) && same_name(&playlist.name, name)) + .map(|playlist| playlist.uri.clone()) +} + +/// The query a catalog search uses for one master track. +#[cfg(any(feature = "qobuz", feature = "subsonic", feature = "youtube"))] +fn catalog_query(target: &SyncTrack) -> String { + format!( + "{} {}", + target.artist, + crate::core::playlist_sync::strip_title_suffix(&target.title) + ) + .trim() + .to_string() +} + +/// The source-native ids of `tracks`, which is what both playlist writers take. +#[cfg(any(feature = "qobuz", feature = "subsonic"))] +fn keys_of(tracks: &[SyncTrack]) -> Vec { + tracks.iter().map(|track| track.key.clone()).collect() +} + +/// The Cargo feature this build is missing to sync `source`, if any. +pub(crate) fn missing_sync_feature(source: Source) -> Option<&'static str> { + match source { + Source::Qobuz => (!cfg!(feature = "qobuz")).then_some("qobuz"), + Source::Subsonic => (!cfg!(feature = "subsonic")).then_some("subsonic"), + Source::YouTube => (!cfg!(feature = "youtube")).then_some("youtube"), + Source::Spotify | Source::Local | Source::Radio => None, + } +} + +/// A client for one endpoint, or why it cannot be opened now. +pub(crate) async fn for_source(endpoint: &Endpoint, ctx: &SyncContext) -> Result { + match endpoint.source { + Source::Spotify => ctx + .spotify + .clone() + .map(|spotify| { + SourceClient::Spotify(Box::new(spotify::SpotifyClient::new( + spotify, + ctx.token_cache_path.clone(), + Arc::clone(&ctx.app), + ))) + }) + .ok_or_else(|| anyhow!("Spotify is not connected")), + #[cfg(feature = "qobuz")] + Source::Qobuz => Ok(SourceClient::Qobuz( + crate::infra::qobuz::dispatch::build_sync_source(&ctx.app).await?, + )), + #[cfg(feature = "subsonic")] + Source::Subsonic => Ok(SourceClient::Subsonic( + crate::infra::subsonic::dispatch::build_sync_source(&ctx.app).await?, + )), + #[cfg(feature = "youtube")] + Source::YouTube => Ok(SourceClient::YouTube(youtube::YouTubeSyncClient::new( + crate::infra::youtube::dispatch::build_source(&ctx.app).await, + crate::infra::youtube::playlists::default_playlists_path()?, + Arc::clone(&ctx.app), + ))), + other => Err(anyhow!(match missing_sync_feature(other) { + Some(feature) => format!( + "{} is not compiled into this build (feature `{feature}`)", + other.label() + ), + None => format!("{} playlists cannot be synced", other.label()), + })), + } +} + +/// One whole run, including the in-flight guard, the status message and the +/// `App` bookkeeping. +pub async fn run_guarded( + ctx: SyncContext, + filter: Option, + dry_run: bool, + retry_unmatched: bool, +) -> SyncReport { + log::info!("playlist sync: run requested (filter={filter:?}, dry_run={dry_run})"); + if !ctx.app.lock().await.begin_playlist_sync() { + ctx + .app + .lock() + .await + .set_status_message("Playlist sync already running", 4); + return SyncReport { + error: Some("Playlist sync already running".to_string()), + dry_run, + ..Default::default() + }; + } + + let report = match crate::core::playlist_sync::store::default_path() { + Ok(path) => { + run::run_all( + &ctx, + ctx.app(), + &path, + filter.as_deref(), + dry_run, + retry_unmatched, + ) + .await + } + Err(e) => SyncReport { + error: Some(format!("{e:#}")), + dry_run, + ..Default::default() + }, + }; + + if !report.links.is_empty() || report.error.is_some() { + if report.failed() { + ctx + .app + .lock() + .await + .set_error_status_message(report.summary(), 10); + } else { + ctx.app.lock().await.set_status_message(report.summary(), 8); + } + } + ctx.app.lock().await.finish_playlist_sync(report.clone()); + report +} + +/// Start a run on a detached task, for the `IoEvent` handler. +pub fn spawn_run( + spotify: Option, + token_cache_path: PathBuf, + app: Arc>, + retry_unmatched: bool, +) { + let ctx = SyncContext::new(spotify, token_cache_path, app); + tokio::spawn(run_guarded(ctx, None, false, retry_unmatched)); +} + +/// Refresh the sidebar list of `source` after a playlist was created there. +fn refresh_playlists_event(source: Source) -> Option { + match source { + Source::Spotify => Some(IoEvent::GetPlaylists), + Source::Qobuz => Some(IoEvent::GetQobuzPlaylists), + Source::Subsonic => Some(IoEvent::GetSubsonicPlaylists), + // The YouTube client reloads the sidebar itself after every write. + Source::YouTube | Source::Local | Source::Radio => None, + } +} + +/// Create the mirror playlist, record the link, refresh the sidebar and sync that link. +pub async fn link_guarded(ctx: SyncContext, master: Endpoint, mirror: Source) { + let path = match crate::core::playlist_sync::store::default_path() { + Ok(path) => path, + Err(e) => { + ctx + .app + .lock() + .await + .set_error_status_message(format!("Playlist link failed: {e:#}"), 10); + return; + } + }; + let name = master.name.clone(); + let linked = run::link_mirror(&ctx, ctx.app(), &path, master, mirror).await; + match linked { + Ok((id, adopted)) => { + if adopted { + ctx.app.lock().await.set_status_message( + format!("Using the existing {} playlist \"{name}\"", mirror.label()), + 6, + ); + } else if let Some(event) = refresh_playlists_event(mirror) { + ctx.app.lock().await.dispatch(event); + } + run_guarded(ctx, Some(id), false, true).await; + } + Err(e) => { + ctx + .app + .lock() + .await + .set_error_status_message(format!("Playlist link failed: {e:#}"), 10); + } + } +} + +/// Start [`link_guarded`] on a detached task, for the `IoEvent` handler. +pub fn spawn_link( + spotify: Option, + token_cache_path: PathBuf, + app: Arc>, + master: Endpoint, + mirror: Source, +) { + let ctx = SyncContext::new(spotify, token_cache_path, app); + tokio::spawn(link_guarded(ctx, master, mirror)); +} + +/// Forget one link on a detached task, for the `IoEvent` handler. +pub fn spawn_remove_link(app: Arc>, id: String) { + tokio::spawn(async move { + let outcome = match crate::core::playlist_sync::store::default_path() { + Ok(path) => run::remove_link(&app, &path, &id).await, + Err(e) => Err(e), + }; + let mut app = app.lock().await; + match outcome { + Ok(()) => app.set_status_message("Playlist link removed; the mirror playlists stay", 6), + Err(e) => app.set_error_status_message(format!("Removing the link failed: {e:#}"), 10), + } + }); +} diff --git a/src/infra/playlist_sync/run.rs b/src/infra/playlist_sync/run.rs new file mode 100644 index 00000000..07a61118 --- /dev/null +++ b/src/infra/playlist_sync/run.rs @@ -0,0 +1,1552 @@ +//! One sync run: load the store, walk the links, and bring every mirror up to +//! date through the engine's plan. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{bail, Context, Result}; +use tokio::sync::Mutex; + +use super::{SyncClient, SyncClients}; +use crate::core::app::App; +use crate::core::playlist_sync::store; +use crate::core::playlist_sync::{ + pick_candidate, plan, Endpoint, Link, LinkOutcome, LinkReport, Mirror, MirrorRun, SyncReport, + SyncTrack, UnmatchReason, Unmatched, +}; +use crate::core::source::Source; +use crate::infra::network::requests; + +/// Mutable state one run threads through its links. +struct RunState<'a> { + app: &'a Arc>, + /// The store, for the checkpoint saves between batches. + path: PathBuf, + /// One RFC 3339 stamp for the whole run. + now: String, + dry_run: bool, + /// Whether tracks the last run found no candidate for are searched again. + retry_unmatched: bool, + /// Set when a rate limit stopped the run; no further link is started. + stop: Option, +} + +/// Every link in the store, or the one `filter` names. +pub(crate) async fn run_all( + clients: &C, + app: &Arc>, + path: &Path, + filter: Option<&str>, + dry_run: bool, + retry_unmatched: bool, +) -> SyncReport { + let mut report = SyncReport { + dry_run, + ..Default::default() + }; + + let loaded = { + let path = path.to_owned(); + tokio::task::spawn_blocking(move || store::load(&path)).await + }; + let mut file = match loaded { + Ok(Ok(file)) => file, + Ok(Err(e)) => { + report.error = Some(format!("{e:#}")); + return report; + } + Err(e) => { + report.error = Some(e.to_string()); + return report; + } + }; + app.lock().await.set_playlist_sync_links(file.links.clone()); + + let selected: Vec = match filter { + Some(filter) => file + .links + .iter() + .enumerate() + .filter(|(_, link)| link.matches_filter(filter)) + .map(|(index, _)| index) + .collect(), + None => (0..file.links.len()).collect(), + }; + if selected.is_empty() { + if let Some(filter) = filter { + report.error = Some(format!("no link matches \"{filter}\"")); + } + return report; + } + + log::info!( + "playlist sync: {} of {} links, dry_run={dry_run}", + selected.len(), + file.links.len() + ); + let mut state = RunState { + app, + path: path.to_owned(), + now: chrono::Utc::now().to_rfc3339(), + dry_run, + retry_unmatched, + stop: None, + }; + + for index in selected { + log::info!("playlist sync: link {} starts", file.links[index].id); + let entry = sync_link(clients, &mut state, &mut file.links[index]).await; + log::info!("playlist sync: {}", entry.line()); + report.links.push(entry); + + if !dry_run { + let synced = file.links[index].clone(); + let path = path.to_owned(); + let saved = tokio::task::spawn_blocking(move || save_synced_link(&path, synced)).await; + match saved { + Ok(Ok(links)) => app.lock().await.set_playlist_sync_links(links), + Ok(Err(e)) => { + report.error = Some(format!("{e:#}")); + break; + } + Err(e) => { + report.error = Some(e.to_string()); + break; + } + } + } + + if let Some(text) = &state.stop { + report.error = Some(text.clone()); + break; + } + } + + report +} + +/// Splice one synced link into a fresh load of the store, so a link added or +/// removed while the run was working survives; answers the links as saved. +fn save_synced_link(path: &Path, mut synced: Link) -> Result> { + let mut current = store::load(path)?; + let Some(slot) = current.links.iter_mut().find(|link| link.id == synced.id) else { + return Ok(current.links); + }; + for mirror in slot.mirrors.drain(..) { + if !synced + .mirrors + .iter() + .any(|known| known.endpoint.playlist_uri == mirror.endpoint.playlist_uri) + { + synced.mirrors.push(mirror); + } + } + *slot = synced; + store::save(path, ¤t)?; + Ok(current.links) +} + +/// Adopt the mirror source's playlist named after the master, or create one; store +/// the link, publish it, and answer the link id and whether a playlist was adopted. +pub(crate) async fn link_mirror( + clients: &C, + app: &Arc>, + path: &Path, + master: Endpoint, + mirror: Source, +) -> Result<(String, bool)> { + let client = clients + .open(&Endpoint { + source: mirror, + playlist_uri: String::new(), + name: master.name.clone(), + }) + .await?; + let (playlist_uri, adopted) = match client.find_playlist(&master.name).await? { + Some(uri) => (uri, true), + None => (client.create_playlist(&master.name).await?, false), + }; + log::info!( + "playlist sync: {} mirror {} {playlist_uri}", + mirror.label(), + if adopted { "adopted" } else { "created" } + ); + let endpoint = Endpoint { + source: mirror, + playlist_uri, + name: master.name.clone(), + }; + let owned = path.to_owned(); + let (id, links) = tokio::task::spawn_blocking(move || -> Result<(String, Vec)> { + let mut file = store::load(&owned)?; + let id = file.link(master, endpoint); + store::save(&owned, &file)?; + Ok((id, file.links)) + }) + .await + .context("playlist sync task failed")??; + app.lock().await.set_playlist_sync_links(links); + Ok((id, adopted)) +} + +/// Drop the link with `id` from the store and publish the rest. +pub(crate) async fn remove_link(app: &Arc>, path: &Path, id: &str) -> Result<()> { + let owned = path.to_owned(); + let id = id.to_string(); + let links = tokio::task::spawn_blocking(move || -> Result> { + let mut file = store::load(&owned)?; + if !file.remove_link(&id) { + bail!("no link with id {id}"); + } + store::save(&owned, &file)?; + Ok(file.links) + }) + .await + .context("playlist sync task failed")??; + app.lock().await.set_playlist_sync_links(links); + Ok(()) +} + +/// One link: read the master once, then bring every mirror up to date. +async fn sync_link( + clients: &C, + state: &mut RunState<'_>, + link: &mut Link, +) -> LinkReport { + let mut report = LinkReport::new(&link.id, &link.master.name); + if link.mirrors.is_empty() { + report.note(LinkOutcome::Skipped("no mirrors".to_string())); + return report; + } + + let client = match clients.open(&link.master).await { + Ok(client) => client, + Err(e) => { + report.note(LinkOutcome::Skipped(format!("{e:#}"))); + return report; + } + }; + let read = match client.read_playlist(&link.master.playlist_uri).await { + Ok(read) => read, + Err(e) => { + report.note(classify(link.master.source, &e, &mut state.stop)); + return report; + } + }; + + for index in 0..link.mirrors.len() { + let mirror_client = match clients.open(&link.mirrors[index].endpoint).await { + Ok(client) => client, + Err(e) => { + report.note(LinkOutcome::Skipped(format!("{e:#}"))); + continue; + } + }; + let run = sync_mirror( + &mirror_client, + state, + link, + index, + &read.tracks, + &read.not_syncable, + ) + .await; + report.absorb(run); + if state.stop.is_some() { + return report; + } + } + + report +} + +/// Resolves per progress line, per batch of adds, and per checkpoint save. +const BATCH: usize = 10; + +/// One mirror: plan, resolve in batches, write each batch as it lands, and +/// close the run on its match cache. `index` names the mirror inside `link`, +/// so a checkpoint can save the whole link between batches. +async fn sync_mirror( + client: &C, + state: &mut RunState<'_>, + link: &mut Link, + index: usize, + master: &[SyncTrack], + not_syncable: &[SyncTrack], +) -> MirrorRun { + let endpoint = link.mirrors[index].endpoint.clone(); + let uri = endpoint.playlist_uri.as_str(); + let read = match client.read_playlist(uri).await { + Ok(read) => read, + Err(e) => { + return MirrorRun { + outcome: classify(endpoint.source, &e, &mut state.stop), + ..Default::default() + } + } + }; + let mut on_mirror = read.keys(); + let mut updated = link.mirrors[index].matches.clone(); + let mut first = plan(master, &on_mirror, &updated); + let mut pending: Vec<(String, String)> = Vec::new(); + let mut resolved: BTreeMap = BTreeMap::new(); + + // Pair what the mirror already holds before any search is paid for. + let mut to_resolve = Vec::with_capacity(first.to_resolve.len()); + for track in std::mem::take(&mut first.to_resolve) { + match pick_candidate(&track, &read.tracks) { + Some(found) => { + let found = read.tracks[found].clone(); + pending.push((track.key.clone(), found.key.clone())); + updated.insert(track.key.clone(), found.key.clone()); + resolved.insert(found.key.clone(), found); + } + None => to_resolve.push(track), + } + } + first.to_resolve = to_resolve; + + let mut unmatched: Vec = not_syncable + .iter() + .map(|track| Unmatched::new(track, UnmatchReason::NotSyncable)) + .collect(); + if !state.retry_unmatched { + // A startup run keeps last time's "no candidate" verdicts; a manual run searches again. + let held: Vec = link.mirrors[index] + .unmatched + .iter() + .filter(|entry| entry.reason == UnmatchReason::NoCandidate) + .filter(|entry| { + first + .to_resolve + .iter() + .any(|track| track.key == entry.master_key) + }) + .cloned() + .collect(); + first + .to_resolve + .retain(|track| !held.iter().any(|entry| entry.master_key == track.key)); + unmatched.extend(held); + } + let mut added = 0usize; + + // Cached matches the mirror lost come back before any search is paid for. + if !state.dry_run { + if let Err(e) = flush_adds( + client, + uri, + master, + &mut on_mirror, + &updated, + &resolved, + &mut added, + ) + .await + { + return MirrorRun { + added, + unmatched: unmatched.len(), + outcome: classify(endpoint.source, &e, &mut state.stop), + ..Default::default() + }; + } + } + + let total = first.to_resolve.len(); + for (done, track) in (1..).zip(first.to_resolve.iter()) { + match client.resolve(track).await { + Ok(Some(found)) => { + pending.push((track.key.clone(), found.key.clone())); + updated.insert(track.key.clone(), found.key.clone()); + resolved.insert(found.key.clone(), found); + } + Ok(None) => unmatched.push(Unmatched::new(track, UnmatchReason::NoCandidate)), + Err(e) => { + if requests::is_rate_limited_error(&e) { + // Keep the searches this run paid for, so the next run resumes past them. + if !state.dry_run { + commit(&mut link.mirrors[index], &mut pending); + } + return MirrorRun { + added, + unmatched: unmatched.len(), + outcome: classify(endpoint.source, &e, &mut state.stop), + ..Default::default() + }; + } + unmatched.push(Unmatched::new( + track, + UnmatchReason::SearchFailed(format!("{e:#}")), + )); + } + } + if done % BATCH != 0 || done == total { + continue; + } + state.app.lock().await.set_status_message( + format!("Playlist sync: {} {done}/{total}", endpoint.name), + 8, + ); + if state.dry_run { + continue; + } + commit(&mut link.mirrors[index], &mut pending); + if let Err(e) = flush_adds( + client, + uri, + master, + &mut on_mirror, + &updated, + &resolved, + &mut added, + ) + .await + { + return MirrorRun { + added, + unmatched: unmatched.len(), + outcome: classify(endpoint.source, &e, &mut state.stop), + ..Default::default() + }; + } + checkpoint(state, link).await; + } + + if state.dry_run { + let writes = plan(master, &on_mirror, &updated); + let rows = add_rows(master, &writes.to_add, &resolved); + return MirrorRun { + added: rows.len(), + removed: writes.to_remove.len(), + unmatched: unmatched.len(), + outcome: LinkOutcome::Ran, + }; + } + + commit(&mut link.mirrors[index], &mut pending); + if let Err(e) = flush_adds( + client, + uri, + master, + &mut on_mirror, + &updated, + &resolved, + &mut added, + ) + .await + { + return MirrorRun { + added, + unmatched: unmatched.len(), + outcome: classify(endpoint.source, &e, &mut state.stop), + ..Default::default() + }; + } + let writes = plan(master, &on_mirror, &updated); + if !writes.to_remove.is_empty() { + if let Err(e) = client.remove(uri, &writes.to_remove).await { + return MirrorRun { + added, + unmatched: unmatched.len(), + outcome: classify(endpoint.source, &e, &mut state.stop), + ..Default::default() + }; + } + } + + let mirror = &mut link.mirrors[index]; + mirror.drop_stale(&writes.stale); + let count = unmatched.len(); + mirror.finish_run(unmatched, state.now.clone()); + + MirrorRun { + added, + removed: writes.to_remove.len(), + unmatched: count, + outcome: LinkOutcome::Ran, + } +} + +/// Send every planned add the mirror does not hold yet, in master order. +async fn flush_adds( + client: &C, + uri: &str, + master: &[SyncTrack], + on_mirror: &mut Vec, + matches: &BTreeMap, + resolved: &BTreeMap, + added: &mut usize, +) -> Result<()> { + let writes = plan(master, on_mirror, matches); + let rows = add_rows(master, &writes.to_add, resolved); + if rows.is_empty() { + return Ok(()); + } + client.add(uri, &rows).await?; + on_mirror.extend(rows.iter().map(|row| row.key.clone())); + *added += rows.len(); + Ok(()) +} + +/// Record the resolutions since the last commit on the mirror's match cache. +fn commit(mirror: &mut Mirror, pending: &mut Vec<(String, String)>) { + for (master_key, mirror_key) in pending.drain(..) { + mirror.record_match(&master_key, &mirror_key); + } +} + +/// Save the link as it stands and publish the links; a failure here is logged, +/// and the link's own save reports it. +async fn checkpoint(state: &RunState<'_>, link: &Link) { + let path = state.path.clone(); + let synced = link.clone(); + match tokio::task::spawn_blocking(move || save_synced_link(&path, synced)).await { + Ok(Ok(links)) => state.app.lock().await.set_playlist_sync_links(links), + Ok(Err(e)) => log::warn!("playlist sync: checkpoint failed: {e:#}"), + Err(e) => log::warn!("playlist sync: checkpoint failed: {e}"), + } +} + +/// The rows to send a mirror: this run's candidate when it resolved one, else +/// the master track under the mirror's key. +fn add_rows( + master: &[SyncTrack], + to_add: &[(String, String)], + resolved: &BTreeMap, +) -> Vec { + let mut by_key: BTreeMap<&str, &SyncTrack> = BTreeMap::new(); + for track in master { + by_key.entry(track.key.as_str()).or_insert(track); + } + + let mut rows = Vec::with_capacity(to_add.len()); + for (master_key, mirror_key) in to_add { + if let Some(found) = resolved.get(mirror_key) { + rows.push(found.clone()); + continue; + } + if let Some(track) = by_key.get(master_key.as_str()) { + rows.push(SyncTrack { + key: mirror_key.clone(), + ..(*track).clone() + }); + } + } + rows +} + +/// How a failed client call ends this mirror: a rate limit stops the run, an +/// unreachable source is a skip, anything else is a failure. +fn classify(source: Source, e: &anyhow::Error, stop: &mut Option) -> LinkOutcome { + if requests::is_rate_limited_error(e) { + let text = format!("{e:#}"); + *stop = Some(text.clone()); + return LinkOutcome::Failed(text); + } + if requests::is_transient_network_error(e) { + return LinkOutcome::Skipped(format!("{} unreachable: {e}", source.label())); + } + LinkOutcome::Failed(format!("{e:#}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::playlist_sync::Endpoint; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use crate::infra::playlist_sync::PlaylistRead; + use anyhow::{anyhow, Result}; + use std::path::PathBuf; + use tempfile::TempDir; + + /// Scripted reads and resolutions, recording every write. + #[derive(Default)] + struct FakeClient { + tracks: Vec, + not_syncable: Vec, + /// The mirror candidate per master key; a missing key resolves to nothing. + hits: BTreeMap, + read_error: Option, + /// The error `resolve` answers with, per master key. + search_errors: BTreeMap, + searched: std::sync::Mutex>, + added: std::sync::Mutex>>, + removed: std::sync::Mutex>>, + created: std::sync::Mutex>, + /// Playlists this source already has, by exact name. + existing: BTreeMap, + } + + impl FakeClient { + fn added_rows(&self) -> Vec> { + self.added.lock().unwrap().clone() + } + + fn added_keys(&self) -> Vec> { + self + .added_rows() + .iter() + .map(|rows| rows.iter().map(|row| row.key.clone()).collect()) + .collect() + } + + fn removed_keys(&self) -> Vec> { + self.removed.lock().unwrap().clone() + } + + fn searched_keys(&self) -> Vec { + self.searched.lock().unwrap().clone() + } + + fn created_names(&self) -> Vec { + self.created.lock().unwrap().clone() + } + } + + impl SyncClient for Arc { + async fn find_playlist(&self, name: &str) -> Result> { + Ok(self.existing.get(name).cloned()) + } + + async fn create_playlist(&self, name: &str) -> Result { + self.created.lock().unwrap().push(name.to_string()); + let count = self.created.lock().unwrap().len(); + Ok(format!("fake:playlist:{count}")) + } + + async fn read_playlist(&self, _playlist_uri: &str) -> Result { + if let Some(text) = &self.read_error { + return Err(anyhow!("{text}")); + } + Ok(PlaylistRead { + tracks: self.tracks.clone(), + not_syncable: self.not_syncable.clone(), + }) + } + + async fn resolve(&self, target: &SyncTrack) -> Result> { + self.searched.lock().unwrap().push(target.key.clone()); + if let Some(text) = self.search_errors.get(&target.key) { + return Err(anyhow!("{text}")); + } + Ok(self.hits.get(&target.key).cloned()) + } + + async fn add(&self, _playlist_uri: &str, tracks: &[SyncTrack]) -> Result<()> { + self.added.lock().unwrap().push(tracks.to_vec()); + Ok(()) + } + + async fn remove(&self, _playlist_uri: &str, keys: &[String]) -> Result<()> { + self.removed.lock().unwrap().push(keys.to_vec()); + Ok(()) + } + } + + /// Opens the client registered for an endpoint's playlist URI. + struct FakeClients { + by_uri: BTreeMap>, + } + + impl SyncClients for FakeClients { + type Client = Arc; + + async fn open(&self, endpoint: &Endpoint) -> Result> { + let label = endpoint.source.label(); + self + .by_uri + .get(&endpoint.playlist_uri) + .cloned() + .ok_or_else(|| anyhow!("{label} is not compiled into this build")) + } + } + + fn test_app() -> (Arc>, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + let app = App::new(tx, UserConfig::new(), None); + (Arc::new(Mutex::new(app)), rx) + } + + fn track(key: &str, title: &str) -> SyncTrack { + SyncTrack { + key: key.to_string(), + isrc: None, + title: title.to_string(), + artist: "Radiohead".to_string(), + duration_ms: Some(238_000), + } + } + + fn endpoint(source: Source, uri: &str, name: &str) -> Endpoint { + Endpoint { + source, + playlist_uri: uri.to_string(), + name: name.to_string(), + } + } + + fn mirror_at(uri: &str, matches: &[(&str, &str)]) -> Mirror { + Mirror { + endpoint: endpoint(Source::Qobuz, uri, "Mirror"), + matches: matches + .iter() + .map(|(master, mirror)| (master.to_string(), mirror.to_string())) + .collect(), + unmatched: Vec::new(), + last_run: None, + } + } + + fn link_at(id: &str, name: &str, master_uri: &str, mirrors: Vec) -> Link { + Link { + id: id.to_string(), + master: endpoint(Source::Spotify, master_uri, name), + mirrors, + } + } + + fn fake(tracks: Vec) -> FakeClient { + FakeClient { + tracks, + ..Default::default() + } + } + + fn hits(pairs: Vec<(&str, SyncTrack)>) -> BTreeMap { + pairs + .into_iter() + .map(|(master_key, found)| (master_key.to_string(), found)) + .collect() + } + + fn fake_clients(clients: Vec<(&str, FakeClient)>) -> FakeClients { + FakeClients { + by_uri: clients + .into_iter() + .map(|(uri, client)| (uri.to_string(), Arc::new(client))) + .collect(), + } + } + + fn seeded_store(dir: &TempDir, links: Vec) -> PathBuf { + let path = dir.path().join("playlist_sync.yml"); + let file = store::PlaylistSyncFile { + links, + ..Default::default() + }; + store::save(&path, &file).unwrap(); + path + } + + fn saved_mirror(path: &Path, link: usize, mirror: usize) -> Mirror { + store::load(path).unwrap().links[link].mirrors[mirror].clone() + } + + #[tokio::test] + async fn a_run_resolves_adds_and_removes_in_one_pass() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[("m_gone", "x_old")])], + )], + ); + let clients = fake_clients(vec![ + ( + "spotify:playlist:1", + fake(vec![track("m1", "Alpha"), track("m2", "Beta")]), + ), + ( + "qobuz:playlist:1", + FakeClient { + tracks: vec![track("foreign", "Theirs"), track("x_old", "Dropped")], + hits: hits(vec![ + ("m1", track("x1", "Alpha")), + ("m2", track("x2", "Beta")), + ]), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert_eq!(mirror.added_keys(), vec![vec!["x1", "x2"]]); + assert_eq!(mirror.removed_keys(), vec![vec!["x_old"]]); + assert_eq!( + (report.added(), report.removed(), report.unmatched()), + (2, 1, 0) + ); + assert_eq!( + report.summary(), + "Playlist sync: 2 added, 1 removed, 0 unmatched" + ); + assert_eq!( + saved_mirror(&path, 0, 0).matches, + BTreeMap::from([ + ("m1".to_string(), "x1".to_string()), + ("m2".to_string(), "x2".to_string()), + ]) + ); + assert_eq!(app.lock().await.playlist_sync_links().len(), 1); + } + + #[tokio::test] + async fn additions_keep_master_order_and_skip_keys_already_on_the_mirror() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + )], + ); + let clients = fake_clients(vec![ + ( + "spotify:playlist:1", + fake(vec![ + track("m3", "Third"), + track("m1", "First"), + track("m2", "Second"), + ]), + ), + ( + "qobuz:playlist:1", + FakeClient { + tracks: vec![track("x1", "First")], + hits: hits(vec![ + ("m3", track("x3", "Third")), + ("m1", track("x1", "First")), + ("m2", track("x2", "Second")), + ]), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert_eq!(mirror.added_keys(), vec![vec!["x3", "x2"]]); + assert!(mirror.removed_keys().is_empty()); + assert_eq!(report.added(), 2); + } + + #[tokio::test] + async fn a_track_the_mirror_already_holds_is_paired_and_never_added_twice() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + )], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(vec![track("m1", "Alpha")])), + ( + "qobuz:playlist:1", + FakeClient { + tracks: vec![track("x1", "Alpha")], + hits: hits(vec![("m1", track("x1", "Alpha"))]), + ..Default::default() + }, + ), + ]); + + let first = run_all(&clients, &app, &path, None, false, true).await; + let second = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert!(mirror.added_keys().is_empty()); + assert!(mirror.searched_keys().is_empty()); + assert_eq!((first.added(), second.added()), (0, 0)); + assert_eq!( + saved_mirror(&path, 0, 0).matches, + BTreeMap::from([("m1".to_string(), "x1".to_string())]) + ); + } + + #[tokio::test] + async fn a_cached_match_absent_from_the_mirror_is_added_from_the_master_metadata() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[("m1", "x1")])], + )], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(vec![track("m1", "Alpha")])), + ("qobuz:playlist:1", fake(Vec::new())), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + let rows = mirror.added_rows(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0][0].key, "x1"); + assert_eq!(rows[0][0].title, "Alpha"); + assert!(mirror.searched_keys().is_empty()); + assert_eq!(report.added(), 1); + } + + #[tokio::test] + async fn a_removal_is_dropped_when_another_master_track_still_wants_that_key() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[("m_old", "x1")])], + )], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(vec![track("m_new", "Alpha")])), + ( + "qobuz:playlist:1", + FakeClient { + tracks: vec![track("x1", "Alpha")], + hits: hits(vec![("m_new", track("x1", "Alpha"))]), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert!(mirror.added_keys().is_empty()); + assert!(mirror.removed_keys().is_empty()); + assert_eq!( + saved_mirror(&path, 0, 0).matches, + BTreeMap::from([("m_new".to_string(), "x1".to_string())]) + ); + assert_eq!((report.added(), report.removed()), (0, 0)); + } + + #[tokio::test] + async fn a_dry_run_writes_nothing_to_the_mirror_or_the_file() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[("m_gone", "x_old")])], + )], + ); + let clients = fake_clients(vec![ + ( + "spotify:playlist:1", + fake(vec![track("m1", "Alpha"), track("m2", "Beta")]), + ), + ( + "qobuz:playlist:1", + FakeClient { + tracks: vec![track("x_old", "Dropped")], + hits: hits(vec![ + ("m1", track("x1", "Alpha")), + ("m2", track("x2", "Beta")), + ]), + ..Default::default() + }, + ), + ]); + let before = std::fs::read_to_string(&path).unwrap(); + + let report = run_all(&clients, &app, &path, None, true, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert_eq!(std::fs::read_to_string(&path).unwrap(), before); + assert!(mirror.added_keys().is_empty()); + assert!(mirror.removed_keys().is_empty()); + assert_eq!((report.added(), report.removed()), (2, 1)); + assert_eq!( + report.summary(), + "Playlist sync (dry run): 2 added, 1 removed, 0 unmatched" + ); + } + + #[tokio::test] + async fn a_failed_mirror_fails_its_link_but_keeps_what_its_sibling_resolved() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![ + mirror_at("qobuz:playlist:1", &[]), + mirror_at("qobuz:playlist:2", &[]), + ], + )], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(vec![track("m1", "Alpha")])), + ( + "qobuz:playlist:1", + FakeClient { + hits: hits(vec![("m1", track("x1", "Alpha"))]), + ..Default::default() + }, + ), + ( + "qobuz:playlist:2", + FakeClient { + read_error: Some("the mirror said no".to_string()), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + assert_eq!( + saved_mirror(&path, 0, 0).matches, + BTreeMap::from([("m1".to_string(), "x1".to_string())]) + ); + assert!(saved_mirror(&path, 0, 1).matches.is_empty()); + assert_eq!(report.links[0].added, 1); + assert!(matches!(report.links[0].outcome, LinkOutcome::Failed(_))); + assert!(report.failed()); + } + + #[tokio::test] + async fn a_rate_limit_stops_the_run_and_is_reported() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![ + link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + ), + link_at( + "bbb", + "Dinner", + "spotify:playlist:2", + vec![mirror_at("qobuz:playlist:2", &[])], + ), + ], + ); + let clients = fake_clients(vec![ + ( + "spotify:playlist:1", + fake(vec![track("m0", "Zero"), track("m1", "Alpha")]), + ), + ( + "qobuz:playlist:1", + FakeClient { + hits: hits(vec![("m0", track("x0", "Zero"))]), + search_errors: [("m1".to_string(), "429 Too Many Requests".to_string())] + .into_iter() + .collect(), + ..Default::default() + }, + ), + ("spotify:playlist:2", fake(vec![track("m2", "Beta")])), + ( + "qobuz:playlist:2", + FakeClient { + hits: hits(vec![("m2", track("x2", "Beta"))]), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + assert_eq!(report.links.len(), 1); + assert!(report + .error + .as_deref() + .is_some_and(|text| text.contains("429"))); + assert!(report.failed()); + assert!(clients.by_uri["qobuz:playlist:2"].added_keys().is_empty()); + assert_eq!( + saved_mirror(&path, 0, 0).matches, + BTreeMap::from([("m0".to_string(), "x0".to_string())]) + ); + } + + #[tokio::test] + async fn tracks_already_on_the_mirror_are_paired_without_a_search() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + )], + ); + let clients = fake_clients(vec![ + ( + "spotify:playlist:1", + fake(vec![track("m1", "Alpha"), track("m2", "Beta")]), + ), + ( + "qobuz:playlist:1", + FakeClient { + tracks: vec![track("x1", "Alpha")], + hits: hits(vec![("m2", track("x2", "Beta"))]), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert_eq!(mirror.searched_keys(), vec!["m2"]); + assert_eq!(mirror.added_keys(), vec![vec!["x2"]]); + assert_eq!(report.added(), 1); + assert_eq!( + saved_mirror(&path, 0, 0).matches, + BTreeMap::from([ + ("m1".to_string(), "x1".to_string()), + ("m2".to_string(), "x2".to_string()), + ]) + ); + } + + #[tokio::test] + async fn a_startup_run_keeps_a_no_candidate_verdict_and_a_manual_run_searches_again() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let mut mirror = mirror_at("qobuz:playlist:1", &[]); + mirror.finish_run( + vec![Unmatched::new( + &track("m1", "Alpha"), + UnmatchReason::NoCandidate, + )], + "2026-09-17T10:00:00Z".to_string(), + ); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror], + )], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(vec![track("m1", "Alpha")])), + ( + "qobuz:playlist:1", + FakeClient { + hits: hits(vec![("m1", track("x1", "Alpha"))]), + ..Default::default() + }, + ), + ]); + + let startup = run_all(&clients, &app, &path, None, false, false).await; + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert!(mirror.searched_keys().is_empty()); + assert!(mirror.added_keys().is_empty()); + assert_eq!(startup.unmatched(), 1); + assert_eq!(saved_mirror(&path, 0, 0).unmatched.len(), 1); + + let manual = run_all(&clients, &app, &path, None, false, true).await; + assert_eq!(mirror.searched_keys(), vec!["m1"]); + assert_eq!(mirror.added_keys(), vec![vec!["x1"]]); + assert_eq!(manual.unmatched(), 0); + assert!(saved_mirror(&path, 0, 0).unmatched.is_empty()); + } + + #[tokio::test] + async fn adds_land_in_batches_and_survive_a_rate_limit_after_two_of_them() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + )], + ); + let master: Vec = (1..=25) + .map(|n| track(&format!("m{n}"), &format!("Song {n}"))) + .collect(); + let found: Vec<(String, SyncTrack)> = (1..=25) + .map(|n| { + ( + format!("m{n}"), + track(&format!("x{n}"), &format!("Song {n}")), + ) + }) + .collect(); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(master)), + ( + "qobuz:playlist:1", + FakeClient { + hits: found.into_iter().collect(), + search_errors: [("m22".to_string(), "429 Too Many Requests".to_string())] + .into_iter() + .collect(), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + let batches: Vec = mirror.added_keys().iter().map(Vec::len).collect(); + assert_eq!(batches, vec![10, 10]); + assert_eq!(mirror.added_keys()[1][0], "x11"); + assert_eq!(report.links[0].added, 20); + assert!(report.failed()); + assert_eq!(saved_mirror(&path, 0, 0).matches.len(), 21); + } + + #[test] + fn the_save_keeps_a_link_removed_and_a_mirror_added_while_the_run_worked() { + let dir = tempfile::tempdir().unwrap(); + let path = seeded_store( + &dir, + vec![ + link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + ), + link_at("bbb", "Dinner", "spotify:playlist:2", Vec::new()), + ], + ); + let mut synced = store::load(&path).unwrap().links[0].clone(); + synced.mirrors[0].record_match("m1", "x1"); + + let mut meanwhile = store::load(&path).unwrap(); + meanwhile.remove_link("bbb"); + meanwhile.links[0] + .mirrors + .push(mirror_at("qobuz:playlist:9", &[])); + store::save(&path, &meanwhile).unwrap(); + + let links = save_synced_link(&path, synced).unwrap(); + + assert_eq!(links.len(), 1); + assert_eq!(links[0].mirrors.len(), 2); + assert_eq!( + saved_mirror(&path, 0, 0) + .matches + .get("m1") + .map(String::as_str), + Some("x1") + ); + assert_eq!( + saved_mirror(&path, 0, 1).endpoint.playlist_uri, + "qobuz:playlist:9" + ); + + let gone = link_at("bbb", "Dinner", "spotify:playlist:2", Vec::new()); + assert_eq!(save_synced_link(&path, gone).unwrap().len(), 1); + } + + #[tokio::test] + async fn an_unopenable_master_and_an_unreachable_mirror_are_skipped_not_failed() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let mut orphan = link_at( + "aaa", + "Orphan", + "qobuz:playlist:missing", + vec![mirror_at("qobuz:playlist:3", &[])], + ); + orphan.master.source = Source::Qobuz; + let path = seeded_store( + &dir, + vec![ + orphan, + link_at( + "bbb", + "Road Trip", + "spotify:playlist:2", + vec![ + mirror_at("qobuz:playlist:2", &[]), + mirror_at("qobuz:playlist:3", &[]), + ], + ), + ], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:2", fake(vec![track("m1", "Alpha")])), + ( + "qobuz:playlist:2", + FakeClient { + read_error: Some("error sending request for url (https://mirror.example)".to_string()), + ..Default::default() + }, + ), + ( + "qobuz:playlist:3", + FakeClient { + hits: hits(vec![("m1", track("x1", "Alpha"))]), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + assert_eq!(report.links.len(), 2); + let LinkOutcome::Skipped(missing) = &report.links[0].outcome else { + panic!("a master that cannot be opened skips its link"); + }; + assert!(missing.contains("not compiled into this build")); + let LinkOutcome::Skipped(unreachable) = &report.links[1].outcome else { + panic!("an unreachable mirror skips its link, never fails it"); + }; + assert!(unreachable.contains("unreachable")); + assert_eq!(report.links[1].added, 1); + assert!(!report.failed()); + } + + #[tokio::test] + async fn a_link_filter_runs_only_the_link_it_names() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![ + link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + ), + link_at( + "bbb", + "Dinner", + "spotify:playlist:2", + vec![mirror_at("qobuz:playlist:2", &[])], + ), + ], + ); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(Vec::new())), + ("qobuz:playlist:1", fake(Vec::new())), + ("spotify:playlist:2", fake(Vec::new())), + ("qobuz:playlist:2", fake(Vec::new())), + ]); + + let by_name = run_all(&clients, &app, &path, Some(" road TRIP "), false, true).await; + assert_eq!(by_name.links.len(), 1); + assert_eq!(by_name.links[0].id, "aaa"); + + let by_id = run_all(&clients, &app, &path, Some("bbb"), false, true).await; + assert_eq!(by_id.links.len(), 1); + assert_eq!(by_id.links[0].name, "Dinner"); + + let missing = run_all(&clients, &app, &path, Some("nope"), false, true).await; + assert!(missing.links.is_empty()); + assert_eq!(missing.error.as_deref(), Some("no link matches \"nope\"")); + } + + #[tokio::test] + async fn a_not_syncable_track_a_missing_candidate_and_a_failed_search_are_all_unmatched() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + )], + ); + let clients = fake_clients(vec![ + ( + "spotify:playlist:1", + FakeClient { + tracks: vec![track("m1", "Alpha"), track("m2", "Beta")], + not_syncable: vec![track("spotify:local:ns", "A local file")], + ..Default::default() + }, + ), + ( + "qobuz:playlist:1", + FakeClient { + search_errors: [("m2".to_string(), "boom".to_string())] + .into_iter() + .collect(), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert_eq!(mirror.searched_keys(), vec!["m1", "m2"]); + let saved = saved_mirror(&path, 0, 0); + let reasons: Vec = saved + .unmatched + .iter() + .map(|entry| entry.reason.clone()) + .collect(); + assert_eq!( + reasons, + vec![ + UnmatchReason::NotSyncable, + UnmatchReason::NoCandidate, + UnmatchReason::SearchFailed("boom".to_string()), + ] + ); + assert_eq!(report.unmatched(), 3); + } + + #[tokio::test] + async fn linking_creates_the_mirror_playlist_stores_the_link_and_publishes_it() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store(&dir, Vec::new()); + let clients = fake_clients(vec![("", FakeClient::default())]); + + let (id, adopted) = link_mirror( + &clients, + &app, + &path, + endpoint(Source::Spotify, "spotify:playlist:1", "Road Trip"), + Source::Qobuz, + ) + .await + .unwrap(); + + assert!(!adopted); + assert_eq!(id.len(), 12); + assert_eq!(clients.by_uri[""].created_names(), vec!["Road Trip"]); + let file = store::load(&path).unwrap(); + assert_eq!(file.links.len(), 1); + assert_eq!(file.links[0].id, id); + assert_eq!(file.links[0].master.playlist_uri, "spotify:playlist:1"); + assert_eq!( + file.links[0].mirrors[0].endpoint, + endpoint(Source::Qobuz, "fake:playlist:1", "Road Trip") + ); + assert_eq!(app.lock().await.playlist_sync_links().len(), 1); + } + + #[tokio::test] + async fn linking_adopts_an_existing_playlist_with_the_masters_name() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store(&dir, Vec::new()); + let clients = fake_clients(vec![( + "", + FakeClient { + existing: BTreeMap::from([("Road Trip".to_string(), "fake:playlist:kept".to_string())]), + ..Default::default() + }, + )]); + + let (_, adopted) = link_mirror( + &clients, + &app, + &path, + endpoint(Source::Spotify, "spotify:playlist:1", "Road Trip"), + Source::Qobuz, + ) + .await + .unwrap(); + + assert!(adopted); + assert!(clients.by_uri[""].created_names().is_empty()); + let file = store::load(&path).unwrap(); + assert_eq!( + file.links[0].mirrors[0].endpoint.playlist_uri, + "fake:playlist:kept" + ); + } + + #[tokio::test] + async fn linking_the_same_master_twice_appends_a_second_mirror() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store(&dir, Vec::new()); + let clients = fake_clients(vec![("", FakeClient::default())]); + let master = endpoint(Source::Spotify, "spotify:playlist:1", "Road Trip"); + + let (first, _) = link_mirror(&clients, &app, &path, master.clone(), Source::Qobuz) + .await + .unwrap(); + let (second, _) = link_mirror(&clients, &app, &path, master, Source::Subsonic) + .await + .unwrap(); + + assert_eq!(first, second); + let file = store::load(&path).unwrap(); + assert_eq!(file.links.len(), 1); + let uris: Vec<&str> = file.links[0] + .mirrors + .iter() + .map(|mirror| mirror.endpoint.playlist_uri.as_str()) + .collect(); + assert_eq!(uris, ["fake:playlist:1", "fake:playlist:2"]); + } + + #[tokio::test] + async fn removing_a_link_forgets_it_and_an_unknown_id_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![ + link_at("aaa", "Road Trip", "spotify:playlist:1", Vec::new()), + link_at("bbb", "Dinner", "spotify:playlist:2", Vec::new()), + ], + ); + + remove_link(&app, &path, "aaa").await.unwrap(); + + let ids: Vec = store::load(&path) + .unwrap() + .links + .iter() + .map(|link| link.id.clone()) + .collect(); + assert_eq!(ids, ["bbb"]); + assert_eq!(app.lock().await.playlist_sync_links().len(), 1); + assert!(remove_link(&app, &path, "aaa").await.is_err()); + } +} diff --git a/src/infra/playlist_sync/spotify.rs b/src/infra/playlist_sync/spotify.rs new file mode 100644 index 00000000..21565dfc --- /dev/null +++ b/src/infra/playlist_sync/spotify.rs @@ -0,0 +1,675 @@ +use super::{PlaylistRead, SyncClient}; +use crate::core::app::App; +use crate::core::playlist_sync::{normalize_isrc, pick_candidate, SyncTrack}; +use crate::infra::network::requests::spotify_api_request_json_for_with_refresh; +use crate::infra::network::search::SPOTIFY_SEARCH_LIMIT; +use anyhow::{anyhow, Result}; +use reqwest::Method; +use rspotify::AuthCodePkceSpotify; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Everything the matcher needs from one playlist item and nothing else, asked +/// for under both spellings so either side of Spotify's `track` rename parses. +const ITEM_FIELDS: &str = "items(item(id,uri,type,is_local,name,duration_ms,artists(name),external_ids(isrc)),track(id,uri,type,is_local,name,duration_ms,artists(name),external_ids(isrc))),next"; +/// The library endpoints still take 50 under a Development Mode app. +const PAGE_LIMIT: u32 = 50; +/// URIs per playlist write call. +const WRITE_CHUNK: usize = 100; +/// Hard cap on one playlist read. +const MAX_ITEMS: usize = 10_000; + +/// Spotify playlist reads, searches and writes through the shared paced helper. +pub(crate) struct SpotifyClient { + spotify: AuthCodePkceSpotify, + token_cache_path: PathBuf, + app: Arc>, +} + +impl SpotifyClient { + pub(crate) fn new( + spotify: AuthCodePkceSpotify, + token_cache_path: PathBuf, + app: Arc>, + ) -> Self { + SpotifyClient { + spotify, + token_cache_path, + app, + } + } + + /// One `search` call as candidates, capped by the endpoint's own ceiling. + async fn search(&self, q: &str, limit: u32) -> Result> { + let params = [ + ("q", q.to_string()), + ("type", "track".to_string()), + ("limit", limit.min(SPOTIFY_SEARCH_LIMIT).to_string()), + ("market", "from_token".to_string()), + ]; + let value = spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::GET, + "search", + ¶ms, + None, + &self.token_cache_path, + &self.app, + ) + .await?; + Ok(search_candidates(value)) + } + + /// The ISRC query's hit, with a failed or empty query reading as no hit. + async fn resolve_by_isrc(&self, target: &SyncTrack) -> Option { + let isrc = normalize_isrc(target.isrc.as_deref()?); + if isrc.is_empty() { + return None; + } + let found = self.search(&isrc_query(&isrc), 5).await.ok()?; + let index = pick_candidate(target, &found)?; + Some(found[index].clone()) + } +} + +impl SyncClient for SpotifyClient { + async fn find_playlist(&self, name: &str) -> Result> { + let me = spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::GET, + "me", + &[], + None, + &self.token_cache_path, + &self.app, + ) + .await?; + let me = me + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let mut offset: u32 = 0; + loop { + let params = [ + ("fields", "items(id,name,owner(id)),next".to_string()), + ("limit", PAGE_LIMIT.to_string()), + ("offset", offset.to_string()), + ]; + let value = spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::GET, + "me/playlists", + ¶ms, + None, + &self.token_cache_path, + &self.app, + ) + .await?; + let (hit, count, has_next) = owned_playlist_on_page(&value, name, &me); + if hit.is_some() { + return Ok(hit); + } + offset = offset.saturating_add(PAGE_LIMIT); + if is_last_page(count, has_next) || offset as usize >= MAX_ITEMS { + return Ok(None); + } + } + } + + async fn create_playlist(&self, name: &str) -> Result { + let value = spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::POST, + "me/playlists", + &[], + Some(create_body(name)), + &self.token_cache_path, + &self.app, + ) + .await?; + created_playlist_uri(value) + } + + async fn read_playlist(&self, playlist_uri: &str) -> Result { + let path = format!("playlists/{}/items", playlist_id_of(playlist_uri)); + let mut out = PlaylistRead::default(); + let mut offset: u32 = 0; + loop { + let params = [ + ("fields", ITEM_FIELDS.to_string()), + ("limit", PAGE_LIMIT.to_string()), + ("offset", offset.to_string()), + ]; + log::info!("playlist sync: reading Spotify playlist page at offset {offset}"); + let value = spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::GET, + &path, + ¶ms, + None, + &self.token_cache_path, + &self.app, + ) + .await?; + let page = read_items_page(value); + if page.unrecognized > 0 { + return Err(anyhow!( + "Spotify playlist items came back in an unknown shape; nothing was changed" + )); + } + out.tracks.extend(page.read.tracks); + out.not_syncable.extend(page.read.not_syncable); + offset = offset.saturating_add(PAGE_LIMIT); + if is_last_page(page.count, page.has_next) || offset as usize >= MAX_ITEMS { + return Ok(out); + } + } + } + + async fn resolve(&self, target: &SyncTrack) -> Result> { + if let Some(found) = self.resolve_by_isrc(target).await { + return Ok(Some(found)); + } + let found = self + .search(&title_query(target), SPOTIFY_SEARCH_LIMIT) + .await?; + Ok(pick_candidate(target, &found).map(|index| found[index].clone())) + } + + async fn add(&self, playlist_uri: &str, tracks: &[SyncTrack]) -> Result<()> { + let path = format!("playlists/{}/items", playlist_id_of(playlist_uri)); + for chunk in tracks.chunks(WRITE_CHUNK) { + spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::POST, + &path, + &[], + Some(add_body(chunk)), + &self.token_cache_path, + &self.app, + ) + .await?; + } + Ok(()) + } + + async fn remove(&self, playlist_uri: &str, keys: &[String]) -> Result<()> { + let path = format!("playlists/{}/items", playlist_id_of(playlist_uri)); + for chunk in keys.chunks(WRITE_CHUNK) { + spotify_api_request_json_for_with_refresh( + &self.spotify, + Method::DELETE, + &path, + &[], + Some(remove_body(chunk)), + &self.token_cache_path, + &self.app, + ) + .await?; + } + Ok(()) + } +} + +/// One `items` page, shaped by the fields mask; entries stay raw so a null one is skipped. +#[derive(Debug, Default, Deserialize)] +struct ItemsPage { + #[serde(default)] + items: Vec, + #[serde(default)] + next: Option, +} + +#[derive(Debug, Deserialize)] +struct ItemEnvelope { + #[serde(default, alias = "track")] + item: Option, +} + +/// One parsed `items` page. +#[derive(Debug, Default)] +struct ItemsRead { + read: PlaylistRead, + /// Raw entries on the page, dead ones included. + count: usize, + /// Entries that carry neither `item` nor `track`: a shape the sync must not trust. + unrecognized: usize, + has_next: bool, +} + +/// Stop paging when a page is empty, or when it is short and Spotify names no next page. +fn is_last_page(count: usize, has_next: bool) -> bool { + count == 0 || (!has_next && count < PAGE_LIMIT as usize) +} + +/// A playlist item's track or a search result, every field optional. +#[derive(Debug, Deserialize)] +struct RawTrack { + #[serde(default)] + id: Option, + #[serde(default)] + uri: Option, + #[serde(rename = "type", default)] + kind: Option, + #[serde(default)] + is_local: bool, + #[serde(default)] + name: String, + #[serde(default)] + duration_ms: Option, + #[serde(default)] + artists: Vec, + #[serde(default)] + external_ids: RawExternalIds, +} + +#[derive(Debug, Deserialize)] +struct RawArtist { + #[serde(default)] + name: String, +} + +#[derive(Debug, Default, Deserialize)] +struct RawExternalIds { + #[serde(default)] + isrc: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct SearchPage { + #[serde(default)] + tracks: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawTracks { + #[serde(default)] + items: Vec, +} + +/// The bare base62 id of a `spotify:playlist:` URI, or the value as given. +fn playlist_id_of(uri: &str) -> &str { + uri.strip_prefix("spotify:playlist:").unwrap_or(uri) +} + +/// One raw item as the sync currency, under the key the caller can address it with. +fn sync_track_of(raw: &RawTrack, key: String) -> SyncTrack { + SyncTrack { + key, + isrc: raw + .external_ids + .isrc + .as_ref() + .filter(|isrc| !isrc.trim().is_empty()) + .cloned(), + title: raw.name.clone(), + artist: raw + .artists + .first() + .map(|artist| artist.name.clone()) + .unwrap_or_default(), + duration_ms: raw.duration_ms.filter(|ms| *ms > 0), + } +} + +/// One `items` page: its tracks, its unsyncable items, and how the page ended. +fn read_items_page(page: Value) -> ItemsRead { + let page: ItemsPage = serde_json::from_value(page).unwrap_or_default(); + let mut out = ItemsRead { + count: page.items.len(), + has_next: page.next.is_some(), + ..Default::default() + }; + for entry in page.items { + if let Some(object) = entry.as_object() { + if !object.contains_key("item") && !object.contains_key("track") { + out.unrecognized += 1; + continue; + } + } + let Ok(envelope) = serde_json::from_value::(entry) else { + continue; + }; + let Some(raw) = envelope.item else { + continue; + }; + let syncable_key = raw + .id + .clone() + .filter(|_| !raw.is_local && raw.kind.as_deref() == Some("track")); + if let Some(key) = syncable_key { + out.read.tracks.push(sync_track_of(&raw, key)); + } else if let Some(key) = raw.uri.clone().or_else(|| raw.id.clone()) { + out.read.not_syncable.push(sync_track_of(&raw, key)); + } + } + out +} + +/// The `tracks.items` of a search response as candidates. +fn search_candidates(page: Value) -> Vec { + let page: SearchPage = serde_json::from_value(page).unwrap_or_default(); + page + .tracks + .unwrap_or_default() + .items + .into_iter() + .filter_map(|entry| serde_json::from_value::(entry).ok()) + .filter_map(|raw| { + let key = raw.id.clone().filter(|_| !raw.is_local)?; + Some(sync_track_of(&raw, key)) + }) + .collect() +} + +/// `isrc:`, normalized. +fn isrc_query(isrc: &str) -> String { + format!("isrc:{}", normalize_isrc(isrc)) +} + +/// `track: artist:<artist>`, with the artist clause dropped when there is none. +fn title_query(target: &SyncTrack) -> String { + let title = target.title.trim(); + let artist = target.artist.trim(); + if artist.is_empty() { + return format!("track:{title}"); + } + format!("track:{title} artist:{artist}") +} + +/// The body of one new private playlist. +fn create_body(name: &str) -> Value { + json!({ + "name": name, + "public": false, + "collaborative": false, + "description": "Created with spotatui" + }) +} + +/// The user's own playlist named `name` on one `me/playlists` page, as a URI, with +/// the page's size and whether a next page exists. +fn owned_playlist_on_page(page: &Value, name: &str, me: &str) -> (Option<String>, usize, bool) { + let empty = Vec::new(); + let items = page + .get("items") + .and_then(Value::as_array) + .unwrap_or(&empty); + let hit = items + .iter() + .find(|item| { + let owner = item + .get("owner") + .and_then(|owner| owner.get("id")) + .and_then(Value::as_str); + owner == Some(me) + && item + .get("name") + .and_then(Value::as_str) + .is_some_and(|listed| super::same_name(listed, name)) + }) + .and_then(|item| item.get("id").and_then(Value::as_str)) + .map(|id| format!("spotify:playlist:{id}")); + let has_next = page.get("next").is_some_and(|next| !next.is_null()); + (hit, items.len(), has_next) +} + +/// The `spotify:playlist:` URI of a create response. +fn created_playlist_uri(value: Value) -> Result<String> { + value + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(|id| format!("spotify:playlist:{id}")) + .ok_or_else(|| anyhow!("Spotify created the playlist but returned no id")) +} + +/// `{"uris": [...]}` for one batch. +fn add_body(tracks: &[SyncTrack]) -> Value { + let uris: Vec<String> = tracks + .iter() + .map(|track| format!("spotify:track:{}", track.key)) + .collect(); + json!({ "uris": uris }) +} + +/// `{"items": [{"uri": ...}]}` for one batch, no positions, so every occurrence goes. +fn remove_body(keys: &[String]) -> Value { + let items: Vec<Value> = keys + .iter() + .map(|key| json!({ "uri": format!("spotify:track:{key}") })) + .collect(); + json!({ "items": items }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn track(key: &str, title: &str, artist: &str) -> SyncTrack { + SyncTrack { + key: key.to_string(), + isrc: None, + title: title.to_string(), + artist: artist.to_string(), + duration_ms: None, + } + } + + #[test] + fn the_items_mask_names_item_and_the_isrc() { + assert!(ITEM_FIELDS.starts_with("items(item(")); + assert!(ITEM_FIELDS.contains("is_local")); + assert!(ITEM_FIELDS.contains("duration_ms")); + assert!(ITEM_FIELDS.contains("external_ids(isrc)")); + assert!(ITEM_FIELDS.contains("),track(")); + assert!(ITEM_FIELDS.ends_with(",next")); + assert!(!ITEM_FIELDS.contains("added_at")); + } + + #[test] + fn a_track_spelled_page_parses_and_an_unknown_shape_is_flagged() { + let renamed = json!({ + "next": "https://api.spotify.com/v1/playlists/x/items?offset=50", + "items": [ + { "track": { "id": "1", "type": "track", "name": "Alpha", "artists": [{ "name": "A" }] } }, + { "track": null } + ] + }); + let page = read_items_page(renamed); + assert_eq!(page.read.keys(), ["1"]); + assert_eq!((page.count, page.unrecognized, page.has_next), (2, 0, true)); + + let unknown = json!({ "items": [{ "entry": { "id": "1" } }] }); + let page = read_items_page(unknown); + assert_eq!((page.count, page.unrecognized), (1, 1)); + assert!(page.read.tracks.is_empty()); + + assert!(is_last_page(0, true)); + assert!(is_last_page(3, false)); + assert!(!is_last_page(3, true)); + assert!(!is_last_page(PAGE_LIMIT as usize, false)); + } + + #[test] + fn a_page_splits_tracks_from_local_files_and_episodes() { + let page = json!({ + "total": 4, + "items": [ + { "item": { + "id": "4cOdK2wGLETKBW3PvgPWqT", + "uri": "spotify:track:4cOdK2wGLETKBW3PvgPWqT", + "type": "track", + "is_local": false, + "name": "Never Gonna Give You Up", + "duration_ms": 213573, + "artists": [{ "name": "Rick Astley" }, { "name": "Someone Else" }], + "external_ids": { "isrc": "GBARL9300135" } + }}, + { "item": { + "uri": "spotify:local:Artist:Album:Song:180", + "type": "track", + "is_local": true, + "name": "Song", + "artists": [{ "name": "Artist" }] + }}, + { "item": { + "id": "5Ggwid8mfGpx6PNfUVjWAE", + "uri": "spotify:episode:5Ggwid8mfGpx6PNfUVjWAE", + "type": "episode", + "is_local": false, + "name": "An episode" + }}, + { "item": null }, + null + ] + }); + + let page = read_items_page(page); + let read = page.read; + + assert_eq!( + (page.count, page.unrecognized, page.has_next), + (5, 0, false) + ); + assert_eq!(read.tracks.len(), 1); + assert_eq!(read.tracks[0].key, "4cOdK2wGLETKBW3PvgPWqT"); + assert_eq!(read.tracks[0].isrc.as_deref(), Some("GBARL9300135")); + assert_eq!(read.tracks[0].title, "Never Gonna Give You Up"); + assert_eq!(read.tracks[0].artist, "Rick Astley"); + assert_eq!(read.tracks[0].duration_ms, Some(213573)); + let keys: Vec<&str> = read + .not_syncable + .iter() + .map(|entry| entry.key.as_str()) + .collect(); + assert_eq!( + keys, + [ + "spotify:local:Artist:Album:Song:180", + "spotify:episode:5Ggwid8mfGpx6PNfUVjWAE" + ] + ); + assert_eq!(read.keys(), ["4cOdK2wGLETKBW3PvgPWqT"]); + } + + #[test] + fn a_search_page_becomes_candidates_with_their_isrc() { + let page = json!({ + "tracks": { + "items": [ + { + "id": "1", + "type": "track", + "name": "Alpha", + "duration_ms": 1000, + "artists": [{ "name": "A" }], + "external_ids": { "isrc": "GB-AAA-00-00001" } + }, + { "type": "track", "name": "No id", "artists": [] }, + { "id": "3", "type": "track", "is_local": true, "name": "Local", "artists": [] } + ] + } + }); + + let found = search_candidates(page); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].key, "1"); + assert_eq!(found[0].isrc.as_deref(), Some("GB-AAA-00-00001")); + assert_eq!(found[0].title, "Alpha"); + assert_eq!(found[0].artist, "A"); + assert!(search_candidates(json!({})).is_empty()); + } + + #[test] + fn the_isrc_query_is_normalized_and_the_fallback_names_title_and_artist() { + assert_eq!(isrc_query("gb-aaa-00-00001"), "isrc:GBAAA0000001"); + assert_eq!( + title_query(&track("1", " Alpha ", " A ")), + "track:Alpha artist:A" + ); + assert_eq!(title_query(&track("1", "Alpha", "")), "track:Alpha"); + } + + #[test] + fn an_add_body_carries_track_uris_a_hundred_per_call() { + let tracks: Vec<SyncTrack> = (0..250).map(|n| track(&n.to_string(), "T", "A")).collect(); + let batches: Vec<usize> = tracks + .chunks(WRITE_CHUNK) + .map(|chunk| chunk.len()) + .collect(); + + assert_eq!(batches, [100, 100, 50]); + assert_eq!( + add_body(&tracks[..2]), + json!({ "uris": ["spotify:track:0", "spotify:track:1"] }) + ); + assert_eq!(add_body(&[]), json!({ "uris": [] })); + } + + #[test] + fn a_remove_body_names_items_by_uri_without_positions() { + let body = remove_body(&["abc".to_string(), "def".to_string()]); + + assert_eq!( + body, + json!({ "items": [{ "uri": "spotify:track:abc" }, { "uri": "spotify:track:def" }] }) + ); + assert!(!body.to_string().contains("positions")); + } + + #[test] + fn only_the_users_own_playlist_with_the_name_is_found_on_a_page() { + let page = json!({ + "next": null, + "items": [ + { "id": "theirs", "name": "Road Trip", "owner": { "id": "someone" } }, + { "id": "mine", "name": " road trip ", "owner": { "id": "me" } } + ] + }); + assert_eq!( + owned_playlist_on_page(&page, "Road Trip", "me"), + (Some("spotify:playlist:mine".to_string()), 2, false) + ); + assert_eq!(owned_playlist_on_page(&page, "Gym", "me"), (None, 2, false)); + + let more = json!({ "next": "https://api.spotify.com/v1/me/playlists?offset=50", "items": [] }); + assert_eq!(owned_playlist_on_page(&more, "Gym", "me"), (None, 0, true)); + } + + #[test] + fn a_created_playlist_answers_its_uri_and_a_missing_id_is_an_error() { + assert_eq!( + created_playlist_uri(json!({ "id": "3cEYpjA9oz9GiPac4AsH4n" })).unwrap(), + "spotify:playlist:3cEYpjA9oz9GiPac4AsH4n" + ); + assert!(created_playlist_uri(json!({ "id": "" })).is_err()); + assert!(created_playlist_uri(json!({ "name": "Road Trip" })).is_err()); + assert_eq!( + create_body("Road Trip"), + json!({ + "name": "Road Trip", + "public": false, + "collaborative": false, + "description": "Created with spotatui" + }) + ); + } + + #[test] + fn a_playlist_uri_or_a_bare_id_both_yield_the_bare_id() { + assert_eq!( + playlist_id_of("spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"), + "37i9dQZF1DXcBWIGoYBM5M" + ); + assert_eq!( + playlist_id_of("37i9dQZF1DXcBWIGoYBM5M"), + "37i9dQZF1DXcBWIGoYBM5M" + ); + } +} diff --git a/src/infra/playlist_sync/youtube.rs b/src/infra/playlist_sync/youtube.rs new file mode 100644 index 00000000..49d8797b --- /dev/null +++ b/src/infra/playlist_sync/youtube.rs @@ -0,0 +1,417 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{anyhow, Context, Result}; +use tokio::sync::Mutex; + +use super::{PlaylistRead, SyncClient}; +use crate::core::app::App; +use crate::core::playlist_sync::{normalize_text, strip_title_suffix, SyncTrack}; +use crate::infra::network::IoEvent; +use crate::infra::youtube::playlists::{self, StoredTrack}; +use crate::infra::youtube::YouTubeSource; + +/// Duration window for a YouTube match, wider than the catalog sources' because +/// uploads carry intros and outros. +const DURATION_TOLERANCE_MS: u64 = 3_000; + +/// The local `youtube_playlists.yml` as a sync endpoint. The path is injected, +/// so a test points it at a tempdir instead of the user's config directory. +pub(crate) struct YouTubeSyncClient { + source: YouTubeSource, + path: PathBuf, + app: Arc<Mutex<App>>, +} + +impl YouTubeSyncClient { + pub(crate) fn new(source: YouTubeSource, path: PathBuf, app: Arc<Mutex<App>>) -> Self { + YouTubeSyncClient { source, path, app } + } + + /// One load, mutate and save transaction against the injected file, on the blocking pool. + async fn with_file<T, F>(&self, mutate: F) -> Result<T> + where + T: Send + 'static, + F: FnOnce(&mut playlists::PlaylistsFile) -> Result<T> + Send + 'static, + { + let path = self.path.clone(); + tokio::task::spawn_blocking(move || { + let mut file = playlists::load(&path)?; + let out = mutate(&mut file)?; + playlists::save(&path, &file)?; + Ok(out) + }) + .await + .context("YouTube playlists task failed")? + } +} + +impl SyncClient for YouTubeSyncClient { + async fn find_playlist(&self, name: &str) -> Result<Option<String>> { + let path = self.path.clone(); + let file = tokio::task::spawn_blocking(move || playlists::load(&path)) + .await + .context("YouTube playlists task failed")??; + Ok( + file + .playlists + .iter() + .find(|playlist| super::same_name(&playlist.name, name)) + .map(|playlist| playlists::uri_for_playlist_id(&playlist.id)), + ) + } + + async fn create_playlist(&self, name: &str) -> Result<String> { + let name = name.to_string(); + let id = self + .with_file(move |file| playlists::create_playlist(file, &name)) + .await?; + let uri = playlists::uri_for_playlist_id(&id); + self.app.lock().await.dispatch(IoEvent::GetYouTubePlaylists); + Ok(uri) + } + + async fn read_playlist(&self, playlist_uri: &str) -> Result<PlaylistRead> { + let path = self.path.clone(); + let file = tokio::task::spawn_blocking(move || playlists::load(&path)) + .await + .context("YouTube playlists task failed")??; + let playlist = playlists::find_playlist(&file, playlist_uri) + .ok_or_else(|| anyhow!("no such YouTube playlist: {playlist_uri}"))?; + let tracks: Vec<SyncTrack> = playlist.tracks.iter().map(stored_to_sync_track).collect(); + Ok(PlaylistRead::from(tracks)) + } + + async fn resolve(&self, target: &SyncTrack) -> Result<Option<SyncTrack>> { + let found = self + .source + .sync_search(&super::catalog_query(target)) + .await?; + Ok(pick_youtube(target, &found).map(|index| found[index].clone())) + } + + async fn add(&self, playlist_uri: &str, tracks: &[SyncTrack]) -> Result<()> { + if tracks.is_empty() { + return Ok(()); + } + let uri = playlist_uri.to_string(); + let rows: Vec<StoredTrack> = tracks.iter().map(sync_to_stored_track).collect(); + self + .with_file(move |file| { + for row in rows { + playlists::add_track(file, &uri, row)?; + } + Ok(()) + }) + .await?; + self.app.lock().await.dispatch(IoEvent::GetYouTubePlaylists); + Ok(()) + } + + async fn remove(&self, playlist_uri: &str, keys: &[String]) -> Result<()> { + if keys.is_empty() { + return Ok(()); + } + let uri = playlist_uri.to_string(); + let keys = keys.to_vec(); + self + .with_file(move |file| { + for key in &keys { + if let Err(e) = playlists::remove_track(file, &uri, key) { + log::debug!("YouTube mirror: {e}"); + } + } + Ok(()) + }) + .await?; + self.app.lock().await.dispatch(IoEvent::GetYouTubePlaylists); + Ok(()) + } +} + +/// Map a stored row onto the sync currency; a stored zero duration is unknown. +fn stored_to_sync_track(t: &StoredTrack) -> SyncTrack { + SyncTrack { + key: t.video_id.clone(), + isrc: None, + title: t.title.clone(), + artist: t.channel.clone(), + duration_ms: (t.duration_ms > 0).then_some(t.duration_ms), + } +} + +/// Map a sync row onto a stored one; an unknown duration stores as zero. +fn sync_to_stored_track(t: &SyncTrack) -> StoredTrack { + StoredTrack { + video_id: t.key.clone(), + title: t.title.clone(), + channel: t.artist.clone(), + duration_ms: t.duration_ms.unwrap_or(0), + } +} + +/// Duration window for a video on another channel: a re-upload of the same +/// audio has to be this close, with both durations known. +const REUPLOAD_TOLERANCE_MS: u64 = 2_000; + +/// The candidate that is the master track. First choice: the artist's own +/// channel (or a `- Topic` one), a title that contains the master title, and a +/// duration within [`DURATION_TOLERANCE_MS`], where an unknown duration never +/// vetoes. Second choice: any channel, when the title matches and both +/// durations are known and within [`REUPLOAD_TOLERANCE_MS`]. +fn pick_youtube(target: &SyncTrack, candidates: &[SyncTrack]) -> Option<usize> { + let title = normalize_text(&target.title); + if title.is_empty() { + return None; + } + let stripped = normalize_text(&strip_title_suffix(&target.title)); + let title_ok = |candidate: &SyncTrack| { + let listed = normalize_text(&candidate.title); + listed.contains(&title) || (!stripped.is_empty() && listed.contains(&stripped)) + }; + let own = candidates.iter().position(|candidate| { + let within = match (target.duration_ms, candidate.duration_ms) { + (Some(want), Some(got)) => want.abs_diff(got) <= DURATION_TOLERANCE_MS, + _ => true, + }; + let own_channel = candidate.artist.to_lowercase().ends_with(" - topic") + || same_artist(&candidate.artist, &target.artist); + within && own_channel && title_ok(candidate) + }); + own.or_else(|| { + candidates.iter().position(|candidate| { + let close = match (target.duration_ms, candidate.duration_ms) { + (Some(want), Some(got)) => want.abs_diff(got) <= REUPLOAD_TOLERANCE_MS, + _ => false, + }; + close && title_ok(candidate) + }) + }) +} + +/// Whether a channel name is the artist's, ignoring spaces, case and symbols, +/// so `ImagineDragons` and `Axwell Λ Ingrosso` still count. +fn same_artist(channel: &str, artist: &str) -> bool { + let (left, right) = (compact(channel), compact(artist)); + if left.is_empty() || right.is_empty() { + return normalize_text(channel) == normalize_text(artist); + } + left == right +} + +/// Lowercase ASCII letters and digits only. +fn compact(value: &str) -> String { + value + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|ch| ch.to_ascii_lowercase()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::user_config::UserConfig; + use std::path::Path; + use std::sync::mpsc::{channel, Receiver}; + + fn track(key: &str, title: &str, artist: &str, duration_ms: Option<u64>) -> SyncTrack { + SyncTrack { + key: key.to_string(), + isrc: None, + title: title.to_string(), + artist: artist.to_string(), + duration_ms, + } + } + + fn client_for(path: &Path) -> (YouTubeSyncClient, Receiver<IoEvent>) { + let (tx, rx) = channel(); + let app = Arc::new(Mutex::new(App::new(tx, UserConfig::new(), None))); + let client = YouTubeSyncClient::new(YouTubeSource::new(None), path.to_path_buf(), app); + (client, rx) + } + + fn seed_playlist(path: &Path) -> String { + let mut file = playlists::PlaylistsFile::default(); + let id = playlists::create_playlist(&mut file, "Focus").unwrap(); + playlists::save(path, &file).unwrap(); + playlists::uri_for_playlist_id(&id) + } + + #[test] + fn a_topic_channel_wins_over_an_earlier_upload() { + let target = track("m1", "Creep", "Radiohead", Some(238_000)); + let candidates = vec![ + track("x0", "Creep (Live)", "Some Fan Channel", Some(238_000)), + track("x1", "Creep", "Radiohead - Topic", Some(238_500)), + track("x2", "Creep", "Radiohead", Some(238_000)), + ]; + assert_eq!(pick_youtube(&target, &candidates), Some(1)); + + let without_topic = vec![candidates[0].clone(), candidates[2].clone()]; + assert_eq!(pick_youtube(&target, &without_topic), Some(1)); + + let reupload_only = vec![candidates[0].clone()]; + assert_eq!(pick_youtube(&target, &reupload_only), Some(0)); + + let reupload_off = vec![track( + "x0", + "Creep (Lyrics)", + "Some Fan Channel", + Some(241_000), + )]; + assert_eq!(pick_youtube(&target, &reupload_off), None); + + let reupload_blind = vec![track("x0", "Creep (Lyrics)", "Some Fan Channel", None)]; + assert_eq!(pick_youtube(&target, &reupload_blind), None); + } + + #[test] + fn a_channel_spelled_without_spaces_or_with_a_symbol_is_the_artists() { + let target = track("m1", "Believer", "Imagine Dragons", Some(204_346)); + let official = vec![track( + "x1", + "Imagine Dragons - Believer (Audio)", + "ImagineDragons", + Some(203_000), + )]; + assert_eq!(pick_youtube(&target, &official), Some(0)); + + let target = track("m2", "Dreamer", "Axwell /\\ Ingrosso", Some(251_147)); + let official = vec![track( + "x2", + "Dreamer (Matisse & Sadko Remix)", + "Axwell \u{39b} Ingrosso", + Some(251_000), + )]; + assert_eq!(pick_youtube(&target, &official), Some(0)); + assert!(!same_artist("Some Fan Channel", "Imagine Dragons")); + } + + #[test] + fn a_feat_or_edition_suffix_on_the_master_title_is_forgiven() { + let target = track( + "m1", + "No Sleep (feat. Bonn)", + "Martin Garrix", + Some(207_094), + ); + let candidates = vec![ + track( + "x0", + "Martin Garrix - High On Life", + "Martin Garrix", + Some(227_000), + ), + track( + "x1", + "Martin Garrix feat. Bonn - No Sleep (Official Video)", + "Martin Garrix", + Some(208_000), + ), + ]; + assert_eq!(pick_youtube(&target, &candidates), Some(1)); + } + + #[test] + fn a_title_that_does_not_carry_the_master_title_is_no_candidate() { + let target = track("m1", "Creep", "Radiohead", Some(238_000)); + let candidates = vec![track( + "x1", + "Karma Police", + "Radiohead - Topic", + Some(238_000), + )]; + assert_eq!(pick_youtube(&target, &candidates), None); + + let blank = track("m2", " ", "Radiohead", None); + assert_eq!(pick_youtube(&blank, &candidates), None); + } + + #[test] + fn an_unknown_duration_never_reads_as_zero() { + let target = track("m1", "Creep", "Radiohead", Some(238_000)); + let too_long = vec![track("x1", "Creep", "Radiohead - Topic", Some(243_000))]; + assert_eq!(pick_youtube(&target, &too_long), None); + + let edge = vec![track("x2", "Creep", "Radiohead - Topic", Some(241_000))]; + assert_eq!(pick_youtube(&target, &edge), Some(0)); + + let unknown = vec![track("x3", "Creep", "Radiohead - Topic", None)]; + assert_eq!(pick_youtube(&target, &unknown), Some(0)); + } + + #[tokio::test] + async fn creating_a_playlist_writes_the_file_and_answers_its_uri() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("yt_playlists.yml"); + let (client, rx) = client_for(&path); + + let uri = client.create_playlist("Road Trip").await.unwrap(); + + assert!(matches!(rx.try_recv(), Ok(IoEvent::GetYouTubePlaylists))); + assert!(uri.starts_with("youtube:playlist:")); + let file = playlists::load(&path).unwrap(); + assert_eq!( + playlists::find_playlist(&file, &uri).map(|p| p.name.as_str()), + Some("Road Trip") + ); + assert!(client.read_playlist(&uri).await.unwrap().tracks.is_empty()); + assert_eq!( + client.find_playlist(" road trip ").await.unwrap(), + Some(uri) + ); + assert_eq!(client.find_playlist("Nope").await.unwrap(), None); + } + + #[tokio::test] + async fn a_playlist_read_and_write_round_trip_through_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("yt_playlists.yml"); + let uri = seed_playlist(&path); + let (client, rx) = client_for(&path); + + assert!(client.read_playlist(&uri).await.unwrap().tracks.is_empty()); + + client + .add( + &uri, + &[track("vid1", "Get Lucky", "Daft Punk", Some(249_000))], + ) + .await + .unwrap(); + assert!(matches!(rx.try_recv(), Ok(IoEvent::GetYouTubePlaylists))); + + let read = client.read_playlist(&uri).await.unwrap(); + assert_eq!(read.keys(), vec!["vid1".to_string()]); + assert_eq!(read.tracks[0].title, "Get Lucky"); + assert_eq!(read.tracks[0].artist, "Daft Punk"); + assert_eq!(read.tracks[0].duration_ms, Some(249_000)); + assert!(read.not_syncable.is_empty()); + + client.remove(&uri, &["vid1".to_string()]).await.unwrap(); + assert!(client.read_playlist(&uri).await.unwrap().tracks.is_empty()); + } + + #[tokio::test] + async fn removing_a_video_that_is_already_gone_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("yt_playlists.yml"); + let uri = seed_playlist(&path); + let (client, _rx) = client_for(&path); + + client + .add( + &uri, + &[track("vid1", "Get Lucky", "Daft Punk", Some(249_000))], + ) + .await + .unwrap(); + client + .remove(&uri, &["ghost".to_string(), "vid1".to_string()]) + .await + .unwrap(); + assert!(client.read_playlist(&uri).await.unwrap().tracks.is_empty()); + } +} diff --git a/src/infra/qobuz/dispatch.rs b/src/infra/qobuz/dispatch.rs index 53fb4644..358f10ff 100644 --- a/src/infra/qobuz/dispatch.rs +++ b/src/infra/qobuz/dispatch.rs @@ -206,6 +206,17 @@ pub(crate) async fn build_playback_source(app: &Arc<Mutex<App>>) -> Option<Qobuz build_source(app, WhenLoggedOut::Message).await } +/// A source for the playlist sync; the caller reports the logged-out case itself. +pub(crate) async fn build_sync_source(app: &Arc<Mutex<App>>) -> Result<QobuzSource> { + let token = auth::current_token().context("Qobuz is not logged in")?; + let constants = constants(app).await.context("Qobuz web player constants")?; + Ok(QobuzSource::new( + constants.app_id, + constants.app_secret, + token, + )) +} + /// Report a failed call as one status message; a 401 also clears the token. async fn report(app: &Arc<Mutex<App>>, step: &str, err: anyhow::Error) { let mut guard = app.lock().await; diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index fe800ecf..ad20b0bd 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -27,6 +27,7 @@ use reqwest::Client; use serde::de::{DeserializeOwned, IgnoredAny}; use tokio::sync::Mutex; +use crate::core::playlist_sync::SyncTrack; use crate::core::plugin_api::{ArtistRef, PlaylistInfo, SearchResults, TrackInfo}; use crate::core::source::{MediaSource, PlaylistWriter, Searcher}; use crate::infra::audio::LocalPlayer; @@ -542,8 +543,39 @@ impl QobuzSource { .await } + /// Every track of a playlist as sync candidates, with the ISRC `listing_tracks` drops. + pub(crate) async fn sync_playlist_tracks(&self, playlist_uri: &str) -> Result<Vec<SyncTrack>> { + let id = playlist_id_from_uri(playlist_uri)?; + Ok( + self + .playlist_items(id) + .await? + .iter() + .map(track_to_sync_track) + .collect(), + ) + } + + /// Catalog search results as sync candidates, ISRC included. + pub(crate) async fn sync_search(&self, query: &str, limit: u32) -> Result<Vec<SyncTrack>> { + let found: types::Search = self + .get( + "catalog/search", + &[("query", query.to_string()), ("limit", limit.to_string())], + ) + .await?; + Ok( + found + .tracks + .unwrap_or_default() + .items + .iter() + .map(track_to_sync_track) + .collect(), + ) + } + /// Create a private playlist and return its id. - #[allow(dead_code)] // The sync engine is the first caller. pub async fn create_playlist(&self, name: &str) -> Result<String> { let created: types::Playlist = self .playlist_write( @@ -705,6 +737,21 @@ fn track_to_track_info(t: &types::Track, parent: Option<&types::Album>) -> Track } } +/// Map a Qobuz track onto the sync currency: the bare title, never the version suffix. +fn track_to_sync_track(t: &types::Track) -> SyncTrack { + let performer = t + .performer + .as_ref() + .or(t.album.as_ref().and_then(|a| a.artist.as_ref())); + SyncTrack { + key: t.id.clone(), + isrc: t.isrc.clone(), + title: t.title.clone(), + artist: performer.map(|n| n.name.clone()).unwrap_or_default(), + duration_ms: (t.duration > 0).then_some(t.duration * 1000), + } +} + // --------------------------------------------------------------------------- // Trait implementations // --------------------------------------------------------------------------- @@ -944,6 +991,32 @@ mod tests { const WRITE_OK: &str = r#"{ "status": "success" }"#; + const SYNC_PLAYLIST: &str = r#"{ + "id": 111, "name": "Morning", + "tracks": { + "offset": 0, "limit": 500, "total": 2, + "items": [ + { "id": 5001, "title": "Around the World", "version": "Radio Edit", + "duration": 429, "isrc": "gb-aaa-00-00001", + "performer": { "id": 36819, "name": "Daft Punk" } }, + { "id": 5002, "title": "Veridis Quo", + "album": { "id": "0060254730302", "title": "Discovery", + "artist": { "id": 36819, "name": "Daft Punk" } } } + ] + } + }"#; + + const SYNC_SEARCH: &str = r#"{ + "tracks": { + "offset": 0, "limit": 10, "total": 1, + "items": [ + { "id": 5001, "title": "Around the World", "duration": 429, + "isrc": "GBAAA0000001", + "performer": { "id": 36819, "name": "Daft Punk" } } + ] + } + }"#; + #[test] fn user_playlists_map_to_playlist_info() { let page: types::UserPlaylists = serde_json::from_str(USER_PLAYLISTS).unwrap(); @@ -1152,6 +1225,58 @@ mod tests { assert!(sent(&seen[1]).contains("playlist_track_ids=90001%2C90003")); } + #[tokio::test] + async fn sync_playlist_tracks_keeps_the_isrc_and_the_bare_title() { + let (base, server) = serve(vec![("200 OK", SYNC_PLAYLIST)]).await; + let tracks = QobuzSource::with_base(base) + .sync_playlist_tracks("qobuz:playlist:111") + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 1); + assert!(seen[0].0.starts_with("GET /playlist/get?")); + assert!(seen[0].0.contains("playlist_id=111")); + assert!(seen[0].0.contains("extra=tracks")); + assert_eq!(tracks.len(), 2); + assert_eq!(tracks[0].key, "5001"); + assert_eq!(tracks[0].title, "Around the World"); + assert_eq!(tracks[0].artist, "Daft Punk"); + assert_eq!(tracks[0].isrc.as_deref(), Some("gb-aaa-00-00001")); + assert_eq!(tracks[0].duration_ms, Some(429_000)); + assert_eq!(tracks[1].key, "5002"); + assert_eq!(tracks[1].artist, "Daft Punk"); + assert_eq!(tracks[1].isrc, None); + } + + #[tokio::test] + async fn sync_search_sends_the_limit_it_was_given() { + let (base, server) = serve(vec![("200 OK", SYNC_SEARCH)]).await; + let found = QobuzSource::with_base(base) + .sync_search("daft punk around the world", 10) + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 1); + assert!(seen[0].0.starts_with("GET /catalog/search?")); + assert!(seen[0].0.contains("query=daft+punk+around+the+world")); + assert!(seen[0].0.contains("limit=10")); + assert_eq!(found.len(), 1); + assert_eq!(found[0].key, "5001"); + assert_eq!(found[0].isrc.as_deref(), Some("GBAAA0000001")); + assert_eq!(found[0].duration_ms, Some(429_000)); + } + + #[test] + fn a_track_with_no_duration_reports_an_unknown_one() { + let playlist: types::Playlist = serde_json::from_str(PLAYLIST_ITEMS).unwrap(); + let items = playlist.tracks.unwrap().items; + let track = track_to_sync_track(&items[1]); + assert_eq!(track.key, "5002"); + assert_eq!(track.duration_ms, None); + assert_eq!(track.isrc, None); + assert_eq!(track.artist, ""); + } + /// Live end to end: scrape the bundle, start a session, stream one track /// through the shared sink while it downloads, and seek ahead. Needs /// `SPOTATUI_QOBUZ_TOKEN` (and `SPOTATUI_QOBUZ_TEST_FORMAT`, default 27). Run: diff --git a/src/infra/qobuz/types.rs b/src/infra/qobuz/types.rs index a05c7d86..b2969939 100644 --- a/src/infra/qobuz/types.rs +++ b/src/infra/qobuz/types.rs @@ -124,8 +124,7 @@ pub struct Track { pub streamable: bool, #[serde(default)] pub parental_warning: bool, - /// Read by the sync matcher; no production reader yet. - #[allow(dead_code)] + /// The recording id the sync matcher prefers over a title match. #[serde(default)] pub isrc: Option<String>, /// The per-playlist item id `playlist/deleteTracks` takes. diff --git a/src/infra/subsonic/dispatch.rs b/src/infra/subsonic/dispatch.rs index 5b603776..adb7c382 100644 --- a/src/infra/subsonic/dispatch.rs +++ b/src/infra/subsonic/dispatch.rs @@ -30,7 +30,7 @@ use std::sync::Arc; use std::time::Duration; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use tempfile::NamedTempFile; use tokio::sync::Mutex; @@ -149,6 +149,17 @@ pub async fn route_subsonic_event(app: &Arc<Mutex<App>>, event: &IoEvent) -> boo /// taken from the `SPOTATUI_SUBSONIC_PASSWORD` env var when set. Returns `None` /// (after surfacing a status message) when no server URL is configured. pub(crate) async fn build_source(app: &Arc<Mutex<App>>) -> Option<SubsonicSource> { + match build_sync_source(app).await { + Ok(source) => Some(source), + Err(e) => { + set_error(app, e.to_string()).await; + None + } + } +} + +/// A source for the playlist sync, with the unconfigured case left to the caller. +pub(crate) async fn build_sync_source(app: &Arc<Mutex<App>>) -> Result<SubsonicSource> { let (url, username, config_password) = { let guard = app.lock().await; let behavior = &guard.user_config.behavior; @@ -160,12 +171,9 @@ pub(crate) async fn build_source(app: &Arc<Mutex<App>>) -> Option<SubsonicSource }; let Some(url) = url else { - set_error( - app, - "No Subsonic server configured (set behavior.subsonic_url)".to_string(), - ) - .await; - return None; + return Err(anyhow!( + "No Subsonic server configured (set behavior.subsonic_url)" + )); }; // Env override takes precedence over the plaintext config field. @@ -174,7 +182,7 @@ pub(crate) async fn build_source(app: &Arc<Mutex<App>>) -> Option<SubsonicSource .or(config_password) .unwrap_or_default(); - Some(SubsonicSource::new( + Ok(SubsonicSource::new( url, username.unwrap_or_default(), password, diff --git a/src/infra/subsonic/mod.rs b/src/infra/subsonic/mod.rs index 34f24480..dbacda26 100644 --- a/src/infra/subsonic/mod.rs +++ b/src/infra/subsonic/mod.rs @@ -35,6 +35,7 @@ use md5::{Digest, Md5}; use rand::RngExt; use reqwest::Client; +use crate::core::playlist_sync::SyncTrack; use crate::core::plugin_api::{ AlbumInfo, ArtistInfo, ArtistRef, PlaylistInfo, SearchResults, TrackInfo, }; @@ -339,6 +340,51 @@ impl SubsonicSource { .with_context(|| format!("flushing stream to {}", dest.display()))?; Ok(()) } + + /// Every entry of a playlist; `tracks`, `remove_tracks` and the sync read share it. + async fn playlist_entries(&self, id: &str) -> Result<Vec<types::SubsonicSong>> { + let url = Self::append_param( + &self.endpoint_url("getPlaylist.view"), + "id", + &url_encode(id), + ); + let detail = self + .fetch(&url) + .await? + .playlist + .ok_or_else(|| anyhow!("No playlist in getPlaylist response"))?; + Ok(detail.entry) + } + + /// Every track of a playlist as sync candidates, with the ISRC `tracks` drops. + pub(crate) async fn sync_playlist_tracks(&self, playlist_uri: &str) -> Result<Vec<SyncTrack>> { + let id = playlist_id_from_uri(playlist_uri)?; + Ok( + self + .playlist_entries(id) + .await? + .iter() + .map(song_to_sync_track) + .collect(), + ) + } + + /// `search3.view` songs as sync candidates; albums and artists are asked for as zero. + pub(crate) async fn sync_search(&self, query: &str, limit: u32) -> Result<Vec<SyncTrack>> { + let encoded = url_encode(query); + let base = Self::append_param(&self.endpoint_url("search3.view"), "query", &encoded); + let url = format!("{base}&songCount={limit}&albumCount=0&artistCount=0"); + let resp = self.fetch(&url).await?; + Ok( + resp + .search_result3 + .unwrap_or_default() + .song + .iter() + .map(song_to_sync_track) + .collect(), + ) + } } /// Strip the `subsonic:track:` prefix and return the raw track id. @@ -444,6 +490,17 @@ impl SubsonicSource { } } +/// Map a Subsonic song onto the sync currency; only the first ISRC survives. +fn song_to_sync_track(s: &types::SubsonicSong) -> SyncTrack { + SyncTrack { + key: s.id.clone(), + isrc: s.isrc.first().cloned(), + title: s.title.clone(), + artist: s.artist.clone().unwrap_or_default(), + duration_ms: s.duration.filter(|d| *d > 0).map(|d| d * 1000), + } +} + fn album_to_album_info(a: &types::SubsonicAlbum) -> AlbumInfo { let artists = a .artist @@ -522,18 +579,9 @@ impl PlaylistWriter for SubsonicSource { if track_uris.is_empty() { return Ok(()); } - let read = Self::append_param( - &self.endpoint_url("getPlaylist.view"), - "id", - &url_encode(id), - ); - let detail = self - .fetch(&read) - .await? - .playlist - .ok_or_else(|| anyhow!("No playlist in getPlaylist response"))?; + let entries = self.playlist_entries(id).await?; let wanted: Vec<&str> = track_uris.iter().map(|uri| track_id_of(uri)).collect(); - let mut indices = song_indices_for(&detail.entry, &wanted); + let mut indices = song_indices_for(&entries, &wanted); indices.reverse(); for chunk in indices.chunks(WRITE_CHUNK) { let mut url = Self::append_param( @@ -574,16 +622,10 @@ impl MediaSource for SubsonicSource { async fn tracks(&self, playlist_uri: &str) -> Result<Vec<TrackInfo>> { let id = playlist_id_from_uri(playlist_uri)?; - let url = Self::append_param(&self.endpoint_url("getPlaylist.view"), "id", id); - let resp = self.fetch(&url).await?; - - let detail = resp - .playlist - .ok_or_else(|| anyhow!("No playlist in getPlaylist response"))?; - Ok( - detail - .entry + self + .playlist_entries(id) + .await? .iter() .map(|s| self.song_to_track_info(s)) .collect(), @@ -962,6 +1004,29 @@ mod tests { const UPDATE_OK: &str = r#"{"subsonic-response":{"status":"ok","version":"1.16.1"}}"#; + const SYNC_PLAYLIST: &str = r#" + { + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "playlist": { + "id": "7", + "name": "Mirror", + "songCount": 2, + "entry": [ + { + "id": "101", + "title": "Weightless", + "artist": "Marconi Union", + "duration": 469, + "isrc": ["GBAYE0601498", "GBAYE0601499"] + }, + { "id": "102", "title": "Clair de Lune" } + ] + } + } + }"#; + // ------------------------------------------------------------------------- // JSON parsing tests // ------------------------------------------------------------------------- @@ -1187,4 +1252,48 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn sync_playlist_tracks_takes_the_first_isrc() { + let (base, server) = serve(vec![("200 OK", SYNC_PLAYLIST)]).await; + let tracks = SubsonicSource::new(base, "u", "p") + .sync_playlist_tracks("subsonic:playlist:7") + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 1); + assert!(seen[0].starts_with("/rest/getPlaylist.view?")); + assert!(seen[0].contains("&id=7")); + assert_eq!(tracks.len(), 2); + assert_eq!(tracks[0].key, "101"); + assert_eq!(tracks[0].title, "Weightless"); + assert_eq!(tracks[0].artist, "Marconi Union"); + assert_eq!(tracks[0].isrc.as_deref(), Some("GBAYE0601498")); + assert_eq!(tracks[0].duration_ms, Some(469_000)); + assert_eq!(tracks[1].key, "102"); + assert_eq!(tracks[1].isrc, None); + assert_eq!(tracks[1].artist, ""); + assert_eq!(tracks[1].duration_ms, None); + } + + #[tokio::test] + async fn sync_search_asks_for_songs_only() { + let (base, server) = serve(vec![("200 OK", SEARCH3)]).await; + let found = SubsonicSource::new(base, "u", "p") + .sync_search("the beatles yesterday", 10) + .await + .unwrap(); + let seen = server.await.unwrap(); + assert_eq!(seen.len(), 1); + assert!(seen[0].starts_with("/rest/search3.view?")); + assert!(seen[0].contains("&query=the+beatles+yesterday")); + assert!(seen[0].contains("&songCount=10")); + assert!(seen[0].contains("&albumCount=0")); + assert!(seen[0].contains("&artistCount=0")); + assert_eq!(found.len(), 1); + assert_eq!(found[0].key, "201"); + assert_eq!(found[0].title, "Yesterday"); + assert_eq!(found[0].artist, "The Beatles"); + assert_eq!(found[0].duration_ms, Some(125_000)); + } } diff --git a/src/infra/youtube/mod.rs b/src/infra/youtube/mod.rs index dfe06649..2fa4f08a 100644 --- a/src/infra/youtube/mod.rs +++ b/src/infra/youtube/mod.rs @@ -27,6 +27,7 @@ use anyhow::{anyhow, bail, Context, Result}; use tokio::io::AsyncReadExt; use tokio::process::Command; +use crate::core::playlist_sync::SyncTrack; use crate::core::plugin_api::{SearchResults, TrackInfo}; use crate::core::source::Searcher; use crate::infra::audio::LocalPlayer; @@ -205,6 +206,18 @@ impl YouTubeSource { ) } + /// Search results as sync candidates, with the duration left unknown when yt-dlp reports none. + pub(crate) async fn sync_search(&self, query: &str) -> Result<Vec<SyncTrack>> { + Ok( + self + .search_videos(query) + .await? + .iter() + .map(video_to_sync_track) + .collect(), + ) + } + /// Download a video's audio (itag 140 AAC/M4A preferred) to `dest`. /// /// When `ffmpeg` is on `$PATH`, yt-dlp remuxes the DASH fragments into a @@ -404,6 +417,23 @@ fn video_to_track_info(v: &YtVideo) -> TrackInfo { } } +/// Map a search row onto the sync currency; an absent duration stays absent. +fn video_to_sync_track(v: &YtVideo) -> SyncTrack { + SyncTrack { + key: v.id.clone(), + isrc: None, + title: v.title.trim().to_string(), + artist: v + .channel + .as_deref() + .or(v.uploader.as_deref()) + .unwrap_or_default() + .trim() + .to_string(), + duration_ms: v.duration.filter(|s| *s > 0.0).map(|s| (s * 1000.0) as u64), + } +} + /// Deterministic YouTube thumbnail URL for a video id. `hqdefault.jpg` exists /// for every video (unlike `maxresdefault`), so it serves as the cover-art /// fallback when yt-dlp's flat rows or a stored playlist carry no thumbnail. diff --git a/src/runtime/bootstrap.rs b/src/runtime/bootstrap.rs index 8d339b3a..59856c5c 100644 --- a/src/runtime/bootstrap.rs +++ b/src/runtime/bootstrap.rs @@ -299,18 +299,19 @@ enum SpotifyAuthMode { /// Interactive only right after the client wizard (fresh install, /// `--reconfigure-auth`, or the auth-setup migration): the user just asked for -/// Spotify. A subcommand needs a session; a UI launch never blocks on a browser. +/// Spotify. Most subcommands need a session; `sync` and a UI launch do not. fn spotify_auth_mode( - subcommand: bool, + subcommand: Option<&str>, reconfigure_auth: bool, wizard_ran: bool, ) -> SpotifyAuthMode { if reconfigure_auth || wizard_ran { SpotifyAuthMode::Interactive - } else if subcommand { - SpotifyAuthMode::CachedOrFail } else { - SpotifyAuthMode::CachedOrNone + match subcommand { + Some("sync") | None => SpotifyAuthMode::CachedOrNone, + Some(_) => SpotifyAuthMode::CachedOrFail, + } } } @@ -615,11 +616,7 @@ pub(super) async fn boot(matches: &ArgMatches, onboarding: Arc<dyn Onboarding>) let config_paths = client_config.get_or_build_paths()?; - let auth_mode = spotify_auth_mode( - matches.subcommand_name().is_some(), - reconfigure_auth, - wizard_ran, - ); + let auth_mode = spotify_auth_mode(matches.subcommand_name(), reconfigure_auth, wizard_ran); // The GitHub update check runs concurrently with authentication: both are // network round trips and neither depends on the other, so the check no @@ -1002,19 +999,20 @@ mod tests { #[test] fn the_boot_auth_mode_follows_the_wizard_and_the_subcommand() { let cases = [ - (false, true, false, SpotifyAuthMode::Interactive), - (true, true, false, SpotifyAuthMode::Interactive), - (false, false, true, SpotifyAuthMode::Interactive), - (true, false, true, SpotifyAuthMode::Interactive), - (true, false, false, SpotifyAuthMode::CachedOrFail), - (false, false, false, SpotifyAuthMode::CachedOrNone), + (None, true, false, SpotifyAuthMode::Interactive), + (Some("play"), true, false, SpotifyAuthMode::Interactive), + (None, false, true, SpotifyAuthMode::Interactive), + (Some("play"), false, true, SpotifyAuthMode::Interactive), + (Some("play"), false, false, SpotifyAuthMode::CachedOrFail), + (Some("sync"), false, false, SpotifyAuthMode::CachedOrNone), + (None, false, false, SpotifyAuthMode::CachedOrNone), ]; for (subcommand, reconfigure_auth, wizard_ran, expected) in cases { assert_eq!( spotify_auth_mode(subcommand, reconfigure_auth, wizard_ran), expected, - "subcommand={subcommand} reconfigure_auth={reconfigure_auth} wizard_ran={wizard_ran}" + "subcommand={subcommand:?} reconfigure_auth={reconfigure_auth} wizard_ran={wizard_ran}" ); } } diff --git a/src/runtime/cli.rs b/src/runtime/cli.rs index f3850001..2ba79619 100644 --- a/src/runtime/cli.rs +++ b/src/runtime/cli.rs @@ -6,8 +6,9 @@ use crate::cli; use crate::core::banner::BANNER; use crate::core::user_config::UserConfig; use crate::infra::network::Network; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use clap::{Arg, ArgMatches, Command as ClapApp}; +use std::sync::Arc; pub(super) fn build_clap_app() -> ClapApp { // `mut` is only exercised by the feature-gated subcommand additions below. @@ -65,7 +66,8 @@ screens more often and cost more CPU. Animation-heavy views keep their separate .subcommand(cli::play_subcommand()) .subcommand(cli::list_subcommand()) .subcommand(cli::history_subcommand()) - .subcommand(cli::search_subcommand()), + .subcommand(cli::search_subcommand()) + .subcommand(cli::sync_subcommand()), ); #[cfg(feature = "scripting")] @@ -84,6 +86,9 @@ screens more often and cost more CPU. Animation-heavy views keep their separate /// CLI mode: run one subcommand against the network layer and print its /// result. pub(super) async fn run_subcommand(boot: Boot, cmd: &str, matches: &ArgMatches) -> Result<()> { + if cmd == "sync" { + return run_sync(boot, matches).await; + } let app = boot.app; // Held (unread) for the length of the command; see the field doc on `Boot`. let _sync_io_rx = boot.sync_io_rx; @@ -101,6 +106,29 @@ pub(super) async fn run_subcommand(boot: Boot, cmd: &str, matches: &ArgMatches) Ok(()) } +/// The `sync` subcommand: no device probe, no Spotify requirement, its own exit signal. +async fn run_sync(boot: Boot, matches: &ArgMatches) -> Result<()> { + let app = boot.app; + // Held (unread) for the length of the command; see the field doc on `Boot`. + let _sync_io_rx = boot.sync_io_rx; + let args = cli::sync_args(matches); + let ctx = crate::infra::playlist_sync::SyncContext::new( + boot.spotify, + boot.token_cache_path, + Arc::clone(&app), + ); + let run = crate::infra::playlist_sync::run_guarded(ctx, args.link, args.dry_run, true); + let report = tokio::spawn(run) + .await + .context("playlist sync task failed")?; + app.lock().await.flush_state_save(true); + println!("{}", report.printable()); + if report.failed() { + return Err(anyhow!("playlist sync failed")); + } + Ok(()) +} + #[cfg(feature = "self-update")] fn add_self_update_cli(clap_app: ClapApp) -> ClapApp { clap_app diff --git a/src/runtime/startup.rs b/src/runtime/startup.rs index 4a05b406..55f71708 100644 --- a/src/runtime/startup.rs +++ b/src/runtime/startup.rs @@ -241,6 +241,10 @@ pub(super) async fn launch_ui(boot: Boot) -> Result<()> { let history_collector = crate::infra::history::spawn_history_collector(Arc::clone(&app)); + app.lock().await.dispatch(IoEvent::RunPlaylistSync { + retry_unmatched: false, + }); + // Opt-in MCP control socket. Nothing listens unless the user asked for it; // a bind failure is reported and shrugged off rather than blocking startup, // since the player is perfectly usable without it. diff --git a/src/tui/handlers/common_key_events.rs b/src/tui/handlers/common_key_events.rs index 5093768b..e7af4853 100644 --- a/src/tui/handlers/common_key_events.rs +++ b/src/tui/handlers/common_key_events.rs @@ -110,6 +110,7 @@ pub fn content_active_block_for_route(route_id: &RouteId) -> Option<ActiveBlock> RouteId::PodcastEpisodes => Some(ActiveBlock::EpisodeTable), RouteId::Discover => Some(ActiveBlock::Discover), RouteId::Stats => Some(ActiveBlock::Stats), + RouteId::PlaylistSync => Some(ActiveBlock::PlaylistSync), // Without this the right-arrow from the sidebar silently does nothing: the // match has a `_ => None` fallthrough, so it compiles either way. #[cfg(feature = "ai-dj")] diff --git a/src/tui/handlers/dialog.rs b/src/tui/handlers/dialog.rs index 10948ab9..0021e73a 100644 --- a/src/tui/handlers/dialog.rs +++ b/src/tui/handlers/dialog.rs @@ -1,6 +1,6 @@ use super::common_key_events; use crate::core::action::Action; -use crate::core::app::{ActiveBlock, App, DialogContext, PlaylistPickerRow}; +use crate::core::app::{ActiveBlock, App, DialogContext, PlaylistPickerRow, RouteId}; use crate::tui::event::Key; pub fn handler(key: Key, app: &mut App) { @@ -11,10 +11,12 @@ pub fn handler(key: Key, app: &mut App) { match dialog_context { DialogContext::AddTrackToPlaylistPicker => handle_add_to_playlist_picker(key, app), + DialogContext::PlaylistSyncPicker => handle_playlist_sync_picker(key, app), DialogContext::PlaylistWindow | DialogContext::PlaylistSearch | DialogContext::RemoveTrackFromPlaylistConfirm | DialogContext::PersistKeybindingFallback + | DialogContext::RemovePlaylistSyncLinkConfirm | DialogContext::YouTubePlaylistWindow => handle_confirmation_dialog(key, app, dialog_context), } } @@ -33,7 +35,11 @@ fn handle_confirmation_dialog(key: Key, app: &mut App, dialog_context: DialogCon app.persist_open_settings_fallback(); } DialogContext::YouTubePlaylistWindow => handle_youtube_playlist_dialog(app), + DialogContext::RemovePlaylistSyncLinkConfirm => { + handle_remove_playlist_sync_link_dialog(app); + } DialogContext::AddTrackToPlaylistPicker => {} + DialogContext::PlaylistSyncPicker => {} } } else if dialog_context == DialogContext::PersistKeybindingFallback { app.set_status_message("Using Alt+, for this session only", 4); @@ -122,6 +128,53 @@ fn handle_add_to_playlist_picker(key: Key, app: &mut App) { } } +/// The mirror picker: pick the source the highlighted playlist is mirrored onto. +fn handle_playlist_sync_picker(key: Key, app: &mut App) { + let rows = app.playlist_sync_picker_sources(); + let row_count = rows.len(); + match key { + k if common_key_events::down_event(k, &app.user_config.keys) && row_count > 0 => { + app.view.playlist_sync_picker_index = + common_key_events::on_down_press_handler(&rows, Some(app.view.playlist_sync_picker_index)); + } + k if common_key_events::up_event(k, &app.user_config.keys) && row_count > 0 => { + app.view.playlist_sync_picker_index = + common_key_events::on_up_press_handler(&rows, Some(app.view.playlist_sync_picker_index)); + } + k if common_key_events::high_event(k) && row_count > 0 => { + app.view.playlist_sync_picker_index = common_key_events::on_high_press_handler(); + } + k if common_key_events::middle_event(k) && row_count > 0 => { + app.view.playlist_sync_picker_index = common_key_events::on_middle_press_handler(&rows); + } + k if common_key_events::low_event(k) && row_count > 0 => { + app.view.playlist_sync_picker_index = common_key_events::on_low_press_handler(&rows); + } + Key::Enter => { + // No clamp: the offered list follows live state, so a stale cursor picks nothing. + let source = rows.get(app.view.playlist_sync_picker_index).copied(); + if let Some(source) = source { + app.apply(Action::LinkPlaylistTo(source)); + } + close_dialog(app); + // The run's progress shows on the sync screen, so open it. + if source.is_some() { + app.push_navigation_stack(RouteId::PlaylistSync, ActiveBlock::PlaylistSync); + } + } + Key::Char('q') => close_dialog(app), + _ => {} + } +} + +/// Confirmed removal of the highlighted playlist-sync link. +fn handle_remove_playlist_sync_link_dialog(app: &mut App) { + let id = app.pending_playlist_sync_remove().map(str::to_string); + if let Some(id) = id { + app.apply(Action::RemovePlaylistSyncLink(id)); + } +} + fn handle_playlist_dialog(app: &mut App) { if let Some(playlist_id) = app.selected_sidebar_playlist_id() { app.apply(Action::UnfollowPlaylist(playlist_id)); @@ -326,4 +379,67 @@ mod tests { PlaylistPickerRow::Folder(_) )); } + + #[test] + fn enter_in_the_mirror_picker_links_to_the_highlighted_source() { + use crate::core::plugin_api::PlaylistInfo; + use crate::core::source::Source; + + let (tx, rx) = channel(); + let mut app = + App::new(tx, UserConfig::new(), Some(SystemTime::now())).under_source(Source::Qobuz); + app.qobuz_playlists.push(PlaylistInfo { + uri: "qobuz:playlist:9".to_string(), + name: "Mine".to_string(), + owner: "qobuz".to_string(), + track_count: 3, + id: Some("9".to_string()), + owner_id: None, + collaborative: false, + public: None, + image_url: None, + }); + app.view.selected_playlist_index = Some(0); + app.apply(Action::OpenPlaylistSyncPicker); + assert!(app.pending_playlist_sync_master().is_some()); + + handler(Key::Enter, &mut app); + + assert!(app.pending_playlist_sync_master().is_none()); + assert!(!matches!( + app.get_current_route().active_block, + ActiveBlock::Dialog(DialogContext::PlaylistSyncPicker) + )); + assert_eq!(app.status_message(), Some("Mirroring Mine onto Spotify")); + assert_eq!(app.get_current_route().id, RouteId::PlaylistSync); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn confirming_the_remove_dialog_forgets_the_link() { + use crate::core::playlist_sync::{Endpoint, Link}; + use crate::core::source::Source; + + let (tx, rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + app.set_playlist_sync_links(vec![Link { + id: "aaaaaaaaaaaa".to_string(), + master: Endpoint { + source: Source::Qobuz, + playlist_uri: "qobuz:playlist:9".to_string(), + name: "Mine".to_string(), + }, + mirrors: Vec::new(), + }]); + app.begin_remove_playlist_sync_link(); + assert_eq!(app.pending_playlist_sync_remove(), Some("aaaaaaaaaaaa")); + app.view.confirm = true; + + handler(Key::Enter, &mut app); + + assert_ne!(app.get_current_route().id, RouteId::Dialog); + assert!(app.view.dialog.is_none()); + assert!(app.pending_playlist_sync_remove().is_none()); + assert!(rx.try_recv().is_ok()); + } } diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 263b455e..e6a6845d 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -29,6 +29,7 @@ mod mouse; mod party; mod playbar; mod playlist; +mod playlist_sync; mod plugin_screen; mod podcasts; mod queue_menu; @@ -570,6 +571,9 @@ fn handle_block_events(key: Key, app: &mut App) { ActiveBlock::Stats => { stats::handler(key, app); } + ActiveBlock::PlaylistSync => { + playlist_sync::handler(key, app); + } #[cfg(feature = "ai-dj")] ActiveBlock::AiDj => { ai_dj::handler(key, app); diff --git a/src/tui/handlers/playlist.rs b/src/tui/handlers/playlist.rs index c09e2d62..365308e5 100644 --- a/src/tui/handlers/playlist.rs +++ b/src/tui/handlers/playlist.rs @@ -2,6 +2,7 @@ use super::common_key_events; use crate::core::action::{Action, OpenTarget}; use crate::core::app::{ActiveBlock, RouteId}; use crate::core::app::{App, DialogContext, PlaylistFolderItem}; +use crate::core::requirement::{Capability, Requirement}; use crate::core::source::Source; use crate::tui::event::Key; @@ -188,6 +189,13 @@ pub fn handler(key: Key, app: &mut App) { } } } + Key::Char('m') + if app + .availability(Requirement::Capability(Capability::PlaylistSync)) + .is_available() => + { + app.apply(Action::OpenPlaylistSyncPicker); + } _ => {} } } @@ -575,4 +583,31 @@ mod tests { Some("Removed saved radio station: Runtime Duplicate") ); } + + #[test] + fn m_on_a_qobuz_playlist_opens_the_mirror_picker() { + use crate::core::plugin_api::PlaylistInfo; + let (tx, _rx) = channel(); + let mut app = + App::new(tx, UserConfig::new(), Some(SystemTime::now())).under_source(Source::Qobuz); + app.qobuz_playlists.push(PlaylistInfo { + uri: "qobuz:playlist:9".to_string(), + name: "Mine".to_string(), + owner: "qobuz".to_string(), + track_count: 3, + id: Some("9".to_string()), + owner_id: None, + collaborative: false, + public: None, + image_url: None, + }); + app.view.selected_playlist_index = Some(0); + + handler(Key::Char('m'), &mut app); + + assert_eq!( + app.get_current_route().active_block, + ActiveBlock::Dialog(DialogContext::PlaylistSyncPicker) + ); + } } diff --git a/src/tui/handlers/playlist_sync.rs b/src/tui/handlers/playlist_sync.rs new file mode 100644 index 00000000..1f4d4ef7 --- /dev/null +++ b/src/tui/handlers/playlist_sync.rs @@ -0,0 +1,105 @@ +use super::common_key_events; +use crate::core::action::Action; +use crate::core::app::App; +use crate::tui::event::Key; + +pub fn handler(key: Key, app: &mut App) { + match key { + k if common_key_events::left_event(k, &app.user_config.keys) => { + common_key_events::handle_left_event(app) + } + k if common_key_events::down_event(k, &app.user_config.keys) => { + let next = common_key_events::on_down_press_handler( + app.playlist_sync_links(), + Some(app.view.playlist_sync_selected_link), + ); + app.view.playlist_sync_selected_link = next; + } + k if common_key_events::up_event(k, &app.user_config.keys) => { + let next = common_key_events::on_up_press_handler( + app.playlist_sync_links(), + Some(app.view.playlist_sync_selected_link), + ); + app.view.playlist_sync_selected_link = next; + } + k if common_key_events::high_event(k) => { + app.view.playlist_sync_selected_link = common_key_events::on_high_press_handler(); + } + k if common_key_events::middle_event(k) => { + let next = common_key_events::on_middle_press_handler(app.playlist_sync_links()); + app.view.playlist_sync_selected_link = next; + } + k if common_key_events::low_event(k) => { + let next = common_key_events::on_low_press_handler(app.playlist_sync_links()); + app.view.playlist_sync_selected_link = next; + } + Key::Char('s') => { + app.apply(Action::RunPlaylistSync); + } + Key::Char('D') => app.begin_remove_playlist_sync_link(), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app::{ActiveBlock, DialogContext}; + use crate::core::playlist_sync::{Endpoint, Link}; + use crate::core::source::Source; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use std::sync::mpsc::{channel, Receiver}; + use std::time::SystemTime; + + fn link(id: &str, name: &str) -> Link { + Link { + id: id.to_string(), + master: Endpoint { + source: Source::Qobuz, + playlist_uri: format!("qobuz:playlist:{id}"), + name: name.to_string(), + }, + mirrors: Vec::new(), + } + } + + fn app_with_links() -> (App, Receiver<IoEvent>) { + let (tx, rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + app.set_playlist_sync_links(vec![ + link("aaaaaaaaaaaa", "Road Trip"), + link("bbbbbbbbbbbb", "Gym"), + ]); + (app, rx) + } + + #[test] + fn d_opens_the_remove_confirm_for_the_highlighted_link() { + let (mut app, _rx) = app_with_links(); + app.view.playlist_sync_selected_link = 1; + + handler(Key::Char('D'), &mut app); + + assert_eq!( + app.get_current_route().active_block, + ActiveBlock::Dialog(DialogContext::RemovePlaylistSyncLinkConfirm) + ); + assert_eq!(app.view.dialog.as_deref(), Some("Gym")); + assert!(!app.view.confirm); + } + + #[test] + fn the_cursor_wraps_over_the_links() { + let (mut app, _rx) = app_with_links(); + + handler(Key::Down, &mut app); + assert_eq!(app.view.playlist_sync_selected_link, 1); + + handler(Key::Down, &mut app); + assert_eq!(app.view.playlist_sync_selected_link, 0); + + handler(Key::Up, &mut app); + assert_eq!(app.view.playlist_sync_selected_link, 1); + } +} diff --git a/src/tui/keymap.rs b/src/tui/keymap.rs index 1370a493..56f12e39 100644 --- a/src/tui/keymap.rs +++ b/src/tui/keymap.rs @@ -55,6 +55,7 @@ const RADIO: Requirement = Requirement::Source(Source::Radio); const LIKE: Requirement = Requirement::Capability(Capability::Like); const PLAYLIST_WRITE: Requirement = Requirement::Capability(Capability::PlaylistWrite); const SEARCH: Requirement = Requirement::Capability(Capability::Search); +const PLAYLIST_SYNC: Requirement = Requirement::Capability(Capability::PlaylistSync); /// Every help row in display order, before the availability filter. pub fn help_entries() -> Vec<HelpEntry> { @@ -363,6 +364,12 @@ pub fn help_entries() -> Vec<HelpEntry> { ), row("Delete saved album", Literal("D"), "Library -> Albums").needs(SPOTIFY), row("Delete saved playlist", Literal("D"), "Playlist").needs(PLAYLIST_WRITE), + row( + "Mirror playlist onto another source", + Literal("m"), + "Playlist", + ) + .needs(PLAYLIST_SYNC), row("Remove favorite radio station", Literal("D"), "Radio").needs(RADIO), row("Follow an artist/playlist", Literal("w"), "Search result").needs(SPOTIFY), row( @@ -423,6 +430,17 @@ pub fn help_entries() -> Vec<HelpEntry> { row("Open Stats screen", Literal("Library sidebar"), "Stats"), row("Cycle stats period", Literal("[ / ]"), "Stats"), row("Play selected top track", Literal("<Enter>"), "Stats").needs(SESSION), + row( + "Open Playlist sync screen", + Literal("Library sidebar"), + "Playlist sync", + ), + row( + "Run every playlist-sync link now", + Literal("s"), + "Playlist sync", + ), + row("Remove selected link", Literal("D"), "Playlist sync"), row("Open sort menu", Literal(","), "Track/Album/Artist list"), row( "Open Listening Party menu", @@ -575,6 +593,10 @@ pub fn default_binding(action: &Action) -> Exposure<TuiSurface> { Action::FollowPlaylist(_) => literal("w", "Search result"), Action::UnfollowPlaylist(_) => literal("D", "Playlist"), Action::DeletePlaylist(_) => literal("D", "Playlist"), + Action::OpenPlaylistSyncPicker => literal("m", "Playlist"), + Action::LinkPlaylistTo(_) => literal(ENTER, "Mirror playlist picker"), + Action::RunPlaylistSync => literal("s", "Playlist sync"), + Action::RemovePlaylistSyncLink(_) => literal(ENTER, "Remove link confirmation"), Action::ToggleSaveTrack(_) => literal("s", "Selected block"), Action::ToggleSaveCurrentItem => binding(|k| k.like_track), Action::SaveAlbum(_) => literal("w", "Search result"), @@ -630,6 +652,7 @@ pub fn default_binding(action: &Action) -> Exposure<TuiSurface> { | LibraryTarget::RecentlyPlayed | LibraryTarget::Friends | LibraryTarget::Stats + | LibraryTarget::PlaylistSync | LibraryTarget::LikedSongs | LibraryTarget::Albums | LibraryTarget::Artists @@ -1099,6 +1122,10 @@ mod tests { Action::FollowPlaylist(text()), Action::UnfollowPlaylist(text()), Action::DeletePlaylist(text()), + Action::OpenPlaylistSyncPicker, + Action::LinkPlaylistTo(Source::Qobuz), + Action::RunPlaylistSync, + Action::RemovePlaylistSyncLink(text()), Action::ToggleSaveTrack(text()), Action::ToggleSaveCurrentItem, Action::SaveAlbum(text()), diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index 7868af3a..6721f057 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -11,6 +11,7 @@ pub mod home; pub mod library; pub mod lyrics; pub mod player; +pub mod playlist_sync; pub mod plugin_screen; pub mod popups; pub mod search; @@ -34,6 +35,7 @@ pub use self::lyrics::draw_lyrics_view; pub use self::player::draw_cover_art_view; pub use self::player::draw_miniplayer; pub use self::player::{draw_device_list, draw_playbar}; +pub use self::playlist_sync::draw_playlist_sync; pub use self::plugin_screen::draw_plugin_screen; pub use self::popups::{ draw_announcement_prompt, draw_community_pin_prompt, draw_dialog, draw_error_screen, @@ -110,6 +112,9 @@ fn draw_route_content(f: &mut Frame<'_>, app: &App, content_area: Rect) { RouteId::Stats => { draw_stats(f, app, content_area); } + RouteId::PlaylistSync => { + draw_playlist_sync(f, app, content_area); + } RouteId::Artists => { draw_artist_table(f, app, content_area); } diff --git a/src/tui/ui/playlist_sync.rs b/src/tui/ui/playlist_sync.rs new file mode 100644 index 00000000..a2d557df --- /dev/null +++ b/src/tui/ui/playlist_sync.rs @@ -0,0 +1,241 @@ +use crate::core::app::{ActiveBlock, App}; +use crate::core::playlist_sync::{Link, UnmatchReason}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use super::util::{draw_selectable_list, get_color, hint_span}; + +pub fn draw_playlist_sync(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { + let current_route = app.get_current_route(); + let highlight_state = ( + current_route.active_block == ActiveBlock::PlaylistSync, + current_route.hovered_block == ActiveBlock::PlaylistSync, + ); + let theme = app.user_config.theme; + + let [links_area, detail_area, help_area] = layout_chunk.layout(&Layout::vertical([ + Constraint::Min(4), + Constraint::Length(10), + Constraint::Length(1), + ])); + + let links = app.playlist_sync_links(); + let mut title = "Playlist sync".to_string(); + if app.playlist_sync_in_flight() { + title.push_str(" (syncing...)"); + } + + if links.is_empty() { + let empty = Paragraph::new( + "No playlist links. Highlight a playlist in the sidebar and press m to mirror it.", + ) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(theme.hint.into())) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled(title, get_color(highlight_state, theme))) + .border_style(get_color(highlight_state, theme)), + ); + f.render_widget(empty, links_area); + } else { + let rows: Vec<String> = links.iter().map(link_row).collect(); + draw_selectable_list( + f, + app, + links_area, + &title, + &rows, + highlight_state, + Some(app.view.playlist_sync_selected_link.min(rows.len() - 1)), + ); + } + + draw_detail(f, app, detail_area); + draw_help_bar(f, app, help_area); +} + +/// One link's row: the master, its source, and the sources it mirrors onto. +fn link_row(link: &Link) -> String { + let mut mirrors = link + .mirrors + .iter() + .map(|mirror| mirror.endpoint.source.label()) + .collect::<Vec<&str>>() + .join(", "); + if mirrors.is_empty() { + mirrors = "no mirrors".to_string(); + } + format!( + "{} [{}] -> {} ({} unmatched)", + link.master.name, + link.master.source.label(), + mirrors, + link.unmatched_count() + ) +} + +/// What the unmatched line says about a track the last run could not place. +fn reason_text(reason: &UnmatchReason) -> String { + match reason { + UnmatchReason::NoCandidate => "no match".to_string(), + UnmatchReason::NotSyncable => "cannot sync".to_string(), + UnmatchReason::SearchFailed(text) => format!("search failed: {text}"), + } +} + +fn draw_detail(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + let selected = app.selected_playlist_sync_link(); + let title = selected + .zip(app.playlist_sync_last_report()) + .and_then(|(link, report)| { + report + .links + .iter() + .find(|entry| entry.id == link.id) + .map(|entry| format!("Last run: {}", entry.line())) + }) + .unwrap_or_else(|| "Details".to_string()); + + let mut lines: Vec<Line> = Vec::new(); + if let Some(link) = selected { + for mirror in &link.mirrors { + lines.push(Line::from(Span::styled( + format!( + "{}: {} matched, {} unmatched, last run {}", + mirror.endpoint.source.label(), + mirror.matches.len(), + mirror.unmatched.len(), + mirror.last_run.as_deref().unwrap_or("never") + ), + Style::default().fg(theme.text.into()), + ))); + for entry in &mirror.unmatched { + lines.push(Line::from(Span::styled( + format!( + " {} - {}: {}", + entry.title, + entry.artist, + reason_text(&entry.reason) + ), + Style::default().fg(theme.hint.into()), + ))); + } + } + } + + let detail = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled( + title, + Style::default().fg(theme.inactive.into()), + )) + .border_style(Style::default().fg(theme.inactive.into())), + ); + f.render_widget(detail, area); +} + +fn draw_help_bar(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + let label = |text: &'static str| Span::styled(text, Style::default().fg(theme.inactive.into())); + + let line = Line::from(vec![ + hint_span("s", theme), + label(" Sync now "), + hint_span("D", theme), + label(" Remove link "), + hint_span("↑/↓", theme), + label(" Select "), + hint_span("←", theme), + label(" Back"), + ]); + + f.render_widget(Paragraph::new(line), area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app::RouteId; + use crate::core::playlist_sync::{Endpoint, Mirror, Unmatched}; + use crate::core::source::Source; + use ratatui::{backend::TestBackend, Terminal}; + use std::collections::BTreeMap; + + fn rendered(app: &App, area: Rect) -> String { + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal.draw(|f| draw_playlist_sync(f, app, area)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..area.height) + .flat_map(|y| (0..area.width).map(move |x| (x, y))) + .filter_map(|(x, y)| buffer.cell((x, y)).map(|c| c.symbol().to_string())) + .collect() + } + + fn endpoint(source: Source, name: &str) -> Endpoint { + Endpoint { + source, + playlist_uri: format!("{}:playlist:9", source.to_config_str()), + name: name.to_string(), + } + } + + #[test] + fn the_sync_screen_lists_links_and_their_mirrors() { + let mut app = App::default_connected(); + let mut matches = BTreeMap::new(); + matches.insert("master-1".to_string(), "mirror-1".to_string()); + app.set_playlist_sync_links(vec![Link { + id: "aaaaaaaaaaaa".to_string(), + master: endpoint(Source::Spotify, "Road Trip"), + mirrors: vec![Mirror { + endpoint: endpoint(Source::Qobuz, "Road Trip"), + matches, + unmatched: vec![Unmatched { + master_key: "master-2".to_string(), + title: "Rare Take".to_string(), + artist: "Nobody".to_string(), + reason: UnmatchReason::NoCandidate, + }], + last_run: Some("2026-09-17T10:00:00Z".to_string()), + }], + }]); + app.push_navigation_stack(RouteId::PlaylistSync, ActiveBlock::PlaylistSync); + + let content = rendered(&app, Rect::new(0, 0, 100, 20)); + + assert!( + content.contains("Road Trip [Spotify] -> Qobuz"), + "link row missing: {content}" + ); + assert!( + content.contains("Qobuz: 1 matched, 1 unmatched, last run 2026-09-17T10:00:00Z"), + "mirror detail missing: {content}" + ); + assert!( + content.contains("Rare Take - Nobody: no match"), + "unmatched detail missing: {content}" + ); + } + + #[test] + fn an_empty_sync_screen_explains_the_m_key() { + let mut app = App::default_connected(); + app.push_navigation_stack(RouteId::PlaylistSync, ActiveBlock::PlaylistSync); + + let content = rendered(&app, Rect::new(0, 0, 100, 20)); + + assert!( + content.contains("press m to mirror it"), + "empty hint missing: {content}" + ); + assert!(content.contains("Sync now"), "help bar missing: {content}"); + } +} diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index a25d68bb..d3df47ee 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -709,9 +709,25 @@ pub fn draw_dialog(f: &mut Frame<'_>, app: &App) { draw_confirmation_dialog(f, app, "Save Shortcut Fallback", text, 66); } } + DialogContext::RemovePlaylistSyncLinkConfirm => { + if let Some(name) = app.view.dialog.as_ref() { + let text = vec![ + Line::from(Span::raw("Remove the playlist link for:")), + Line::from(Span::styled( + name.as_str(), + Style::default().add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), + )), + Line::from(Span::raw("The mirror playlists stay.")), + ]; + draw_confirmation_dialog(f, app, "Remove Link", text, 50); + } + } DialogContext::AddTrackToPlaylistPicker => { draw_add_track_to_playlist_picker_dialog(f, app); } + DialogContext::PlaylistSyncPicker => { + draw_playlist_sync_picker_dialog(f, app); + } } } @@ -897,6 +913,74 @@ fn draw_add_track_to_playlist_picker_dialog(f: &mut Frame<'_>, app: &App) { f.render_widget(footer, vchunks[2]); } +fn draw_playlist_sync_picker_dialog(f: &mut Frame<'_>, app: &App) { + let rect = centered_modal_rect(f.area(), 50, 12); + f.render_widget(Clear, rect); + + let block = Block::default() + .title(Span::styled( + "Mirror Playlist", + Style::default() + .fg(app.user_config.theme.header.into()) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)), + )) + .borders(Borders::ALL) + .style(app.user_config.theme.base_style()) + .border_style(Style::default().fg(app.user_config.theme.inactive.into())); + f.render_widget(block, rect); + + let vchunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(2), + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(rect); + + let master = app + .pending_playlist_sync_master() + .map(|endpoint| endpoint.name.as_str()) + .unwrap_or("Selected playlist"); + + let header = Paragraph::new(Line::from(Span::raw(format!("Mirror \"{master}\" onto:")))) + .wrap(Wrap { trim: true }) + .style(app.user_config.theme.base_style()); + f.render_widget(header, vchunks[0]); + + let sources = app.playlist_sync_picker_sources(); + if sources.is_empty() { + let empty_text = Paragraph::new("No other source can take a mirror") + .style(Style::default().fg(app.user_config.theme.inactive.into())) + .alignment(Alignment::Center); + f.render_widget(empty_text, vchunks[1]); + } else { + let items: Vec<ListItem> = sources + .iter() + .map(|source| ListItem::new(Span::raw(source.label()))) + .collect(); + let selected = app.view.playlist_sync_picker_index.min(sources.len() - 1); + let mut list_state = ListState::default(); + list_state.select(Some(selected)); + + let list = List::new(items) + .style(app.user_config.theme.base_style()) + .highlight_style(Style::default().fg(app.user_config.theme.hovered.into())) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, vchunks[1], &mut list_state); + } + + let footer = Paragraph::new(format!( + "Enter mirror | q cancel | {}/{} or arrows move", + app.user_config.keys.move_down, app.user_config.keys.move_up, + )) + .style(Style::default().fg(app.user_config.theme.inactive.into())) + .alignment(Alignment::Center); + f.render_widget(footer, vchunks[2]); +} + pub fn draw_announcement_prompt(f: &mut Frame<'_>, app: &App) { let Some(announcement) = &app.active_announcement else { return; @@ -1450,3 +1534,55 @@ fn build_popup_line<'a>(pl: &'a PopupLine) -> Line<'a> { } Line::from(Span::styled(pl.text.clone(), style)) } + +#[cfg(test)] +mod playlist_sync_picker_tests { + use super::*; + use crate::core::action::Action; + use crate::core::plugin_api::PlaylistInfo; + use crate::core::source::Source; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn the_mirror_picker_lists_the_offered_sources() { + let mut app = App::default_connected().under_source(Source::Qobuz); + app.qobuz_playlists.push(PlaylistInfo { + uri: "qobuz:playlist:9".to_string(), + name: "Mine".to_string(), + owner: "qobuz".to_string(), + track_count: 3, + id: Some("9".to_string()), + owner_id: None, + collaborative: false, + public: None, + image_url: None, + }); + app.view.selected_playlist_index = Some(0); + app.apply(Action::OpenPlaylistSyncPicker); + + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| draw_dialog(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + let content: String = (0..24) + .flat_map(|y| (0..80).map(move |x| (x, y))) + .filter_map(|(x, y)| buffer.cell((x, y)).map(|c| c.symbol().to_string())) + .collect(); + + assert!( + content.contains("Mirror Playlist"), + "picker title missing: {content}" + ); + assert!( + content.contains("Mirror \"Mine\" onto:"), + "picker header missing: {content}" + ); + assert!( + content.contains("Spotify"), + "the connected session should be offered: {content}" + ); + assert!( + !content.contains("Qobuz"), + "the master's own source must not be offered: {content}" + ); + } +} diff --git a/tools/gates.count b/tools/gates.count index 50b97c1b..c9430de2 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -14,5 +14,5 @@ wildcard_arms_in_action_tree = 0 # target 0, must stay 0 view_writes_outside_tui = 12 # target 0 (producers outside tui/ and core/app/ writing App::view) pub_fields_on_app = 139 # target 1 (App.view stays public for the frontend; the rest go through App methods) direct_playback_context_reads = 90 # target 0 (readers of App::current_playback_context outside the ownership resolver and the snapshot builder, which are excluded) -action_refs_in_tui_handlers = 183 # adoption: may only rise -test_attribute_total = 1900 # adoption: may only rise +action_refs_in_tui_handlers = 187 # adoption: may only rise +test_attribute_total = 2000 # adoption: may only rise From 54cf11f5e1f6e5b09811acdcbd747f0c0dd01ada Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:52:18 +0200 Subject: [PATCH 3/6] fix(sync): count adds per batch and drop literal test passwords --- CHANGELOG.md | 2 + docs/playlist-sync.md | 11 +++-- src/infra/playlist_sync/run.rs | 60 ++++++++++++++++++++++++--- src/infra/subsonic/mod.rs | 12 +++--- src/tui/handlers/common_key_events.rs | 8 ++++ src/tui/handlers/mod.rs | 23 ++++++++++ src/tui/handlers/playlist_sync.rs | 10 +++++ tools/gates.count | 2 +- 8 files changed, 112 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fec4650f..4b02e126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Playlist sync across sources**: link one playlist as the master and mirror it onto Spotify, Qobuz, Subsonic or YouTube. Press `m` on a sidebar playlist and pick the mirror source: a playlist with the master's name is adopted there when you have one, or created. Every start syncs the changes, `s` on the new Playlist sync screen runs it now, and `spotatui sync [--link NAME] [--dry-run]` does the same from a shell. Tracks are matched by ISRC first, then by title, artist and duration; a YouTube mirror is filled through `yt-dlp` searches, so its first run takes minutes. The master wins: additions land on every mirror in master order, removals follow, and anything on a mirror that matches nothing in the master is never touched. Links and their match caches live in `playlist_sync.yml` in the state directory. See `docs/playlist-sync.md`. + - **Optional theme-aware cover-art dithering** (`cover-art` builds): Enable it in Settings or with `behavior.cover_art_dither: true` to render two-color art in the playbar, full-screen view, and plugin widgets. Stucki is the default algorithm; Bayer 8×8 and Atkinson are also available, with a pixel scale from 1 to 3. The accent follows the current theme by default, or can be set independently with `theme.cover_art_dither_color`. Terminal cover-art requirements still apply. ### Fixed diff --git a/docs/playlist-sync.md b/docs/playlist-sync.md index 977fed75..ef83f9cc 100644 --- a/docs/playlist-sync.md +++ b/docs/playlist-sync.md @@ -28,8 +28,10 @@ The sync is one way. On every run: - Tracks added to the master are appended to each mirror, in master order. - Tracks removed from the master are removed from each mirror. -- A track the sync never added is never touched. Add what you like to a mirror - by hand and the sync leaves it alone. +- A row the sync paired with a master track, or added itself, follows the + master from then on: when the track leaves the master, that row goes. A row + that matches nothing in the master is never touched, so add what you like to + a mirror by hand and the sync leaves it alone. - A track the sync did add and you then deleted on the mirror comes back on the next run. The master is the truth. @@ -91,8 +93,9 @@ server configured. Enter looks for a playlist of yours with the master's name on that source and adopts it, or creates an empty one when there is none; then it records the link and starts a run. An adopted playlist keeps every track it already has: the run pairs them with the master by ISRC, title and -duration before it searches anything, adds what is missing, and only ever -removes tracks the sync itself added. Enter also opens the sync screen, where the run's progress shows. Press +duration before it searches anything, adds what is missing, and leaves every +row that matches nothing in the master alone. A paired row follows the master +from then on, like a row the sync added. Enter also opens the sync screen, where the run's progress shows. Press `m` on the same master again to add a second mirror to the same link. The **Playlist sync** row in the Library block of the sidebar opens the sync diff --git a/src/infra/playlist_sync/run.rs b/src/infra/playlist_sync/run.rs index 07a61118..7cb27d6b 100644 --- a/src/infra/playlist_sync/run.rs +++ b/src/infra/playlist_sync/run.rs @@ -481,12 +481,11 @@ async fn flush_adds<C: SyncClient>( ) -> Result<()> { let writes = plan(master, on_mirror, matches); let rows = add_rows(master, &writes.to_add, resolved); - if rows.is_empty() { - return Ok(()); + for chunk in rows.chunks(BATCH) { + client.add(uri, chunk).await?; + on_mirror.extend(chunk.iter().map(|row| row.key.clone())); + *added += chunk.len(); } - client.add(uri, &rows).await?; - on_mirror.extend(rows.iter().map(|row| row.key.clone())); - *added += rows.len(); Ok(()) } @@ -578,6 +577,8 @@ mod tests { created: std::sync::Mutex<Vec<String>>, /// Playlists this source already has, by exact name. existing: BTreeMap<String, String>, + /// The add call, counted from one, from which every add fails. + fail_add_from: Option<usize>, } impl FakeClient { @@ -636,6 +637,10 @@ mod tests { } async fn add(&self, _playlist_uri: &str, tracks: &[SyncTrack]) -> Result<()> { + let call = self.added.lock().unwrap().len() + 1; + if self.fail_add_from.is_some_and(|from| call >= from) { + return Err(anyhow!("the mirror refused the write")); + } self.added.lock().unwrap().push(tracks.to_vec()); Ok(()) } @@ -1142,6 +1147,51 @@ mod tests { ); } + #[tokio::test] + async fn a_failed_add_keeps_the_batches_that_landed_before_it() { + let dir = tempfile::tempdir().unwrap(); + let (app, _rx) = test_app(); + let path = seeded_store( + &dir, + vec![link_at( + "aaa", + "Road Trip", + "spotify:playlist:1", + vec![mirror_at("qobuz:playlist:1", &[])], + )], + ); + let master: Vec<SyncTrack> = (1..=25) + .map(|n| track(&format!("m{n}"), &format!("Song {n}"))) + .collect(); + let found: Vec<(String, SyncTrack)> = (1..=25) + .map(|n| { + ( + format!("m{n}"), + track(&format!("x{n}"), &format!("Song {n}")), + ) + }) + .collect(); + let clients = fake_clients(vec![ + ("spotify:playlist:1", fake(master)), + ( + "qobuz:playlist:1", + FakeClient { + hits: found.into_iter().collect(), + fail_add_from: Some(2), + ..Default::default() + }, + ), + ]); + + let report = run_all(&clients, &app, &path, None, false, true).await; + + let mirror = &clients.by_uri["qobuz:playlist:1"]; + assert_eq!(mirror.added_keys().len(), 1); + assert_eq!(report.links[0].added, 10); + assert!(report.failed()); + assert_eq!(saved_mirror(&path, 0, 0).matches.len(), 20); + } + #[tokio::test] async fn a_startup_run_keeps_a_no_candidate_verdict_and_a_manual_run_searches_again() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/infra/subsonic/mod.rs b/src/infra/subsonic/mod.rs index dbacda26..e2a266e9 100644 --- a/src/infra/subsonic/mod.rs +++ b/src/infra/subsonic/mod.rs @@ -1199,7 +1199,7 @@ mod tests { #[tokio::test] async fn create_playlist_encodes_the_name_and_returns_the_new_id() { let (base, server) = serve(vec![("200 OK", CREATE_PLAYLIST)]).await; - let id = SubsonicSource::new(base, "u", "p") + let id = SubsonicSource::new(base, "u", String::new()) .create_playlist("Road Trip") .await .unwrap(); @@ -1214,7 +1214,7 @@ mod tests { #[tokio::test] async fn add_tracks_sends_one_song_id_to_add_per_track() { let (base, server) = serve(vec![("200 OK", UPDATE_OK)]).await; - SubsonicSource::new(base, "u", "p") + SubsonicSource::new(base, "u", String::new()) .add_tracks( "subsonic:playlist:7", &["subsonic:track:101".to_string(), "102".to_string()], @@ -1232,7 +1232,7 @@ mod tests { #[tokio::test] async fn remove_tracks_reads_the_playlist_then_removes_the_highest_index_first() { let (base, server) = serve(vec![("200 OK", DUPLICATE_ENTRIES), ("200 OK", UPDATE_OK)]).await; - SubsonicSource::new(base, "u", "p") + SubsonicSource::new(base, "u", String::new()) .remove_tracks("subsonic:playlist:7", &["subsonic:track:101".to_string()]) .await .unwrap(); @@ -1247,7 +1247,7 @@ mod tests { #[tokio::test] async fn adding_no_tracks_makes_no_request() { - SubsonicSource::new("http://127.0.0.1:1", "u", "p") + SubsonicSource::new("http://127.0.0.1:1", "u", String::new()) .add_tracks("subsonic:playlist:7", &[]) .await .unwrap(); @@ -1256,7 +1256,7 @@ mod tests { #[tokio::test] async fn sync_playlist_tracks_takes_the_first_isrc() { let (base, server) = serve(vec![("200 OK", SYNC_PLAYLIST)]).await; - let tracks = SubsonicSource::new(base, "u", "p") + let tracks = SubsonicSource::new(base, "u", String::new()) .sync_playlist_tracks("subsonic:playlist:7") .await .unwrap(); @@ -1279,7 +1279,7 @@ mod tests { #[tokio::test] async fn sync_search_asks_for_songs_only() { let (base, server) = serve(vec![("200 OK", SEARCH3)]).await; - let found = SubsonicSource::new(base, "u", "p") + let found = SubsonicSource::new(base, "u", String::new()) .sync_search("the beatles yesterday", 10) .await .unwrap(); diff --git a/src/tui/handlers/common_key_events.rs b/src/tui/handlers/common_key_events.rs index e7af4853..10aed843 100644 --- a/src/tui/handlers/common_key_events.rs +++ b/src/tui/handlers/common_key_events.rs @@ -145,6 +145,14 @@ mod tests { use super::*; use crate::core::source::Source; + #[test] + fn the_playlist_sync_route_focuses_its_block_from_the_sidebar() { + assert_eq!( + content_active_block_for_route(&RouteId::PlaylistSync), + Some(ActiveBlock::PlaylistSync) + ); + } + #[test] fn test_on_down_press_handler() { let data = vec!["Choice 1", "Choice 2", "Choice 3"]; diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index e6a6845d..207a27ae 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -702,6 +702,29 @@ mod tests { app } + #[test] + fn keys_on_the_playlist_sync_block_reach_its_handler() { + use crate::core::playlist_sync::{Endpoint, Link}; + let mut app = App::default_connected(); + app.set_playlist_sync_links(vec![Link { + id: "aaaaaaaaaaaa".to_string(), + master: Endpoint { + source: crate::core::source::Source::Spotify, + playlist_uri: "spotify:playlist:1".to_string(), + name: "Road Trip".to_string(), + }, + mirrors: Vec::new(), + }]); + app.push_navigation_stack(RouteId::PlaylistSync, ActiveBlock::PlaylistSync); + + handle_block_events(Key::Char('D'), &mut app); + + assert_eq!( + app.get_current_route().active_block, + ActiveBlock::Dialog(crate::core::app::DialogContext::RemovePlaylistSyncLinkConfirm) + ); + } + #[test] fn search_key_on_the_error_page_dismisses_the_error_first() { let mut app = App::default_connected(); diff --git a/src/tui/handlers/playlist_sync.rs b/src/tui/handlers/playlist_sync.rs index 1f4d4ef7..2ac977e1 100644 --- a/src/tui/handlers/playlist_sync.rs +++ b/src/tui/handlers/playlist_sync.rs @@ -89,6 +89,16 @@ mod tests { assert!(!app.view.confirm); } + #[test] + fn s_asks_for_one_run_of_every_link() { + let (mut app, rx) = app_with_links(); + + handler(Key::Char('s'), &mut app); + + assert!(rx.try_recv().is_ok()); + assert!(rx.try_recv().is_err()); + } + #[test] fn the_cursor_wraps_over_the_links() { let (mut app, _rx) = app_with_links(); diff --git a/tools/gates.count b/tools/gates.count index c9430de2..9f0b26b7 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -15,4 +15,4 @@ view_writes_outside_tui = 12 # target 0 (producers outside tui/ and co pub_fields_on_app = 139 # target 1 (App.view stays public for the frontend; the rest go through App methods) direct_playback_context_reads = 90 # target 0 (readers of App::current_playback_context outside the ownership resolver and the snapshot builder, which are excluded) action_refs_in_tui_handlers = 187 # adoption: may only rise -test_attribute_total = 2000 # adoption: may only rise +test_attribute_total = 2004 # adoption: may only rise From d803bb20ada92ce2858806c4384af0aa95e9f478 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:07:21 +0200 Subject: [PATCH 4/6] docs(sync): state that removal works by track id --- docs/playlist-sync.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/playlist-sync.md b/docs/playlist-sync.md index ef83f9cc..882014b2 100644 --- a/docs/playlist-sync.md +++ b/docs/playlist-sync.md @@ -32,6 +32,9 @@ The sync is one way. On every run: master from then on: when the track leaves the master, that row goes. A row that matches nothing in the master is never touched, so add what you like to a mirror by hand and the sync leaves it alone. +- Removal works by track id. When a track leaves the master, every row on the + mirror with that id goes, a second copy you added by hand included. The sync + keeps no per-row ownership yet. - A track the sync did add and you then deleted on the mirror comes back on the next run. The master is the truth. From 4eba9c3a3eb121cb7b45ae94cee8f5e73747fc7d Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:20:15 +0200 Subject: [PATCH 5/6] fix(sync): remove one row per stale track on Qobuz and Subsonic --- docs/playlist-sync.md | 7 ++++--- src/infra/qobuz/mod.rs | 10 ++++++---- src/infra/subsonic/mod.rs | 14 ++++++++------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/playlist-sync.md b/docs/playlist-sync.md index 882014b2..fa361075 100644 --- a/docs/playlist-sync.md +++ b/docs/playlist-sync.md @@ -32,9 +32,10 @@ The sync is one way. On every run: master from then on: when the track leaves the master, that row goes. A row that matches nothing in the master is never touched, so add what you like to a mirror by hand and the sync leaves it alone. -- Removal works by track id. When a track leaves the master, every row on the - mirror with that id goes, a second copy you added by hand included. The sync - keeps no per-row ownership yet. +- Removal takes one row per track. When a track leaves the master, the sync + removes the last row on the mirror with that id and leaves an earlier copy + you added by hand. Spotify is the exception: its API removes a track by id + from every position, so a hand-added copy of the same track goes with it. - A track the sync did add and you then deleted on the mirror comes back on the next run. The master is the truth. diff --git a/src/infra/qobuz/mod.rs b/src/infra/qobuz/mod.rs index ad20b0bd..5d97ab6c 100644 --- a/src/infra/qobuz/mod.rs +++ b/src/infra/qobuz/mod.rs @@ -633,10 +633,12 @@ fn track_id_of(uri: &str) -> &str { } /// The playlist item ids of `track_ids`, in playlist order. +/// The item id of the last occurrence of each wanted track: the sync adds one +/// row per track, so one row per track goes and an earlier hand-added copy stays. fn item_ids_for(items: &[types::Track], track_ids: &[&str]) -> Vec<String> { - items + track_ids .iter() - .filter(|t| track_ids.contains(&t.id.as_str())) + .filter_map(|id| items.iter().rev().find(|t| t.id == *id)) .filter_map(|t| t.playlist_track_id.clone()) .collect() } @@ -1137,7 +1139,7 @@ mod tests { fn item_ids_map_track_ids_to_playlist_item_ids() { let playlist: types::Playlist = serde_json::from_str(PLAYLIST_ITEMS).unwrap(); let items = playlist.tracks.unwrap().items; - assert_eq!(item_ids_for(&items, &["5001"]), vec!["90001", "90003"]); + assert_eq!(item_ids_for(&items, &["5001"]), vec!["90003"]); assert_eq!(item_ids_for(&items, &["5002"]), vec!["90002"]); assert!(item_ids_for(&items, &["9999"]).is_empty()); } @@ -1222,7 +1224,7 @@ mod tests { assert!(seen[0].0.contains("extra=tracks")); assert!(seen[1].0.contains("/playlist/deleteTracks")); assert!(sent(&seen[1]).contains("playlist_id=111")); - assert!(sent(&seen[1]).contains("playlist_track_ids=90001%2C90003")); + assert!(sent(&seen[1]).contains("playlist_track_ids=90003")); } #[tokio::test] diff --git a/src/infra/subsonic/mod.rs b/src/infra/subsonic/mod.rs index e2a266e9..86336e52 100644 --- a/src/infra/subsonic/mod.rs +++ b/src/infra/subsonic/mod.rs @@ -411,13 +411,15 @@ fn track_id_of(uri: &str) -> &str { } /// The positions of `track_ids` in the playlist, ascending. +/// The position of the last occurrence of each wanted song: the sync adds one +/// row per track, so one row per track goes and an earlier hand-added copy stays. fn song_indices_for(entries: &[types::SubsonicSong], track_ids: &[&str]) -> Vec<usize> { - entries + let mut indices: Vec<usize> = track_ids .iter() - .enumerate() - .filter(|(_, s)| track_ids.contains(&s.id.as_str())) - .map(|(index, _)| index) - .collect() + .filter_map(|id| entries.iter().rposition(|s| s.id == *id)) + .collect(); + indices.sort_unstable(); + indices } impl From<&types::SubsonicPlaylist> for PlaylistInfo { @@ -1191,7 +1193,7 @@ mod tests { fn song_indices_map_ids_to_every_position() { let envelope: SubsonicEnvelope = serde_json::from_str(DUPLICATE_ENTRIES).unwrap(); let entries = envelope.response.playlist.unwrap().entry; - assert_eq!(song_indices_for(&entries, &["101"]), vec![0, 2]); + assert_eq!(song_indices_for(&entries, &["101"]), vec![2]); assert_eq!(song_indices_for(&entries, &["102"]), vec![1]); assert!(song_indices_for(&entries, &["999"]).is_empty()); } From a7c4ff31f006f221c30e78474a7851f09d8ba4b5 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:20:57 +0200 Subject: [PATCH 6/6] test(subsonic): one index per stale track in the removal --- src/infra/subsonic/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/infra/subsonic/mod.rs b/src/infra/subsonic/mod.rs index 86336e52..0b43f51a 100644 --- a/src/infra/subsonic/mod.rs +++ b/src/infra/subsonic/mod.rs @@ -1244,7 +1244,8 @@ mod tests { assert!(seen[0].contains("&id=7")); assert!(seen[1].starts_with("/rest/updatePlaylist.view?")); assert!(seen[1].contains("&playlistId=7")); - assert!(seen[1].contains("&songIndexToRemove=2&songIndexToRemove=0")); + assert!(seen[1].contains("&songIndexToRemove=2")); + assert!(!seen[1].contains("songIndexToRemove=0")); } #[tokio::test]