feat: enhance media management with EXIF data extraction, metadata filtering, and storage path generation

refactor: update configuration handling to use environment variables and improve code organization
This commit is contained in:
2025-11-14 11:22:51 +01:00
parent 70dc0a7131
commit 3c3b51a2a7
24 changed files with 393 additions and 181 deletions

View File

@@ -13,3 +13,5 @@ thiserror = "2.0.17"
uuid = {version = "1.18.1", features = ["v4", "serde"] }
serde = { version = "1.0.228", features = ["derive"] }
nom-exif = { version = "2.5.4", features = ["serde", "async", "tokio"] }
dotenvy = "0.15.7"
envy = "0.4.2"

View File

@@ -1,6 +1,9 @@
use serde::Deserialize;
use crate::error::{CoreError, CoreResult};
#[derive(Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseType {
Postgres,
Sqlite,
@@ -30,6 +33,48 @@ pub struct ThumbnailConfig {
#[derive(Deserialize, Clone)]
pub struct Config {
#[serde(rename = "DATABASE_URL")]
pub database_url: String,
#[serde(rename = "DATABASE_DB_TYPE")]
pub database_db_type: DatabaseType,
#[serde(rename = "SERVER_ADDRESS")]
pub server_address: String,
#[serde(rename = "JWT_SECRET")]
pub jwt_secret: String,
#[serde(rename = "MEDIA_LIBRARY_PATH")]
pub media_library_path: String,
#[serde(rename = "BROKER_URL")]
pub broker_url: String,
#[serde(default = "default_max_upload_size")]
#[serde(rename = "MAX_UPLOAD_SIZE_MB")]
pub max_upload_size_mb: u32,
#[serde(default = "default_storage_quota")]
#[serde(rename = "DEFAULT_STORAGE_QUOTA_GB")]
pub default_storage_quota_gb: u64,
#[serde(default = "default_allowed_sort_columns")]
#[serde(rename = "ALLOWED_SORT_COLUMNS")]
pub allowed_sort_columns: Vec<String>,
#[serde(flatten)]
pub thumbnail_config: Option<ThumbnailConfig>,
}
fn default_max_upload_size() -> u32 { 100 }
fn default_storage_quota() -> u64 { 10 }
fn default_allowed_sort_columns() -> Vec<String> {
vec!["created_at".to_string(), "original_filename".to_string()]
}
#[derive(Clone)]
pub struct AppConfig {
pub database: DatabaseConfig,
pub server_address: String,
pub jwt_secret: String,
@@ -40,3 +85,29 @@ pub struct Config {
pub allowed_sort_columns: Option<Vec<String>>,
pub thumbnail_config: Option<ThumbnailConfig>,
}
pub fn load_config() -> CoreResult<AppConfig> {
// Load the .env file at that specific path
let env_path = dotenvy::dotenv()
.map_err(|e| CoreError::Config(format!("Failed to load .env file: {}", e)))?;
println!("Loaded config from {}", env_path.display());
let config = envy::from_env::<Config>()
.map_err(|e| CoreError::Config(format!("Failed to load config from env: {}", e)))?;
Ok(AppConfig {
database: DatabaseConfig {
db_type: config.database_db_type,
url: config.database_url,
},
server_address: config.server_address,
jwt_secret: config.jwt_secret,
media_library_path: config.media_library_path,
broker_url: config.broker_url,
max_upload_size_mb: Some(config.max_upload_size_mb),
default_storage_quota_gb: Some(config.default_storage_quota_gb),
allowed_sort_columns: Some(config.allowed_sort_columns),
thumbnail_config: config.thumbnail_config,
})
}

View File

@@ -1,7 +1,7 @@
use std::path::Path;
use std::{io::Cursor, path::{Path, PathBuf}};
use chrono::{DateTime, NaiveDateTime, Utc};
use nom_exif::{AsyncMediaParser, AsyncMediaSource, ExifIter, TrackInfo};
use chrono::{DateTime, Datelike, NaiveDateTime, Utc};
use nom_exif::{AsyncMediaParser, AsyncMediaSource, ExifIter, MediaParser, MediaSource, TrackInfo};
use crate::{error::{CoreError, CoreResult}, models::MediaMetadataSource};
@@ -10,10 +10,76 @@ pub struct ExtractedExif {
pub all_tags: Vec<(MediaMetadataSource, String, String)>,
}
const EXIF_DATE_FORMATS: &[&str] = &[
"%Y:%m:%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
];
pub fn parse_exif_datetime(s: &str) -> Option<DateTime<Utc>> {
NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S")
.ok()
.map(|ndt| ndt.and_local_timezone(Utc).unwrap())
for format in EXIF_DATE_FORMATS {
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, format) {
return Some(ndt.and_local_timezone(Utc).unwrap());
}
}
None
}
pub fn extract_exif_data_from_bytes(
bytes: &[u8],
) -> CoreResult<ExtractedExif> {
let ms = MediaSource::seekable(Cursor::new(bytes))
.map_err(|e| CoreError::Unknown(format!("Failed to open bytes for EXIF: {}", e)))?;
let mut parser = MediaParser::new();
let all_tags = if ms.has_exif() {
let iter: ExifIter = match parser.parse(ms) {
Ok(iter) => iter,
Err(e) => {
println!("Could not parse EXIF: {}", e);
return Ok(ExtractedExif::default());
}
};
iter.into_iter()
.filter_map(|mut x| {
let res = x.take_result();
match res {
Ok(v) => Some((
MediaMetadataSource::Exif,
x.tag()
.map(|t| t.to_string())
.unwrap_or_else(|| format!("Unknown(0x{:04x})", x.tag_code())),
v.to_string(),
)),
Err(e) => {
println!("!! EXIF parsing error for tag 0x{:04x}: {}", x.tag_code(), e);
None
}
}
})
.collect::<Vec<_>>()
} else {
match parser.parse::<_, _, TrackInfo>(ms) {
Ok(info) => info
.into_iter()
.map(|x| {
(
MediaMetadataSource::TrackInfo,
x.0.to_string(),
x.1.to_string(),
)
})
.collect::<Vec<_>>(),
Err(e) => {
println!("Could not parse TrackInfo: {}", e);
return Ok(ExtractedExif::default());
}
}
};
Ok(ExtractedExif { all_tags })
}
pub async fn extract_exif_data(file_path: &Path) -> CoreResult<ExtractedExif> {
@@ -74,4 +140,26 @@ pub async fn extract_exif_data(file_path: &Path) -> CoreResult<ExtractedExif> {
};
Ok(ExtractedExif { all_tags })
}
pub fn get_storage_path_and_date(
extracted_data: &ExtractedExif,
filename: &str,
) -> (PathBuf, Option<DateTime<Utc>>) {
let date_taken_str = extracted_data.all_tags.iter()
.find(|(source, tag_name, _)| {
*source == MediaMetadataSource::Exif &&
(tag_name == "DateTimeOriginal" || tag_name == "ModifyDate")
})
.map(|(_, _, tag_value)| tag_value);
let date_taken = date_taken_str.and_then(|s| parse_exif_datetime(s));
let file_date = date_taken.clone().unwrap_or_else(chrono::Utc::now);
let year = file_date.year().to_string();
let month = format!("{:02}", file_date.month());
let storage_path = PathBuf::from(&year).join(&month).join(filename);
(storage_path, date_taken)
}

View File

@@ -145,3 +145,9 @@ pub struct AlbumShare {
pub user_id: uuid::Uuid,
pub permission: AlbumPermission,
}
pub struct MediaBundle {
pub media: Media,
pub metadata: Vec<MediaMetadata>,
}

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::{
config::Config, error::CoreResult, models::Media, repositories::{AlbumRepository, MediaMetadataRepository, MediaRepository, UserRepository}
config::AppConfig, error::CoreResult, models::Media, repositories::{AlbumRepository, MediaMetadataRepository, MediaRepository, UserRepository}
};
pub struct PluginData {
@@ -16,7 +16,7 @@ pub struct PluginContext {
pub user_repo: Arc<dyn UserRepository>,
pub metadata_repo: Arc<dyn MediaMetadataRepository>,
pub media_library_path: String,
pub config: Arc<Config>,
pub config: Arc<AppConfig>,
}
#[async_trait]

View File

@@ -57,8 +57,9 @@ pub struct SortParams {
#[derive(Debug, Clone, Default)]
pub struct FilterParams {
// In the future, you can add fields like:
// pub mime_type: Option<String>,
pub mime_type: Option<String>,
pub metadata_filters: Option<Vec<MetadataFilter>>,
// In the future, we can add fields like:
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
}
@@ -67,4 +68,10 @@ pub struct ListMediaOptions {
pub sort: Option<SortParams>,
pub filter: Option<FilterParams>,
// pub pagination: Option<PaginationParams>,
}
#[derive(Debug, Clone)]
pub struct MetadataFilter {
pub tag_name: String,
pub tag_value: String,
}

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use crate::{
error::CoreResult,
models::{Album, Media, User},
models::{Album, Media, MediaBundle, User},
schema::{
AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData, ShareAlbumData, UpdateAlbumData, UploadMediaData
},
@@ -12,7 +12,7 @@ use crate::{
#[async_trait]
pub trait MediaService: Send + Sync {
async fn upload_media(&self, data: UploadMediaData<'_>) -> CoreResult<Media>;
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<Media>;
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<MediaBundle>;
async fn list_user_media(&self, user_id: Uuid, options: ListMediaOptions) -> CoreResult<Vec<Media>>;
async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult<String>;
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>;