1use chrono::{SecondsFormat, Utc};
10use laterite_core::strata::*;
11use laterite_core::Db;
12use sea_query::UpdateStatement;
13
14#[derive(Iden)]
16pub enum Media {
17 Table,
18 Id,
19 Disk,
23 Hash,
25 Size,
26 ContentType,
28 OriginalName,
31 Title,
32 Alt,
33 InLibrary,
36 CreatedBy,
37 CreatedAt,
38 UpdatedAt,
39 FolderId,
41 Width,
43 Height,
44 Meta,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct MediaRecord {
51 pub id: i64,
52 pub disk: String,
53 pub hash: String,
54 pub size: i64,
55 pub content_type: String,
56 pub original_name: String,
57 pub title: Option<String>,
58 pub alt: Option<String>,
59 pub in_library: bool,
60 pub created_by: Option<i64>,
61 pub folder_id: Option<i64>,
63 pub width: Option<i64>,
64 pub height: Option<i64>,
65 pub meta: serde_json::Value,
67 pub created_at: String,
68}
69
70impl MediaRecord {
71 pub fn kind(&self) -> crate::Kind {
74 crate::Kind::of(&self.content_type)
75 }
76
77 pub fn display_name(&self) -> &str {
79 match &self.title {
80 Some(t) if !t.trim().is_empty() => t,
81 _ => &self.original_name,
82 }
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct NewMedia {
89 pub disk: String,
90 pub hash: String,
91 pub size: i64,
92 pub content_type: String,
93 pub original_name: String,
95 pub in_library: bool,
96 pub created_by: Option<i64>,
97}
98
99#[derive(Debug, Clone, Default)]
102pub struct NewMediaExtra {
103 pub folder_id: Option<i64>,
104 pub width: Option<i64>,
105 pub height: Option<i64>,
106 pub meta: Option<serde_json::Value>,
107}
108
109pub(crate) const COLUMNS: [Media; 15] = [
111 Media::Id,
112 Media::Disk,
113 Media::Hash,
114 Media::Size,
115 Media::ContentType,
116 Media::OriginalName,
117 Media::Title,
118 Media::Alt,
119 Media::InLibrary,
120 Media::CreatedBy,
121 Media::FolderId,
122 Media::Width,
123 Media::Height,
124 Media::Meta,
125 Media::CreatedAt,
126];
127
128pub(crate) fn from_row(r: &sqlx::any::AnyRow) -> Result<MediaRecord, sqlx::Error> {
130 let meta = r
131 .get_text_opt("meta")?
132 .and_then(|t| serde_json::from_str(&t).ok())
133 .unwrap_or_else(|| serde_json::json!({}));
134 Ok(MediaRecord {
135 id: r.get_int("id")?,
136 disk: r.get_text("disk")?,
137 hash: r.get_text("hash")?,
138 size: r.get_int("size")?,
139 content_type: r.get_text("content_type")?,
140 original_name: r.get_text("original_name")?,
141 title: r.get_text_opt("title")?,
142 alt: r.get_text_opt("alt")?,
143 in_library: r.get_bool("in_library")?,
144 created_by: r.get_int_opt("created_by")?,
145 folder_id: r.get_int_opt("folder_id")?,
146 width: r.get_int_opt("width")?,
147 height: r.get_int_opt("height")?,
148 meta,
149 created_at: r.get_text("created_at")?,
150 })
151}
152
153#[derive(Debug, thiserror::Error)]
154pub enum RecordError {
155 #[error("database error")]
156 Database(#[from] sqlx::Error),
157 #[error(transparent)]
158 Core(#[from] CoreError),
159}
160
161pub async fn create(db: &Db, new: NewMedia) -> Result<i64, RecordError> {
167 create_with(db, new, NewMediaExtra::default()).await
168}
169
170pub async fn create_with(db: &Db, new: NewMedia, extra: NewMediaExtra) -> Result<i64, RecordError> {
172 let now = stamp();
173 let meta = extra
174 .meta
175 .unwrap_or_else(|| serde_json::json!({}))
176 .to_string();
177 let stmt = Query::insert()
178 .into_table(Media::Table)
179 .columns([
180 Media::Disk,
181 Media::Hash,
182 Media::Size,
183 Media::ContentType,
184 Media::OriginalName,
185 Media::InLibrary,
186 Media::CreatedBy,
187 Media::FolderId,
188 Media::Width,
189 Media::Height,
190 Media::Meta,
191 Media::CreatedAt,
192 Media::UpdatedAt,
193 ])
194 .values_panic([
195 new.disk.into(),
196 new.hash.into(),
197 new.size.into(),
198 new.content_type.into(),
199 new.original_name.into(),
200 i32::from(new.in_library).into(),
203 new.created_by.into(),
204 extra.folder_id.into(),
205 extra.width.into(),
206 extra.height.into(),
207 meta.into(),
208 now.clone().into(),
209 now.into(),
210 ])
211 .to_owned();
212 Ok(insert_returning_id(db, stmt, Media::Id).await?)
213}
214
215fn stamp() -> String {
216 Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
217}
218
219fn update_one(
222 db: &Db,
223 id: i64,
224 mut stmt: UpdateStatement,
225) -> impl std::future::Future<Output = Result<(), RecordError>> + Send + '_ {
226 stmt.value(Media::UpdatedAt, stamp())
227 .and_where(Expr::col(Media::Id).eq(id));
228 let (sql, values) = build(db.backend, stmt);
229 async move {
230 bind_values(sqlx::query(&sql), values)
231 .execute(&db.pool)
232 .await?;
233 Ok(())
234 }
235}
236
237pub async fn update_details(
239 db: &Db,
240 id: i64,
241 title: Option<&str>,
242 alt: Option<&str>,
243) -> Result<(), RecordError> {
244 let stmt = Query::update()
245 .table(Media::Table)
246 .value(Media::Title, title.map(str::to_string))
247 .value(Media::Alt, alt.map(str::to_string))
248 .to_owned();
249 update_one(db, id, stmt).await
250}
251
252pub async fn rename(db: &Db, id: i64, original_name: &str) -> Result<(), RecordError> {
254 let stmt = Query::update()
255 .table(Media::Table)
256 .value(Media::OriginalName, original_name)
257 .to_owned();
258 update_one(db, id, stmt).await
259}
260
261pub async fn replace_blob(
265 db: &Db,
266 id: i64,
267 hash: &str,
268 size: i64,
269 content_type: &str,
270 width: Option<i64>,
271 height: Option<i64>,
272) -> Result<(), RecordError> {
273 let stmt = Query::update()
274 .table(Media::Table)
275 .value(Media::Hash, hash)
276 .value(Media::Size, size)
277 .value(Media::ContentType, content_type)
278 .value(Media::Width, width)
279 .value(Media::Height, height)
280 .value(Media::Meta, "{}")
281 .to_owned();
282 update_one(db, id, stmt).await
283}
284
285pub async fn move_to_folder(db: &Db, id: i64, folder_id: Option<i64>) -> Result<(), RecordError> {
287 let stmt = Query::update()
288 .table(Media::Table)
289 .value(Media::FolderId, folder_id)
290 .to_owned();
291 update_one(db, id, stmt).await
292}
293
294pub async fn set_in_library(
296 db: &Db,
297 id: i64,
298 in_library: bool,
299 folder_id: Option<i64>,
300) -> Result<(), RecordError> {
301 let stmt = Query::update()
302 .table(Media::Table)
303 .value(Media::InLibrary, i32::from(in_library))
304 .value(Media::FolderId, folder_id)
305 .to_owned();
306 update_one(db, id, stmt).await
307}
308
309pub fn set_dimensions(
311 db: &Db,
312 id: i64,
313 width: Option<i64>,
314 height: Option<i64>,
315) -> impl std::future::Future<Output = Result<(), RecordError>> + Send + '_ {
316 let stmt = Query::update()
317 .table(Media::Table)
318 .value(Media::Width, width)
319 .value(Media::Height, height)
320 .to_owned();
321 update_one(db, id, stmt)
322}
323
324pub async fn find_by_hash(
326 db: &Db,
327 disk: &str,
328 hash: &str,
329) -> Result<Option<MediaRecord>, RecordError> {
330 let (sql, values) = build(
331 db.backend,
332 Query::select()
333 .columns(COLUMNS)
334 .from(Media::Table)
335 .and_where(Expr::col(Media::Disk).eq(disk))
336 .and_where(Expr::col(Media::Hash).eq(hash))
337 .limit(1)
338 .to_owned(),
339 );
340 let row = bind_values(sqlx::query(&sql), values)
341 .fetch_optional(&db.pool)
342 .await?;
343 Ok(row.as_ref().map(from_row).transpose()?)
344}
345
346pub async fn find_all_by_hash(db: &Db, hash: &str) -> Result<Vec<MediaRecord>, RecordError> {
348 let (sql, values) = build(
349 db.backend,
350 Query::select()
351 .columns(COLUMNS)
352 .from(Media::Table)
353 .and_where(Expr::col(Media::Hash).eq(hash))
354 .to_owned(),
355 );
356 let rows = bind_values(sqlx::query(&sql), values)
357 .fetch_all(&db.pool)
358 .await?;
359 Ok(rows.iter().map(from_row).collect::<Result<_, _>>()?)
360}
361
362pub async fn find(db: &Db, id: i64) -> Result<Option<MediaRecord>, RecordError> {
364 let (sql, values) = build(
365 db.backend,
366 Query::select()
367 .columns(COLUMNS)
368 .from(Media::Table)
369 .and_where(Expr::col(Media::Id).eq(id))
370 .to_owned(),
371 );
372 let row = bind_values(sqlx::query(&sql), values)
373 .fetch_optional(&db.pool)
374 .await?;
375 Ok(row.as_ref().map(from_row).transpose()?)
376}
377
378pub async fn delete(db: &Db, id: i64) -> Result<bool, RecordError> {
384 let Some(record) = find(db, id).await? else {
385 return Ok(false);
386 };
387 let (sql, values) = build(
388 db.backend,
389 Query::delete()
390 .from_table(Media::Table)
391 .and_where(Expr::col(Media::Id).eq(id))
392 .to_owned(),
393 );
394 bind_values(sqlx::query(&sql), values)
395 .execute(&db.pool)
396 .await?;
397
398 let (sql, values) = build(
399 db.backend,
400 Query::select()
401 .expr(Expr::col(Media::Id).count())
402 .from(Media::Table)
403 .and_where(Expr::col(Media::Hash).eq(record.hash))
404 .and_where(Expr::col(Media::Disk).eq(record.disk))
405 .to_owned(),
406 );
407 let remaining: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
408 .fetch_one(&db.pool)
409 .await?
410 .0;
411 Ok(remaining == 0)
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use laterite_core::testing::connect_test;
418
419 async fn db() -> (Db, laterite_core::testing::TestGuard) {
421 connect_test(&[crate::migrations::migrations()]).await
422 }
423
424 fn new(hash: &str, name: &str) -> NewMedia {
425 NewMedia {
426 disk: "private".to_string(),
427 hash: hash.to_string(),
428 size: 12,
429 content_type: "image/png".to_string(),
430 original_name: name.to_string(),
431 in_library: false,
432 created_by: None,
433 }
434 }
435
436 #[tokio::test]
437 async fn a_record_keeps_what_the_blob_cannot() {
438 let (db, _guard) = db().await;
439 let id = create(&db, new(&"a".repeat(64), "logo.png")).await.unwrap();
440 let found = find(&db, id).await.unwrap().unwrap();
441
442 assert_eq!(found.hash, "a".repeat(64));
443 assert_eq!(found.disk, "private");
444 assert_eq!(found.original_name, "logo.png");
447 assert!(!found.in_library);
448 }
449
450 #[tokio::test]
454 async fn one_blob_can_back_several_records() {
455 let (db, _guard) = db().await;
456 let hash = "b".repeat(64);
457 let first = create(&db, new(&hash, "invoice.pdf")).await.unwrap();
458 let second = create(&db, new(&hash, "receipt.pdf")).await.unwrap();
459
460 assert_ne!(first, second);
461 assert_eq!(find(&db, first).await.unwrap().unwrap().hash, hash);
462 assert_eq!(
463 find(&db, second).await.unwrap().unwrap().original_name,
464 "receipt.pdf"
465 );
466 }
467
468 #[tokio::test]
471 async fn only_the_last_record_releases_the_blob() {
472 let (db, _guard) = db().await;
473 let hash = "c".repeat(64);
474 let first = create(&db, new(&hash, "one")).await.unwrap();
475 let second = create(&db, new(&hash, "two")).await.unwrap();
476
477 assert!(
478 !delete(&db, first).await.unwrap(),
479 "another record still holds it"
480 );
481 assert!(
482 delete(&db, second).await.unwrap(),
483 "the last one releases it"
484 );
485 }
486
487 #[tokio::test]
490 async fn a_blob_on_another_disk_is_a_different_blob() {
491 let (db, _guard) = db().await;
492 let hash = "d".repeat(64);
493 let private = create(&db, new(&hash, "here")).await.unwrap();
494 let public = create(
495 &db,
496 NewMedia {
497 disk: "public".to_string(),
498 ..new(&hash, "there")
499 },
500 )
501 .await
502 .unwrap();
503
504 assert!(
505 delete(&db, private).await.unwrap(),
506 "its own disk had no other holder"
507 );
508 assert!(find(&db, public).await.unwrap().is_some());
509 }
510
511 #[tokio::test]
512 async fn deleting_a_record_that_is_gone_says_so() {
513 let (db, _guard) = db().await;
514 assert!(!delete(&db, 4242).await.unwrap());
515 assert!(find(&db, 4242).await.unwrap().is_none());
516 }
517}