Files
RuDS/crates/bds-core/src/db/queries/media.rs

287 lines
9.0 KiB
Rust

use chrono::{Datelike, TimeZone, Utc};
use diesel::prelude::*;
use diesel::sql_types::Text;
use crate::db::DbConnection;
use crate::db::schema::media;
use crate::model::Media;
use crate::util::calendar_range_unix_ms;
diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer);
pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
conn.with(|c| {
diesel::insert_into(media::table)
.values(m.clone())
.execute(c)
.map(|_| ())
})
}
pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult<Media> {
conn.with(|c| {
media::table
.filter(media::id.eq(id))
.select(Media::as_select())
.first(c)
})
}
pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<Media>> {
conn.with(|c| {
media::table
.filter(media::project_id.eq(project_id))
.order(media::created_at.desc())
.select(Media::as_select())
.load(c)
})
}
pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<i64> {
conn.with(|c| {
media::table
.filter(media::project_id.eq(project_id))
.count()
.get_result(c)
})
}
pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> {
conn.with(|c| {
diesel::update(media::table.filter(media::id.eq(&m.id)))
.set(m.clone())
.execute(c)
.map(|_| ())
})
}
pub fn delete_media(conn: &DbConnection, id: &str) -> QueryResult<()> {
conn.with(|c| {
diesel::delete(media::table.filter(media::id.eq(id)))
.execute(c)
.map(|_| ())
})
}
// ── Filtered queries (per sidebar_views.allium MediaView) ───
/// Parameters for filtered media listing.
#[derive(Debug, Clone, Default)]
pub struct MediaFilterParams {
/// FTS search query (empty = no search filter).
pub search_query: String,
/// Year filter from calendar archive.
pub year: Option<i32>,
/// Month filter (1-12) from calendar archive.
pub month: Option<u32>,
/// Tag filter (media must have ALL of these tags).
pub tags: Vec<String>,
}
impl MediaFilterParams {
pub fn has_active_filters(&self) -> bool {
!self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty()
}
}
/// List media with optional filters applied.
pub fn list_media_filtered(
conn: &DbConnection,
project_id: &str,
filters: &MediaFilterParams,
limit: i64,
offset: i64,
) -> QueryResult<Vec<Media>> {
conn.with(|c| {
let mut query = media::table
.filter(media::project_id.eq(project_id))
.into_boxed();
if !filters.search_query.is_empty() {
query = query.filter(
media::title
.is_not_null()
.and(instr(media::title.assume_not_null(), &filters.search_query).gt(0))
.or(media::title
.is_null()
.and(instr(media::original_name, &filters.search_query).gt(0))),
);
}
if let Some(year) = filters.year {
let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| {
diesel::result::Error::SerializationError("invalid calendar range".into())
})?;
query = query.filter(media::created_at.ge(start).and(media::created_at.lt(end)));
}
for tag in &filters.tags {
query = query.filter(instr(media::tags, serde_json::to_string(tag).unwrap()).gt(0));
}
query
.order(media::created_at.desc())
.limit(limit)
.offset(offset)
.select(Media::as_select())
.load(c)
})
}
/// Year/month counts for the media calendar archive widget.
pub fn media_calendar_counts(
conn: &DbConnection,
project_id: &str,
) -> QueryResult<Vec<(i32, u32, usize)>> {
conn.with(|c| {
let timestamps = media::table
.filter(media::project_id.eq(project_id))
.select(media::created_at)
.load::<i64>(c)?;
let mut counts = std::collections::BTreeMap::new();
for timestamp in timestamps {
let date = Utc
.timestamp_millis_opt(timestamp)
.single()
.ok_or_else(|| {
diesel::result::Error::DeserializationError("invalid timestamp".into())
})?;
*counts.entry((date.year(), date.month())).or_insert(0) += 1;
}
Ok(counts
.into_iter()
.rev()
.map(|((year, month), count)| (year, month, count))
.collect())
})
}
/// Collect all distinct tag values across media for a project.
pub fn distinct_media_tags(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<String>> {
let rows = conn.with(|c| {
media::table
.filter(media::project_id.eq(project_id))
.filter(media::tags.ne("[]"))
.select(media::tags)
.distinct()
.load::<String>(c)
})?;
let mut all_tags = std::collections::BTreeSet::new();
for json_str in rows {
if let Ok(tags) = serde_json::from_str::<Vec<String>>(&json_str) {
all_tags.extend(tags);
}
}
Ok(all_tags.into_iter().collect())
}
/// Test helper: create a minimal Media value (available to sibling test modules).
#[cfg(test)]
pub fn make_test_media(id: &str, project_id: &str) -> Media {
Media {
id: id.to_string(),
project_id: project_id.to_string(),
filename: format!("{id}.jpg"),
original_name: "photo.jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 50000,
width: Some(1920),
height: Some(1080),
title: None,
alt: None,
caption: None,
author: None,
language: None,
file_path: format!("/media/{id}.jpg"),
sidecar_path: format!("/media/{id}.jpg.meta"),
checksum: None,
tags: vec![],
created_at: 1000,
updated_at: 2000,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
fn setup() -> Database {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
db
}
fn make_media_item(id: &str) -> Media {
Media {
id: id.to_string(),
project_id: "p1".to_string(),
filename: "abc.jpg".to_string(),
original_name: "photo.jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 50000,
width: Some(1920),
height: Some(1080),
title: Some("Sunset".into()),
alt: Some("A sunset".into()),
caption: None,
author: Some("Bob".into()),
language: Some("en".into()),
file_path: "/media/abc.jpg".to_string(),
sidecar_path: "/media/abc.jpg.meta".to_string(),
checksum: Some("hash1".into()),
tags: vec!["nature".into()],
created_at: 1000,
updated_at: 2000,
}
}
#[test]
fn insert_and_get_by_id() {
let db = setup();
let m = make_media_item("m1");
insert_media(db.conn(), &m).unwrap();
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
assert_eq!(fetched.original_name, "photo.jpg");
assert_eq!(fetched.width, Some(1920));
assert_eq!(fetched.height, Some(1080));
assert_eq!(fetched.tags, vec!["nature"]);
assert_eq!(fetched.size, 50000);
}
#[test]
fn list_by_project() {
let db = setup();
let mut m1 = make_media_item("m1");
m1.created_at = 1000;
let mut m2 = make_media_item("m2");
m2.filename = "def.jpg".into();
m2.created_at = 2000;
insert_media(db.conn(), &m1).unwrap();
insert_media(db.conn(), &m2).unwrap();
let list = list_media_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, "m2");
}
#[test]
fn update_media_fields() {
let db = setup();
let mut m = make_media_item("m1");
insert_media(db.conn(), &m).unwrap();
m.title = Some("New Title".into());
m.tags = vec!["updated".into()];
m.updated_at = 9999;
update_media(db.conn(), &m).unwrap();
let fetched = get_media_by_id(db.conn(), "m1").unwrap();
assert_eq!(fetched.title.as_deref(), Some("New Title"));
assert_eq!(fetched.tags, vec!["updated"]);
}
#[test]
fn delete_removes_media() {
let db = setup();
insert_media(db.conn(), &make_media_item("m1")).unwrap();
delete_media(db.conn(), "m1").unwrap();
assert!(get_media_by_id(db.conn(), "m1").is_err());
}
}