1use rusqlite::{params, Connection, Result as SqliteResult};
8use std::collections::{HashMap, HashSet};
9
10#[allow(dead_code)]
12#[derive(Debug, Clone)]
13pub struct AlbumSuggestionRecord {
14 pub id: i64,
15 pub kind: String, pub title: String,
17 pub photo_ids_json: String, pub cover_photo_id: Option<i64>,
19 pub fingerprint: String,
20 pub status: String, pub seen_count: i64,
22 pub created_at: String,
23 pub cover_thumbnail_path: Option<String>,
25}
26
27impl AlbumSuggestionRecord {
28 pub fn photo_ids(&self) -> Vec<i64> {
30 serde_json::from_str(&self.photo_ids_json).unwrap_or_default()
31 }
32
33 pub fn photo_count(&self) -> usize {
34 self.photo_ids().len()
35 }
36}
37
38pub struct AlbumSuggestionRepo<'a> {
39 conn: &'a Connection,
40}
41
42impl<'a> AlbumSuggestionRepo<'a> {
43 pub fn new(conn: &'a Connection) -> Self {
44 Self { conn }
45 }
46
47 pub fn insert(
49 &self,
50 kind: &str,
51 title: &str,
52 photo_ids: &[i64],
53 cover_photo_id: Option<i64>,
54 fingerprint: &str,
55 ) -> SqliteResult<i64> {
56 let json = serde_json::to_string(photo_ids).unwrap_or_else(|_| "[]".to_string());
57 self.conn.execute(
58 r#"INSERT INTO album_suggestions
59 (kind, title, photo_ids_json, cover_photo_id, fingerprint, status, seen_count)
60 VALUES (?1, ?2, ?3, ?4, ?5, 'pending', 0)"#,
61 params![kind, title, json, cover_photo_id, fingerprint],
62 )?;
63 Ok(self.conn.last_insert_rowid())
64 }
65
66 pub fn get_pending(&self) -> SqliteResult<Vec<AlbumSuggestionRecord>> {
68 let mut stmt = self.conn.prepare(
69 r#"SELECT s.id, s.kind, s.title, s.photo_ids_json, s.cover_photo_id,
70 s.fingerprint, s.status, s.seen_count, s.created_at,
71 pcov.thumbnail_path
72 FROM album_suggestions s
73 LEFT JOIN photos pcov
74 ON pcov.id = s.cover_photo_id
75 AND pcov.is_trashed = FALSE
76 WHERE s.status = 'pending'
77 ORDER BY s.created_at DESC"#,
78 )?;
79 let rows = stmt.query_map([], Self::map_row)?;
80 let mut out = Vec::new();
81 for r in rows {
82 if let Some(record) = self.with_active_photos_only(r?)? {
83 out.push(record);
84 }
85 }
86 Ok(out)
87 }
88
89 pub fn accept(&self, id: i64) -> SqliteResult<()> {
91 let affected = self.conn.execute(
92 "UPDATE album_suggestions SET status = 'accepted' WHERE id = ?1",
93 params![id],
94 )?;
95 if affected == 0 {
96 return Err(rusqlite::Error::QueryReturnedNoRows);
97 }
98 Ok(())
99 }
100
101 pub fn dismiss(&self, id: i64) -> SqliteResult<()> {
103 let affected = self.conn.execute(
104 "UPDATE album_suggestions SET status = 'dismissed' WHERE id = ?1",
105 params![id],
106 )?;
107 if affected == 0 {
108 return Err(rusqlite::Error::QueryReturnedNoRows);
109 }
110 Ok(())
111 }
112
113 pub fn increment_seen_counts(&self) -> SqliteResult<()> {
116 self.conn.execute(
117 "UPDATE album_suggestions SET seen_count = seen_count + 1 WHERE status = 'pending'",
118 [],
119 )?;
120 self.conn.execute(
121 "UPDATE album_suggestions SET status = 'dismissed' WHERE status = 'pending' AND seen_count > 10",
122 [],
123 )?;
124 Ok(())
125 }
126
127 pub fn get_all_fingerprints(&self) -> SqliteResult<Vec<String>> {
129 let mut stmt = self.conn.prepare(
130 "SELECT fingerprint FROM album_suggestions WHERE status IN ('accepted', 'dismissed', 'pending')",
131 )?;
132 let rows = stmt.query_map([], |row| row.get(0))?;
133 let mut out = Vec::new();
134 for r in rows {
135 out.push(r?);
136 }
137 Ok(out)
138 }
139
140 pub fn cleanup_old(&self, days: u32) -> SqliteResult<usize> {
142 let affected = self.conn.execute(
143 r#"DELETE FROM album_suggestions
144 WHERE status != 'pending'
145 AND created_at < datetime('now', ?1)"#,
146 params![format!("-{} days", days)],
147 )?;
148 Ok(affected)
149 }
150
151 fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AlbumSuggestionRecord> {
152 Ok(AlbumSuggestionRecord {
153 id: row.get(0)?,
154 kind: row.get(1)?,
155 title: row.get(2)?,
156 photo_ids_json: row.get(3)?,
157 cover_photo_id: row.get(4)?,
158 fingerprint: row.get(5)?,
159 status: row.get(6)?,
160 seen_count: row.get(7)?,
161 created_at: row.get(8)?,
162 cover_thumbnail_path: row.get(9)?,
163 })
164 }
165
166 fn with_active_photos_only(
167 &self,
168 mut record: AlbumSuggestionRecord,
169 ) -> SqliteResult<Option<AlbumSuggestionRecord>> {
170 let ids = record.photo_ids();
171 if ids.is_empty() {
172 return Ok(None);
173 }
174 let active = self.active_photo_rows(&ids)?;
175 if active.is_empty() {
176 return Ok(None);
177 }
178 let active_set: HashSet<i64> = active.iter().map(|p| p.id).collect();
179 let active_by_id: HashMap<i64, ActivePhoto> =
180 active.into_iter().map(|p| (p.id, p)).collect();
181 let filtered: Vec<i64> = ids
182 .into_iter()
183 .filter(|id| active_set.contains(id))
184 .collect();
185 record.photo_ids_json = serde_json::to_string(&filtered).unwrap_or_else(|_| "[]".into());
186 let cover_is_active = record
187 .cover_photo_id
188 .map(|id| active_set.contains(&id))
189 .unwrap_or(false);
190 let cover_is_renderable = record
191 .cover_photo_id
192 .and_then(|id| active_by_id.get(&id))
193 .map(|p| p.media_type == "photo" || p.thumbnail_path.is_some())
194 .unwrap_or(false);
195 if !cover_is_active || !cover_is_renderable {
196 record.cover_photo_id = filtered
197 .iter()
198 .copied()
199 .find(|id| {
200 active_by_id
201 .get(id)
202 .map(|p| p.media_type == "photo")
203 .unwrap_or(false)
204 })
205 .or_else(|| {
206 filtered.iter().copied().find(|id| {
207 active_by_id
208 .get(id)
209 .and_then(|p| p.thumbnail_path.as_ref())
210 .is_some()
211 })
212 })
213 .or_else(|| filtered.first().copied());
214 }
215 record.cover_thumbnail_path = record
216 .cover_photo_id
217 .and_then(|id| active_by_id.get(&id))
218 .and_then(|p| p.thumbnail_path.clone());
219 let cover = record.cover_photo_id.and_then(|id| active_by_id.get(&id));
220 let has_renderable_cover = cover
221 .map(|p| p.media_type == "photo" || p.thumbnail_path.is_some())
222 .unwrap_or(false);
223 if has_renderable_cover {
224 Ok(Some(record))
225 } else {
226 Ok(None)
227 }
228 }
229
230 fn active_photo_rows(&self, ids: &[i64]) -> SqliteResult<Vec<ActivePhoto>> {
231 let mut out = Vec::new();
232 for chunk in ids.chunks(900) {
233 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
234 let sql = format!(
235 "SELECT id, media_type, thumbnail_path FROM photos WHERE is_trashed = FALSE AND id IN ({})",
236 placeholders
237 );
238 let mut stmt = self.conn.prepare(&sql)?;
239 let rows =
240 stmt.query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
241 Ok(ActivePhoto {
242 id: row.get(0)?,
243 media_type: row.get(1)?,
244 thumbnail_path: row.get(2)?,
245 })
246 })?;
247 for row in rows {
248 out.push(row?);
249 }
250 }
251 Ok(out)
252 }
253}
254
255#[derive(Debug, Clone)]
256struct ActivePhoto {
257 id: i64,
258 media_type: String,
259 thumbnail_path: Option<String>,
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::db::create_schema;
266
267 #[test]
268 fn accept_and_dismiss_report_missing_suggestions() {
269 let conn = Connection::open_in_memory().unwrap();
270 create_schema(&conn).unwrap();
271 let repo = AlbumSuggestionRepo::new(&conn);
272
273 assert!(matches!(
274 repo.accept(999),
275 Err(rusqlite::Error::QueryReturnedNoRows)
276 ));
277 assert!(matches!(
278 repo.dismiss(999),
279 Err(rusqlite::Error::QueryReturnedNoRows)
280 ));
281 }
282}