diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 31fe129..85b0158 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -4450,6 +4450,126 @@ impl MetadataClient { } /// Catalog search: artists, albums and tracks for the query, in parallel. + /// Tracks only, for identity lookups that do not want an artist or album + /// panel. `catalog_search` fires three Deezer calls per query because it + /// feeds a search PAGE; verifying an import's rows one at a time made + /// that three times the traffic for two answers nobody read — a 258-row + /// playlist cost 774 calls. This is the one call that matters. + /// + /// The query is passed through verbatim, so callers may use Deezer's + /// field syntax (`artist:"…" track:"…"`), which finds records its plain + /// search returns nothing at all for. + /// Every track the ARTIST PAGE would show for `artist_name`, for a + /// lookup that struck out against Deezer's search index. The index is + /// known to omit records Deezer itself sells, and the artist page is + /// richer still: `catalog_artist` supplements the Deezer shelf with + /// MusicBrainz/Apple releases (synthetic ids), which is why Mock + /// Orange's "Disguised As Ghosts" shows on the page while no Deezer + /// query can surface it. This walk reads that same shelf, so anything + /// the page can show, an import can match. + /// + /// `title` steers the album order only: releases whose names share a + /// word with the wanted title are opened first (a single is usually + /// named after its track), and the walk stops early once any opened + /// tracklist carries the base name. + /// + /// Deliberately heavier than a search (the artist page build is disk- + /// cached for a day, tracklists likewise): it runs per missing ARTIST, + /// not per row, and the caller caches what it hands back. + pub async fn catalog_artist_track_candidates( + self: &std::sync::Arc, + artist_name: &str, + title: &str, + ) -> Vec { + const MAX_ALBUM_FETCHES: usize = 30; + const MAX_TRACKS: usize = 600; + + // The artist, by name — ranked exactly the way the search page ranks + // them, so this walk lands on the same artist a click would. + let key = normalize(artist_name); + if key.is_empty() { + return Vec::new(); + } + let url = format!( + "https://api.deezer.com/search/artist?q={}&limit=5", + urlencode(artist_name) + ); + let mut artists: Vec = self + .deezer(&url) + .await + .as_ref() + .and_then(|b| b.get("data")) + .and_then(|d| d.as_array()) + .map(|a| a.iter().filter_map(parse_artist).collect()) + .unwrap_or_default(); + artists.sort_by(|a, b| { + artist_name_relevance(&b.name, &key) + .cmp(&artist_name_relevance(&a.name, &key)) + .then(b.fans.unwrap_or(0).cmp(&a.fans.unwrap_or(0))) + }); + let Some(artist) = artists + .into_iter() + .find(|a| artist_name_relevance(&a.name, &key) > 0) + else { + return Vec::new(); + }; + + let Some(page) = self.catalog_artist(artist.id).await else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + // The curated top tracks are already here — free, and for a small + // artist they cover most of the catalog. + out.extend(page.popular_tracks.iter().cloned()); + + let wanted_words: std::collections::HashSet = normalize_words(title); + let mut releases: Vec<&CatalogAlbum> = page.albums.iter().collect(); + releases.sort_by_key(|album| { + let shared = normalize_words(&album.title) + .intersection(&wanted_words) + .count(); + std::cmp::Reverse(shared) + }); + + let base = normalize(title); + let mut found_by_name = false; + for album in releases.into_iter().take(MAX_ALBUM_FETCHES) { + if found_by_name || out.len() >= MAX_TRACKS { + break; + } + let Some(album_page) = self.catalog_album(album.id).await else { + continue; + }; + for track in &album_page.tracks { + if !base.is_empty() { + let candidate = normalize(&track.title); + if candidate.contains(&base) || (!candidate.is_empty() && base.contains(&candidate)) + { + found_by_name = true; + } + } + } + out.extend(album_page.tracks.iter().cloned()); + } + out.truncate(MAX_TRACKS); + out + } + + pub async fn catalog_search_tracks(&self, query: &str, limit: u32) -> Vec { + let url = format!( + "https://api.deezer.com/search/track?q={}&limit={}", + urlencode(query), + limit.clamp(1, 25) + ); + self.deezer(&url) + .await + .as_ref() + .and_then(|b| b.get("data")) + .and_then(|d| d.as_array()) + .map(|a| a.iter().filter_map(parse_track).collect()) + .unwrap_or_default() + } + pub async fn catalog_search(&self, query: &str) -> CatalogSearch { let q = urlencode(query); let artist_url = format!("https://api.deezer.com/search/artist?q={q}&limit=8"); @@ -12710,6 +12830,15 @@ fn escape(s: &str) -> String { } /// Loose artist comparison: lowercase, alphanumerics only. +/// The normalized words of a display title, for cheap overlap tests. +fn normalize_words(s: &str) -> std::collections::HashSet { + s.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() >= 3) + .map(str::to_string) + .collect() +} + fn normalize(s: &str) -> String { s.to_lowercase() .chars() diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cc58c8c..3f4ebc9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1557,6 +1557,32 @@ pub async fn catalog_search( Ok(state.metadata.catalog_search(&query).await) } +#[tauri::command] +pub async fn catalog_search_tracks( + state: State<'_, LibraryState>, + query: String, + limit: Option, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state + .metadata + .catalog_search_tracks(&query, limit.unwrap_or(10)) + .await) +} + +#[tauri::command] +pub async fn catalog_artist_track_candidates( + state: State<'_, LibraryState>, + artist: String, + title: String, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state + .metadata + .catalog_artist_track_candidates(&artist, &title) + .await) +} + #[tauri::command] pub async fn catalog_track_by_isrc( state: State<'_, LibraryState>, @@ -3592,7 +3618,16 @@ pub async fn save_to_library( let mut organized = 0u32; let mut queued = 0u32; for f in files { - let cached = crate::playback::cache_path(&app, &f.username, &f.path).filter(|c| c.exists()); + // Only a BYTE-COMPLETE cache saves directly: a mid-stream (or + // abandoned) cache file is a prefix of the track, and the organizer + // would file the truncation as the real thing. Anything shorter + // falls through — to the in-flight transfer if one is running, else + // to a fresh download of the known file. + let cached = crate::playback::cache_path(&app, &f.username, &f.path) + .filter(|c| c.exists()) + .filter(|c| { + f.size == 0 || c.metadata().map(|m| m.len() == f.size).unwrap_or(false) + }); if let Some(cache) = cached { if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? { organized += 1; @@ -3639,6 +3674,17 @@ pub async fn save_to_library( Ok((organized, queued)) } +/// Byte length of the stream-cache copy of one peer file, if it exists. +/// The caller compares against the peer-stated size to tell a finished +/// cache from a mid-stream prefix. Read-only — never contacts the peer. +#[tauri::command] +pub fn stream_cache_len(app: tauri::AppHandle, username: String, path: String) -> Option { + crate::playback::cache_path(&app, &username, &path) + .filter(|c| c.exists()) + .and_then(|c| c.metadata().ok()) + .map(|m| m.len()) +} + /// Copy a completed stream cache into staging, then run the normal tagging and /// library organizer. Returns false only when the copy itself did not happen. async fn organize_cached_save( @@ -4172,19 +4218,100 @@ pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result) -> Result { - let bytes = tauri::async_runtime::spawn_blocking(move || -> Result, String> { - let raw = std::fs::read(&path).map_err(|e| e.to_string())?; - let Some(edge) = max_edge else { return Ok(raw) }; - // An undecodable or already-small source falls through to the original - // bytes: a thumbnail is an optimisation, never a reason to show nothing. - match pipeline::thumbnail_jpeg(&raw, edge) { - Ok(Some(thumb)) => Ok(thumb), - _ => Ok(raw), - } - }) +pub async fn album_art( + app: tauri::AppHandle, + path: String, + max_edge: Option, +) -> Result { + // Thumbnails used to cost a FULL master decode + Lanczos resize per + // request, on the unbounded blocking pool, with nothing remembered — + // so a Library page scrolling past a few hundred tiles pinned every + // core in image decodes and the AUDIO thread started to starve (the + // player decodes on its own std::thread, which only chops when the + // whole machine is saturated). Two changes, and either alone would + // have hidden the other's need: + // 1. a decode SEMAPHORE — at most 2 covers decode at once, ever; + // 2. a thumbnail disk cache keyed by (path, mtime, len, edge) — each + // cover is decoded once per size PER LIFETIME, then it's a small + // file read. Covers change by being replaced (cover_pick writes a + // new file), so the mtime+len key self-invalidates; orphaned + // entries are tens of KB and left to lie. + // Master requests (no edge: the player-bar hero) stay a plain read — + // no decode, no permit, no cache. + let thumb_dir = app + .path() + .app_cache_dir() + .ok() + .map(|d| d.join("art-thumbs-v1")); + + // Phase 1 — cheap and permit-free: stat, cache probe, raw read. + let probe_path = path.clone(); + let probe_dir = thumb_dir.clone(); + let (bytes, write_to) = tauri::async_runtime::spawn_blocking( + move || -> Result<(Vec, Option), String> { + let Some(edge) = max_edge else { + return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None)); + }; + let target = probe_dir.and_then(|dir| { + let meta = std::fs::metadata(&probe_path).ok()?; + let mtime = meta + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos(); + let mut hash: u64 = 0xcbf29ce484222325; + for b in probe_path + .as_bytes() + .iter() + .chain(mtime.to_le_bytes().iter()) + .chain(meta.len().to_le_bytes().iter()) + { + hash ^= u64::from(*b); + hash = hash.wrapping_mul(0x100000001b3); + } + Some(dir.join(format!("{hash:016x}-{edge}.jpg"))) + }); + if let Some(t) = &target { + if let Ok(cached) = std::fs::read(t) { + return Ok((cached, None)); + } + } + Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, target)) + }, + ) .await .map_err(|e| e.to_string())??; + + // Phase 2 — the decode, gated. Runs only on a cache miss. + let bytes = match write_to { + None => bytes, + Some(target) => { + let _permit = ART_DECODE_SLOTS + .acquire() + .await + .map_err(|e| e.to_string())?; + let edge = max_edge.unwrap_or(0); + tauri::async_runtime::spawn_blocking(move || { + // An undecodable or already-small source falls through to the + // original bytes: a thumbnail is an optimisation, never a + // reason to show nothing. Either result is CACHED, so the + // "already small" discovery (itself a full decode) is also + // paid once. + let out = match pipeline::thumbnail_jpeg(&bytes, edge) { + Ok(Some(thumb)) => thumb, + _ => bytes, + }; + if let Some(dir) = target.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&target, &out); // failure-soft + out + }) + .await + .map_err(|e| e.to_string())? + } + }; use base64::Engine as _; Ok(format!( "data:image/jpeg;base64,{}", @@ -4192,6 +4319,68 @@ pub async fn album_art(path: String, max_edge: Option) -> Result Result, String> { + let Some(cache) = playback::cache_path(&app, &username, &path) else { + return Ok(None); + }; + // Beside the library, not in AppData. The path is persisted onto the + // playlist row, and `playlist_items.art_path` is one of the columns the + // portable-library rebase rewrites — a cover kept in AppData would opt + // out of that and dangle the first time the drive is opened elsewhere. + let dir = config::ensure_library_data_dir(&app, &config::load(&app)) + .map_err(|e| e.to_string())? + .join("stream-art"); + tauri::async_runtime::spawn_blocking(move || -> Result, String> { + // A transfer still in flight has no cache file yet (the bytes land in + // `.partial`), and a tag read over a half-written one simply + // fails — either way the answer is "nothing yet", and the caller asks + // again when the transfer completes. + if !cache.exists() { + return Ok(None); + } + let Ok(Some(bytes)) = tagger::extract_art(&cache) else { + return Ok(None); + }; + if bytes.is_empty() { + return Ok(None); + } + let mut hash: u64 = 0xcbf29ce484222325; + for b in &bytes { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + // No extension the loader trusts: JPEG and PNG both appear in tags and + // the art pipeline sniffs the bytes anyway. + let out = dir.join(format!("{hash:016x}.img")); + if !matches!(std::fs::metadata(&out), Ok(md) if md.len() == bytes.len() as u64) { + std::fs::write(&out, &bytes).map_err(|e| e.to_string())?; + } + Ok(Some(out.to_string_lossy().to_string())) + }) + .await + .map_err(|e| e.to_string())? +} + /// Open an http(s) URL in the system's default browser. The WebView blocks /// `target="_blank"` navigation (no new-window handler is installed), so /// external links such as artist socials route through diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8af782f..6522712 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod pipeline; mod playback; mod playlist_formats; mod playlists; +mod provider_playlist; mod preview; mod setlist_scan; mod shazam; @@ -20,6 +21,7 @@ mod smtc; mod state; mod thumbbar; mod vocal_sep; +mod youtube_playlist; mod youtube_setlist; mod ytdlp; @@ -72,6 +74,7 @@ pub fn run() { commands::download_file, commands::copy_stream_to_folder, commands::save_to_library, + commands::stream_cache_len, commands::cancel_download, commands::show_in_folder, commands::refresh_artist_art, @@ -85,6 +88,7 @@ pub fn run() { commands::library_album_stats, commands::album_tracks, commands::album_art, + commands::stream_embedded_art, commands::refresh_album_art, commands::refresh_all_album_art, commands::get_settings, @@ -122,6 +126,9 @@ pub fn run() { playlists::playlist_import_folder, playlists::playlist_import_text, playlists::playlist_import_youtube, + playlists::playlist_import_youtube_playlist, + playlists::playlist_import_link, + playlists::playlist_import_track_links, playlists::playlist_import_commit, setlist_scan::playlist_scan_youtube, setlist_scan::playlist_pick_set_file, @@ -143,6 +150,8 @@ pub fn run() { lyrics::lyrics_save_text, lyrics::lyrics_clear, commands::catalog_search, + commands::catalog_search_tracks, + commands::catalog_artist_track_candidates, commands::catalog_track_by_isrc, import::import_scan, import::import_portable_root, diff --git a/src-tauri/src/playlist_formats.rs b/src-tauri/src/playlist_formats.rs index f343a65..c1a732e 100644 --- a/src-tauri/src/playlist_formats.rs +++ b/src-tauri/src/playlist_formats.rs @@ -233,7 +233,7 @@ fn parse_m3u(bytes: &[u8]) -> Result { /// "Artist - Title" (or the em/en-dash forms) split. Returns (artist, title); /// no separator means the whole display string is the title. -fn split_artist_title(display: &str) -> (Option, Option) { +pub(crate) fn split_artist_title(display: &str) -> (Option, Option) { let display = display.trim(); if display.is_empty() { return (None, None); diff --git a/src-tauri/src/playlists.rs b/src-tauri/src/playlists.rs index aa2c360..91cadac 100644 --- a/src-tauri/src/playlists.rs +++ b/src-tauri/src/playlists.rs @@ -946,6 +946,196 @@ pub async fn playlist_import_youtube( Ok(draft) } +/// Import a YouTube PLAYLIST — one row per video, off the playlist page's +/// own metadata (title, channel, length). No tools, no audio, no listening: +/// this is the same honest metadata-import posture as the DJ-set door, for +/// links where each video IS one track. +#[tauri::command] +pub async fn playlist_import_youtube_playlist( + lib: State<'_, LibraryState>, + url: String, +) -> Result { + let fetched = crate::youtube_playlist::fetch(&url).await?; + let parsed = crate::youtube_playlist::to_parsed(&fetched); + let source = if fetched.author.is_empty() { + fetched.title.clone() + } else { + format!("{} — {}", fetched.author, fetched.title) + }; + let mut draft = resolve_draft(&lib, parsed, source, None).await?; + draft.format = "youtube-playlist".into(); + draft.name = Some(fetched.title.clone()); + // The first video's thumbnail is the playlist's default face — the same + // choice YouTube itself makes. + if let Some(first) = fetched.rows.first() { + draft.cover_url = Some(format!( + "https://i.ytimg.com/vi/{}/maxresdefault.jpg", + first.video_id + )); + } + draft.warnings.insert( + 0, + format!( + "{} video{} from the playlist.", + fetched.rows.len(), + if fetched.rows.len() == 1 { "" } else { "s" } + ), + ); + draft.warnings.extend(fetched.warnings); + Ok(draft) +} + +/// Import a playlist from any supported LINK — YouTube, Spotify, Apple +/// Music, TIDAL, Deezer — one door, dispatched by host. Each source is +/// metadata-only and anonymous; rows ride the same resolve → review → +/// commit pipeline as every other import. +#[tauri::command] +pub async fn playlist_import_link( + lib: State<'_, LibraryState>, + url: String, +) -> Result { + // YouTube keeps its own module (lockup pages, continuations, title + // cleaning); everything else is a provider fetch. + if crate::youtube_playlist::playlist_id_from_url(&url).is_some() { + return playlist_import_youtube_playlist(lib, url).await; + } + let fetched = crate::provider_playlist::fetch(&url).await?; + let parsed = crate::provider_playlist::to_parsed(&fetched); + let source = if fetched.author.is_empty() { + fetched.title.clone() + } else { + format!("{} — {}", fetched.author, fetched.title) + }; + let mut draft = resolve_draft(&lib, parsed, source, None).await?; + draft.format = fetched.provider.into(); + draft.name = Some(fetched.title.clone()); + draft.cover_url = fetched.cover_url.clone(); + draft.warnings.insert( + 0, + format!( + "{} track{} from the playlist.", + fetched.rows.len(), + if fetched.rows.len() == 1 { "" } else { "s" } + ), + ); + draft.warnings.extend(fetched.warnings); + Ok(draft) +} + +/// Track links one paste will look up. Each costs a page fetch, so this is a +/// politeness ceiling as much as a sanity one. +const MAX_PASTED_LINKS: usize = 2000; + +/// Import a pasted block of TRACK links — the way past a service's own +/// playlist ceiling. Spotify's public playlist page stops at 100 tracks, but +/// every track has its own public page, and Spotify's app hands over all the +/// links at once (select all, copy). Each line is looked up on its own; a +/// line that is not a link is read as ordinary "Artist - Title" text, so a +/// mixed paste works too. +#[tauri::command] +pub async fn playlist_import_track_links( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + text: String, + name: Option, +) -> Result { + let lines = crate::provider_playlist::pasted_lines(&text); + if lines.is_empty() { + return Err("Nothing to import in that paste.".into()); + } + let link_count = lines + .iter() + .filter(|l| matches!(l, crate::provider_playlist::PastedLine::Link(_))) + .count(); + if link_count > MAX_PASTED_LINKS { + return Err(format!( + "That paste holds {link_count} track links — more than the {MAX_PASTED_LINKS} one import takes." + )); + } + // The lookups take a page each, so the modal gets a live count instead of + // a frozen button. + let emitter = app.clone(); + let outcome = + crate::provider_playlist::resolve_pasted(lines, move |done, total, waiting| { + let _ = emitter.emit( + "playlist:link-progress", + serde_json::json!({ "done": done, "total": total, "waiting": waiting }), + ); + }) + .await?; + let total = outcome.links; + + let unread = outcome.rows.iter().filter(|r| r.is_none()).count(); + let entries: Vec<_> = outcome + .rows + .into_iter() + .flatten() + .enumerate() + .map(|(i, r)| playlist_formats::ParsedEntry { + ordinal: i as u32, + title: Some(r.title.clone()), + artist: (!r.artist.is_empty()).then(|| r.artist.clone()), + album: r.album.clone(), + duration_secs: r.duration_secs, + location: None, + start_ms: None, + raw: format!("{} - {}", r.artist, r.title), + }) + .collect(); + if entries.is_empty() { + return Err("None of those links could be read.".into()); + } + let parsed = playlist_formats::ParsedPlaylist { + format: "links".into(), + title: name.clone(), + description: None, + entries, + warnings: Vec::new(), + }; + let source = name.clone().unwrap_or_else(|| "Pasted track links".into()); + let mut draft = resolve_draft(&lib, parsed, source, None).await?; + draft.format = "links".into(); + draft.name = name; + draft.warnings.insert( + 0, + format!( + "{} track{} read from {total} pasted link{}.", + draft.entries.len(), + if draft.entries.len() == 1 { "" } else { "s" }, + if total == 1 { "" } else { "s" } + ), + ); + // A link the service refused us on is a DIFFERENT failure from a link + // that names nothing, and only one of them is worth retrying. + let missing = unread.saturating_sub(outcome.throttled_out); + if outcome.throttled_out > 0 { + draft.warnings.insert( + 1, + format!( + "{} link{} went unread because the service throttled the lookups. \ + Paste those again in a few minutes and they will come through.", + outcome.throttled_out, + if outcome.throttled_out == 1 { "" } else { "s" } + ), + ); + } else if outcome.was_throttled { + draft.warnings.insert( + 1, + "The service throttled the lookups partway; the import slowed down and \ + finished anyway." + .into(), + ); + } + if missing > 0 { + draft.warnings.push(format!( + "{missing} line{} named nothing readable and {} left out.", + if missing == 1 { "" } else { "s" }, + if missing == 1 { "was" } else { "were" } + )); + } + Ok(draft) +} + /// Commit a reviewed draft as a new playlist, atomically. #[tauri::command] pub async fn playlist_import_commit( diff --git a/src-tauri/src/provider_playlist.rs b/src-tauri/src/provider_playlist.rs new file mode 100644 index 0000000..ccb1928 --- /dev/null +++ b/src-tauri/src/provider_playlist.rs @@ -0,0 +1,1348 @@ +//! Playlist import from the streaming services — Spotify, Apple Music, +//! TIDAL, Deezer — off each service's own PUBLIC surface, the same honest +//! metadata posture as the YouTube doors: nothing is downloaded, nothing +//! needs an account, and what a service refuses to show anonymously is +//! reported instead of worked around. +//! +//! Where each list comes from (all live-probed 2026-08-24): +//! - **Deezer**: the open JSON API (`api.deezer.com/playlist/{id}`), no +//! auth at all, paginated by the `next` links it hands back. +//! - **Spotify**: the embed player page's `__NEXT_DATA__` JSON — the only +//! anonymous surface left. Both token endpoints are closed to us on +//! purpose: `/get_access_token` is blocked at their edge (Fastly, 403 +//! "URL Blocked"), and `/api/token` answers 400 with "Usage of this +//! endpoint is not permitted under the Spotify Developer Terms" — so +//! this module does not go near them. The embed lists AT MOST 100 tracks +//! and accepts no offset/limit/page parameter (all four probed), which is +//! why pasted TRACK links exist below: the per-track pages carry the same +//! data with no ceiling. +//! - **Apple Music**: the playlist page's `serialized-server-data` JSON — +//! full tracklists inline (`trackCount` is checked against what was +//! actually present). +//! - **TIDAL**: `api.tidal.com/v1` with the public anonymous token their +//! own embed widget ships to every browser; if that token rotates, the +//! current one is re-mined from the embed bundle before giving up. +//! Paginated to the full list. + +use crate::youtube_setlist::walk; +use serde_json::Value; + +pub struct Row { + pub artist: String, + pub title: String, + pub album: Option, + pub duration_secs: Option, +} + +pub struct ProviderFetch { + /// Draft-format slug: "spotify" | "apple-music" | "tidal" | "deezer". + pub provider: &'static str, + pub title: String, + pub author: String, + pub rows: Vec, + pub cover_url: Option, + pub warnings: Vec, +} + +/// Rows a single import will take, across every provider. +const MAX_TRACKS: usize = 1000; + +const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +/// The TIDAL embed widget's anonymous token, as shipped 2026-08. +const TIDAL_PUBLIC_TOKEN: &str = "TPIsV0A9lyiqKl9u"; + +fn client(secs: u64) -> Result { + reqwest::Client::builder() + .user_agent(UA) + .timeout(std::time::Duration::from_secs(secs)) + .build() + .map_err(|e| e.to_string()) +} + +// ---- URL detection ---------------------------------------------------------- + +#[derive(Debug, PartialEq)] +pub enum ProviderUrl { + Spotify(String), + Apple { url: String }, + Tidal(String), + Deezer(String), +} + +/// Which service a pasted link belongs to, and the playlist id it names. +/// None for anything else — including YouTube, which has its own module. +pub fn detect(url: &str) -> Option { + let s = url.trim(); + let after_scheme = s.split("://").nth(1).unwrap_or(s); + let (host, rest) = after_scheme.split_once('/')?; + let host = host.split('@').next_back().unwrap_or(host).to_lowercase(); + let host = host.split(':').next().unwrap_or(&host).to_string(); + let path = rest.split(['?', '#']).next().unwrap_or(rest); + let segs: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + + if host == "open.spotify.com" { + // open.spotify.com/playlist/{id} — possibly behind /intl-xx/. + let at = segs.iter().position(|s| *s == "playlist")?; + let id = segs.get(at + 1)?; + let ok = (16..=32).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric()); + return ok.then(|| ProviderUrl::Spotify((*id).to_string())); + } + if host == "music.apple.com" { + // music.apple.com/{storefront}/playlist/{slug}/pl.{id} — the whole + // URL is the identity (storefront matters), so keep it as given. + let is_pl = segs.iter().any(|s| *s == "playlist") && segs.iter().any(|s| s.starts_with("pl.")); + return is_pl.then(|| ProviderUrl::Apple { + url: format!("https://music.apple.com/{}", segs.join("/")), + }); + } + if host == "tidal.com" || host.ends_with(".tidal.com") { + // tidal.com/browse/playlist/{uuid} · listen.tidal.com/playlist/{uuid} + let at = segs.iter().position(|s| *s == "playlist")?; + let id = segs.get(at + 1)?; + let ok = id.len() == 36 + && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') + && id.bytes().filter(|b| *b == b'-').count() == 4; + return ok.then(|| ProviderUrl::Tidal((*id).to_string())); + } + if host == "www.deezer.com" || host == "deezer.com" || host == "deezer.page.link" { + // deezer.com/{lang}/playlist/{digits} + let at = segs.iter().position(|s| *s == "playlist")?; + let id = segs.get(at + 1)?; + let ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()); + return ok.then(|| ProviderUrl::Deezer((*id).to_string())); + } + None +} + +// ---- shared JSON slicing ---------------------------------------------------- + +/// The JSON body of a `")?; + Some(rest[open..open + close].trim()) +} + +fn og_image(html: &str) -> Option { + let at = html.find("property=\"og:image\" content=\"")?; + let rest = &html[at + "property=\"og:image\" content=\"".len()..]; + rest.split('"').next().map(str::to_string) +} + +// ---- Spotify ---------------------------------------------------------------- + +/// Tracks out of the embed page's entity. Artists arrive pre-joined +/// ("Shakira, Burna Boy"); durations in milliseconds. +pub(crate) fn spotify_rows(entity: &Value) -> Vec { + let mut rows = Vec::new(); + if let Some(list) = entity.get("trackList").and_then(Value::as_array) { + for t in list { + let title = t.get("title").and_then(Value::as_str).unwrap_or("").trim(); + let artist = t.get("subtitle").and_then(Value::as_str).unwrap_or("").trim(); + if title.is_empty() { + continue; + } + rows.push(Row { + artist: artist.to_string(), + title: title.to_string(), + album: None, + duration_secs: t + .get("duration") + .and_then(Value::as_i64) + .filter(|d| *d > 0) + .map(|ms| (ms / 1000) as u32), + }); + } + } + rows +} + +async fn fetch_spotify(id: &str) -> Result { + let client = client(20)?; + let html = client + .get(format!("https://open.spotify.com/embed/playlist/{id}")) + .send() + .await + .map_err(|e| format!("Spotify did not answer: {e}"))? + .error_for_status() + .map_err(|e| format!("Spotify refused the page: {e}"))? + .text() + .await + .map_err(|e| e.to_string())?; + let data: Value = script_json(&html, "id=\"__NEXT_DATA__\"") + .and_then(|s| serde_json::from_str(s).ok()) + .ok_or("Could not read the playlist's data — is the link public?")?; + let entity = &data["props"]["pageProps"]["state"]["data"]["entity"]; + if entity.get("type").and_then(Value::as_str) != Some("playlist") { + return Err("That Spotify link is not a playlist.".into()); + } + let rows = spotify_rows(entity); + if rows.is_empty() { + return Err("Spotify listed no tracks for this playlist — it may be private.".into()); + } + let mut warnings = Vec::new(); + if rows.len() == 100 { + warnings.push( + "Spotify shows only the first 100 tracks of a playlist to anyone not signed \ + in, so a longer playlist stops here." + .into(), + ); + } + Ok(ProviderFetch { + provider: "spotify", + title: entity + .get("name") + .and_then(Value::as_str) + .unwrap_or("Spotify playlist") + .to_string(), + author: entity + .get("subtitle") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + cover_url: entity["coverArt"]["sources"] + .as_array() + .and_then(|s| s.last()) + .and_then(|s| s.get("url")) + .and_then(Value::as_str) + .map(str::to_string), + rows, + warnings, + }) +} + +// ---- Apple Music ------------------------------------------------------------ + +/// Track rows and the header out of `serialized-server-data`. The tree is +/// deep and renderer-happy, so sections are FOUND (key walk), not pathed. +pub(crate) fn apple_parse(data: &Value) -> (Vec, Option, Option) { + let mut rows = Vec::new(); + let mut header_title = None; + let mut track_count = None; + walk(data, "sections", &mut |secs| { + let Some(list) = secs.as_array() else { return }; + for sec in list { + let kind = sec.get("itemKind").and_then(Value::as_str).unwrap_or(""); + let items = sec.get("items").and_then(Value::as_array); + match (kind, items) { + ("containerDetailHeaderLockup", Some(items)) => { + if let Some(h) = items.first() { + if header_title.is_none() { + header_title = h + .get("title") + .and_then(Value::as_str) + .map(str::to_string); + } + if track_count.is_none() { + track_count = h.get("trackCount").and_then(Value::as_u64); + } + } + } + ("trackLockup", Some(items)) => { + for it in items { + let title = + it.get("title").and_then(Value::as_str).unwrap_or("").trim(); + let artist = it + .get("artistName") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if title.is_empty() { + continue; + } + rows.push(Row { + artist: artist.to_string(), + title: title.to_string(), + album: it["tertiaryLinks"] + .as_array() + .and_then(|l| l.first()) + .and_then(|l| l.get("title")) + .and_then(Value::as_str) + .map(str::to_string), + duration_secs: it + .get("duration") + .and_then(Value::as_i64) + .filter(|d| *d > 0) + .map(|ms| (ms / 1000) as u32), + }); + } + } + _ => {} + } + } + }); + (rows, header_title, track_count) +} + +async fn fetch_apple(url: &str) -> Result { + let client = client(25)?; + let html = client + .get(url) + .send() + .await + .map_err(|e| format!("Apple Music did not answer: {e}"))? + .error_for_status() + .map_err(|e| format!("Apple Music refused the page: {e}"))? + .text() + .await + .map_err(|e| e.to_string())?; + let data: Value = script_json(&html, "id=\"serialized-server-data\"") + .and_then(|s| serde_json::from_str(s).ok()) + .ok_or("Could not read the playlist's data — is the link public?")?; + let (rows, header_title, track_count) = apple_parse(&data); + if rows.is_empty() { + return Err("Apple Music listed no tracks for this playlist — it may be private or region-locked.".into()); + } + let mut warnings = Vec::new(); + if let Some(total) = track_count { + if (rows.len() as u64) < total { + warnings.push(format!( + "The page lists the first {} of {total} tracks.", + rows.len() + )); + } + } + let author = { + let mut curator = None; + walk(&data, "curatorName", &mut |c| { + if curator.is_none() { + curator = c.as_str().map(str::to_string); + } + }); + curator.unwrap_or_default() + }; + Ok(ProviderFetch { + provider: "apple-music", + title: header_title.unwrap_or_else(|| "Apple Music playlist".into()), + author, + cover_url: og_image(&html), + rows, + warnings, + }) +} + +// ---- TIDAL ------------------------------------------------------------------ + +/// One TIDAL API GET with the anonymous token. +async fn tidal_get(client: &reqwest::Client, token: &str, url: &str) -> Result { + client + .get(url) + .header("X-Tidal-Token", token) + .send() + .await + .map_err(|e| format!("TIDAL did not answer: {e}")) +} + +/// The current anonymous token out of the embed widget's app bundle — the +/// fallback for the day the baked constant rotates. +async fn tidal_mine_token(client: &reqwest::Client) -> Option { + let shell = client + .get("https://embed.tidal.com/playlists/00000000-0000-0000-0000-000000000000") + .send() + .await + .ok()? + .text() + .await + .ok()?; + let src = shell + .split("src=\"") + .find_map(|part| part.split('"').next().filter(|p| p.contains("/js/app-")))?; + let js = client + .get(format!("https://embed.tidal.com{src}")) + .send() + .await + .ok()? + .text() + .await + .ok()?; + let at = js.find("X-Tidal-Token\",\"")?; + js[at + "X-Tidal-Token\",\"".len()..] + .split('"') + .next() + .filter(|t| (8..=64).contains(&t.len())) + .map(str::to_string) +} + +pub(crate) fn tidal_rows(items: &[Value]) -> Vec { + let mut rows = Vec::new(); + for t in items { + let base = t.get("title").and_then(Value::as_str).unwrap_or("").trim(); + if base.is_empty() { + continue; + } + // TIDAL keeps the version qualifier ("Remix", "Extended Mix") in its + // own field; fold it back into the display title the way every other + // source states it. + let version = t.get("version").and_then(Value::as_str).unwrap_or("").trim(); + let title = if !version.is_empty() + && !base.to_lowercase().contains(&version.to_lowercase()) + { + format!("{base} ({version})") + } else { + base.to_string() + }; + rows.push(Row { + artist: t["artist"]["name"].as_str().unwrap_or("").trim().to_string(), + title, + album: t["album"]["title"].as_str().map(str::to_string), + duration_secs: t + .get("duration") + .and_then(Value::as_i64) + .filter(|d| *d > 0) + .map(|s| s as u32), + }); + } + rows +} + +async fn fetch_tidal(id: &str) -> Result { + let client = client(20)?; + let mut token = TIDAL_PUBLIC_TOKEN.to_string(); + let meta_url = format!("https://api.tidal.com/v1/playlists/{id}?countryCode=US"); + let mut resp = tidal_get(&client, &token, &meta_url).await?; + if matches!(resp.status().as_u16(), 401 | 403) { + token = tidal_mine_token(&client) + .await + .ok_or("TIDAL rejected the public token and a fresh one could not be found.")?; + resp = tidal_get(&client, &token, &meta_url).await?; + } + if resp.status().as_u16() == 404 { + return Err("TIDAL knows no playlist at that link — it may be private or deleted.".into()); + } + let meta: Value = resp + .error_for_status() + .map_err(|e| format!("TIDAL refused: {e}"))? + .json() + .await + .map_err(|e| e.to_string())?; + + let mut rows = Vec::new(); + let mut warnings = Vec::new(); + let mut offset = 0usize; + loop { + let page: Value = tidal_get( + &client, + &token, + &format!( + "https://api.tidal.com/v1/playlists/{id}/tracks?countryCode=US&limit=100&offset={offset}" + ), + ) + .await? + .error_for_status() + .map_err(|e| format!("TIDAL refused the tracks: {e}"))? + .json() + .await + .map_err(|e| e.to_string())?; + let items = page.get("items").and_then(Value::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + break; + } + rows.extend(tidal_rows(&items)); + offset += items.len(); + let total = page + .get("totalNumberOfItems") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + if offset >= total { + break; + } + if rows.len() >= MAX_TRACKS { + warnings.push(format!( + "Only the first {MAX_TRACKS} tracks were taken — the playlist goes on." + )); + break; + } + } + if rows.is_empty() { + return Err("TIDAL listed no tracks for this playlist.".into()); + } + let cover_url = meta + .get("squareImage") + .or_else(|| meta.get("image")) + .and_then(Value::as_str) + .map(|u| format!("https://resources.tidal.com/images/{}/640x640.jpg", u.replace('-', "/"))); + Ok(ProviderFetch { + provider: "tidal", + title: meta + .get("title") + .and_then(Value::as_str) + .unwrap_or("TIDAL playlist") + .to_string(), + author: meta["creator"]["name"].as_str().unwrap_or("").to_string(), + cover_url, + rows, + warnings, + }) +} + +// ---- Deezer ----------------------------------------------------------------- + +pub(crate) fn deezer_rows(items: &[Value]) -> Vec { + let mut rows = Vec::new(); + for t in items { + let title = t.get("title").and_then(Value::as_str).unwrap_or("").trim(); + if title.is_empty() { + continue; + } + rows.push(Row { + artist: t["artist"]["name"].as_str().unwrap_or("").trim().to_string(), + title: title.to_string(), + album: t["album"]["title"].as_str().map(str::to_string), + duration_secs: t + .get("duration") + .and_then(Value::as_i64) + .filter(|d| *d > 0) + .map(|s| s as u32), + }); + } + rows +} + +async fn fetch_deezer(id: &str) -> Result { + let client = client(20)?; + let root: Value = client + .get(format!("https://api.deezer.com/playlist/{id}")) + .send() + .await + .map_err(|e| format!("Deezer did not answer: {e}"))? + .json() + .await + .map_err(|e| e.to_string())?; + if let Some(err) = root.get("error") { + let msg = err["message"].as_str().unwrap_or("unknown error"); + return Err(format!("Deezer refused the playlist — {msg}.")); + } + let mut rows = deezer_rows( + root["tracks"]["data"].as_array().map(Vec::as_slice).unwrap_or(&[]), + ); + let mut warnings = Vec::new(); + // Long playlists page through `next` links the API itself hands out. + let mut next = root["tracks"]["next"].as_str().map(str::to_string); + while let Some(url) = next.take() { + if rows.len() >= MAX_TRACKS { + warnings.push(format!( + "Only the first {MAX_TRACKS} tracks were taken — the playlist goes on." + )); + break; + } + if !url.starts_with("https://api.deezer.com/") { + break; + } + let page: Value = match client.get(&url).send().await { + Err(e) => { + warnings.push(format!("Only the first {} tracks could be fetched: {e}", rows.len())); + break; + } + Ok(r) => match r.json().await { + Err(e) => { + warnings.push(format!("Only the first {} tracks could be fetched: {e}", rows.len())); + break; + } + Ok(v) => v, + }, + }; + rows.extend(deezer_rows( + page.get("data").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), + )); + next = page.get("next").and_then(Value::as_str).map(str::to_string); + } + if rows.is_empty() { + return Err("Deezer listed no tracks for this playlist — it may be private.".into()); + } + Ok(ProviderFetch { + provider: "deezer", + title: root + .get("title") + .and_then(Value::as_str) + .unwrap_or("Deezer playlist") + .to_string(), + author: root["creator"]["name"].as_str().unwrap_or("").to_string(), + cover_url: root + .get("picture_xl") + .or_else(|| root.get("picture_big")) + .and_then(Value::as_str) + .map(str::to_string), + rows, + warnings, + }) +} + +// ---- single track links ----------------------------------------------------- +// The escape hatch for Spotify's 100-track ceiling (and a courtesy for the +// rest): a service's PLAYLIST page may cap what it shows, but every single +// TRACK has its own public page. Paste the track links — Spotify's own app +// hands them over with select-all + copy — and each is read from its own +// page, so the playlist's length stops mattering. + +#[derive(Debug, Clone, PartialEq)] +pub enum TrackLink { + Spotify(String), + Deezer(String), + Tidal(String), + /// Apple names a song by URL, not a bare id — the storefront is part of + /// the identity. + Apple(String), +} + +fn is_spotify_id(id: &str) -> bool { + id.len() == 22 && id.bytes().all(|b| b.is_ascii_alphanumeric()) +} + +fn all_digits(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// The first track link inside one pasted line, if it holds one. A line can +/// carry other text around the link ("1. ", tab-separated columns, quotes), +/// so every separated token is tried. +pub fn detect_track(line: &str) -> Option { + for tok in line.split(|c: char| c.is_whitespace() || c == ',' || c == '"') { + let tok = tok.trim_matches(|c: char| "()[]<>'\u{201c}\u{201d}".contains(c)); + if tok.is_empty() { + continue; + } + // Spotify's own clipboard form, straight out of the desktop app. + if let Some(id) = tok.strip_prefix("spotify:track:") { + if is_spotify_id(id) { + return Some(TrackLink::Spotify(id.to_string())); + } + continue; + } + let after_scheme = match tok.split_once("://") { + Some((_, rest)) => rest, + None => tok, + }; + let Some((host, rest)) = after_scheme.split_once('/') else { + continue; + }; + let host = host.split('@').next_back().unwrap_or(host).to_lowercase(); + let host = host.split(':').next().unwrap_or(&host).to_string(); + let path = rest.split(['?', '#']).next().unwrap_or(rest); + let query = rest + .split('#') + .next() + .unwrap_or(rest) + .split_once('?') + .map(|(_, q)| q); + let segs: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + let after = |name: &str| -> Option<&str> { + segs.iter() + .position(|s| *s == name) + .and_then(|at| segs.get(at + 1)) + .copied() + }; + + if host == "open.spotify.com" { + if let Some(id) = after("track").filter(|id| is_spotify_id(id)) { + return Some(TrackLink::Spotify(id.to_string())); + } + } else if host == "www.deezer.com" || host == "deezer.com" { + if let Some(id) = after("track").filter(|id| all_digits(id)) { + return Some(TrackLink::Deezer(id.to_string())); + } + } else if host == "tidal.com" || host.ends_with(".tidal.com") { + if let Some(id) = after("track").filter(|id| all_digits(id)) { + return Some(TrackLink::Tidal(id.to_string())); + } + } else if host == "music.apple.com" { + // Two spellings: /song/{slug}/{id}, and an album link whose `?i=` + // names one track on it. + let is_song = segs.iter().any(|s| *s == "song"); + let has_i = query + .map(|q| q.split('&').any(|kv| kv.starts_with("i="))) + .unwrap_or(false); + if is_song || has_i { + return Some(TrackLink::Apple(format!( + "https://music.apple.com/{}{}", + segs.join("/"), + query.map(|q| format!("?{q}")).unwrap_or_default() + ))); + } + } + } + None +} + +// ---- pacing ------------------------------------------------------------ +// Spotify rate-limits these pages, and its 429 is expensive: measured +// 2026-08-24, a burst of ~350 lookups tripped it and the block held for +// **211 seconds**, with `Retry-After: 0` on every refusal — the header is +// worthless, so the backoff here is our own. 150 lookups in one run were +// fine; the ceiling is a few hundred per window across ALL of a session's +// requests, so the run paces itself from the start rather than sprinting +// into a wall it then has to wait out. + +/// Track pages in flight at once. +const TRACK_FETCH_CONCURRENCY: usize = 3; +/// Minimum spacing between request STARTS, before any throttling is seen. +/// ~6/s: 350 tracks in about a minute, comfortably under what tripped it. +const TRACK_PACE_MS: u64 = 160; +/// Spacing never grows past this, however many times we are refused. +const TRACK_PACE_MAX_MS: u64 = 1_200; +/// How long to stand down when refused. The measured block was ~3.5 min, +/// but it was being poked every 10s throughout; a quiet minute first is the +/// polite read, and the run resumes at half speed either way. +const TRACK_COOLDOWN_SECS: u64 = 60; +/// Stand-downs before the run stops trying and reports what it got. +const MAX_COOLDOWNS: u32 = 3; +/// Attempts per track, across all stand-downs. +const TRACK_ATTEMPTS: u32 = 3; + +/// Shared pacing state for one paste run: a slot reservation clock, the +/// current spacing, and a stand-down deadline every worker honours — so one +/// worker meeting a 429 parks the whole run instead of the other two +/// carrying on and deepening the penalty. +#[derive(Default)] +struct Throttle { + next_slot: std::sync::Mutex>, + interval_ms: std::sync::Mutex, + cooldown_until: std::sync::Mutex>, + cooldowns: std::sync::Mutex, + /// Set while standing down, so the UI can say so instead of looking hung. + waiting: std::sync::atomic::AtomicBool, +} + +impl Throttle { + fn new() -> Self { + Throttle { + interval_ms: std::sync::Mutex::new(TRACK_PACE_MS), + ..Default::default() + } + } + + /// Reserve the next send slot and sleep until it comes up. + async fn slot(&self) { + let wait = { + let now = std::time::Instant::now(); + let interval = + std::time::Duration::from_millis(*self.interval_ms.lock().unwrap()); + let cooldown = *self.cooldown_until.lock().unwrap(); + let mut next = self.next_slot.lock().unwrap(); + let earliest = [Some(now), *next, cooldown] + .into_iter() + .flatten() + .max() + .unwrap_or(now); + *next = Some(earliest + interval); + earliest.saturating_duration_since(now) + }; + if !wait.is_zero() { + tokio::time::sleep(wait).await; + } + } + + /// Record a refusal. Returns false once the run has stood down as often + /// as it is going to. + fn back_off(&self) -> bool { + let mut count = self.cooldowns.lock().unwrap(); + *count += 1; + if *count > MAX_COOLDOWNS { + return false; + } + let mut interval = self.interval_ms.lock().unwrap(); + *interval = (*interval * 2).min(TRACK_PACE_MAX_MS); + *self.cooldown_until.lock().unwrap() = + Some(std::time::Instant::now() + std::time::Duration::from_secs(TRACK_COOLDOWN_SECS)); + self.waiting + .store(true, std::sync::atomic::Ordering::Relaxed); + true + } + + fn is_waiting(&self) -> bool { + match *self.cooldown_until.lock().unwrap() { + Some(until) if until > std::time::Instant::now() => true, + _ => { + self.waiting + .store(false, std::sync::atomic::Ordering::Relaxed); + false + } + } + } +} + +/// What one lookup came back as. +enum Fetched { + Body(String), + /// The service refused us for going too fast — worth retrying. + RateLimited, + /// No such track, or the page could not be read — retrying will not help. + Missing, +} + +/// One paced GET, with 429 recognized as its own answer. +async fn paced_get( + client: &reqwest::Client, + throttle: &Throttle, + url: &str, + token: Option<&str>, +) -> Fetched { + throttle.slot().await; + let mut request = client.get(url); + if let Some(t) = token { + request = request.header("X-Tidal-Token", t); + } + match request.send().await { + Err(_) => Fetched::Missing, + Ok(response) => { + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + return Fetched::RateLimited; + } + if !response.status().is_success() { + return Fetched::Missing; + } + match response.text().await { + Ok(body) => Fetched::Body(body), + Err(_) => Fetched::Missing, + } + } + } +} + +async fn resolve_spotify_track( + client: &reqwest::Client, + throttle: &Throttle, + id: &str, +) -> Result, ()> { + let html = match paced_get( + client, + throttle, + &format!("https://open.spotify.com/embed/track/{id}"), + None, + ) + .await + { + Fetched::Body(b) => b, + Fetched::RateLimited => return Err(()), + Fetched::Missing => return Ok(None), + }; + let Some(json) = script_json(&html, "id=\"__NEXT_DATA__\"") else { + return Ok(None); + }; + let Ok(data) = serde_json::from_str::(json) else { + return Ok(None); + }; + let e = &data["props"]["pageProps"]["state"]["data"]["entity"]; + let title = match e.get("title").and_then(Value::as_str) { + Some(t) if !t.trim().is_empty() => t.trim().to_string(), + _ => return Ok(None), + }; + // A TRACK embed lists its artists; a PLAYLIST embed pre-joins them into + // `subtitle`. Take whichever this page carries. + let artist = e + .get("artists") + .and_then(Value::as_array) + .map(|list| { + list.iter() + .filter_map(|a| a.get("name").and_then(Value::as_str)) + .collect::>() + .join(", ") + }) + .filter(|a| !a.is_empty()) + .or_else(|| e.get("subtitle").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_default(); + Ok(Some(Row { + artist, + title, + album: None, + duration_secs: e + .get("duration") + .and_then(Value::as_i64) + .filter(|d| *d > 0) + .map(|ms| (ms / 1000) as u32), + })) +} + +async fn resolve_deezer_track( + client: &reqwest::Client, + throttle: &Throttle, + id: &str, +) -> Result, ()> { + let body = match paced_get( + client, + throttle, + &format!("https://api.deezer.com/track/{id}"), + None, + ) + .await + { + Fetched::Body(b) => b, + Fetched::RateLimited => return Err(()), + Fetched::Missing => return Ok(None), + }; + let Ok(v) = serde_json::from_str::(&body) else { + return Ok(None); + }; + // Deezer answers 200 with an error body; code 4 is its quota refusal, + // which is a 429 by another name. + if let Some(code) = v["error"]["code"].as_i64() { + return if code == 4 { Err(()) } else { Ok(None) }; + } + Ok(deezer_rows(std::slice::from_ref(&v)).into_iter().next()) +} + +async fn resolve_tidal_track( + client: &reqwest::Client, + throttle: &Throttle, + id: &str, +) -> Result, ()> { + let body = match paced_get( + client, + throttle, + &format!("https://api.tidal.com/v1/tracks/{id}?countryCode=US"), + Some(TIDAL_PUBLIC_TOKEN), + ) + .await + { + Fetched::Body(b) => b, + Fetched::RateLimited => return Err(()), + Fetched::Missing => return Ok(None), + }; + let Ok(v) = serde_json::from_str::(&body) else { + return Ok(None); + }; + Ok(tidal_rows(std::slice::from_ref(&v)).into_iter().next()) +} + +/// A song page states itself twice: in a `songDetailHeader` section of its +/// page JSON (title plus artist links — structured and the same in every +/// storefront), and in OpenGraph tags. The JSON is read first; the tags are +/// the fallback, and they carry the only duration either surface offers. +/// +/// The tags are also where Apple writes "on Apple\u{a0}Music" with a NO-BREAK +/// SPACE — a plain " on Apple Music" suffix strip silently misses, and the +/// artist comes out as "Drake on Apple Music". Text off this page is +/// normalized before anything is matched against it. +async fn resolve_apple_track( + client: &reqwest::Client, + throttle: &Throttle, + url: &str, +) -> Result, ()> { + let html = match paced_get(client, throttle, url, None).await { + Fetched::Body(b) => b, + Fetched::RateLimited => return Err(()), + Fetched::Missing => return Ok(None), + }; + let nbsp = |s: &str| s.replace('\u{a0}', " ").trim().to_string(); + + let mut title = String::new(); + let mut artist = String::new(); + if let Some(data) = script_json(&html, "id=\"serialized-server-data\"") + .and_then(|j| serde_json::from_str::(j).ok()) + { + walk(&data, "sections", &mut |secs| { + let Some(list) = secs.as_array() else { return }; + for sec in list { + if sec.get("itemKind").and_then(Value::as_str) != Some("songDetailHeader") { + continue; + } + let Some(item) = sec.get("items").and_then(Value::as_array).and_then(|i| i.first()) + else { + continue; + }; + if title.is_empty() { + if let Some(t) = item.get("title").and_then(Value::as_str) { + title = nbsp(t); + } + } + if artist.is_empty() { + if let Some(links) = item.get("artistLinks").and_then(Value::as_array) { + artist = links + .iter() + .filter_map(|l| l.get("title").and_then(Value::as_str)) + .map(|a| nbsp(a)) + .collect::>() + .join(", "); + } + } + } + }); + } + + let og = |prop: &str| -> Option { + let needle = format!("property=\"{prop}\" content=\""); + let at = html.find(&needle)?; + html[at + needle.len()..].split('"').next().map(nbsp) + }; + if title.is_empty() { + // "TITLE by ARTIST on Apple Music" — the wording is localized, so the + // suffix is only trimmed when it is actually there. + let Some(raw) = og("og:title") else { + return Ok(None); + }; + let raw = raw.strip_suffix(" on Apple Music").unwrap_or(&raw).trim(); + match raw.rsplit_once(" by ") { + Some((t, a)) => { + title = t.trim().to_string(); + if artist.is_empty() { + artist = a.trim().to_string(); + } + } + None => title = raw.to_string(), + } + } + if title.is_empty() { + return Ok(None); + } + let duration_secs = og("og:description").and_then(|d| { + let at = d.find("Duration ")? + "Duration ".len(); + let tok = d[at..].split(|c: char| c.is_whitespace()).next()?; + crate::playlist_formats::parse_timestamp(tok).map(|ms| (ms / 1000) as u32) + }); + Ok(Some(Row { + artist, + title, + album: None, + duration_secs, + })) +} + +/// One pasted line: a track link to look up, or plain text to read as-is. +#[derive(Debug, Clone)] +pub enum PastedLine { + Link(TrackLink), + Text(String), +} + +/// Read a pasted block into lines the resolver understands. Blank lines and +/// section headers drop out; everything else is either a link or a name. +pub fn pasted_lines(text: &str) -> Vec { + let mut out = Vec::new(); + for line in text.lines() { + let raw = line.trim(); + if raw.is_empty() || raw.len() > 4096 { + continue; + } + let bare = raw.trim_matches(['-', '=', '*', '#', ' ']); + if bare.is_empty() + || bare.eq_ignore_ascii_case("tracklist") + || bare.eq_ignore_ascii_case("tracklist:") + { + continue; + } + match detect_track(raw) { + Some(link) => out.push(PastedLine::Link(link)), + None => out.push(PastedLine::Text(raw.to_string())), + } + } + out +} + +/// What a whole paste run came to. +pub struct PastedOutcome { + pub rows: Vec>, + /// Links attempted. + pub links: usize, + /// Links given up on because the service kept refusing us — the number + /// that would have arrived on a quieter day, reported separately from + /// links that simply name nothing. + pub throttled_out: usize, + /// Whether the run ever had to stand down. + pub was_throttled: bool, +} + +/// Resolve every line, in order, a few pages at a time and paced. `progress` +/// is called with (done, total, waiting) as lookups land — `waiting` is true +/// while standing down from a refusal, so a minute of silence reads as what +/// it is. A link that cannot be read comes back as None, never a guess. +pub async fn resolve_pasted(lines: Vec, progress: F) -> Result +where + F: Fn(usize, usize, bool) + Send + Sync + 'static, +{ + let client = client(20)?; + let links = lines + .iter() + .filter(|l| matches!(l, PastedLine::Link(_))) + .count(); + let throttle = std::sync::Arc::new(Throttle::new()); + let mut rows: Vec> = Vec::with_capacity(lines.len()); + let mut done = 0usize; + let mut throttled_out = 0usize; + + for chunk in lines.chunks(TRACK_FETCH_CONCURRENCY) { + let mut handles = Vec::with_capacity(chunk.len()); + for line in chunk { + let client = client.clone(); + let line = line.clone(); + let throttle = throttle.clone(); + let is_link = matches!(line, PastedLine::Link(_)); + handles.push(( + is_link, + tauri::async_runtime::spawn(async move { + // A plain line is read the way every pasted tracklist + // already is — no lookup, no network. + let PastedLine::Link(link) = line else { + let PastedLine::Text(t) = line else { + unreachable!() + }; + let (artist, title) = crate::playlist_formats::split_artist_title(&t); + return ( + title.map(|title| Row { + artist: artist.unwrap_or_default(), + title, + album: None, + duration_secs: None, + }), + false, + ); + }; + for attempt in 0..TRACK_ATTEMPTS { + let got = match &link { + TrackLink::Spotify(id) => { + resolve_spotify_track(&client, &throttle, id).await + } + TrackLink::Deezer(id) => { + resolve_deezer_track(&client, &throttle, id).await + } + TrackLink::Tidal(id) => { + resolve_tidal_track(&client, &throttle, id).await + } + TrackLink::Apple(u) => { + resolve_apple_track(&client, &throttle, u).await + } + }; + match got { + Ok(row) => return (row, false), + Err(()) => { + // Refused. Park the whole run, then try again + // — unless we have already stood down as often + // as we are going to. + if attempt + 1 == TRACK_ATTEMPTS || !throttle.back_off() { + return (None, true); + } + } + } + } + (None, true) + }), + )); + } + for (is_link, handle) in handles { + let (row, gave_up_throttled) = handle.await.unwrap_or((None, false)); + if is_link { + done += 1; + if gave_up_throttled { + throttled_out += 1; + } + progress(done, links, throttle.is_waiting()); + } + rows.push(row); + } + } + let was_throttled = *throttle.cooldowns.lock().unwrap() > 0; + Ok(PastedOutcome { + rows, + links, + throttled_out, + was_throttled, + }) +} + +// ---- entry ------------------------------------------------------------------ + +pub async fn fetch(url: &str) -> Result { + match detect(url) { + Some(ProviderUrl::Spotify(id)) => fetch_spotify(&id).await, + Some(ProviderUrl::Apple { url }) => fetch_apple(&url).await, + Some(ProviderUrl::Tidal(id)) => fetch_tidal(&id).await, + Some(ProviderUrl::Deezer(id)) => fetch_deezer(&id).await, + None => Err( + "That link isn't a playlist from a service this door knows — it takes \ + YouTube, Spotify, Apple Music, TIDAL, and Deezer playlist links." + .into(), + ), + } +} + +/// Draft rows from a provider fetch. The services state artist and title as +/// separate fields, so no display-string splitting or title cleaning happens +/// — what the service said is what the review shows. +pub fn to_parsed(fetch: &ProviderFetch) -> crate::playlist_formats::ParsedPlaylist { + let entries = fetch + .rows + .iter() + .enumerate() + .map(|(i, r)| crate::playlist_formats::ParsedEntry { + ordinal: i as u32, + title: Some(r.title.clone()), + artist: (!r.artist.is_empty()).then(|| r.artist.clone()), + album: r.album.clone(), + duration_secs: r.duration_secs, + location: None, + start_ms: None, + raw: format!("{} - {}", r.artist, r.title), + }) + .collect(); + crate::playlist_formats::ParsedPlaylist { + format: fetch.provider.into(), + title: Some(fetch.title.clone()), + description: None, + entries, + warnings: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_each_provider_and_rejects_the_rest() { + assert_eq!( + detect("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc"), + Some(ProviderUrl::Spotify("37i9dQZF1DXcBWIGoYBM5M".into())) + ); + assert_eq!( + detect("https://open.spotify.com/intl-de/playlist/37i9dQZF1DXcBWIGoYBM5M"), + Some(ProviderUrl::Spotify("37i9dQZF1DXcBWIGoYBM5M".into())) + ); + assert_eq!( + detect("https://music.apple.com/us/playlist/todays-hits/pl.f4d106fed2bd41149aaacabb233eb5eb"), + Some(ProviderUrl::Apple { + url: "https://music.apple.com/us/playlist/todays-hits/pl.f4d106fed2bd41149aaacabb233eb5eb".into() + }) + ); + assert_eq!( + detect("https://tidal.com/browse/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b"), + Some(ProviderUrl::Tidal("edf3b7d2-cb42-41d7-93c0-afa2a395521b".into())) + ); + assert_eq!( + detect("https://listen.tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b"), + Some(ProviderUrl::Tidal("edf3b7d2-cb42-41d7-93c0-afa2a395521b".into())) + ); + assert_eq!( + detect("https://www.deezer.com/en/playlist/908622995"), + Some(ProviderUrl::Deezer("908622995".into())) + ); + assert_eq!(detect("https://www.youtube.com/playlist?list=PLabc"), None); + assert_eq!(detect("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"), None); + assert_eq!(detect("https://example.com/playlist/123"), None); + } + + #[test] + fn spotify_rows_from_entity_fixture() { + let entity: Value = serde_json::json!({ + "type": "playlist", + "trackList": [ + { "title": "Dai Dai", "subtitle": "Shakira, Burna Boy", "duration": 223448 }, + { "title": "", "subtitle": "nobody", "duration": 1000 }, + { "title": "Loser", "subtitle": "Tame Impala", "duration": 0 } + ] + }); + let rows = spotify_rows(&entity); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].artist, "Shakira, Burna Boy"); + assert_eq!(rows[0].duration_secs, Some(223)); + assert_eq!(rows[1].duration_secs, None); + } + + #[test] + fn apple_sections_walked_for_header_and_tracks() { + let data: Value = serde_json::json!({ "data": [ { "data": { "sections": [ + { "itemKind": "containerDetailHeaderLockup", + "items": [ { "title": "Today’s Hits", "trackCount": 3 } ] }, + { "itemKind": "trackLockup", "items": [ + { "title": "Janice STFU", "artistName": "Drake", "duration": 237344, + "tertiaryLinks": [ { "title": "ICEMAN" } ] }, + { "title": "Golden", "artistName": "HUNTR/X", "duration": 200000 } + ]} + ]}}]}); + let (rows, title, count) = apple_parse(&data); + assert_eq!(title.as_deref(), Some("Today’s Hits")); + assert_eq!(count, Some(3)); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].album.as_deref(), Some("ICEMAN")); + assert_eq!(rows[0].duration_secs, Some(237)); + assert_eq!(rows[1].album, None); + } + + #[test] + fn tidal_version_folds_into_the_title() { + let items = vec![ + serde_json::json!({ "title": "Song", "version": "VIP Mix", + "artist": { "name": "A" }, "album": { "title": "B" }, "duration": 200 }), + serde_json::json!({ "title": "Song (VIP Mix)", "version": "VIP Mix", + "artist": { "name": "A" }, "duration": 100 }), + ]; + let rows = tidal_rows(&items); + assert_eq!(rows[0].title, "Song (VIP Mix)"); + assert_eq!(rows[1].title, "Song (VIP Mix)"); + } + + #[test] + fn track_links_detected_in_every_spelling() { + let sp = "7bxaFZ1O3cHkgLKMsdC3xR"; + assert_eq!( + detect_track(&format!("https://open.spotify.com/track/{sp}?si=abc123")), + Some(TrackLink::Spotify(sp.into())) + ); + // The desktop app's clipboard form, and a link with row noise around it. + assert_eq!( + detect_track(&format!("spotify:track:{sp}")), + Some(TrackLink::Spotify(sp.into())) + ); + assert_eq!( + detect_track(&format!("12. Tame Impala - Loser\thttps://open.spotify.com/track/{sp}")), + Some(TrackLink::Spotify(sp.into())) + ); + assert_eq!( + detect_track("https://www.deezer.com/en/track/3135556"), + Some(TrackLink::Deezer("3135556".into())) + ); + assert_eq!( + detect_track("https://listen.tidal.com/track/77646168"), + Some(TrackLink::Tidal("77646168".into())) + ); + assert_eq!( + detect_track("https://music.apple.com/us/song/janice-stfu/6769568596"), + Some(TrackLink::Apple( + "https://music.apple.com/us/song/janice-stfu/6769568596".into() + )) + ); + // An Apple ALBUM link naming one track on it. + assert_eq!( + detect_track("https://music.apple.com/us/album/janice-stfu/6769568449?i=6769568596"), + Some(TrackLink::Apple( + "https://music.apple.com/us/album/janice-stfu/6769568449?i=6769568596".into() + )) + ); + // Not tracks: a playlist link, an album link with no track, plain text. + assert_eq!(detect_track("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"), None); + assert_eq!(detect_track("https://music.apple.com/us/album/discovery/697194953"), None); + assert_eq!(detect_track("Daft Punk - Around the World"), None); + // A malformed Spotify id must not pass as one. + assert_eq!(detect_track("https://open.spotify.com/track/tooshort"), None); + } + + #[test] + fn pasted_lines_split_links_from_names_and_drop_headers() { + let text = "Tracklist:\n\ + https://open.spotify.com/track/7bxaFZ1O3cHkgLKMsdC3xR\n\ + \n\ + Daft Punk - Around the World\n\ + ---\n\ + spotify:track:4LfCY65LvojKjWEnU7fNN4\n"; + let lines = pasted_lines(text); + assert_eq!(lines.len(), 3); + assert!(matches!(lines[0], PastedLine::Link(TrackLink::Spotify(_)))); + assert!(matches!(lines[1], PastedLine::Text(_))); + assert!(matches!(lines[2], PastedLine::Link(TrackLink::Spotify(_)))); + } + + #[test] + fn deezer_rows_from_api_fixture() { + let items = vec![serde_json::json!({ + "title": "Hey Jude (Remastered 2015)", + "artist": { "name": "The Beatles" }, + "album": { "title": "1 (Remastered)" }, + "duration": 429 + })]; + let rows = deezer_rows(&items); + assert_eq!(rows[0].artist, "The Beatles"); + assert_eq!(rows[0].duration_secs, Some(429)); + } +} diff --git a/src-tauri/src/youtube_playlist.rs b/src-tauri/src/youtube_playlist.rs new file mode 100644 index 0000000..29846d5 --- /dev/null +++ b/src-tauri/src/youtube_playlist.rs @@ -0,0 +1,613 @@ +//! YouTube PLAYLIST import — one row per video, straight off the playlist's +//! public page. This is the metadata-import posture of `youtube_setlist` +//! applied to the other kind of link: nothing is downloaded and nothing is +//! recognized — the page already states every video's title, channel and +//! length, and those become ordinary draft rows for the existing +//! resolve → review → commit pipeline. +//! +//! Everything runs in-process over reqwest: one GET of the playlist page, +//! plus one innertube `browse` POST per additional hundred videos (the same +//! dialect the setlist module's comments call speaks). Since 2024 the +//! desktop page renders videos as `lockupViewModel` nodes; the old +//! `playlistVideoRenderer` shape is still walked as insurance, since YouTube +//! has served both in the wild. + +use crate::youtube_setlist::{collect_runs, slice_json_after, walk}; +use serde_json::Value; + +/// One video as the playlist page stated it. +pub struct VideoRow { + pub video_id: String, + /// The raw video title, junk suffixes and all — cleaning happens at + /// draft-building so the review's provenance line can show the original. + pub title: String, + /// The uploading channel's display name ("Skrillex", "Artist - Topic"). + pub byline: String, + pub duration_secs: Option, +} + +pub struct PlaylistFetch { + pub title: String, + /// The playlist's owner ("Tommy"), not any track's artist. + pub author: String, + pub rows: Vec, + pub warnings: Vec, +} + +/// Videos a single import will take. A playlist page holds 100; each +/// continuation another 100. Ten requests is a generous ceiling — past that +/// the review modal stops being a review. +const MAX_VIDEOS: usize = 1000; + +// ---- URL handling ----------------------------------------------------------- + +/// The `list=` id out of any YouTube-shaped URL (playlist pages, watch links +/// carrying a list, music.youtube.com), or a bare id pasted alone. +pub fn playlist_id_from_url(url: &str) -> Option { + let s = url.trim(); + let is_id = |c: &str| { + (10..=64).contains(&c.len()) + && c.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }; + // A bare id: playlist ids start with a recognizable prefix; a raw video + // id must NOT be mistaken for one (11 chars, no such prefix). + if is_id(s) + && ["PL", "OLAK5uy_", "FL", "UU", "LL", "RD"] + .iter() + .any(|p| s.starts_with(p)) + { + return Some(s.to_string()); + } + let after_scheme = s.split("://").nth(1).unwrap_or(s); + let (host, rest) = after_scheme.split_once('/')?; + let host = host.split('@').next_back().unwrap_or(host).to_lowercase(); + let host = host.split(':').next().unwrap_or(&host); + if !(host.ends_with("youtube.com") || host == "youtu.be" || host.ends_with("youtube-nocookie.com")) { + return None; + } + let query = rest.split('#').next().unwrap_or(rest).split_once('?')?.1; + query + .split('&') + .find_map(|kv| kv.strip_prefix("list=").map(str::to_string)) + .filter(|c| is_id(c)) +} + +// ---- page shapes ------------------------------------------------------------ + +/// Video rows out of any subtree, both page generations: +/// `lockupViewModel` (2024+ desktop) and `playlistVideoRenderer` (classic). +/// Order follows the document, which is the playlist's own order. +pub(crate) fn video_rows(root: &Value, skipped_unavailable: &mut usize) -> Vec { + let mut rows: Vec = Vec::new(); + + walk(root, "lockupViewModel", &mut |lk| { + if lk.get("contentType").and_then(Value::as_str) != Some("LOCKUP_CONTENT_TYPE_VIDEO") { + return; + } + let Some(id) = lk.get("contentId").and_then(Value::as_str) else { + return; + }; + let md = &lk["metadata"]["lockupMetadataViewModel"]; + let title = md + .get("title") + .and_then(|t| t.get("content")) + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if title.is_empty() || is_unavailable_stub(&title) { + *skipped_unavailable += usize::from(!title.is_empty()); + return; + } + // First metadata row's first part is the channel line. + let mut byline = String::new(); + walk(md, "metadataParts", &mut |parts| { + if byline.is_empty() { + if let Some(t) = parts + .get(0) + .and_then(|p| p.get("text")) + .and_then(|t| t.get("content")) + .and_then(Value::as_str) + { + byline = t.trim().to_string(); + } + } + }); + // Duration rides a thumbnail badge as "3:20"; other badges say + // "LIVE"/"SHORTS" and simply fail the timestamp parse. + let mut secs = None; + walk(lk, "thumbnailBadgeViewModel", &mut |b| { + if secs.is_none() { + secs = b + .get("text") + .and_then(Value::as_str) + .and_then(|t| crate::playlist_formats::parse_timestamp(t.trim())) + .map(|ms| (ms / 1000) as u32); + } + }); + rows.push(VideoRow { + video_id: id.to_string(), + title, + byline, + duration_secs: secs, + }); + }); + + walk(root, "playlistVideoRenderer", &mut |v| { + let Some(id) = v.get("videoId").and_then(Value::as_str) else { + return; + }; + let title = v.get("title").map(collect_runs).unwrap_or_default().trim().to_string(); + if title.is_empty() || is_unavailable_stub(&title) { + *skipped_unavailable += usize::from(!title.is_empty()); + return; + } + let byline = v + .get("shortBylineText") + .map(collect_runs) + .unwrap_or_default() + .trim() + .to_string(); + let secs = v + .get("lengthSeconds") + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()); + rows.push(VideoRow { + video_id: id.to_string(), + title, + byline, + duration_secs: secs, + }); + }); + + rows +} + +/// The placeholder rows YouTube leaves for videos it can no longer show. +fn is_unavailable_stub(title: &str) -> bool { + matches!(title, "[Private video]" | "[Deleted video]" | "[Unavailable]") +} + +/// The next page's continuation token, wherever the shape put it. +pub(crate) fn continuation_token(root: &Value) -> Option { + let mut token = None; + for key in ["continuationItemViewModel", "continuationItemRenderer"] { + walk(root, key, &mut |c| { + if token.is_none() { + walk(c, "token", &mut |t| { + if token.is_none() { + if let Some(s) = t.as_str() { + token = Some(s.to_string()); + } + } + }); + } + }); + if token.is_some() { + break; + } + } + token +} + +/// The playlist's own name, across header generations. +fn playlist_title(initial_data: &Value) -> Option { + let mut title = None; + for key in ["playlistMetadataRenderer", "microformatDataRenderer", "pageHeaderRenderer"] { + walk(initial_data, key, &mut |n| { + if title.is_none() { + title = n + .get("title") + .or_else(|| n.get("pageTitle")) + .and_then(Value::as_str) + .map(str::to_string); + } + }); + if title.is_some() { + break; + } + } + title +} + +/// The playlist owner's display name. The new header states it as an avatar +/// stack captioned "by Tommy"; the classic one as `ownerText` runs. +fn playlist_author(initial_data: &Value) -> String { + let mut author = String::new(); + walk(initial_data, "avatarStackViewModel", &mut |a| { + if author.is_empty() { + if let Some(t) = a + .get("text") + .and_then(|t| t.get("content")) + .and_then(Value::as_str) + { + author = t.trim().strip_prefix("by ").unwrap_or(t.trim()).to_string(); + } + } + }); + if author.is_empty() { + walk(initial_data, "ownerText", &mut |o| { + if author.is_empty() { + let t = collect_runs(o); + if !t.trim().is_empty() { + author = t.trim().to_string(); + } + } + }); + } + author +} + +/// INFO banners worth repeating ("Unavailable videos are hidden") and the +/// fatal alert text for dead lists ("The playlist does not exist."). +fn alert_texts(initial_data: &Value) -> Vec { + let mut out = Vec::new(); + for key in ["alertRenderer", "alertWithButtonRenderer"] { + walk(initial_data, key, &mut |a| { + if let Some(t) = a + .get("text") + .and_then(|t| crate::youtube_setlist::first_string(t, &["simpleText", "text"])) + { + if !t.trim().is_empty() { + out.push(t.trim().to_string()); + } + } + }); + } + out +} + +// ---- title cleaning and the artist policy ----------------------------------- + +/// Strip the upload-side junk a video title carries that a TRACK title does +/// not: "(Official Video)", "[Official Audio]", "(Lyric Video)", "[4K]"… +/// Version qualifiers are load-bearing (the DJ-set work proved remix naming +/// matters), so any group naming a remix/edit/mix/version survives even when +/// it also says "official" — "(Official Remix)" stays. +pub fn clean_video_title(title: &str) -> String { + const KEEP: &[&str] = &[ + "remix", "edit", "bootleg", "mix", "vip", "flip", "mashup", "rework", + "cover", "version", "feat", "ft.", "ft ", "live", "acoustic", "demo", + "instrumental", "extended", "radio", "dub", "remaster", + ]; + const JUNK: &[&str] = &[ + "official video", "official music video", "official audio", + "official lyric", "official visualizer", "official visualiser", + "music video", "lyric video", "lyrics video", "lyric", "lyrics", + "visualizer", "visualiser", "official", "out now", "free download", + "free dl", "premiere", "monstercat release", "ncs release", + "video oficial", "audio oficial", "clip officiel", + ]; + let mut s = title.trim().to_string(); + loop { + let t = s.trim_end(); + let Some((open, close)) = (match t.as_bytes().last() { + Some(b')') => Some(('(', ')')), + Some(b']') => Some(('[', ']')), + _ => None, + }) else { + break; + }; + let Some(at) = t.rfind(open) else { break }; + let inner = t[at + 1..t.len() - 1].trim().to_lowercase(); + let _ = close; + let keeps = KEEP.iter().any(|k| inner.contains(k)); + let junk = !keeps + && (JUNK.iter().any(|j| inner.contains(j)) + || matches!(inner.as_str(), "hd" | "hq" | "4k" | "8k" | "mv" | "m/v" | "video" | "audio" | "full version")); + if !junk || at == 0 { + break; + } + s = t[..at].trim_end().to_string(); + } + if s.is_empty() { + title.trim().to_string() + } else { + s + } +} + +/// The artist a channel name implies, when it does. Auto-generated music +/// channels ("Artist - Topic") and VEVO channels ("ArtistVEVO") are the +/// artist by construction; any other channel is offered as-is — for artist +/// channels it is exactly right, and the review shows every row's artist +/// before anything commits. +pub fn byline_artist(byline: &str) -> Option { + let b = byline.trim(); + if b.is_empty() { + return None; + } + if let Some(a) = b.strip_suffix(" - Topic") { + let a = a.trim(); + return (!a.is_empty()).then(|| a.to_string()); + } + if let Some(a) = b.strip_suffix("VEVO").filter(|a| !a.is_empty()) { + // "TaylorSwiftVEVO" → "TaylorSwift"; normalized matching downstream + // strips spacing anyway. + return Some(a.trim().to_string()); + } + Some(b.to_string()) +} + +/// Draft rows from the fetched videos: the title's own "Artist - Title" +/// split wins (a curator's upload states the artist there); a bare title +/// takes the channel as artist. +pub fn to_parsed(fetch: &PlaylistFetch) -> crate::playlist_formats::ParsedPlaylist { + let mut entries = Vec::new(); + for (i, row) in fetch.rows.iter().enumerate() { + let cleaned = clean_video_title(&row.title); + let (artist, title) = match crate::playlist_formats::split_artist_title(&cleaned) { + (Some(a), Some(t)) => (Some(a), Some(t)), + (_, t) => (byline_artist(&row.byline), t), + }; + entries.push(crate::playlist_formats::ParsedEntry { + ordinal: i as u32, + title, + artist, + album: None, + duration_secs: row.duration_secs, + location: None, + start_ms: None, + raw: row.title.clone(), + }); + } + crate::playlist_formats::ParsedPlaylist { + format: "youtube-playlist".into(), + title: Some(fetch.title.clone()), + description: None, + entries, + warnings: Vec::new(), + } +} + +// ---- the fetch ---------------------------------------------------------------- + +/// Fetch the playlist's full video list: the page, then one `browse` POST +/// per continuation. Continuation failures degrade to "first N videos" with +/// a warning instead of failing the import. +pub async fn fetch(url: &str) -> Result { + let id = playlist_id_from_url(url) + .ok_or("That doesn't look like a YouTube playlist link — it needs a list= id")?; + let client = reqwest::Client::builder() + .user_agent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/126.0.0.0 Safari/537.36", + ) + .timeout(std::time::Duration::from_secs(25)) + .build() + .map_err(|e| e.to_string())?; + let html = client + .get(format!("https://www.youtube.com/playlist?list={id}&hl=en")) + .header("Cookie", "CONSENT=YES+1; SOCS=CAI") + .header("Accept-Language", "en") + .send() + .await + .map_err(|e| format!("YouTube did not answer: {e}"))? + .error_for_status() + .map_err(|e| format!("YouTube refused the page: {e}"))? + .text() + .await + .map_err(|e| e.to_string())?; + + let initial_data: Value = slice_json_after(&html, "ytInitialData") + .and_then(|s| serde_json::from_str(s).ok()) + .ok_or("Could not read the playlist's data — is the link public?")?; + + let mut warnings = Vec::new(); + let mut skipped = 0usize; + let mut rows = video_rows(&initial_data, &mut skipped); + let alerts = alert_texts(&initial_data); + if rows.is_empty() { + let why = alerts + .first() + .cloned() + .unwrap_or_else(|| "it may be private or deleted".into()); + return Err(format!("YouTube listed no videos for this playlist — {why}")); + } + // "Unavailable videos are hidden" and its kin — YouTube already counted + // what it dropped, so repeat it rather than pretending completeness. + warnings.extend(alerts); + + let title = playlist_title(&initial_data).unwrap_or_else(|| "YouTube playlist".into()); + let author = playlist_author(&initial_data); + let version = crate::youtube_setlist::client_version(&html); + + let mut token = continuation_token(&initial_data); + while let Some(t) = token.take() { + if rows.len() >= MAX_VIDEOS { + warnings.push(format!( + "Only the first {MAX_VIDEOS} videos were taken — the playlist goes on." + )); + break; + } + let body = serde_json::json!({ + "context": { "client": { + "clientName": "WEB", "clientVersion": version, + "hl": "en", "gl": "US", + }}, + "continuation": t, + }); + let resp = client + .post("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false") + .json(&body) + .send() + .await; + let next: Value = match resp { + Err(e) => { + warnings.push(format!( + "Only the first {} videos could be fetched: {e}", + rows.len() + )); + break; + } + Ok(r) => match r.json().await { + Err(e) => { + warnings.push(format!( + "Only the first {} videos could be fetched: {e}", + rows.len() + )); + break; + } + Ok(v) => v, + }, + }; + let mut more = video_rows(&next, &mut skipped); + if more.is_empty() { + break; + } + rows.append(&mut more); + token = continuation_token(&next); + } + + if skipped > 0 { + warnings.push(format!( + "{skipped} unavailable video{} skipped (private or deleted).", + if skipped == 1 { "" } else { "s" } + )); + } + + Ok(PlaylistFetch { + title, + author, + rows, + warnings, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn playlist_ids_from_all_the_spellings() { + let id = "PLRJiC-nOkffKTsUPqj4NfkOYtBX490wI1"; + for url in [ + &format!("https://www.youtube.com/playlist?list={id}"), + &format!("https://music.youtube.com/playlist?list={id}"), + &format!("https://www.youtube.com/watch?v=abc123def45&list={id}&index=2"), + &format!("https://youtu.be/abc123def45?list={id}"), + &format!("youtube.com/playlist?list={id}#top"), + id, + ] { + assert_eq!(playlist_id_from_url(url).as_deref(), Some(id), "{url}"); + } + // A bare VIDEO id must not read as a playlist. + assert_eq!(playlist_id_from_url("dQw4w9WgXcQ"), None); + assert_eq!(playlist_id_from_url("https://example.com/playlist?list=PLabc123456"), None); + assert_eq!(playlist_id_from_url("https://www.youtube.com/watch?v=abc123def45"), None); + } + + #[test] + fn junk_suffixes_stripped_qualifiers_kept() { + assert_eq!(clean_video_title("Song (Official Video)"), "Song"); + assert_eq!(clean_video_title("Song [Official Music Video] (4K)"), "Song"); + assert_eq!(clean_video_title("Song (Ben Böhmer Remix)"), "Song (Ben Böhmer Remix)"); + assert_eq!( + clean_video_title("Song (Unofficial Remix) [HD]"), + "Song (Unofficial Remix)" + ); + assert_eq!(clean_video_title("Only You (feat. Haley)"), "Only You (feat. Haley)"); + assert_eq!(clean_video_title("Song (Official Audio)"), "Song"); + // A title that IS one junk-looking group survives whole. + assert_eq!(clean_video_title("(Official Video)"), "(Official Video)"); + } + + #[test] + fn byline_artist_policy() { + assert_eq!(byline_artist("Ben Böhmer - Topic").as_deref(), Some("Ben Böhmer")); + assert_eq!(byline_artist("TaylorSwiftVEVO").as_deref(), Some("TaylorSwift")); + assert_eq!(byline_artist("Skrillex").as_deref(), Some("Skrillex")); + assert_eq!(byline_artist(""), None); + } + + #[test] + fn lockup_rows_and_continuation_from_fixture() { + let page: Value = serde_json::json!({ + "contents": [ + { "lockupViewModel": { + "contentId": "1FSqIJTiovQ", + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO", + "contentImage": { "overlays": [ { "thumbnailBadgeViewModel": { "text": "3:20" } } ] }, + "metadata": { "lockupMetadataViewModel": { + "title": { "content": "When We Were Young" }, + "metadata": { "contentMetadataViewModel": { "metadataRows": [ + { "metadataParts": [ { "text": { "content": "Dillon Francis" } } ] } + ]}} + }} + }}, + { "lockupViewModel": { + "contentId": "zzzzzzzzzzz", + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO", + "metadata": { "lockupMetadataViewModel": { "title": { "content": "[Private video]" } } } + }}, + { "lockupViewModel": { + "contentId": "PLnotavideo", + "contentType": "LOCKUP_CONTENT_TYPE_PLAYLIST", + "metadata": { "lockupMetadataViewModel": { "title": { "content": "A shelf" } } } + }}, + { "continuationItemViewModel": { "continuationCommand": { + "innertubeCommand": { "continuationCommand": { "token": "NEXT_PAGE" } } + }}} + ] + }); + let mut skipped = 0; + let rows = video_rows(&page, &mut skipped); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].video_id, "1FSqIJTiovQ"); + assert_eq!(rows[0].title, "When We Were Young"); + assert_eq!(rows[0].byline, "Dillon Francis"); + assert_eq!(rows[0].duration_secs, Some(200)); + assert_eq!(skipped, 1); + assert_eq!(continuation_token(&page).as_deref(), Some("NEXT_PAGE")); + } + + #[test] + fn classic_renderer_still_read() { + let page: Value = serde_json::json!({ + "contents": [ { "playlistVideoRenderer": { + "videoId": "abc123def45", + "title": { "runs": [ { "text": "Artist - Song" } ] }, + "shortBylineText": { "runs": [ { "text": "Some Channel" } ] }, + "lengthSeconds": "253" + }}] + }); + let mut skipped = 0; + let rows = video_rows(&page, &mut skipped); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].duration_secs, Some(253)); + assert_eq!(rows[0].byline, "Some Channel"); + } + + #[test] + fn split_wins_over_byline_and_byline_fills_bare_titles() { + let fetch = PlaylistFetch { + title: "party playlist".into(), + author: "Tommy".into(), + warnings: Vec::new(), + rows: vec![ + VideoRow { + video_id: "a".into(), + title: "Jessi Malay - Summer Love (Funk LeBlanc Remix)".into(), + byline: "Funk LeBlanc".into(), + duration_secs: Some(278), + }, + VideoRow { + video_id: "b".into(), + title: "RATATA (Official Video)".into(), + byline: "Skrillex".into(), + duration_secs: Some(127), + }, + ], + }; + let parsed = to_parsed(&fetch); + assert_eq!(parsed.entries[0].artist.as_deref(), Some("Jessi Malay")); + assert_eq!( + parsed.entries[0].title.as_deref(), + Some("Summer Love (Funk LeBlanc Remix)") + ); + assert_eq!(parsed.entries[1].artist.as_deref(), Some("Skrillex")); + assert_eq!(parsed.entries[1].title.as_deref(), Some("RATATA")); + assert!(parsed.entries.iter().all(|e| e.start_ms.is_none())); + } +} diff --git a/src-tauri/src/youtube_setlist.rs b/src-tauri/src/youtube_setlist.rs index 17ec205..67e56e7 100644 --- a/src-tauri/src/youtube_setlist.rs +++ b/src-tauri/src/youtube_setlist.rs @@ -122,7 +122,7 @@ pub(crate) fn slice_json_after<'a>(html: &'a str, needle: &str) -> Option<&'a st /// applying `f` to the value. Youtube's trees are deep, volatile, and /// renderer-name-happy; searching for leaf keys survives their reshuffles /// better than hardcoded paths. -fn walk<'a>(root: &'a Value, key: &str, f: &mut dyn FnMut(&'a Value)) { +pub(crate) fn walk<'a>(root: &'a Value, key: &str, f: &mut dyn FnMut(&'a Value)) { match root { Value::Object(map) => { if let Some(v) = map.get(key) { @@ -143,7 +143,7 @@ fn walk<'a>(root: &'a Value, key: &str, f: &mut dyn FnMut(&'a Value)) { /// First string under any of `keys` in reading order — for label-ish leaves /// ("simpleText", nested runs) where youtube varies the wrapper. -fn first_string(v: &Value, keys: &[&str]) -> Option { +pub(crate) fn first_string(v: &Value, keys: &[&str]) -> Option { match v { Value::String(s) => Some(s.clone()), _ => { @@ -292,7 +292,7 @@ pub(crate) fn comment_texts(next_response: &Value) -> Vec<(String, String)> { /// Flatten a `runs`/`simpleText` rich-text node into plain text, preserving /// the run boundaries' own newlines (timestamps arrive as separate runs). -fn collect_runs(v: &Value) -> String { +pub(crate) fn collect_runs(v: &Value) -> String { if let Some(s) = v.get("simpleText").and_then(Value::as_str) { return s.to_string(); } diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 3ba50ac..8853812 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -7,6 +7,7 @@ getArt, getCatalogArt, jumpToQueueIndex, + peekNextIndex, streamSong, discovery, loadDiscovery, @@ -101,11 +102,6 @@ // nothing, then a partial list. const albumMode = $derived(!!player.catalogRef.albumId); - // Artist name for the Save action: album items carry "artist — album". - const artistName = $derived.by(() => { - const sub = currentItem?.sub ?? ""; - return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub; - }); // Artist / album split for the separate, clickable identity lines. // Per field, like the player bar's: the item's own artist/album win when // present (the continuation fill writes them beside a bare-artist `sub`), @@ -121,6 +117,16 @@ currentItem?.album ?? split[1] ?? "", ]; }); + // The artist everything else in this panel is ABOUT — discovery, the + // chip, "More from", the in-library count, the Save action. + // + // It used to split `sub` on its own, which is only "artist — album" for + // album playback. A playlist item's `sub` is the PLAYLIST's name, so + // playing out of one loaded the whole Artist and About panes for an + // artist called "Modern Electro Ghetto Tech Mix" while the identity + // lines two rows above correctly read Harka. The item's own `artist` + // field is the answer, and `metaParts` already applies that rule. + const artistName = $derived(metaParts[0]); const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]); @@ -170,7 +176,9 @@ // tier, drop the album that's playing now, cap at 6. const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2); const moreAlbums = $derived.by(() => { - const here = norm(currentItem?.sub?.split(" — ")[1] ?? ""); + // The record playing now, by the same per-field rule — `sub` is the + // playlist's name, not "artist — album", whenever one is playing. + const here = norm(metaParts[1]); return [...discovery.albums] .filter((a) => norm(a.title) !== here) .sort( @@ -906,7 +914,26 @@ } } - const upNext = $derived(player.queue[player.queueIndex + 1] ?? null); + // What actually plays next — shuffle included: peekNextIndex reads the + // predetermined order, so this card, the preloader and the advance all + // name the same track. + const upNextIndex = $derived(peekNextIndex()); + const upNext = $derived(upNextIndex >= 0 ? (player.queue[upNextIndex] ?? null) : null); + // The NEXT track's own artwork — the card wore the CURRENT track's cover + // for a while, which read as "The Medic" having the Jets to Brazil sleeve. + let upNextArt = $state(null); + $effect(() => { + const item = upNext; + upNextArt = null; + if (!item) return; + let active = true; + if (item.art) { + getArt(item.art, 96).then((u) => active && (upNextArt = u)).catch(() => {}); + } else if (item.artUrl) { + getCatalogArt(playerArtSource(item)).then((u) => active && (upNextArt = u)).catch(() => {}); + } + return () => (active = false); + }); const fmtListeners = (n) => (n == null ? null : n.toLocaleString()); @@ -1249,8 +1276,12 @@
{#if pane === "queue"} {#if upNext} - - {:else if mode === "youtube"} + {:else if door === "youtube"} {#if scanning}
@@ -537,16 +596,72 @@ {/if} + {:else if door === "ytplaylist"} +

+ Paste a playlist link from YouTube, Spotify, + Apple Music, TIDAL, or Deezer — every + track arrives named by the service's own listing. Read + straight off the playlist's public page; nothing is + downloaded and no account is involved. +

+ e.key === "Enter" && previewYoutubePlaylist()} + /> + {#if error}

{error}

{/if} +
+ + +
{:else} + {#if pasteFromSpotify} +
    +
  1. Open the playlist in Spotify.
  2. +
  3. Click any track, then Ctrl+A to select them all.
  4. +
  5. Ctrl+C, then paste below — every track comes over, + however many there are.
  6. +
+ {/if}

One track per line. Timestamps make it a setlist — 00:00 Artist - Title keeps its place in the mix. + Track links work too: in Spotify (or Apple Music, TIDAL, + Deezer) select every track and copy, then paste here — that path + has no length limit, unlike a playlist link.

+ {#if pasteHasLinks && !parsing} +
+ Track links detected — each is read from its own public page. +
+ {/if} + {#if linkProgress} +
+ +
+ {#if linkProgress.waiting} + The service is throttling — waiting a minute, then + carrying on from {linkProgress.done} of {linkProgress.total}… + {:else} + Reading track {linkProgress.done} of {linkProgress.total}… + {/if} +
+
+ {/if} {#if error}

{error}

{/if}
@@ -555,7 +670,7 @@ onclick={previewPaste} disabled={!pasteText.trim() || parsing} > - {parsing ? "Reading…" : "Preview"} + {parsing ? (pasteHasLinks ? "Looking up…" : "Reading…") : "Preview"}
{/if} @@ -583,6 +698,19 @@
{/if} + {#if spotifyCapped} +
+
+ This is Spotify's limit, not the playlist's. + Pasted track links have none — and Spotify hands them all over + at once. +
+ +
+ {/if} +
{#each working.entries as e (e.ordinal)}
{#if error}

{error}

{/if}
- {#if mode === "paste" || mode === "youtube"} + {#if door === "paste" || door === "youtube" || door === "ytplaylist"} {:else} @@ -921,6 +1049,49 @@ color: #e8a33d; } + /* The capped-import hand-off: a statement and the way out of it. */ + .capline { + display: flex; + align-items: center; + gap: 14px; + margin: 0 0 12px; + padding: 11px 14px; + border-radius: 10px; + background: var(--bg-highlight); + border: 1px solid var(--border); + } + .capwords { + flex: 1; + font-size: 12.5px; + line-height: 1.45; + color: var(--text-secondary); + } + .capwords b { + color: var(--text-primary); + } + .btn.small { + flex: none; + padding: 7px 13px; + font-size: 12.5px; + border-radius: 8px; + background: var(--accent); + color: #04121d; + font-weight: 700; + white-space: nowrap; + } + .btn.small:hover { + background: var(--accent-hover); + } + .steps { + margin: 0 0 4px; + padding-left: 20px; + font-size: 12.5px; + line-height: 1.75; + color: var(--text-secondary); + } + .steps b { + color: var(--text-primary); + } .warnings { margin-bottom: 8px; } diff --git a/src/lib/api.js b/src/lib/api.js index 6920ea3..9544b13 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -68,6 +68,10 @@ export const downloadFile = (username, path, size) => export const copyStreamToFolder = (files, folder) => invoke("copy_stream_to_folder", { files, folder }); export const saveToLibrary = (files) => invoke("save_to_library", { files }); +// Byte length of the cached copy of one peer file (null when uncached) — +// compared against the peer-stated size to tell "finished" from "prefix". +export const streamCacheLen = (username, path) => + invoke("stream_cache_len", { username, path }); export const cancelDownload = (id) => invoke("cancel_download", { id }); export const showInFolder = (path) => invoke("show_in_folder", { path }); export const refreshArtistArt = () => invoke("refresh_artist_art"); @@ -136,6 +140,11 @@ export const openExternal = (url) => invoke("open_external", { url }); // pixels on the long side. Omit it only where true pixels matter (the // fullscreen viewer, the import picker's comparison and loupe). export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge }); +/** The cover embedded in a streamed track's own cache file, extracted to a + * local image path — null while the transfer is too young to read, or when + * the peer's file simply carries no picture. */ +export const streamEmbeddedArt = (username, path) => + invoke("stream_embedded_art", { username, path }); export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId }); export const refreshAllAlbumArt = () => invoke("refresh_all_album_art"); export const getSettings = () => invoke("get_settings"); @@ -186,6 +195,14 @@ export const playlistImportProbe = () => invoke("playlist_import_probe"); export const playlistImportFolder = () => invoke("playlist_import_folder"); export const playlistImportText = (text) => invoke("playlist_import_text", { text }); export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url }); +export const playlistImportYoutubePlaylist = (url) => + invoke("playlist_import_youtube_playlist", { url }); +// One door for every service link: YouTube, Spotify, Apple Music, TIDAL, Deezer. +export const playlistImportLink = (url) => invoke("playlist_import_link", { url }); +// A pasted block of TRACK links (any supported service), looked up one page +// each — the way past Spotify's 100-track public ceiling. +export const playlistImportTrackLinks = (text, name = null) => + invoke("playlist_import_track_links", { text, name }); // The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a // mix's tracks from its audio. Progress rides "playlist:scan-progress". export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url }); @@ -257,6 +274,15 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p export const lyricsSaveLines = (title, artist, durationSecs, lines) => invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines }); export const catalogSearch = (query) => invoke("catalog_search", { query }); +// Track rows only — one Deezer call, and the query may use Deezer's field +// syntax (artist:"…" track:"…"). The identity lookups use this; the search +// PAGE still uses catalogSearch, which also wants artists and albums. +export const catalogSearchTracks = (query, limit = 10) => + invoke("catalog_search_tracks", { query, limit }); +// The artist page's whole shelf — Deezer plus the MusicBrainz/Apple +// supplements — flattened to tracks, for lookups the search index failed. +export const catalogArtistTrackCandidates = (artist, title) => + invoke("catalog_artist_track_candidates", { artist, title }); export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc }); export const rememberFusedArtistPortrait = (artistId, portrait) => invoke("remember_fused_artist_portrait", { artistId, portrait }); diff --git a/src/lib/catalogMatch.js b/src/lib/catalogMatch.js new file mode 100644 index 0000000..1a6e57d --- /dev/null +++ b/src/lib/catalogMatch.js @@ -0,0 +1,315 @@ +// One strict name matcher against catalog search, shared by everything that +// has a name and wants a record. +// +// It lives here rather than inside the import review because a name is the +// weakest identity this app deals with, and the app's rule is that a name +// never identifies a record on its own (memory: the artist/album anchor +// rule). Where a name IS all there is, every caller must at least agree on +// how strict "matched" means — the loose version of this lookup once put one +// record's cover on seven unrelated songs, because a first-result fallback +// answers every query with something. +// +// The bar: the artist agrees, the title agrees ONCE ITS VERSION WORDING IS +// SET ASIDE, the version qualifiers are compatible, and a known duration on +// both sides confirms rather than contradicts. +// +// Why the wording has to be set aside (measured against a real 258-row +// import, where 41 rows read "not in catalog" while plainly being in it): +// services word the same master differently. Spotify writes "Bohemian +// Rhapsody - Remastered 2011"; Deezer writes "Bohemian Rhapsody (2011 +// Remaster)". Neither string contains the other, so a containment test +// scores them zero and the most famous rock song ever recorded comes back +// unfound. But the qualifier cannot simply be DROPPED either — that would +// match a remix to the original it samples, which the DJ-set work proved is +// the wrong answer. So the base names must agree AND the qualifiers must be +// of compatible kinds. + +/** Fold to comparable letters and digits: case, accents and punctuation all + * differ freely between a tag, a tracklist line and a catalog row. */ +export const nameNorm = (s) => + (s ?? "") + .toLowerCase() + .normalize("NFKD") + .replace(/&/g, " and ") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); + +/** A different MASTER of one performance: same recording, repackaged. */ +const EDITION_WORDS = + /\b(remaster|remastered|remasterd|stereo|mono|deluxe|anniversary|reissue|expanded|digital|album version|single version|original version|radio edit|explicit|clean|bonus|mix master|from the vault)\b/; +/** A different PERFORMANCE: another take, another artist's hands on it. */ +const IDENTITY_WORDS = + /\b(remix|live|acoustic|instrumental|demo|cover|karaoke|unplugged|reprise|vip|bootleg|session|rework|flip|mashup|dub|edit|mix|version|take|alternate|orchestral|piano|8 bit|tribute|originally|made famous)\b/; +/** Credits, not recordings. */ +const CREDIT_WORDS = /^(feat|featuring|ft|with|w)\b/; + +/** Split "Title (2011 Remaster)" / "Title - Remastered 2011" / + * "Title -album version-" into its base name and its trailing qualifier. + * Only ONE trailing group is taken off per pass, repeatedly, so + * "Lola (Stereo Remastered 2020) [Bonus]" reduces fully. */ +export function splitVersion(title) { + let base = (title ?? "").trim(); + const parts = []; + for (let pass = 0; pass < 4; pass += 1) { + const before = base; + // A bracketed group at the end. + let m = base.match(/^(.*\S)\s*[([{]([^([{]*)[)\]}]\s*$/); + if (m) { + base = m[1].trim(); + parts.push(m[2]); + continue; + } + // "Title -album version-" — dashes on both sides of the tail. + m = base.match(/^(.*\S)\s+-\s*([^-]+?)\s*-\s*$/); + if (m) { + base = m[1].trim(); + parts.push(m[2]); + continue; + } + // "Title - Remastered 2011" — a dash-introduced tail. Only when it + // reads like a qualifier, so "Kiss - Detroit Rock City" keeps its + // shape and a hyphenated name like "A-Punk" is never split (no + // surrounding spaces). + m = base.match(/^(.*\S)\s+-\s+(.+)$/); + if (m) { + const tail = nameNorm(m[2]); + if (EDITION_WORDS.test(tail) || IDENTITY_WORDS.test(tail) || /^\d{4}$/.test(tail)) { + base = m[1].trim(); + parts.push(m[2]); + continue; + } + } + if (base === before) break; + } + return { base, qualifier: parts.reverse().join(" ") }; +} + +/** What a qualifier means for identity: nothing, a different master, or a + * different performance. An unrecognized qualifier counts as a different + * performance — the strict reading, so an unknown word never silently + * merges two records. */ +export function qualifierClass(qualifier) { + const n = nameNorm(qualifier); + if (!n) return "none"; + if (CREDIT_WORDS.test(n)) return "edition"; + if (/^\d{4}$/.test(n)) return "edition"; + if (EDITION_WORDS.test(n)) return "edition"; + if (IDENTITY_WORDS.test(n)) return "identity"; + return "identity"; +} + +/** The comma-separated lead artist: services credit "Ennio Morricone, Joan + * Baez" where the catalog rows only the first. Never splits on "&" or "and" + * — "Joan Jett and the Blackhearts" is one act. */ +export function primaryArtist(artist) { + const first = (artist ?? "").split(",")[0].trim(); + return first || (artist ?? "").trim(); +} + +/** The act without its backing band: "Joan Jett and the Blackhearts" → + * "Joan Jett". Catalogs credit the same recording both ways — Deezer files + * the studio "Bad Reputation" under plain "Joan Jett" while giving the live + * takes the full name — so both spellings have to be comparable. + * + * Only a trailing "and the …" / "with …" comes off, never a bare word, so + * "Queen" and "Queen Latifah" stay the strangers they are. */ +export function bandCore(artist) { + const n = nameNorm(artist); + const m = n.match(/^(.*?\S)\s+(?:and|with)\s+(?:the\s+)?\S.*$/); + return m ? m[1] : n; +} + +/** Do these two artist strings name the same act? Exact after folding + * ("&" → "and"), or one side's lead artist equals the other whole, or they + * agree once a backing band is set aside. */ +function artistAgrees(wantArtist, candArtist) { + const w = nameNorm(wantArtist); + const c = nameNorm(candArtist); + if (!w || !c) return false; + if (w === c) return true; + const wp = nameNorm(primaryArtist(wantArtist)); + const cp = nameNorm(primaryArtist(candArtist)); + if (wp === c || cp === w || (!!wp && wp === cp)) return true; + const wc = bandCore(wp); + const cc = bandCore(cp); + return (!!wc && (wc === c || wc === cc)) || (!!cc && cc === w); +} + +/** Durations that both sides know and that disagree by more than this are + * different cuts (a single edit against an album version), not sloppy + * metadata. Rejecting on it is what keeps a 2:14 radio edit from answering + * for a 6:21 album track. */ +const DURATION_TOLERANCE_SECS = 20; +/** Within this, the duration actively CONFIRMS the match. */ +const DURATION_CONFIRM_SECS = 7; + +/** The best catalog track for `want` ({ title, artist, duration_secs }), or + * null when nothing clears the bar. Null is a real answer: no artwork is + * better than another record's artwork. */ +export function pickCatalogMatch(tracks, want) { + const wantSplit = splitVersion(want?.title); + const wantBase = nameNorm(wantSplit.base); + const wantClass = qualifierClass(wantSplit.qualifier); + const wantQual = nameNorm(wantSplit.qualifier); + if (!wantBase || !nameNorm(want?.artist)) return null; + + let best = null; + let bestScore = -1; + for (const t of tracks ?? []) { + if (!artistAgrees(want.artist, t.artist)) continue; + + const candSplit = splitVersion(t.title); + const candBase = nameNorm(candSplit.base); + const candClass = qualifierClass(candSplit.qualifier); + const candQual = nameNorm(candSplit.qualifier); + if (!candBase) continue; + + // The names, with version wording set aside, must actually agree. + let titleScore; + if (candBase === wantBase) titleScore = 4; + else if ( + // A containment still counts, but only when the shorter name is + // substantial — "Go" inside "Go Your Own Way" is a coincidence. + (candBase.includes(wantBase) || wantBase.includes(candBase)) && + Math.min(candBase.length, wantBase.length) >= 6 && + Math.min(candBase.length, wantBase.length) / Math.max(candBase.length, wantBase.length) >= 0.6 + ) + titleScore = 2; + else continue; + + // Compatible versions: an edition may answer for a plain name (and + // vice versa) because it is the same performance; a REMIX, live take + // or cover may only answer a request that asked for one. + if (wantClass === "identity" || candClass === "identity") { + const shared = + wantQual && + candQual && + wantQual.split(" ").some((w) => w.length > 2 && candQual.includes(w)); + if (!shared) continue; + } + + // A duration both sides know, disagreeing badly, means a different cut. + const bothKnow = want.duration_secs && t.duration_secs; + const delta = bothKnow ? Math.abs(t.duration_secs - want.duration_secs) : null; + if (bothKnow && delta > DURATION_TOLERANCE_SECS) continue; + + let score = titleScore; + if (bothKnow && delta <= DURATION_CONFIRM_SECS) score += 2; + // Same KIND of qualifier is a better answer than a merely allowed one. + if (wantClass === candClass) score += 1; + // Deezer's own rank breaks ties so a famous track beats a namesake. + score += Math.min((t.rank ?? 0) / 1_000_000, 1); + if (score > bestScore) { + bestScore = score; + best = t; + } + } + return best; +} + +/** Letters outside the Latin script — the tell that a title cannot be + * name-compared against a catalog that romanizes ("孤独の発明" is filed on + * Deezer as "Kodokunohatsumei"). */ +const nonLatinLetters = /(?![\p{Script=Latin}])\p{L}/u; + +/** The one match a NAME cannot make: the wanted title is written in a + * non-Latin script and the catalog romanized it. No amount of folding + * compares those, so within an ARTIST-SCOPED candidate list the duration + * carries the identity instead — and only under the strictest reading: + * the artist agrees, both durations are known, they agree within 3 + * seconds, and exactly ONE candidate qualifies. Two near-length songs on + * one record → null, the honest answer. */ +export function pickCrossScript(tracks, want) { + if (!want?.duration_secs) return null; + if (!nonLatinLetters.test(want?.title ?? "")) return null; + const qualifying = (tracks ?? []).filter( + (t) => + t.duration_secs && + Math.abs(t.duration_secs - want.duration_secs) <= 3 && + !nonLatinLetters.test(t.title ?? "") && + nameNorm(t.artist) && + nameNorm(want.artist) && + (nameNorm(t.artist) === nameNorm(want.artist) || + bandCore(t.artist) === bandCore(want.artist)), + ); + return qualifying.length === 1 ? qualifying[0] : null; +} + +/** Quote-safe value for Deezer's field search syntax. */ +const q = (s) => (s ?? "").replace(/["\\]/g, " ").trim(); + +/** The queries to try for one wanted track, strongest first. + * + * Deezer's plain search is a blunt instrument: "Vashti Bunyan Diamond Day", + * "King Krule Flimsier" and "toe LONELINESS WILL SHINE" all return ZERO + * rows, while its field syntax — artist:"…" track:"…" — finds every one of + * them. Version wording poisons it too: searching the full "Take It Easy - + * 2013 Remaster" finds nothing, the base name finds the remaster. So the + * ladder asks the precise way first, with the base name, and only falls + * back to the blunt way. */ +export function catalogQueries(want) { + const artist = q(primaryArtist(want?.artist)); + const full = q(want?.title); + const base = q(splitVersion(want?.title).base); + const core = q(bandCore(artist)); + const out = []; + if (artist && base) out.push(`artist:"${artist}" track:"${base}"`); + // Deezer may file the studio cut under the shorter credit while giving + // the live takes the full band name — ask both ways before giving up. + if (core && base && core !== nameNorm(artist)) out.push(`artist:"${core}" track:"${base}"`); + if (artist && full && full !== base) out.push(`artist:"${artist}" track:"${full}"`); + if (artist && base) out.push(`${artist} ${base}`); + return out; +} + +// The artist-shelf walk is per ARTIST, and a missing artist usually misses +// with several rows at once (four King Krule rows, four Vashti Bunyan rows +// on one real import) — so its results are remembered for the session. +const shelfCache = new Map(); +async function artistShelf(artistTracks, artist, title) { + const key = nameNorm(artist); + if (!key) return []; + if (!shelfCache.has(key)) { + if (shelfCache.size > 40) shelfCache.clear(); + shelfCache.set( + key, + artistTracks(artist, title).catch(() => { + shelfCache.delete(key); + return []; + }), + ); + } + return shelfCache.get(key); +} + +/** Find one track in the catalog: walk the query ladder, stop at the first + * rung that produces a match. `search` takes a query string and resolves to + * a list of catalog tracks; `artistTracks` (optional) takes an artist name + * plus the wanted title and resolves to the artist PAGE's whole shelf as + * tracks — the last resort for records the search index omits (which + * includes every MusicBrainz/Apple-supplemented release: the page shows + * them, the index has never heard of them). Returns null when nothing + * clears the bar — which stays a real, common, correct answer. */ +export async function findCatalogTrack(search, want, artistTracks = null) { + for (const query of catalogQueries(want)) { + let tracks; + try { + tracks = await search(query); + } catch { + return null; // offline or throttled — let the caller retry later + } + const hit = pickCatalogMatch(tracks, want) ?? pickCrossScript(tracks, want); + if (hit) return hit; + } + if (artistTracks) { + const names = [primaryArtist(want?.artist)]; + const core = bandCore(names[0]); + if (core && core !== nameNorm(names[0])) names.push(core); + for (const name of names) { + const shelf = await artistShelf(artistTracks, name, want?.title ?? ""); + const hit = pickCatalogMatch(shelf, want) ?? pickCrossScript(shelf, want); + if (hit) return hit; + } + } + return null; +} diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 1aec2cb..5ee5571 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -1,5 +1,6 @@ // Shared reactive app state (Svelte 5 runes module). import * as api from "./api.js"; +import { nameNorm, pickCatalogMatch } from "./catalogMatch.js"; import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js"; import { nav, @@ -522,12 +523,39 @@ export const ART_EDGE_DEFAULT = 512; * tile's own `object-fit` still decides the crop. Sources already within * `edge` come back untouched rather than re-encoded. Pass `null` to opt out * and get the master. */ +// ---- art fetch gate --------------------------------------------------- +// A page mounting a wall of tiles fires its art requests as fast as the +// observer sees them; each answer is a base64 payload crossing IPC onto +// the main thread. Six in flight keeps the pipe busy without the burst — +// the backend bounds the DECODES separately (2 at a time); this bounds +// the traffic. Masters (the player-bar hero) bypass the gate: rare, and +// they should never wait behind a hundred tiles. +const ART_FETCH_LIMIT = 6; +let artFetchActive = 0; +const artFetchQueue = []; +function gatedArtFetch(fn) { + return new Promise((resolve, reject) => { + const run = () => { + artFetchActive += 1; + fn() + .then(resolve, reject) + .finally(() => { + artFetchActive -= 1; + const next = artFetchQueue.shift(); + if (next) next(); + }); + }; + if (artFetchActive < ART_FETCH_LIMIT) run(); + else artFetchQueue.push(run); + }); +} + export async function getArt(path, edge = ART_EDGE_DEFAULT) { if (!path) return null; if (edge == null) return getArtMaster(path); const key = `${edge}|${path}`; if (artThumbCache.has(key)) return touchCache(artThumbCache, key); - const pending = api.albumArt(path, edge).then( + const pending = gatedArtFetch(() => api.albumArt(path, edge)).then( (url) => { if (artThumbCache.get(key) === pending) { setResolvedCache(artThumbCache, key, url, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS); @@ -3386,6 +3414,129 @@ export async function saveStream(scope) { } } +// ---- bulk save of a playlist's stream rows ---------------------------------- +// The playlist page's two collection actions, one job at a time app-wide: +// "cached" — file every row whose bytes are ALREADY fully in the stream +// cache into the library. Instant, free, touches no peer. +// "download" — find a source for every row (download prefs: quality floor, +// quality priority) and fetch it through save_to_library — +// cached rows organize free, the rest download the known file. +// Sequential on purpose: one Soulseek search at a time is the pending- +// resolver's own anti-flood posture, and a bulk job has no reason to be +// ruder than playback. +export const playlistBulk = $state({ + active: false, + mode: null, // "cached" | "download" + playlistId: null, + done: 0, + total: 0, + organized: 0, // filed into the library instantly (was fully cached) + queued: 0, // handed to the download pipeline + missed: 0, // no complete cache ("cached") / no source found ("download") + summary: null, // last finished run's one-line result, until dismissed +}); +let playlistBulkCancel = false; +export function cancelPlaylistBulk() { + playlistBulkCancel = true; +} +export function dismissPlaylistBulk() { + playlistBulk.summary = null; +} + +export async function runPlaylistBulk(playlistId, rows, mode) { + if (playlistBulk.active) return false; + // Stream rows with full identity, deduped — the party-playlist case: + // the same track twice must not download twice. + const seen = new Set(); + const want = rows.filter((r) => { + if (r.kind !== "stream" || !r.artist || !r.title) return false; + const key = `${normText(r.artist)}|${normText(r.title)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + playlistBulkCancel = false; + Object.assign(playlistBulk, { + active: true, + mode, + playlistId, + done: 0, + total: want.length, + organized: 0, + queued: 0, + missed: 0, + summary: null, + }); + try { + if (mode === "download") await autoConnect(); + for (const row of want) { + if (playlistBulkCancel) break; + try { + const known = await cachedStreamSourceFor( + row.track_id ?? null, + row.artist, + row.title, + ); + let src = known + ? { username: known.username, path: known.path, size: known.size } + : null; + if (mode === "cached") { + // Only a byte-complete cache counts — a prefix is not + // the track, and this mode promised "no downloading". + const len = src ? await api.streamCacheLen(src.username, src.path) : null; + if (!src || !src.size || len !== src.size) { + playlistBulk.missed += 1; + continue; + } + } else if (!src) { + const found = await resolveStreamSourceFor( + { + trackId: row.track_id ?? null, + artist: row.artist, + title: row.title, + album: row.album ?? null, + duration: row.duration_secs ?? null, + }, + 9000, + false, + ); + if (!found) { + playlistBulk.missed += 1; + continue; + } + src = { + username: found.stream.username, + path: found.path, + size: found.stream.size, + }; + } + const [organized, queued] = await api.saveToLibrary([src]); + playlistBulk.organized += organized; + playlistBulk.queued += queued; + if (organized === 0 && queued === 0) playlistBulk.missed += 1; + } catch { + playlistBulk.missed += 1; + } finally { + playlistBulk.done += 1; + } + } + const parts = []; + if (playlistBulk.organized) + parts.push(`${playlistBulk.organized} saved to library`); + if (playlistBulk.queued) parts.push(`${playlistBulk.queued} downloading`); + if (playlistBulk.missed) + parts.push( + `${playlistBulk.missed} ${mode === "cached" ? "not fully streamed yet" : "without a source"}`, + ); + if (playlistBulkCancel) parts.push("stopped early"); + playlistBulk.summary = parts.length ? parts.join(" · ") : "Nothing to do"; + if (playlistBulk.organized > 0) void refreshLibrary(); + } finally { + playlistBulk.active = false; + } + return true; +} + /** Parse "Artist — Album" (or just "Artist") from the current track's sub. */ function currentArtistAlbum() { const item = player.queue[player.queueIndex] ?? null; @@ -3882,6 +4033,95 @@ async function cachedStreamSourceFor(trackId, artist, title) { } void refreshSourcePrefs(); +// ---- artwork for a stream that arrived without any ----------------------- + +/** The cover embedded in a track's stream, but only if this app has already + * streamed it and still holds the whole file. + * + * Nothing is fetched and no peer is contacted: this is the playlist page + * asking what it can know for free about rows the catalog has never heard + * of. Null means "not from here" — never "this row has no artwork". */ +export async function cachedStreamArt(trackId, artist, title) { + const src = await cachedStreamSourceFor(trackId ?? null, artist, title); + if (!src) return null; + try { + return await api.streamEmbeddedArt(src.username, src.path); + } catch { + return null; + } +} + +/** Catalog covers already looked up this session, keyed artist|title. + * A stored `null` is a real answer — the catalog was asked and had nothing + * that matched strictly enough to be sure — and it stops every replay of + * that track asking again. */ +const streamArtLookups = new Map(); + +/** A streamed track showing no artwork at all: find it some, in order of + * certainty. + * + * The bytes the peer is sending ARE the recording, so a cover embedded in + * them belongs to it — no matching, no doubt, no network. Only a file that + * carries none asks the catalog, and then through the same strict matcher + * the import review verifies rows with. A loose name lookup here is exactly + * what once put one record's cover on seven unrelated songs, so no match + * means no art: a blank tile is the honest answer, a wrong cover is not. + * + * Mutates the queue item in place, which is what every art surface already + * reads — `art` for a local image, `artUrl` for a catalog cover — so the + * bar, the panel and the lightbox all fill from one assignment. */ +async function ensureStreamArt(item) { + if (!item?.stream || !item.path || item.art || item.artUrl) return; + // The world moves while a lookup is in flight: the queue can be replaced, + // and a later pass (or the stream-done retry) can land art first. + const stillBlank = () => + player.queue.includes(item) && !item.art && !item.artUrl; + const showing = () => player.queue[player.queueIndex] === item; + + try { + const embedded = await api.streamEmbeddedArt(item.stream.username, item.path); + if (embedded && stillBlank()) { + item.art = embedded; + if (showing()) setAccentFromArt(embedded); + return; + } + } catch { + // No tag, no file yet, or an unreadable one — the catalog is next. + } + if (!stillBlank()) return; + + const artist = item.artist ?? ""; + const title = item.title ?? ""; + if (!artist || !title) return; // nothing to be strict ABOUT + const key = `${nameNorm(artist)}|${nameNorm(title)}`; + if (streamArtLookups.has(key)) { + const known = streamArtLookups.get(key); + if (known && stillBlank()) { + item.artUrl = known; + if (showing()) setAccentFromArtUrl(known); + } + return; + } + try { + const res = await api.catalogSearch(`${artist} ${title}`); + const hit = pickCatalogMatch(res?.tracks, { + title, + artist, + duration_secs: item.duration ?? null, + }); + const url = hqCover(hit?.cover ?? null) ?? null; + streamArtLookups.set(key, url); + if (url && stillBlank()) { + item.artUrl = url; + if (showing()) setAccentFromArtUrl(url); + } + } catch { + // Offline or a provider hiccup: leave the key unset so a later play + // can try again rather than remembering a failure as an absence. + } +} + + /** Whether a peer file satisfies the user's quality window for this mode. * Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN * bitrate at or above it — a peer that hides its bitrate does not get the @@ -5228,6 +5468,18 @@ export const player = $state({ queue: [], queueIndex: -1, shuffle: false, + // The PREDETERMINED shuffle order: a permutation of queue indices for + // the current cycle. Play order is this array left to right; the + // position is wherever `queueIndex` sits in it. Predetermined so the + // up-next card, the gapless preload/append, and the actual advance all + // agree on the SAME future — a random pick at advance time made the + // card show the linear next track and the preloader cache it, while + // playback then went somewhere else entirely. + shuffleOrder: [], + // Under repeat "all", the NEXT cycle's order — generated the moment + // anything needs to see across the cycle boundary, so the last track's + // up-next and its preload name the reshuffle's real first track. + shuffleNextOrder: null, repeat: "off", // off | all | one // waveform peaks (0..1) for the current local track, [] while loading waveform: [], @@ -5650,7 +5902,12 @@ export function initEvents() { if (s.done) { // Fully cached → final waveform, and the peer's slot is // definitely free, so make sure the next track is pre-buffering. - if (cur?.stream) loadStreamWaveform(cur, true); + if (cur?.stream) { + loadStreamWaveform(cur, true); + // The first attempt may have read a file too young to parse; + // the complete one carries whatever tag the peer sent. + void ensureStreamArt(cur); + } prepareFollowing(); } else if ( ui.windowActive @@ -5920,6 +6177,7 @@ function playQueueIndex(i) { // correct: a restore must never resurrect a track the queue moved past). bumpMainRevision("queueJumpOrNextPrev"); player.queueIndex = i; + ensureShuffleOrder(); player.track = { title: item.title, sub: item.sub, @@ -5953,6 +6211,10 @@ function playQueueIndex(i) { if ("trackId" in item) player.catalogRef.trackId = item.trackId; appendedNextPath = null; setAccentFromArtUrl(item.artUrl ?? null); + // A row that arrived with no cover of its own (a setlist row the + // catalog could not verify, a by-name stream) gets one looked up + // while it plays — the file's own embedded art first. + void ensureStreamArt(item); loadStreamWaveform(item); // instant when already fully cached api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null) .then(() => { @@ -5998,10 +6260,10 @@ export function jumpToQueueIndex(i) { } function gaplessNextIndex() { - if (player.shuffle) return -1; if (player.repeat === "one") return player.queueIndex; - const next = player.queueIndex + 1; - const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1; + // Shuffle included: the order is predetermined, so the engine can arm + // the SAME track the panel promises. + const i = peekNextIndex(); // Set-recording slices never join the gapless machinery: a preload or // append would come up at the FILE's start, not the slice's, and the // slice's early cut is driven by the position handler, not the @@ -6024,14 +6286,27 @@ function preloadFollowingTrack() { // Quietly pre-buffer the next queued stream into the cache. The backend // no-ops when it's already cached or being fetched; single-slot peers just // queue the request until the current transfer finishes. -let lastPreloadedPath = null; +// Paths already asked for this session — the window re-arms on every +// advance, and a path once requested is cached or in flight; asking again +// would only duplicate peer traffic. +let preloadRequestedPaths = new Set(); -function preloadNextStream() { - const next = gaplessNextIndex(); - const item = next >= 0 ? player.queue[next] : null; - if (!item?.stream || item.path === lastPreloadedPath) return; - lastPreloadedPath = item.path; - api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {}); +/** Pull the next few stream tracks' bytes into the cache — the on-deck + * window. Nearest first, so the imminent boundary always has the head + * start; rows with no source yet are skipped here and picked up by the + * pending-row lookahead, whose resolve calls back into this. */ +function preloadUpcomingStreams() { + for (const i of peekNextIndices(STREAM_PRELOAD_DEPTH)) { + const item = player.queue[i]; + // Segments play a slice of a recording that is already on disk. + if (!item?.stream || !item.path || item.segment) continue; + if (preloadRequestedPaths.has(item.path)) continue; + preloadRequestedPaths.add(item.path); + api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => { + // Failed to even start — let a later window retry it. + preloadRequestedPaths.delete(item.path); + }); + } } // Once the next queued stream is fully cached, append it to the active @@ -6077,7 +6352,7 @@ async function tryAppendNext() { * exactly one boundary and stalled on every one after it.) */ function prepareFollowing() { preloadFollowingTrack(); - preloadNextStream(); + preloadUpcomingStreams(); tryAppendNext(); scheduleQueueLookahead(); } @@ -6264,10 +6539,10 @@ async function maybeExtendAlbumQueue() { // Teach the memory as the fill goes, so the album's LATER tracks // are known even if playback never reaches them this session. rememberStreamSource(item); - // Both are guarded against repeat work (lastPreloadedPath, + // Both are guarded against repeat work (preloadRequestedPaths, // appendedNextPath), so calling them per append costs nothing once - // the immediate next track is already prepared. - preloadNextStream(); + // the upcoming window is already prepared. + preloadUpcomingStreams(); tryAppendNext(); } autoAlbum = null; // every remaining track queued or skipped @@ -6287,8 +6562,10 @@ async function maybeExtendAlbumQueue() { const pendingResolves = new WeakMap(); -/** Resolve one pending descriptor to a playable source, or null. */ -async function resolveStreamSourceFor(t, budgetMs = 7000) { +/** Resolve one pending descriptor to a playable source, or null. + * `streamOnly=false` ranks by the DOWNLOAD source prefs instead (quality + * floor, quality priority) — the bulk download uses it; playback never. */ +async function resolveStreamSourceFor(t, budgetMs = 7000, streamOnly = true) { const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title); if (known) { return { @@ -6300,7 +6577,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) { } if (app.connection !== "online") return null; const goodCount = (rs) => - songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length; + songCandidates(rs, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist).length; const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`); const fallback = sanitizeSearchQuery(simplifyTitle(t.title)); let artistIncluded = true; @@ -6312,7 +6589,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) { artistIncluded = normText(primary).includes(normText(t.artist ?? "")); } const best = songCandidates( - responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded, + responses, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded, )[0]; if (!best) return null; return { @@ -6377,17 +6654,36 @@ function scheduleQueueLookahead() { }, ALBUM_FILL_DELAY_MS); } +// Rows the LOOKAHEAD has already searched once. Without this, a row whose +// search came up dry would re-enter the window every tick and re-search on +// a 4-second loop — the precise flood the pacing exists to prevent. One +// lookahead try per row; the play-on-reach resolver still retries it when +// its turn actually comes. Queue rebuilds mint new row objects, so the set +// forgets naturally. +const lookaheadTried = new WeakSet(); + async function resolveNextPendingItem() { - if (player.shuffle) return; // the next index is unknowable - const next = player.queueIndex + 1; + // The whole on-deck window wants sources, but Soulseek searches stay + // ONE at a time on the album fill's own pacing — resolve the nearest + // untried pending row, then schedule the next tick if more remain. + const pendingIn = (indices) => + indices.find((i) => { + const it = player.queue[i]; + return it && !it.path && !it.stream && !it.unplayable && !lookaheadTried.has(it); + }); + const next = pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH)); + if (next == null) return; const item = player.queue[next]; - if (!item || item.path || item.stream || item.unplayable) return; + lookaheadTried.add(item); const src = await resolveQueueItem(item); - if (!src || !player.queue.includes(item)) return; - // Now a normal upcoming stream: pre-buffer it and offer the gapless - // append, exactly as if it had been queued with its source. - preloadNextStream(); - tryAppendNext(); + if (!player.queue.includes(item)) return; + if (src) { + // Now a normal upcoming stream: pre-buffer it and offer the gapless + // append, exactly as if it had been queued with its source. + preloadUpcomingStreams(); + tryAppendNext(); + } + if (pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH)) != null) scheduleQueueLookahead(); } // ---- waveform ---- @@ -6506,16 +6802,115 @@ async function loadStreamWaveform(item, final = false) { // ---- queue navigation, shuffle, repeat ---- -// Indices already played this shuffle cycle (module-private). -let shuffleSeen = new Set(); +/** A Fisher-Yates permutation of 0..n, optionally anchored so `firstIndex` + * leads — the track already playing when an order is (re)built stays where + * it is and the shuffle happens to everything after it. */ +function shuffledIndices(n, firstIndex = null) { + const idx = Array.from({ length: n }, (_, i) => i); + for (let i = n - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [idx[i], idx[j]] = [idx[j], idx[i]]; + } + if (firstIndex != null) { + const at = idx.indexOf(firstIndex); + if (at > 0) { + idx.splice(at, 1); + idx.unshift(firstIndex); + } + } + return idx; +} -function pickShuffleIndex(reset = false) { +/** Keep the predetermined order valid. Called from the MUTATION points — + * every track start, the shuffle/repeat toggles, session restore — never + * from render-time reads: `peekNextIndex` runs inside $derived, where + * writing state is (rightly) an error. */ +function ensureShuffleOrder() { + if (!player.shuffle) return; const n = player.queue.length; - if (reset) shuffleSeen = new Set(); - const remaining = []; - for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i); - if (remaining.length === 0) return -1; - return remaining[Math.floor(Math.random() * remaining.length)]; + if (n === 0) { + player.shuffleOrder = []; + player.shuffleNextOrder = null; + return; + } + if (player.shuffleOrder.length !== n || !player.shuffleOrder.includes(player.queueIndex)) { + player.shuffleOrder = shuffledIndices(n, player.queueIndex); + player.shuffleNextOrder = null; + } + // Within a preload-window of the cycle's end with repeat "all": the + // future crosses into a reshuffle, so make that reshuffle NOW and keep + // it — the up-next display, the 3-deep pre-buffer and the advance all + // need the SAME answer. + const pos = player.shuffleOrder.indexOf(player.queueIndex); + if (pos >= n - STREAM_PRELOAD_DEPTH && player.repeat === "all") { + if (!player.shuffleNextOrder || player.shuffleNextOrder.length !== n) { + const next = shuffledIndices(n); + // A cycle boundary should not replay the same track back to back. + if (n > 1 && next[0] === player.queueIndex) { + [next[0], next[1]] = [next[1], next[0]]; + } + player.shuffleNextOrder = next; + } + } else { + player.shuffleNextOrder = null; + } +} + +/** How many upcoming tracks keep their bytes ON DECK in the stream cache. + * One was the minimum for gapless boundaries; three gives a slow peer two + * whole songs of runway, and the window slides — every advance tops it + * back up. The SEARCHES that find sources stay one-at-a-time regardless + * (the album fill's own anti-flood posture); only the byte-pulling widens. */ +const STREAM_PRELOAD_DEPTH = 3; + +/** The next `count` queue indices in PLAY order — through the shuffle + * order when it's on, across the repeat-all boundary when the pre-made + * next cycle exists. Pure: reads state only. Duplicates collapse (a + * two-track queue on repeat has one upcoming track, not two entries). */ +export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) { + const n = player.queue.length; + if (n === 0) return []; + let seq; + let pos; + if (player.shuffle) { + seq = player.shuffleOrder; + if (player.repeat === "all" && player.shuffleNextOrder?.length === n) { + seq = seq.concat(player.shuffleNextOrder); + } + pos = player.shuffleOrder.indexOf(player.queueIndex); + if (pos < 0) return []; + } else { + seq = Array.from({ length: n }, (_, i) => i); + if (player.repeat === "all") seq = seq.concat(seq); + pos = player.queueIndex; + } + const out = []; + const seen = new Set([player.queueIndex]); + for (let at = pos + 1; at < seq.length && out.length < count; at += 1) { + const i = seq[at]; + if (seen.has(i)) continue; + seen.add(i); + out.push(i); + } + return out; +} + +/** The queue index that plays after the current track, or -1 when playback + * would stop there. THE one answer for what comes next — the up-next card, + * the gapless preload/append, the pending-row lookahead and the advance + * itself all read this, so they cannot disagree. Pure: reads state only. */ +export function peekNextIndex() { + const n = player.queue.length; + if (n === 0) return -1; + if (player.shuffle) { + const pos = player.shuffleOrder.indexOf(player.queueIndex); + if (pos < 0) return -1; // order not built yet — a mutation point will + if (pos + 1 < player.shuffleOrder.length) return player.shuffleOrder[pos + 1]; + if (player.repeat !== "all") return -1; + return player.shuffleNextOrder?.length === n ? player.shuffleNextOrder[0] : -1; + } + const next = player.queueIndex + 1; + return next < n ? next : player.repeat === "all" ? 0 : -1; } export function nextTrack(auto = false) { @@ -6532,11 +6927,14 @@ export function nextTrack(auto = false) { let next; if (player.shuffle) { - shuffleSeen.add(player.queueIndex); - next = pickShuffleIndex(); - if (next < 0) { - if (player.repeat === "all") next = pickShuffleIndex(true); - else return stopPlayback(); + ensureShuffleOrder(); + next = peekNextIndex(); + if (next < 0) return stopPlayback(); + // Crossing the cycle boundary consumes the pre-made reshuffle. + const pos = player.shuffleOrder.indexOf(player.queueIndex); + if (pos === player.shuffleOrder.length - 1 && player.shuffleNextOrder) { + player.shuffleOrder = player.shuffleNextOrder; + player.shuffleNextOrder = null; } } else { next = player.queueIndex + 1; @@ -6568,6 +6966,14 @@ export function prevTrack() { seekPlayer(0); return; } + // Under shuffle, "previous" is the order's previous — the track that + // actually just played — not the neighbouring playlist row. + if (player.shuffle) { + const pos = player.shuffleOrder.indexOf(player.queueIndex); + if (pos > 0) playQueueIndex(player.shuffleOrder[pos - 1]); + else seekPlayer(0); + return; + } const prev = player.queueIndex - 1; if (prev >= 0) playQueueIndex(prev); else seekPlayer(0); @@ -6575,12 +6981,16 @@ export function prevTrack() { export function toggleShuffle() { player.shuffle = !player.shuffle; - shuffleSeen = new Set([player.queueIndex]); + player.shuffleOrder = []; + player.shuffleNextOrder = null; + ensureShuffleOrder(); } export function cycleRepeat() { player.repeat = player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off"; + // Repeat decides whether a cycle boundary has a future — recheck it. + ensureShuffleOrder(); } function stopPlayback() { @@ -6602,7 +7012,7 @@ function stopPlayback() { latestStreamTotal = 0; autoAlbum = null; appendedNextPath = null; - lastPreloadedPath = null; + preloadRequestedPaths = new Set(); setAccentFromArt(null); } @@ -6734,6 +7144,7 @@ function writeSession() { duration: player.duration, catalogRef: player.catalogRef, shuffle: player.shuffle, + shuffleOrder: player.shuffleOrder, repeat: player.repeat, volume: player.volume, muted: player.muted, @@ -6829,6 +7240,9 @@ export async function restoreSession() { player.muted = !!saveState.muted; player.shuffle = !!saveState.shuffle; player.repeat = saveState.repeat ?? "off"; + // The predetermined order survives a restart with its queue; anything + // stale is rebuilt at the next mutation point. + player.shuffleOrder = Array.isArray(saveState.shuffleOrder) ? saveState.shuffleOrder : []; const item = saveState.queue?.[saveState.queueIndex] ?? null; if (item && saveState.track) { @@ -6844,6 +7258,7 @@ export async function restoreSession() { // Nothing is loaded in the audio thread, so the play button has to // re-source rather than resume — see `resumePlayback`. player.needsResource = !live; + ensureShuffleOrder(); // The bar's artwork and the app accent follow the track exactly as // they would had it just been started. if (item.stream) setAccentFromArtUrl(item.artUrl ?? null); @@ -6901,6 +7316,6 @@ if (import.meta.env.DEV) { playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack, catalogGo, refreshLibrary, refreshRecents, autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, - playlistStore, refreshPlaylists, goToView, + playlistStore, refreshPlaylists, goToView, peekNextIndices, }; } diff --git a/src/views/Playlist.svelte b/src/views/Playlist.svelte index a92390c..f36136a 100644 --- a/src/views/Playlist.svelte +++ b/src/views/Playlist.svelte @@ -16,8 +16,15 @@ toggleShuffle, goToView, getArt, + cachedStreamArt, + playlistBulk, + runPlaylistBulk, + cancelPlaylistBulk, + dismissPlaylistBulk, } from "../lib/state.svelte.js"; + import { untrack } from "svelte"; import * as api from "../lib/api.js"; + import { findCatalogTrack } from "../lib/catalogMatch.js"; import Icon from "../components/Icon.svelte"; const playlist = $derived( @@ -29,6 +36,7 @@ let query = $state(""); let menuOpen = $state(false); let confirmDelete = $state(false); + let exportOpen = $state(false); let renaming = $state(false); let nameInput = $state(""); let editingDesc = $state(false); @@ -115,7 +123,6 @@ // the import ladder's own evidence class — supplies the cover and the // track id, persisted on the row. Sequential, one search per row, each // row attempted once per session; no match simply keeps the glyph. - const normId = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); const artAttempted = new Set(); let backfillBusy = false; $effect(() => { @@ -124,6 +131,7 @@ (i) => i.kind === "stream" && !i.art_url && + !i.art_path && i.artist && i.title && !artAttempted.has(i.id), @@ -138,17 +146,37 @@ if (ui.playlistId !== plId) return; if (artAttempted.has(row.id)) continue; artAttempted.add(row.id); + // The file BEFORE the catalog: a row already streamed once + // holds the peer's own copy, and underground rows — the ones + // with no catalog cover to find, which is why they are in + // this walk at all — are exactly the ones whose files carry + // artwork nothing else has. + const embedded = await cachedStreamArt(row.track_id, row.artist, row.title); + if (ui.playlistId !== plId) return; + if (embedded) { + try { + await api.playlistRelinkItem(plId, row.id, { artPath: embedded }); + const mine = items.find((i) => i.id === row.id); + if (mine) mine.art_path = embedded; + } catch { + // The row vanished mid-walk — nothing to persist onto. + } + continue; + } + // The SAME lookup the import review uses — one definition of + // "this row is that record", so a row that matched on import + // matches here too. let hit = null; try { - const res = await api.catalogSearch(`${row.artist} ${row.title}`); - const same = (t) => - normId(t.artist) === normId(row.artist) && - normId(t.title) === normId(row.title); - const hits = (res?.tracks ?? []).filter(same); - hit = - hits.find((t) => row.album && normId(t.album) === normId(row.album)) ?? - hits[0] ?? - null; + hit = await findCatalogTrack( + (query) => api.catalogSearchTracks(query), + { + title: row.title, + artist: row.artist, + duration_secs: row.duration_secs ?? null, + }, + (artist, title) => api.catalogArtistTrackCandidates(artist, title), + ); } catch { // Offline or throttled — let the next page visit retry. artAttempted.delete(row.id); @@ -174,6 +202,28 @@ } } + // A row the walk above could not help: its file was not cached when the + // page opened, so nothing local existed to read. The player extracts the + // cover the moment it streams one — if that row is one of ours, keep it, + // rather than making the row blank again on the next visit. + $effect(() => { + const q = player.queue[player.queueIndex]; + const found = q?.art; + const rowId = q?.plItemId; + if (!found || rowId == null || !playlist) return; + const mine = items.find((i) => i.id === rowId); + if (!mine || mine.art_path === found) return; + const plId = playlist.id; + // Writing back into `items` is what this effect READS; untrack keeps + // the assignment from re-entering it, and the equality guard above + // makes a second pass a no-op either way. + untrack(() => { + mine.art_path = found; + artAttempted.add(rowId); + api.playlistRelinkItem(plId, rowId, { artPath: found }).catch(() => {}); + }); + }); + const norm = (s) => (s ?? "").toLowerCase(); const visible = $derived.by(() => { const q = norm(query.trim()); @@ -340,8 +390,23 @@ delete playlistStore.covers[playlist.id]; await refreshPlaylists(); } + // The two collection actions — stream rows with full identity are the + // material; the runner dedupes namesakes and narrates via playlistBulk. + const bulkableCount = $derived( + items.filter((i) => i.kind === "stream" && i.artist && i.title).length, + ); + function addCachedToLibrary() { + menuOpen = false; + void runPlaylistBulk(playlist.id, items, "cached"); + } + function downloadAllTracks() { + menuOpen = false; + void runPlaylistBulk(playlist.id, items, "download"); + } + async function exportAs(format) { menuOpen = false; + exportOpen = false; try { await api.playlistExport(playlist.id, format); } catch (e) { @@ -670,6 +735,7 @@ onclick={(e) => { e.stopPropagation(); confirmDelete = false; + exportOpen = false; menuOpen = !menuOpen; }} > @@ -698,24 +764,51 @@ -
{/if} - - - - -
+ +
(exportOpen = true)} + onmouseleave={() => (exportOpen = false)} + > + + {#if exportOpen} + + {/if} +
+
+ {#if playlistBulk.playlistId === playlist.id && (playlistBulk.active || playlistBulk.summary)} +
+ {#if playlistBulk.active} + + + {playlistBulk.mode === "cached" + ? "Adding streamed tracks to the library" + : "Downloading tracks"} + — {playlistBulk.done}/{playlistBulk.total} + {#if playlistBulk.organized} · {playlistBulk.organized} saved{/if} + {#if playlistBulk.queued} · {playlistBulk.queued} downloading{/if} + {#if playlistBulk.missed} · {playlistBulk.missed} missed{/if} + + + {:else} + + {playlistBulk.summary} + + {/if} +
+ {/if}
@@ -1236,6 +1354,77 @@ .mi.danger:hover { color: #f15e6c; } + .sub-wrap { + position: relative; + } + .sub-wrap > .mi { + width: 100%; + } + .sub-arrow { + margin-left: auto; + display: inline-flex; + color: var(--text-tertiary); + } + /* The flyout: the same glass card, berthed beside its row. The bridge + (::before) covers the 6px gap so the pointer can cross without the + mouseleave closing it. */ + .menu.sub { + position: absolute; + left: calc(100% + 4px); + top: -6px; + min-width: 130px; + } + .menu.sub::before { + content: ""; + position: absolute; + left: -8px; + top: 0; + bottom: 0; + width: 8px; + } + .mi:disabled { + opacity: 0.45; + pointer-events: none; + } + .bulkline { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 7px 12px; + border-radius: 999px; + width: fit-content; + max-width: 100%; + background: var(--bg-elevated); + border: 1px solid var(--border); + font-size: 12.5px; + font-weight: 600; + color: var(--text-secondary); + } + .bulkline .ring { + width: 12px; + height: 12px; + flex: none; + border-radius: 50%; + border: 2px solid var(--border); + border-top-color: var(--accent); + animation: bulkspin 0.9s linear infinite; + } + @keyframes bulkspin { + to { + transform: rotate(360deg); + } + } + .bulkline .bulkx { + display: inline-flex; + color: var(--text-tertiary); + padding: 2px; + border-radius: 50%; + } + .bulkline .bulkx:hover { + color: var(--text-primary); + background: var(--bg-highlight); + } .menu .rule { height: 1px; background: var(--border); diff --git a/src/views/Playlists.svelte b/src/views/Playlists.svelte index 93dd8d2..81605c8 100644 --- a/src/views/Playlists.svelte +++ b/src/views/Playlists.svelte @@ -19,6 +19,7 @@ let importMode = $state("file"); // "file" | "paste" let importDraft = $state(null); let menuFor = $state(null); // playlist id whose ⋯ menu is open + let exportFor = $state(null); // playlist id whose Export flyout is open let confirmDelete = $state(null); // playlist id armed for deletion let creating = $state(false); let newMenuOpen = $state(false); @@ -130,6 +131,13 @@ importOpen = true; } + function importFromYoutubePlaylist() { + newMenuOpen = false; + importDraft = null; + importMode = "ytplaylist"; + importOpen = true; + } + function open(pl) { goToView("playlist", { playlistId: pl.id, label: pl.name }); } @@ -211,6 +219,7 @@ async function exportAs(pl, format) { menuFor = null; + exportFor = null; try { await api.playlistExport(pl.id, format); } catch (e) { @@ -307,6 +316,11 @@ YouTube DJ set… tracks from chapters, description or comments + {/if} @@ -420,21 +434,31 @@ Duplicate
- - - - - + +
(exportFor = pl.id)} + onmouseleave={() => (exportFor = null)} + > + + {#if exportFor === pl.id} + + {/if} +