Skip to main content

mimick/library/
state.rs

1//! Library runtime state used by the built-in asset browser.
2
3use std::collections::VecDeque;
4
5use crate::api_client::{
6    LibraryAlbum, LibraryAsset, MetadataSearchFilters, ServerAbout, ServerStats,
7};
8
9const PAGE_SIZE: usize = 50;
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum LibrarySource {
13    AllAssets,
14    Timeline,
15    /// Random sample via `POST /api/search/random`. Used as the "Explore"
16    /// sidebar destination — refresh re-rolls; pagination is meaningless.
17    Explore,
18    Album {
19        id: String,
20        name: String,
21    },
22    SmartSearch {
23        query: String,
24    },
25    MetadataSearch {
26        query: String,
27    },
28    OcrSearch {
29        query: String,
30    },
31
32    AdvancedSearch {
33        filters: Box<MetadataSearchFilters>,
34    },
35    /// Local watched-folder enumeration only (no remote calls).
36    LocalAll,
37    /// Filename substring filter applied over the local enumeration.
38    LocalSearch {
39        query: String,
40    },
41    /// Remote assets overlayed with sync state from local SyncIndex.
42    Unified,
43    /// Local filename filter applied over a unified view.
44    UnifiedSearch {
45        query: String,
46    },
47    /// Local enumeration scoped to the folder linked to a specific album.
48    /// Empty if the album has no linked folder.
49    AlbumLocal {
50        id: String,
51        name: String,
52    },
53    /// Remote album assets overlayed with sync state from the linked folder.
54    AlbumUnified {
55        id: String,
56        name: String,
57    },
58}
59
60impl LibrarySource {
61    pub fn is_search(&self) -> bool {
62        matches!(
63            self,
64            LibrarySource::SmartSearch { .. }
65                | LibrarySource::MetadataSearch { .. }
66                | LibrarySource::OcrSearch { .. }
67                | LibrarySource::AdvancedSearch { .. }
68                | LibrarySource::LocalSearch { .. }
69                | LibrarySource::UnifiedSearch { .. }
70        )
71    }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum LibrarySortMode {
76    NewestFirst,
77    OldestFirst,
78    Filename,
79    FileType,
80}
81
82impl LibrarySortMode {
83    /// Server-side date order for paged remote sources, or `None` when the
84    /// mode is purely client-side (filename/filetype sort over loaded pages).
85    pub fn server_order(&self) -> Option<crate::api_client::SortOrder> {
86        match self {
87            LibrarySortMode::NewestFirst => Some(crate::api_client::SortOrder::Desc),
88            LibrarySortMode::OldestFirst => Some(crate::api_client::SortOrder::Asc),
89            LibrarySortMode::Filename | LibrarySortMode::FileType => None,
90        }
91    }
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum LibraryLoadState {
96    Idle,
97    Loading,
98    Loaded,
99    Empty,
100    Error(String),
101}
102
103#[derive(Clone, Debug, Default)]
104pub struct LibraryStatus {
105    pub stats: Option<ServerStats>,
106    pub about: Option<ServerAbout>,
107}
108
109#[derive(Clone, Debug)]
110pub struct LibraryState {
111    pub source: LibrarySource,
112    pub previous_non_search_source: LibrarySource,
113    /// Stack of past sources for back-navigation. Pushed by `navigate_to`,
114    /// popped by `navigate_back`. Searches are not pushed (they are ephemeral).
115    /// `VecDeque` for O(1) front pops when the 50-entry cap is hit.
116    pub nav_history: VecDeque<LibrarySource>,
117    pub sort_mode: LibrarySortMode,
118    pub load_state: LibraryLoadState,
119    pub selected_asset_id: Option<String>,
120    pub albums: Vec<LibraryAlbum>,
121    pub assets: Vec<LibraryAsset>,
122    pub next_page: u32,
123    pub has_more: bool,
124    pub page_in_flight: bool,
125    pub generation: u64,
126    pub status: LibraryStatus,
127}
128
129impl Default for LibraryState {
130    fn default() -> Self {
131        Self {
132            source: LibrarySource::AllAssets,
133            previous_non_search_source: LibrarySource::AllAssets,
134            nav_history: VecDeque::new(),
135            sort_mode: LibrarySortMode::NewestFirst,
136            load_state: LibraryLoadState::Idle,
137            selected_asset_id: None,
138            albums: Vec::new(),
139            assets: Vec::new(),
140            next_page: 1,
141            has_more: true,
142            page_in_flight: false,
143            generation: 0,
144            status: LibraryStatus::default(),
145        }
146    }
147}
148
149impl LibraryState {
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    pub fn load_albums(&mut self, albums: Vec<LibraryAlbum>) {
155        self.albums = albums;
156    }
157
158    pub fn load_initial_source(&mut self) -> (u64, LibrarySource, u32) {
159        self.switch_source(LibrarySource::AllAssets)
160    }
161
162    pub fn switch_source(&mut self, source: LibrarySource) -> (u64, LibrarySource, u32) {
163        if !source.is_search() {
164            self.previous_non_search_source = source.clone();
165        }
166        self.source = source.clone();
167        self.selected_asset_id = None;
168        self.assets.clear();
169        self.next_page = 1;
170        self.has_more = true;
171        self.page_in_flight = true;
172        self.generation = self.generation.saturating_add(1);
173        self.load_state = LibraryLoadState::Loading;
174        (self.generation, source, 1)
175    }
176
177    /// Switch to `source`, recording the current source in nav_history first
178    /// so back-navigation can return to it. Searches are not pushed (they're
179    /// ephemeral). No-ops when navigating to the source we're already on.
180    pub fn navigate_to(&mut self, source: LibrarySource) -> (u64, LibrarySource, u32) {
181        if self.source != source
182            && !self.source.is_search()
183            && self.nav_history.back() != Some(&self.source)
184        {
185            self.nav_history.push_back(self.source.clone());
186            if self.nav_history.len() > 50 {
187                self.nav_history.pop_front();
188            }
189        }
190        self.switch_source(source)
191    }
192
193    /// Pop one entry from nav_history and switch to it. Does not push to
194    /// history. Returns None when there's nowhere to go back to.
195    pub fn navigate_back(&mut self) -> Option<(u64, LibrarySource, u32)> {
196        let prev = self.nav_history.pop_back()?;
197        Some(self.switch_source(prev))
198    }
199
200    pub fn can_go_back(&self) -> bool {
201        !self.nav_history.is_empty()
202    }
203
204    pub fn load_next_page_if_needed(&mut self) -> Option<(u64, LibrarySource, u32)> {
205        if self.page_in_flight || !self.has_more {
206            return None;
207        }
208
209        self.page_in_flight = true;
210        Some((self.generation, self.source.clone(), self.next_page))
211    }
212
213    pub fn replace_assets(&mut self, generation: u64, items: Vec<LibraryAsset>) -> bool {
214        let has_more = items.len() >= PAGE_SIZE;
215        self.replace_assets_with_more(generation, items, has_more)
216    }
217
218    pub fn append_assets(&mut self, generation: u64, items: Vec<LibraryAsset>) -> bool {
219        let has_more = items.len() >= PAGE_SIZE;
220        self.append_assets_with_more(generation, items, has_more)
221    }
222
223    /// `replace_assets` with an explicit has_more signal — used by search
224    /// endpoints that return Immich's `nextPage` field, since the page-size
225    /// heuristic is wrong when the server returns short pages due to
226    /// post-filtering (visibility, archive, etc.).
227    pub fn replace_assets_with_more(
228        &mut self,
229        generation: u64,
230        items: Vec<LibraryAsset>,
231        has_more: bool,
232    ) -> bool {
233        if generation != self.generation {
234            return false;
235        }
236
237        self.assets = dedup_assets(items);
238        self.page_in_flight = false;
239        self.next_page = 2;
240        self.has_more = has_more;
241        self.load_state = if self.assets.is_empty() {
242            LibraryLoadState::Empty
243        } else {
244            LibraryLoadState::Loaded
245        };
246        self.apply_sort(self.sort_mode.clone());
247        true
248    }
249
250    pub fn append_assets_with_more(
251        &mut self,
252        generation: u64,
253        items: Vec<LibraryAsset>,
254        has_more: bool,
255    ) -> bool {
256        if generation != self.generation {
257            return false;
258        }
259
260        let page_len = items.len();
261        self.page_in_flight = false;
262        self.assets.extend(items);
263        self.assets = dedup_assets(std::mem::take(&mut self.assets));
264
265        if page_len > 0 {
266            self.next_page = self.next_page.saturating_add(1);
267        }
268        self.has_more = has_more;
269        self.load_state = if self.assets.is_empty() {
270            LibraryLoadState::Empty
271        } else {
272            LibraryLoadState::Loaded
273        };
274        self.apply_sort(self.sort_mode.clone());
275        true
276    }
277
278    /// Records the user's preferred sort mode. `LibraryState.assets` is always
279    /// stored in insertion (server) order so that the sliding-window FIFO
280    /// eviction is well-defined; the listmodel layer applies client-side sort
281    /// visually for `Filename`/`FileType`.
282    pub fn apply_sort(&mut self, mode: LibrarySortMode) {
283        self.sort_mode = mode;
284    }
285
286    pub fn clear_search_restore_previous_source(&mut self) -> Option<(u64, LibrarySource, u32)> {
287        if self.source.is_search() {
288            Some(self.switch_source(self.previous_non_search_source.clone()))
289        } else {
290            None
291        }
292    }
293
294    pub fn mark_error(&mut self, generation: u64, message: impl Into<String>) {
295        if generation == self.generation {
296            self.page_in_flight = false;
297            self.load_state = LibraryLoadState::Error(message.into());
298        }
299    }
300
301    pub fn set_status(&mut self, stats: Option<ServerStats>, about: Option<ServerAbout>) {
302        self.status.stats = stats;
303        self.status.about = about;
304    }
305}
306
307fn dedup_assets(items: Vec<LibraryAsset>) -> Vec<LibraryAsset> {
308    let mut seen = std::collections::HashSet::new();
309    let mut deduped = Vec::with_capacity(items.len());
310    for item in items {
311        if seen.insert(item.id.clone()) {
312            deduped.push(item);
313        }
314    }
315    deduped
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    fn asset(id: &str, filename: &str) -> LibraryAsset {
323        LibraryAsset {
324            id: id.into(),
325            filename: filename.into(),
326            mime_type: "image/jpeg".into(),
327            created_at: format!("2024-01-0{}T00:00:00.000Z", id),
328            asset_type: "IMAGE".into(),
329            thumbhash: None,
330            width: Some(10),
331            height: Some(10),
332            checksum: None,
333            exif_info: None,
334        }
335    }
336
337    #[test]
338    fn test_switch_source_resets_pagination_and_assets() {
339        let mut state = LibraryState::new();
340        state.assets.push(asset("1", "a.jpg"));
341        state.next_page = 9;
342        state.has_more = false;
343
344        let (_, source, page) = state.switch_source(LibrarySource::Album {
345            id: "album-1".into(),
346            name: "Trips".into(),
347        });
348
349        assert!(matches!(source, LibrarySource::Album { .. }));
350        assert_eq!(page, 1);
351        assert!(state.assets.is_empty());
352        assert_eq!(state.next_page, 1);
353        assert!(state.has_more);
354        assert!(state.page_in_flight);
355    }
356
357    #[test]
358    fn test_stale_generation_results_are_ignored() {
359        let mut state = LibraryState::new();
360        let (generation, _, _) = state.load_initial_source();
361        let newer_generation = state.switch_source(LibrarySource::MetadataSearch {
362            query: "cats".into(),
363        });
364
365        assert!(!state.replace_assets(generation, vec![asset("1", "a.jpg")]));
366        assert!(state.replace_assets(newer_generation.0, vec![asset("2", "b.jpg")]));
367        assert_eq!(state.assets.len(), 1);
368        assert_eq!(state.assets[0].id, "2");
369    }
370
371    #[test]
372    fn test_duplicate_page_requests_are_suppressed() {
373        let mut state = LibraryState::new();
374        let (_, _, _) = state.load_initial_source();
375        assert!(state.load_next_page_if_needed().is_none());
376        state.page_in_flight = false;
377        assert!(state.load_next_page_if_needed().is_some());
378        assert!(state.load_next_page_if_needed().is_none());
379    }
380
381    #[test]
382    fn test_dedup_drops_duplicates_across_appends() {
383        let mut state = LibraryState::new();
384        let (generation, _, _) = state.load_initial_source();
385        let mk_page = |start: u32| -> Vec<LibraryAsset> {
386            (0..50)
387                .map(|i| asset(&format!("{}", start + i), "a.jpg"))
388                .collect()
389        };
390        state.append_assets(generation, mk_page(0));
391        state.append_assets(generation, mk_page(50));
392        // Re-append the second page; ids must not appear twice.
393        state.append_assets(generation, mk_page(50));
394        let unique: std::collections::HashSet<_> =
395            state.assets.iter().map(|a| a.id.clone()).collect();
396        assert_eq!(unique.len(), state.assets.len());
397        assert_eq!(state.assets.len(), 100);
398    }
399
400    #[test]
401    fn test_sort_mode_server_order_mapping() {
402        assert!(matches!(
403            LibrarySortMode::NewestFirst.server_order(),
404            Some(crate::api_client::SortOrder::Desc)
405        ));
406        assert!(matches!(
407            LibrarySortMode::OldestFirst.server_order(),
408            Some(crate::api_client::SortOrder::Asc)
409        ));
410        assert!(LibrarySortMode::Filename.server_order().is_none());
411        assert!(LibrarySortMode::FileType.server_order().is_none());
412    }
413
414    #[test]
415    fn test_clear_search_restores_previous_non_search_source() {
416        let mut state = LibraryState::new();
417        state.switch_source(LibrarySource::Album {
418            id: "album-1".into(),
419            name: "Trips".into(),
420        });
421        state.switch_source(LibrarySource::SmartSearch {
422            query: "sunset".into(),
423        });
424
425        let (_, source, page) = state.clear_search_restore_previous_source().unwrap();
426        assert!(matches!(source, LibrarySource::Album { .. }));
427        assert_eq!(page, 1);
428    }
429
430    #[test]
431    fn test_navigate_back_pops_history() {
432        let mut state = LibraryState::new();
433        state.navigate_to(LibrarySource::AllAssets);
434        state.navigate_to(LibrarySource::Album {
435            id: "a1".into(),
436            name: "Trips".into(),
437        });
438        state.navigate_to(LibrarySource::Explore);
439
440        assert!(state.can_go_back());
441        let (_, source, _) = state.navigate_back().unwrap();
442        assert!(matches!(source, LibrarySource::Album { .. }));
443        let (_, source, _) = state.navigate_back().unwrap();
444        assert!(matches!(source, LibrarySource::AllAssets));
445        assert!(!state.can_go_back());
446        assert!(state.navigate_back().is_none());
447    }
448
449    #[test]
450    fn test_navigate_skips_searches_in_history() {
451        let mut state = LibraryState::new();
452        state.navigate_to(LibrarySource::AllAssets);
453        state.navigate_to(LibrarySource::SmartSearch {
454            query: "sunset".into(),
455        });
456        // Going from a search to another non-search must NOT push the search.
457        state.navigate_to(LibrarySource::Explore);
458        // History should be [AllAssets], not [AllAssets, SmartSearch].
459        let (_, source, _) = state.navigate_back().unwrap();
460        assert!(matches!(source, LibrarySource::AllAssets));
461        assert!(!state.can_go_back());
462    }
463
464    #[test]
465    fn test_navigate_to_same_source_is_noop_for_history() {
466        let mut state = LibraryState::new();
467        state.navigate_to(LibrarySource::AllAssets);
468        state.navigate_to(LibrarySource::AllAssets);
469        assert!(!state.can_go_back());
470    }
471}