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:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
@@ -4450,6 +4450,126 @@ impl MetadataClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Catalog search: artists, albums and tracks for the query, in parallel.
|
/// 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 {
|
pub async fn catalog_search(&self, query: &str) -> CatalogSearch {
|
||||||
let q = urlencode(query);
|
let q = urlencode(query);
|
||||||
let artist_url = format!("https://api.deezer.com/search/artist?q={q}&limit=8");
|
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.
|
/// 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 {
|
fn normalize(s: &str) -> String {
|
||||||
s.to_lowercase()
|
s.to_lowercase()
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
+201
-12
@@ -1557,6 +1557,32 @@ pub async fn catalog_search(
|
|||||||
Ok(state.metadata.catalog_search(&query).await)
|
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]
|
#[tauri::command]
|
||||||
pub async fn catalog_track_by_isrc(
|
pub async fn catalog_track_by_isrc(
|
||||||
state: State<'_, LibraryState>,
|
state: State<'_, LibraryState>,
|
||||||
@@ -3592,7 +3618,16 @@ pub async fn save_to_library(
|
|||||||
let mut organized = 0u32;
|
let mut organized = 0u32;
|
||||||
let mut queued = 0u32;
|
let mut queued = 0u32;
|
||||||
for f in files {
|
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 let Some(cache) = cached {
|
||||||
if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? {
|
if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? {
|
||||||
organized += 1;
|
organized += 1;
|
||||||
@@ -3639,6 +3674,17 @@ pub async fn save_to_library(
|
|||||||
Ok((organized, queued))
|
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
|
/// 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.
|
/// library organizer. Returns false only when the copy itself did not happen.
|
||||||
async fn organize_cached_save(
|
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
|
/// A source already within bounds is passed through rather than re-encoded, so
|
||||||
/// this never costs quality it was meant to protect.
|
/// this never costs quality it was meant to protect.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn album_art(path: String, max_edge: Option<u32>) -> Result<String, String> {
|
pub async fn album_art(
|
||||||
let bytes = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
app: tauri::AppHandle,
|
||||||
let raw = std::fs::read(&path).map_err(|e| e.to_string())?;
|
path: String,
|
||||||
let Some(edge) = max_edge else { return Ok(raw) };
|
max_edge: Option<u32>,
|
||||||
// An undecodable or already-small source falls through to the original
|
) -> Result<String, String> {
|
||||||
// bytes: a thumbnail is an optimisation, never a reason to show nothing.
|
// Thumbnails used to cost a FULL master decode + Lanczos resize per
|
||||||
match pipeline::thumbnail_jpeg(&raw, edge) {
|
// request, on the unbounded blocking pool, with nothing remembered —
|
||||||
Ok(Some(thumb)) => Ok(thumb),
|
// so a Library page scrolling past a few hundred tiles pinned every
|
||||||
_ => Ok(raw),
|
// 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
|
.await
|
||||||
.map_err(|e| e.to_string())??;
|
.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 _;
|
use base64::Engine as _;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"data:image/jpeg;base64,{}",
|
"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
|
/// Open an http(s) URL in the system's default browser. The WebView blocks
|
||||||
/// `target="_blank"` navigation (no new-window handler is installed), so
|
/// `target="_blank"` navigation (no new-window handler is installed), so
|
||||||
/// external links such as artist socials route through
|
/// external links such as artist socials route through
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod pipeline;
|
|||||||
mod playback;
|
mod playback;
|
||||||
mod playlist_formats;
|
mod playlist_formats;
|
||||||
mod playlists;
|
mod playlists;
|
||||||
|
mod provider_playlist;
|
||||||
mod preview;
|
mod preview;
|
||||||
mod setlist_scan;
|
mod setlist_scan;
|
||||||
mod shazam;
|
mod shazam;
|
||||||
@@ -20,6 +21,7 @@ mod smtc;
|
|||||||
mod state;
|
mod state;
|
||||||
mod thumbbar;
|
mod thumbbar;
|
||||||
mod vocal_sep;
|
mod vocal_sep;
|
||||||
|
mod youtube_playlist;
|
||||||
mod youtube_setlist;
|
mod youtube_setlist;
|
||||||
mod ytdlp;
|
mod ytdlp;
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ pub fn run() {
|
|||||||
commands::download_file,
|
commands::download_file,
|
||||||
commands::copy_stream_to_folder,
|
commands::copy_stream_to_folder,
|
||||||
commands::save_to_library,
|
commands::save_to_library,
|
||||||
|
commands::stream_cache_len,
|
||||||
commands::cancel_download,
|
commands::cancel_download,
|
||||||
commands::show_in_folder,
|
commands::show_in_folder,
|
||||||
commands::refresh_artist_art,
|
commands::refresh_artist_art,
|
||||||
@@ -85,6 +88,7 @@ pub fn run() {
|
|||||||
commands::library_album_stats,
|
commands::library_album_stats,
|
||||||
commands::album_tracks,
|
commands::album_tracks,
|
||||||
commands::album_art,
|
commands::album_art,
|
||||||
|
commands::stream_embedded_art,
|
||||||
commands::refresh_album_art,
|
commands::refresh_album_art,
|
||||||
commands::refresh_all_album_art,
|
commands::refresh_all_album_art,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
@@ -122,6 +126,9 @@ pub fn run() {
|
|||||||
playlists::playlist_import_folder,
|
playlists::playlist_import_folder,
|
||||||
playlists::playlist_import_text,
|
playlists::playlist_import_text,
|
||||||
playlists::playlist_import_youtube,
|
playlists::playlist_import_youtube,
|
||||||
|
playlists::playlist_import_youtube_playlist,
|
||||||
|
playlists::playlist_import_link,
|
||||||
|
playlists::playlist_import_track_links,
|
||||||
playlists::playlist_import_commit,
|
playlists::playlist_import_commit,
|
||||||
setlist_scan::playlist_scan_youtube,
|
setlist_scan::playlist_scan_youtube,
|
||||||
setlist_scan::playlist_pick_set_file,
|
setlist_scan::playlist_pick_set_file,
|
||||||
@@ -143,6 +150,8 @@ pub fn run() {
|
|||||||
lyrics::lyrics_save_text,
|
lyrics::lyrics_save_text,
|
||||||
lyrics::lyrics_clear,
|
lyrics::lyrics_clear,
|
||||||
commands::catalog_search,
|
commands::catalog_search,
|
||||||
|
commands::catalog_search_tracks,
|
||||||
|
commands::catalog_artist_track_candidates,
|
||||||
commands::catalog_track_by_isrc,
|
commands::catalog_track_by_isrc,
|
||||||
import::import_scan,
|
import::import_scan,
|
||||||
import::import_portable_root,
|
import::import_portable_root,
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ fn parse_m3u(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
|
|||||||
|
|
||||||
/// "Artist - Title" (or the em/en-dash forms) split. Returns (artist, title);
|
/// "Artist - Title" (or the em/en-dash forms) split. Returns (artist, title);
|
||||||
/// no separator means the whole display string is the 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();
|
let display = display.trim();
|
||||||
if display.is_empty() {
|
if display.is_empty() {
|
||||||
return (None, None);
|
return (None, None);
|
||||||
|
|||||||
@@ -946,6 +946,196 @@ pub async fn playlist_import_youtube(
|
|||||||
Ok(draft)
|
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.
|
/// Commit a reviewed draft as a new playlist, atomically.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn playlist_import_commit(
|
pub async fn playlist_import_commit(
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
/// applying `f` to the value. Youtube's trees are deep, volatile, and
|
||||||
/// renderer-name-happy; searching for leaf keys survives their reshuffles
|
/// renderer-name-happy; searching for leaf keys survives their reshuffles
|
||||||
/// better than hardcoded paths.
|
/// 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 {
|
match root {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
if let Some(v) = map.get(key) {
|
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
|
/// First string under any of `keys` in reading order — for label-ish leaves
|
||||||
/// ("simpleText", nested runs) where youtube varies the wrapper.
|
/// ("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 {
|
match v {
|
||||||
Value::String(s) => Some(s.clone()),
|
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
|
/// Flatten a `runs`/`simpleText` rich-text node into plain text, preserving
|
||||||
/// the run boundaries' own newlines (timestamps arrive as separate runs).
|
/// 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) {
|
if let Some(s) = v.get("simpleText").and_then(Value::as_str) {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
getArt,
|
getArt,
|
||||||
getCatalogArt,
|
getCatalogArt,
|
||||||
jumpToQueueIndex,
|
jumpToQueueIndex,
|
||||||
|
peekNextIndex,
|
||||||
streamSong,
|
streamSong,
|
||||||
discovery,
|
discovery,
|
||||||
loadDiscovery,
|
loadDiscovery,
|
||||||
@@ -101,11 +102,6 @@
|
|||||||
// nothing, then a partial list.
|
// nothing, then a partial list.
|
||||||
const albumMode = $derived(!!player.catalogRef.albumId);
|
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.
|
// Artist / album split for the separate, clickable identity lines.
|
||||||
// Per field, like the player bar's: the item's own artist/album win when
|
// 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`),
|
// present (the continuation fill writes them beside a bare-artist `sub`),
|
||||||
@@ -121,6 +117,16 @@
|
|||||||
currentItem?.album ?? split[1] ?? "",
|
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"]);
|
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
|
||||||
|
|
||||||
@@ -170,7 +176,9 @@
|
|||||||
// tier, drop the album that's playing now, cap at 6.
|
// tier, drop the album that's playing now, cap at 6.
|
||||||
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
|
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
|
||||||
const moreAlbums = $derived.by(() => {
|
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]
|
return [...discovery.albums]
|
||||||
.filter((a) => norm(a.title) !== here)
|
.filter((a) => norm(a.title) !== here)
|
||||||
.sort(
|
.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());
|
const fmtListeners = (n) => (n == null ? null : n.toLocaleString());
|
||||||
|
|
||||||
@@ -1249,8 +1276,12 @@
|
|||||||
<div class="pane" style="--dir: {paneDir}px">
|
<div class="pane" style="--dir: {paneDir}px">
|
||||||
{#if pane === "queue"}
|
{#if pane === "queue"}
|
||||||
{#if upNext}
|
{#if upNext}
|
||||||
<button class="upnext" onclick={() => jumpToQueueIndex(player.queueIndex + 1)}>
|
<button class="upnext" onclick={() => jumpToQueueIndex(upNextIndex)}>
|
||||||
{#if artUrl}<img src={artUrl} alt="" draggable="false" />{/if}
|
{#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-body">
|
||||||
<span class="un-lab">Up next</span>
|
<span class="un-lab">Up next</span>
|
||||||
<span class="un-title" title={upNext.title}>{upNext.title}</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));
|
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; }
|
.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-body { min-width: 0; flex: 1; display: block; }
|
||||||
.un-lab {
|
.un-lab {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -15,18 +15,31 @@
|
|||||||
startYoutubeListen,
|
startYoutubeListen,
|
||||||
startFileListen,
|
startFileListen,
|
||||||
} from "../lib/setlistScan.svelte.js";
|
} from "../lib/setlistScan.svelte.js";
|
||||||
|
import { findCatalogTrack } from "../lib/catalogMatch.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
// "file": a probed draft arrives via `draft`; "paste": textarea
|
// "file": a probed draft arrives via `draft`; "paste": textarea
|
||||||
// first; "youtube": a link field first — the set's own tracklist
|
// 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",
|
mode = "file",
|
||||||
draft = null,
|
draft = null,
|
||||||
onimported = () => {},
|
onimported = () => {},
|
||||||
} = $props();
|
} = $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 pasteText = $state("");
|
||||||
let youtubeUrl = $state("");
|
let youtubeUrl = $state("");
|
||||||
let localDraft = $state(null);
|
let localDraft = $state(null);
|
||||||
@@ -52,6 +65,14 @@
|
|||||||
return r.startsWith("deno") ? "deno" : r;
|
return r.startsWith("deno") ? "deno" : r;
|
||||||
});
|
});
|
||||||
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
|
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.
|
// The working draft: probed (file mode) or built from the paste box.
|
||||||
const working = $derived(localDraft ?? draft);
|
const working = $derived(localDraft ?? draft);
|
||||||
@@ -68,12 +89,23 @@
|
|||||||
fileTracklist = "";
|
fileTracklist = "";
|
||||||
includeUnresolved = true;
|
includeUnresolved = true;
|
||||||
scanning = null;
|
scanning = null;
|
||||||
|
linkProgress = null;
|
||||||
|
doorOverride = null;
|
||||||
|
pasteFromSpotify = false;
|
||||||
name = draft?.name ?? "";
|
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") {
|
if (mode === "youtube") {
|
||||||
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
|
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
|
||||||
}
|
}
|
||||||
if (draft?.entries?.length) startChecks(draft);
|
if (draft?.entries?.length) startChecks(draft);
|
||||||
});
|
});
|
||||||
|
$effect(() => {
|
||||||
|
return api.onEvent("playlist:link-progress", (p) => {
|
||||||
|
if (parsing) linkProgress = p;
|
||||||
|
});
|
||||||
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return api.onEvent("playlist:scan-progress", (p) => {
|
return api.onEvent("playlist:scan-progress", (p) => {
|
||||||
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
|
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
|
||||||
@@ -170,8 +202,13 @@
|
|||||||
if (!pasteText.trim() || parsing) return;
|
if (!pasteText.trim() || parsing) return;
|
||||||
parsing = true;
|
parsing = true;
|
||||||
error = "";
|
error = "";
|
||||||
|
linkProgress = null;
|
||||||
try {
|
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) {
|
if (!parsed.entries.length) {
|
||||||
error = "No tracks recognized in that text.";
|
error = "No tracks recognized in that text.";
|
||||||
} else {
|
} else {
|
||||||
@@ -183,6 +220,7 @@
|
|||||||
error = String(e);
|
error = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
parsing = false;
|
parsing = false;
|
||||||
|
linkProgress = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function previewYoutube() {
|
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(() => {
|
const kept = $derived.by(() => {
|
||||||
if (!working) return [];
|
if (!working) return [];
|
||||||
// Streams are first-class (identified, playable) — the checkbox
|
// Streams are first-class (identified, playable) — the checkbox
|
||||||
@@ -217,43 +291,9 @@
|
|||||||
// ---- catalog verification of stream rows, live in the review -------
|
// ---- catalog verification of stream rows, live in the review -------
|
||||||
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
|
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
|
||||||
let checkGen = 0;
|
let checkGen = 0;
|
||||||
const norm = (s) =>
|
// The matcher moved to lib/catalogMatch.js when the player started
|
||||||
(s ?? "")
|
// needing the same bar to put artwork on a stream that arrived without
|
||||||
.toLowerCase()
|
// any: strict is only meaningful if there is ONE definition of it.
|
||||||
.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;
|
|
||||||
}
|
|
||||||
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
|
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
|
||||||
/// reads and writes `checks`, and doing that inside an $effect made
|
/// reads and writes `checks`, and doing that inside an $effect made
|
||||||
/// every row verdict re-trigger the effect, which bumped the
|
/// every row verdict re-trigger the effect, which bumped the
|
||||||
@@ -282,9 +322,20 @@
|
|||||||
anchored = !!hit;
|
anchored = !!hit;
|
||||||
}
|
}
|
||||||
if (!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;
|
if (gen !== checkGen) return;
|
||||||
hit = pickCatalogMatch(res?.tracks, e);
|
|
||||||
}
|
}
|
||||||
if (hit) {
|
if (hit) {
|
||||||
// Attach catalog identity to the row commit will send.
|
// Attach catalog identity to the row commit will send.
|
||||||
@@ -364,6 +415,12 @@
|
|||||||
text: "Pasted text",
|
text: "Pasted text",
|
||||||
folder: "Folder",
|
folder: "Folder",
|
||||||
youtube: "YouTube",
|
youtube: "YouTube",
|
||||||
|
"youtube-playlist": "YouTube playlist",
|
||||||
|
spotify: "Spotify",
|
||||||
|
"apple-music": "Apple Music",
|
||||||
|
tidal: "TIDAL",
|
||||||
|
deezer: "Deezer",
|
||||||
|
links: "Pasted links",
|
||||||
listened: "Recognized by ear",
|
listened: "Recognized by ear",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -388,8 +445,10 @@
|
|||||||
Review import
|
Review import
|
||||||
{:else if pickedFile}
|
{:else if pickedFile}
|
||||||
Listen to a set recording
|
Listen to a set recording
|
||||||
{:else if mode === "youtube"}
|
{:else if door === "youtube"}
|
||||||
Import a DJ set from YouTube
|
Import a DJ set from YouTube
|
||||||
|
{:else if door === "ytplaylist"}
|
||||||
|
Import a playlist from a link
|
||||||
{:else}
|
{:else}
|
||||||
Paste a tracklist
|
Paste a tracklist
|
||||||
{/if}
|
{/if}
|
||||||
@@ -440,7 +499,7 @@
|
|||||||
Listen
|
Listen
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
{:else if mode === "youtube"}
|
{:else if door === "youtube"}
|
||||||
{#if scanning}
|
{#if scanning}
|
||||||
<div class="scanbox">
|
<div class="scanbox">
|
||||||
<span class="ring"></span>
|
<span class="ring"></span>
|
||||||
@@ -537,16 +596,72 @@
|
|||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/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}
|
{: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">
|
<p class="lead">
|
||||||
One track per line. Timestamps make it a setlist —
|
One track per line. Timestamps make it a setlist —
|
||||||
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
|
<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>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={pasteText}
|
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"
|
spellcheck="false"
|
||||||
></textarea>
|
></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}
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
<footer>
|
<footer>
|
||||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||||
@@ -555,7 +670,7 @@
|
|||||||
onclick={previewPaste}
|
onclick={previewPaste}
|
||||||
disabled={!pasteText.trim() || parsing}
|
disabled={!pasteText.trim() || parsing}
|
||||||
>
|
>
|
||||||
{parsing ? "Reading…" : "Preview"}
|
{parsing ? (pasteHasLinks ? "Looking up…" : "Reading…") : "Preview"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -583,6 +698,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="rows" tabindex="-1">
|
||||||
{#each working.entries as e (e.ordinal)}
|
{#each working.entries as e (e.ordinal)}
|
||||||
<div
|
<div
|
||||||
@@ -675,7 +803,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if error}<p class="error">{error}</p>{/if}
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
<footer>
|
<footer>
|
||||||
{#if mode === "paste" || mode === "youtube"}
|
{#if door === "paste" || door === "youtube" || door === "ytplaylist"}
|
||||||
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
|
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn ghost" onclick={close}>Cancel</button>
|
<button class="btn ghost" onclick={close}>Cancel</button>
|
||||||
@@ -921,6 +1049,49 @@
|
|||||||
color: #e8a33d;
|
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 {
|
.warnings {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export const downloadFile = (username, path, size) =>
|
|||||||
export const copyStreamToFolder = (files, folder) =>
|
export const copyStreamToFolder = (files, folder) =>
|
||||||
invoke("copy_stream_to_folder", { files, folder });
|
invoke("copy_stream_to_folder", { files, folder });
|
||||||
export const saveToLibrary = (files) => invoke("save_to_library", { files });
|
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 cancelDownload = (id) => invoke("cancel_download", { id });
|
||||||
export const showInFolder = (path) => invoke("show_in_folder", { path });
|
export const showInFolder = (path) => invoke("show_in_folder", { path });
|
||||||
export const refreshArtistArt = () => invoke("refresh_artist_art");
|
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
|
// pixels on the long side. Omit it only where true pixels matter (the
|
||||||
// fullscreen viewer, the import picker's comparison and loupe).
|
// fullscreen viewer, the import picker's comparison and loupe).
|
||||||
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
|
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 refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
|
||||||
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
|
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
|
||||||
export const getSettings = () => invoke("get_settings");
|
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 playlistImportFolder = () => invoke("playlist_import_folder");
|
||||||
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
|
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
|
||||||
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
|
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
|
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
|
||||||
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
|
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
|
||||||
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
|
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) =>
|
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
|
||||||
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
|
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
|
||||||
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
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 catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
|
||||||
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||||
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
// Shared reactive app state (Svelte 5 runes module).
|
// Shared reactive app state (Svelte 5 runes module).
|
||||||
import * as api from "./api.js";
|
import * as api from "./api.js";
|
||||||
|
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
|
||||||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||||||
import {
|
import {
|
||||||
nav,
|
nav,
|
||||||
@@ -522,12 +523,39 @@ export const ART_EDGE_DEFAULT = 512;
|
|||||||
* tile's own `object-fit` still decides the crop. Sources already within
|
* 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
|
* `edge` come back untouched rather than re-encoded. Pass `null` to opt out
|
||||||
* and get the master. */
|
* 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) {
|
export async function getArt(path, edge = ART_EDGE_DEFAULT) {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
if (edge == null) return getArtMaster(path);
|
if (edge == null) return getArtMaster(path);
|
||||||
const key = `${edge}|${path}`;
|
const key = `${edge}|${path}`;
|
||||||
if (artThumbCache.has(key)) return touchCache(artThumbCache, key);
|
if (artThumbCache.has(key)) return touchCache(artThumbCache, key);
|
||||||
const pending = api.albumArt(path, edge).then(
|
const pending = gatedArtFetch(() => api.albumArt(path, edge)).then(
|
||||||
(url) => {
|
(url) => {
|
||||||
if (artThumbCache.get(key) === pending) {
|
if (artThumbCache.get(key) === pending) {
|
||||||
setResolvedCache(artThumbCache, key, url, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
|
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. */
|
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
|
||||||
function currentArtistAlbum() {
|
function currentArtistAlbum() {
|
||||||
const item = player.queue[player.queueIndex] ?? null;
|
const item = player.queue[player.queueIndex] ?? null;
|
||||||
@@ -3882,6 +4033,95 @@ async function cachedStreamSourceFor(trackId, artist, title) {
|
|||||||
}
|
}
|
||||||
void refreshSourcePrefs();
|
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.
|
/** 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
|
* 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
|
* bitrate at or above it — a peer that hides its bitrate does not get the
|
||||||
@@ -5228,6 +5468,18 @@ export const player = $state({
|
|||||||
queue: [],
|
queue: [],
|
||||||
queueIndex: -1,
|
queueIndex: -1,
|
||||||
shuffle: false,
|
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
|
repeat: "off", // off | all | one
|
||||||
// waveform peaks (0..1) for the current local track, [] while loading
|
// waveform peaks (0..1) for the current local track, [] while loading
|
||||||
waveform: [],
|
waveform: [],
|
||||||
@@ -5650,7 +5902,12 @@ export function initEvents() {
|
|||||||
if (s.done) {
|
if (s.done) {
|
||||||
// Fully cached → final waveform, and the peer's slot is
|
// Fully cached → final waveform, and the peer's slot is
|
||||||
// definitely free, so make sure the next track is pre-buffering.
|
// 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();
|
prepareFollowing();
|
||||||
} else if (
|
} else if (
|
||||||
ui.windowActive
|
ui.windowActive
|
||||||
@@ -5920,6 +6177,7 @@ function playQueueIndex(i) {
|
|||||||
// correct: a restore must never resurrect a track the queue moved past).
|
// correct: a restore must never resurrect a track the queue moved past).
|
||||||
bumpMainRevision("queueJumpOrNextPrev");
|
bumpMainRevision("queueJumpOrNextPrev");
|
||||||
player.queueIndex = i;
|
player.queueIndex = i;
|
||||||
|
ensureShuffleOrder();
|
||||||
player.track = {
|
player.track = {
|
||||||
title: item.title,
|
title: item.title,
|
||||||
sub: item.sub,
|
sub: item.sub,
|
||||||
@@ -5953,6 +6211,10 @@ function playQueueIndex(i) {
|
|||||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||||
appendedNextPath = null;
|
appendedNextPath = null;
|
||||||
setAccentFromArtUrl(item.artUrl ?? 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
|
loadStreamWaveform(item); // instant when already fully cached
|
||||||
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
|
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -5998,10 +6260,10 @@ export function jumpToQueueIndex(i) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gaplessNextIndex() {
|
function gaplessNextIndex() {
|
||||||
if (player.shuffle) return -1;
|
|
||||||
if (player.repeat === "one") return player.queueIndex;
|
if (player.repeat === "one") return player.queueIndex;
|
||||||
const next = player.queueIndex + 1;
|
// Shuffle included: the order is predetermined, so the engine can arm
|
||||||
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
// the SAME track the panel promises.
|
||||||
|
const i = peekNextIndex();
|
||||||
// Set-recording slices never join the gapless machinery: a preload or
|
// 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
|
// 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
|
// 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
|
// 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
|
// no-ops when it's already cached or being fetched; single-slot peers just
|
||||||
// queue the request until the current transfer finishes.
|
// 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() {
|
/** Pull the next few stream tracks' bytes into the cache — the on-deck
|
||||||
const next = gaplessNextIndex();
|
* window. Nearest first, so the imminent boundary always has the head
|
||||||
const item = next >= 0 ? player.queue[next] : null;
|
* start; rows with no source yet are skipped here and picked up by the
|
||||||
if (!item?.stream || item.path === lastPreloadedPath) return;
|
* pending-row lookahead, whose resolve calls back into this. */
|
||||||
lastPreloadedPath = item.path;
|
function preloadUpcomingStreams() {
|
||||||
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {});
|
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
|
// 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.) */
|
* exactly one boundary and stalled on every one after it.) */
|
||||||
function prepareFollowing() {
|
function prepareFollowing() {
|
||||||
preloadFollowingTrack();
|
preloadFollowingTrack();
|
||||||
preloadNextStream();
|
preloadUpcomingStreams();
|
||||||
tryAppendNext();
|
tryAppendNext();
|
||||||
scheduleQueueLookahead();
|
scheduleQueueLookahead();
|
||||||
}
|
}
|
||||||
@@ -6264,10 +6539,10 @@ async function maybeExtendAlbumQueue() {
|
|||||||
// Teach the memory as the fill goes, so the album's LATER tracks
|
// Teach the memory as the fill goes, so the album's LATER tracks
|
||||||
// are known even if playback never reaches them this session.
|
// are known even if playback never reaches them this session.
|
||||||
rememberStreamSource(item);
|
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
|
// appendedNextPath), so calling them per append costs nothing once
|
||||||
// the immediate next track is already prepared.
|
// the upcoming window is already prepared.
|
||||||
preloadNextStream();
|
preloadUpcomingStreams();
|
||||||
tryAppendNext();
|
tryAppendNext();
|
||||||
}
|
}
|
||||||
autoAlbum = null; // every remaining track queued or skipped
|
autoAlbum = null; // every remaining track queued or skipped
|
||||||
@@ -6287,8 +6562,10 @@ async function maybeExtendAlbumQueue() {
|
|||||||
|
|
||||||
const pendingResolves = new WeakMap();
|
const pendingResolves = new WeakMap();
|
||||||
|
|
||||||
/** Resolve one pending descriptor to a playable source, or null. */
|
/** Resolve one pending descriptor to a playable source, or null.
|
||||||
async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
* `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);
|
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
|
||||||
if (known) {
|
if (known) {
|
||||||
return {
|
return {
|
||||||
@@ -6300,7 +6577,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
|||||||
}
|
}
|
||||||
if (app.connection !== "online") return null;
|
if (app.connection !== "online") return null;
|
||||||
const goodCount = (rs) =>
|
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 primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
|
||||||
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
||||||
let artistIncluded = true;
|
let artistIncluded = true;
|
||||||
@@ -6312,7 +6589,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
|||||||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||||||
}
|
}
|
||||||
const best = songCandidates(
|
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];
|
)[0];
|
||||||
if (!best) return null;
|
if (!best) return null;
|
||||||
return {
|
return {
|
||||||
@@ -6377,17 +6654,36 @@ function scheduleQueueLookahead() {
|
|||||||
}, ALBUM_FILL_DELAY_MS);
|
}, 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() {
|
async function resolveNextPendingItem() {
|
||||||
if (player.shuffle) return; // the next index is unknowable
|
// The whole on-deck window wants sources, but Soulseek searches stay
|
||||||
const next = player.queueIndex + 1;
|
// 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];
|
const item = player.queue[next];
|
||||||
if (!item || item.path || item.stream || item.unplayable) return;
|
lookaheadTried.add(item);
|
||||||
const src = await resolveQueueItem(item);
|
const src = await resolveQueueItem(item);
|
||||||
if (!src || !player.queue.includes(item)) return;
|
if (!player.queue.includes(item)) return;
|
||||||
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
if (src) {
|
||||||
// append, exactly as if it had been queued with its source.
|
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
||||||
preloadNextStream();
|
// append, exactly as if it had been queued with its source.
|
||||||
tryAppendNext();
|
preloadUpcomingStreams();
|
||||||
|
tryAppendNext();
|
||||||
|
}
|
||||||
|
if (pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH)) != null) scheduleQueueLookahead();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- waveform ----
|
// ---- waveform ----
|
||||||
@@ -6506,16 +6802,115 @@ async function loadStreamWaveform(item, final = false) {
|
|||||||
|
|
||||||
// ---- queue navigation, shuffle, repeat ----
|
// ---- queue navigation, shuffle, repeat ----
|
||||||
|
|
||||||
// Indices already played this shuffle cycle (module-private).
|
/** A Fisher-Yates permutation of 0..n, optionally anchored so `firstIndex`
|
||||||
let shuffleSeen = new Set();
|
* 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;
|
const n = player.queue.length;
|
||||||
if (reset) shuffleSeen = new Set();
|
if (n === 0) {
|
||||||
const remaining = [];
|
player.shuffleOrder = [];
|
||||||
for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i);
|
player.shuffleNextOrder = null;
|
||||||
if (remaining.length === 0) return -1;
|
return;
|
||||||
return remaining[Math.floor(Math.random() * remaining.length)];
|
}
|
||||||
|
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) {
|
export function nextTrack(auto = false) {
|
||||||
@@ -6532,11 +6927,14 @@ export function nextTrack(auto = false) {
|
|||||||
|
|
||||||
let next;
|
let next;
|
||||||
if (player.shuffle) {
|
if (player.shuffle) {
|
||||||
shuffleSeen.add(player.queueIndex);
|
ensureShuffleOrder();
|
||||||
next = pickShuffleIndex();
|
next = peekNextIndex();
|
||||||
if (next < 0) {
|
if (next < 0) return stopPlayback();
|
||||||
if (player.repeat === "all") next = pickShuffleIndex(true);
|
// Crossing the cycle boundary consumes the pre-made reshuffle.
|
||||||
else return stopPlayback();
|
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||||||
|
if (pos === player.shuffleOrder.length - 1 && player.shuffleNextOrder) {
|
||||||
|
player.shuffleOrder = player.shuffleNextOrder;
|
||||||
|
player.shuffleNextOrder = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
next = player.queueIndex + 1;
|
next = player.queueIndex + 1;
|
||||||
@@ -6568,6 +6966,14 @@ export function prevTrack() {
|
|||||||
seekPlayer(0);
|
seekPlayer(0);
|
||||||
return;
|
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;
|
const prev = player.queueIndex - 1;
|
||||||
if (prev >= 0) playQueueIndex(prev);
|
if (prev >= 0) playQueueIndex(prev);
|
||||||
else seekPlayer(0);
|
else seekPlayer(0);
|
||||||
@@ -6575,12 +6981,16 @@ export function prevTrack() {
|
|||||||
|
|
||||||
export function toggleShuffle() {
|
export function toggleShuffle() {
|
||||||
player.shuffle = !player.shuffle;
|
player.shuffle = !player.shuffle;
|
||||||
shuffleSeen = new Set([player.queueIndex]);
|
player.shuffleOrder = [];
|
||||||
|
player.shuffleNextOrder = null;
|
||||||
|
ensureShuffleOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cycleRepeat() {
|
export function cycleRepeat() {
|
||||||
player.repeat =
|
player.repeat =
|
||||||
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
|
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
|
||||||
|
// Repeat decides whether a cycle boundary has a future — recheck it.
|
||||||
|
ensureShuffleOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPlayback() {
|
function stopPlayback() {
|
||||||
@@ -6602,7 +7012,7 @@ function stopPlayback() {
|
|||||||
latestStreamTotal = 0;
|
latestStreamTotal = 0;
|
||||||
autoAlbum = null;
|
autoAlbum = null;
|
||||||
appendedNextPath = null;
|
appendedNextPath = null;
|
||||||
lastPreloadedPath = null;
|
preloadRequestedPaths = new Set();
|
||||||
setAccentFromArt(null);
|
setAccentFromArt(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6734,6 +7144,7 @@ function writeSession() {
|
|||||||
duration: player.duration,
|
duration: player.duration,
|
||||||
catalogRef: player.catalogRef,
|
catalogRef: player.catalogRef,
|
||||||
shuffle: player.shuffle,
|
shuffle: player.shuffle,
|
||||||
|
shuffleOrder: player.shuffleOrder,
|
||||||
repeat: player.repeat,
|
repeat: player.repeat,
|
||||||
volume: player.volume,
|
volume: player.volume,
|
||||||
muted: player.muted,
|
muted: player.muted,
|
||||||
@@ -6829,6 +7240,9 @@ export async function restoreSession() {
|
|||||||
player.muted = !!saveState.muted;
|
player.muted = !!saveState.muted;
|
||||||
player.shuffle = !!saveState.shuffle;
|
player.shuffle = !!saveState.shuffle;
|
||||||
player.repeat = saveState.repeat ?? "off";
|
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;
|
const item = saveState.queue?.[saveState.queueIndex] ?? null;
|
||||||
if (item && saveState.track) {
|
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
|
// Nothing is loaded in the audio thread, so the play button has to
|
||||||
// re-source rather than resume — see `resumePlayback`.
|
// re-source rather than resume — see `resumePlayback`.
|
||||||
player.needsResource = !live;
|
player.needsResource = !live;
|
||||||
|
ensureShuffleOrder();
|
||||||
// The bar's artwork and the app accent follow the track exactly as
|
// The bar's artwork and the app accent follow the track exactly as
|
||||||
// they would had it just been started.
|
// they would had it just been started.
|
||||||
if (item.stream) setAccentFromArtUrl(item.artUrl ?? null);
|
if (item.stream) setAccentFromArtUrl(item.artUrl ?? null);
|
||||||
@@ -6901,6 +7316,6 @@ if (import.meta.env.DEV) {
|
|||||||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||||||
catalogGo, refreshLibrary, refreshRecents,
|
catalogGo, refreshLibrary, refreshRecents,
|
||||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||||
playlistStore, refreshPlaylists, goToView,
|
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+213
-24
@@ -16,8 +16,15 @@
|
|||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
goToView,
|
goToView,
|
||||||
getArt,
|
getArt,
|
||||||
|
cachedStreamArt,
|
||||||
|
playlistBulk,
|
||||||
|
runPlaylistBulk,
|
||||||
|
cancelPlaylistBulk,
|
||||||
|
dismissPlaylistBulk,
|
||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
|
import { untrack } from "svelte";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
|
import { findCatalogTrack } from "../lib/catalogMatch.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
|
|
||||||
const playlist = $derived(
|
const playlist = $derived(
|
||||||
@@ -29,6 +36,7 @@
|
|||||||
let query = $state("");
|
let query = $state("");
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
|
let exportOpen = $state(false);
|
||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
let nameInput = $state("");
|
let nameInput = $state("");
|
||||||
let editingDesc = $state(false);
|
let editingDesc = $state(false);
|
||||||
@@ -115,7 +123,6 @@
|
|||||||
// the import ladder's own evidence class — supplies the cover and the
|
// the import ladder's own evidence class — supplies the cover and the
|
||||||
// track id, persisted on the row. Sequential, one search per row, each
|
// track id, persisted on the row. Sequential, one search per row, each
|
||||||
// row attempted once per session; no match simply keeps the glyph.
|
// 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();
|
const artAttempted = new Set();
|
||||||
let backfillBusy = false;
|
let backfillBusy = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -124,6 +131,7 @@
|
|||||||
(i) =>
|
(i) =>
|
||||||
i.kind === "stream" &&
|
i.kind === "stream" &&
|
||||||
!i.art_url &&
|
!i.art_url &&
|
||||||
|
!i.art_path &&
|
||||||
i.artist &&
|
i.artist &&
|
||||||
i.title &&
|
i.title &&
|
||||||
!artAttempted.has(i.id),
|
!artAttempted.has(i.id),
|
||||||
@@ -138,17 +146,37 @@
|
|||||||
if (ui.playlistId !== plId) return;
|
if (ui.playlistId !== plId) return;
|
||||||
if (artAttempted.has(row.id)) continue;
|
if (artAttempted.has(row.id)) continue;
|
||||||
artAttempted.add(row.id);
|
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;
|
let hit = null;
|
||||||
try {
|
try {
|
||||||
const res = await api.catalogSearch(`${row.artist} ${row.title}`);
|
hit = await findCatalogTrack(
|
||||||
const same = (t) =>
|
(query) => api.catalogSearchTracks(query),
|
||||||
normId(t.artist) === normId(row.artist) &&
|
{
|
||||||
normId(t.title) === normId(row.title);
|
title: row.title,
|
||||||
const hits = (res?.tracks ?? []).filter(same);
|
artist: row.artist,
|
||||||
hit =
|
duration_secs: row.duration_secs ?? null,
|
||||||
hits.find((t) => row.album && normId(t.album) === normId(row.album)) ??
|
},
|
||||||
hits[0] ??
|
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
|
||||||
null;
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Offline or throttled — let the next page visit retry.
|
// Offline or throttled — let the next page visit retry.
|
||||||
artAttempted.delete(row.id);
|
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 norm = (s) => (s ?? "").toLowerCase();
|
||||||
const visible = $derived.by(() => {
|
const visible = $derived.by(() => {
|
||||||
const q = norm(query.trim());
|
const q = norm(query.trim());
|
||||||
@@ -340,8 +390,23 @@
|
|||||||
delete playlistStore.covers[playlist.id];
|
delete playlistStore.covers[playlist.id];
|
||||||
await refreshPlaylists();
|
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) {
|
async function exportAs(format) {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
|
exportOpen = false;
|
||||||
try {
|
try {
|
||||||
await api.playlistExport(playlist.id, format);
|
await api.playlistExport(playlist.id, format);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -670,6 +735,7 @@
|
|||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
confirmDelete = false;
|
confirmDelete = false;
|
||||||
|
exportOpen = false;
|
||||||
menuOpen = !menuOpen;
|
menuOpen = !menuOpen;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -698,24 +764,51 @@
|
|||||||
<button class="mi" onclick={removeUnresolved}>
|
<button class="mi" onclick={removeUnresolved}>
|
||||||
<Icon name="x" size={15} /> Remove unresolved entries
|
<Icon name="x" size={15} /> Remove unresolved entries
|
||||||
</button>
|
</button>
|
||||||
<div class="rule"></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
<button class="mi" onclick={() => exportAs("m3u8")}>
|
<div class="rule"></div>
|
||||||
<Icon name="export" size={15} /> Export M3U8
|
<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>
|
||||||
<button class="mi" onclick={() => exportAs("xspf")}>
|
<button
|
||||||
<Icon name="export" size={15} /> Export XSPF
|
class="mi"
|
||||||
</button>
|
onclick={downloadAllTracks}
|
||||||
<button class="mi" onclick={() => exportAs("pls")}>
|
disabled={playlistBulk.active || bulkableCount === 0}
|
||||||
<Icon name="export" size={15} /> Export PLS
|
title="Find and download every track that isn't in the library yet"
|
||||||
</button>
|
>
|
||||||
<button class="mi" onclick={() => exportAs("csv")}>
|
<Icon name="download" size={15} /> Download all tracks
|
||||||
<Icon name="export" size={15} /> Export CSV
|
|
||||||
</button>
|
|
||||||
<button class="mi" onclick={() => exportAs("json")}>
|
|
||||||
<Icon name="export" size={15} /> Export backup (JSON)
|
|
||||||
</button>
|
</button>
|
||||||
<div class="rule"></div>
|
<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}>
|
<button class="mi danger" onclick={del}>
|
||||||
<Icon name="trash" size={15} />
|
<Icon name="trash" size={15} />
|
||||||
{confirmDelete ? "Really delete?" : "Delete playlist"}
|
{confirmDelete ? "Really delete?" : "Delete playlist"}
|
||||||
@@ -737,6 +830,31 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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} · {playlistBulk.organized} saved{/if}
|
||||||
|
{#if playlistBulk.queued} · {playlistBulk.queued} downloading{/if}
|
||||||
|
{#if playlistBulk.missed} · {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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1236,6 +1354,77 @@
|
|||||||
.mi.danger:hover {
|
.mi.danger:hover {
|
||||||
color: #f15e6c;
|
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 {
|
.menu .rule {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
|
|||||||
+65
-15
@@ -19,6 +19,7 @@
|
|||||||
let importMode = $state("file"); // "file" | "paste"
|
let importMode = $state("file"); // "file" | "paste"
|
||||||
let importDraft = $state(null);
|
let importDraft = $state(null);
|
||||||
let menuFor = $state(null); // playlist id whose ⋯ menu is open
|
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 confirmDelete = $state(null); // playlist id armed for deletion
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let newMenuOpen = $state(false);
|
let newMenuOpen = $state(false);
|
||||||
@@ -130,6 +131,13 @@
|
|||||||
importOpen = true;
|
importOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function importFromYoutubePlaylist() {
|
||||||
|
newMenuOpen = false;
|
||||||
|
importDraft = null;
|
||||||
|
importMode = "ytplaylist";
|
||||||
|
importOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function open(pl) {
|
function open(pl) {
|
||||||
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
goToView("playlist", { playlistId: pl.id, label: pl.name });
|
||||||
}
|
}
|
||||||
@@ -211,6 +219,7 @@
|
|||||||
|
|
||||||
async function exportAs(pl, format) {
|
async function exportAs(pl, format) {
|
||||||
menuFor = null;
|
menuFor = null;
|
||||||
|
exportFor = null;
|
||||||
try {
|
try {
|
||||||
await api.playlistExport(pl.id, format);
|
await api.playlistExport(pl.id, format);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -307,6 +316,11 @@
|
|||||||
YouTube DJ set…
|
YouTube DJ set…
|
||||||
<span class="hint">tracks from chapters, description or comments</span>
|
<span class="hint">tracks from chapters, description or comments</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -420,21 +434,31 @@
|
|||||||
<Icon name="layers" size={15} /> Duplicate
|
<Icon name="layers" size={15} /> Duplicate
|
||||||
</button>
|
</button>
|
||||||
<div class="rule"></div>
|
<div class="rule"></div>
|
||||||
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<Icon name="export" size={15} /> Export M3U8
|
<div
|
||||||
</button>
|
class="sub-wrap"
|
||||||
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
|
onmouseenter={() => (exportFor = pl.id)}
|
||||||
<Icon name="export" size={15} /> Export XSPF
|
onmouseleave={() => (exportFor = null)}
|
||||||
</button>
|
>
|
||||||
<button class="mi" onclick={() => exportAs(pl, "pls")}>
|
<button
|
||||||
<Icon name="export" size={15} /> Export PLS
|
class="mi"
|
||||||
</button>
|
onclick={() => (exportFor = exportFor === pl.id ? null : pl.id)}
|
||||||
<button class="mi" onclick={() => exportAs(pl, "csv")}>
|
aria-haspopup="menu"
|
||||||
<Icon name="export" size={15} /> Export CSV
|
aria-expanded={exportFor === pl.id}
|
||||||
</button>
|
>
|
||||||
<button class="mi" onclick={() => exportAs(pl, "json")}>
|
<Icon name="export" size={15} /> Export
|
||||||
<Icon name="export" size={15} /> Export backup (JSON)
|
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
|
||||||
</button>
|
</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>
|
<div class="rule"></div>
|
||||||
<button class="mi danger" onclick={() => remove(pl)}>
|
<button class="mi danger" onclick={() => remove(pl)}>
|
||||||
<Icon name="trash" size={15} />
|
<Icon name="trash" size={15} />
|
||||||
@@ -641,6 +665,32 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding-left: 14px;
|
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 {
|
.menu .rule {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user