feat(library): type folder registration and queue scans
cx
This commit is contained in:
11 files changed
+350
-21
No files matched your search
@@ -6603,3 +6603,9 @@ msgstr "ZU KOPIERENDE DATEN"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "Die Migration ist additiv: Sie entfernt keine Favoriten oder Playlists aus einem der Konten."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "Dieser Bibliotheksordner ist deaktiviert."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "Dieser Ordner überschneidet sich mit einem vorhandenen Bibliotheksordner."
|
||||
@@ -2187,3 +2187,9 @@ msgstr "WHAT TO COPY"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "That library folder is disabled."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "That folder overlaps an existing library folder."
|
||||
@@ -6609,3 +6609,9 @@ msgstr "QUÉ COPIAR"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "La migración es aditiva: no elimina favoritos ni playlists de ninguna cuenta."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "Esa carpeta de la biblioteca está deshabilitada."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "Esa carpeta se superpone con una carpeta existente de la biblioteca."
|
||||
@@ -6601,3 +6601,9 @@ msgstr "DONNÉES À COPIER"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "La migration est additive : elle ne supprime aucun favori ni aucune playlist des deux comptes."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "Ce dossier de bibliothèque est désactivé."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "Ce dossier chevauche un dossier de bibliothèque existant."
|
||||
@@ -6584,3 +6584,9 @@ msgstr "コピーする項目"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "移行は追加のみです。どちらのアカウントからもお気に入りやプレイリストは削除されません。"
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "そのライブラリフォルダーは無効です。"
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "そのフォルダーは既存のライブラリフォルダーと重複しています。"
|
||||
@@ -6591,3 +6591,9 @@ msgstr "WAT KOPIËREN"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "De migratie is aanvullend: favorieten en afspeellijsten worden uit geen van beide accounts verwijderd."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "Deze bibliotheekmap is uitgeschakeld."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "Deze map overlapt met een bestaande bibliotheekmap."
|
||||
@@ -6601,3 +6601,9 @@ msgstr "O QUE COPIAR"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "A migração é aditiva: ela não remove favoritos nem playlists de nenhuma conta."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "Essa pasta da biblioteca está desativada."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "Essa pasta se sobrepõe a uma pasta existente da biblioteca."
|
||||
@@ -6622,3 +6622,9 @@ msgstr "ЧТО КОПИРОВАТЬ"
|
||||
|
||||
msgid "Migration is additive: it does not remove favorites or playlists from either account."
|
||||
msgstr "Перенос только добавляет данные: избранное и плейлисты не удаляются ни из одного аккаунта."
|
||||
|
||||
msgid "That library folder is disabled."
|
||||
msgstr "Эта папка медиатеки отключена."
|
||||
|
||||
msgid "That folder overlaps an existing library folder."
|
||||
msgstr "Эта папка пересекается с существующей папкой медиатеки."
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Canonical, typed registration for Local Library roots.
|
||||
|
||||
use crate::{is_network_path, network_fs_label, LibraryDatabase};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "kebab-case")]
|
||||
pub enum RegisterFolderOutcome {
|
||||
Added { folder_id: i64 },
|
||||
Refreshed { folder_id: i64 },
|
||||
Covered { folder_id: i64 },
|
||||
RegisteredDisabled { folder_id: i64 },
|
||||
Conflict,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl RegisterFolderOutcome {
|
||||
pub fn scan_folder_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Added { folder_id }
|
||||
| Self::Refreshed { folder_id }
|
||||
| Self::Covered { folder_id } => Some(*folder_id),
|
||||
Self::RegisteredDisabled { .. } | Self::Conflict | Self::Failed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LibraryDatabase {
|
||||
/// Register a canonical root, or identify the exact existing root that
|
||||
/// should be refreshed. Path comparisons are component-wise (`Path`), not
|
||||
/// string-prefix comparisons.
|
||||
pub fn register_or_refresh_folder(&self, path: &Path) -> RegisterFolderOutcome {
|
||||
let Ok(candidate) = path.canonicalize() else {
|
||||
return RegisterFolderOutcome::Failed;
|
||||
};
|
||||
if !candidate.is_dir() {
|
||||
return RegisterFolderOutcome::Failed;
|
||||
}
|
||||
|
||||
let rows = match self.get_folders_with_metadata() {
|
||||
Ok(rows) => rows,
|
||||
Err(_) => return RegisterFolderOutcome::Failed,
|
||||
};
|
||||
let existing: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|row| (row, PathBuf::from(&row.path)))
|
||||
.collect();
|
||||
|
||||
if let Some((row, _)) = existing.iter().find(|(_, root)| root == &candidate) {
|
||||
return if row.enabled {
|
||||
RegisterFolderOutcome::Refreshed { folder_id: row.id }
|
||||
} else {
|
||||
RegisterFolderOutcome::RegisteredDisabled { folder_id: row.id }
|
||||
};
|
||||
}
|
||||
|
||||
if let Some((row, _)) = existing
|
||||
.iter()
|
||||
.filter(|(_, root)| candidate.starts_with(root))
|
||||
.max_by_key(|(_, root)| root.components().count())
|
||||
{
|
||||
return if row.enabled {
|
||||
RegisterFolderOutcome::Covered { folder_id: row.id }
|
||||
} else {
|
||||
RegisterFolderOutcome::RegisteredDisabled { folder_id: row.id }
|
||||
};
|
||||
}
|
||||
|
||||
if existing
|
||||
.iter()
|
||||
.any(|(_, root)| root.starts_with(&candidate))
|
||||
{
|
||||
return RegisterFolderOutcome::Conflict;
|
||||
}
|
||||
|
||||
let canonical = candidate.to_string_lossy().into_owned();
|
||||
let is_network = is_network_path(&candidate);
|
||||
let fs_label = is_network.then(|| network_fs_label(&candidate)).flatten();
|
||||
self.with_connection(|conn| {
|
||||
let inserted = match conn.execute(
|
||||
"INSERT OR IGNORE INTO library_folders
|
||||
(path, is_network, network_fs_type) VALUES (?1, ?2, ?3)",
|
||||
params![canonical, is_network as i32, fs_label],
|
||||
) {
|
||||
Ok(count) => count,
|
||||
Err(_) => return RegisterFolderOutcome::Failed,
|
||||
};
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT id, enabled FROM library_folders WHERE path = ?1",
|
||||
[canonical],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)? != 0)),
|
||||
)
|
||||
.optional();
|
||||
match row {
|
||||
Ok(Some((folder_id, _))) if inserted > 0 => {
|
||||
RegisterFolderOutcome::Added { folder_id }
|
||||
}
|
||||
Ok(Some((folder_id, true))) => RegisterFolderOutcome::Refreshed { folder_id },
|
||||
Ok(Some((folder_id, false))) => {
|
||||
RegisterFolderOutcome::RegisteredDisabled { folder_id }
|
||||
}
|
||||
_ => RegisterFolderOutcome::Failed,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_and_descendant_paths_reuse_one_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let music = tmp.path().join("music");
|
||||
let album = music.join("Artist").join("Album");
|
||||
std::fs::create_dir_all(&album).unwrap();
|
||||
let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap();
|
||||
|
||||
let RegisterFolderOutcome::Added { folder_id } = db.register_or_refresh_folder(&music)
|
||||
else {
|
||||
panic!("first registration must add");
|
||||
};
|
||||
assert_eq!(
|
||||
db.register_or_refresh_folder(&music),
|
||||
RegisterFolderOutcome::Refreshed { folder_id }
|
||||
);
|
||||
assert_eq!(
|
||||
db.register_or_refresh_folder(&album),
|
||||
RegisterFolderOutcome::Covered { folder_id }
|
||||
);
|
||||
assert_eq!(db.get_folders_with_metadata().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_prefix_siblings_are_not_ancestors() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let music = tmp.path().join("music");
|
||||
let music_archive = tmp.path().join("music-archive");
|
||||
std::fs::create_dir_all(&music).unwrap();
|
||||
std::fs::create_dir_all(&music_archive).unwrap();
|
||||
let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap();
|
||||
assert!(matches!(
|
||||
db.register_or_refresh_folder(&music),
|
||||
RegisterFolderOutcome::Added { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
db.register_or_refresh_folder(&music_archive),
|
||||
RegisterFolderOutcome::Added { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_of_an_existing_root_is_a_conflict() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let parent = tmp.path().join("music");
|
||||
let child = parent.join("collection");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap();
|
||||
assert!(matches!(
|
||||
db.register_or_refresh_folder(&child),
|
||||
RegisterFolderOutcome::Added { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
db.register_or_refresh_folder(&parent),
|
||||
RegisterFolderOutcome::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_roots_are_never_reenabled_silently() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let music = tmp.path().join("music");
|
||||
std::fs::create_dir_all(&music).unwrap();
|
||||
let db = LibraryDatabase::open(&tmp.path().join("library.db")).unwrap();
|
||||
let RegisterFolderOutcome::Added { folder_id } = db.register_or_refresh_folder(&music)
|
||||
else {
|
||||
panic!("first registration must add");
|
||||
};
|
||||
db.set_folder_enabled(folder_id, false).unwrap();
|
||||
assert_eq!(
|
||||
db.register_or_refresh_folder(&music),
|
||||
RegisterFolderOutcome::RegisteredDisabled { folder_id }
|
||||
);
|
||||
assert!(!db.get_folder_by_id(folder_id).unwrap().unwrap().enabled);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ mod cue_parser;
|
||||
mod database;
|
||||
pub mod ephemeral;
|
||||
mod errors;
|
||||
mod folder_registration;
|
||||
pub mod local_playlists;
|
||||
mod metadata;
|
||||
mod models;
|
||||
@@ -58,6 +59,7 @@ pub use database::{
|
||||
PlaylistFolder, PlaylistSettings, PlaylistStats, TrackMetadataUpdateFull,
|
||||
};
|
||||
pub use errors::LibraryError;
|
||||
pub use folder_registration::RegisterFolderOutcome;
|
||||
pub use metadata::MetadataExtractor;
|
||||
pub use models::*;
|
||||
pub use mount_info::{is_network_path, network_fs_label};
|
||||
|
||||
@@ -228,6 +228,50 @@ static CLEANUP_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(Str
|
||||
static CLEARING: AtomicBool = AtomicBool::new(false);
|
||||
static STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingScans {
|
||||
all: bool,
|
||||
folder_ids: std::collections::BTreeSet<i64>,
|
||||
}
|
||||
|
||||
enum PendingScan {
|
||||
All,
|
||||
Folder(i64),
|
||||
}
|
||||
|
||||
impl PendingScans {
|
||||
fn push(&mut self, folder_id: Option<i64>) {
|
||||
match folder_id {
|
||||
None => {
|
||||
self.all = true;
|
||||
self.folder_ids.clear();
|
||||
}
|
||||
Some(id) if !self.all => {
|
||||
self.folder_ids.insert(id);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn pop(&mut self) -> Option<PendingScan> {
|
||||
if self.all {
|
||||
self.all = false;
|
||||
self.folder_ids.clear();
|
||||
Some(PendingScan::All)
|
||||
} else {
|
||||
self.folder_ids.pop_first().map(PendingScan::Folder)
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.all = false;
|
||||
self.folder_ids.clear();
|
||||
}
|
||||
}
|
||||
|
||||
static PENDING_SCANS: LazyLock<Mutex<PendingScans>> =
|
||||
LazyLock::new(|| Mutex::new(PendingScans::default()));
|
||||
|
||||
/// Last known accessibility per NETWORK folder id, filled by
|
||||
/// [`spawn_accessibility_probes`].
|
||||
///
|
||||
@@ -610,35 +654,31 @@ pub async fn pick_and_add_folder() {
|
||||
add_folder(dir.path().to_string_lossy().to_string()).await;
|
||||
}
|
||||
|
||||
/// "Add folder": validate the path, detect network-ness, insert.
|
||||
/// "Add folder": canonicalize once and let Local Library decide whether to
|
||||
/// add, refresh, reuse an ancestor, or reject an overlap.
|
||||
pub async fn add_folder(path: String) {
|
||||
let path = path.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let p = std::path::Path::new(&path);
|
||||
if !p.is_dir() {
|
||||
set_status(qbz_i18n::t("That folder does not exist."));
|
||||
return;
|
||||
}
|
||||
let canonical = std::fs::canonicalize(p)
|
||||
.map(|c| c.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| path.clone());
|
||||
let is_network = qbz_library::is_network_path(std::path::Path::new(&canonical));
|
||||
let fs_label = if is_network {
|
||||
qbz_library::network_fs_label(std::path::Path::new(&canonical))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(db) = open_db() else {
|
||||
set_status(qbz_i18n::t("Could not open the library database."));
|
||||
return;
|
||||
};
|
||||
match db.add_folder_with_network_info(&canonical, is_network, fs_label.as_deref()) {
|
||||
Ok(_) => set_status(String::new()),
|
||||
Err(e) => {
|
||||
log::error!("[qbz-qt] add folder failed: {e}");
|
||||
match db.register_or_refresh_folder(std::path::Path::new(&path)) {
|
||||
qbz_library::RegisterFolderOutcome::Added { .. }
|
||||
| qbz_library::RegisterFolderOutcome::Refreshed { .. }
|
||||
| qbz_library::RegisterFolderOutcome::Covered { .. } => set_status(String::new()),
|
||||
qbz_library::RegisterFolderOutcome::RegisteredDisabled { .. } => {
|
||||
set_status(qbz_i18n::t("That library folder is disabled."));
|
||||
}
|
||||
qbz_library::RegisterFolderOutcome::Conflict => {
|
||||
set_status(qbz_i18n::t(
|
||||
"That folder overlaps an existing library folder.",
|
||||
));
|
||||
}
|
||||
qbz_library::RegisterFolderOutcome::Failed => {
|
||||
set_status(qbz_i18n::t("Could not add that folder."));
|
||||
}
|
||||
}
|
||||
@@ -716,14 +756,19 @@ pub async fn toggle_folder_enabled(id: i64) {
|
||||
// Scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scan every enabled folder (`None`) or exactly one (`Some(id)`).
|
||||
/// Scan every enabled folder (`None`) or exactly one (`Some(id)`). A request
|
||||
/// arriving during an active scan is coalesced and run afterwards.
|
||||
///
|
||||
/// Progress rides the statics; a short ticker republishes the settings document
|
||||
/// while the scan runs (the document is the only transport this port has, and
|
||||
/// republishing per FILE would rebuild the whole snapshot thousands of times).
|
||||
pub fn scan(folder_id: Option<i64>) -> bool {
|
||||
if SCANNING.swap(true, Ordering::SeqCst) {
|
||||
return false;
|
||||
PENDING_SCANS
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(folder_id);
|
||||
return true;
|
||||
}
|
||||
CANCEL.store(false, Ordering::SeqCst);
|
||||
PROCESSED.store(0, Ordering::SeqCst);
|
||||
@@ -884,6 +929,20 @@ pub fn scan(folder_id: Option<i64>) -> bool {
|
||||
crate::local_catalog_qt::request_catch_up();
|
||||
refresh_browse();
|
||||
super::publish_snapshot().await;
|
||||
|
||||
let pending = PENDING_SCANS
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.pop();
|
||||
match pending {
|
||||
Some(PendingScan::All) => {
|
||||
scan(None);
|
||||
}
|
||||
Some(PendingScan::Folder(id)) => {
|
||||
scan(Some(id));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
@@ -891,6 +950,36 @@ pub fn scan(folder_id: Option<i64>) -> bool {
|
||||
/// "Stop" — checked at every file boundary by the shared engine.
|
||||
pub fn stop_scan() {
|
||||
CANCEL.store(true, Ordering::SeqCst);
|
||||
PENDING_SCANS
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pending_scan_tests {
|
||||
use super::{PendingScan, PendingScans};
|
||||
|
||||
#[test]
|
||||
fn directed_scans_are_deduplicated_and_kept() {
|
||||
let mut queue = PendingScans::default();
|
||||
queue.push(Some(9));
|
||||
queue.push(Some(9));
|
||||
queue.push(Some(4));
|
||||
assert!(matches!(queue.pop(), Some(PendingScan::Folder(4))));
|
||||
assert!(matches!(queue.pop(), Some(PendingScan::Folder(9))));
|
||||
assert!(queue.pop().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_scan_coalesces_every_directed_refresh() {
|
||||
let mut queue = PendingScans::default();
|
||||
queue.push(Some(1));
|
||||
queue.push(None);
|
||||
queue.push(Some(2));
|
||||
assert!(matches!(queue.pop(), Some(PendingScan::All)));
|
||||
assert!(queue.pop().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user