diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs index ce0aaad76c..2e10e76b12 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs @@ -6,6 +6,11 @@ use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::users::UserToken; +// NB: The "folder media file" list-item shape lives in storyteller_web +// (see `endpoints/folders/media_files/list_folder_media_files_handler.rs`) +// because it embeds `MediaFileCoverImageDetails` and other domain types +// that depend on the request's `MediaDomain` + `ServerEnvironment`. + /// Canonical wire shape for a folder. Used by single-folder GETs, create /// responses, list rows, and subfolder list rows. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] @@ -33,22 +38,3 @@ pub struct FolderInfo { pub is_orphaned: bool, } -/// Canonical wire shape for a media file inside a folder. Lean enough for -/// thumbnail rendering; callers that need richer fields should hit the -/// media-file batch-get endpoint. -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct FolderMediaFileInfo { - pub media_file_token: MediaFileToken, - pub added_to_folder_at: DateTime, - - pub media_type: String, - pub media_class: String, - pub maybe_mime_type: Option, - pub public_bucket_directory_hash: String, - pub maybe_public_bucket_prefix: Option, - pub maybe_public_bucket_extension: Option, - pub maybe_frame_width: Option, - pub maybe_frame_height: Option, - pub maybe_duration_millis: Option, - pub maybe_title: Option, -} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs index 5af1111fcd..3a98e06492 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs @@ -4,9 +4,12 @@ use utoipa::{IntoParams, ToSchema}; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; -use crate::folders::common::FolderMediaFileInfo; - // ── GET /v1/folders/media_files/{folder_token} ── +// +// NB: `ListFolderMediaFilesSuccessResponse` and `FolderMediaFileListItem` +// live in `storyteller_web`'s handler because the wire shape embeds +// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend +// on the request's `MediaDomain` + `ServerEnvironment`. #[derive(Deserialize, ToSchema)] pub struct FolderMediaFilesPathInfo { @@ -19,13 +22,6 @@ pub struct ListFolderMediaFilesQueryParams { pub limit: Option, } -#[derive(Serialize, ToSchema)] -pub struct ListFolderMediaFilesSuccessResponse { - pub success: bool, - pub media_files: Vec, - pub maybe_cursor: Option, -} - // ── PUT /v1/folders/media_files/{folder_token}/bulk_add ── #[derive(Deserialize, ToSchema)] diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs index 62c5e31f38..b04946524c 100644 --- a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs @@ -3,29 +3,57 @@ use std::marker::PhantomData; use chrono::{DateTime, Utc}; use sqlx::{Executor, MySql}; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +/// One folder-media-file row + the joined media-file fields the list +/// endpoint needs to materialize MediaLinks, MediaFileCoverImageDetails, +/// and the rest of the typed response shape. #[derive(Debug, Clone)] pub struct FolderMediaFileRow { - /// The `folder_media_files.id` of the membership row. Use this as the - /// cursor for the next page. + /// `folder_media_files.id` — used as the pagination cursor. pub membership_id: u64, /// When the media file was added to the folder. pub added_to_folder_at: DateTime, pub media_file_token: MediaFileToken, - pub media_type: String, - pub media_class: String, - pub maybe_mime_type: Option, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + + pub maybe_batch_token: Option, + + // The media file's own bucket fields (caller uses these to build MediaLinks). pub public_bucket_directory_hash: String, pub maybe_public_bucket_prefix: Option, pub maybe_public_bucket_extension: Option, + + // Cover image's bucket fields, from a LEFT JOIN on + // mf.maybe_cover_image_media_file_token. All three are Some-or-None + // together (matched / unmatched join row). + pub maybe_cover_public_bucket_directory_hash: Option, + pub maybe_cover_public_bucket_prefix: Option, + pub maybe_cover_public_bucket_extension: Option, + + pub creator_set_visibility: Visibility, + pub is_user_upload: bool, + pub is_intermediate_system_file: bool, + + pub maybe_title: Option, + pub maybe_prompt_token: Option, + pub maybe_origin_filename: Option, + + pub maybe_duration_millis: Option, pub maybe_frame_width: Option, pub maybe_frame_height: Option, - pub maybe_duration_millis: Option, - pub maybe_title: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, } pub struct ListFolderMediaFilesArgs<'e, 'c, E> @@ -40,9 +68,12 @@ where } /// Paginated list of media files in the given folder. Joins `media_files` -/// and filters out rows that are soft-deleted on the media-file side. -/// Most-recently-added first. The caller is expected to have already -/// authorized the folder access. +/// for the file's own metadata and LEFT JOINs `media_files` again on +/// `maybe_cover_image_media_file_token` so the cover-image bucket fields +/// come back in a single round-trip. Filters out media files that are +/// soft-deleted on the media-file side. Most-recently-added first. +/// +/// The caller is expected to have already authorized the folder access. pub async fn list_folder_media_files<'e, 'c: 'e, E>( args: ListFolderMediaFilesArgs<'e, 'c, E>, ) -> Result, sqlx::Error> @@ -58,20 +89,40 @@ where SELECT fmf.id as `membership_id: u64`, fmf.created_at as `added_to_folder_at: DateTime`, + mf.token as `media_file_token: MediaFileToken`, - mf.media_type, - mf.media_class, - mf.maybe_mime_type, + mf.media_class as `media_class: MediaFileClass`, + mf.media_type as `media_type: MediaFileType`, + + mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`, + mf.public_bucket_directory_hash, mf.maybe_public_bucket_prefix, mf.maybe_public_bucket_extension, + + cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`, + cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`, + cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`, + + mf.creator_set_visibility as `creator_set_visibility: Visibility`, + mf.is_user_upload as `is_user_upload: bool`, + mf.is_intermediate_system_file as `is_intermediate_system_file: bool`, + + mf.maybe_title, + mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`, + mf.maybe_origin_filename, + + mf.maybe_duration_millis, mf.maybe_frame_width, mf.maybe_frame_height, - mf.maybe_duration_millis, - mf.maybe_title + + mf.created_at, + mf.updated_at FROM folder_media_files fmf JOIN media_files mf ON mf.token = fmf.media_file_token +LEFT JOIN media_files cover + ON cover.token = mf.maybe_cover_image_media_file_token WHERE fmf.folder_token = ? AND mf.user_deleted_at IS NULL AND mf.mod_deleted_at IS NULL @@ -90,16 +141,26 @@ LIMIT ? membership_id: r.membership_id, added_to_folder_at: r.added_to_folder_at, media_file_token: r.media_file_token, - media_type: r.media_type, media_class: r.media_class, - maybe_mime_type: r.maybe_mime_type, + media_type: r.media_type, + maybe_batch_token: r.maybe_batch_token, public_bucket_directory_hash: r.public_bucket_directory_hash, maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, + maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, + maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, + creator_set_visibility: r.creator_set_visibility, + is_user_upload: r.is_user_upload, + is_intermediate_system_file: r.is_intermediate_system_file, + maybe_title: r.maybe_title, + maybe_prompt_token: r.maybe_prompt_token, + maybe_origin_filename: r.maybe_origin_filename, + maybe_duration_millis: r.maybe_duration_millis, maybe_frame_width: r.maybe_frame_width, maybe_frame_height: r.maybe_frame_height, - maybe_duration_millis: r.maybe_duration_millis, - maybe_title: r.maybe_title, + created_at: r.created_at, + updated_at: r.updated_at, }) .collect::>() } @@ -109,20 +170,40 @@ LIMIT ? SELECT fmf.id as `membership_id: u64`, fmf.created_at as `added_to_folder_at: DateTime`, + mf.token as `media_file_token: MediaFileToken`, - mf.media_type, - mf.media_class, - mf.maybe_mime_type, + mf.media_class as `media_class: MediaFileClass`, + mf.media_type as `media_type: MediaFileType`, + + mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`, + mf.public_bucket_directory_hash, mf.maybe_public_bucket_prefix, mf.maybe_public_bucket_extension, + + cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`, + cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`, + cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`, + + mf.creator_set_visibility as `creator_set_visibility: Visibility`, + mf.is_user_upload as `is_user_upload: bool`, + mf.is_intermediate_system_file as `is_intermediate_system_file: bool`, + + mf.maybe_title, + mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`, + mf.maybe_origin_filename, + + mf.maybe_duration_millis, mf.maybe_frame_width, mf.maybe_frame_height, - mf.maybe_duration_millis, - mf.maybe_title + + mf.created_at, + mf.updated_at FROM folder_media_files fmf JOIN media_files mf ON mf.token = fmf.media_file_token +LEFT JOIN media_files cover + ON cover.token = mf.maybe_cover_image_media_file_token WHERE fmf.folder_token = ? AND mf.user_deleted_at IS NULL AND mf.mod_deleted_at IS NULL @@ -139,16 +220,26 @@ LIMIT ? membership_id: r.membership_id, added_to_folder_at: r.added_to_folder_at, media_file_token: r.media_file_token, - media_type: r.media_type, media_class: r.media_class, - maybe_mime_type: r.maybe_mime_type, + media_type: r.media_type, + maybe_batch_token: r.maybe_batch_token, public_bucket_directory_hash: r.public_bucket_directory_hash, maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, + maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, + maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, + creator_set_visibility: r.creator_set_visibility, + is_user_upload: r.is_user_upload, + is_intermediate_system_file: r.is_intermediate_system_file, + maybe_title: r.maybe_title, + maybe_prompt_token: r.maybe_prompt_token, + maybe_origin_filename: r.maybe_origin_filename, + maybe_duration_millis: r.maybe_duration_millis, maybe_frame_width: r.maybe_frame_width, maybe_frame_height: r.maybe_frame_height, - maybe_duration_millis: r.maybe_duration_millis, - maybe_title: r.maybe_title, + created_at: r.created_at, + updated_at: r.updated_at, }) .collect::>() } diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 206ff27bc9..7ec89c1a61 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -1246,7 +1246,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Folders FolderInfo, - FolderMediaFileInfo, + FolderMediaFileListItem, FolderPathInfo, FolderMediaFilesPathInfo, SubfolderPathInfo, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs index c78579e677..cecfe3c946 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs @@ -1,6 +1,5 @@ -use artcraft_api_defs::folders::common::{FolderInfo, FolderMediaFileInfo}; +use artcraft_api_defs::folders::common::FolderInfo; use mysql_queries::queries::folders::folder::folder_row::FolderRow; -use mysql_queries::queries::folders::media_files::list_folder_media_files::FolderMediaFileRow; pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { FolderInfo { @@ -20,20 +19,3 @@ pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { is_orphaned: row.is_orphaned, } } - -pub fn folder_media_file_row_to_info(row: FolderMediaFileRow) -> FolderMediaFileInfo { - FolderMediaFileInfo { - media_file_token: row.media_file_token, - added_to_folder_at: row.added_to_folder_at, - media_type: row.media_type, - media_class: row.media_class, - maybe_mime_type: row.maybe_mime_type, - public_bucket_directory_hash: row.public_bucket_directory_hash, - maybe_public_bucket_prefix: row.maybe_public_bucket_prefix, - maybe_public_bucket_extension: row.maybe_public_bucket_extension, - maybe_frame_width: row.maybe_frame_width, - maybe_frame_height: row.maybe_frame_height, - maybe_duration_millis: row.maybe_duration_millis, - maybe_title: row.maybe_title, - } -} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs index cd507fe848..5114e8e942 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs @@ -3,21 +3,35 @@ use std::sync::Arc; use actix_web::web::{Json, Path, Query}; use actix_web::{web, HttpRequest}; +use chrono::{DateTime, Utc}; use log::warn; +use serde_derive::Serialize; +use utoipa::ToSchema; +use artcraft_api_defs::common::responses::media_links::MediaLinks; use artcraft_api_defs::folders::media_files::{ - FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, ListFolderMediaFilesSuccessResponse, + FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, }; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; use mysql_queries::queries::folders::folder::get_folder_for_owner::{ get_folder_for_owner, GetFolderForOwnerArgs, }; use mysql_queries::queries::folders::media_files::list_folder_media_files::{ - list_folder_media_files, ListFolderMediaFilesArgs, + list_folder_media_files, FolderMediaFileRow, ListFolderMediaFilesArgs, }; +use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_media_file_row_to_info; +use crate::http_server::common_responses::media::media_domain::MediaDomain; +use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; @@ -25,6 +39,65 @@ const CURSOR_NAME: &str = "folder_mf"; const DEFAULT_LIMIT: u32 = 100; const MAX_LIMIT: u32 = 1000; +// ── Response shape ── + +#[derive(Serialize, ToSchema)] +pub struct ListFolderMediaFilesSuccessResponse { + pub success: bool, + pub media_files: Vec, + pub maybe_cursor: Option, +} + +/// One media file as it appears inside a folder. Lean version of the +/// "by batch" / "by user" list-item shape — keeps the fields that matter +/// for folder browsing and drops the bucket-path noise. +#[derive(Serialize, ToSchema)] +pub struct FolderMediaFileListItem { + pub token: MediaFileToken, + + /// When the media file was added to the folder. + pub added_to_folder_at: DateTime, + + /// Coarse-grained class (image / video / audio / dimensional). + pub media_class: MediaFileClass, + + /// Specific format (jpg, png, mp4, etc.) — closer to a MIME type. + pub media_type: MediaFileType, + + /// If this file was generated as part of a batch, the batch token — + /// useful for showing "siblings" in the UI. + pub maybe_batch_token: Option, + + /// Rich CDN links to the media itself (full URL, thumbnail template, + /// video previews when applicable). + pub media_links: MediaLinks, + + /// Cover image details. For files that don't carry their own cover + /// image, this still gives the deterministic default-cover spec + /// (image_index + color_index) the frontend uses for placeholders. + pub cover_image: MediaFileCoverImageDetails, + + pub creator_set_visibility: Visibility, + pub is_user_upload: bool, + pub is_intermediate_system_file: bool, + + pub maybe_title: Option, + pub maybe_prompt_token: Option, + pub maybe_origin_filename: Option, + + /// Duration for audio and video files, if available. Milliseconds. + pub maybe_duration_millis: Option, + + /// Original pixel width / height for image and video files when known. + pub maybe_frame_width: Option, + pub maybe_frame_height: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// ── Handler ── + #[utoipa::path( get, tag = "Folders (Media File Management)", @@ -65,7 +138,7 @@ pub async fn list_folder_media_files_handler( warn!("Folder lookup failed: {:?}", err); CommonWebError::from_error(err) })?; - + if folder.is_none() { return Err(CommonWebError::NotFound); } @@ -103,7 +176,12 @@ pub async fn list_folder_media_files_handler( CommonWebError::server_error_with_message("Failed to encode cursor") })?; - let media_files = rows.into_iter().map(folder_media_file_row_to_info).collect(); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let media_files = rows.into_iter() + .map(|row| folder_media_file_row_to_list_item(row, media_domain, server_environment)) + .collect(); Ok(Json(ListFolderMediaFilesSuccessResponse { success: true, @@ -111,3 +189,52 @@ pub async fn list_folder_media_files_handler( maybe_cursor, })) } + +fn folder_media_file_row_to_list_item( + row: FolderMediaFileRow, + media_domain: MediaDomain, + server_environment: server_environment::ServerEnvironment, +) -> FolderMediaFileListItem { + let bucket_path = MediaFileBucketPath::from_object_hash( + &row.public_bucket_directory_hash, + row.maybe_public_bucket_prefix.as_deref(), + row.maybe_public_bucket_extension.as_deref(), + ); + + let media_links = MediaLinksBuilder::from_media_path_and_env( + media_domain, + server_environment, + &bucket_path, + ); + + let cover_image = MediaFileCoverImageDetails::from_optional_db_fields( + &row.media_file_token, + media_domain, + server_environment, + row.maybe_cover_public_bucket_directory_hash.as_deref(), + row.maybe_cover_public_bucket_prefix.as_deref(), + row.maybe_cover_public_bucket_extension.as_deref(), + ); + + FolderMediaFileListItem { + token: row.media_file_token, + added_to_folder_at: row.added_to_folder_at, + media_class: row.media_class, + media_type: row.media_type, + maybe_batch_token: row.maybe_batch_token, + media_links, + cover_image, + creator_set_visibility: row.creator_set_visibility, + is_user_upload: row.is_user_upload, + is_intermediate_system_file: row.is_intermediate_system_file, + maybe_title: row.maybe_title, + maybe_prompt_token: row.maybe_prompt_token, + maybe_origin_filename: row.maybe_origin_filename, + // Schema stores `INT(10)`; widen to u64 for the wire shape. + maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64), + maybe_frame_width: row.maybe_frame_width, + maybe_frame_height: row.maybe_frame_height, + created_at: row.created_at, + updated_at: row.updated_at, + } +}