From 3c3b51a2a71922efc264f2739847a0f8468d2043 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 14 Nov 2025 11:22:51 +0100 Subject: [PATCH] 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 --- .gitignore | 2 + Cargo.lock | 11 ++ libertas_api/.env.example | 1 - libertas_api/src/config.rs | 26 +---- libertas_api/src/extractors/query_options.rs | 21 +++- libertas_api/src/factory.rs | 10 +- libertas_api/src/handlers/media_handlers.rs | 21 +++- libertas_api/src/schema.rs | 38 ++++++- libertas_api/src/services/media_service.rs | 93 +++++++++------- libertas_api/src/services/user_service.rs | 6 +- libertas_api/src/state.rs | 4 +- libertas_core/Cargo.toml | 2 + libertas_core/src/config.rs | 71 +++++++++++++ libertas_core/src/media_utils.rs | 100 ++++++++++++++++-- libertas_core/src/models.rs | 6 ++ libertas_core/src/plugins.rs | 4 +- libertas_core/src/schema.rs | 11 +- libertas_core/src/services.rs | 4 +- libertas_importer/src/config.rs | 26 +---- libertas_importer/src/main.rs | 34 +++--- libertas_infra/src/factory.rs | 4 +- libertas_infra/src/query_builder.rs | 41 +++++-- .../src/repositories/media_repository.rs | 12 +-- libertas_worker/src/config.rs | 26 +---- 24 files changed, 393 insertions(+), 181 deletions(-) delete mode 100644 libertas_api/.env.example diff --git a/.gitignore b/.gitignore index ea1187e..337bc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ target/ .sqlx/ media_library/ +.ai/ +.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5cf17b5..d31733c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,6 +822,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + [[package]] name = "equator" version = "0.4.2" @@ -1660,6 +1669,8 @@ dependencies = [ "async-trait", "bytes", "chrono", + "dotenvy", + "envy", "futures", "nom-exif", "serde", diff --git a/libertas_api/.env.example b/libertas_api/.env.example deleted file mode 100644 index 1dd7e45..0000000 --- a/libertas_api/.env.example +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL="postgres://postgres:postgres@localhost:5432/libertas_db" \ No newline at end of file diff --git a/libertas_api/src/config.rs b/libertas_api/src/config.rs index efdf442..7baba32 100644 --- a/libertas_api/src/config.rs +++ b/libertas_api/src/config.rs @@ -1,25 +1 @@ -use libertas_core::{ - config::{Config, DatabaseConfig, DatabaseType}, - error::CoreResult, -}; - -pub fn load_config() -> CoreResult { - Ok(Config { - database: DatabaseConfig { - db_type: DatabaseType::Postgres, - url: "postgres://libertas:libertas_password@localhost:5436/libertas_db".to_string(), - }, - server_address: "127.0.0.1:8080".to_string(), - jwt_secret: "super_secret_jwt_key".to_string(), - media_library_path: "media_library".to_string(), - broker_url: "nats://localhost:4222".to_string(), - max_upload_size_mb: Some(100), - default_storage_quota_gb: Some(10), - allowed_sort_columns: Some(vec![ - "date_taken".to_string(), - "created_at".to_string(), - "original_filename".to_string(), - ]), - thumbnail_config: None, - }) -} +pub use libertas_core::config::load_config; \ No newline at end of file diff --git a/libertas_api/src/extractors/query_options.rs b/libertas_api/src/extractors/query_options.rs index cfce245..d3d4adf 100644 --- a/libertas_api/src/extractors/query_options.rs +++ b/libertas_api/src/extractors/query_options.rs @@ -1,5 +1,5 @@ use axum::{extract::{FromRequestParts, Query}, http::request::Parts}; -use libertas_core::{error::CoreError, schema::{FilterParams, ListMediaOptions, SortOrder, SortParams}}; +use libertas_core::{error::CoreError, schema::{FilterParams, ListMediaOptions, MetadataFilter, SortOrder, SortParams}}; use crate::{error::ApiError, schema::ListMediaParams, state::AppState}; @@ -18,8 +18,25 @@ impl From for ListMediaOptions { } }); + let metadata_filters = if params.metadata.is_empty() { + None + } else { + Some( + params.metadata + .into_iter() + .filter_map(|s| { + s.split_once(":").map(|(key, value)| MetadataFilter { + tag_name: key.to_string(), + tag_value: value.to_string(), + }) + }) + .collect::>() + ) + }; + let filter = Some(FilterParams { - // e.g., mime_type: params.mime_type + mime_type: params.mime_type, + metadata_filters, }); ListMediaOptions { sort, filter } diff --git a/libertas_api/src/factory.rs b/libertas_api/src/factory.rs index 3a1a26e..296a64a 100644 --- a/libertas_api/src/factory.rs +++ b/libertas_api/src/factory.rs @@ -1,12 +1,11 @@ use std::sync::Arc; use libertas_core::{ - config::Config, + config::{AppConfig}, error::{CoreError, CoreResult}, }; use libertas_infra::factory::{ - build_album_repository, build_album_share_repository, build_database_pool, - build_media_repository, build_user_repository, + build_album_repository, build_album_share_repository, build_database_pool, build_media_metadata_repository, build_media_repository, build_user_repository }; use crate::{ @@ -18,7 +17,7 @@ use crate::{ state::AppState, }; -pub async fn build_app_state(config: Config) -> CoreResult { +pub async fn build_app_state(config: AppConfig) -> CoreResult { let nats_client = async_nats::connect(&config.broker_url) .await .map_err(|e| CoreError::Config(format!("Failed to connect to NATS: {}", e)))?; @@ -30,6 +29,8 @@ pub async fn build_app_state(config: Config) -> CoreResult { let media_repo = build_media_repository(&config, db_pool.clone()).await?; let album_repo = build_album_repository(&config.database, db_pool.clone()).await?; let album_share_repo = build_album_share_repository(&config.database, db_pool.clone()).await?; + let media_metadata_repo = + build_media_metadata_repository(&config.database, db_pool.clone()).await?; let hasher = Arc::new(Argon2Hasher::default()); let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone())); @@ -44,6 +45,7 @@ pub async fn build_app_state(config: Config) -> CoreResult { media_repo.clone(), user_repo.clone(), album_share_repo.clone(), + media_metadata_repo.clone(), config.clone(), nats_client.clone(), )); diff --git a/libertas_api/src/handlers/media_handlers.rs b/libertas_api/src/handlers/media_handlers.rs index 223fc24..7143457 100644 --- a/libertas_api/src/handlers/media_handlers.rs +++ b/libertas_api/src/handlers/media_handlers.rs @@ -13,7 +13,7 @@ use tower::ServiceExt; use tower_http::services::ServeFile; use uuid::Uuid; -use crate::{error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::UserId, schema::MediaResponse, state::AppState}; +use crate::{error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::UserId, schema::{MediaDetailsResponse, MediaMetadataResponse, MediaResponse}, state::AppState}; impl From for MediaResponse { @@ -101,9 +101,22 @@ async fn get_media_details( State(state): State, UserId(user_id): UserId, Path(id): Path, -) -> Result, ApiError> { - let media = state.media_service.get_media_details(id, user_id).await?; - Ok(Json(media.into())) +) -> Result, ApiError> { + let bundle = state.media_service.get_media_details(id, user_id).await?; + let response = MediaDetailsResponse { + id: bundle.media.id, + storage_path: bundle.media.storage_path, + original_filename: bundle.media.original_filename, + mime_type: bundle.media.mime_type, + hash: bundle.media.hash, + metadata: bundle.metadata + .into_iter() + .map(MediaMetadataResponse::from) + .collect(), + }; + + + Ok(Json(response)) } async fn delete_media( diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 0323cc5..9b93eb3 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -1,4 +1,4 @@ -use libertas_core::models::{Album, AlbumPermission}; +use libertas_core::{models::{Album, AlbumPermission, MediaMetadata}}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -15,9 +15,9 @@ pub struct MediaResponse { pub struct ListMediaParams { pub sort_by: Option, pub order: Option, - // You can add future filters here, e.g.: - // pub mime_type: Option, -} + pub mime_type: Option, + #[serde(default)] + pub metadata: Vec,} #[derive(Deserialize)] pub struct CreateAlbumRequest { @@ -92,4 +92,32 @@ pub struct UserResponse { pub id: Uuid, pub username: String, pub email: String, -} \ No newline at end of file +} + +#[derive(Serialize)] +pub struct MediaMetadataResponse { + pub source: String, + pub tag_name: String, + pub tag_value: String, +} + +impl From for MediaMetadataResponse { + fn from(metadata: MediaMetadata) -> Self { + Self { + source: metadata.source.as_str().to_string(), + tag_name: metadata.tag_name, + tag_value: metadata.tag_value, + } + } +} + +#[derive(Serialize)] +pub struct MediaDetailsResponse { + pub id: uuid::Uuid, + pub storage_path: String, + pub original_filename: String, + pub mime_type: String, + pub hash: String, + pub metadata: Vec, +} + diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index a07a297..e4e758a 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -1,16 +1,9 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{path::{Path, PathBuf}, sync::Arc}; use async_trait::async_trait; -use chrono::Datelike; use futures::stream::StreamExt; use libertas_core::{ - authz, - config::Config, - error::{CoreError, CoreResult}, - models::Media, - repositories::{AlbumShareRepository, MediaRepository, UserRepository}, - schema::{ListMediaOptions, UploadMediaData}, - services::MediaService, + authz, config::AppConfig, error::{CoreError, CoreResult}, media_utils::{ExtractedExif, extract_exif_data_from_bytes, get_storage_path_and_date}, models::{Media, MediaBundle, MediaMetadata}, repositories::{AlbumShareRepository, MediaMetadataRepository, MediaRepository, UserRepository}, schema::{ListMediaOptions, UploadMediaData}, services::MediaService }; use serde_json::json; use sha2::{Digest, Sha256}; @@ -21,7 +14,8 @@ pub struct MediaServiceImpl { repo: Arc, user_repo: Arc, album_share_repo: Arc, - config: Config, + metadata_repo: Arc, + config: AppConfig, nats_client: async_nats::Client, } @@ -30,13 +24,15 @@ impl MediaServiceImpl { repo: Arc, user_repo: Arc, album_share_repo: Arc, - config: Config, + metadata_repo: Arc, + config: AppConfig, nats_client: async_nats::Client, ) -> Self { Self { repo, user_repo, album_share_repo, + metadata_repo, config, nats_client, } @@ -55,10 +51,22 @@ impl MediaService for MediaServiceImpl { self.check_upload_prerequisites(owner_id, file_size, &hash) .await?; - let storage_path = self.persist_media_file(&file_bytes, &filename).await?; + let file_bytes_clone = file_bytes.clone(); + let extracted_data = tokio::task::spawn_blocking(move || { + extract_exif_data_from_bytes(&file_bytes_clone) + }) + .await + .unwrap()?; + + let (storage_path_buf, _date_taken) = + get_storage_path_and_date(&extracted_data, &filename); + + let storage_path_str = self + .persist_media_file(&file_bytes, &storage_path_buf) + .await?; let media = self - .persist_media_metadata(owner_id, filename, mime_type, storage_path, hash, file_size) + .persist_media_metadata(owner_id, filename, mime_type, storage_path_str, hash, file_size, extracted_data) .await?; self.publish_new_media_job(media.id).await?; @@ -66,7 +74,7 @@ impl MediaService for MediaServiceImpl { Ok(media) } - async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult { + async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult { let media = self .repo .find_by_id(id) @@ -79,20 +87,23 @@ impl MediaService for MediaServiceImpl { .await? .ok_or(CoreError::NotFound("User".to_string(), user_id))?; - if authz::is_owner(user_id, &media) || authz::is_admin(&user) { - return Ok(media); - } - - let is_shared = self + if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) { + let is_shared = self .album_share_repo .is_media_in_shared_album(id, user_id) .await?; - if is_shared { - return Ok(media); + tracing::warn!("User {} attempted to access media {} without permission, media owner is: {}", user_id, id, media.owner_id); + + if !is_shared { + tracing::warn!("User {} attempted to access media {} without permission, media owner is: {}", user_id, id, media.owner_id); + return Err(CoreError::Auth("Access denied".to_string())); + } } - Err(CoreError::Auth("Access denied".to_string())) + let metadata = self.metadata_repo.find_by_media_id(id).await?; + + Ok(MediaBundle { media, metadata }) } async fn list_user_media(&self, user_id: Uuid, options: ListMediaOptions) -> CoreResult> { @@ -216,27 +227,18 @@ impl MediaServiceImpl { Ok(()) } - async fn persist_media_file(&self, file_bytes: &[u8], filename: &str) -> CoreResult { - let now = chrono::Utc::now(); - let year = now.year().to_string(); - let month = format!("{:02}", now.month()); + async fn persist_media_file(&self, file_bytes: &[u8], storage_path: &Path) -> CoreResult { let mut dest_path = PathBuf::from(&self.config.media_library_path); - dest_path.push(year.clone()); - dest_path.push(month.clone()); + dest_path.push(storage_path); - fs::create_dir_all(&dest_path).await?; - dest_path.push(filename); - - let storage_path_str = PathBuf::from(&year) - .join(&month) - .join(filename) - .to_string_lossy() - .to_string(); + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).await?; + } let mut file = fs::File::create(&dest_path).await?; file.write_all(&file_bytes).await?; - Ok(storage_path_str) + Ok(storage_path.to_string_lossy().to_string()) } async fn persist_media_metadata( @@ -247,6 +249,7 @@ impl MediaServiceImpl { storage_path: String, hash: String, file_size: i64, + extracted_data: ExtractedExif, ) -> CoreResult { let media_model = Media { id: Uuid::new_v4(), @@ -260,6 +263,22 @@ impl MediaServiceImpl { }; self.repo.create(&media_model).await?; + + let mut metadata_models = Vec::new(); + for (source, tag_name, tag_value) in extracted_data.all_tags { + metadata_models.push(MediaMetadata { + id: Uuid::new_v4(), + media_id: media_model.id, + source, + tag_name, + tag_value, + }); + } + + if !metadata_models.is_empty() { + self.metadata_repo.create_batch(&metadata_models).await?; + } + self.user_repo .update_storage_used(owner_id, file_size) .await?; diff --git a/libertas_api/src/services/user_service.rs b/libertas_api/src/services/user_service.rs index de8d629..a30118f 100644 --- a/libertas_api/src/services/user_service.rs +++ b/libertas_api/src/services/user_service.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use libertas_core::{ - config::Config, + config::AppConfig, error::{CoreError, CoreResult}, models::{Role, User}, repositories::UserRepository, @@ -17,7 +17,7 @@ pub struct UserServiceImpl { repo: Arc, hasher: Arc, tokenizer: Arc, - config: Arc, + config: Arc, } impl UserServiceImpl { @@ -25,7 +25,7 @@ impl UserServiceImpl { repo: Arc, hasher: Arc, tokenizer: Arc, - config: Arc, + config: Arc, ) -> Self { Self { repo, diff --git a/libertas_api/src/state.rs b/libertas_api/src/state.rs index 17bb5e3..093ccc4 100644 --- a/libertas_api/src/state.rs +++ b/libertas_api/src/state.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use libertas_core::{ - config::Config, + config::AppConfig, services::{AlbumService, MediaService, UserService}, }; @@ -14,5 +14,5 @@ pub struct AppState { pub album_service: Arc, pub token_generator: Arc, pub nats_client: async_nats::Client, - pub config: Config, + pub config: AppConfig, } diff --git a/libertas_core/Cargo.toml b/libertas_core/Cargo.toml index 9de88b0..529bb6f 100644 --- a/libertas_core/Cargo.toml +++ b/libertas_core/Cargo.toml @@ -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" diff --git a/libertas_core/src/config.rs b/libertas_core/src/config.rs index e1dddd1..462eac2 100644 --- a/libertas_core/src/config.rs +++ b/libertas_core/src/config.rs @@ -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, + + #[serde(flatten)] + pub thumbnail_config: Option, +} + +fn default_max_upload_size() -> u32 { 100 } +fn default_storage_quota() -> u64 { 10 } +fn default_allowed_sort_columns() -> Vec { + 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>, pub thumbnail_config: Option, } + +pub fn load_config() -> CoreResult { + // 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::() + .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, + }) +} \ No newline at end of file diff --git a/libertas_core/src/media_utils.rs b/libertas_core/src/media_utils.rs index 71256ed..e6a2338 100644 --- a/libertas_core/src/media_utils.rs +++ b/libertas_core/src/media_utils.rs @@ -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> { - 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 { + 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::>() + } else { + match parser.parse::<_, _, TrackInfo>(ms) { + Ok(info) => info + .into_iter() + .map(|x| { + ( + MediaMetadataSource::TrackInfo, + x.0.to_string(), + x.1.to_string(), + ) + }) + .collect::>(), + 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 { @@ -74,4 +140,26 @@ pub async fn extract_exif_data(file_path: &Path) -> CoreResult { }; Ok(ExtractedExif { all_tags }) +} + +pub fn get_storage_path_and_date( + extracted_data: &ExtractedExif, + filename: &str, +) -> (PathBuf, Option>) { + 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) } \ No newline at end of file diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index d0574f1..1f6d65b 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -145,3 +145,9 @@ pub struct AlbumShare { pub user_id: uuid::Uuid, pub permission: AlbumPermission, } + + +pub struct MediaBundle { + pub media: Media, + pub metadata: Vec, +} \ No newline at end of file diff --git a/libertas_core/src/plugins.rs b/libertas_core/src/plugins.rs index 40eb783..b44d906 100644 --- a/libertas_core/src/plugins.rs +++ b/libertas_core/src/plugins.rs @@ -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, pub metadata_repo: Arc, pub media_library_path: String, - pub config: Arc, + pub config: Arc, } #[async_trait] diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index 82901da..84cc9ab 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -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, + pub mime_type: Option, + pub metadata_filters: Option>, + // In the future, we can add fields like: // pub date_range: Option<(chrono::DateTime, chrono::DateTime)>, } @@ -67,4 +68,10 @@ pub struct ListMediaOptions { pub sort: Option, pub filter: Option, // pub pagination: Option, +} + +#[derive(Debug, Clone)] +pub struct MetadataFilter { + pub tag_name: String, + pub tag_value: String, } \ No newline at end of file diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index c3b07d0..02c0a23 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -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; - async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult; + async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult; async fn list_user_media(&self, user_id: Uuid, options: ListMediaOptions) -> CoreResult>; async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult; async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>; diff --git a/libertas_importer/src/config.rs b/libertas_importer/src/config.rs index 6f256ab..7baba32 100644 --- a/libertas_importer/src/config.rs +++ b/libertas_importer/src/config.rs @@ -1,25 +1 @@ -use libertas_core::{ - config::{Config, DatabaseConfig, DatabaseType}, - error::CoreResult, -}; - -pub fn load_config() -> CoreResult { - Ok(Config { - database: DatabaseConfig { - db_type: DatabaseType::Postgres, - url: "postgres://postgres:postgres@localhost:5432/libertas_db".to_string(), - }, - server_address: "127.0.0.1:8080".to_string(), - jwt_secret: "super_secret_jwt_key".to_string(), - media_library_path: "media_library".to_string(), - broker_url: "nats://localhost:4222".to_string(), - max_upload_size_mb: Some(100), - default_storage_quota_gb: Some(10), - allowed_sort_columns: Some(vec![ - "date_taken".to_string(), - "created_at".to_string(), - "original_filename".to_string(), - ]), - thumbnail_config: None, - }) -} +pub use libertas_core::config::load_config; \ No newline at end of file diff --git a/libertas_importer/src/main.rs b/libertas_importer/src/main.rs index 6bb43fa..03e7911 100644 --- a/libertas_importer/src/main.rs +++ b/libertas_importer/src/main.rs @@ -4,10 +4,9 @@ use std::{ sync::Arc, }; -use chrono::Datelike; use clap::Parser; use libertas_core::{ - config::Config, error::{CoreError, CoreResult}, media_utils::{extract_exif_data, parse_exif_datetime}, models::{Media, MediaMetadata, MediaMetadataSource, User}, repositories::{MediaMetadataRepository, MediaRepository, UserRepository} + config::AppConfig, error::{CoreError, CoreResult}, media_utils::{extract_exif_data, get_storage_path_and_date}, models::{Media, MediaMetadata, User}, repositories::{MediaMetadataRepository, MediaRepository, UserRepository} }; use libertas_infra::factory::{build_database_pool, build_media_metadata_repository, build_media_repository, build_user_repository}; use serde_json; @@ -30,7 +29,7 @@ struct Cli { } struct ImporterState { - config: Config, + config: AppConfig, media_repo: Arc, user_repo: Arc, metadata_repo: Arc, @@ -132,31 +131,22 @@ async fn process_file(file_path: &Path, user: &User, state: &ImporterState) -> C } }; - 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 (storage_path_buf, _date_taken) = + get_storage_path_and_date(&extracted_data, &filename); - let date_taken = date_taken_str.and_then(|s| parse_exif_datetime(s)); - let file_date = date_taken.unwrap_or_else(chrono::Utc::now); - let year = file_date.year().to_string(); - let month = format!("{:02}", file_date.month()); let mut dest_path_buf = PathBuf::from(&state.config.media_library_path); - dest_path_buf.push(&year); - dest_path_buf.push(&month); + dest_path_buf.push(&storage_path_buf); - fs::create_dir_all(&dest_path_buf).await?; + println!(" -> Storing file at: {}", dest_path_buf.display()); - dest_path_buf.push(&filename); + if let Some(parent) = dest_path_buf.parent() { + fs::create_dir_all(parent).await?; + } fs::copy(file_path, &dest_path_buf).await?; - let storage_path_str = PathBuf::from(&year) - .join(&month) - .join(&filename) - .to_string_lossy() - .to_string(); + + let storage_path_str = storage_path_buf.to_string_lossy().to_string(); + let mime_type = mime_guess::from_path(file_path) .first_or_octet_stream() .to_string(); diff --git a/libertas_infra/src/factory.rs b/libertas_infra/src/factory.rs index d7251c4..78b07a9 100644 --- a/libertas_infra/src/factory.rs +++ b/libertas_infra/src/factory.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use libertas_core::{ - config::{Config, DatabaseConfig, DatabaseType}, + config::{AppConfig, DatabaseConfig, DatabaseType}, error::{CoreError, CoreResult}, repositories::UserRepository, }; @@ -47,7 +47,7 @@ pub async fn build_user_repository( } pub async fn build_media_repository( - config: &Config, + config: &AppConfig, pool: DatabasePool, ) -> CoreResult> { match pool { diff --git a/libertas_infra/src/query_builder.rs b/libertas_infra/src/query_builder.rs index 5203f8d..4054d37 100644 --- a/libertas_infra/src/query_builder.rs +++ b/libertas_infra/src/query_builder.rs @@ -38,12 +38,41 @@ impl QueryBuilder for MediaQueryBuilder { mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, options: &'a ListMediaOptions, ) -> CoreResult> { - if let Some(_filter) = &options.filter { - // In the future, you would add logic here: - // if let Some(mime) = &filter.mime_type { - // query.push(" AND mime_type = "); - // query.push_bind(mime); - // } + let mut metadata_filter_count = 0; + + if let Some(filter) = &options.filter { + if let Some(mime) = &filter.mime_type { + query.push(" AND media.mime_type = "); + query.push_bind(mime); + } + + if let Some(metadata_filters) = &filter.metadata_filters { + if !metadata_filters.is_empty() { + metadata_filter_count = metadata_filters.len(); + + query.push(" JOIN media_metadata mm ON media.id == mm.media_id "); + query.push(" AND ( "); + + for (i, filter) in metadata_filters.iter().enumerate() { + if i > 0 { + query.push(" OR "); + } + + query.push(" ( mm.tag_name = "); + query.push_bind(&filter.tag_name); + query.push(" AND mm.tag_value = "); + query.push_bind(&filter.tag_value); + query.push(" ) "); + } + query.push(" ) "); + } + } + } + + if metadata_filter_count > 0 { + query.push(" GROUP BY media.id "); + query.push(" HAVING COUNT(DISTINCT mm.tag_name) = "); + query.push_bind(metadata_filter_count as i64); } if let Some(sort) = &options.sort { diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index a41ad45..b1710a8 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use libertas_core::{ - config::Config, error::{CoreError, CoreResult}, models::Media, repositories::MediaRepository, schema::ListMediaOptions + config::AppConfig, error::{CoreError, CoreResult}, models::Media, repositories::MediaRepository, schema::ListMediaOptions }; use sqlx::PgPool; use uuid::Uuid; @@ -16,11 +16,11 @@ pub struct PostgresMediaRepository { } impl PostgresMediaRepository { - pub fn new(pool: PgPool, config: &Config) -> Self { + pub fn new(pool: PgPool, config: &AppConfig) -> Self { let allowed_columns = config .allowed_sort_columns .clone() - .unwrap_or_else(|| vec!["created_at".to_string()]); + .unwrap_or_else(|| vec!["created_at".to_string(), "original_filename".to_string()]); Self { pool, query_builder: Arc::new(MediaQueryBuilder::new(allowed_columns)) } } @@ -89,10 +89,10 @@ impl MediaRepository for PostgresMediaRepository { async fn list_by_user(&self, user_id: Uuid, options: &ListMediaOptions) -> CoreResult> { let mut query = sqlx::QueryBuilder::new( r#" - SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at, - thumbnail_path + SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, + media.thumbnail_path FROM media - WHERE owner_id = + WHERE media.owner_id = "#, ); diff --git a/libertas_worker/src/config.rs b/libertas_worker/src/config.rs index efdf442..7baba32 100644 --- a/libertas_worker/src/config.rs +++ b/libertas_worker/src/config.rs @@ -1,25 +1 @@ -use libertas_core::{ - config::{Config, DatabaseConfig, DatabaseType}, - error::CoreResult, -}; - -pub fn load_config() -> CoreResult { - Ok(Config { - database: DatabaseConfig { - db_type: DatabaseType::Postgres, - url: "postgres://libertas:libertas_password@localhost:5436/libertas_db".to_string(), - }, - server_address: "127.0.0.1:8080".to_string(), - jwt_secret: "super_secret_jwt_key".to_string(), - media_library_path: "media_library".to_string(), - broker_url: "nats://localhost:4222".to_string(), - max_upload_size_mb: Some(100), - default_storage_quota_gb: Some(10), - allowed_sort_columns: Some(vec![ - "date_taken".to_string(), - "created_at".to_string(), - "original_filename".to_string(), - ]), - thumbnail_config: None, - }) -} +pub use libertas_core::config::load_config; \ No newline at end of file