playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user