change media files api

This commit is contained in:
Brandon Thomas committed 2026-06-09 08:38:00 -04:00
1 parent e9f46b1e87
commit 7d7e95fc22
6 files changed
+263 -81

No files matched your search

@@ -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<Utc>,
pub media_type: String,
pub media_class: String,
pub maybe_mime_type: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub maybe_duration_millis: Option<i32>,
pub maybe_title: Option<String>,
}
@@ -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<u32>,
}
#[derive(Serialize, ToSchema)]
pub struct ListFolderMediaFilesSuccessResponse {
pub success: bool,
pub media_files: Vec<FolderMediaFileInfo>,
pub maybe_cursor: Option<String>,
}
// ── PUT /v1/folders/media_files/{folder_token}/bulk_add ──
#[derive(Deserialize, ToSchema)]
@@ -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<Utc>,
pub media_file_token: MediaFileToken,
pub media_type: String,
pub media_class: String,
pub maybe_mime_type: Option<String>,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_batch_token: Option<BatchGenerationToken>,
// 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<String>,
pub maybe_public_bucket_extension: Option<String>,
// 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<String>,
pub maybe_cover_public_bucket_prefix: Option<String>,
pub maybe_cover_public_bucket_extension: Option<String>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_title: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_origin_filename: Option<String>,
pub maybe_duration_millis: Option<i32>,
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub maybe_duration_millis: Option<i32>,
pub maybe_title: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<Vec<FolderMediaFileRow>, sqlx::Error>
@@ -58,20 +89,40 @@ where
SELECT
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
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::<Vec<_>>()
}
@@ -109,20 +170,40 @@ LIMIT ?
SELECT
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
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::<Vec<_>>()
}
@@ -1246,7 +1246,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// Folders
FolderInfo,
FolderMediaFileInfo,
FolderMediaFileListItem,
FolderPathInfo,
FolderMediaFilesPathInfo,
SubfolderPathInfo,
@@ -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,
}
}
@@ -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<FolderMediaFileListItem>,
pub maybe_cursor: Option<String>,
}
/// 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<Utc>,
/// 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<BatchGenerationToken>,
/// 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<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_origin_filename: Option<String>,
/// Duration for audio and video files, if available. Milliseconds.
pub maybe_duration_millis: Option<u64>,
/// Original pixel width / height for image and video files when known.
pub maybe_frame_width: Option<i32>,
pub maybe_frame_height: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── 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,
}
}