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

+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 {