Files
xevion.dev/src/db/media.rs

397 lines
11 KiB
Rust

use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Media type enum matching PostgreSQL enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "media_type", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum MediaType {
Image,
Video,
}
/// Database model for project media
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbProjectMedia {
pub id: Uuid,
pub project_id: Uuid,
pub display_order: i32,
pub media_type: MediaType,
pub r2_base_path: String,
pub variants: serde_json::Value,
pub blurhash: Option<String>,
pub metadata: Option<serde_json::Value>,
}
/// Variant info for images
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageVariant {
pub key: String,
pub width: i32,
pub height: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime: Option<String>,
}
/// Variant info for video poster
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoOriginal {
pub key: String,
pub mime: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// API response for media variant with full URL
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiMediaVariant {
pub url: String,
pub width: i32,
pub height: i32,
}
/// API response for video original
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiVideoOriginal {
pub url: String,
pub mime: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// API response for media variants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiMediaVariants {
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub medium: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poster: Option<ApiMediaVariant>,
// For video original (different structure)
#[serde(skip_serializing_if = "Option::is_none")]
pub video: Option<ApiVideoOriginal>,
}
/// Optional metadata stored with media
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub focal_point: Option<FocalPoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocalPoint {
pub x: f64,
pub y: f64,
}
/// API response type for project media
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiProjectMedia {
pub id: String,
pub display_order: i32,
pub media_type: MediaType,
pub variants: ApiMediaVariants,
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<MediaMetadata>,
}
/// Base URL for R2 media storage
const R2_BASE_URL: &str = "https://media.xevion.dev";
impl DbProjectMedia {
/// Convert database media to API response format
pub fn to_api_media(&self) -> ApiProjectMedia {
let variants = self.build_api_variants();
let metadata = self
.metadata
.as_ref()
.and_then(|m| serde_json::from_value(m.clone()).ok());
ApiProjectMedia {
id: self.id.to_string(),
display_order: self.display_order,
media_type: self.media_type,
variants,
blurhash: self.blurhash.clone(),
metadata,
}
}
fn build_api_variants(&self) -> ApiMediaVariants {
let base_url = format!(
"{}/{}",
R2_BASE_URL,
self.r2_base_path.trim_end_matches('/')
);
let mut variants = ApiMediaVariants {
thumb: None,
medium: None,
full: None,
original: None,
poster: None,
video: None,
};
// Parse the JSONB variants
if let Some(obj) = self.variants.as_object() {
// Handle image variants
if let Some(thumb) = obj.get("thumb")
&& let Ok(v) = serde_json::from_value::<ImageVariant>(thumb.clone())
{
variants.thumb = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
if let Some(medium) = obj.get("medium")
&& let Ok(v) = serde_json::from_value::<ImageVariant>(medium.clone())
{
variants.medium = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
if let Some(full) = obj.get("full")
&& let Ok(v) = serde_json::from_value::<ImageVariant>(full.clone())
{
variants.full = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
// Handle original - could be image or video
if let Some(original) = obj.get("original") {
if self.media_type == MediaType::Video {
// Video original has different structure
if let Ok(v) = serde_json::from_value::<VideoOriginal>(original.clone()) {
variants.video = Some(ApiVideoOriginal {
url: format!("{}/{}", base_url, v.key),
mime: v.mime,
duration: v.duration,
});
}
} else {
// Image original
if let Ok(v) = serde_json::from_value::<ImageVariant>(original.clone()) {
variants.original = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
}
// Handle video poster
if let Some(poster) = obj.get("poster")
&& let Ok(v) = serde_json::from_value::<ImageVariant>(poster.clone())
{
variants.poster = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
variants
}
}
// Database query functions
/// Get all media for a project, ordered by display_order
pub async fn get_media_for_project(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<DbProjectMedia>, sqlx::Error> {
sqlx::query_as!(
DbProjectMedia,
r#"
SELECT
id,
project_id,
display_order,
media_type as "media_type: MediaType",
r2_base_path,
variants,
blurhash,
metadata
FROM project_media
WHERE project_id = $1
ORDER BY display_order ASC
"#,
project_id
)
.fetch_all(pool)
.await
}
/// Get single media item by ID
pub async fn get_media_by_id(
pool: &PgPool,
id: Uuid,
) -> Result<Option<DbProjectMedia>, sqlx::Error> {
sqlx::query_as!(
DbProjectMedia,
r#"
SELECT
id,
project_id,
display_order,
media_type as "media_type: MediaType",
r2_base_path,
variants,
blurhash,
metadata
FROM project_media
WHERE id = $1
"#,
id
)
.fetch_optional(pool)
.await
}
/// Get the next display order for a project's media
pub async fn get_next_display_order(pool: &PgPool, project_id: Uuid) -> Result<i32, sqlx::Error> {
let result = sqlx::query!(
r#"
SELECT COALESCE(MAX(display_order) + 1, 0) as "next_order!"
FROM project_media
WHERE project_id = $1
"#,
project_id
)
.fetch_one(pool)
.await?;
Ok(result.next_order)
}
/// Create a new media record
#[allow(clippy::too_many_arguments)]
pub async fn create_media(
pool: &PgPool,
project_id: Uuid,
media_type: MediaType,
original_filename: &str,
r2_base_path: &str,
variants: serde_json::Value,
width: Option<i32>,
height: Option<i32>,
size_bytes: i64,
blurhash: Option<&str>,
metadata: Option<serde_json::Value>,
) -> Result<DbProjectMedia, sqlx::Error> {
let display_order = get_next_display_order(pool, project_id).await?;
sqlx::query_as!(
DbProjectMedia,
r#"
INSERT INTO project_media (
project_id, display_order, media_type, original_filename,
r2_base_path, variants, width, height, size_bytes, blurhash, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING
id,
project_id,
display_order,
media_type as "media_type: MediaType",
r2_base_path,
variants,
blurhash,
metadata
"#,
project_id,
display_order,
media_type as MediaType,
original_filename,
r2_base_path,
variants,
width,
height,
size_bytes,
blurhash,
metadata
)
.fetch_one(pool)
.await
}
/// Delete a media record
pub async fn delete_media(pool: &PgPool, id: Uuid) -> Result<Option<DbProjectMedia>, sqlx::Error> {
// First get the media to return it
let media = get_media_by_id(pool, id).await?;
if media.is_some() {
sqlx::query!("DELETE FROM project_media WHERE id = $1", id)
.execute(pool)
.await?;
}
Ok(media)
}
/// Reorder media for a project
/// Takes a list of media IDs in desired order and updates display_order accordingly
pub async fn reorder_media(
pool: &PgPool,
project_id: Uuid,
media_ids: &[Uuid],
) -> Result<(), sqlx::Error> {
// Use a transaction to ensure atomicity
let mut tx = pool.begin().await?;
// First, set all to negative values to avoid unique constraint conflicts
for (i, id) in media_ids.iter().enumerate() {
sqlx::query!(
"UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
-(i as i32 + 1),
id,
project_id
)
.execute(&mut *tx)
.await?;
}
// Then set to final positive values
for (i, id) in media_ids.iter().enumerate() {
sqlx::query!(
"UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
i as i32,
id,
project_id
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}