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

+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