//! 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 { 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, 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, 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, ) -> Result { 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, ) -> 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 { 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, pub album: Option, pub duration_secs: Option, pub path: Option, pub art_path: Option, #[serde(default)] pub start_ms: Option, #[serde(default)] pub source_line: Option, #[serde(default)] pub note: Option, /// Catalog track id for a stream row — the identity anchor (exact id, /// never a name match). #[serde(default)] pub track_id: Option, /// Cover URL for a stream row, which has no local file to read art from. #[serde(default)] pub art_url: Option, } #[tauri::command] pub async fn playlist_add_items( app: tauri::AppHandle, lib: State<'_, LibraryState>, playlist_id: i64, items: Vec, at: Option, ) -> Result { let l = lib.library.clone(); let rows: Vec = 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, ) -> Result { 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, ) -> 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, artist: Option, album: Option, duration_secs: Option, path: Option, art_path: Option, track_id: Option, art_url: Option, ) -> 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, 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, 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, ) -> Result, 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, 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 { 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> { let tiles: Vec = 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, pub duration_secs: Option, pub start_ms: Option, 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, pub path: Option, pub art_path: Option, /// 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, #[serde(default)] pub art_url: Option, /// 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, } #[derive(serde::Serialize)] pub struct ImportDraft { pub source_name: String, pub format: String, pub name: Option, pub description: Option, /// "setlist" when any entry carries a source timestamp. pub kind: String, pub entries: Vec, pub warnings: Vec, 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, } /// 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, 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, 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, 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 = 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 = 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 = 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, 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| -> 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 { 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 { 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 { let fetched = crate::youtube_playlist::fetch(&url).await?; let parsed = crate::youtube_playlist::to_parsed(&fetched); let source = if fetched.author.is_empty() { fetched.title.clone() } else { format!("{} — {}", fetched.author, fetched.title) }; let mut draft = resolve_draft(&lib, parsed, source, None).await?; draft.format = "youtube-playlist".into(); draft.name = Some(fetched.title.clone()); // The first video's thumbnail is the playlist's default face — the same // choice YouTube itself makes. if let Some(first) = fetched.rows.first() { draft.cover_url = Some(format!( "https://i.ytimg.com/vi/{}/maxresdefault.jpg", first.video_id )); } draft.warnings.insert( 0, format!( "{} video{} from the playlist.", fetched.rows.len(), if fetched.rows.len() == 1 { "" } else { "s" } ), ); draft.warnings.extend(fetched.warnings); Ok(draft) } /// Import a playlist from any supported LINK — YouTube, Spotify, Apple /// Music, TIDAL, Deezer — one door, dispatched by host. Each source is /// metadata-only and anonymous; rows ride the same resolve → review → /// commit pipeline as every other import. #[tauri::command] pub async fn playlist_import_link( lib: State<'_, LibraryState>, url: String, ) -> Result { // YouTube keeps its own module (lockup pages, continuations, title // cleaning); everything else is a provider fetch. if crate::youtube_playlist::playlist_id_from_url(&url).is_some() { return playlist_import_youtube_playlist(lib, url).await; } let fetched = crate::provider_playlist::fetch(&url).await?; let parsed = crate::provider_playlist::to_parsed(&fetched); let source = if fetched.author.is_empty() { fetched.title.clone() } else { format!("{} — {}", fetched.author, fetched.title) }; let mut draft = resolve_draft(&lib, parsed, source, None).await?; draft.format = fetched.provider.into(); draft.name = Some(fetched.title.clone()); draft.cover_url = fetched.cover_url.clone(); draft.warnings.insert( 0, format!( "{} track{} from the playlist.", fetched.rows.len(), if fetched.rows.len() == 1 { "" } else { "s" } ), ); draft.warnings.extend(fetched.warnings); Ok(draft) } /// Track links one paste will look up. Each costs a page fetch, so this is a /// politeness ceiling as much as a sanity one. const MAX_PASTED_LINKS: usize = 2000; /// Import a pasted block of TRACK links — the way past a service's own /// playlist ceiling. Spotify's public playlist page stops at 100 tracks, but /// every track has its own public page, and Spotify's app hands over all the /// links at once (select all, copy). Each line is looked up on its own; a /// line that is not a link is read as ordinary "Artist - Title" text, so a /// mixed paste works too. #[tauri::command] pub async fn playlist_import_track_links( app: tauri::AppHandle, lib: State<'_, LibraryState>, text: String, name: Option, ) -> Result { let lines = crate::provider_playlist::pasted_lines(&text); if lines.is_empty() { return Err("Nothing to import in that paste.".into()); } let link_count = lines .iter() .filter(|l| matches!(l, crate::provider_playlist::PastedLine::Link(_))) .count(); if link_count > MAX_PASTED_LINKS { return Err(format!( "That paste holds {link_count} track links — more than the {MAX_PASTED_LINKS} one import takes." )); } // The lookups take a page each, so the modal gets a live count instead of // a frozen button. let emitter = app.clone(); let outcome = crate::provider_playlist::resolve_pasted(lines, move |done, total, waiting| { let _ = emitter.emit( "playlist:link-progress", serde_json::json!({ "done": done, "total": total, "waiting": waiting }), ); }) .await?; let total = outcome.links; let unread = outcome.rows.iter().filter(|r| r.is_none()).count(); let entries: Vec<_> = outcome .rows .into_iter() .flatten() .enumerate() .map(|(i, r)| playlist_formats::ParsedEntry { ordinal: i as u32, title: Some(r.title.clone()), artist: (!r.artist.is_empty()).then(|| r.artist.clone()), album: r.album.clone(), duration_secs: r.duration_secs, location: None, start_ms: None, raw: format!("{} - {}", r.artist, r.title), }) .collect(); if entries.is_empty() { return Err("None of those links could be read.".into()); } let parsed = playlist_formats::ParsedPlaylist { format: "links".into(), title: name.clone(), description: None, entries, warnings: Vec::new(), }; let source = name.clone().unwrap_or_else(|| "Pasted track links".into()); let mut draft = resolve_draft(&lib, parsed, source, None).await?; draft.format = "links".into(); draft.name = name; draft.warnings.insert( 0, format!( "{} track{} read from {total} pasted link{}.", draft.entries.len(), if draft.entries.len() == 1 { "" } else { "s" }, if total == 1 { "" } else { "s" } ), ); // A link the service refused us on is a DIFFERENT failure from a link // that names nothing, and only one of them is worth retrying. let missing = unread.saturating_sub(outcome.throttled_out); if outcome.throttled_out > 0 { draft.warnings.insert( 1, format!( "{} link{} went unread because the service throttled the lookups. \ Paste those again in a few minutes and they will come through.", outcome.throttled_out, if outcome.throttled_out == 1 { "" } else { "s" } ), ); } else if outcome.was_throttled { draft.warnings.insert( 1, "The service throttled the lookups partway; the import slowed down and \ finished anyway." .into(), ); } if missing > 0 { draft.warnings.push(format!( "{missing} line{} named nothing readable and {} left out.", if missing == 1 { "" } else { "s" }, if missing == 1 { "was" } else { "were" } )); } Ok(draft) } /// Commit a reviewed draft as a new playlist, atomically. #[tauri::command] pub async fn playlist_import_commit( app: tauri::AppHandle, lib: State<'_, LibraryState>, name: String, description: Option, kind: String, entries: Vec, cover_url: Option, ) -> Result { 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 = 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> = 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, 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, ) -> Result { 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 = Default::default(); let mut by_base: std::collections::HashMap> = Default::default(); let mut by_tags: std::collections::HashMap<(String, String), Vec> = Default::default(); let mut by_title: std::collections::HashMap> = 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>| -> Option { 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, String> { let words: Vec = 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, 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> = 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)) }