Skip to main content

max / audiofiles

11.1 KB · 328 lines History Blame Raw
1 //! Collections: named groups of samples (manual or dynamic/saved-search).
2
3 use crate::db::Database;
4 use crate::error::{unix_now, CoreError, Result};
5 use crate::id_types::CollectionId;
6 use crate::search::SearchFilter;
7 use tracing::instrument;
8
9 /// A named collection of samples.
10 ///
11 /// When `filter` is `None`, the collection is manual (explicit membership).
12 /// When `filter` is `Some`, the collection is dynamic (re-computed from the filter on access).
13 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14 pub struct Collection {
15 pub id: CollectionId,
16 pub name: String,
17 pub description: Option<String>,
18 pub created_at: i64,
19 pub member_count: usize,
20 /// Dynamic filter — if set, this collection is a saved search.
21 pub filter: Option<SearchFilter>,
22 }
23
24 impl Collection {
25 /// Returns true if this is a dynamic (saved search) collection.
26 pub fn is_dynamic(&self) -> bool {
27 self.filter.is_some()
28 }
29 }
30
31 /// Create a manual collection, returning its ID.
32 #[instrument(skip_all)]
33 pub fn create_collection(
34 db: &Database,
35 name: &str,
36 description: Option<&str>,
37 ) -> Result<CollectionId> {
38 let now = unix_now();
39 db.conn().execute(
40 "INSERT INTO collections (name, description, created_at) VALUES (?1, ?2, ?3)",
41 rusqlite::params![name, description, now],
42 )?;
43 Ok(CollectionId::from(db.conn().last_insert_rowid()))
44 }
45
46 /// Create a dynamic collection (saved search) with a filter, returning its ID.
47 #[instrument(skip_all)]
48 pub fn create_dynamic_collection(
49 db: &Database,
50 name: &str,
51 filter: &SearchFilter,
52 ) -> Result<CollectionId> {
53 let now = unix_now();
54 let filter_json = serde_json::to_string(filter)
55 .map_err(|e| CoreError::Internal(format!("Failed to serialize filter: {e}")))?;
56 db.conn().execute(
57 "INSERT INTO collections (name, created_at, filter_json) VALUES (?1, ?2, ?3)",
58 rusqlite::params![name, now, filter_json],
59 )?;
60 Ok(CollectionId::from(db.conn().last_insert_rowid()))
61 }
62
63 /// Update the filter on a dynamic collection.
64 #[instrument(skip_all)]
65 pub fn update_collection_filter(
66 db: &Database,
67 id: CollectionId,
68 filter: &SearchFilter,
69 ) -> Result<()> {
70 let filter_json = serde_json::to_string(filter)
71 .map_err(|e| CoreError::Internal(format!("Failed to serialize filter: {e}")))?;
72 let changed = db.conn().execute(
73 "UPDATE collections SET filter_json = ?1 WHERE id = ?2",
74 rusqlite::params![filter_json, id],
75 )?;
76 if changed == 0 {
77 return Err(CoreError::CollectionNotFound(id));
78 }
79 Ok(())
80 }
81
82 /// List all collections with member counts, ordered by name.
83 #[instrument(skip_all)]
84 pub fn list_collections(db: &Database) -> Result<Vec<Collection>> {
85 let mut stmt = db.conn().prepare(
86 "SELECT c.id, c.name, c.description, c.created_at, COUNT(cm.sample_hash), c.filter_json
87 FROM collections c
88 LEFT JOIN collection_members cm ON cm.collection_id = c.id
89 GROUP BY c.id
90 ORDER BY c.name ASC",
91 )?;
92 let rows = stmt.query_map([], |row| {
93 let filter_json: Option<String> = row.get(5)?;
94 let filter = filter_json.and_then(|json| {
95 match serde_json::from_str(&json) {
96 Ok(f) => Some(f),
97 Err(e) => {
98 tracing::warn!("Malformed filter JSON in collection: {e}");
99 None
100 }
101 }
102 });
103 Ok(Collection {
104 id: row.get(0)?,
105 name: row.get(1)?,
106 description: row.get(2)?,
107 created_at: row.get(3)?,
108 member_count: row.get::<_, i64>(4)?.max(0) as usize,
109 filter,
110 })
111 })?;
112 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
113 }
114
115 /// Rename a collection.
116 #[instrument(skip_all)]
117 pub fn rename_collection(db: &Database, id: CollectionId, new_name: &str) -> Result<()> {
118 let changed = db.conn().execute(
119 "UPDATE collections SET name = ?1 WHERE id = ?2",
120 rusqlite::params![new_name, id],
121 )?;
122 if changed == 0 {
123 return Err(CoreError::CollectionNotFound(id));
124 }
125 Ok(())
126 }
127
128 /// Delete a collection by ID. Members are cascaded by the FK constraint.
129 #[instrument(skip_all)]
130 pub fn delete_collection(db: &Database, id: CollectionId) -> Result<()> {
131 let changed = db.conn().execute(
132 "DELETE FROM collections WHERE id = ?1",
133 rusqlite::params![id],
134 )?;
135 if changed == 0 {
136 return Err(CoreError::CollectionNotFound(id));
137 }
138 Ok(())
139 }
140
141 /// Add a sample to a collection. No-op if already a member.
142 #[instrument(skip_all)]
143 pub fn add_to_collection(db: &Database, collection_id: CollectionId, sample_hash: &str) -> Result<()> {
144 let now = unix_now();
145 db.conn().execute(
146 "INSERT OR IGNORE INTO collection_members (collection_id, sample_hash, added_at) VALUES (?1, ?2, ?3)",
147 rusqlite::params![collection_id, sample_hash, now],
148 )?;
149 Ok(())
150 }
151
152 /// Remove a sample from a collection.
153 #[instrument(skip_all)]
154 pub fn remove_from_collection(db: &Database, collection_id: CollectionId, sample_hash: &str) -> Result<()> {
155 db.conn().execute(
156 "DELETE FROM collection_members WHERE collection_id = ?1 AND sample_hash = ?2",
157 rusqlite::params![collection_id, sample_hash],
158 )?;
159 Ok(())
160 }
161
162 /// List all sample hashes in a collection.
163 #[instrument(skip_all)]
164 pub fn list_collection_members(db: &Database, collection_id: CollectionId) -> Result<Vec<String>> {
165 let mut stmt = db.conn().prepare(
166 "SELECT sample_hash FROM collection_members WHERE collection_id = ?1 ORDER BY added_at ASC",
167 )?;
168 let rows = stmt.query_map([collection_id], |row| row.get::<_, String>(0))?;
169 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
170 }
171
172 /// Get all collections that contain a given sample.
173 #[instrument(skip_all)]
174 pub fn get_sample_collections(db: &Database, sample_hash: &str) -> Result<Vec<Collection>> {
175 let mut stmt = db.conn().prepare(
176 "SELECT c.id, c.name, c.description, c.created_at, COUNT(cm2.sample_hash), c.filter_json
177 FROM collections c
178 INNER JOIN collection_members cm ON cm.collection_id = c.id AND cm.sample_hash = ?1
179 LEFT JOIN collection_members cm2 ON cm2.collection_id = c.id
180 GROUP BY c.id
181 ORDER BY c.name ASC",
182 )?;
183 let rows = stmt.query_map([sample_hash], |row| {
184 let filter_json: Option<String> = row.get(5)?;
185 let filter = filter_json.and_then(|json| {
186 match serde_json::from_str(&json) {
187 Ok(f) => Some(f),
188 Err(e) => {
189 tracing::warn!("Malformed filter JSON in collection: {e}");
190 None
191 }
192 }
193 });
194 Ok(Collection {
195 id: row.get(0)?,
196 name: row.get(1)?,
197 description: row.get(2)?,
198 created_at: row.get(3)?,
199 member_count: row.get::<_, i64>(4)?.max(0) as usize,
200 filter,
201 })
202 })?;
203 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
204 }
205
206 #[cfg(test)]
207 mod tests {
208 use super::*;
209
210 fn setup() -> Database {
211 let db = Database::open_in_memory().unwrap();
212 // Insert a fake sample for FK constraints
213 db.conn()
214 .execute(
215 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
216 VALUES ('aaa', 'kick.wav', 'wav', 100, 0, 0)",
217 [],
218 )
219 .unwrap();
220 db.conn()
221 .execute(
222 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
223 VALUES ('bbb', 'snare.wav', 'wav', 200, 0, 0)",
224 [],
225 )
226 .unwrap();
227 db
228 }
229
230 #[test]
231 fn create_and_list() {
232 let db = setup();
233 let id = create_collection(&db, "Favorites", Some("My best samples")).unwrap();
234 assert!(id.as_i64() > 0);
235
236 let collections = list_collections(&db).unwrap();
237 assert_eq!(collections.len(), 1);
238 assert_eq!(collections[0].name, "Favorites");
239 assert_eq!(collections[0].description.as_deref(), Some("My best samples"));
240 assert_eq!(collections[0].member_count, 0);
241 }
242
243 #[test]
244 fn rename() {
245 let db = setup();
246 let id = create_collection(&db, "Old", None).unwrap();
247 rename_collection(&db, id, "New").unwrap();
248 let collections = list_collections(&db).unwrap();
249 assert_eq!(collections[0].name, "New");
250 }
251
252 #[test]
253 fn delete() {
254 let db = setup();
255 let id = create_collection(&db, "Temp", None).unwrap();
256 delete_collection(&db, id).unwrap();
257 assert!(list_collections(&db).unwrap().is_empty());
258 }
259
260 #[test]
261 fn add_remove_members() {
262 let db = setup();
263 let id = create_collection(&db, "Test", None).unwrap();
264
265 add_to_collection(&db, id, "aaa").unwrap();
266 add_to_collection(&db, id, "bbb").unwrap();
267
268 let members = list_collection_members(&db, id).unwrap();
269 assert_eq!(members.len(), 2);
270
271 // member_count reflects in list
272 let collections = list_collections(&db).unwrap();
273 assert_eq!(collections[0].member_count, 2);
274
275 remove_from_collection(&db, id, "aaa").unwrap();
276 let members = list_collection_members(&db, id).unwrap();
277 assert_eq!(members.len(), 1);
278 assert_eq!(members[0], "bbb");
279 }
280
281 #[test]
282 fn duplicate_add_is_noop() {
283 let db = setup();
284 let id = create_collection(&db, "Test", None).unwrap();
285 add_to_collection(&db, id, "aaa").unwrap();
286 add_to_collection(&db, id, "aaa").unwrap(); // no error
287 let members = list_collection_members(&db, id).unwrap();
288 assert_eq!(members.len(), 1);
289 }
290
291 #[test]
292 fn nonexistent_errors() {
293 let db = setup();
294 let bad_id = CollectionId::from(9999);
295 assert!(rename_collection(&db, bad_id, "X").is_err());
296 assert!(delete_collection(&db, bad_id).is_err());
297 }
298
299 #[test]
300 fn get_sample_collections_works() {
301 let db = setup();
302 let c1 = create_collection(&db, "Alpha", None).unwrap();
303 let c2 = create_collection(&db, "Beta", None).unwrap();
304 add_to_collection(&db, c1, "aaa").unwrap();
305 add_to_collection(&db, c2, "aaa").unwrap();
306 add_to_collection(&db, c2, "bbb").unwrap();
307
308 let sample_colls = get_sample_collections(&db, "aaa").unwrap();
309 assert_eq!(sample_colls.len(), 2);
310
311 let sample_colls_b = get_sample_collections(&db, "bbb").unwrap();
312 assert_eq!(sample_colls_b.len(), 1);
313 assert_eq!(sample_colls_b[0].name, "Beta");
314 }
315
316 #[test]
317 fn delete_cascades_members() {
318 let db = setup();
319 let id = create_collection(&db, "Test", None).unwrap();
320 add_to_collection(&db, id, "aaa").unwrap();
321 delete_collection(&db, id).unwrap();
322 // members table should be clean — verify by creating a new collection
323 // with the same name and checking it has no members
324 let id2 = create_collection(&db, "Test", None).unwrap();
325 assert_eq!(list_collection_members(&db, id2).unwrap().len(), 0);
326 }
327 }
328