1use 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 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 LocalAll,
37 LocalSearch {
39 query: String,
40 },
41 Unified,
43 UnifiedSearch {
45 query: String,
46 },
47 AlbumLocal {
50 id: String,
51 name: String,
52 },
53 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 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 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 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 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 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 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 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 state.navigate_to(LibrarySource::Explore);
458 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}