mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 04:16:55 +00:00
media file links for folders
This commit is contained in:
1 parent
5b64a4f322
commit
f985a97d45
9 files changed
+330
-21
No files matched your search
@@ -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<FolderToken>,
|
||||
|
||||
pub maybe_last_media_file_token_1: Option<MediaFileToken>,
|
||||
pub maybe_last_media_file_token_2: Option<MediaFileToken>,
|
||||
pub maybe_last_media_file_token_3: Option<MediaFileToken>,
|
||||
pub maybe_last_media_file_token_4: Option<MediaFileToken>,
|
||||
/// 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<FolderThumbnail>,
|
||||
|
||||
pub maybe_cover_image_custom_media_token: Option<MediaFileToken>,
|
||||
/// 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<FolderThumbnail>,
|
||||
|
||||
pub maybe_color_code: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
+91
@@ -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<String>,
|
||||
pub maybe_public_bucket_extension: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<MediaFileThumbnailRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
if args.candidate_tokens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut builder = QueryBuilder::<MySql>::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::<RawRow>()
|
||||
.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<String>,
|
||||
maybe_public_bucket_extension: Option<String>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1246,6 +1246,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
|
||||
// Folders
|
||||
FolderInfo,
|
||||
FolderThumbnail,
|
||||
FolderMediaFileListItem,
|
||||
FolderPathInfo,
|
||||
FolderMediaFilesPathInfo,
|
||||
|
||||
+18
-2
@@ -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,
|
||||
|
||||
+127
-7
@@ -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<HashMap<MediaFileToken, FolderThumbnail>, 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<MediaFileToken, FolderThumbnail>,
|
||||
) -> FolderInfo {
|
||||
// Vec rather than array so `.into_iter()` yields owned `Option`s on
|
||||
// every edition.
|
||||
let last_media_thumbnails: Vec<FolderThumbnail> = 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<MediaFileToken> {
|
||||
// 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<MediaFileToken> = 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,
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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),
|
||||
}))
|
||||
}
|
||||
+20
-2
@@ -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,
|
||||
|
||||
+20
-2
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user