mimick/library/
local_source.rs1use 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#[derive(Clone, Debug)]
28pub struct LocalAsset {
29 pub path: PathBuf,
31 pub filename: String,
33 pub mime: String,
35 pub asset_type: &'static str,
37 pub created_at: String,
40}
41
42pub 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
54pub 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
73fn 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
114fn 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
133fn 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
143pub 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
156pub 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 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}