forked from Keelrleek/SoulStream
1645 lines
60 KiB
Rust
1645 lines
60 KiB
Rust
//! 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))
|
||
}
|
||
|