Skip to main content

smriti/db/
stack_repo.rs

1//! Photo stack persistence.
2
3use rusqlite::{params, params_from_iter, types::ToSql, Connection, Result as SqliteResult};
4
5use super::MAX_ROWS_PER_INSERT;
6
7#[derive(Debug, Clone)]
8pub struct PhotoStackRecord {
9    pub id: i64,
10    pub kind: String,
11    pub source_group_id: i64,
12    pub cover_photo_id: i64,
13    pub member_count: i64,
14    pub confidence: f32,
15}
16
17#[derive(Debug, Clone)]
18pub struct PhotoStackMemberRecord {
19    pub photo_id: i64,
20    pub thumbnail_path: Option<String>,
21    pub date_taken: Option<String>,
22    pub quality_score: f32,
23    pub score_reasons: Option<String>,
24    pub is_cover: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct StackCandidate {
29    pub kind: String,
30    pub source_group_id: i64,
31    pub source_group_hash: Option<String>,
32    pub photo_ids: Vec<i64>,
33    pub cover_photo_id: i64,
34    pub confidence: f32,
35    pub member_scores: Vec<(i64, f32, String)>,
36}
37
38pub struct PhotoStackRepo<'a> {
39    conn: &'a Connection,
40}
41
42impl<'a> PhotoStackRepo<'a> {
43    pub fn new(conn: &'a Connection) -> Self {
44        Self { conn }
45    }
46
47    pub fn sync_stacks(&self, candidates: &[StackCandidate]) -> SqliteResult<()> {
48        use std::collections::HashSet;
49
50        let tx = self.conn.unchecked_transaction()?;
51        let mut seen: HashSet<(String, i64)> = HashSet::new();
52
53        for c in candidates {
54            if c.photo_ids.len() < 2 || !c.photo_ids.contains(&c.cover_photo_id) {
55                continue;
56            }
57            seen.insert((c.kind.clone(), c.source_group_id));
58
59            let existing = tx.query_row(
60                "SELECT id, dismissed, cover_photo_id FROM photo_stacks WHERE kind = ?1 AND source_group_id = ?2",
61                params![c.kind, c.source_group_id],
62                |r| Ok((r.get::<_, i64>(0)?, r.get::<_, bool>(1)?, r.get::<_, i64>(2)?)),
63            );
64
65            let (stack_id, dismissed, existing_cover) = match existing {
66                Ok(row) => row,
67                Err(rusqlite::Error::QueryReturnedNoRows) => {
68                    tx.execute(
69                        r#"
70                        INSERT INTO photo_stacks
71                            (kind, source_group_id, source_group_hash, cover_photo_id, confidence)
72                        VALUES (?1, ?2, ?3, ?4, ?5)
73                        "#,
74                        params![
75                            c.kind,
76                            c.source_group_id,
77                            c.source_group_hash,
78                            c.cover_photo_id,
79                            c.confidence
80                        ],
81                    )?;
82                    (tx.last_insert_rowid(), false, c.cover_photo_id)
83                }
84                Err(e) => return Err(e),
85            };
86
87            if dismissed {
88                continue;
89            }
90
91            let cover = if c.photo_ids.contains(&existing_cover) {
92                existing_cover
93            } else {
94                c.cover_photo_id
95            };
96
97            tx.execute(
98                r#"
99                UPDATE photo_stacks
100                   SET source_group_hash = ?3,
101                       cover_photo_id = ?4,
102                       confidence = ?5,
103                       updated_at = CURRENT_TIMESTAMP
104                 WHERE kind = ?1 AND source_group_id = ?2
105                "#,
106                params![
107                    c.kind,
108                    c.source_group_id,
109                    c.source_group_hash,
110                    cover,
111                    c.confidence
112                ],
113            )?;
114            tx.execute(
115                "DELETE FROM photo_stack_members WHERE stack_id = ?1",
116                params![stack_id],
117            )?;
118            insert_members(&tx, stack_id, cover, &c.member_scores)?;
119        }
120
121        let mut stmt = tx.prepare(
122            "SELECT id, kind, source_group_id FROM photo_stacks WHERE dismissed = FALSE",
123        )?;
124        let existing: Vec<(i64, String, i64)> = stmt
125            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
126            .collect::<SqliteResult<Vec<_>>>()?;
127        drop(stmt);
128        for (id, kind, source_id) in existing {
129            if !seen.contains(&(kind, source_id)) {
130                tx.execute("DELETE FROM photo_stacks WHERE id = ?1", params![id])?;
131            }
132        }
133
134        tx.commit()
135    }
136
137    pub fn get_stack(&self, stack_id: i64) -> SqliteResult<Option<PhotoStackRecord>> {
138        match self.conn.query_row(
139            r#"
140            SELECT s.id, s.kind, s.source_group_id, s.cover_photo_id,
141                   COUNT(m.id), s.confidence
142              FROM photo_stacks s
143              JOIN photo_stack_members m ON m.stack_id = s.id
144              JOIN photos p ON p.id = m.photo_id AND p.is_trashed = FALSE
145             WHERE s.id = ?1 AND s.dismissed = FALSE
146          GROUP BY s.id
147            HAVING COUNT(m.id) >= 2
148            "#,
149            params![stack_id],
150            row_to_stack,
151        ) {
152            Ok(s) => Ok(Some(s)),
153            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
154            Err(e) => Err(e),
155        }
156    }
157
158    pub fn get_stack_for_photo(&self, photo_id: i64) -> SqliteResult<Option<PhotoStackRecord>> {
159        match self.conn.query_row(
160            r#"
161            SELECT s.id, s.kind, s.source_group_id, s.cover_photo_id,
162                   COUNT(all_m.id), s.confidence
163              FROM photo_stack_members m
164              JOIN photos p ON p.id = m.photo_id AND p.is_trashed = FALSE
165              JOIN photo_stacks s ON s.id = m.stack_id
166              JOIN photo_stack_members all_m ON all_m.stack_id = s.id
167              JOIN photos all_p ON all_p.id = all_m.photo_id AND all_p.is_trashed = FALSE
168             WHERE m.photo_id = ?1 AND s.dismissed = FALSE
169          GROUP BY s.id
170            HAVING COUNT(all_m.id) >= 2
171            "#,
172            params![photo_id],
173            row_to_stack,
174        ) {
175            Ok(s) => Ok(Some(s)),
176            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
177            Err(e) => Err(e),
178        }
179    }
180
181    pub fn get_members(&self, stack_id: i64) -> SqliteResult<Vec<PhotoStackMemberRecord>> {
182        let mut stmt = self.conn.prepare(
183            r#"
184            SELECT m.photo_id, p.thumbnail_path, p.date_taken,
185                   m.quality_score, m.score_reasons, m.is_cover
186              FROM photo_stack_members m
187              JOIN photos p ON p.id = m.photo_id
188              JOIN photo_stacks s ON s.id = m.stack_id
189             WHERE m.stack_id = ?1 AND s.dismissed = FALSE AND p.is_trashed = FALSE
190          ORDER BY m.is_cover DESC, m.quality_score DESC, p.date_taken ASC, m.photo_id ASC
191            "#,
192        )?;
193        let rows = stmt.query_map(params![stack_id], |r| {
194            Ok(PhotoStackMemberRecord {
195                photo_id: r.get(0)?,
196                thumbnail_path: r.get(1)?,
197                date_taken: r.get(2)?,
198                quality_score: r.get(3)?,
199                score_reasons: r.get(4)?,
200                is_cover: r.get(5)?,
201            })
202        })?;
203        rows.collect()
204    }
205
206    pub fn set_cover(&self, stack_id: i64, photo_id: i64) -> SqliteResult<()> {
207        let tx = self.conn.unchecked_transaction()?;
208        let exists: i64 = tx.query_row(
209            "SELECT COUNT(*)
210               FROM photo_stack_members m
211               JOIN photo_stacks s ON s.id = m.stack_id
212               JOIN photos p ON p.id = m.photo_id
213              WHERE m.stack_id = ?1
214                AND m.photo_id = ?2
215                AND s.dismissed = FALSE
216                AND p.is_trashed = FALSE",
217            params![stack_id, photo_id],
218            |r| r.get(0),
219        )?;
220        if exists == 0 {
221            return Err(rusqlite::Error::QueryReturnedNoRows);
222        }
223        tx.execute(
224            "UPDATE photo_stack_members SET is_cover = FALSE WHERE stack_id = ?1",
225            params![stack_id],
226        )?;
227        tx.execute(
228            "UPDATE photo_stack_members SET is_cover = TRUE WHERE stack_id = ?1 AND photo_id = ?2",
229            params![stack_id, photo_id],
230        )?;
231        tx.execute(
232            "UPDATE photo_stacks SET cover_photo_id = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
233            params![stack_id, photo_id],
234        )?;
235        tx.commit()
236    }
237
238    pub fn remove_member(&self, stack_id: i64, photo_id: i64) -> SqliteResult<()> {
239        let tx = self.conn.unchecked_transaction()?;
240        let removed = tx.execute(
241            "DELETE FROM photo_stack_members
242              WHERE stack_id = ?1
243                AND photo_id = ?2
244                AND EXISTS (
245                    SELECT 1 FROM photo_stacks s
246                     WHERE s.id = ?1 AND s.dismissed = FALSE
247                )",
248            params![stack_id, photo_id],
249        )?;
250        if removed == 0 {
251            return Err(rusqlite::Error::QueryReturnedNoRows);
252        }
253        let count: i64 = tx.query_row(
254            "SELECT COUNT(*)
255               FROM photo_stack_members m
256               JOIN photos p ON p.id = m.photo_id
257              WHERE m.stack_id = ?1 AND p.is_trashed = FALSE",
258            params![stack_id],
259            |r| r.get(0),
260        )?;
261        if count < 2 {
262            tx.execute("DELETE FROM photo_stacks WHERE id = ?1", params![stack_id])?;
263            tx.commit()?;
264            return Ok(());
265        }
266        let cover_exists: i64 = tx.query_row(
267            "SELECT COUNT(*)
268               FROM photo_stack_members m
269               JOIN photos p ON p.id = m.photo_id
270              WHERE m.stack_id = ?1 AND m.is_cover = TRUE AND p.is_trashed = FALSE",
271            params![stack_id],
272            |r| r.get(0),
273        )?;
274        if cover_exists == 0 {
275            let next_cover: i64 = tx.query_row(
276                "SELECT m.photo_id
277                   FROM photo_stack_members m
278                   JOIN photos p ON p.id = m.photo_id
279                  WHERE m.stack_id = ?1 AND p.is_trashed = FALSE
280               ORDER BY m.quality_score DESC, m.photo_id ASC
281                  LIMIT 1",
282                params![stack_id],
283                |r| r.get(0),
284            )?;
285            tx.execute(
286                "UPDATE photo_stack_members SET is_cover = TRUE WHERE stack_id = ?1 AND photo_id = ?2",
287                params![stack_id, next_cover],
288            )?;
289            tx.execute(
290                "UPDATE photo_stacks SET cover_photo_id = ?2 WHERE id = ?1",
291                params![stack_id, next_cover],
292            )?;
293        }
294        tx.commit()
295    }
296
297    pub fn unstack(&self, stack_id: i64) -> SqliteResult<()> {
298        let updated = self.conn.execute(
299            "UPDATE photo_stacks
300                SET dismissed = TRUE, updated_at = CURRENT_TIMESTAMP
301              WHERE id = ?1 AND dismissed = FALSE",
302            params![stack_id],
303        )?;
304        if updated == 0 {
305            return Err(rusqlite::Error::QueryReturnedNoRows);
306        }
307        Ok(())
308    }
309
310    pub fn photos_to_trash_except_cover(&self, stack_id: i64) -> SqliteResult<Vec<i64>> {
311        let mut stmt = self.conn.prepare(
312            r#"
313            SELECT m.photo_id
314              FROM photo_stack_members m
315              JOIN photo_stacks s ON s.id = m.stack_id
316              JOIN photos p ON p.id = m.photo_id
317             WHERE m.stack_id = ?1 AND m.photo_id != s.cover_photo_id
318               AND s.dismissed = FALSE
319               AND p.is_trashed = FALSE
320            "#,
321        )?;
322        let rows = stmt.query_map(params![stack_id], |r| r.get(0))?;
323        rows.collect()
324    }
325
326    pub fn reconcile_after_photos_trashed(&self, photo_ids: &[i64]) -> SqliteResult<()> {
327        let mut ids: Vec<i64> = photo_ids.iter().copied().filter(|id| *id > 0).collect();
328        ids.sort_unstable();
329        ids.dedup();
330        if ids.is_empty() {
331            return Ok(());
332        }
333
334        let placeholders = repeat_vars(ids.len());
335        let mut stmt = self.conn.prepare(&format!(
336            "SELECT DISTINCT stack_id FROM photo_stack_members WHERE photo_id IN ({placeholders})"
337        ))?;
338        let stack_ids: Vec<i64> = stmt
339            .query_map(params_from_iter(ids.iter()), |r| r.get(0))?
340            .collect::<SqliteResult<Vec<_>>>()?;
341        drop(stmt);
342
343        self.conn.execute(
344            &format!("DELETE FROM photo_stack_members WHERE photo_id IN ({placeholders})"),
345            params_from_iter(ids.iter()),
346        )?;
347
348        for stack_id in stack_ids {
349            let live_count: i64 = self.conn.query_row(
350                "SELECT COUNT(*)
351                   FROM photo_stack_members m
352                   JOIN photos p ON p.id = m.photo_id
353                  WHERE m.stack_id = ?1 AND p.is_trashed = FALSE",
354                params![stack_id],
355                |r| r.get(0),
356            )?;
357            if live_count < 2 {
358                self.conn
359                    .execute("DELETE FROM photo_stacks WHERE id = ?1", params![stack_id])?;
360                continue;
361            }
362
363            let live_cover_exists: i64 = self.conn.query_row(
364                "SELECT COUNT(*)
365                   FROM photo_stack_members m
366                   JOIN photos p ON p.id = m.photo_id
367                  WHERE m.stack_id = ?1 AND m.is_cover = TRUE AND p.is_trashed = FALSE",
368                params![stack_id],
369                |r| r.get(0),
370            )?;
371            if live_cover_exists > 0 {
372                continue;
373            }
374
375            let next_cover: i64 = self.conn.query_row(
376                "SELECT m.photo_id
377                   FROM photo_stack_members m
378                   JOIN photos p ON p.id = m.photo_id
379                  WHERE m.stack_id = ?1 AND p.is_trashed = FALSE
380               ORDER BY m.quality_score DESC, m.photo_id ASC
381                  LIMIT 1",
382                params![stack_id],
383                |r| r.get(0),
384            )?;
385            self.conn.execute(
386                "UPDATE photo_stack_members SET is_cover = FALSE WHERE stack_id = ?1",
387                params![stack_id],
388            )?;
389            self.conn.execute(
390                "UPDATE photo_stack_members SET is_cover = TRUE WHERE stack_id = ?1 AND photo_id = ?2",
391                params![stack_id, next_cover],
392            )?;
393            self.conn.execute(
394                "UPDATE photo_stacks SET cover_photo_id = ?2, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
395                params![stack_id, next_cover],
396            )?;
397        }
398
399        Ok(())
400    }
401
402    pub fn delete_stack(&self, stack_id: i64) -> SqliteResult<()> {
403        self.conn
404            .execute("DELETE FROM photo_stacks WHERE id = ?1", params![stack_id])?;
405        Ok(())
406    }
407}
408
409fn row_to_stack(row: &rusqlite::Row) -> SqliteResult<PhotoStackRecord> {
410    Ok(PhotoStackRecord {
411        id: row.get(0)?,
412        kind: row.get(1)?,
413        source_group_id: row.get(2)?,
414        cover_photo_id: row.get(3)?,
415        member_count: row.get(4)?,
416        confidence: row.get(5)?,
417    })
418}
419
420fn insert_members(
421    conn: &Connection,
422    stack_id: i64,
423    cover_photo_id: i64,
424    members: &[(i64, f32, String)],
425) -> SqliteResult<()> {
426    for chunk in members.chunks(MAX_ROWS_PER_INSERT) {
427        let placeholders = (0..chunk.len())
428            .map(|_| "(?, ?, ?, ?, ?)")
429            .collect::<Vec<_>>()
430            .join(", ");
431        let sql = format!(
432            "INSERT INTO photo_stack_members \
433             (stack_id, photo_id, quality_score, score_reasons, is_cover) VALUES {}",
434            placeholders
435        );
436        let mut values: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() * 5);
437        for (photo_id, score, reasons) in chunk {
438            values.push(Box::new(stack_id));
439            values.push(Box::new(*photo_id));
440            values.push(Box::new(*score));
441            values.push(Box::new(reasons.clone()));
442            values.push(Box::new(*photo_id == cover_photo_id));
443        }
444        let refs: Vec<&dyn ToSql> = values.iter().map(|v| v.as_ref()).collect();
445        conn.execute(&sql, refs.as_slice())?;
446    }
447    Ok(())
448}
449
450fn repeat_vars(count: usize) -> String {
451    std::iter::repeat_n("?", count)
452        .collect::<Vec<_>>()
453        .join(", ")
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn setup() -> Connection {
461        let conn = Connection::open_in_memory().expect("open in-memory db");
462        crate::db::create_schema(&conn).expect("create schema");
463        conn
464    }
465
466    fn insert_photo(conn: &Connection, id: i64) {
467        conn.execute(
468            r#"
469            INSERT INTO photos
470                (id, file_path, file_name, file_hash, file_size, date_taken, thumbnail_path)
471            VALUES
472                (?1, ?2, ?3, ?4, ?5, ?6, ?7)
473            "#,
474            params![
475                id,
476                format!("IMG_{id:04}.jpg"),
477                format!("IMG_{id:04}.jpg"),
478                format!("hash-{id}"),
479                1000 + id,
480                format!("2024-01-01T12:0{id}:00Z"),
481                format!(".photovault/thumbs/{id}.jpg"),
482            ],
483        )
484        .expect("insert photo");
485    }
486
487    fn candidate(cover_photo_id: i64) -> StackCandidate {
488        StackCandidate {
489            kind: "burst".into(),
490            source_group_id: 42,
491            source_group_hash: None,
492            photo_ids: vec![1, 2, 3],
493            cover_photo_id,
494            confidence: 0.8,
495            member_scores: vec![
496                (1, 10.0, "resolution".into()),
497                (2, 30.0, "sharpness".into()),
498                (3, 20.0, "faces".into()),
499            ],
500        }
501    }
502
503    #[test]
504    fn sync_stacks_persists_members_and_cover() {
505        let conn = setup();
506        for id in 1..=3 {
507            insert_photo(&conn, id);
508        }
509
510        let repo = PhotoStackRepo::new(&conn);
511        repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
512
513        let stack = repo.get_stack_for_photo(1).expect("load stack").unwrap();
514        assert_eq!(stack.kind, "burst");
515        assert_eq!(stack.cover_photo_id, 2);
516        assert_eq!(stack.member_count, 3);
517
518        let members = repo.get_members(stack.id).expect("load members");
519        assert_eq!(members.len(), 3);
520        assert_eq!(members[0].photo_id, 2);
521        assert!(members[0].is_cover);
522    }
523
524    #[test]
525    fn refresh_preserves_manual_cover_when_member_still_exists() {
526        let conn = setup();
527        for id in 1..=3 {
528            insert_photo(&conn, id);
529        }
530
531        let repo = PhotoStackRepo::new(&conn);
532        repo.sync_stacks(&[candidate(2)]).expect("initial sync");
533        let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
534        repo.set_cover(stack_id, 1).expect("manual cover");
535
536        repo.sync_stacks(&[candidate(2)]).expect("refresh");
537
538        let stack = repo.get_stack(stack_id).expect("load stack").unwrap();
539        assert_eq!(stack.cover_photo_id, 1);
540        let members = repo.get_members(stack_id).expect("load members");
541        assert_eq!(members[0].photo_id, 1);
542        assert!(members[0].is_cover);
543    }
544
545    #[test]
546    fn sync_stacks_preserves_dismissed_stacks_not_seen_this_run() {
547        let conn = setup();
548        for id in 1..=3 {
549            insert_photo(&conn, id);
550        }
551
552        let repo = PhotoStackRepo::new(&conn);
553        repo.sync_stacks(&[candidate(2)]).expect("initial sync");
554        let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
555        repo.unstack(stack_id).expect("dismiss stack");
556
557        repo.sync_stacks(&[]).expect("refresh with no candidates");
558
559        let dismissed: bool = conn
560            .query_row(
561                "SELECT dismissed FROM photo_stacks WHERE id = ?1",
562                params![stack_id],
563                |row| row.get(0),
564            )
565            .expect("dismissed row remains");
566        assert!(dismissed);
567    }
568
569    #[test]
570    fn removing_cover_promotes_best_remaining_member_and_deletes_singleton() {
571        let conn = setup();
572        for id in 1..=3 {
573            insert_photo(&conn, id);
574        }
575
576        let repo = PhotoStackRepo::new(&conn);
577        repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
578        let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
579
580        repo.remove_member(stack_id, 2).expect("remove cover");
581        let stack = repo.get_stack(stack_id).expect("load stack").unwrap();
582        assert_eq!(stack.cover_photo_id, 3);
583
584        repo.remove_member(stack_id, 3)
585            .expect("remove second member");
586        assert!(repo.get_stack(stack_id).expect("load stack").is_none());
587    }
588
589    #[test]
590    fn trashed_members_are_hidden_from_stack_reads() {
591        let conn = setup();
592        for id in 1..=3 {
593            insert_photo(&conn, id);
594        }
595
596        let repo = PhotoStackRepo::new(&conn);
597        repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
598        conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 3", [])
599            .expect("trash photo");
600
601        let stack = repo.get_stack_for_photo(1).expect("load stack").unwrap();
602        assert_eq!(stack.member_count, 2);
603        let members = repo.get_members(stack.id).expect("load members");
604        assert_eq!(members.len(), 2);
605        assert!(members.iter().all(|m| m.photo_id != 3));
606        assert!(repo
607            .get_stack_for_photo(3)
608            .expect("trashed photo stack")
609            .is_none());
610    }
611
612    #[test]
613    fn reconcile_after_trash_prunes_singletons_and_promotes_live_cover() {
614        let conn = setup();
615        for id in 1..=3 {
616            insert_photo(&conn, id);
617        }
618
619        let repo = PhotoStackRepo::new(&conn);
620        repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
621        let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
622
623        conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 2", [])
624            .expect("trash cover");
625        repo.reconcile_after_photos_trashed(&[2])
626            .expect("reconcile cover");
627        let stack = repo.get_stack(stack_id).expect("load stack").unwrap();
628        assert_eq!(stack.cover_photo_id, 3);
629        assert_eq!(stack.member_count, 2);
630
631        conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 3", [])
632            .expect("trash second member");
633        repo.reconcile_after_photos_trashed(&[3])
634            .expect("reconcile singleton");
635        assert!(repo.get_stack(stack_id).expect("load stack").is_none());
636    }
637
638    #[test]
639    fn dismissed_stack_rejects_stale_detail_mutations() {
640        let conn = setup();
641        for id in 1..=3 {
642            insert_photo(&conn, id);
643        }
644
645        let repo = PhotoStackRepo::new(&conn);
646        repo.sync_stacks(&[candidate(2)]).expect("sync stacks");
647        let stack_id = repo.get_stack_for_photo(1).unwrap().unwrap().id;
648        repo.unstack(stack_id).expect("dismiss stack");
649
650        assert!(repo.set_cover(stack_id, 1).is_err());
651        assert!(repo.remove_member(stack_id, 1).is_err());
652        assert!(repo.unstack(stack_id).is_err());
653        assert!(repo
654            .photos_to_trash_except_cover(stack_id)
655            .unwrap()
656            .is_empty());
657    }
658}