playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+129
View File
@@ -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<Self>,
artist_name: &str,
title: &str,
) -> Vec<CatalogTrack> {
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<CatalogArtist> = 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<CatalogTrack> = 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<String> = 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<CatalogTrack> {
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<String> {
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()
+201 -12
View File
@@ -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<u32>,
) -> Result<Vec<metadata::CatalogTrack>, 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<Vec<metadata::CatalogTrack>, 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<u64> {
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<usize, Strin
/// A source already within bounds is passed through rather than re-encoded, so
/// this never costs quality it was meant to protect.
#[tauri::command]
pub async fn album_art(path: String, max_edge: Option<u32>) -> Result<String, String> {
let bytes = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, 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<u32>,
) -> Result<String, String> {
// 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<u8>, Option<std::path::PathBuf>), 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<u32>) -> Result<String, St
))
}
/// At most this many album-art decodes run at once, machine-wide. Art is
/// cosmetic; audio and the UI are not — two permits keep a burst of tile
/// requests from ever owning more than two cores.
static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
/// The cover a streamed track carries INSIDE its own bytes, extracted to a
/// file the ordinary art path can read.
///
/// A Soulseek transfer is a tagged audio file, and in the reference library
/// 449 of 515 records keep their best cover embedded — so a stream that
/// arrived without any catalog artwork usually still has the real thing a
/// tag read away, and that cover needs no name matching to be certain it
/// belongs to this recording. Extracted once, keyed by the bytes' own hash
/// so two tracks off one record share the file.
#[tauri::command]
pub async fn stream_embedded_art(
app: tauri::AppHandle,
username: String,
path: String,
) -> Result<Option<String>, 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<Option<String>, String> {
// A transfer still in flight has no cache file yet (the bytes land in
// `<name>.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
+9
View File
@@ -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,
+1 -1
View File
@@ -233,7 +233,7 @@ fn parse_m3u(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
/// "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<String>, Option<String>) {
pub(crate) fn split_artist_title(display: &str) -> (Option<String>, Option<String>) {
let display = display.trim();
if display.is_empty() {
return (None, None);
+190
View File
@@ -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<ImportDraft, String> {
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<ImportDraft, String> {
// 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<String>,
) -> Result<ImportDraft, String> {
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(
File diff suppressed because it is too large. Load diff
+613
View File
@@ -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<u32>,
}
pub struct PlaylistFetch {
pub title: String,
/// The playlist's owner ("Tommy"), not any track's artist.
pub author: String,
pub rows: Vec<VideoRow>,
pub warnings: Vec<String>,
}
/// 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<String> {
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<VideoRow> {
let mut rows: Vec<VideoRow> = 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::<u32>().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<String> {
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<String> {
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<String> {
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<String> {
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<PlaylistFetch, String> {
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()));
}
}
+3 -3
View File
@@ -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<String> {
pub(crate) fn first_string(v: &Value, keys: &[&str]) -> Option<String> {
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();
}
+50 -9
View File
@@ -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 @@
<div class="pane" style="--dir: {paneDir}px">
{#if pane === "queue"}
{#if upNext}
<button class="upnext" onclick={() => jumpToQueueIndex(player.queueIndex + 1)}>
{#if artUrl}<img src={artUrl} alt="" draggable="false" />{/if}
<button class="upnext" onclick={() => jumpToQueueIndex(upNextIndex)}>
{#if upNextArt}
<img src={upNextArt} alt="" draggable="false" />
{:else}
<span class="un-fallback"><Icon name="music" size={15} /></span>
{/if}
<span class="un-body">
<span class="un-lab">Up next</span>
<span class="un-title" title={upNext.title}>{upNext.title}</span>
@@ -2242,6 +2273,16 @@
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
.upnext img { width: 34px; height: 34px; border-radius: 4px; flex: none; object-fit: cover; }
.un-fallback {
width: 34px;
height: 34px;
flex: none;
display: grid;
place-items: center;
border-radius: 6px;
background: var(--bg-highlight);
color: var(--text-tertiary);
}
.un-body { min-width: 0; flex: 1; display: block; }
.un-lab {
display: block;
+217 -46
View File
@@ -15,18 +15,31 @@
startYoutubeListen,
startFileListen,
} from "../lib/setlistScan.svelte.js";
import { findCatalogTrack } from "../lib/catalogMatch.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
// "file": a probed draft arrives via `draft`; "paste": textarea
// first; "youtube": a link field first — the set's own tracklist
// evidence (chapters/description/comments) becomes the draft.
// evidence (chapters/description/comments) becomes the draft;
// "ytplaylist": a link field for an ordinary playlist link —
// YouTube (one row per video), Spotify, Apple Music, TIDAL, or
// Deezer (one row per track); metadata only, no tools involved.
mode = "file",
draft = null,
onimported = () => {},
} = $props();
// The door actually being shown. `mode` is what the caller opened; a
// capped Spotify import can hand itself over to the paste door from
// inside the review, and that switch is this component's own business.
let doorOverride = $state(null);
const door = $derived(doorOverride ?? mode);
// Set when the paste door was reached FROM a capped Spotify import, so it
// can say what to paste instead of leaving the reader to remember.
let pasteFromSpotify = $state(false);
let pasteText = $state("");
let youtubeUrl = $state("");
let localDraft = $state(null);
@@ -52,6 +65,14 @@
return r.startsWith("deno") ? "deno" : r;
});
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
// Pasted TRACK links are looked up a page each, so the paste door reports
// its own progress instead of freezing on "Reading…".
let linkProgress = $state(null); // {done, total, waiting}
// Mirrors the backend's detector closely enough to CHOOSE a door; the
// backend decides for real. A paste holding even one track link takes the
// link path, so a list copied out of Spotify never parses as track names.
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
const pasteHasLinks = $derived(TRACK_LINK.test(pasteText));
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
@@ -68,12 +89,23 @@
fileTracklist = "";
includeUnresolved = true;
scanning = null;
linkProgress = null;
doorOverride = null;
pasteFromSpotify = false;
name = draft?.name ?? "";
// NOTE: `mode`, not `door`. This effect WRITES doorOverride, so
// reading the derived door here would make it re-trigger itself and
// wipe the hand-off the instant it was set.
if (mode === "youtube") {
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
}
if (draft?.entries?.length) startChecks(draft);
});
$effect(() => {
return api.onEvent("playlist:link-progress", (p) => {
if (parsing) linkProgress = p;
});
});
$effect(() => {
return api.onEvent("playlist:scan-progress", (p) => {
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
@@ -170,8 +202,13 @@
if (!pasteText.trim() || parsing) return;
parsing = true;
error = "";
linkProgress = null;
try {
const parsed = await api.playlistImportText(pasteText);
// Track links get looked up one public page each — the path with
// no length ceiling. Plain text stays on the old parser.
const parsed = pasteHasLinks
? await api.playlistImportTrackLinks(pasteText, name.trim() || null)
: await api.playlistImportText(pasteText);
if (!parsed.entries.length) {
error = "No tracks recognized in that text.";
} else {
@@ -183,6 +220,7 @@
error = String(e);
} finally {
parsing = false;
linkProgress = null;
}
}
async function previewYoutube() {
@@ -205,6 +243,42 @@
}
}
async function previewYoutubePlaylist() {
if (!youtubeUrl.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportLink(youtubeUrl.trim());
if (!parsed.entries.length) {
error = "The playlist yielded no importable tracks.";
} else {
localDraft = parsed;
name = parsed.name ?? name;
startChecks(parsed);
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
// A Spotify import that came back at exactly the public ceiling. The
// real total is not exposed to anyone signed out, so this cannot say how
// many are missing — only that there are likely more.
const spotifyCapped = $derived(
working?.format === "spotify" && working.entries.length >= 100,
);
/// Hand the reader from the capped review to the door that has no
/// ceiling, with the paste box empty and waiting.
function bringTheRest() {
localDraft = null;
pasteText = "";
error = "";
doorOverride = "paste";
pasteFromSpotify = true;
}
const kept = $derived.by(() => {
if (!working) return [];
// Streams are first-class (identified, playable) — the checkbox
@@ -217,43 +291,9 @@
// ---- catalog verification of stream rows, live in the review -------
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
let checkGen = 0;
const norm = (s) =>
(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
/// Strict match: at least one exact side (title or artist), both at
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
/// ties so a famous track beats a namesake.
function pickCatalogMatch(tracks, e) {
const wantT = norm(e.title);
const wantA = norm(e.artist);
let best = null;
let bestScore = 0;
for (const t of tracks ?? []) {
const tT = norm(t.title);
const tA = norm(t.artist);
let score = 0;
if (tT === wantT) score += 4;
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
if (tA === wantA) score += 4;
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
if (score < 6) continue;
if (
e.duration_secs &&
t.duration_secs &&
Math.abs(t.duration_secs - e.duration_secs) <= 7
)
score += 2;
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
return best;
}
// The matcher moved to lib/catalogMatch.js when the player started
// needing the same bar to put artwork on a stream that arrived without
// any: strict is only meaningful if there is ONE definition of it.
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
/// reads and writes `checks`, and doing that inside an $effect made
/// every row verdict re-trigger the effect, which bumped the
@@ -282,9 +322,20 @@
anchored = !!hit;
}
if (!hit) {
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
// The hardened lookup: Deezer's field syntax with the
// version wording set aside, then wider rungs. A plain
// "artist title" query returns NOTHING for a great many
// real records (measured: 41 of 258 rows on one import).
hit = await findCatalogTrack(
(query) => api.catalogSearchTracks(query),
{
title: e.title,
artist: e.artist,
duration_secs: e.duration_secs ?? null,
},
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
);
if (gen !== checkGen) return;
hit = pickCatalogMatch(res?.tracks, e);
}
if (hit) {
// Attach catalog identity to the row commit will send.
@@ -364,6 +415,12 @@
text: "Pasted text",
folder: "Folder",
youtube: "YouTube",
"youtube-playlist": "YouTube playlist",
spotify: "Spotify",
"apple-music": "Apple Music",
tidal: "TIDAL",
deezer: "Deezer",
links: "Pasted links",
listened: "Recognized by ear",
};
</script>
@@ -388,8 +445,10 @@
Review import
{:else if pickedFile}
Listen to a set recording
{:else if mode === "youtube"}
{:else if door === "youtube"}
Import a DJ set from YouTube
{:else if door === "ytplaylist"}
Import a playlist from a link
{:else}
Paste a tracklist
{/if}
@@ -440,7 +499,7 @@
Listen
</button>
</footer>
{:else if mode === "youtube"}
{:else if door === "youtube"}
{#if scanning}
<div class="scanbox">
<span class="ring"></span>
@@ -537,16 +596,72 @@
</button>
</footer>
{/if}
{:else if door === "ytplaylist"}
<p class="lead">
Paste a playlist link from <b>YouTube</b>, <b>Spotify</b>,
<b>Apple Music</b>, <b>TIDAL</b>, or <b>Deezer</b> — 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.
</p>
<input
class="url"
type="text"
bind:value={youtubeUrl}
placeholder="https://open.spotify.com/playlist/… · youtube.com/playlist?list=…"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && previewYoutubePlaylist()}
/>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewYoutubePlaylist}
disabled={!youtubeUrl.trim() || parsing}
>
{parsing ? "Fetching…" : "Fetch tracks"}
</button>
</footer>
{:else}
{#if pasteFromSpotify}
<ol class="steps">
<li>Open the playlist in Spotify.</li>
<li>Click any track, then <b>Ctrl+A</b> to select them all.</li>
<li><b>Ctrl+C</b>, then paste below — every track comes over,
however many there are.</li>
</ol>
{/if}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
<b>Track links work too</b>: 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.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n\n…or pasted track links:\nhttps://open.spotify.com/track/…"}
spellcheck="false"
></textarea>
{#if pasteHasLinks && !parsing}
<div class="hint">
Track links detected — each is read from its own public page.
</div>
{/if}
{#if linkProgress}
<div class="scanbox">
<span class="ring"></span>
<div class="scanline">
{#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}
</div>
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -555,7 +670,7 @@
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
{parsing ? (pasteHasLinks ? "Looking up…" : "Reading…") : "Preview"}
</button>
</footer>
{/if}
@@ -583,6 +698,19 @@
</div>
{/if}
{#if spotifyCapped}
<div class="capline">
<div class="capwords">
<b>This is Spotify's limit, not the playlist's.</b>
Pasted track links have none — and Spotify hands them all over
at once.
</div>
<button class="btn small" onclick={bringTheRest}>
Bring the whole playlist
</button>
</div>
{/if}
<div class="rows" tabindex="-1">
{#each working.entries as e (e.ordinal)}
<div
@@ -675,7 +803,7 @@
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste" || mode === "youtube"}
{#if door === "paste" || door === "youtube" || door === "ytplaylist"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -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;
}
+26
View File
@@ -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 });
+315
View File
@@ -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;
}
+459 -44
View File
@@ -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,
};
}
+213 -24
View File
@@ -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 @@
<button class="mi" onclick={removeUnresolved}>
<Icon name="x" size={15} /> Remove unresolved entries
</button>
<div class="rule"></div>
{/if}
<button class="mi" onclick={() => exportAs("m3u8")}>
<Icon name="export" size={15} /> Export M3U8
<div class="rule"></div>
<button
class="mi"
onclick={addCachedToLibrary}
disabled={playlistBulk.active || bulkableCount === 0}
title="File every already-streamed track into the library — nothing is downloaded"
>
<Icon name="library" size={15} /> Add to library
</button>
<button class="mi" onclick={() => exportAs("xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs("pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs("csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs("json")}>
<Icon name="export" size={15} /> Export backup (JSON)
<button
class="mi"
onclick={downloadAllTracks}
disabled={playlistBulk.active || bulkableCount === 0}
title="Find and download every track that isn't in the library yet"
>
<Icon name="download" size={15} /> Download all tracks
</button>
<div class="rule"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportOpen = true)}
onmouseleave={() => (exportOpen = false)}
>
<button
class="mi"
onclick={() => (exportOpen = !exportOpen)}
aria-haspopup="menu"
aria-expanded={exportOpen}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportOpen}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs("m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs("xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs("pls")}>PLS</button>
<button class="mi" onclick={() => exportAs("csv")}>CSV</button>
<button class="mi" onclick={() => exportAs("json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={del}>
<Icon name="trash" size={15} />
{confirmDelete ? "Really delete?" : "Delete playlist"}
@@ -737,6 +830,31 @@
{/if}
</div>
</div>
{#if playlistBulk.playlistId === playlist.id && (playlistBulk.active || playlistBulk.summary)}
<div class="bulkline">
{#if playlistBulk.active}
<span class="ring"></span>
<span>
{playlistBulk.mode === "cached"
? "Adding streamed tracks to the library"
: "Downloading tracks"}
— {playlistBulk.done}/{playlistBulk.total}
{#if playlistBulk.organized}&thinsp;·&thinsp;{playlistBulk.organized} saved{/if}
{#if playlistBulk.queued}&thinsp;·&thinsp;{playlistBulk.queued} downloading{/if}
{#if playlistBulk.missed}&thinsp;·&thinsp;{playlistBulk.missed} missed{/if}
</span>
<button class="bulkx" title="Stop" onclick={cancelPlaylistBulk}>
<Icon name="x" size={13} />
</button>
{:else}
<Icon name="check" size={13} />
<span>{playlistBulk.summary}</span>
<button class="bulkx" title="Dismiss" onclick={dismissPlaylistBulk}>
<Icon name="x" size={13} />
</button>
{/if}
</div>
{/if}
</div>
</div>
</header>
@@ -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);
+65 -15
View File
@@ -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…
<span class="hint">tracks from chapters, description or comments</span>
</button>
<button class="mi" onclick={importFromYoutubePlaylist}>
<Icon name="playlist" size={15} />
Playlist from a link…
<span class="hint">YouTube · Spotify · Apple Music · TIDAL · Deezer</span>
</button>
</div>
{/if}
</div>
@@ -420,21 +434,31 @@
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportFor = pl.id)}
onmouseleave={() => (exportFor = null)}
>
<button
class="mi"
onclick={() => (exportFor = exportFor === pl.id ? null : pl.id)}
aria-haspopup="menu"
aria-expanded={exportFor === pl.id}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportFor === pl.id}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>PLS</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>CSV</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
@@ -641,6 +665,32 @@
color: var(--text-muted);
padding-left: 14px;
}
.sub-wrap {
position: relative;
}
.sub-arrow {
margin-left: auto;
display: inline-flex;
color: var(--text-tertiary);
}
/* Same glass card, berthed beside its row; the ::before bridge covers
the gap so the pointer can cross without closing it. Card menus sit
at the grid's right edge as often as not, so the flyout opens LEFT. */
.menu.sub {
position: absolute;
right: calc(100% + 4px);
left: auto;
top: -6px;
min-width: 130px;
}
.menu.sub::after {
content: "";
position: absolute;
right: -8px;
top: 0;
bottom: 0;
width: 8px;
}
.menu .rule {
height: 1px;
background: var(--border);
+318
View File
@@ -0,0 +1,318 @@
// The catalog matcher, against the real import that exposed it: a 258-row
// playlist where 41 rows read "not in catalog" while plainly being in it.
// Every case below is a pairing taken from that run.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
splitVersion,
qualifierClass,
primaryArtist,
bandCore,
pickCatalogMatch,
catalogQueries,
findCatalogTrack,
pickCrossScript,
} from "../../src/lib/catalogMatch.js";
const track = (artist, title, duration_secs, rank = 500_000) => ({
id: 1,
artist,
title,
duration_secs,
rank,
});
test("version wording is split off, not thrown away", () => {
assert.deepEqual(splitVersion("Bohemian Rhapsody - Remastered 2011"), {
base: "Bohemian Rhapsody",
qualifier: "Remastered 2011",
});
assert.deepEqual(splitVersion("Lola (2020 Stereo Remaster)"), {
base: "Lola",
qualifier: "2020 Stereo Remaster",
});
assert.deepEqual(splitVersion("グッドバイ -album version-"), {
base: "グッドバイ",
qualifier: "album version",
});
assert.deepEqual(splitVersion("Here's to you (feat. Joan Baez)"), {
base: "Here's to you",
qualifier: "feat. Joan Baez",
});
// A hyphen inside a word is part of the name, never a qualifier.
assert.deepEqual(splitVersion("A-Punk"), { base: "A-Punk", qualifier: "" });
// A dash-separated title that names no version keeps its shape.
assert.deepEqual(splitVersion("Sunday Bloody Sunday - Live"), {
base: "Sunday Bloody Sunday",
qualifier: "Live",
});
assert.deepEqual(splitVersion("Detroit Rock City"), {
base: "Detroit Rock City",
qualifier: "",
});
});
test("qualifiers are classed by whether they change the recording", () => {
assert.equal(qualifierClass(""), "none");
assert.equal(qualifierClass("2011 Remaster"), "edition");
assert.equal(qualifierClass("Remastered 2011"), "edition");
assert.equal(qualifierClass("2020 Stereo Remaster"), "edition");
assert.equal(qualifierClass("feat. Joan Baez"), "edition");
assert.equal(qualifierClass("Samurai Breaks Remix"), "identity");
assert.equal(qualifierClass("Acoustic"), "identity");
assert.equal(qualifierClass("Live"), "identity");
assert.equal(qualifierClass("Karaoke Version"), "identity");
});
test("the same master worded differently now matches", () => {
// The headline failure: Spotify's wording vs Deezer's.
const hit = pickCatalogMatch(
[track("Queen", "Bohemian Rhapsody (2011 Remaster)", 358)],
{ artist: "Queen", title: "Bohemian Rhapsody - Remastered 2011", duration_secs: 354 },
);
assert.equal(hit?.title, "Bohemian Rhapsody (2011 Remaster)");
assert.ok(
pickCatalogMatch([track("The Kinks", "Lola (Stereo Remastered 2020)", 241)], {
artist: "The Kinks",
title: "Lola (2020 Stereo Remaster)",
duration_secs: 241,
}),
);
// A plain catalog title answering a remastered request, and vice versa.
assert.ok(
pickCatalogMatch([track("David Bowie", "Suffragette City", 206)], {
artist: "David Bowie",
title: "Suffragette City - 2012 Remastered Version",
duration_secs: 206,
}),
);
});
test("artist wording: ampersands and comma-credited guests", () => {
assert.ok(
pickCatalogMatch([track("Joan Jett and the Blackhearts", "Bad Reputation", 170)], {
artist: "Joan Jett & the Blackhearts",
title: "Bad Reputation",
duration_secs: 170,
}),
);
assert.ok(
pickCatalogMatch([track("Ennio Morricone", "Here's to You", 190)], {
artist: "Ennio Morricone, Joan Baez",
title: "Here's to you (feat. Joan Baez)",
duration_secs: 188,
}),
);
assert.ok(
pickCatalogMatch([track("King Krule", "Seagirl", 202)], {
artist: "King Krule, Raveena",
title: "Seagirl",
duration_secs: 202,
}),
);
assert.equal(primaryArtist("Ennio Morricone, Joan Baez"), "Ennio Morricone");
// "and" is part of the act's name, never a credit separator.
assert.equal(primaryArtist("Joan Jett and the Blackhearts"), "Joan Jett and the Blackhearts");
// Deezer files the STUDIO cut under the shorter credit and gives the
// live takes the full band name; both must reach the same record.
assert.ok(
pickCatalogMatch([track("Joan Jett", "Bad Reputation", 169)], {
artist: "Joan Jett & the Blackhearts",
title: "Bad Reputation",
duration_secs: 170,
}),
);
assert.equal(bandCore("Joan Jett and the Blackhearts"), "joan jett");
// A bare word is never a backing band.
assert.equal(bandCore("Queen Latifah"), "queen latifah");
assert.equal(
pickCatalogMatch([track("Queen Latifah", "U.N.I.T.Y.", 200)], {
artist: "Queen",
title: "U.N.I.T.Y.",
duration_secs: 200,
}),
null,
);
});
test("a different PERFORMANCE still never answers for the original", () => {
// The DJ-set lesson: a remix must not match the track it samples.
assert.equal(
pickCatalogMatch([track("Rebound X", "Rhythm 'N' Gash", 240)], {
artist: "Rebound X",
title: "Rhythm 'N' Gash (Samurai Breaks Remix)",
duration_secs: 240,
}),
null,
);
// Nor the reverse: asking for the original must not get the remix.
assert.equal(
pickCatalogMatch([track("Rebound X", "Rhythm 'N' Gash (Samurai Breaks Remix)", 240)], {
artist: "Rebound X",
title: "Rhythm 'N' Gash",
duration_secs: 240,
}),
null,
);
// The acoustic/live re-recordings Deezer offered for Bad Reputation.
assert.equal(
pickCatalogMatch(
[
track("Joan Jett and the Blackhearts", "Bad Reputation (Acoustic)", 160),
track("Joan Jett and the Blackhearts", "Bad Reputation (Live)", 131),
],
{ artist: "Joan Jett & the Blackhearts", title: "Bad Reputation", duration_secs: 170 },
),
null,
);
});
test("covers, karaoke and namesakes are still refused", () => {
// What Deezer's plain search actually returned for A-Punk.
assert.equal(
pickCatalogMatch(
[
track("Midium", "A-Punk (Vampire Weekend cover)", 139),
track("Zoom Karaoke", "A-Punk (Originally By Vampire Weekend) [Karaoke Version]", 133),
],
{ artist: "Vampire Weekend", title: "A-Punk", duration_secs: 137 },
),
null,
);
// A short name inside a longer one is a coincidence, not a match.
assert.equal(
pickCatalogMatch([track("Fleetwood Mac", "Go Your Own Way", 223)], {
artist: "Fleetwood Mac",
title: "Go",
duration_secs: 223,
}),
null,
);
});
test("a badly disagreeing duration disqualifies a different cut", () => {
// The single edit Deezer offered against the album track.
assert.equal(
pickCatalogMatch([track("Eagles", "Lyin' Eyes (Single Version; 2018 Remaster)", 255)], {
artist: "Eagles",
title: "Lyin' Eyes - 2013 Remaster",
duration_secs: 381,
}),
null,
);
// With no duration known, the same pairing is allowed — nothing
// contradicts it, and a name match is all there is.
assert.ok(
pickCatalogMatch([track("Eagles", "Lyin' Eyes (Single Version; 2018 Remaster)", 255)], {
artist: "Eagles",
title: "Lyin' Eyes - 2013 Remaster",
duration_secs: null,
}),
);
});
test("the closest cut wins when several versions qualify", () => {
const hit = pickCatalogMatch(
[
track("Queen", "Bohemian Rhapsody (2011 Remaster)", 358, 900_000),
track("Queen", "Bohemian Rhapsody", 367, 950_000),
],
{ artist: "Queen", title: "Bohemian Rhapsody - Remastered 2011", duration_secs: 354 },
);
assert.equal(hit?.title, "Bohemian Rhapsody (2011 Remaster)");
});
test("the query ladder asks precisely before asking bluntly", () => {
assert.deepEqual(
catalogQueries({ artist: "Eagles", title: "Take It Easy - 2013 Remaster" }),
[
'artist:"Eagles" track:"Take It Easy"',
'artist:"Eagles" track:"Take It Easy - 2013 Remaster"',
"Eagles Take It Easy",
],
);
// Comma-credited guests are dropped from the query's artist.
assert.equal(
catalogQueries({ artist: "King Krule, Raveena", title: "Seagirl" })[0],
'artist:"King Krule" track:"Seagirl"',
);
});
test("the ladder stops at the first rung that answers", async () => {
const asked = [];
const hit = await findCatalogTrack(
async (query) => {
asked.push(query);
// Mirrors the measured reality: the precise query finds it, the
// plain one would have returned nothing.
return query.startsWith("artist:") ? [track("Vashti Bunyan", "Diamond Day", 107)] : [];
},
{ artist: "Vashti Bunyan", title: "Diamond Day", duration_secs: 107 },
);
assert.equal(hit?.title, "Diamond Day");
assert.equal(asked.length, 1);
});
test("a romanized catalog entry matches by duration, strictly", () => {
const want = { artist: "toe", title: "孤独の発明", duration_secs: 203 };
// The real pairing: Deezer files the track romanized.
assert.ok(pickCrossScript([track("toe", "Kodokunohatsumei", 203)], want));
// Two near-length candidates → ambiguous → null.
assert.equal(
pickCrossScript(
[track("toe", "Kodokunohatsumei", 203), track("toe", "Two Moons", 204)],
want,
),
null,
);
// No duration on the row → no anchor → null.
assert.equal(
pickCrossScript([track("toe", "Kodokunohatsumei", 203)], { ...want, duration_secs: null }),
null,
);
// A Latin-titled row never takes this path.
assert.equal(
pickCrossScript([track("toe", "Loneliness Will Shine", 356)], {
artist: "toe",
title: "Loneliness Will Shine",
duration_secs: 356,
}),
null,
);
});
test("the artist shelf is the last rung and answers what search cannot", async () => {
const asked = { search: 0, shelf: 0 };
const hit = await findCatalogTrack(
async () => {
asked.search += 1;
return []; // the search index has never heard of it
},
{ artist: "Mock Orange", title: "End of the World", duration_secs: null },
async () => {
asked.shelf += 1;
// What the supplemented artist page carries.
return [track("Mock Orange", "End of the World", 262)];
},
);
assert.equal(hit?.title, "End of the World");
assert.ok(asked.search >= 2);
assert.equal(asked.shelf, 1);
});
test("nothing matching still returns null after every rung", async () => {
const asked = [];
const hit = await findCatalogTrack(
async (query) => {
asked.push(query);
return [track("Someone Else", "A Different Song", 200)];
},
{ artist: "Mock Orange", title: "End of the World", duration_secs: 210 },
);
assert.equal(hit, null);
// Two rungs, not three: this title carries no version wording, so the
// base-name query and the full-title query are the same string.
assert.equal(asked.length, 2);
});