1use rusqlite::{params, types::ToSql, Connection, Result as SqliteResult};
4
5use super::MAX_ROWS_PER_INSERT;
6
7#[allow(dead_code)]
9#[derive(Debug, Clone)]
10pub struct AlbumRecord {
11 pub id: i64,
12 pub name: String,
13 pub cover_photo_id: Option<i64>,
14 pub cover_auto_picked: bool,
15 pub photo_count: i64,
16 pub date_range_start: Option<String>,
18 pub date_range_end: Option<String>,
20 pub created_at: String,
21 pub updated_at: String,
22 pub created_by: String,
23 pub cover_thumbnail_path: Option<String>,
25}
26
27pub struct AlbumRepo<'a> {
28 conn: &'a Connection,
29}
30
31impl<'a> AlbumRepo<'a> {
32 pub fn new(conn: &'a Connection) -> Self {
33 Self { conn }
34 }
35
36 pub fn create(&self, name: &str) -> SqliteResult<i64> {
38 self.create_with_source(name, "user")
39 }
40
41 pub fn create_with_source(&self, name: &str, created_by: &str) -> SqliteResult<i64> {
43 self.conn.execute(
44 "INSERT INTO albums (name, created_by) VALUES (?1, ?2)",
45 params![name, created_by],
46 )?;
47 Ok(self.conn.last_insert_rowid())
48 }
49
50 pub fn rename(&self, album_id: i64, name: &str) -> SqliteResult<()> {
52 self.conn.execute(
53 "UPDATE albums SET name = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
54 params![name, album_id],
55 )?;
56 Ok(())
57 }
58
59 pub fn delete(&self, album_id: i64) -> SqliteResult<()> {
61 self.conn.execute(
62 "DELETE FROM album_photos WHERE album_id = ?1",
63 params![album_id],
64 )?;
65 self.conn
66 .execute("DELETE FROM albums WHERE id = ?1", params![album_id])?;
67 Ok(())
68 }
69
70 pub fn add_photos(&self, album_id: i64, photo_ids: &[i64]) -> SqliteResult<usize> {
72 if photo_ids.is_empty() {
73 return Ok(0);
74 }
75 let tx = self.conn.unchecked_transaction()?;
76 let mut added = 0usize;
77 for chunk in photo_ids.chunks(MAX_ROWS_PER_INSERT) {
78 let candidate_sql = chunk
79 .iter()
80 .enumerate()
81 .map(|(idx, _)| {
82 if idx == 0 {
83 "SELECT ? AS photo_id"
84 } else {
85 "UNION ALL SELECT ?"
86 }
87 })
88 .collect::<Vec<_>>()
89 .join(" ");
90 let sql = format!(
91 r#"
92 INSERT OR IGNORE INTO album_photos (album_id, photo_id)
93 SELECT ?, candidate.photo_id
94 FROM ({candidate_sql}) AS candidate
95 JOIN photos p ON p.id = candidate.photo_id
96 WHERE p.is_trashed = FALSE
97 "#
98 );
99 let mut values: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() + 1);
100 values.push(Box::new(album_id));
101 for pid in chunk {
102 values.push(Box::new(*pid));
103 }
104 let refs: Vec<&dyn ToSql> = values.iter().map(|v| v.as_ref()).collect();
105 added += tx.execute(&sql, refs.as_slice())?;
106 }
107 update_album_stats_in_conn(&tx, album_id)?;
108 tx.commit()?;
109 Ok(added)
110 }
111
112 pub fn remove_photos(&self, album_id: i64, photo_ids: &[i64]) -> SqliteResult<usize> {
114 if photo_ids.is_empty() {
115 return Ok(0);
116 }
117 let tx = self.conn.unchecked_transaction()?;
118 let mut removed = 0usize;
119 for chunk in photo_ids.chunks(MAX_ROWS_PER_INSERT) {
120 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
121 let sql = format!(
122 "DELETE FROM album_photos WHERE album_id = ? AND photo_id IN ({placeholders})"
123 );
124 let mut values: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() + 1);
125 values.push(Box::new(album_id));
126 for pid in chunk {
127 values.push(Box::new(*pid));
128 }
129 let refs: Vec<&dyn ToSql> = values.iter().map(|v| v.as_ref()).collect();
130 removed += tx.execute(&sql, refs.as_slice())?;
131 }
132 update_album_stats_in_conn(&tx, album_id)?;
133 tx.commit()?;
134 Ok(removed)
135 }
136
137 pub fn get_all(&self) -> SqliteResult<Vec<AlbumRecord>> {
139 let mut stmt = self.conn.prepare(
140 r#"
141 SELECT a.id, a.name, a.cover_photo_id, a.cover_auto_picked,
142 COUNT(p.id) AS live_photo_count, a.created_at, a.updated_at,
143 MIN(p.date_taken) AS date_range_start,
144 MAX(p.date_taken) AS date_range_end,
145 pcov.id AS live_cover_photo_id,
146 COALESCE(a.created_by, 'user') AS created_by,
147 pcov.thumbnail_path AS cover_thumbnail_path
148 FROM albums a
149 LEFT JOIN album_photos ap ON a.id = ap.album_id
150 LEFT JOIN photos p ON ap.photo_id = p.id AND p.is_trashed = FALSE
151 LEFT JOIN photos pcov ON pcov.id = a.cover_photo_id AND pcov.is_trashed = FALSE
152 GROUP BY a.id
153 ORDER BY a.updated_at DESC
154 "#,
155 )?;
156
157 let rows = stmt.query_map([], |row| {
158 Ok(AlbumRecord {
159 id: row.get(0)?,
160 name: row.get(1)?,
161 cover_photo_id: row.get(9)?,
162 cover_auto_picked: row.get::<_, bool>(3).unwrap_or(true),
163 photo_count: row.get(4)?,
164 created_at: row.get(5)?,
165 updated_at: row.get(6)?,
166 date_range_start: row.get(7)?,
167 date_range_end: row.get(8)?,
168 cover_thumbnail_path: row.get(11)?,
169 created_by: row.get(10)?,
170 })
171 })?;
172
173 let mut albums = Vec::new();
174 for r in rows {
175 albums.push(r?);
176 }
177 Ok(albums)
178 }
179
180 pub fn get_album_photo_ids(&self, album_id: i64) -> SqliteResult<Vec<i64>> {
182 let mut stmt = self.conn.prepare(
183 r#"
184 SELECT ap.photo_id FROM album_photos ap
185 JOIN photos p ON ap.photo_id = p.id
186 WHERE ap.album_id = ?1
187 AND p.is_trashed = FALSE
188 ORDER BY p.date_taken IS NULL ASC, p.date_taken DESC, p.id DESC
189 "#,
190 )?;
191
192 let rows = stmt.query_map(params![album_id], |row| row.get(0))?;
193 let mut ids = Vec::new();
194 for r in rows {
195 ids.push(r?);
196 }
197 Ok(ids)
198 }
199
200 pub fn get_albums_for_photo(&self, photo_id: i64) -> SqliteResult<Vec<(i64, String)>> {
202 let mut stmt = self.conn.prepare(
203 r#"
204 SELECT a.id, a.name FROM albums a
205 JOIN album_photos ap ON a.id = ap.album_id
206 JOIN photos p ON p.id = ap.photo_id
207 WHERE ap.photo_id = ?1
208 AND p.is_trashed = FALSE
209 ORDER BY a.name ASC
210 "#,
211 )?;
212
213 let rows = stmt.query_map(params![photo_id], |row| Ok((row.get(0)?, row.get(1)?)))?;
214
215 let mut result = Vec::new();
216 for r in rows {
217 result.push(r?);
218 }
219 Ok(result)
220 }
221
222 pub fn auto_pick_cover(&self, album_id: i64) -> SqliteResult<()> {
224 auto_pick_cover_in_conn(self.conn, album_id)
225 }
226
227 pub fn refresh_stats(&self, album_id: i64) -> SqliteResult<()> {
229 self.update_album_stats(album_id)
230 }
231
232 fn update_album_stats(&self, album_id: i64) -> SqliteResult<()> {
234 update_album_stats_in_conn(self.conn, album_id)
235 }
236}
237
238fn update_album_stats_in_conn(conn: &Connection, album_id: i64) -> SqliteResult<()> {
239 conn.execute(
240 r#"
241 UPDATE albums SET
242 photo_count = (
243 SELECT COUNT(*)
244 FROM album_photos ap
245 JOIN photos p ON p.id = ap.photo_id
246 WHERE ap.album_id = ?1
247 AND p.is_trashed = FALSE
248 ),
249 updated_at = CURRENT_TIMESTAMP
250 WHERE id = ?1
251 "#,
252 params![album_id],
253 )?;
254
255 let auto_picked: bool = conn
257 .query_row(
258 "SELECT cover_auto_picked FROM albums WHERE id = ?1",
259 params![album_id],
260 |row| row.get(0),
261 )
262 .unwrap_or(true);
263
264 if auto_picked {
265 auto_pick_cover_in_conn(conn, album_id)?;
266 }
267
268 Ok(())
269}
270
271fn auto_pick_cover_in_conn(conn: &Connection, album_id: i64) -> SqliteResult<()> {
272 let cover_id: Option<i64> = conn
273 .query_row(
274 r#"
275 SELECT ap.photo_id FROM album_photos ap
276 JOIN photos p ON ap.photo_id = p.id
277 LEFT JOIN faces f ON p.id = f.photo_id
278 WHERE ap.album_id = ?1 AND p.is_trashed = FALSE
279 GROUP BY ap.photo_id
280 ORDER BY
281 COUNT(f.id) > 0 DESC,
282 p.width > p.height DESC,
283 p.date_taken DESC
284 LIMIT 1
285 "#,
286 params![album_id],
287 |row| row.get(0),
288 )
289 .ok();
290
291 conn.execute(
292 "UPDATE albums SET cover_photo_id = ?1, cover_auto_picked = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
293 params![cover_id, album_id],
294 )?;
295 Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn setup() -> Connection {
303 let conn = Connection::open_in_memory().expect("open in-memory db");
304 crate::db::create_schema(&conn).expect("create schema");
305 conn
306 }
307
308 fn insert_photo(conn: &Connection, id: i64, trashed: bool) {
309 conn.execute(
310 r#"
311 INSERT INTO photos
312 (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
313 VALUES
314 (?1, ?2, ?3, ?4, ?5, ?6, ?7)
315 "#,
316 params![
317 id,
318 format!("IMG_{id:04}.jpg"),
319 format!("IMG_{id:04}.jpg"),
320 format!("hash-{id}"),
321 1000 + id,
322 format!("2024-01-01T12:0{id}:00Z"),
323 trashed,
324 ],
325 )
326 .expect("insert photo");
327 }
328
329 #[test]
330 fn add_photos_batches_live_rows_and_ignores_duplicates_or_trashed() {
331 let conn = setup();
332 for id in 1..=3 {
333 insert_photo(&conn, id, id == 3);
334 }
335 let repo = AlbumRepo::new(&conn);
336 let album_id = repo.create("Trip").unwrap();
337
338 let added = repo.add_photos(album_id, &[1, 2, 2, 3]).unwrap();
339
340 assert_eq!(added, 2);
341 let album = repo.get_all().unwrap().pop().unwrap();
342 assert_eq!(album.photo_count, 2);
343 assert!(album.cover_photo_id.is_some());
344 }
345
346 #[test]
347 fn remove_photos_reports_actual_removed_count_and_refreshes_stats() {
348 let conn = setup();
349 for id in 1..=3 {
350 insert_photo(&conn, id, false);
351 }
352 let repo = AlbumRepo::new(&conn);
353 let album_id = repo.create("Trip").unwrap();
354 repo.add_photos(album_id, &[1, 2, 3]).unwrap();
355
356 let removed = repo.remove_photos(album_id, &[2, 999]).unwrap();
357
358 assert_eq!(removed, 1);
359 let album = repo.get_all().unwrap().pop().unwrap();
360 assert_eq!(album.photo_count, 2);
361 assert_eq!(repo.get_album_photo_ids(album_id).unwrap(), vec![3, 1]);
362 }
363}