diff --git a/core/src/common/utils.rs b/core/src/common/utils.rs index ff900fedb38e..07584b5a73ce 100644 --- a/core/src/common/utils.rs +++ b/core/src/common/utils.rs @@ -3,3 +3,41 @@ // Note: Device ID management has been moved to device::manager for better // module organization. Import from there instead: // use crate::device::manager::{get_current_device_id, set_current_device_id}; + +/// Strip Windows extended path prefixes produced by `std::fs::canonicalize()`. +/// +/// On Windows, `canonicalize()` returns paths like `\\?\C:\...` (local) or +/// `\\?\UNC\server\share\...` (network). These prefixes break `starts_with()` +/// matching throughout the codebase and must be normalized. +/// +/// - `\\?\UNC\server\share\...` → `\\server\share\...` +/// - `\\?\C:\...` → `C:\...` +/// - All other paths are returned unchanged. +#[cfg(windows)] +pub fn strip_windows_extended_prefix(path: std::path::PathBuf) -> std::path::PathBuf { + if let Some(s) = path.to_str() { + if s.starts_with(r"\\?\UNC\") { + // \\?\UNC\server\share\... → \\server\share\... + std::path::PathBuf::from(format!(r"\\{}", &s[8..])) + } else if let Some(stripped) = s.strip_prefix(r"\\?\") { + // Only strip \\?\ when followed by a drive letter (e.g. C:\). + // Leave volume GUIDs (\\?\Volume{...}\) and other verbatim + // forms untouched — they are invalid without the prefix. + if stripped.as_bytes().get(1) == Some(&b':') { + std::path::PathBuf::from(stripped) + } else { + path + } + } else { + path + } + } else { + path + } +} + +/// No-op on non-Windows platforms. +#[cfg(not(windows))] +pub fn strip_windows_extended_prefix(path: std::path::PathBuf) -> std::path::PathBuf { + path +} diff --git a/core/src/location/manager.rs b/core/src/location/manager.rs index c57381d19b16..037c61cda413 100644 --- a/core/src/location/manager.rs +++ b/core/src/location/manager.rs @@ -46,6 +46,30 @@ impl LocationManager { job_policies: Option, volume_manager: &crate::volume::VolumeManager, ) -> LocationResult<(Uuid, String)> { + // Canonicalize local physical paths to absolute form before storing. + // Relative paths break the watcher, volume resolution, and indexer. + // Only for local device — remote paths can't be resolved locally. + let sd_path = if sd_path.is_local() { + if let crate::domain::addressing::SdPath::Physical { device_slug, path } = sd_path { + let canonical = tokio::fs::canonicalize(&path).await.map_err(|e| { + LocationError::InvalidPath(format!( + "Failed to resolve path {}: {}", + path.display(), + e + )) + })?; + let canonical = crate::common::utils::strip_windows_extended_prefix(canonical); + crate::domain::addressing::SdPath::Physical { + device_slug, + path: canonical, + } + } else { + sd_path + } + } else { + sd_path + }; + info!("Adding location: {}", sd_path); // Validate the path based on type diff --git a/core/src/ops/locations/add/action.rs b/core/src/ops/locations/add/action.rs index f77b09df11dc..ecf1d92cfacd 100644 --- a/core/src/ops/locations/add/action.rs +++ b/core/src/ops/locations/add/action.rs @@ -102,6 +102,31 @@ impl LibraryAction for LocationAddAction { .await .map_err(|e| ActionError::Internal(e.to_string()))?; + // Register the new location with the filesystem watcher so changes + // (creates, deletes, renames) are detected in real-time. + // Without this, the watcher only learns about locations at startup. + if let Some(local_path) = self.input.path.as_local_path() { + if let Some(fs_watcher) = context.get_fs_watcher().await { + use crate::ops::indexing::handlers::LocationMeta; + use crate::ops::indexing::RuleToggles; + + let root_path = tokio::fs::canonicalize(local_path) + .await + .unwrap_or_else(|_| local_path.to_path_buf()); + let root_path = crate::common::utils::strip_windows_extended_prefix(root_path); + + let meta = LocationMeta { + id: location_id, + library_id: library.id(), + root_path, + rule_toggles: RuleToggles::default(), + }; + if let Err(e) = fs_watcher.watch_location(meta).await { + tracing::warn!("Failed to register location with watcher: {}", e); + } + } + } + // Parse the job ID from the string returned by add_location let job_id = if !job_id_string.is_empty() { Some( diff --git a/core/src/ops/locations/remove/action.rs b/core/src/ops/locations/remove/action.rs index 7ac73207e68c..43e722a2bb2e 100644 --- a/core/src/ops/locations/remove/action.rs +++ b/core/src/ops/locations/remove/action.rs @@ -43,13 +43,24 @@ impl LibraryAction for LocationRemoveAction { library: std::sync::Arc, context: Arc, ) -> Result { - // Remove the location + // Remove the location from DB let location_manager = LocationManager::new(context.events.as_ref().clone()); location_manager .remove_location(&library, self.input.location_id) .await .map_err(|e| ActionError::Internal(e.to_string()))?; + // Unwatch the location from the filesystem watcher + if let Some(watcher) = context.get_fs_watcher().await { + if let Err(e) = watcher.unwatch_location(self.input.location_id).await { + tracing::warn!( + "Failed to unwatch location {}: {}", + self.input.location_id, + e + ); + } + } + Ok(LocationRemoveOutput::new(self.input.location_id, None)) } diff --git a/core/src/volume/fs/refs.rs b/core/src/volume/fs/refs.rs index c398268afb8d..cf2496daa6a2 100644 --- a/core/src/volume/fs/refs.rs +++ b/core/src/volume/fs/refs.rs @@ -293,18 +293,7 @@ impl super::FilesystemHandler for RefsHandler { } fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool { - // Strip Windows extended path prefix (\\?\) produced by canonicalize() - let normalized_path = if let Some(path_str) = path.to_str() { - if path_str.starts_with("\\\\?\\UNC\\") { - PathBuf::from(format!("\\\\{}", &path_str[8..])) - } else if let Some(stripped) = path_str.strip_prefix("\\\\?\\") { - PathBuf::from(stripped) - } else { - path.to_path_buf() - } - } else { - path.to_path_buf() - }; + let normalized_path = crate::common::utils::strip_windows_extended_prefix(path.to_path_buf()); if normalized_path.starts_with(&volume.mount_point) { return true;