Skip to main content

smriti/db/
photo_repo.rs

1//! Photo database repository
2//!
3//! Handles all database operations for photos.
4
5use chrono::{DateTime, Utc};
6use rusqlite::{params, Connection, OptionalExtension, Result as SqliteResult};
7
8use crate::models::{ContentCategory, MediaType, Photo};
9
10/// A discovered file ready for database insertion
11#[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
45/// Photo repository for database operations
46pub 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    /// Batch insert stub rows (streaming scanner Phase 1B).
64    ///
65    /// Uses `INSERT OR IGNORE` so an idempotent re-scan never blows away
66    /// metadata that Phase 2+ already filled in. Only sets the columns
67    /// known at walk time; everything else (EXIF, thumbnail, geocoding)
68    /// is updated later by the pipeline workers.
69    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    /// Batch insert photos within a transaction
100    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    /// Get total photo count
202    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    /// Photos awaiting EXIF / geocoding extraction (Phase 2 of the
237    /// streaming scanner). Drives the "Resume reading metadata" banner.
238    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    /// Photos awaiting thumbnail generation (Phase 3).
247    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    /// Get all photos ordered by date
256    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    /// Get photos by IDs ordered by date (descending).
293    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        // Keep IN clause under SQLite variable limits.
301        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    /// Get a single photo by ID.
343    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
750/// Convert a database row to a Photo struct.
751///
752/// The selected columns must match the ordering used by `PhotoRepo` and document queries.
753pub(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/// Cursor-based pagination methods used by the Tauri command surface.
818///
819/// A short descriptor for a photo that's enough to render a grid cell or
820/// a map pin, without paying the cost of a full Photo materialisation.
821///
822/// Cursor-paginated reads use `(date_taken DESC, id DESC)` with explicit
823/// `IS NULL` ordering so a stable cursor can be carried across pages.
824#[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    /// Cursor-paginated timeline list. Cursor key is `(date_taken, id)`
918    /// descending. NULL date_taken sorts last via SQLite's `IS NULL`.
919    /// `limit` is clamped by the caller; this just trusts it.
920    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; // suppress unused if logging removed
993        Ok(rows)
994    }
995
996    /// Offset-addressed timeline window. Used only for fast jumps into
997    /// very large timelines; cursor paging remains the normal path.
998    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    /// Cursor-paginated photos in a specific album, ordered by date.
1219    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    /// Cursor-paginated favorite photos. This backs the virtual
1276    /// Favorites album without writing synthetic rows to `albums`.
1277    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    /// Cursor-paginated photos featuring a person (face cluster).
1332    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    /// Cursor-paginated photos in a half-open date range: [start, end).
1393    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    /// Cursor-paginated photos in a place (city and/or country, both optional).
1439    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        // Build SQL by replacing named tokens with positional placeholders.
1461        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    /// Photos within a lat/lng bounding box that have GPS coordinates.
1520    /// Used by `map.pins` for server-side aggregation.
1521    ///
1522    /// Bounds are inclusive on all sides. Returns a hard cap of `cap` rows.
1523    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        // Handle the antimeridian-crossing case (west > east) by splitting.
1532        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}