Files

1645 lines
60 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Playlist commands: CRUD over the occurrence model, covers (custom +
//! composed mosaic), file import with staged preview, and export profiles.
//!
//! Boundaries, per the architecture audit: parsing happens in
//! `playlist_formats` (pure, bounded), resolution against the library happens
//! here in memory off one index read, and every accepted mutation is a single
//! repository transaction. Nothing in this module holds the database lock
//! across a dialog, a decode, or a network call.
use std::path::{Path, PathBuf};
use library::NewPlaylistItem;
use tauri::{Emitter, State};
use crate::pipeline::LibraryState;
use crate::playlist_formats::{self, ExportItem, ParsedEntry};
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub(crate) fn changed(app: &tauri::AppHandle) {
let _ = app.emit("soulseek:playlists-changed", ());
}
/// The library-owned home of managed playlist covers. Lives inside the
/// library folder: a chosen cover is a decision about this library and
/// travels with it.
fn covers_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let cfg = crate::config::load(app);
let dir = crate::config::ensure_library_data_dir(app, &cfg)
.map_err(|e| e.to_string())?
.join("playlist-covers");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
// ---- list / meta / items ---------------------------------------------------
#[tauri::command]
pub async fn list_playlists(
lib: State<'_, LibraryState>,
) -> Result<Vec<library::Playlist>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
/// One playlist row as the page renders it. `missing` is computed at read
/// time — a row whose file has vanished keeps its kind ("local") and its
/// place; the flag is presentation, not stored state.
#[derive(serde::Serialize)]
pub struct PlaylistItemPayload {
#[serde(flatten)]
pub item: library::PlaylistItem,
pub missing: bool,
}
#[tauri::command]
pub async fn playlist_items(
lib: State<'_, LibraryState>,
id: i64,
) -> Result<Vec<PlaylistItemPayload>, String> {
let l = lib.library.clone();
// The existence sweep rides the same blocking task as the DB read (after
// the lock is released): a stat can block for seconds on a dead network
// path, and the async runtime must never eat that.
tauri::async_runtime::spawn_blocking(move || {
let rows = l.playlist_items(id).map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|item| {
let missing = item
.path
.as_deref()
.is_some_and(|p| !Path::new(p).is_file());
PlaylistItemPayload { item, missing }
})
.collect())
})
.await
.map_err(|e| e.to_string())?
}
// ---- create / metadata mutations -------------------------------------------
#[tauri::command]
pub async fn create_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
name: String,
kind: Option<String>,
) -> Result<i64, String> {
let l = lib.library.clone();
let kind = kind.unwrap_or_else(|| "manual".into());
let id = tauri::async_runtime::spawn_blocking(move || {
l.create_playlist(&name, &kind, now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(id)
}
#[tauri::command]
pub async fn rename_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
name: String,
) -> Result<(), String> {
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return Err("A playlist needs a name".into());
}
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.rename_playlist(id, &trimmed, now_secs()))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}
#[tauri::command]
pub async fn set_playlist_description(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
description: Option<String>,
) -> Result<(), String> {
let l = lib.library.clone();
let desc = description.map(|d| d.trim().to_string()).filter(|d| !d.is_empty());
tauri::async_runtime::spawn_blocking(move || {
l.set_playlist_description(id, desc.as_deref(), now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}
#[tauri::command]
pub async fn set_playlist_pinned(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
pinned: bool,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.set_playlist_pinned(id, pinned, now_secs()))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}
#[tauri::command]
pub async fn duplicate_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
name: String,
) -> Result<i64, String> {
let l = lib.library.clone();
let new_id = tauri::async_runtime::spawn_blocking(move || {
l.duplicate_playlist(id, &name, now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
// A custom cover is copied, not shared: cover files are keyed by playlist
// id, and deleting the original must not tear the copy's cover away.
if let Ok(dir) = covers_dir(&app) {
let src = dir.join(format!("{id}.jpg"));
if src.is_file() {
let target = dir.join(format!("{new_id}.jpg"));
if std::fs::copy(&src, &target).is_ok() {
let l = lib.library.clone();
let stored = target.to_string_lossy().into_owned();
let _ = tauri::async_runtime::spawn_blocking(move || {
l.set_playlist_cover(new_id, Some(&stored), now_secs())
})
.await;
}
}
}
changed(&app);
Ok(new_id)
}
#[tauri::command]
pub async fn delete_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
) -> Result<(), String> {
// Before the local truth goes, not after: a share this device can no
// longer honour is withdrawn and its ledger row dropped, or the next
// playlist to be handed this row id publishes as the one just deleted.
crate::shared_playlists::forget_playlist(&app, id).await;
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
// The cover files belong to the row that just went away.
if let Ok(dir) = covers_dir(&app) {
remove_cover_files(&dir, id);
}
changed(&app);
Ok(())
}
fn remove_cover_files(dir: &Path, id: i64) {
if let Ok(entries) = std::fs::read_dir(dir) {
let prefix = format!("{id}-");
let exact = format!("{id}.jpg");
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name == exact || name.starts_with(&prefix) {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
// ---- item mutations --------------------------------------------------------
/// What UI surfaces send when adding rows (album page, Now Playing, repair,
/// and the page's undo — which restores setlist timing and provenance too).
#[derive(serde::Deserialize)]
pub struct AddItemInput {
pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
pub duration_secs: Option<u32>,
pub path: Option<String>,
pub art_path: Option<String>,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub source_line: Option<String>,
#[serde(default)]
pub note: Option<String>,
/// Catalog track id for a stream row — the identity anchor (exact id,
/// never a name match).
#[serde(default)]
pub track_id: Option<String>,
/// Cover URL for a stream row, which has no local file to read art from.
#[serde(default)]
pub art_url: Option<String>,
}
#[tauri::command]
pub async fn playlist_add_items(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
items: Vec<AddItemInput>,
at: Option<i64>,
) -> Result<u32, String> {
let l = lib.library.clone();
let rows: Vec<NewPlaylistItem> = items
.into_iter()
.map(|t| NewPlaylistItem {
title: t.title,
artist: t.artist.unwrap_or_default(),
album: t.album,
duration_secs: t.duration_secs,
path: t.path,
art_path: t.art_path,
start_ms: t.start_ms,
source_line: t.source_line,
note: t.note,
track_id: t.track_id,
art_url: t.art_url,
..Default::default()
})
.collect();
let added = tauri::async_runtime::spawn_blocking(move || {
l.add_playlist_items(playlist_id, &rows, at, now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(added)
}
#[tauri::command]
pub async fn playlist_remove_items(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
item_ids: Vec<i64>,
) -> Result<u32, String> {
let l = lib.library.clone();
let removed = tauri::async_runtime::spawn_blocking(move || {
l.remove_playlist_items(playlist_id, &item_ids, now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(removed)
}
#[tauri::command]
pub async fn playlist_set_order(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
ordered_ids: Vec<i64>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.set_playlist_order(playlist_id, &ordered_ids, now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}
/// Repair one row — attach a found local file, corrected metadata, or a
/// stream row's catalog anchor (track id + cover URL).
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn playlist_relink_item(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
item_id: i64,
title: Option<String>,
artist: Option<String>,
album: Option<String>,
duration_secs: Option<u32>,
path: Option<String>,
art_path: Option<String>,
track_id: Option<String>,
art_url: Option<String>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.relink_playlist_item(
playlist_id,
item_id,
title.as_deref(),
artist.as_deref(),
album.as_deref(),
duration_secs,
path.as_deref(),
art_path.as_deref(),
track_id.as_deref(),
art_url.as_deref(),
now_secs(),
)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(())
}
// ---- covers ----------------------------------------------------------------
/// Managed master size for a custom cover; big enough for the page header at
/// 2x, small enough to compose and ship as a data URL instantly.
const COVER_MASTER_EDGE: u32 = 1024;
const MOSAIC_EDGE: u32 = 512;
/// Pick a custom cover image. Validates by decoding, center-crops square,
/// normalizes to a JPEG master under the managed covers dir, points the
/// playlist at it. Returns the new cover as a data URL (or None on cancel).
#[tauri::command]
pub async fn playlist_cover_pick(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter("Image", &["jpg", "jpeg", "png", "webp"])
.pick_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(source) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let dir = covers_dir(&app)?;
let target = dir.join(format!("{id}.jpg"));
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let bytes = std::fs::read(&source).map_err(|e| e.to_string())?;
if bytes.len() > 64 * 1024 * 1024 {
return Err("Image is unreasonably large".into());
}
let img = image::load_from_memory(&bytes).map_err(|e| format!("Not a usable image: {e}"))?;
Ok(square_jpeg(&img, COVER_MASTER_EDGE))
})
.await
.map_err(|e| e.to_string())??;
let temp = dir.join(format!("{id}.importing"));
std::fs::write(&temp, &jpeg).map_err(|e| e.to_string())?;
std::fs::rename(&temp, &target).map_err(|e| e.to_string())?;
let l = lib.library.clone();
let stored = target.to_string_lossy().into_owned();
tauri::async_runtime::spawn_blocking(move || {
l.set_playlist_cover(id, Some(&stored), now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
Ok(Some(data_url_jpeg(&jpeg)))
}
#[tauri::command]
pub async fn playlist_cover_clear(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.set_playlist_cover(id, None, now_secs()))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
if let Ok(dir) = covers_dir(&app) {
let _ = std::fs::remove_file(dir.join(format!("{id}.jpg")));
}
changed(&app);
Ok(())
}
/// The cover a surface should draw for one playlist, as a data URL:
/// the custom master when chosen, otherwise a mosaic composed from the first
/// four distinct album covers, otherwise None (the UI draws its gradient).
/// Mosaics are cached on disk keyed by the seed set, so a reorder that does
/// not change the four covers costs a file read.
#[tauri::command]
pub async fn playlist_cover(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
max_edge: Option<u32>,
) -> Result<Option<String>, String> {
let l = lib.library.clone();
let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let Some(meta) = meta else { return Ok(None) };
let edge = max_edge.unwrap_or(MOSAIC_EDGE).clamp(64, COVER_MASTER_EDGE);
let dir = covers_dir(&app)?;
// Every file read, decode and compose happens off the async runtime — a
// cover on an unplugged drive can block for seconds per call.
tauri::async_runtime::spawn_blocking(move || -> Result<Option<String>, String> {
// Custom cover wins outright.
if let Some(cover) = meta.cover_path.as_deref() {
if let Ok(bytes) = std::fs::read(cover) {
let out = match crate::pipeline::thumbnail_jpeg(&bytes, edge) {
Ok(Some(thumb)) => thumb,
_ => bytes,
};
return Ok(Some(data_url_jpeg(&out)));
}
}
if meta.art_seeds.is_empty() {
return Ok(None);
}
let key = mosaic_key(&meta.art_seeds);
let cached = dir.join(format!("{id}-mosaic-{key}.jpg"));
let bytes = if cached.is_file() {
std::fs::read(&cached).map_err(|e| e.to_string())?
} else {
let Some(out) = compose_mosaic(&meta.art_seeds) else {
return Ok(None);
};
// Retire stale mosaics for this playlist, then commit the new one.
if let Ok(entries) = std::fs::read_dir(&dir) {
let prefix = format!("{id}-mosaic-");
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(&prefix) {
let _ = std::fs::remove_file(entry.path());
}
}
}
let temp = dir.join(format!("{id}-mosaic-{key}.importing"));
if std::fs::write(&temp, &out).is_ok() {
let _ = std::fs::rename(&temp, &cached);
}
out
};
let out = match crate::pipeline::thumbnail_jpeg(&bytes, edge) {
Ok(Some(thumb)) => thumb,
_ => bytes,
};
Ok(Some(data_url_jpeg(&out)))
})
.await
.map_err(|e| e.to_string())?
}
fn data_url_jpeg(bytes: &[u8]) -> String {
use base64::Engine as _;
format!(
"data:image/jpeg;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
}
fn mosaic_key(seeds: &[String]) -> String {
use std::hash::{Hash as _, Hasher as _};
let mut h = std::collections::hash_map::DefaultHasher::new();
for s in seeds {
s.hash(&mut h);
}
format!("{:016x}", h.finish())
}
/// Center-crop square + Lanczos3 to `edge`, JPEG q92.
fn square_jpeg(img: &image::DynamicImage, edge: u32) -> Vec<u8> {
let (w, h) = (img.width(), img.height());
let side = w.min(h);
let cropped = img.crop_imm((w - side) / 2, (h - side) / 2, side, side);
let scaled = if side > edge {
cropped.resize_exact(edge, edge, image::imageops::FilterType::Lanczos3)
} else {
cropped
};
let mut out = std::io::Cursor::new(Vec::new());
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
let _ = scaled.into_rgb8().write_with_encoder(encoder);
out.into_inner()
}
/// Deterministic 2×2 mosaic from up to four covers, in playlist order.
/// One cover fills the square; two alternate diagonally; three repeat the
/// first. Unreadable seeds are skipped; zero readable seeds composes nothing.
fn compose_mosaic(seeds: &[String]) -> Option<Vec<u8>> {
let tiles: Vec<image::DynamicImage> = seeds
.iter()
.filter_map(|p| image::open(p).ok())
.take(4)
.collect();
if tiles.is_empty() {
return None;
}
if tiles.len() == 1 {
return Some(square_jpeg(&tiles[0], MOSAIC_EDGE));
}
let half = MOSAIC_EDGE / 2;
let layout: [usize; 4] = match tiles.len() {
2 => [0, 1, 1, 0],
3 => [0, 1, 2, 0],
_ => [0, 1, 2, 3],
};
let mut canvas = image::RgbImage::new(MOSAIC_EDGE, MOSAIC_EDGE);
for (slot, &tile_index) in layout.iter().enumerate() {
let tile = &tiles[tile_index];
let (w, h) = (tile.width(), tile.height());
let side = w.min(h);
let square = tile
.crop_imm((w - side) / 2, (h - side) / 2, side, side)
.resize_exact(half, half, image::imageops::FilterType::Lanczos3)
.into_rgb8();
let (dx, dy) = ((slot as u32 % 2) * half, (slot as u32 / 2) * half);
image::imageops::replace(&mut canvas, &square, dx as i64, dy as i64);
}
let mut out = std::io::Cursor::new(Vec::new());
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
image::DynamicImage::ImageRgb8(canvas)
.into_rgb8()
.write_with_encoder(encoder)
.ok()?;
Some(out.into_inner())
}
// ---- import ----------------------------------------------------------------
/// One staged import row, resolution decided, awaiting user review.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct DraftEntry {
pub ordinal: u32,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_secs: Option<u32>,
pub start_ms: Option<i64>,
pub raw: String,
/// "local" (matched a library file) | "stream" (full identity, no local
/// file — becomes a Soulseek stream on commit) | "unresolved" (identity
/// too thin to play: ID rows, title-only lines).
pub resolution: String,
/// How the local match was made: "path" | "relinked" | "tags" — evidence
/// the preview shows so an exact path match reads differently from a
/// name-based one.
pub matched_by: Option<String>,
pub path: Option<String>,
pub art_path: Option<String>,
/// Catalog identity, attached by the review's verification pass (the
/// modal matches stream rows against the Deezer-backed catalog — Kyle:
/// verify against "the streaming library part, not Soulseek"). Flows
/// into the committed row so it plays as a catalog track with artwork.
#[serde(default)]
pub track_id: Option<i64>,
#[serde(default)]
pub art_url: Option<String>,
/// The heard release's ISRC, when this row came from the listener door
/// — lets the review resolve the catalog by exact recording id.
#[serde(default)]
pub isrc: Option<String>,
}
#[derive(serde::Serialize)]
pub struct ImportDraft {
pub source_name: String,
pub format: String,
pub name: Option<String>,
pub description: Option<String>,
/// "setlist" when any entry carries a source timestamp.
pub kind: String,
pub entries: Vec<DraftEntry>,
pub warnings: Vec<String>,
pub resolved_count: u32,
/// Rows that will play as Soulseek streams (identified, not owned).
pub stream_count: u32,
pub unresolved_count: u32,
/// A suggested cover image URL (the YouTube doors set the video's own
/// thumbnail). Commit installs it as the playlist's custom cover —
/// failure-soft, a cover is never worth failing an import over.
pub cover_url: Option<String>,
}
/// Open a playlist file and stage it for review. Parsing and resolution only —
/// nothing is written until `playlist_import_commit`.
#[tauri::command]
pub async fn playlist_import_probe(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
) -> Result<Option<ImportDraft>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter(
"Playlist",
&["m3u", "m3u8", "pls", "xspf", "csv", "json", "txt"],
)
.pick_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(source) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let source_path = PathBuf::from(&source);
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| source.clone());
// Size-check before reading, and read off the async runtime — a playlist
// picked from a dead network share must not hang the whole backend.
let read_path = source_path.clone();
let bytes = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let len = std::fs::metadata(&read_path).map_err(|e| e.to_string())?.len();
if len as usize > playlist_formats::MAX_SOURCE_BYTES {
return Err(format!("{} MB is too large for a playlist file", len / (1024 * 1024)));
}
std::fs::read(&read_path).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
let parsed = playlist_formats::parse(&name, &bytes)?;
let base_dir = source_path.parent().map(Path::to_path_buf);
let draft = resolve_draft(&lib, parsed, name, base_dir).await?;
Ok(Some(draft))
}
/// Stage a picked FOLDER of audio files as a playlist draft — the product
/// doc's "directory as playlist". Order comes from the files' own tag
/// track/disc numbers when every file carries a distinct one, otherwise
/// natural filename order (digit runs compared as numbers — the import
/// review's own lesson: byte order reads 10 before 2).
#[tauri::command]
pub async fn playlist_import_folder(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
) -> Result<Option<ImportDraft>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog().file().pick_folder(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(root) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let root_path = PathBuf::from(&root);
let folder_name = root_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Folder".into());
let l = lib.library.clone();
let index = tauri::async_runtime::spawn_blocking(move || l.track_index())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut by_path: std::collections::HashMap<String, usize> = Default::default();
for (i, row) in index.iter().enumerate() {
by_path.insert(row.path.trim().replace('/', "\\").to_lowercase(), i);
}
// Walk + tag-read off the runtime: a big folder is hundreds of file
// opens, and any of them can sit on slow storage.
let scan_root = root_path.clone();
let mut files = tauri::async_runtime::spawn_blocking(move || {
let mut found: Vec<PathBuf> = Vec::new();
collect_folder_audio(&scan_root, &mut found, 0);
// A junction inside the folder can re-surface the same files under a
// second spelling; canonical identity collapses them to one row each.
let mut seen = std::collections::HashSet::new();
found.retain(|p| {
let key = p
.canonicalize()
.map(|c| c.to_string_lossy().to_lowercase())
.unwrap_or_else(|_| p.to_string_lossy().to_lowercase());
seen.insert(key)
});
found.sort_by(|a, b| natural_cmp(&a.to_string_lossy(), &b.to_string_lossy()));
found.truncate(2_000);
let mut rows = Vec::with_capacity(found.len());
for path in found {
let summary = tagger::read_summary(&path).unwrap_or_default();
rows.push((path, summary));
}
rows
})
.await
.map_err(|e| e.to_string())?;
if files.is_empty() {
return Err("No audio files in that folder".into());
}
// Tag order only when it is COHERENT: every file numbered, all
// (disc, track) pairs distinct. Otherwise the natural name order stands.
let all_numbered = files.iter().all(|(_, s)| s.track_no.is_some());
if all_numbered {
let mut keys: Vec<(u32, u32)> = files
.iter()
.map(|(_, s)| (s.disc_no.unwrap_or(1), s.track_no.unwrap_or(0)))
.collect();
keys.sort_unstable();
keys.dedup();
if keys.len() == files.len() {
files.sort_by_key(|(_, s)| (s.disc_no.unwrap_or(1), s.track_no.unwrap_or(0)));
}
}
let entries: Vec<DraftEntry> = files
.into_iter()
.enumerate()
.map(|(ordinal, (path, summary))| {
let path_string = path.to_string_lossy().into_owned();
let indexed = by_path
.get(&path_string.trim().replace('/', "\\").to_lowercase())
.map(|&i| &index[i]);
let stem_title = || {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Unknown".into())
};
DraftEntry {
ordinal: ordinal as u32,
title: summary
.title
.clone()
.or_else(|| indexed.map(|r| r.title.clone()))
.unwrap_or_else(stem_title),
artist: summary
.artist
.clone()
.or_else(|| indexed.map(|r| r.artist.clone()))
.unwrap_or_default(),
album: summary
.album
.clone()
.or_else(|| indexed.map(|r| r.album.clone())),
duration_secs: summary
.duration_secs
.or_else(|| indexed.and_then(|r| r.duration_secs)),
start_ms: None,
raw: path_string.clone(),
resolution: "local".into(),
matched_by: Some(if indexed.is_some() { "path" } else { "file" }.into()),
path: Some(path_string),
art_path: indexed.and_then(|r| r.art_path.clone()),
track_id: None,
art_url: None,
isrc: None,
}
})
.collect();
let resolved = entries.len() as u32;
Ok(Some(ImportDraft {
source_name: folder_name.clone(),
format: "folder".into(),
name: Some(folder_name),
description: None,
kind: "manual".into(),
entries,
warnings: Vec::new(),
resolved_count: resolved,
stream_count: 0,
unresolved_count: 0,
cover_url: None,
}))
}
/// Audio files under `dir`, two levels deep — the folder itself plus disc
/// subfolders. Deeper nesting is somebody's whole collection, not one list;
/// the staging pen and the library's own state dir are skipped by name.
fn collect_folder_audio(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
if depth > 1 || out.len() >= 2_000 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
if name == "(Cache)" || name == crate::config::LIBRARY_DATA_DIRNAME {
continue;
}
collect_folder_audio(&path, out, depth + 1);
} else if audio_formats::is_audio_path(&path) {
out.push(path);
}
}
}
/// Compare with digit runs as numbers: `2 - Song` before `10 - Song`.
/// Runs compare by (length after leading zeros, then digits) — no integer
/// conversion, so arbitrarily long runs order correctly.
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let (a, b) = (a.to_lowercase(), b.to_lowercase());
let take_run = |iter: &mut std::iter::Peekable<std::str::Chars>| -> String {
let mut run = String::new();
while let Some(&c) = iter.peek() {
if c.is_ascii_digit() {
run.push(c);
iter.next();
} else {
break;
}
}
run
};
let (mut ai, mut bi) = (a.chars().peekable(), b.chars().peekable());
loop {
match (ai.peek().copied(), bi.peek().copied()) {
(None, None) => return std::cmp::Ordering::Equal,
(None, Some(_)) => return std::cmp::Ordering::Less,
(Some(_), None) => return std::cmp::Ordering::Greater,
(Some(x), Some(y)) => {
if x.is_ascii_digit() && y.is_ascii_digit() {
let xr = take_run(&mut ai);
let yr = take_run(&mut bi);
let xs = xr.trim_start_matches('0');
let ys = yr.trim_start_matches('0');
let ordering = xs
.len()
.cmp(&ys.len())
.then_with(|| xs.cmp(ys))
// Equal values: more leading zeros sorts first, and
// keeps the comparator total.
.then_with(|| xr.len().cmp(&yr.len()));
match ordering {
std::cmp::Ordering::Equal => {}
other => return other,
}
} else {
match x.cmp(&y) {
std::cmp::Ordering::Equal => {
ai.next();
bi.next();
}
other => return other,
}
}
}
}
}
}
/// Stage a pasted tracklist (playlist or timestamped setlist) for review.
#[tauri::command]
pub async fn playlist_import_text(
lib: State<'_, LibraryState>,
text: String,
) -> Result<ImportDraft, String> {
if text.len() > playlist_formats::MAX_SOURCE_BYTES {
return Err("Pasted text is too large".into());
}
let parsed = playlist_formats::parse_text(text.as_bytes())?;
resolve_draft(&lib, parsed, "Pasted tracklist".into(), None).await
}
/// Stage a YouTube DJ set for review: fetch the video's own tracklist
/// evidence (chapters → description → top comments, the research bundle's
/// declared-data rungs), then run it through the exact pasted-tracklist
/// pipeline — same parser, same resolution, same review modal, same commit.
/// No audio is fetched and nothing is guessed: a set with no posted
/// tracklist says so instead of inventing one.
#[tauri::command]
pub async fn playlist_import_youtube(
lib: State<'_, LibraryState>,
url: String,
) -> Result<ImportDraft, String> {
let fetched = crate::youtube_setlist::fetch(&url).await?;
let parsed = playlist_formats::parse_text(fetched.tracklist.as_bytes())?;
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".into();
draft.name = Some(fetched.title);
// The video's own thumbnail is the playlist's default face.
if let Some(id) = crate::youtube_setlist::video_id_from_url(&url) {
draft.cover_url = Some(format!("https://i.ytimg.com/vi/{id}/maxresdefault.jpg"));
}
draft
.warnings
.insert(0, format!("Tracklist from {}.", fetched.provenance));
draft.warnings.extend(fetched.warnings);
Ok(draft)
}
/// Import a YouTube PLAYLIST — one row per video, off the playlist page's
/// own metadata (title, channel, length). No tools, no audio, no listening:
/// this is the same honest metadata-import posture as the DJ-set door, for
/// links where each video IS one track.
#[tauri::command]
pub async fn playlist_import_youtube_playlist(
lib: State<'_, LibraryState>,
url: String,
) -> Result<ImportDraft, String> {
let fetched = crate::youtube_playlist::fetch(&url).await?;
let parsed = crate::youtube_playlist::to_parsed(&fetched);
let source = if fetched.author.is_empty() {
fetched.title.clone()
} else {
format!("{} — {}", fetched.author, fetched.title)
};
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = "youtube-playlist".into();
draft.name = Some(fetched.title.clone());
// The first video's thumbnail is the playlist's default face — the same
// choice YouTube itself makes.
if let Some(first) = fetched.rows.first() {
draft.cover_url = Some(format!(
"https://i.ytimg.com/vi/{}/maxresdefault.jpg",
first.video_id
));
}
draft.warnings.insert(
0,
format!(
"{} video{} from the playlist.",
fetched.rows.len(),
if fetched.rows.len() == 1 { "" } else { "s" }
),
);
draft.warnings.extend(fetched.warnings);
Ok(draft)
}
/// Import a playlist from any supported LINK — YouTube, Spotify, Apple
/// Music, TIDAL, Deezer — one door, dispatched by host. Each source is
/// metadata-only and anonymous; rows ride the same resolve → review →
/// commit pipeline as every other import.
#[tauri::command]
pub async fn playlist_import_link(
lib: State<'_, LibraryState>,
url: String,
) -> Result<ImportDraft, String> {
// YouTube keeps its own module (lockup pages, continuations, title
// cleaning); everything else is a provider fetch.
if crate::youtube_playlist::playlist_id_from_url(&url).is_some() {
return playlist_import_youtube_playlist(lib, url).await;
}
let fetched = crate::provider_playlist::fetch(&url).await?;
let parsed = crate::provider_playlist::to_parsed(&fetched);
let source = if fetched.author.is_empty() {
fetched.title.clone()
} else {
format!("{} — {}", fetched.author, fetched.title)
};
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = fetched.provider.into();
draft.name = Some(fetched.title.clone());
draft.cover_url = fetched.cover_url.clone();
draft.warnings.insert(
0,
format!(
"{} track{} from the playlist.",
fetched.rows.len(),
if fetched.rows.len() == 1 { "" } else { "s" }
),
);
draft.warnings.extend(fetched.warnings);
Ok(draft)
}
/// Track links one paste will look up. Each costs a page fetch, so this is a
/// politeness ceiling as much as a sanity one.
const MAX_PASTED_LINKS: usize = 2000;
/// Import a pasted block of TRACK links — the way past a service's own
/// playlist ceiling. Spotify's public playlist page stops at 100 tracks, but
/// every track has its own public page, and Spotify's app hands over all the
/// links at once (select all, copy). Each line is looked up on its own; a
/// line that is not a link is read as ordinary "Artist - Title" text, so a
/// mixed paste works too.
#[tauri::command]
pub async fn playlist_import_track_links(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
text: String,
name: Option<String>,
) -> Result<ImportDraft, String> {
let lines = crate::provider_playlist::pasted_lines(&text);
if lines.is_empty() {
return Err("Nothing to import in that paste.".into());
}
let link_count = lines
.iter()
.filter(|l| matches!(l, crate::provider_playlist::PastedLine::Link(_)))
.count();
if link_count > MAX_PASTED_LINKS {
return Err(format!(
"That paste holds {link_count} track links — more than the {MAX_PASTED_LINKS} one import takes."
));
}
// The lookups take a page each, so the modal gets a live count instead of
// a frozen button.
let emitter = app.clone();
let outcome =
crate::provider_playlist::resolve_pasted(lines, move |done, total, waiting| {
let _ = emitter.emit(
"playlist:link-progress",
serde_json::json!({ "done": done, "total": total, "waiting": waiting }),
);
})
.await?;
let total = outcome.links;
let unread = outcome.rows.iter().filter(|r| r.is_none()).count();
let entries: Vec<_> = outcome
.rows
.into_iter()
.flatten()
.enumerate()
.map(|(i, r)| playlist_formats::ParsedEntry {
ordinal: i as u32,
title: Some(r.title.clone()),
artist: (!r.artist.is_empty()).then(|| r.artist.clone()),
album: r.album.clone(),
duration_secs: r.duration_secs,
location: None,
start_ms: None,
raw: format!("{} - {}", r.artist, r.title),
})
.collect();
if entries.is_empty() {
return Err("None of those links could be read.".into());
}
let parsed = playlist_formats::ParsedPlaylist {
format: "links".into(),
title: name.clone(),
description: None,
entries,
warnings: Vec::new(),
};
let source = name.clone().unwrap_or_else(|| "Pasted track links".into());
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = "links".into();
draft.name = name;
draft.warnings.insert(
0,
format!(
"{} track{} read from {total} pasted link{}.",
draft.entries.len(),
if draft.entries.len() == 1 { "" } else { "s" },
if total == 1 { "" } else { "s" }
),
);
// A link the service refused us on is a DIFFERENT failure from a link
// that names nothing, and only one of them is worth retrying.
let missing = unread.saturating_sub(outcome.throttled_out);
if outcome.throttled_out > 0 {
draft.warnings.insert(
1,
format!(
"{} link{} went unread because the service throttled the lookups. \
Paste those again in a few minutes and they will come through.",
outcome.throttled_out,
if outcome.throttled_out == 1 { "" } else { "s" }
),
);
} else if outcome.was_throttled {
draft.warnings.insert(
1,
"The service throttled the lookups partway; the import slowed down and \
finished anyway."
.into(),
);
}
if missing > 0 {
draft.warnings.push(format!(
"{missing} line{} named nothing readable and {} left out.",
if missing == 1 { "" } else { "s" },
if missing == 1 { "was" } else { "were" }
));
}
Ok(draft)
}
/// Commit a reviewed draft as a new playlist, atomically.
#[tauri::command]
pub async fn playlist_import_commit(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
name: String,
description: Option<String>,
kind: String,
entries: Vec<DraftEntry>,
cover_url: Option<String>,
) -> Result<i64, String> {
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return Err("The playlist needs a name".into());
}
let kind = if kind == "setlist" { "setlist" } else { "manual" };
let rows: Vec<NewPlaylistItem> = entries
.into_iter()
.map(|e| NewPlaylistItem {
title: e.title,
artist: e.artist,
album: e.album,
duration_secs: e.duration_secs,
// Empty lets the insert classify: a matched path is local, an
// unmatched row with full identity is a stream, the rest stay
// unresolved placeholders. The one kind the command DOES assert
// is "segment" — a listener row that plays a slice of the set's
// own recording; the classifier would read its path as "local".
kind: if e.resolution == "segment" {
"segment".into()
} else {
String::new()
},
path: e.path.filter(|p| !p.is_empty()),
art_path: e.art_path,
source_line: (!e.raw.is_empty()).then_some(e.raw),
start_ms: e.start_ms,
note: None,
// Catalog identity from the review's verification pass rides
// into the row: the playlist plays it as a catalog track.
track_id: e.track_id.map(|id| id.to_string()),
art_url: e.art_url,
})
.collect();
let l = lib.library.clone();
let desc = description.filter(|d| !d.trim().is_empty());
let kind_owned = kind.to_string();
let id = tauri::async_runtime::spawn_blocking(move || {
l.import_playlist(&trimmed, desc.as_deref(), &kind_owned, &rows, now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
changed(&app);
if let Some(url) = cover_url {
// Failure-soft: the playlist is already real; a cover is never
// worth failing it over.
match set_cover_from_url(&app, &lib, id, &url).await {
Ok(()) => changed(&app),
Err(e) => log::debug!("import cover fetch failed: {e}"),
}
}
Ok(id)
}
/// Download an image URL and install it as the playlist's custom cover —
/// the same square master `playlist_cover_pick` writes. YouTube's
/// maxresdefault 404s on some videos, so that URL falls back to hqdefault.
async fn set_cover_from_url(
app: &tauri::AppHandle,
lib: &State<'_, LibraryState>,
id: i64,
url: &str,
) -> Result<(), String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1")
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let mut candidates = vec![url.to_string()];
if let Some(rest) = url.strip_suffix("maxresdefault.jpg") {
candidates.push(format!("{rest}hqdefault.jpg"));
}
let mut bytes: Option<Vec<u8>> = None;
for candidate in candidates {
if let Ok(resp) = client.get(&candidate).send().await {
if resp.status().is_success() {
if let Ok(body) = resp.bytes().await {
// YouTube's CDN can answer a missing size with a tiny
// gray placeholder instead of a 404 — refuse those.
if body.len() > 4096 {
bytes = Some(body.to_vec());
break;
}
}
}
}
}
let bytes = bytes.ok_or("no usable image answered")?;
let dir = covers_dir(app)?;
let target = dir.join(format!("{id}.jpg"));
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let img =
image::load_from_memory(&bytes).map_err(|e| format!("not a usable image: {e}"))?;
Ok(square_jpeg(&img, COVER_MASTER_EDGE))
})
.await
.map_err(|e| e.to_string())??;
let temp = dir.join(format!("{id}.importing"));
std::fs::write(&temp, &jpeg).map_err(|e| e.to_string())?;
std::fs::rename(&temp, &target).map_err(|e| e.to_string())?;
let l = lib.library.clone();
let stored = target.to_string_lossy().into_owned();
tauri::async_runtime::spawn_blocking(move || {
l.set_playlist_cover(id, Some(&stored), now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(())
}
/// Resolve parsed entries against the local library, in memory.
///
/// The ladder, strongest evidence first (identity rules: an exact locator
/// outranks names; a name match must be unique or it stays unresolved):
/// 1. the entry's own path, existing on disk;
/// 2. the same path re-rooted under the current library (drive moved);
/// 3. a unique basename match in the library index;
/// 4. a unique normalized (artist, title) tag match;
/// 5. a unique normalized title match, when the entry names no artist.
pub(crate) async fn resolve_draft(
lib: &State<'_, LibraryState>,
parsed: playlist_formats::ParsedPlaylist,
source_name: String,
base_dir: Option<PathBuf>,
) -> Result<ImportDraft, String> {
let l = lib.library.clone();
let index = tauri::async_runtime::spawn_blocking(move || l.track_index())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let norm_path = |p: &str| p.trim().replace('/', "\\").to_lowercase();
let mut by_path: std::collections::HashMap<String, usize> = Default::default();
let mut by_base: std::collections::HashMap<String, Vec<usize>> = Default::default();
let mut by_tags: std::collections::HashMap<(String, String), Vec<usize>> = Default::default();
let mut by_title: std::collections::HashMap<String, Vec<usize>> = Default::default();
for (i, row) in index.iter().enumerate() {
let np = norm_path(&row.path);
if let Some(base) = np.rsplit('\\').next() {
by_base.entry(base.to_string()).or_default().push(i);
}
by_path.insert(np, i);
by_tags
.entry((norm_text(&row.artist), norm_text(&row.title)))
.or_default()
.push(i);
by_title.entry(norm_text(&row.title)).or_default().push(i);
}
let unique = |hits: Option<&Vec<usize>>| -> Option<usize> {
match hits {
Some(v) if v.len() == 1 => Some(v[0]),
// Several copies of the SAME recording (re-rips, deluxe copies)
// still resolve — any of them plays this entry. Different
// recordings sharing a display name stay ambiguous.
Some(v) if v.len() > 1 => {
let first = &index[v[0]];
v.iter()
.all(|&i| {
norm_text(&index[i].artist) == norm_text(&first.artist)
&& norm_text(&index[i].title) == norm_text(&first.title)
})
.then(|| v[0])
}
_ => None,
}
};
let mut entries = Vec::new();
let (mut resolved, mut streams, mut unresolved) = (0u32, 0u32, 0u32);
let mut any_start = false;
for entry in &parsed.entries {
let ParsedEntry {
ordinal,
title,
artist,
album,
duration_secs,
location,
start_ms,
raw,
} = entry;
any_start |= start_ms.is_some();
let mut hit: Option<(usize, &'static str)> = None;
if let Some(loc) = location.as_deref() {
// Relative locators resolve from the playlist file's directory.
let absolute = if looks_relative(loc) {
base_dir
.as_deref()
.map(|d| d.join(loc).to_string_lossy().into_owned())
} else {
Some(loc.to_string())
};
if let Some(abs) = absolute {
if let Some(&i) = by_path.get(&norm_path(&abs)) {
hit = Some((i, "path"));
} else if Path::new(&abs).is_file() {
// The file exists but is not in the library (outside the
// root). It still plays — treat as a resolved path-only row.
entries.push(DraftEntry {
ordinal: *ordinal,
title: title.clone().unwrap_or_else(|| "Unknown".into()),
artist: artist.clone().unwrap_or_default(),
album: album.clone(),
duration_secs: *duration_secs,
start_ms: *start_ms,
raw: raw.clone(),
resolution: "local".into(),
matched_by: Some("path".into()),
path: Some(abs),
art_path: None,
track_id: None,
art_url: None,
isrc: None,
});
resolved += 1;
continue;
}
}
if hit.is_none() {
// The library moved: match by basename when it names one file.
// But a locator is only a LOCATION — when the entry also names
// the track and the found file's tags disagree, the filename
// similarity is coincidence ("01 Track.mp3" exists everywhere)
// and the row stays unresolved rather than playing the wrong
// recording.
let base = norm_path(loc)
.rsplit('\\')
.next()
.unwrap_or_default()
.to_string();
if !base.is_empty() {
if let Some(i) = unique(by_base.get(&base)) {
let agrees = match title.as_deref() {
Some(t) => {
let nt = norm_text(t);
let ct = norm_text(&index[i].title);
nt.is_empty() || ct.contains(&nt) || nt.contains(&ct)
}
None => true,
};
if agrees {
hit = Some((i, "relinked"));
}
}
}
}
}
if hit.is_none() {
if let Some(t) = title.as_deref() {
let nt = norm_text(t);
if let Some(a) = artist.as_deref() {
if let Some(i) = unique(by_tags.get(&(norm_text(a), nt.clone()))) {
hit = Some((i, "tags"));
}
} else if nt != "id" && nt.chars().count() >= 3 {
// Title-only is the weakest evidence: never for "ID" (the
// DJ-culture unknown-track token — a library track that
// happens to be titled "ID" is NOT the unknown), and never
// for a name too short to mean anything.
if let Some(i) = unique(by_title.get(&nt)) {
hit = Some((i, "tags"));
}
}
}
}
match hit {
Some((i, how)) => {
let row = &index[i];
resolved += 1;
entries.push(DraftEntry {
ordinal: *ordinal,
title: title.clone().unwrap_or_else(|| row.title.clone()),
artist: artist.clone().unwrap_or_else(|| row.artist.clone()),
album: album.clone().or_else(|| Some(row.album.clone())),
duration_secs: duration_secs.or(row.duration_secs),
start_ms: *start_ms,
raw: raw.clone(),
resolution: "local".into(),
matched_by: Some(how.into()),
path: Some(row.path.clone()),
art_path: row.art_path.clone(),
track_id: None,
art_url: None,
isrc: None,
});
}
None => {
// No library file — but a row with FULL identity becomes a
// STREAM on commit (the insert classifier's rule), so the
// preview says so instead of "unresolved": Kyle read that
// label as "not identified" on a fully-recognized set.
// "unresolved" now means what it says: identity too thin
// to play (ID rows, title-only lines).
let full_identity = title.is_some()
&& artist.as_deref().map(|a| !a.trim().is_empty()).unwrap_or(false)
&& title.as_deref() != Some("ID");
if full_identity {
streams += 1;
} else {
unresolved += 1;
}
entries.push(DraftEntry {
ordinal: *ordinal,
title: title.clone().unwrap_or_else(|| "Unknown".into()),
artist: artist.clone().unwrap_or_default(),
album: album.clone(),
duration_secs: *duration_secs,
start_ms: *start_ms,
raw: raw.clone(),
resolution: if full_identity { "stream" } else { "unresolved" }.into(),
matched_by: None,
path: None,
art_path: None,
track_id: None,
art_url: None,
isrc: None,
});
}
}
}
Ok(ImportDraft {
source_name,
format: parsed.format,
name: parsed.title,
description: parsed.description,
kind: if any_start { "setlist".into() } else { "manual".into() },
entries,
warnings: parsed.warnings,
resolved_count: resolved,
stream_count: streams,
unresolved_count: unresolved,
cover_url: None,
})
}
fn looks_relative(loc: &str) -> bool {
!(loc.contains("://")
|| loc.starts_with('\\')
|| loc.starts_with('/')
|| loc.get(1..2) == Some(":"))
}
/// ASCII-folded, lowercased, alphanumeric-only matching key — the same
/// normalization the frontend's local-match fallback uses.
fn norm_text(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
/// Local-library track search for the unresolved-row repair panel ("Find
/// match…"). Word-wise normalized substring match over artist/title/album,
/// title hits first. Read-only, bounded to 30 rows.
#[tauri::command]
pub async fn library_track_search(
lib: State<'_, LibraryState>,
query: String,
) -> Result<Vec<library::TrackIndexRow>, String> {
let words: Vec<String> = query
.split_whitespace()
.map(norm_text)
.filter(|w| !w.is_empty())
.collect();
if words.is_empty() {
return Ok(Vec::new());
}
let l = lib.library.clone();
let index = tauri::async_runtime::spawn_blocking(move || l.track_index())
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut scored: Vec<(u32, library::TrackIndexRow)> = index
.into_iter()
.filter_map(|row| {
let title = norm_text(&row.title);
let hay = format!("{}{}{}", norm_text(&row.artist), &title, norm_text(&row.album));
if !words.iter().all(|w| hay.contains(w.as_str())) {
return None;
}
let joined = words.concat();
let score = if title == joined {
0
} else if title.starts_with(joined.as_str()) {
1
} else if words.iter().all(|w| title.contains(w.as_str())) {
2
} else {
3
};
Some((score, row))
})
.collect();
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.title.cmp(&b.1.title)));
Ok(scored.into_iter().take(30).map(|(_, row)| row).collect())
}
// ---- export ----------------------------------------------------------------
/// Export a playlist through a save dialog. `format` ∈ m3u8 | xspf | csv |
/// json. Returns the written path, or None when the dialog was cancelled.
#[tauri::command]
pub async fn playlist_export(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
format: String,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let l = lib.library.clone();
let meta = tauri::async_runtime::spawn_blocking(move || l.playlist_meta(id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?
.ok_or("Playlist not found")?;
let l = lib.library.clone();
let items = tauri::async_runtime::spawn_blocking(move || l.playlist_items(id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (ext, filter_name): (&str, &str) = match format.as_str() {
"m3u8" => ("m3u8", "M3U8 playlist"),
"xspf" => ("xspf", "XSPF playlist"),
"pls" => ("pls", "PLS playlist"),
"csv" => ("csv", "CSV"),
"json" => ("json", "SoulStream playlist JSON"),
other => return Err(format!("Unknown export format {other}")),
};
let safe_name: String = meta
.name
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_file_name(format!("{safe_name}.{ext}"))
.add_filter(filter_name, &[ext])
.save_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(dest) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let export_items: Vec<ExportItem<'_>> = items
.iter()
.map(|i| ExportItem {
title: &i.title,
artist: &i.artist,
album: i.album.as_deref(),
duration_secs: i.duration_secs,
path: i.path.as_deref(),
start_ms: i.start_ms,
source_line: i.source_line.as_deref(),
kind: &i.kind,
note: i.note.as_deref(),
})
.collect();
let body = match format.as_str() {
"m3u8" => playlist_formats::write_m3u8(&export_items),
"xspf" => playlist_formats::write_xspf(&meta.name, meta.description.as_deref(), &export_items),
"pls" => playlist_formats::write_pls(&export_items),
"csv" => playlist_formats::write_csv(&meta.name, &export_items),
_ => playlist_formats::write_soulstream_json(
&meta.name,
meta.description.as_deref(),
&meta.kind,
&export_items,
),
};
// Write beside the destination, then move into place — a failed write
// never leaves a torn file under the chosen name. rename on Windows
// replaces an existing destination, so there is no unlink window.
let dest_path = PathBuf::from(&dest);
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let temp = dest_path.with_extension(format!("{ext}.writing"));
std::fs::write(&temp, body.as_bytes()).map_err(|e| e.to_string())?;
std::fs::rename(&temp, &dest_path).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
Ok(Some(dest))
}