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