1use chrono::{DateTime, Utc};
6use rusqlite::{params, Connection, OptionalExtension, Result as SqliteResult};
7
8use crate::models::{ContentCategory, MediaType, Photo};
9
10#[derive(Debug, Clone)]
12pub struct PhotoInsert {
13 pub relative_path: String,
14 pub file_name: String,
15 pub file_hash: String,
16 pub file_size: i64,
17 pub file_mtime: Option<i64>,
18 pub date_taken: Option<String>,
19 pub date_taken_source: Option<String>,
20 pub gps_latitude: Option<f64>,
21 pub gps_longitude: Option<f64>,
22 pub location_city: Option<String>,
23 pub location_country: Option<String>,
24 pub camera_make: Option<String>,
25 pub camera_model: Option<String>,
26 pub iso: Option<i32>,
27 pub aperture: Option<String>,
28 pub shutter_speed: Option<String>,
29 pub focal_length: Option<String>,
30 pub lens_model: Option<String>,
31 pub flash: Option<String>,
32 pub gps_altitude: Option<f64>,
33 pub width: Option<i32>,
34 pub height: Option<i32>,
35 pub orientation: i32,
36 pub media_type: MediaType,
37 pub duration_ms: Option<i64>,
38 pub video_codec: Option<String>,
39 pub audio_codec: Option<String>,
40 pub frame_rate: Option<f32>,
41 pub bitrate: Option<i64>,
42 pub has_audio: bool,
43}
44
45pub struct PhotoRepo<'a> {
47 conn: &'a Connection,
48}
49
50pub type FavoriteAlbumSummary = (
51 i64,
52 Option<i64>,
53 Option<String>,
54 Option<String>,
55 Option<String>,
56);
57
58impl<'a> PhotoRepo<'a> {
59 pub fn new(conn: &'a Connection) -> Self {
60 Self { conn }
61 }
62
63 pub fn insert_batch_stub(&self, photos: &[PhotoInsert]) -> SqliteResult<usize> {
70 let tx = self.conn.unchecked_transaction()?;
71 let mut count = 0;
72
73 for photo in photos {
74 let changed = tx.execute(
75 r#"
76 INSERT INTO photos (
77 file_path, file_name, file_hash, file_size, file_mtime,
78 orientation, media_type, metadata_extracted, thumbnailed, faces_processed
79 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, FALSE, FALSE, FALSE)
80 ON CONFLICT(file_path) DO NOTHING
81 "#,
82 params![
83 photo.relative_path,
84 photo.file_name,
85 photo.file_hash,
86 photo.file_size,
87 photo.file_mtime,
88 photo.orientation,
89 photo.media_type.as_str(),
90 ],
91 )?;
92 count += changed;
93 }
94
95 tx.commit()?;
96 Ok(count)
97 }
98
99 pub fn insert_batch(&self, photos: &[PhotoInsert]) -> SqliteResult<usize> {
101 let tx = self.conn.unchecked_transaction()?;
102 let mut count = 0;
103
104 for photo in photos {
105 tx.execute(
106 r#"
107 INSERT INTO photos (
108 file_path, file_name, file_hash, file_size, file_mtime,
109 date_taken, date_taken_source,
110 gps_latitude, gps_longitude,
111 location_city, location_country,
112 camera_make, camera_model,
113 iso, aperture, shutter_speed, focal_length,
114 lens_model, flash, gps_altitude,
115 width, height, orientation,
116 media_type, duration_ms, video_codec, audio_codec,
117 frame_rate, bitrate, has_audio
118 ) VALUES (
119 ?1, ?2, ?3, ?4, ?5,
120 ?6, ?7,
121 ?8, ?9,
122 ?10, ?11,
123 ?12, ?13,
124 ?14, ?15, ?16, ?17,
125 ?18, ?19, ?20,
126 ?21, ?22, ?23,
127 ?24, ?25, ?26, ?27,
128 ?28, ?29, ?30
129 )
130 ON CONFLICT(file_path) DO UPDATE SET
131 file_hash = excluded.file_hash,
132 file_size = excluded.file_size,
133 file_mtime = excluded.file_mtime,
134 date_taken = excluded.date_taken,
135 date_taken_source = excluded.date_taken_source,
136 gps_latitude = excluded.gps_latitude,
137 gps_longitude = excluded.gps_longitude,
138 location_city = COALESCE(excluded.location_city, photos.location_city),
139 location_country = COALESCE(excluded.location_country, photos.location_country),
140 camera_make = excluded.camera_make,
141 camera_model = excluded.camera_model,
142 iso = excluded.iso,
143 aperture = excluded.aperture,
144 shutter_speed = excluded.shutter_speed,
145 focal_length = excluded.focal_length,
146 lens_model = excluded.lens_model,
147 flash = excluded.flash,
148 gps_altitude = excluded.gps_altitude,
149 width = excluded.width,
150 height = excluded.height,
151 orientation = excluded.orientation,
152 media_type = excluded.media_type,
153 duration_ms = excluded.duration_ms,
154 video_codec = excluded.video_codec,
155 audio_codec = excluded.audio_codec,
156 frame_rate = excluded.frame_rate,
157 bitrate = excluded.bitrate,
158 has_audio = excluded.has_audio,
159 updated_at = CURRENT_TIMESTAMP
160 "#,
161 params![
162 photo.relative_path,
163 photo.file_name,
164 photo.file_hash,
165 photo.file_size,
166 photo.file_mtime,
167 photo.date_taken,
168 photo.date_taken_source,
169 photo.gps_latitude,
170 photo.gps_longitude,
171 photo.location_city,
172 photo.location_country,
173 photo.camera_make,
174 photo.camera_model,
175 photo.iso,
176 photo.aperture,
177 photo.shutter_speed,
178 photo.focal_length,
179 photo.lens_model,
180 photo.flash,
181 photo.gps_altitude,
182 photo.width,
183 photo.height,
184 photo.orientation,
185 photo.media_type.as_str(),
186 photo.duration_ms,
187 photo.video_codec,
188 photo.audio_codec,
189 photo.frame_rate,
190 photo.bitrate,
191 photo.has_audio,
192 ],
193 )?;
194 count += 1;
195 }
196
197 tx.commit()?;
198 Ok(count)
199 }
200
201 pub fn count(&self) -> SqliteResult<i64> {
203 self.conn.query_row(
204 "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE",
205 [],
206 |row| row.get(0),
207 )
208 }
209
210 pub fn count_timeline_visible(&self, show_stacks: bool) -> SqliteResult<i64> {
211 if !show_stacks {
212 return self.count();
213 }
214 self.conn.query_row(
215 &format!(
216 r#"
217 WITH live_stacks AS ({live_stacks_cte})
218 SELECT COUNT(*)
219 FROM photos p
220 WHERE p.is_trashed = FALSE
221 AND NOT EXISTS (
222 SELECT 1
223 FROM photo_stack_members m
224 JOIN live_stacks s ON s.id = m.stack_id
225 WHERE m.photo_id = p.id
226 AND p.id != s.cover_photo_id
227 )
228 "#,
229 live_stacks_cte = LIVE_STACKS_CTE
230 ),
231 [],
232 |row| row.get(0),
233 )
234 }
235
236 pub fn count_pending_metadata(&self) -> SqliteResult<i64> {
239 self.conn.query_row(
240 "SELECT COUNT(*) FROM photos WHERE metadata_extracted = FALSE AND is_trashed = FALSE",
241 [],
242 |row| row.get(0),
243 )
244 }
245
246 pub fn count_pending_thumbnails(&self) -> SqliteResult<i64> {
248 self.conn.query_row(
249 "SELECT COUNT(*) FROM photos WHERE thumbnailed = FALSE AND is_trashed = FALSE AND media_type = 'photo'",
250 [],
251 |row| row.get(0),
252 )
253 }
254
255 pub fn get_all_by_date(&self, limit: i64, offset: i64) -> SqliteResult<Vec<Photo>> {
257 let mut stmt = self.conn.prepare(
258 r#"
259 SELECT
260 id, file_path, file_name, file_hash, file_size,
261 date_taken, date_taken_source,
262 gps_latitude, gps_longitude,
263 location_city, location_country,
264 camera_make, camera_model,
265 iso, aperture, shutter_speed, focal_length,
266 lens_model, flash, gps_altitude,
267 width, height, orientation,
268 media_type, duration_ms, video_codec, audio_codec,
269 frame_rate, bitrate, has_audio,
270 thumbnail_path, faces_processed,
271 content_category, ocr_text, ocr_processed, ocr_confidence,
272 is_favorite,
273 is_trashed, trashed_at,
274 indexed_at, updated_at
275 FROM photos
276 WHERE is_trashed = FALSE
277 ORDER BY date_taken DESC
278 LIMIT ?1 OFFSET ?2
279 "#,
280 )?;
281
282 let rows = stmt.query_map(params![limit, offset], row_to_photo)?;
283
284 let mut photos = Vec::new();
285 for row in rows {
286 photos.push(row?);
287 }
288
289 Ok(photos)
290 }
291
292 pub fn get_by_ids(&self, photo_ids: &[i64]) -> SqliteResult<Vec<Photo>> {
294 if photo_ids.is_empty() {
295 return Ok(Vec::new());
296 }
297
298 let mut all = Vec::new();
299
300 for chunk in photo_ids.chunks(900) {
302 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
303
304 let sql = format!(
305 r#"
306 SELECT
307 id, file_path, file_name, file_hash, file_size,
308 date_taken, date_taken_source,
309 gps_latitude, gps_longitude,
310 location_city, location_country,
311 camera_make, camera_model,
312 iso, aperture, shutter_speed, focal_length,
313 lens_model, flash, gps_altitude,
314 width, height, orientation,
315 media_type, duration_ms, video_codec, audio_codec,
316 frame_rate, bitrate, has_audio,
317 thumbnail_path, faces_processed,
318 content_category, ocr_text, ocr_processed, ocr_confidence,
319 is_favorite,
320 is_trashed, trashed_at,
321 indexed_at, updated_at
322 FROM photos
323 WHERE is_trashed = FALSE AND id IN ({})
324 "#,
325 placeholders
326 );
327
328 let mut stmt = self.conn.prepare(&sql)?;
329 let rows = stmt.query_map(
330 rusqlite::params_from_iter(chunk.iter().copied()),
331 row_to_photo,
332 )?;
333 for row in rows {
334 all.push(row?);
335 }
336 }
337
338 all.sort_by_key(|p| std::cmp::Reverse(p.date_taken));
339 Ok(all)
340 }
341
342 pub fn get_by_id(&self, id: i64) -> SqliteResult<Option<Photo>> {
344 let mut stmt = self.conn.prepare(
345 r#"
346 SELECT
347 id, file_path, file_name, file_hash, file_size,
348 date_taken, date_taken_source,
349 gps_latitude, gps_longitude,
350 location_city, location_country,
351 camera_make, camera_model,
352 iso, aperture, shutter_speed, focal_length,
353 lens_model, flash, gps_altitude,
354 width, height, orientation,
355 media_type, duration_ms, video_codec, audio_codec,
356 frame_rate, bitrate, has_audio,
357 thumbnail_path, faces_processed,
358 content_category, ocr_text, ocr_processed, ocr_confidence,
359 is_favorite,
360 is_trashed, trashed_at,
361 indexed_at, updated_at
362 FROM photos
363 WHERE id = ?1
364 "#,
365 )?;
366
367 match stmt.query_row(params![id], row_to_photo) {
368 Ok(photo) => Ok(Some(photo)),
369 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
370 Err(e) => Err(e),
371 }
372 }
373
374 pub fn set_favorite(&self, id: i64, is_favorite: bool) -> SqliteResult<usize> {
375 self.conn.execute(
376 "UPDATE photos SET is_favorite = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
377 params![is_favorite, id],
378 )
379 }
380
381 pub fn count_favorites(&self) -> SqliteResult<i64> {
382 self.conn.query_row(
383 "SELECT COUNT(*) FROM photos WHERE is_favorite = TRUE AND is_trashed = FALSE",
384 [],
385 |row| row.get(0),
386 )
387 }
388
389 pub fn favorites_album_summary(&self) -> SqliteResult<Option<FavoriteAlbumSummary>> {
390 self.conn.query_row(
391 r#"
392 SELECT
393 COUNT(*),
394 (
395 SELECT id
396 FROM photos
397 WHERE is_favorite = TRUE AND is_trashed = FALSE
398 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
399 LIMIT 1
400 ),
401 (
402 SELECT thumbnail_path
403 FROM photos
404 WHERE is_favorite = TRUE AND is_trashed = FALSE
405 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
406 LIMIT 1
407 ),
408 MIN(date_taken),
409 MAX(date_taken)
410 FROM photos
411 WHERE is_favorite = TRUE AND is_trashed = FALSE
412 "#,
413 [],
414 |row| {
415 let count: i64 = row.get(0)?;
416 if count == 0 {
417 Ok(None)
418 } else {
419 Ok(Some((
420 count,
421 row.get(1)?,
422 row.get(2)?,
423 row.get(3)?,
424 row.get(4)?,
425 )))
426 }
427 },
428 )
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::db::schema::create_schema;
436 use rusqlite::Connection;
437
438 #[test]
439 fn insert_batch_stub_counts_only_new_rows() {
440 let conn = Connection::open_in_memory().unwrap();
441 create_schema(&conn).unwrap();
442 let repo = PhotoRepo::new(&conn);
443 let photo = PhotoInsert {
444 relative_path: "img.jpg".into(),
445 file_name: "img.jpg".into(),
446 file_hash: "hash".into(),
447 file_size: 12,
448 file_mtime: Some(100),
449 date_taken: None,
450 date_taken_source: None,
451 gps_latitude: None,
452 gps_longitude: None,
453 location_city: None,
454 location_country: None,
455 camera_make: None,
456 camera_model: None,
457 iso: None,
458 aperture: None,
459 shutter_speed: None,
460 focal_length: None,
461 lens_model: None,
462 flash: None,
463 gps_altitude: None,
464 width: None,
465 height: None,
466 orientation: 1,
467 media_type: MediaType::Photo,
468 duration_ms: None,
469 video_codec: None,
470 audio_codec: None,
471 frame_rate: None,
472 bitrate: None,
473 has_audio: false,
474 };
475
476 assert_eq!(
477 repo.insert_batch_stub(std::slice::from_ref(&photo))
478 .unwrap(),
479 1
480 );
481 assert_eq!(repo.insert_batch_stub(&[photo]).unwrap(), 0);
482 }
483
484 #[test]
485 fn list_after_by_person_uses_valid_stack_aliases() {
486 let conn = Connection::open_in_memory().unwrap();
487 create_schema(&conn).unwrap();
488 conn.execute(
489 "INSERT INTO face_clusters (id, face_count, photo_count) VALUES (7, 1, 1)",
490 [],
491 )
492 .unwrap();
493 conn.execute(
494 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
495 VALUES (1, 'img.jpg', 'img.jpg', 'hash', 12, '2024-01-01T00:00:00Z', 0)",
496 [],
497 )
498 .unwrap();
499 conn.execute(
500 "INSERT INTO faces (photo_id, bbox_x, bbox_y, bbox_width, bbox_height, embedding, cluster_id, confidence)
501 VALUES (1, 0.0, 0.0, 0.1, 0.1, zeroblob(16), 7, 0.9)",
502 [],
503 )
504 .unwrap();
505
506 let rows = PhotoRepo::new(&conn)
507 .list_after_by_person(7, None, 10)
508 .unwrap();
509 assert_eq!(rows.len(), 1);
510 assert_eq!(rows[0].id, 1);
511 assert!(rows[0].stack_id.is_none());
512 }
513
514 #[test]
515 fn list_after_by_person_includes_inferred_identity_photos() {
516 let conn = Connection::open_in_memory().unwrap();
517 create_schema(&conn).unwrap();
518 conn.execute(
519 "INSERT INTO face_clusters (id, face_count, photo_count) VALUES (7, 1, 2)",
520 [],
521 )
522 .unwrap();
523 conn.execute(
524 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
525 VALUES
526 (1, 'direct.jpg', 'direct.jpg', 'hash-direct', 12, '2024-02-01T00:00:00Z', 0),
527 (2, 'inferred.jpg', 'inferred.jpg', 'hash-inferred', 12, '2024-01-01T00:00:00Z', 0)",
528 [],
529 )
530 .unwrap();
531 conn.execute(
532 "INSERT INTO faces (photo_id, bbox_x, bbox_y, bbox_width, bbox_height, embedding, cluster_id, confidence)
533 VALUES (1, 0.0, 0.0, 0.1, 0.1, zeroblob(16), 7, 0.9)",
534 [],
535 )
536 .unwrap();
537 conn.execute(
538 "INSERT INTO photo_inferred_identities (photo_id, cluster_id, source_photo_id, confidence)
539 VALUES (2, 7, 1, 0.8)",
540 [],
541 )
542 .unwrap();
543
544 let ids = PhotoRepo::new(&conn)
545 .list_after_by_person(7, None, 10)
546 .unwrap()
547 .into_iter()
548 .map(|p| p.id)
549 .collect::<Vec<_>>();
550
551 assert_eq!(ids, vec![1, 2]);
552 }
553
554 #[test]
555 fn favorites_summary_only_exists_for_visible_favorites() {
556 let conn = Connection::open_in_memory().unwrap();
557 create_schema(&conn).unwrap();
558 conn.execute(
559 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, thumbnail_path, is_favorite, is_trashed)
560 VALUES
561 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2024-01-01T00:00:00Z', 'thumb-a.jpg', 1, 0),
562 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2024-02-01T00:00:00Z', 'thumb-b.jpg', 1, 1),
563 (3, 'c.jpg', 'c.jpg', 'hash-c', 12, '2024-03-01T00:00:00Z', 'thumb-c.jpg', 0, 0)",
564 [],
565 )
566 .unwrap();
567
568 let repo = PhotoRepo::new(&conn);
569 let summary = repo.favorites_album_summary().unwrap().unwrap();
570 assert_eq!(summary.0, 1);
571 assert_eq!(summary.1, Some(1));
572 assert_eq!(summary.2.as_deref(), Some("thumb-a.jpg"));
573
574 repo.set_favorite(1, false).unwrap();
575 assert!(repo.favorites_album_summary().unwrap().is_none());
576 }
577
578 #[test]
579 fn timeline_neighbors_follow_timeline_order_and_skip_trash() {
580 let conn = Connection::open_in_memory().unwrap();
581 create_schema(&conn).unwrap();
582 conn.execute(
583 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
584 VALUES
585 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2025-01-02T00:00:00Z', 0),
586 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2025-01-01T00:00:00Z', 0),
587 (3, 'c.jpg', 'c.jpg', 'hash-c', 12, '2024-12-31T00:00:00Z', 1),
588 (4, 'd.jpg', 'd.jpg', 'hash-d', 12, NULL, 0)",
589 [],
590 )
591 .unwrap();
592
593 let repo = PhotoRepo::new(&conn);
594 let n = repo.timeline_neighbors(2, false).unwrap().unwrap();
595 assert_eq!(n.prev_id, Some(1));
596 assert_eq!(n.next_id, Some(4));
597
598 let null_n = repo.timeline_neighbors(4, false).unwrap().unwrap();
599 assert_eq!(null_n.prev_id, Some(2));
600 assert_eq!(null_n.next_id, None);
601 }
602
603 #[test]
604 fn list_at_offset_uses_timeline_order_and_skip_trash() {
605 let conn = Connection::open_in_memory().unwrap();
606 create_schema(&conn).unwrap();
607 conn.execute(
608 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
609 VALUES
610 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2025-01-04T00:00:00Z', 0),
611 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2025-01-03T00:00:00Z', 0),
612 (3, 'c.jpg', 'c.jpg', 'hash-c', 12, '2025-01-02T00:00:00Z', 1),
613 (4, 'd.jpg', 'd.jpg', 'hash-d', 12, '2025-01-01T00:00:00Z', 0),
614 (5, 'e.jpg', 'e.jpg', 'hash-e', 12, NULL, 0)",
615 [],
616 )
617 .unwrap();
618
619 let rows = PhotoRepo::new(&conn)
620 .list_at_offset(1, 2, false, false)
621 .unwrap();
622 assert_eq!(rows.iter().map(|p| p.id).collect::<Vec<_>>(), vec![2, 4]);
623 }
624
625 #[test]
626 fn list_after_by_date_uses_half_open_end() {
627 let conn = Connection::open_in_memory().unwrap();
628 create_schema(&conn).unwrap();
629 conn.execute(
630 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
631 VALUES
632 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2025-01-01T23:59:59Z', 0),
633 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2025-01-02T00:00:00Z', 0)",
634 [],
635 )
636 .unwrap();
637
638 let rows = PhotoRepo::new(&conn)
639 .list_after_by_date("2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", None, 10)
640 .unwrap();
641 assert_eq!(rows.iter().map(|p| p.id).collect::<Vec<_>>(), vec![1]);
642 }
643
644 #[test]
645 fn timeline_neighbors_hide_non_cover_stack_members_when_enabled() {
646 let conn = Connection::open_in_memory().unwrap();
647 create_schema(&conn).unwrap();
648 conn.execute(
649 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
650 VALUES
651 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2025-01-04T00:00:00Z', 0),
652 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2025-01-03T00:00:00Z', 0),
653 (3, 'c.jpg', 'c.jpg', 'hash-c', 12, '2025-01-02T00:00:00Z', 0),
654 (4, 'd.jpg', 'd.jpg', 'hash-d', 12, '2025-01-01T00:00:00Z', 0)",
655 [],
656 )
657 .unwrap();
658 conn.execute(
659 "INSERT INTO photo_stacks (id, kind, source_group_id, cover_photo_id)
660 VALUES (10, 'burst', 99, 2)",
661 [],
662 )
663 .unwrap();
664 conn.execute(
665 "INSERT INTO photo_stack_members (stack_id, photo_id, is_cover)
666 VALUES (10, 2, 1), (10, 3, 0)",
667 [],
668 )
669 .unwrap();
670
671 let repo = PhotoRepo::new(&conn);
672 let n = repo.timeline_neighbors(2, true).unwrap().unwrap();
673 assert_eq!(n.prev_id, Some(1));
674 assert_eq!(n.next_id, Some(4));
675
676 let unstacked = repo.timeline_neighbors(2, false).unwrap().unwrap();
677 assert_eq!(unstacked.next_id, Some(3));
678 }
679
680 #[test]
681 fn stacked_timeline_counts_only_live_members() {
682 let conn = Connection::open_in_memory().unwrap();
683 create_schema(&conn).unwrap();
684 conn.execute(
685 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
686 VALUES
687 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2025-01-04T00:00:00Z', 0),
688 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2025-01-03T00:00:00Z', 0),
689 (3, 'c.jpg', 'c.jpg', 'hash-c', 12, '2025-01-02T00:00:00Z', 1),
690 (4, 'd.jpg', 'd.jpg', 'hash-d', 12, '2025-01-01T00:00:00Z', 0)",
691 [],
692 )
693 .unwrap();
694 conn.execute(
695 "INSERT INTO photo_stacks (id, kind, source_group_id, cover_photo_id)
696 VALUES (10, 'burst', 99, 2)",
697 [],
698 )
699 .unwrap();
700 conn.execute(
701 "INSERT INTO photo_stack_members (stack_id, photo_id, is_cover)
702 VALUES (10, 2, 1), (10, 3, 0), (10, 4, 0)",
703 [],
704 )
705 .unwrap();
706
707 let repo = PhotoRepo::new(&conn);
708 let rows = repo.list_after(None, 10, false, true).unwrap();
709 let stacked = rows.iter().find(|p| p.id == 2).unwrap();
710 assert_eq!(stacked.stack_member_count, Some(2));
711 assert!(rows.iter().all(|p| p.id != 4));
712 }
713
714 #[test]
715 fn stacked_timeline_ignores_stack_when_cover_is_trashed() {
716 let conn = Connection::open_in_memory().unwrap();
717 create_schema(&conn).unwrap();
718 conn.execute(
719 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
720 VALUES
721 (1, 'a.jpg', 'a.jpg', 'hash-a', 12, '2025-01-04T00:00:00Z', 0),
722 (2, 'b.jpg', 'b.jpg', 'hash-b', 12, '2025-01-03T00:00:00Z', 1),
723 (3, 'c.jpg', 'c.jpg', 'hash-c', 12, '2025-01-02T00:00:00Z', 0)",
724 [],
725 )
726 .unwrap();
727 conn.execute(
728 "INSERT INTO photo_stacks (id, kind, source_group_id, cover_photo_id)
729 VALUES (10, 'burst', 99, 2)",
730 [],
731 )
732 .unwrap();
733 conn.execute(
734 "INSERT INTO photo_stack_members (stack_id, photo_id, is_cover)
735 VALUES (10, 2, 1), (10, 3, 0)",
736 [],
737 )
738 .unwrap();
739
740 let repo = PhotoRepo::new(&conn);
741 let rows = repo.list_after(None, 10, false, true).unwrap();
742 let photo = rows.iter().find(|p| p.id == 3).unwrap();
743 assert_eq!(photo.stack_id, None);
744
745 let neighbors = repo.timeline_neighbors(1, true).unwrap().unwrap();
746 assert_eq!(neighbors.next_id, Some(3));
747 }
748}
749
750pub(crate) fn row_to_photo(row: &rusqlite::Row) -> SqliteResult<Photo> {
754 Ok(Photo {
755 id: row.get(0)?,
756 file_path: row.get(1)?,
757 file_name: row.get(2)?,
758 file_hash: row.get(3)?,
759 file_size: row.get(4)?,
760 date_taken: row
761 .get::<_, Option<String>>(5)?
762 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
763 .map(|d| d.with_timezone(&Utc)),
764 date_taken_source: row.get(6)?,
765 gps_latitude: row.get(7)?,
766 gps_longitude: row.get(8)?,
767 location_city: row.get(9)?,
768 location_country: row.get(10)?,
769 camera_make: row.get(11)?,
770 camera_model: row.get(12)?,
771 iso: row.get(13)?,
772 aperture: row.get(14)?,
773 shutter_speed: row.get(15)?,
774 focal_length: row.get(16)?,
775 lens_model: row.get(17)?,
776 flash: row.get(18)?,
777 gps_altitude: row.get(19)?,
778 width: row.get(20)?,
779 height: row.get(21)?,
780 orientation: row.get::<_, Option<i32>>(22)?.unwrap_or(1),
781 media_type: row
782 .get::<_, Option<String>>(23)?
783 .map(|s| MediaType::from_db(&s))
784 .unwrap_or_default(),
785 duration_ms: row.get(24)?,
786 video_codec: row.get(25)?,
787 audio_codec: row.get(26)?,
788 frame_rate: row.get(27)?,
789 bitrate: row.get(28)?,
790 has_audio: row.get::<_, Option<bool>>(29)?.unwrap_or(false),
791 thumbnail_path: row.get(30)?,
792 faces_processed: row.get(31)?,
793 content_category: row
794 .get::<_, Option<String>>(32)?
795 .map(|s| ContentCategory::from_db(&s))
796 .unwrap_or(ContentCategory::Photo),
797 ocr_text: row.get(33)?,
798 ocr_processed: row.get::<_, Option<bool>>(34)?.unwrap_or(false),
799 ocr_confidence: row.get(35)?,
800 is_favorite: row.get::<_, Option<bool>>(36)?.unwrap_or(false),
801 is_trashed: row.get(37)?,
802 trashed_at: row
803 .get::<_, Option<String>>(38)?
804 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
805 .map(|d| d.with_timezone(&Utc)),
806 indexed_at: row
807 .get::<_, String>(39)?
808 .parse::<DateTime<Utc>>()
809 .unwrap_or_else(|_| Utc::now()),
810 updated_at: row
811 .get::<_, String>(40)?
812 .parse::<DateTime<Utc>>()
813 .unwrap_or_else(|_| Utc::now()),
814 })
815}
816
817#[derive(Debug, Clone)]
825pub struct PhotoLite {
826 pub id: i64,
827 pub date_taken: Option<DateTime<Utc>>,
828 pub thumbnail_path: Option<String>,
829 pub width: Option<i32>,
830 pub height: Option<i32>,
831 pub orientation: i32,
832 pub is_trashed: bool,
833 pub is_favorite: bool,
834 pub gps_latitude: Option<f64>,
835 pub gps_longitude: Option<f64>,
836 pub media_type: MediaType,
837 pub duration_ms: Option<i64>,
838 pub stack_id: Option<i64>,
839 pub stack_kind: Option<String>,
840 pub stack_member_count: Option<i64>,
841 pub stack_cover_photo_id: Option<i64>,
842}
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
845pub struct TimelineNeighbors {
846 pub prev_id: Option<i64>,
847 pub next_id: Option<i64>,
848}
849
850fn row_to_photo_lite(row: &rusqlite::Row) -> SqliteResult<PhotoLite> {
851 Ok(PhotoLite {
852 id: row.get(0)?,
853 date_taken: row
854 .get::<_, Option<String>>(1)?
855 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
856 .map(|d| d.with_timezone(&Utc)),
857 thumbnail_path: row.get(2)?,
858 width: row.get(3)?,
859 height: row.get(4)?,
860 orientation: row.get::<_, Option<i32>>(5)?.unwrap_or(1),
861 is_trashed: row.get(6)?,
862 is_favorite: row.get::<_, Option<bool>>(7)?.unwrap_or(false),
863 gps_latitude: row.get(8)?,
864 gps_longitude: row.get(9)?,
865 media_type: row
866 .get::<_, Option<String>>(10)?
867 .map(|s| MediaType::from_db(&s))
868 .unwrap_or_default(),
869 duration_ms: row.get(11)?,
870 stack_id: row.get(12)?,
871 stack_kind: row.get(13)?,
872 stack_member_count: row.get(14)?,
873 stack_cover_photo_id: row.get(15)?,
874 })
875}
876
877const PHOTO_LITE_COLUMNS: &str = r#"
878 id, date_taken, thumbnail_path,
879 width, height, orientation, is_trashed, is_favorite,
880 gps_latitude, gps_longitude,
881 media_type, duration_ms,
882 NULL AS stack_id, NULL AS stack_kind, NULL AS stack_member_count, NULL AS stack_cover_photo_id
883"#;
884
885const PHOTO_LITE_P_COLUMNS: &str = r#"
886 p.id, p.date_taken, p.thumbnail_path,
887 p.width, p.height, p.orientation, p.is_trashed, p.is_favorite,
888 p.gps_latitude, p.gps_longitude,
889 p.media_type, p.duration_ms,
890 NULL AS stack_id, NULL AS stack_kind, NULL AS stack_member_count, NULL AS stack_cover_photo_id
891"#;
892
893const PHOTO_LITE_STACKED_COLUMNS: &str = r#"
894 p.id, p.date_taken, p.thumbnail_path,
895 p.width, p.height, p.orientation, p.is_trashed, p.is_favorite,
896 p.gps_latitude, p.gps_longitude,
897 p.media_type, p.duration_ms,
898 s.id AS stack_id, s.kind AS stack_kind, s.stack_member_count AS stack_member_count,
899 s.cover_photo_id AS stack_cover_photo_id
900"#;
901
902const LIVE_STACKS_CTE: &str = r#"
903 SELECT s.id,
904 s.kind,
905 s.cover_photo_id,
906 COUNT(live_p.id) AS stack_member_count
907 FROM photo_stacks s
908 JOIN photos cover ON cover.id = s.cover_photo_id AND cover.is_trashed = FALSE
909 JOIN photo_stack_members live_m ON live_m.stack_id = s.id
910 JOIN photos live_p ON live_p.id = live_m.photo_id AND live_p.is_trashed = FALSE
911 WHERE s.dismissed = FALSE
912 GROUP BY s.id
913 HAVING COUNT(live_p.id) >= 2
914"#;
915
916impl<'a> PhotoRepo<'a> {
917 pub fn list_after(
921 &self,
922 cursor: Option<(Option<DateTime<Utc>>, i64)>,
923 limit: i64,
924 include_trashed: bool,
925 show_stacks: bool,
926 ) -> SqliteResult<Vec<PhotoLite>> {
927 if show_stacks && !include_trashed {
928 return self.list_after_stacked(cursor, limit);
929 }
930 let trash_clause = if include_trashed {
931 "1=1"
932 } else {
933 "is_trashed = 0"
934 };
935
936 let (sql, rows): (String, Vec<PhotoLite>) = match cursor {
937 Some((Some(d), id)) => {
938 let s = format!(
939 "SELECT {cols} FROM photos
940 WHERE {trash}
941 AND ((date_taken IS NOT NULL AND
942 (date_taken < ?1 OR (date_taken = ?1 AND id < ?2)))
943 OR (date_taken IS NULL AND ?1 IS NULL AND id < ?2))
944 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
945 LIMIT ?3",
946 cols = PHOTO_LITE_COLUMNS,
947 trash = trash_clause
948 );
949 let date_str = d.to_rfc3339();
950 let mut stmt = self.conn.prepare(&s)?;
951 let mapped = stmt.query_map(params![date_str, id, limit], row_to_photo_lite)?;
952 let mut v = Vec::new();
953 for r in mapped {
954 v.push(r?);
955 }
956 (s, v)
957 }
958 Some((None, id)) => {
959 let s = format!(
960 "SELECT {cols} FROM photos
961 WHERE {trash} AND date_taken IS NULL AND id < ?1
962 ORDER BY id DESC LIMIT ?2",
963 cols = PHOTO_LITE_COLUMNS,
964 trash = trash_clause
965 );
966 let mut stmt = self.conn.prepare(&s)?;
967 let mapped = stmt.query_map(params![id, limit], row_to_photo_lite)?;
968 let mut v = Vec::new();
969 for r in mapped {
970 v.push(r?);
971 }
972 (s, v)
973 }
974 None => {
975 let s = format!(
976 "SELECT {cols} FROM photos
977 WHERE {trash}
978 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
979 LIMIT ?1",
980 cols = PHOTO_LITE_COLUMNS,
981 trash = trash_clause
982 );
983 let mut stmt = self.conn.prepare(&s)?;
984 let mapped = stmt.query_map(params![limit], row_to_photo_lite)?;
985 let mut v = Vec::new();
986 for r in mapped {
987 v.push(r?);
988 }
989 (s, v)
990 }
991 };
992 let _ = sql; Ok(rows)
994 }
995
996 pub fn list_at_offset(
999 &self,
1000 offset: i64,
1001 limit: i64,
1002 include_trashed: bool,
1003 show_stacks: bool,
1004 ) -> SqliteResult<Vec<PhotoLite>> {
1005 if show_stacks && !include_trashed {
1006 return self.list_at_offset_stacked(offset, limit);
1007 }
1008 let trash_clause = if include_trashed {
1009 "1=1"
1010 } else {
1011 "is_trashed = 0"
1012 };
1013 let sql = format!(
1014 "SELECT {cols} FROM photos
1015 WHERE {trash}
1016 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1017 LIMIT ?1 OFFSET ?2",
1018 cols = PHOTO_LITE_COLUMNS,
1019 trash = trash_clause
1020 );
1021 let mut stmt = self.conn.prepare(&sql)?;
1022 let rows = stmt
1023 .query_map(params![limit, offset], row_to_photo_lite)?
1024 .collect::<SqliteResult<Vec<_>>>()?;
1025 Ok(rows)
1026 }
1027
1028 fn list_after_stacked(
1029 &self,
1030 cursor: Option<(Option<DateTime<Utc>>, i64)>,
1031 limit: i64,
1032 ) -> SqliteResult<Vec<PhotoLite>> {
1033 let base = format!(
1034 r#"
1035 WITH live_stacks AS ({live_stacks_cte})
1036 SELECT {cols}
1037 FROM photos p
1038 LEFT JOIN photo_stack_members sm ON sm.photo_id = p.id
1039 LEFT JOIN live_stacks s ON s.id = sm.stack_id
1040 WHERE p.is_trashed = 0
1041 AND (s.id IS NULL OR p.id = s.cover_photo_id)
1042 "#,
1043 cols = PHOTO_LITE_STACKED_COLUMNS,
1044 live_stacks_cte = LIVE_STACKS_CTE
1045 );
1046 let group = " GROUP BY p.id, s.id";
1047
1048 match cursor {
1049 Some((Some(d), id)) => {
1050 let sql = format!(
1051 "{base}
1052 AND ((p.date_taken IS NOT NULL AND
1053 (p.date_taken < ?1 OR (p.date_taken = ?1 AND p.id < ?2)))
1054 OR (p.date_taken IS NULL AND ?1 IS NULL AND p.id < ?2))
1055 {group}
1056 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1057 LIMIT ?3"
1058 );
1059 let date_str = d.to_rfc3339();
1060 let mut stmt = self.conn.prepare(&sql)?;
1061 let rows = stmt.query_map(params![date_str, id, limit], row_to_photo_lite)?;
1062 rows.collect()
1063 }
1064 Some((None, id)) => {
1065 let sql = format!(
1066 "{base}
1067 AND p.date_taken IS NULL AND p.id < ?1
1068 {group}
1069 ORDER BY p.id DESC LIMIT ?2"
1070 );
1071 let mut stmt = self.conn.prepare(&sql)?;
1072 let rows = stmt.query_map(params![id, limit], row_to_photo_lite)?;
1073 rows.collect()
1074 }
1075 None => {
1076 let sql = format!(
1077 "{base}
1078 {group}
1079 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1080 LIMIT ?1"
1081 );
1082 let mut stmt = self.conn.prepare(&sql)?;
1083 let rows = stmt.query_map(params![limit], row_to_photo_lite)?;
1084 rows.collect()
1085 }
1086 }
1087 }
1088
1089 fn list_at_offset_stacked(&self, offset: i64, limit: i64) -> SqliteResult<Vec<PhotoLite>> {
1090 let sql = format!(
1091 r#"
1092 WITH live_stacks AS ({live_stacks_cte})
1093 SELECT {cols}
1094 FROM photos p
1095 LEFT JOIN photo_stack_members sm ON sm.photo_id = p.id
1096 LEFT JOIN live_stacks s ON s.id = sm.stack_id
1097 WHERE p.is_trashed = 0
1098 AND (s.id IS NULL OR p.id = s.cover_photo_id)
1099 GROUP BY p.id, s.id
1100 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1101 LIMIT ?1 OFFSET ?2
1102 "#,
1103 cols = PHOTO_LITE_STACKED_COLUMNS,
1104 live_stacks_cte = LIVE_STACKS_CTE
1105 );
1106 let mut stmt = self.conn.prepare(&sql)?;
1107 let rows = stmt
1108 .query_map(params![limit, offset], row_to_photo_lite)?
1109 .collect::<SqliteResult<Vec<_>>>()?;
1110 Ok(rows)
1111 }
1112
1113 pub fn timeline_neighbors(
1114 &self,
1115 photo_id: i64,
1116 show_stacks: bool,
1117 ) -> SqliteResult<Option<TimelineNeighbors>> {
1118 let Some(current) = self.get_by_id(photo_id)? else {
1119 return Ok(None);
1120 };
1121
1122 let visible = if show_stacks {
1123 "p.is_trashed = 0 AND NOT EXISTS (
1124 SELECT 1
1125 FROM photo_stack_members sm
1126 JOIN live_stacks s ON s.id = sm.stack_id
1127 WHERE sm.photo_id = p.id
1128 AND p.id <> s.cover_photo_id
1129 )"
1130 } else {
1131 "p.is_trashed = 0"
1132 };
1133
1134 let prev_id = if current.date_taken.is_some() {
1135 let sql = format!(
1136 "WITH live_stacks AS ({live_stacks_cte})
1137 SELECT p.id FROM photos p
1138 JOIN photos cur ON cur.id = ?1
1139 WHERE {visible}
1140 AND p.date_taken IS NOT NULL
1141 AND p.id <> cur.id
1142 AND (p.date_taken > cur.date_taken OR (p.date_taken = cur.date_taken AND p.id > cur.id))
1143 ORDER BY p.date_taken ASC, p.id ASC
1144 LIMIT 1",
1145 live_stacks_cte = LIVE_STACKS_CTE
1146 );
1147 self.conn
1148 .query_row(&sql, params![current.id], |r| r.get(0))
1149 .optional()?
1150 } else {
1151 let sql = format!(
1152 "WITH live_stacks AS ({live_stacks_cte})
1153 SELECT p.id FROM photos p
1154 WHERE {visible}
1155 AND p.date_taken IS NULL
1156 AND p.id > ?1
1157 ORDER BY p.id ASC
1158 LIMIT 1",
1159 live_stacks_cte = LIVE_STACKS_CTE
1160 );
1161 let null_prev: Option<i64> = self
1162 .conn
1163 .query_row(&sql, params![current.id], |r| r.get(0))
1164 .optional()?;
1165 if null_prev.is_some() {
1166 null_prev
1167 } else {
1168 let sql = format!(
1169 "WITH live_stacks AS ({live_stacks_cte})
1170 SELECT p.id FROM photos p
1171 WHERE {visible}
1172 AND p.date_taken IS NOT NULL
1173 ORDER BY p.date_taken ASC, p.id ASC
1174 LIMIT 1",
1175 live_stacks_cte = LIVE_STACKS_CTE
1176 );
1177 self.conn.query_row(&sql, [], |r| r.get(0)).optional()?
1178 }
1179 };
1180
1181 let next_id = if current.date_taken.is_some() {
1182 let sql = format!(
1183 "WITH live_stacks AS ({live_stacks_cte})
1184 SELECT p.id FROM photos p
1185 JOIN photos cur ON cur.id = ?1
1186 WHERE {visible}
1187 AND (
1188 (p.date_taken IS NOT NULL
1189 AND (p.date_taken < cur.date_taken OR (p.date_taken = cur.date_taken AND p.id < cur.id)))
1190 OR p.date_taken IS NULL
1191 )
1192 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1193 LIMIT 1",
1194 live_stacks_cte = LIVE_STACKS_CTE
1195 );
1196 self.conn
1197 .query_row(&sql, params![current.id], |r| r.get(0))
1198 .optional()?
1199 } else {
1200 let sql = format!(
1201 "WITH live_stacks AS ({live_stacks_cte})
1202 SELECT p.id FROM photos p
1203 WHERE {visible}
1204 AND p.date_taken IS NULL
1205 AND p.id < ?1
1206 ORDER BY p.id DESC
1207 LIMIT 1",
1208 live_stacks_cte = LIVE_STACKS_CTE
1209 );
1210 self.conn
1211 .query_row(&sql, params![current.id], |r| r.get(0))
1212 .optional()?
1213 };
1214
1215 Ok(Some(TimelineNeighbors { prev_id, next_id }))
1216 }
1217
1218 pub fn list_after_by_album(
1220 &self,
1221 album_id: i64,
1222 cursor: Option<(Option<DateTime<Utc>>, i64)>,
1223 limit: i64,
1224 ) -> SqliteResult<Vec<PhotoLite>> {
1225 let base = format!(
1226 "SELECT {cols} FROM photos p
1227 JOIN album_photos ap ON ap.photo_id = p.id
1228 WHERE ap.album_id = ?1 AND p.is_trashed = 0",
1229 cols = PHOTO_LITE_P_COLUMNS
1230 );
1231
1232 match cursor {
1233 Some((Some(d), id)) => {
1234 let sql = format!(
1235 "{base}
1236 AND ((p.date_taken IS NOT NULL AND
1237 (p.date_taken < ?2 OR (p.date_taken = ?2 AND p.id < ?3)))
1238 OR (p.date_taken IS NULL AND ?2 IS NULL AND p.id < ?3))
1239 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1240 LIMIT ?4"
1241 );
1242 let date_str = d.to_rfc3339();
1243 let mut stmt = self.conn.prepare(&sql)?;
1244 let rows = stmt
1245 .query_map(params![album_id, date_str, id, limit], row_to_photo_lite)?
1246 .collect::<SqliteResult<Vec<_>>>()?;
1247 Ok(rows)
1248 }
1249 Some((None, id)) => {
1250 let sql = format!(
1251 "{base} AND p.date_taken IS NULL AND p.id < ?2
1252 ORDER BY p.id DESC LIMIT ?3"
1253 );
1254 let mut stmt = self.conn.prepare(&sql)?;
1255 let rows = stmt
1256 .query_map(params![album_id, id, limit], row_to_photo_lite)?
1257 .collect::<SqliteResult<Vec<_>>>()?;
1258 Ok(rows)
1259 }
1260 None => {
1261 let sql = format!(
1262 "{base}
1263 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1264 LIMIT ?2"
1265 );
1266 let mut stmt = self.conn.prepare(&sql)?;
1267 let rows = stmt
1268 .query_map(params![album_id, limit], row_to_photo_lite)?
1269 .collect::<SqliteResult<Vec<_>>>()?;
1270 Ok(rows)
1271 }
1272 }
1273 }
1274
1275 pub fn list_after_favorites(
1278 &self,
1279 cursor: Option<(Option<DateTime<Utc>>, i64)>,
1280 limit: i64,
1281 ) -> SqliteResult<Vec<PhotoLite>> {
1282 let base = format!(
1283 "SELECT {cols} FROM photos
1284 WHERE is_favorite = TRUE AND is_trashed = 0",
1285 cols = PHOTO_LITE_COLUMNS
1286 );
1287
1288 match cursor {
1289 Some((Some(d), id)) => {
1290 let sql = format!(
1291 "{base}
1292 AND ((date_taken IS NOT NULL AND
1293 (date_taken < ?1 OR (date_taken = ?1 AND id < ?2)))
1294 OR (date_taken IS NULL AND ?1 IS NULL AND id < ?2))
1295 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1296 LIMIT ?3"
1297 );
1298 let date_str = d.to_rfc3339();
1299 let mut stmt = self.conn.prepare(&sql)?;
1300 let rows = stmt
1301 .query_map(params![date_str, id, limit], row_to_photo_lite)?
1302 .collect::<SqliteResult<Vec<_>>>()?;
1303 Ok(rows)
1304 }
1305 Some((None, id)) => {
1306 let sql = format!(
1307 "{base} AND date_taken IS NULL AND id < ?1
1308 ORDER BY id DESC LIMIT ?2"
1309 );
1310 let mut stmt = self.conn.prepare(&sql)?;
1311 let rows = stmt
1312 .query_map(params![id, limit], row_to_photo_lite)?
1313 .collect::<SqliteResult<Vec<_>>>()?;
1314 Ok(rows)
1315 }
1316 None => {
1317 let sql = format!(
1318 "{base}
1319 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1320 LIMIT ?1"
1321 );
1322 let mut stmt = self.conn.prepare(&sql)?;
1323 let rows = stmt
1324 .query_map(params![limit], row_to_photo_lite)?
1325 .collect::<SqliteResult<Vec<_>>>()?;
1326 Ok(rows)
1327 }
1328 }
1329 }
1330
1331 pub fn list_after_by_person(
1333 &self,
1334 cluster_id: i64,
1335 cursor: Option<(Option<DateTime<Utc>>, i64)>,
1336 limit: i64,
1337 ) -> SqliteResult<Vec<PhotoLite>> {
1338 let base = format!(
1339 "SELECT DISTINCT {cols} FROM photos p
1340 JOIN (
1341 SELECT photo_id FROM faces WHERE cluster_id = ?1
1342 UNION
1343 SELECT photo_id FROM photo_inferred_identities WHERE cluster_id = ?1
1344 ) matches ON matches.photo_id = p.id
1345 WHERE p.is_trashed = 0",
1346 cols = PHOTO_LITE_P_COLUMNS
1347 );
1348
1349 match cursor {
1350 Some((Some(d), id)) => {
1351 let sql = format!(
1352 "{base}
1353 AND ((p.date_taken IS NOT NULL AND
1354 (p.date_taken < ?2 OR (p.date_taken = ?2 AND p.id < ?3)))
1355 OR (p.date_taken IS NULL AND ?2 IS NULL AND p.id < ?3))
1356 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1357 LIMIT ?4"
1358 );
1359 let date_str = d.to_rfc3339();
1360 let mut stmt = self.conn.prepare(&sql)?;
1361 let rows = stmt
1362 .query_map(params![cluster_id, date_str, id, limit], row_to_photo_lite)?
1363 .collect::<SqliteResult<Vec<_>>>()?;
1364 Ok(rows)
1365 }
1366 Some((None, id)) => {
1367 let sql = format!(
1368 "{base} AND p.date_taken IS NULL AND p.id < ?2
1369 ORDER BY p.id DESC LIMIT ?3"
1370 );
1371 let mut stmt = self.conn.prepare(&sql)?;
1372 let rows = stmt
1373 .query_map(params![cluster_id, id, limit], row_to_photo_lite)?
1374 .collect::<SqliteResult<Vec<_>>>()?;
1375 Ok(rows)
1376 }
1377 None => {
1378 let sql = format!(
1379 "{base}
1380 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
1381 LIMIT ?2"
1382 );
1383 let mut stmt = self.conn.prepare(&sql)?;
1384 let rows = stmt
1385 .query_map(params![cluster_id, limit], row_to_photo_lite)?
1386 .collect::<SqliteResult<Vec<_>>>()?;
1387 Ok(rows)
1388 }
1389 }
1390 }
1391
1392 pub fn list_after_by_date(
1394 &self,
1395 start_iso: &str,
1396 end_iso: &str,
1397 cursor: Option<(Option<DateTime<Utc>>, i64)>,
1398 limit: i64,
1399 ) -> SqliteResult<Vec<PhotoLite>> {
1400 let base = format!(
1401 "SELECT {cols} FROM photos
1402 WHERE is_trashed = 0
1403 AND date_taken IS NOT NULL
1404 AND date_taken >= ?1 AND date_taken < ?2",
1405 cols = PHOTO_LITE_COLUMNS
1406 );
1407 match cursor {
1408 Some((Some(d), id)) => {
1409 let sql = format!(
1410 "{base}
1411 AND (date_taken < ?3 OR (date_taken = ?3 AND id < ?4))
1412 ORDER BY date_taken DESC, id DESC LIMIT ?5"
1413 );
1414 let date_str = d.to_rfc3339();
1415 let mut stmt = self.conn.prepare(&sql)?;
1416 let rows = stmt
1417 .query_map(
1418 params![start_iso, end_iso, date_str, id, limit],
1419 row_to_photo_lite,
1420 )?
1421 .collect::<SqliteResult<Vec<_>>>()?;
1422 Ok(rows)
1423 }
1424 Some((None, _)) | None => {
1425 let sql = format!(
1426 "{base}
1427 ORDER BY date_taken DESC, id DESC LIMIT ?3"
1428 );
1429 let mut stmt = self.conn.prepare(&sql)?;
1430 let rows = stmt
1431 .query_map(params![start_iso, end_iso, limit], row_to_photo_lite)?
1432 .collect::<SqliteResult<Vec<_>>>()?;
1433 Ok(rows)
1434 }
1435 }
1436 }
1437
1438 pub fn list_after_by_place(
1440 &self,
1441 city: Option<&str>,
1442 country: Option<&str>,
1443 cursor: Option<(Option<DateTime<Utc>>, i64)>,
1444 limit: i64,
1445 ) -> SqliteResult<Vec<PhotoLite>> {
1446 let mut wheres = vec!["is_trashed = 0".to_string()];
1447 if city.is_some() {
1448 wheres.push("location_city = ?CITY".to_string());
1449 }
1450 if country.is_some() {
1451 wheres.push("location_country = ?COUNTRY".to_string());
1452 }
1453 let base_where = wheres.join(" AND ");
1454 let base = format!(
1455 "SELECT {cols} FROM photos WHERE {w}",
1456 cols = PHOTO_LITE_COLUMNS,
1457 w = base_where
1458 );
1459
1460 let mut next = 1usize;
1462 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1463 let mut sql = base;
1464 if let Some(c) = city {
1465 sql = sql.replacen("?CITY", &format!("?{}", next), 1);
1466 next += 1;
1467 bind.push(Box::new(c.to_string()));
1468 }
1469 if let Some(c) = country {
1470 sql = sql.replacen("?COUNTRY", &format!("?{}", next), 1);
1471 next += 1;
1472 bind.push(Box::new(c.to_string()));
1473 }
1474
1475 match cursor {
1476 Some((Some(d), id)) => {
1477 sql.push_str(&format!(
1478 " AND ((date_taken IS NOT NULL AND
1479 (date_taken < ?{a} OR (date_taken = ?{a} AND id < ?{b})))
1480 OR (date_taken IS NULL AND ?{a} IS NULL AND id < ?{b}))
1481 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1482 LIMIT ?{c}",
1483 a = next,
1484 b = next + 1,
1485 c = next + 2
1486 ));
1487 bind.push(Box::new(d.to_rfc3339()));
1488 bind.push(Box::new(id));
1489 bind.push(Box::new(limit));
1490 }
1491 Some((None, id)) => {
1492 sql.push_str(&format!(
1493 " AND date_taken IS NULL AND id < ?{a}
1494 ORDER BY id DESC LIMIT ?{b}",
1495 a = next,
1496 b = next + 1
1497 ));
1498 bind.push(Box::new(id));
1499 bind.push(Box::new(limit));
1500 }
1501 None => {
1502 sql.push_str(&format!(
1503 " ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1504 LIMIT ?{}",
1505 next
1506 ));
1507 bind.push(Box::new(limit));
1508 }
1509 }
1510
1511 let mut stmt = self.conn.prepare(&sql)?;
1512 let bind_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| &**b).collect();
1513 let rows = stmt
1514 .query_map(rusqlite::params_from_iter(bind_refs), row_to_photo_lite)?
1515 .collect::<SqliteResult<Vec<_>>>()?;
1516 Ok(rows)
1517 }
1518
1519 pub fn list_in_bounds(
1524 &self,
1525 north: f64,
1526 south: f64,
1527 east: f64,
1528 west: f64,
1529 cap: i64,
1530 ) -> SqliteResult<Vec<PhotoLite>> {
1531 let lng_clause = if west <= east {
1533 "gps_longitude >= ?3 AND gps_longitude <= ?4"
1534 } else {
1535 "(gps_longitude >= ?3 OR gps_longitude <= ?4)"
1536 };
1537 let sql = format!(
1538 "SELECT {cols} FROM photos
1539 WHERE is_trashed = 0
1540 AND gps_latitude IS NOT NULL
1541 AND gps_longitude IS NOT NULL
1542 AND gps_latitude >= ?2 AND gps_latitude <= ?1
1543 AND {lng}
1544 ORDER BY date_taken IS NULL ASC, date_taken DESC, id DESC
1545 LIMIT ?5",
1546 cols = PHOTO_LITE_COLUMNS,
1547 lng = lng_clause,
1548 );
1549 let mut stmt = self.conn.prepare(&sql)?;
1550 let rows = stmt
1551 .query_map(params![north, south, west, east, cap], row_to_photo_lite)?
1552 .collect::<SqliteResult<Vec<_>>>()?;
1553 Ok(rows)
1554 }
1555}