Skip to main content

mimick/library/
local_source.rs

1//! Local file enumeration for the library view.
2//!
3//! Walks the user's currently configured watch paths, applies the same
4//! filtering rules used by the sync engine (`FolderRules` + supported-media
5//! extensions), and produces a list of `LocalAsset` rows that the library
6//! grid can display alongside (or instead of) remote Immich assets.
7//!
8//! Unlike the sync path, this module does NOT compute checksums on enumeration
9//! — that would be too expensive for browsing. Sync state is matched by
10//! looking up `SyncIndex.stored_checksum(path)` for paths the engine has
11//! already hashed; assets the user hasn't synced yet show as "Local only".
12
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::SystemTime;
17
18use chrono::{DateTime, SecondsFormat, Utc};
19
20use crate::app_context::AppContext;
21use crate::config::WatchPathEntry;
22use crate::monitor::{is_supported_media_path, is_temporary_file};
23// `crate::sync_index::SyncIndex` is referenced only in `local_sync_state`'s
24// signature and is imported there inline to keep the public surface narrow.
25
26/// A single file enumerated from the user's watched folders.
27#[derive(Clone, Debug)]
28pub struct LocalAsset {
29    /// Absolute path on disk (used as the synthetic row id).
30    pub path: PathBuf,
31    /// Display name (file_name component).
32    pub filename: String,
33    /// MIME type, derived from the extension.
34    pub mime: String,
35    /// "IMAGE" or "VIDEO".
36    pub asset_type: &'static str,
37    /// ISO-8601 modification time, used as `created_at` for sort consistency
38    /// with remote `LibraryAsset`.
39    pub created_at: String,
40}
41
42/// Walk every configured watch path and return matching media files.
43///
44/// Runs the synchronous walk on a Tokio blocking thread so the UI thread
45/// stays responsive even on libraries with tens of thousands of files.
46pub async fn enumerate_local(ctx: Arc<AppContext>) -> Vec<LocalAsset> {
47    let watch_paths = ctx.live_watch_paths.lock().clone();
48
49    tokio::task::spawn_blocking(move || enumerate_blocking(&watch_paths))
50        .await
51        .unwrap_or_default()
52}
53
54/// Enumerate only the watch entries matching `entry_path`. Used by the
55/// album-scoped Local/Unified views so a linked album's view stays bounded
56/// to its own folder instead of spilling assets from sibling albums.
57pub async fn enumerate_local_for_entry(
58    ctx: Arc<AppContext>,
59    entry_path: String,
60) -> Vec<LocalAsset> {
61    let watch_paths = ctx.live_watch_paths.lock().clone();
62
63    let scoped: Vec<WatchPathEntry> = watch_paths
64        .into_iter()
65        .filter(|e| e.path() == entry_path)
66        .collect();
67
68    tokio::task::spawn_blocking(move || enumerate_blocking(&scoped))
69        .await
70        .unwrap_or_default()
71}
72
73/// Walk directories on a thread pool thread to search for media files.
74fn enumerate_blocking(watch_paths: &[WatchPathEntry]) -> Vec<LocalAsset> {
75    let mut out = Vec::new();
76    let mut seen: HashSet<PathBuf> = HashSet::new();
77    for entry in watch_paths {
78        let root = PathBuf::from(entry.path());
79        if !root.is_dir() {
80            continue;
81        }
82        let rules = entry.rules();
83        let mut stack = vec![root];
84        while let Some(dir) = stack.pop() {
85            let read_dir = match std::fs::read_dir(&dir) {
86                Ok(iter) => iter,
87                Err(_) => continue,
88            };
89            for child in read_dir.flatten() {
90                let path = child.path();
91                if path.is_dir() {
92                    stack.push(path);
93                    continue;
94                }
95                if !is_supported_media_path(&path) || is_temporary_file(&path) {
96                    continue;
97                }
98                if !rules.matches(&path) {
99                    continue;
100                }
101                let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
102                if !seen.insert(key) {
103                    continue;
104                }
105                if let Some(asset) = build_asset(&path) {
106                    out.push(asset);
107                }
108            }
109        }
110    }
111    out
112}
113
114/// Parse metadata details and construct a typed `LocalAsset` for a file path.
115fn build_asset(path: &Path) -> Option<LocalAsset> {
116    let filename = path.file_name()?.to_string_lossy().into_owned();
117    let metadata = std::fs::metadata(path).ok()?;
118    let created_at = format_modified(&metadata);
119    let mime = crate::media_kinds::mime_for_path(path);
120    let asset_type = match crate::media_kinds::asset_kind(mime) {
121        crate::media_kinds::AssetKind::Video => "VIDEO",
122        _ => "IMAGE",
123    };
124    Some(LocalAsset {
125        path: path.to_path_buf(),
126        filename,
127        mime: mime.into(),
128        asset_type,
129        created_at,
130    })
131}
132
133/// Convert a file modification timestamp into standard ISO-8601 formatting.
134fn format_modified(meta: &std::fs::Metadata) -> String {
135    let mtime = meta
136        .modified()
137        .or_else(|_| meta.created())
138        .unwrap_or(SystemTime::UNIX_EPOCH);
139    let datetime: DateTime<Utc> = mtime.into();
140    datetime.to_rfc3339_opts(SecondsFormat::Millis, true)
141}
142
143/// Apply a case-insensitive filename substring filter, matching the spec's
144/// "Local Search: file name based" requirement.
145pub fn filter_by_filename(items: Vec<LocalAsset>, query: &str) -> Vec<LocalAsset> {
146    let needle = query.trim().to_ascii_lowercase();
147    if needle.is_empty() {
148        return items;
149    }
150    items
151        .into_iter()
152        .filter(|a| a.filename.to_ascii_lowercase().contains(&needle))
153        .collect()
154}
155
156/// Decide the sync-state badge for a local asset.
157///
158/// Takes a borrowed `&SyncIndex` rather than `&AppContext` because callers
159/// like `asset_objects_from_state` already hold the `sync_index` mutex
160/// guard while iterating assets. Re-acquiring it inside this function
161/// would deadlock against the outer guard (`parking_lot::Mutex` is
162/// non-reentrant). Returns 2 when the file's path is recorded as synced
163/// (badge "Both"), 1 otherwise (LocalOnly).
164pub fn local_sync_state(idx: &crate::sync_index::ShardedSyncIndex, path: &Path) -> u32 {
165    if idx.stored_checksum(&path.display().to_string()).is_some() {
166        2
167    } else {
168        1
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn make(name: &str) -> LocalAsset {
177        LocalAsset {
178            path: PathBuf::from(name),
179            filename: name.into(),
180            mime: "image/jpeg".into(),
181            asset_type: "IMAGE",
182            created_at: String::new(),
183        }
184    }
185
186    /// Synthetic identity used by the GridView model. Using the path keeps
187    /// dedup logic stable across enumerations even when modification times
188    /// change.
189    fn synthetic_id(asset: &LocalAsset) -> String {
190        format!("local::{}", asset.path.display())
191    }
192
193    #[test]
194    fn filter_by_filename_is_case_insensitive_substring() {
195        let items = vec![make("Beach.JPG"), make("city.jpg"), make("forest.png")];
196        let filtered = filter_by_filename(items, "JpG");
197        assert_eq!(filtered.len(), 2);
198    }
199
200    #[test]
201    fn filter_by_filename_empty_query_returns_all() {
202        let items = vec![make("a.jpg"), make("b.jpg")];
203        assert_eq!(filter_by_filename(items, "  ").len(), 2);
204    }
205
206    #[test]
207    fn synthetic_id_is_stable_across_clones() {
208        let a = make("/tmp/a.jpg");
209        assert_eq!(synthetic_id(&a), synthetic_id(&a.clone()));
210    }
211}