From f985a97d456e21820383ea39815b3276fb50e08e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 9 Jun 2026 09:00:30 -0400 Subject: [PATCH] media file links for folders --- .../artcraft_api_defs/src/folders/common.rs | 39 ++++- ...tch_get_media_file_thumbnails_by_tokens.rs | 91 ++++++++++++ .../src/queries/media_files/get/mod.rs | 1 + .../web/storyteller_web/src/docs/api_doc.rs | 1 + .../folders/folder/create_folder_handler.rs | 20 ++- .../folders/folder/folder_info_conversion.rs | 134 +++++++++++++++++- .../folders/folder/get_folder_handler.rs | 21 ++- .../folders/folder/list_folders_handler.rs | 22 ++- .../subfolder/list_subfolders_handler.rs | 22 ++- 9 files changed, 330 insertions(+), 21 deletions(-) create mode 100644 crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs 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 2e10e76b12..0c6a9c0051 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 @@ -1,7 +1,10 @@ use chrono::{DateTime, Utc}; use serde_derive::{Deserialize, Serialize}; +use url::Url; use utoipa::ToSchema; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::users::UserToken; @@ -20,12 +23,16 @@ pub struct FolderInfo { pub owner_user_token: UserToken, pub maybe_parent_folder_token: Option, - pub maybe_last_media_file_token_1: Option, - pub maybe_last_media_file_token_2: Option, - pub maybe_last_media_file_token_3: Option, - pub maybe_last_media_file_token_4: Option, + /// The four most-recent media files added to the folder, in slot order + /// (the schema stores them as `maybe_last_media_file_token_1..4`). + /// Slots whose media file has been deleted are skipped, so the vec + /// may contain 0-4 entries. + pub last_media_thumbnails: Vec, - pub maybe_cover_image_custom_media_token: Option, + /// User-chosen cover image. When present, the frontend should use this + /// as the folder's primary thumbnail instead of the + /// `last_media_thumbnails` grid. + pub maybe_custom_cover_thumbnail: Option, pub maybe_color_code: Option, pub has_star: bool, @@ -38,3 +45,25 @@ pub struct FolderInfo { pub is_orphaned: bool, } +/// A compact thumbnail descriptor for a media file referenced by a +/// folder (either one of the `last_media_*` slots or the custom cover). +/// +/// Intentionally narrower than `MediaLinks` / `CoverImageLinks` — folders +/// only need the bare minimum to render a thumbnail card. The CDN URL is +/// the direct asset link; `maybe_thumbnail_template` carries the resizing +/// template URL (with `{{width}}` / `{{height}}` placeholders) when the +/// media class supports it. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct FolderThumbnail { + pub token: MediaFileToken, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + + /// Direct CDN URL to the underlying media asset. + pub cdn_url: Url, + + /// Resizing template URL (with `{{width}}` / `{{height}}` placeholders) + /// when the media class supports it. `None` for media classes without + /// a thumbnail variant. + pub maybe_thumbnail_template: Option, +} diff --git a/crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs b/crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs new file mode 100644 index 0000000000..47588ab44e --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs @@ -0,0 +1,91 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql, QueryBuilder}; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use tokens::tokens::media_files::MediaFileToken; + +/// Minimal columns the folder-thumbnail builder needs from `media_files`. +#[derive(Debug, Clone)] +pub struct MediaFileThumbnailRow { + pub token: MediaFileToken, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, +} + +pub struct BatchGetMediaFileThumbnailsByTokensArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub candidate_tokens: &'e [MediaFileToken], + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up the bucket-path / class / type for each of the given media +/// file tokens. Silently drops rows that don't exist or are soft-deleted. +/// Caller is responsible for de-duplicating the input set if needed; the +/// SQL doesn't care about duplicates. +/// +/// Uses runtime `QueryBuilder` because the IN-list size is dynamic. +pub async fn batch_get_media_file_thumbnails_by_tokens<'e, 'c: 'e, E>( + args: BatchGetMediaFileThumbnailsByTokensArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + if args.candidate_tokens.is_empty() { + return Ok(Vec::new()); + } + + let mut builder = QueryBuilder::::new( + "SELECT \ + token, \ + media_class, \ + media_type, \ + public_bucket_directory_hash, \ + maybe_public_bucket_prefix, \ + maybe_public_bucket_extension \ + FROM media_files \ + WHERE user_deleted_at IS NULL \ + AND mod_deleted_at IS NULL \ + AND token IN (", + ); + + let mut separated = builder.separated(", "); + for token in args.candidate_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let rows = builder + .build_query_as::() + .fetch_all(args.mysql_executor) + .await?; + + Ok(rows.into_iter().map(|r| MediaFileThumbnailRow { + token: MediaFileToken::new(r.token), + media_class: MediaFileClass::from_str(&r.media_class).unwrap_or(MediaFileClass::Image), + media_type: MediaFileType::from_str(&r.media_type).unwrap_or(MediaFileType::Image), + 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, + }).collect()) +} + +/// Internal row shape used by the dynamic `QueryBuilder` call. We hand-roll +/// the deserialization here because `QueryBuilder` can't ride the +/// compile-time type checks of `sqlx::query!`/`query_as!`. +#[derive(Debug, sqlx::FromRow)] +struct RawRow { + token: String, + media_class: String, + media_type: String, + public_bucket_directory_hash: String, + maybe_public_bucket_prefix: Option, + maybe_public_bucket_extension: Option, +} diff --git a/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs b/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs index 62a0202f3f..f860ee36f4 100644 --- a/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_get_media_file_thumbnails_by_tokens; pub mod batch_get_media_files; pub mod batch_get_media_files_by_tokens; pub mod get_media_file; 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 7ec89c1a61..70cfa28bf7 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -1246,6 +1246,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Folders FolderInfo, + FolderThumbnail, FolderMediaFileListItem, FolderPathInfo, FolderMediaFilesPathInfo, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs index 1fa9a3fbf8..b922986788 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs @@ -13,7 +13,10 @@ use mysql_queries::queries::folders::folder::get_folder_for_owner::{ use mysql_queries::queries::folders::folder::insert_folder::{insert_folder, InsertFolderArgs}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +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; @@ -101,7 +104,20 @@ pub async fn create_folder_handler( })? .ok_or_else(|| CommonWebError::server_error_with_message("folder vanished after insert"))?; - let folder: FolderInfo = folder_row_to_info(row); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + std::slice::from_ref(&row), + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let folder: FolderInfo = folder_row_to_info(row, &thumbnails); Ok(Json(CreateFolderSuccessResponse { success: true, 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 cecfe3c946..7d1100da7b 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,17 +1,87 @@ -use artcraft_api_defs::folders::common::FolderInfo; +use std::collections::{HashMap, HashSet}; +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail}; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use mysql_queries::queries::folders::folder::folder_row::FolderRow; +use mysql_queries::queries::media_files::get::batch_get_media_file_thumbnails_by_tokens::{ + batch_get_media_file_thumbnails_by_tokens, BatchGetMediaFileThumbnailsByTokensArgs, + MediaFileThumbnailRow, +}; +use server_environment::ServerEnvironment; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::common_responses::media::media_domain::MediaDomain; +use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; + +/// Gather every media-file token referenced by the given folder rows +/// (the four `last_media_*` slots + the custom cover) and batch-fetch +/// the thumbnail descriptors so we can build [`FolderInfo`]s in one go. +/// +/// Returns a token → thumbnail map. Tokens for media files that don't +/// exist or are soft-deleted are silently absent from the map; the +/// caller treats them as "slot skipped". +pub async fn build_folder_thumbnails_lookup<'e, 'c: 'e, E>( + folder_rows: &[FolderRow], + mysql_executor: E, + media_domain: MediaDomain, + server_environment: ServerEnvironment, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql> + 'c, +{ + let candidate_tokens = collect_candidate_thumbnail_tokens(folder_rows); + + if candidate_tokens.is_empty() { + return Ok(HashMap::new()); + } + + let thumbnail_rows = batch_get_media_file_thumbnails_by_tokens( + BatchGetMediaFileThumbnailsByTokensArgs { + candidate_tokens: &candidate_tokens, + mysql_executor, + phantom: PhantomData, + }, + ).await?; + + Ok(thumbnail_rows.into_iter() + .map(|row| { + let token = row.token.clone(); + (token, build_folder_thumbnail(row, media_domain, server_environment)) + }) + .collect()) +} + +pub fn folder_row_to_info( + row: FolderRow, + thumbnails_by_token: &HashMap, +) -> FolderInfo { + // Vec rather than array so `.into_iter()` yields owned `Option`s on + // every edition. + let last_media_thumbnails: Vec = vec![ + row.maybe_last_media_file_token_1, + row.maybe_last_media_file_token_2, + row.maybe_last_media_file_token_3, + row.maybe_last_media_file_token_4, + ] + .into_iter() + .filter_map(|maybe_token| { + maybe_token.and_then(|t| thumbnails_by_token.get(&t).cloned()) + }) + .collect(); + + let maybe_custom_cover_thumbnail = row.maybe_cover_image_custom_media_token + .and_then(|t| thumbnails_by_token.get(&t).cloned()); -pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { FolderInfo { token: row.token, name: row.name, owner_user_token: row.owner_user_token, maybe_parent_folder_token: row.maybe_parent_folder_token, - maybe_last_media_file_token_1: row.maybe_last_media_file_token_1, - maybe_last_media_file_token_2: row.maybe_last_media_file_token_2, - maybe_last_media_file_token_3: row.maybe_last_media_file_token_3, - maybe_last_media_file_token_4: row.maybe_last_media_file_token_4, - maybe_cover_image_custom_media_token: row.maybe_cover_image_custom_media_token, + last_media_thumbnails, + maybe_custom_cover_thumbnail, maybe_color_code: row.maybe_color_code, has_star: row.has_star, created_at: row.created_at, @@ -19,3 +89,53 @@ pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { is_orphaned: row.is_orphaned, } } + +fn collect_candidate_thumbnail_tokens(rows: &[FolderRow]) -> Vec { + // Dedup with a `HashSet` so a folder that reuses the same token across + // multiple slots (or two folders that share a thumbnail) doesn't pad + // the IN-list bound parameter list. + let mut seen: HashSet = HashSet::with_capacity(rows.len() * 5); + for row in rows { + for maybe_token in [ + row.maybe_last_media_file_token_1.as_ref(), + row.maybe_last_media_file_token_2.as_ref(), + row.maybe_last_media_file_token_3.as_ref(), + row.maybe_last_media_file_token_4.as_ref(), + row.maybe_cover_image_custom_media_token.as_ref(), + ] { + if let Some(token) = maybe_token { + seen.insert(token.clone()); + } + } + } + seen.into_iter().collect() +} + +fn build_folder_thumbnail( + row: MediaFileThumbnailRow, + media_domain: MediaDomain, + server_environment: ServerEnvironment, +) -> FolderThumbnail { + 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(), + ); + + // Reuse `MediaLinksBuilder` to produce the CDN URL + thumbnail + // template, then pull just those two fields onto the compact + // FolderThumbnail wire shape. + let media_links = MediaLinksBuilder::from_media_path_and_env( + media_domain, + server_environment, + &bucket_path, + ); + + FolderThumbnail { + token: row.token, + media_class: row.media_class, + media_type: row.media_type, + cdn_url: media_links.cdn_url, + maybe_thumbnail_template: media_links.maybe_thumbnail_template, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs index 6926ec4f27..d808b219ee 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs @@ -12,7 +12,10 @@ use mysql_queries::queries::folders::folder::get_folder_for_owner::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +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; @@ -43,7 +46,6 @@ pub async fn get_folder_handler( &http_request, &server_state.session_checker, &mut conn, ).await.map_err(|_| CommonWebError::NotAuthorized)?; - let row = get_folder_for_owner(GetFolderForOwnerArgs { folder_token: &path.folder_token, owner_user_token: &user_session.user_token, @@ -55,8 +57,21 @@ pub async fn get_folder_handler( })? .ok_or(CommonWebError::NotFound)?; + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + std::slice::from_ref(&row), + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + Ok(Json(GetFolderSuccessResponse { success: true, - folder: folder_row_to_info(row), + folder: folder_row_to_info(row, &thumbnails), })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs index 001e34601b..9805075274 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs @@ -13,7 +13,10 @@ use mysql_queries::queries::folders::folder::list_folders_for_user::{ }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +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; @@ -82,7 +85,22 @@ pub async fn list_folders_handler( CommonWebError::server_error_with_message("Failed to encode cursor") })?; - let folders = rows.into_iter().map(folder_row_to_info).collect(); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + &rows, + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let folders = rows.into_iter() + .map(|row| folder_row_to_info(row, &thumbnails)) + .collect(); Ok(Json(ListFoldersSuccessResponse { success: true, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs index 087836a8a8..ec047e4417 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs @@ -17,7 +17,10 @@ use mysql_queries::queries::folders::subfolder::list_subfolders::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +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; @@ -105,7 +108,22 @@ pub async fn list_subfolders_handler( CommonWebError::server_error_with_message("Failed to encode cursor") })?; - let subfolders = rows.into_iter().map(folder_row_to_info).collect(); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + &rows, + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let subfolders = rows.into_iter() + .map(|row| folder_row_to_info(row, &thumbnails)) + .collect(); Ok(Json(ListSubfoldersSuccessResponse { success: true,