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
+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
|
||||
|
||||
Reference in new issue
Block a user