playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+6
View File
@@ -29,6 +29,11 @@ image.workspace = true
tokio.workspace = true
base64 = "0.22"
# XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs
# or external entities, which is exactly the safety posture the importer wants.
quick-xml = "0.37"
# LRCLIB lyrics client (lyrics.rs). Already in the tree via the metadata crate.
reqwest = { version = "0.12", features = ["json"] }
keyring = { version = "3", features = ["windows-native"] }
sysinfo = "0.33"
soulseek-proto = { path = "../crates/soulseek-proto" }
@@ -37,6 +42,7 @@ player = { path = "../crates/player" }
tagger = { path = "../crates/tagger" }
metadata = { path = "../crates/metadata" }
library = { path = "../crates/library" }
audio-formats = { path = "../crates/audio-formats" }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [
+29 -109
View File
@@ -143,6 +143,9 @@ pub async fn set_settings(
volume_normalization: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
let library_folder_moved = download_dir
.as_deref()
.is_some_and(|d| Some(d) != cfg.download_dir.as_deref());
if let Some(d) = download_dir {
cfg.download_dir = Some(d);
}
@@ -208,6 +211,25 @@ pub async fn set_settings(
}
config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than
// at the next launch, so what the shelves show is what the folder holds —
// the old behaviour left the previous folder's albums standing forever,
// and emptying the new folder could not remove them.
if library_folder_moved {
let app2 = app.clone();
let root = config::download_dir(&app, &cfg);
tauri::async_runtime::spawn(async move {
let lib = app2.state::<crate::pipeline::LibraryState>().library.clone();
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
log::info!("library folder changed: dropped {dropped} tracks from the old folder");
}
let _ = crate::pipeline::rescan_library(app2.clone()).await;
let _ = app2.emit("soulseek:library-changed", ());
});
}
// Tighter limits take effect right away, not at the next stream/startup.
if retention_changed {
let app2 = app.clone();
@@ -1263,10 +1285,13 @@ pub struct CatalogHeroPreviewResult {
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16;
/// The CHOSEN hero per artist — a decision about this library, not a render
/// cache — so it travels with the library. (The upscaled results keyed by URL
/// and recipe stay in the machine's cache dir: they regenerate cheaply and
/// would otherwise put gigabytes of derived pixels on the drive.)
fn artist_hero_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_data_dir()
let cfg = config::load(app);
let dir = config::ensure_library_data_dir(app, &cfg)
.map_err(|e| e.to_string())?
.join("artist-heroes");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -4366,7 +4391,7 @@ pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> R
Ok(())
}
// ---- playlists ----
// ---- playlists live in src-tauri/src/playlists.rs ----
fn now_secs() -> i64 {
std::time::SystemTime::now()
@@ -4375,111 +4400,6 @@ fn now_secs() -> i64 {
.unwrap_or(0)
}
#[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())?
}
#[tauri::command]
pub async fn create_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
name: String,
) -> Result<i64, String> {
let l = lib.library.clone();
let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs()))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(id)
}
#[tauri::command]
pub async fn delete_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
id: i64,
) -> Result<(), String> {
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())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(())
}
#[tauri::command]
pub async fn playlist_tracks(
lib: State<'_, LibraryState>,
id: i64,
) -> Result<Vec<library::PlaylistTrack>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[derive(serde::Deserialize)]
pub struct TrackInput {
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub path: String,
pub art_path: Option<String>,
}
#[tauri::command]
pub async fn add_to_playlist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
playlist_id: i64,
tracks: Vec<TrackInput>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
for t in tracks {
l.add_to_playlist(
playlist_id,
&library::PlaylistTrack {
position: 0, // assigned by insert
title: t.title,
artist: t.artist,
duration_secs: t.duration_secs,
path: t.path,
art_path: t.art_path,
},
)?;
}
Ok(())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:playlists-changed", ());
Ok(())
}
#[tauri::command]
pub async fn remove_from_playlist(
lib: State<'_, LibraryState>,
playlist_id: i64,
position: i64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(())
}
/// Compute (or fetch cached) waveform peaks for a local audio file.
/// Returns `buckets` normalized 0..1 values, or an empty array on failure.
#[tauri::command]
+142
View File
@@ -258,6 +258,148 @@ pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
music.join("SoulStream")
}
/// The folder holding everything the app knows ABOUT this library — the
/// database, the review session, the artwork it has chosen and cached.
///
/// It lives inside the library folder because the library has to be portable:
/// carry the drive to another machine and the records, the decisions and the
/// covers arrive together. State kept in AppData does not travel, and an
/// import saved at one machine and opened at another lost every choice it had
/// made while the audio arrived intact.
///
/// AppData keeps only what is about the APP on THIS machine: credentials, the
/// audio device, window state, and caches of REMOTE data (`net-cache-v1`,
/// `stream-cache`, the GPU render caches) which are regenerable and would
/// otherwise bloat the drive.
///
/// The name starts with a dot so it sorts out of the way, and the share index
/// skips it by name as well as by only ever indexing audio.
pub const LIBRARY_DATA_DIRNAME: &str = ".soulstream";
pub fn library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
download_dir(app, cfg).join(LIBRARY_DATA_DIRNAME)
}
/// `library_data_dir`, created if missing. Errors are the caller's to handle:
/// with the drive unplugged this cannot succeed, and inventing a fallback in
/// AppData is exactly the split-brain this whole change removes.
pub fn ensure_library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> std::io::Result<PathBuf> {
let dir = library_data_dir(app, cfg);
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// Everything that used to live in AppData and describes the library, moved
/// once into the library folder. Ordered by how much it hurts to lose.
///
/// `(appdata_relative_name, is_dir)` — the name is kept identical on the far
/// side so a migrated folder is recognisable, and so a half-finished migration
/// can be resumed by simply running again.
const MIGRATED_LIBRARY_STATE: &[(&str, bool)] = &[
("library.db", false),
("import-session.json", false),
("import-art", true),
("art", true),
("artist-identity-v1", true),
("artist-heroes", true),
("loudness-cache.json", false),
];
/// Move library state out of AppData and into the library folder, once.
///
/// Rules, in order of importance:
/// - A destination that already exists WINS and the source is left alone.
/// The library folder is the truth; a stale AppData copy must never
/// overwrite it, and running this twice must be a no-op.
/// - Nothing is deleted until the copy is verified in place. A rename across
/// volumes fails on Windows, so this is copy-then-remove, and the remove is
/// skipped if anything went wrong — a leftover AppData copy is harmless,
/// a half-moved database is not.
/// - The SQLite sidecars (`-wal`, `-shm`) travel WITH the database. Moving
/// `library.db` alone can discard committed transactions still in the WAL.
///
/// Returns the names actually moved, for the log.
pub fn migrate_library_state_into_library(app: &tauri::AppHandle, cfg: &AppConfig) -> Vec<String> {
let Ok(dest_root) = ensure_library_data_dir(app, cfg) else {
return Vec::new();
};
let Ok(src_root) = app.path().app_data_dir() else {
return Vec::new();
};
let mut moved = Vec::new();
for (name, is_dir) in MIGRATED_LIBRARY_STATE {
let src = src_root.join(name);
let dest = dest_root.join(name);
if dest.exists() || !src.exists() {
continue;
}
let ok = if *is_dir {
copy_dir_all(&src, &dest).is_ok()
} else {
// The database's write-ahead log is part of the database.
let mut all = std::fs::copy(&src, &dest).is_ok();
for suffix in ["-wal", "-shm"] {
let extra = src_root.join(format!("{name}{suffix}"));
if extra.exists() {
all &= std::fs::copy(&extra, dest_root.join(format!("{name}{suffix}"))).is_ok();
}
}
all
};
if !ok {
log::warn!("library migration: could not move {name} — leaving it in AppData");
continue;
}
// Verified across: retire the original rather than delete it outright,
// so a mistake here is recoverable by hand.
let retired = src_root.join(format!("{name}.moved-to-library"));
let _ = std::fs::remove_dir_all(&retired);
let _ = std::fs::remove_file(&retired);
if std::fs::rename(&src, &retired).is_ok() {
for suffix in ["-wal", "-shm"] {
let extra = src_root.join(format!("{name}{suffix}"));
let _ = std::fs::rename(&extra, src_root.join(format!("{name}{suffix}.moved-to-library")));
}
}
moved.push((*name).to_string());
}
moved
}
fn copy_dir_all(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let to = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &to)?;
} else {
std::fs::copy(entry.path(), to)?;
}
}
Ok(())
}
/// Where an import writes. The chosen destination, or — when none has been
/// chosen — the library folder itself.
///
/// These were two separately-nullable settings that both mean "the library",
/// and `library_dir` being null silently disabled importing: apply refused
/// with "no library folder chosen yet" and the picker's save-and-bank returned
/// without doing anything or saying so. There is no sensible third answer —
/// music imported into the library goes into the library folder — so an unset
/// destination now means exactly that instead of meaning "refuse".
///
/// A destination OUTSIDE the library folder is still honoured if explicitly
/// set, but it is a strange thing to want: `prune_outside_root` will drop
/// those rows again at the next launch, because they are not in the library.
pub fn import_dest_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
match cfg.library_dir.as_deref().map(str::trim) {
Some(d) if !d.is_empty() => PathBuf::from(d),
_ => download_dir(app, cfg),
}
}
pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}";
pub fn library_layout(cfg: &AppConfig) -> &str {
+499 -33
View File
@@ -20,7 +20,7 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use tauri::{Emitter, State};
use crate::config;
use crate::pipeline::{self, LibraryState};
@@ -368,9 +368,82 @@ pub fn import_session_clear(app: tauri::AppHandle) -> Result<(), String> {
}
}
/// Where the source folder sits RELATIVE to the library folder, when that can
/// be said at all. Saved beside the absolute path so a session can find its
/// source again after the drive is carried somewhere else.
///
/// "I moved the whole folder" is the case this serves: source and library keep
/// their arrangement, only the prefix changes. A source on a different drive
/// from the library has no relative form and gets `None` — its absolute path
/// is the only thing that could ever have identified it.
#[tauri::command]
pub fn import_portable_root(app: tauri::AppHandle, root: String) -> Option<String> {
let cfg = config::load(&app);
relative_to(&config::download_dir(&app, &cfg), Path::new(&root))
}
fn relative_to(from: &Path, to: &Path) -> Option<String> {
let key = |c: &std::path::Component| c.as_os_str().to_string_lossy().to_lowercase();
let (a, b): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
// Different drives (or one relative, one not) share no prefix worth having.
if a.first().map(key) != b.first().map(key) {
return None;
}
let common = a
.iter()
.zip(b.iter())
.take_while(|(x, y)| key(x) == key(y))
.count();
let mut out = PathBuf::new();
for _ in common..a.len() {
out.push("..");
}
for c in &b[common..] {
out.push(c.as_os_str());
}
Some(out.to_string_lossy().into_owned())
}
/// The source folder this session was reviewing, as it exists NOW.
///
/// Tries the path as saved first — nothing has moved in the common case. Then
/// the relative form against the current library folder, which is what makes a
/// review started at one machine resumable at another. `None` means the folder
/// is genuinely not there and only the user can say where it went.
#[tauri::command]
pub fn import_resolve_root(
app: tauri::AppHandle,
saved: Option<String>,
rel: Option<String>,
) -> Option<String> {
if let Some(s) = saved.as_deref() {
if Path::new(s).is_dir() {
return Some(s.to_string());
}
}
let rel = rel?;
let cfg = config::load(&app);
let mut candidate = config::download_dir(&app, &cfg);
for part in rel.split(['/', '\\']) {
match part {
"" | "." => {}
".." => {
candidate.pop();
}
p => candidate.push(p),
}
}
candidate
.is_dir()
.then(|| candidate.to_string_lossy().into_owned())
}
/// In the LIBRARY folder, not AppData: an import saved at one machine and
/// carried to another used to arrive with the audio intact and every decision
/// gone, because the review's session never left the machine that made it.
fn session_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let cfg = config::load(app);
let dir = config::ensure_library_data_dir(app, &cfg).map_err(|e| e.to_string())?;
Ok(dir.join("import-session.json"))
}
@@ -394,20 +467,19 @@ pub struct ImportDest {
#[tauri::command]
pub fn import_get_dest(app: tauri::AppHandle) -> Result<ImportDest, String> {
let cfg = config::load(&app);
let suggested = if cfg.library_dir.is_none() {
Some(config::download_dir(&app, &cfg).to_string_lossy().into_owned())
} else {
None
};
let free_bytes = cfg
// `dir` is the destination that WILL be written to, never null. It used to
// report only an explicitly-chosen folder, so a null read as "no
// destination" and every import path disabled itself — the review's Import
// button greyed out and the picker's save-and-bank returned in silence.
let dir = config::import_dest_dir(&app, &cfg);
let suggested = cfg
.library_dir
.as_deref()
.or(suggested.as_deref())
.and_then(|d| free_space_of(Path::new(d)));
.map_or_else(|| Some(dir.to_string_lossy().into_owned()), |_| None);
Ok(ImportDest {
dir: cfg.library_dir.clone(),
free_bytes: free_space_of(&dir),
dir: Some(dir.to_string_lossy().into_owned()),
suggested,
free_bytes,
layout: config::library_layout(&cfg).to_string(),
})
}
@@ -484,13 +556,62 @@ pub async fn import_local_art(
app: tauri::AppHandle,
source_root: String,
rel: String,
parts: Option<Vec<String>>,
) -> Result<Option<LocalArt>, String> {
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel))
let parts = parts.unwrap_or_default();
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel, &parts))
.await
.map_err(|e| e.to_string())
}
fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<LocalArt> {
/// One folder's audio, sorted, and NOT what is underneath it.
fn audio_in_dir(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut here: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && audio_formats::is_audio_path(p))
.collect();
here.sort();
out.append(&mut here);
}
/// The audio a record actually OWNS: its own folder, plus the part folders the
/// review named — in part order, sorted within each part. The same walk apply
/// does, and for the same reason ([`ApplyAlbum::parts`]).
///
/// It is deliberately not recursive. A recursive walk is how the picker came to
/// show a NEIGHBOUR's cover as "yours": an artist folder holding one loose file
/// beside its albums — `Sweet\` is a single 52 MB "So Serene" rip, while
/// `Sweet\Sweet Valley - SV [2013]\` is a separate 12-track record — collected
/// both sets, and sorting the full paths put the subfolder first ('V' 0x56
/// before 'o' 0x6F), so the record was shown the other record's artwork. That
/// picture also became the record's `art_dhash`, which is what the discography
/// pass compares against the Cover Art Archive.
fn own_audio_files(dir: &Path, parts: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
if parts.is_empty() {
audio_in_dir(dir, &mut out);
} else {
for part in parts {
// An empty entry is the record's own folder — a container holding
// loose audio beside its parts.
if part.is_empty() {
audio_in_dir(dir, &mut out);
} else {
audio_in_dir(&dir.join(part), &mut out);
}
}
}
out
}
fn local_art_of(
app: &tauri::AppHandle,
source_root: &str,
rel: &str,
parts: &[String],
) -> Option<LocalArt> {
let dir = Path::new(source_root).join(rel);
if !dir.is_dir() {
return None;
@@ -499,14 +620,22 @@ fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<
// Embedded front cover of the first files that carry one — materialized
// into app data so the webview can read it (the audio file itself can't
// be handed to an <img>).
let mut files: Vec<PathBuf> = Vec::new();
pipeline::collect_audio_files(&dir, &mut files, 0);
files.sort();
let files = own_audio_files(&dir, parts);
for f in files.iter().take(5) {
let Ok(Some(bytes)) = tagger::extract_art(f) else { continue };
let Ok(img) = image::load_from_memory(&bytes) else { continue };
let (w, h) = (img.width(), img.height());
let cache_dir = app.path().app_data_dir().ok()?.join("import-art");
// In the library folder: these are the covers the review is choosing
// BETWEEN, and a choice made at one machine has to still be there when
// the drive is opened at another.
// v2: entries under the old name were extracted by a RECURSIVE walk, so
// a record sharing a folder with another one cached the neighbour's
// cover — and `if !target.exists()` below means a stale entry is never
// rewritten. Versioning the directory retires all of them at once,
// which is what `cover-logos-v3` and `artist-identity-v1` do.
let cache_dir = config::ensure_library_data_dir(app, &config::load(app))
.ok()?
.join("import-art-v2");
std::fs::create_dir_all(&cache_dir).ok()?;
// Stable name per rel so reopening a record reuses the extraction.
let mut hash: u64 = 0xcbf29ce484222325;
@@ -673,6 +802,92 @@ pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf {
.join(pipeline::safe_component(&flat))
}
/// Where one of a found copy's files lands in the pen. Shared by the download
/// and by the completeness check below, so the two can never disagree about
/// which file on disk is which track.
fn pen_target(base: &Path, remote_path: &str) -> PathBuf {
let leaf = remote_path.rsplit('\\').next().unwrap_or(remote_path);
base.join(pipeline::safe_component(leaf))
}
/// Which of a found copy's files the pen does not (fully) hold, by index into
/// `files`.
///
/// Transfers do not survive the app; the files they wrote do. A restored
/// audition therefore LOOKS live and is not, and its arrived-count is a
/// running total kept by events that stopped when the process did — after a
/// crash, a power cut or a restart it is a number about a session that no
/// longer exists. The pen is the only witness left, so it is what gets asked.
///
/// A SHORT file counts as missing: a transfer killed mid-write leaves a
/// partial behind, and an existence check would call that track arrived and
/// go on to import a truncated one.
#[tauri::command]
pub async fn import_audition_missing(
app: tauri::AppHandle,
rel: String,
files: Vec<AuditionFile>,
) -> Result<Vec<usize>, String> {
tauri::async_runtime::spawn_blocking(move || {
let cfg = config::load(&app);
let base = audition_dir(&config::download_dir(&app, &cfg), &rel);
missing_in_pen(&base, &files)
})
.await
.map_err(|e| e.to_string())
}
/// Empty one record's pen, and report the bytes it freed.
///
/// Called when the review ABANDONS a peer. Switching cancels that peer's
/// transfers, but it cannot un-write the files they already delivered, and the
/// pen is keyed by record rather than by peer — so an abandoned copy sat there
/// until the whole session was cleared, 189 MB of a stranger's Centipede Hz
/// among them. Apply no longer files those (it takes the chosen copy's own
/// files by name), so this is about disk, not correctness.
///
/// The path is DERIVED from `rel` here, exactly as the download and the
/// apply-time move derive it. Nothing outside the pen can be named, whatever
/// the caller passes.
#[tauri::command]
pub async fn import_audition_clear(app: tauri::AppHandle, rel: String) -> Result<u64, String> {
tauri::async_runtime::spawn_blocking(move || {
let cfg = config::load(&app);
let dir = audition_dir(&config::download_dir(&app, &cfg), &rel);
if !dir.is_dir() {
return Ok(0);
}
let freed: u64 = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum();
std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(freed)
})
.await
.map_err(|e| e.to_string())?
}
/// The rule itself, without an app handle so it can be tested. A file the pen
/// does not hold is missing; so is one it holds only part of. A size the peer
/// never stated (0) cannot be checked against, and a file that is there is
/// taken at its word rather than called missing forever.
fn missing_in_pen(base: &Path, files: &[AuditionFile]) -> Vec<usize> {
files
.iter()
.enumerate()
.filter(|(_, f)| match std::fs::metadata(pen_target(base, &f.path)) {
Ok(m) => f.size > 0 && m.len() < f.size,
Err(_) => true,
})
.map(|(i, _)| i)
.collect()
}
/// Download a found album copy into `(Cache)\_audition\<rel>\` for the
/// version comparison — the WHOLE album (his call: keeping it later is a
/// move, not a re-download, and one search's worth of requests).
@@ -701,8 +916,7 @@ pub async fn import_audition(
std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
let mut ids = Vec::with_capacity(files.len());
for (i, f) in files.iter().enumerate() {
let leaf = f.path.rsplit('\\').next().unwrap_or(&f.path);
let target = base.join(pipeline::safe_component(leaf));
let target = pen_target(&base, &f.path);
// Claimed BEFORE the transfer task is spawned: it emits Connecting
// immediately, and an event whose id is not yet registered is one the
// UI throws away — and worse, one commands.rs routes down the ordinary
@@ -755,13 +969,67 @@ pub async fn import_probe_file(path: String) -> Result<ProbeResult, String> {
/// opened — never a batch (decision 12). Deezer down the cleaned try-list,
/// MusicBrainz when Deezer's answer is absent or wrong; the crate holds the
/// whole flow (metadata::identify_album). Read-only.
///
/// `title` is what the record calls ITSELF (the tag majority); `folder` is the
/// folder it sits in, asked after it as the second witness for a record whose
/// tags are wrong (metadata::identify_titles).
///
/// The rest is the record's SHAPE, which is what the discography pass compares
/// when no phrase can find it: how many files it has, what they are called, the
/// year, and — read here rather than shipped over IPC, because a master cover
/// can be 8 MB — the perceptual hash of its own embedded artwork.
#[tauri::command]
pub async fn import_identify(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist: String,
title: String,
folder: Option<String>,
tracks: Option<usize>,
track_titles: Option<Vec<String>>,
year: Option<u32>,
source_root: Option<String>,
rel: Option<String>,
parts: Option<Vec<String>>,
) -> Result<metadata::IdentifyResult, String> {
Ok(state.metadata.identify_album(&artist, &title).await)
// The record's OWN parts, so the hash is its own cover — a record sharing a
// folder with another one used to hash the neighbour's.
let parts = parts.unwrap_or_default();
let art_dhash = match (source_root.as_deref(), rel.as_deref()) {
(Some(root), Some(rel)) => local_art_dhash(&app, root, rel, &parts).await,
_ => None,
};
let shape = metadata::RecordShape {
tracks,
track_titles: track_titles.unwrap_or_default(),
year,
art_dhash,
};
Ok(state
.metadata
.identify_album(&artist, &title, folder.as_deref(), &shape)
.await)
}
/// The perceptual hash of a record's OWN cover, for comparison against an
/// artist's discography. Reuses `local_art_of`, so it reads the extraction that
/// the artwork panel already cached rather than decoding the audio again.
async fn local_art_dhash(
app: &tauri::AppHandle,
source_root: &str,
rel: &str,
parts: &[String],
) -> Option<u64> {
let art = {
let app = app.clone();
let (root, rel) = (source_root.to_string(), rel.to_string());
let parts = parts.to_vec();
tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &root, &rel, &parts))
.await
.ok()??
};
let bytes = tokio::fs::read(&art.path).await.ok()?;
metadata::cover_dhash(&bytes)
}
/// The artwork panel's data: reference + candidates with decoded sizes and
@@ -835,6 +1103,15 @@ pub struct ApplyChoices {
/// fewer than this means the transfer never finished, and importing it
/// would file a truncated album — apply refuses instead.
pub audio_tracks: Option<usize>,
/// The chosen copy's own files, as the peer named them. The pen is keyed
/// by RECORD, not by peer, so a record that changed peers mid-audition has
/// TWO copies in one folder: switching cancels the old transfers but
/// cannot un-write the files they already delivered. Taking "every audio
/// file in the pen" then files both — Centipede Hz landed as 17 tracks,
/// deletia's 11 plus the 6 smash2 got through before it was dropped.
///
/// None (an older session, or "yours") keeps the previous behaviour.
pub audio_files: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
@@ -880,9 +1157,9 @@ pub async fn import_apply(
albums: Vec<ApplyAlbum>,
) -> Result<ApplyReport, String> {
let cfg = config::load(&app);
let Some(dest_root) = cfg.library_dir.clone().map(PathBuf::from) else {
return Err("no library folder chosen yet".into());
};
// An unset destination means the library folder, not a refusal — see
// `config::import_dest_dir`.
let dest_root = config::import_dest_dir(&app, &cfg);
let layout = config::library_layout(&cfg).to_string();
let embed_max_edge = config::embed_art_max_edge(&cfg);
let replace_embedded = config::replace_embedded_art(&cfg);
@@ -1023,16 +1300,43 @@ fn apply_one(
// match the one the review displayed, because `track_titles` is
// positional: disagree here and every title past the first part lands on
// the wrong file. A found copy overrides this — the pen is flat.
// Which disc each file belongs to, aligned with `files`. Empty (all None)
// for anything that is not a folded release — there, the disc is whatever
// the file's own tag says, read back after the copy.
let mut discs: Vec<Option<u32>> = Vec::new();
let mut files: Vec<PathBuf> = if take_theirs || a.parts.is_empty() {
let mut f = audio_files_in(&src_dir);
// A found copy is the files the REVIEW chose, not whatever the folder
// happens to hold. Same derivation as the download used, so a name the
// peer sent maps to exactly the file it wrote. Sorting stays where it
// was: `track_titles` is positional against this order.
if take_theirs {
if let Some(names) = a.choices.as_ref().and_then(|c| c.audio_files.as_ref()) {
if !names.is_empty() {
let chosen: std::collections::HashSet<PathBuf> =
names.iter().map(|n| pen_target(&pen_dir, n)).collect();
f.retain(|p| chosen.contains(p));
}
}
}
f.sort();
f
} else {
let mut out = Vec::new();
for part in &a.parts {
for (n, part) in a.parts.iter().enumerate() {
let dir = if part.is_empty() { src_dir.clone() } else { src_dir.join(part) };
let mut f = audio_files_in(&dir);
f.sort();
// The parts ARE the discs, in the order the review joined them.
// Every part numbers its own tracks from 1, so without this the
// flattened album reads as eleven track-1s in alphabetical order —
// which is exactly what Analord's eleven volumes did.
//
// ONE part is not a multi-disc release: it is a container folder
// whose tracks live a level down, and numbering it "disc 1" would
// put a disc heading on an ordinary album.
let disc = if a.parts.len() > 1 { Some(n as u32 + 1) } else { None };
discs.extend(std::iter::repeat(disc).take(f.len()));
out.append(&mut f);
}
out
@@ -1217,16 +1521,24 @@ fn apply_one(
// chosen cover embeds at the same bounded size downloads use, and
// replaces an existing picture when config says so — an explicit
// pick in the review is the strongest consent that config models.
if let Some(ch) = &a.choices {
// The disc is STRUCTURE, not a review choice: it is what the record is
// made OF, and a record nobody opened still has to come out readable.
// So it is written even when there are no choices at all — and into
// the FILE, because the folder that carried the answer is gone the
// moment the parts are filed flat.
let disc = discs.get(i).copied().flatten();
if a.choices.is_some() || disc.is_some() {
let ch = a.choices.as_ref();
let write = tagger::TagWrite {
artist: ch.artist.clone(),
artist: ch.and_then(|c| c.artist.clone()),
album_artist: None,
album: ch.album.clone(),
album: ch.and_then(|c| c.album.clone()),
title: track_titles.and_then(|t| t.get(i).cloned().flatten()),
track_no: None,
year: ch.year,
label: ch.label.clone(),
genre: ch.genre.clone(),
disc_no: disc,
year: ch.and_then(|c| c.year),
label: ch.and_then(|c| c.label.clone()),
genre: ch.and_then(|c| c.genre.clone()),
};
let embed = embed_art.as_deref();
if let Err(e) = tagger::write_tags(
@@ -1249,6 +1561,9 @@ fn apply_one(
library
.upsert_track(
album_id,
// The fold's answer first: it is the structure we just imposed
// on this record. Everything else takes the file at its word.
disc.or(s.disc_no),
s.track_no,
&title,
&artist,
@@ -1328,6 +1643,39 @@ fn apply_one(
mod tests {
use super::*;
/// What the pen holds is what an audition actually got — and a PARTIAL
/// file is not an arrival. A transfer killed mid-write (the app closing, a
/// power cut) leaves a short file behind, and an existence check would
/// call that track arrived, stop asking for it, and go on to import a
/// truncated one. This is the check that unsticks a record whose
/// transfers died with the process that was watching them.
#[test]
fn the_pen_counts_a_short_file_as_missing_not_arrived() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let f = |name: &str, size: u64| AuditionFile {
path: format!("@peer\\Music\\Album\\{name}"),
size,
};
let files = vec![
f("01 whole.flac", 10),
f("02 partial.flac", 10),
f("03 never came.flac", 10),
f("04 size unstated.flac", 0),
];
std::fs::write(base.join("01 whole.flac"), b"0123456789").unwrap();
std::fs::write(base.join("02 partial.flac"), b"012").unwrap();
// 03 never arrived at all.
std::fs::write(base.join("04 size unstated.flac"), b"anything").unwrap();
assert_eq!(
missing_in_pen(base, &files),
vec![1, 2],
"the partial and the absent are owed; the whole one and the \
unstated-size one are not"
);
}
/// A record laid out the way apply sees one: the user's folder, the
/// audition pen beside the download root, and a library root to file
/// into. Files carry distinct bytes so "which version landed" is a
@@ -1378,6 +1726,7 @@ mod tests {
track_titles: None,
audio: Some(audio.into()),
audio_tracks: want,
audio_files: None,
}
}
@@ -1422,6 +1771,123 @@ mod tests {
out
}
/// A folded release is ONE album made of disc folders, and every folder
/// numbers its tracks from 1. The disc has to survive the flattening or
/// the album becomes unreadable: eleven volumes of Analord filed as one
/// record put eleven "track 1"s at the top of the list in alphabetical
/// order, with nothing anywhere to say which volume any of them was from.
#[test]
fn a_folded_release_keeps_its_discs() {
let b = bed(0);
for (part, names) in [("CD1", ["01 one.mp3", "02 two.mp3"]), ("CD2", ["01 three.mp3", "02 four.mp3"])] {
let dir = b.src_root.join(&b.rel).join(part);
std::fs::create_dir_all(&dir).unwrap();
for n in names {
std::fs::write(dir.join(n), format!("{part} {n}")).unwrap();
}
}
let mut a = album(&b, None);
a.parts = vec!["CD1".into(), "CD2".into()];
let (tracks, _, _) = run(&b, &a).unwrap();
assert_eq!(tracks, 4);
let album_id = b
.library
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
let rows = b.library.album_tracks(album_id).unwrap();
assert_eq!(
rows.iter().map(|t| t.disc_no).collect::<Vec<_>>(),
vec![Some(1), Some(1), Some(2), Some(2)],
"disc-major order, both discs numbered in the order the fold joined them"
);
// A single part is a container folder, not a two-disc set — numbering
// it would put a disc heading on an ordinary album.
let mut solo = album(&b, None);
solo.parts = vec!["CD1".into()];
let b2 = bed(0);
let dir = b2.src_root.join(&b2.rel).join("CD1");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("01 one.mp3"), "only").unwrap();
run(&b2, &solo).unwrap();
let solo_id = b2
.library
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
assert!(
b2.library.album_tracks(solo_id).unwrap().iter().all(|t| t.disc_no.is_none()),
"one part is not a disc"
);
}
/// The pen path is DERIVED from the record, never passed in — and the
/// record is a folder name off somebody's disk, so it can contain
/// anything. `import_audition_clear` deletes this directory, so "it always
/// lands inside the cache, and is always exactly one folder deep" is a
/// property worth holding on to rather than assuming.
#[test]
fn a_records_pen_can_never_name_anything_outside_the_cache() {
let root = Path::new("D:\\Music\\Library");
let pen = root.join("(Cache)").join("_audition");
for rel in [
"..\\..\\Windows\\System32",
"../../../etc/passwd",
"C:\\Windows",
"Artist\\Album",
".",
"",
] {
let dir = audition_dir(root, rel);
assert!(dir.starts_with(&pen), "{rel:?} escaped the pen: {dir:?}");
assert_eq!(
dir.components().count(),
pen.components().count() + 1,
"{rel:?} is not exactly one folder inside the pen: {dir:?}"
);
}
}
/// A record that changed peers mid-audition has TWO copies in one pen. The
/// switch cancels the abandoned peer's transfers, but it cannot un-write
/// the files they already delivered, and the pen is keyed by RECORD rather
/// than by peer. Taking "every audio file in the folder" filed both:
/// Centipede Hz landed as 17 tracks — deletia's 11 plus the 6 smash2 got
/// through before it was dropped — each duplicate under its own peer's
/// naming. The copy the REVIEW chose is the one that imports.
#[test]
fn the_dropped_peers_files_do_not_import_beside_the_copy_that_was_kept() {
let b = bed(2); // the chosen copy: "01 track.flac", "02 track.flac"
let pen = audition_dir(&b.download_root, &b.rel);
// What the abandoned peer left behind: the same music, its own naming.
for n in 1..=2 {
std::fs::write(
pen.join(format!("Weezer-Pinkerton-{n:02}-track-[FLAC 16bit].flac")),
format!("dropped peer {n}"),
)
.unwrap();
}
let mut ch = choices("theirs", Some(2));
ch.audio_files = Some(vec![
"@peer\\Music\\Pinkerton\\01 track.flac".into(),
"@peer\\Music\\Pinkerton\\02 track.flac".into(),
]);
let (tracks, _, _) = run(&b, &album(&b, Some(ch))).unwrap();
assert_eq!(tracks, 2, "only the chosen copy's tracks are filed");
let names: Vec<String> = landed(&b).into_iter().map(|(n, _)| n).collect();
assert!(
names.iter().all(|n| !n.contains("[FLAC 16bit]")),
"the dropped peer's files must not reach the library: {names:?}"
);
assert_eq!(
names.iter().filter(|n| n.ends_with(".flac")).count(),
2,
"eleven-plus-six is what this test exists to prevent: {names:?}"
);
}
/// Re-importing the same record must not copy it again — even though the
/// imported files no longer match their sources byte for byte.
///
@@ -1659,7 +2125,7 @@ mod tests {
.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0)
.unwrap();
b.library
.upsert_track(album_id, None, "Stray", "Weezer", None, None, None, None, Some(5),
.upsert_track(album_id, None, None, "Stray", "Weezer", None, None, None, None, Some(5),
&stray.to_string_lossy(), 0)
.unwrap();
+38 -6
View File
@@ -6,8 +6,11 @@ mod hero_crop;
mod import;
mod hero_detector;
mod loudness;
mod lyrics;
mod pipeline;
mod playback;
mod playlist_formats;
mod playlists;
mod preview;
mod smtc;
mod state;
@@ -93,14 +96,41 @@ pub fn run() {
commands::unfollow_artist,
commands::home_artist_news,
commands::home_warm,
commands::list_playlists,
commands::create_playlist,
commands::delete_playlist,
commands::playlist_tracks,
commands::add_to_playlist,
commands::remove_from_playlist,
playlists::list_playlists,
playlists::create_playlist,
playlists::rename_playlist,
playlists::set_playlist_description,
playlists::set_playlist_pinned,
playlists::duplicate_playlist,
playlists::delete_playlist,
playlists::playlist_items,
playlists::playlist_add_items,
playlists::playlist_remove_items,
playlists::playlist_set_order,
playlists::playlist_relink_item,
playlists::playlist_cover,
playlists::playlist_cover_pick,
playlists::playlist_cover_clear,
playlists::playlist_import_probe,
playlists::playlist_import_folder,
playlists::playlist_import_text,
playlists::playlist_import_commit,
playlists::playlist_export,
playlists::library_track_search,
lyrics::lyrics_get,
lyrics::lyrics_search,
lyrics::lyrics_select,
lyrics::lyrics_set_offset,
lyrics::lyrics_set_auto_sync,
lyrics::lyrics_prepare_instrumental,
lyrics::lyrics_vocal_activity,
lyrics::lyrics_save_lines,
lyrics::lyrics_save_text,
lyrics::lyrics_clear,
commands::catalog_search,
import::import_scan,
import::import_portable_root,
import::import_resolve_root,
import::import_session_get,
import::import_session_set,
import::import_session_clear,
@@ -111,6 +141,8 @@ pub fn run() {
import::import_cover_report,
import::import_local_art,
import::import_audition,
import::import_audition_missing,
import::import_audition_clear,
import::import_probe_file,
pipeline::rescan_library,
commands::catalog_artist,
+12 -5
View File
@@ -101,8 +101,14 @@ pub fn snapshot(app: &tauri::AppHandle) -> (bool, f64, Option<CurrentTrack>) {
)
}
/// Keyed by track path, so it describes THIS library's files and belongs
/// beside them. Measuring a whole library again on another machine is hours
/// of work that the drive was already carrying the answer to.
fn cache_file(app: &tauri::AppHandle) -> Option<PathBuf> {
app.path().app_data_dir().ok().map(|d| d.join(CACHE_FILE))
let cfg = crate::config::load(app);
crate::config::ensure_library_data_dir(app, &cfg)
.ok()
.map(|d| d.join(CACHE_FILE))
}
pub fn init(app: &tauri::AppHandle) {
@@ -947,8 +953,10 @@ mod tests {
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
{
// Measuring means decoding, so this is the PLAYABLE table, not
// the recognised one — an `.opus` or `.wv` here only produces a
// failed decode (or, for WavPack, NaN).
if audio_formats::is_playable_ext(&ext) {
files.push(path);
}
}
@@ -1092,8 +1100,7 @@ mod tests {
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if !matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff")
{
if !audio_formats::is_playable_ext(&ext) {
continue;
}
let artist = path
File diff suppressed because it is too large. Load diff
+90 -20
View File
@@ -11,11 +11,9 @@ use library::Library;
use metadata::{AlbumMeta, MetadataClient};
use tagger::{TagSummary, TagWrite};
// aiff/aif joined for the import tool: the reference library carries 13 AIFF
// tracks that a scan would otherwise silently drop, and "silently" is the
// problem — the album imports incomplete with no warning.
pub(crate) const AUDIO_EXTS: [&str; 10] =
["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"];
// The one table, in `audio-formats`. This used to be a local list that
// disagreed with the share index and the row pruner — the scanner indexed
// `.aiff` and the pruner deleted it on the next startup.
pub struct LibraryState {
pub library: Arc<Library>,
@@ -140,12 +138,53 @@ fn spawn_identity_warmer(app: tauri::AppHandle) {
}
pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
// AppData keeps only what is about the APP on THIS machine — the cache of
// remote provider responses. Everything describing the LIBRARY lives in
// the library folder so it travels with the music.
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
let cfg = crate::config::load(app);
let library_root = crate::config::download_dir(app, &cfg);
let moved = crate::config::migrate_library_state_into_library(app, &cfg);
if !moved.is_empty() {
log::info!("library state moved into the library folder: {}", moved.join(", "));
}
// With the drive unplugged there is no library to open. Fall back to a
// machine-local database rather than refusing to start — the real one
// returns with the drive, and nothing here writes into the wrong library
// because every path is compared against the configured root.
let (data_dir, portable) = match crate::config::ensure_library_data_dir(app, &cfg) {
Ok(dir) => (dir, true),
Err(e) => {
log::warn!(
"library folder {} is unavailable ({e}) — running with a local database until it returns",
library_root.display()
);
(app_dir.clone(), false)
}
};
let art_dir = data_dir.join("art");
std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?;
let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?);
if portable {
// The art cache moved with the database, so rows written before the
// move still point into AppData. Re-point them before anything paints.
match library.rewrite_path_prefix(&app_dir.join("art"), &art_dir) {
Ok(n) if n > 0 => log::info!("library: {n} artwork paths follow the art cache into the library folder"),
Err(e) => log::warn!("library: could not re-point artwork paths: {e}"),
_ => {}
}
// And the library itself may have arrived at a new drive letter or on
// a different machine entirely.
match library.rebase_to_root(&library_root) {
Ok(n) if n > 0 => log::info!("library moved: re-based {n} stored paths onto {}", library_root.display()),
Err(e) => log::warn!("library: could not re-base stored paths: {e}"),
_ => {}
}
}
let repair_library = library.clone();
let repair_art_dir = art_dir.clone();
@@ -156,10 +195,13 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
metadata: Arc::new({
let client = MetadataClient::new()
.with_disk_cache(data_dir.join("net-cache-v1"))
// Provider responses are about the WORLD, not this library, and
// they are cheap to refetch — they stay on the machine.
.with_disk_cache(app_dir.join("net-cache-v1"))
// Its own directory, NOT inside the cache above: that one is
// pruned by mtime, and these are verdicts rather than cached
// responses.
// responses. Verdicts about the library's artists are library
// state, so they travel with it.
.with_identity_store(data_dir.join("artist-identity-v1"));
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
// resolution can be compared against `hero_source_probe` — the two
@@ -193,8 +235,33 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
}
}
let state = handle.state::<LibraryState>();
// Before anything asks the disk: the library folder decides what is IN
// the library. Rows from a folder that used to hold it are not "still
// there because their files exist" — they belong to another library.
let lib = state.library.clone();
let root = downloads_dir(&handle);
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
if dropped > 0 {
log::info!("library: dropped {dropped} tracks outside the library folder");
}
}
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await;
// After the prune, every surviving track has a file — so "the folder
// this album lives in" is answerable, and any cover pointing somewhere
// else is another album's. Files move (a re-import into a new layout, a
// drive back at a different letter) and nothing else re-asks.
let lib = state.library.clone();
let art = state.art_dir.clone();
if let Ok(Ok(moved)) =
tauri::async_runtime::spawn_blocking(move || lib.reanchor_album_art(&art)).await
{
if moved > 0 {
log::info!("library repair: re-anchored artwork for {moved} albums");
}
}
let lib = state.library.clone();
if let Ok(Ok(removed)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await
@@ -459,16 +526,15 @@ pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8)
// "(Cache)" is the download staging pen — a completed file
// sitting there is seconds from being organized out, and
// indexing it would pin a library row to a staging path.
if path.file_name().is_some_and(|n| n == "(Cache)") {
// ".soulstream" is the library's own state, not music.
if path
.file_name()
.is_some_and(|n| n == "(Cache)" || n == crate::config::LIBRARY_DATA_DIRNAME)
{
continue;
}
collect_audio_files(&path, out, depth + 1);
} else if path
.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
{
} else if audio_formats::is_audio_path(&path) {
out.push(path);
}
}
@@ -659,6 +725,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
album: summary.album.is_none().then_some(final_album.clone()),
title: summary.title.is_none().then_some(title.clone()),
track_no: summary.track_no.or(parsed.track_no),
// Nothing to add: a downloaded file either carries its disc or has no
// disc to carry, and there is nowhere here to learn one from.
disc_no: None,
year: summary.year.is_none().then(|| final_year).flatten(),
label: None,
genre: None,
@@ -701,6 +770,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
let p = library_path.to_string_lossy().into_owned();
let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned());
let track_no = summary.track_no.or(parsed.track_no);
// Only what the file itself says. There is no filename convention for
// a disc worth guessing from, and a wrong disc reorders an album.
let disc_no = summary.disc_no;
let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps);
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
let size = std::fs::metadata(&library_path).ok().map(|m| m.len());
@@ -724,6 +796,7 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path
}
lib.upsert_track(
album_id,
disc_no,
track_no,
&title2,
&artist,
@@ -853,10 +926,7 @@ pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result<usize, Str
}
pub fn is_audio_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
audio_formats::is_audio_path(path)
}
/// Move a newly completed download into one album directory under the managed
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff