Compare commits

...
Author SHA1 Message Date
Brandon Thomas 293a41c7d4 let's actually not call the new endpoint prematurely 2026-09-27 17:35:00 -04:00
Brandon Thomas 55ab800542 more performant list user files endpoint 2026-09-27 15:02:19 -04:00
Brandon Thomas 2c93b4cf42 Merge pull request #36 from storytold/bt-opengraph-metadata
add opengraph metadata (social media tags)
2026-09-26 16:32:39 -07:00
Brandon Thomas 9169e4d662 add opengraph metadata (social media tags) 2026-09-26 16:30:57 -07:00
Brandon Thomas 4c7af51851 Merge pull request #35 from storytold/bt-media-previews
fix media previews in new design
2026-09-26 10:30:44 -07:00
34 changed files with 1851 additions and 609 deletions

No files matched your search

+12
View File
@@ -96,6 +96,18 @@ Organize for top-to-bottom reading. Important things first, details later.
- Among helpers: meatier logic above leaf-level formatters
- **In test modules**: constants first, then test cases (grouped into sub-modules when 2+), then helper functions last
## Public API Compatibility
- Public HTTP endpoints have deployed legacy clients. Preserve their request and
response schemas, defaults, limits, and pagination behavior when optimizing them.
- Put a changed contract behind a new endpoint/version and update new clients to
call it explicitly. Do not add a new mode or change response fields on an existing
public endpoint as part of a performance fix.
- For an endpoint-local revision, version the operation path (for example,
`/v1/media_files/list_v2/user/{username}`). Reserve a top-level `/v2` for broader
API changes.
- Add regression tests for the original contract alongside tests for the new route.
## Markdown
- **Tables must be space-padded so columns align in plain text.** Markdown
@@ -8,6 +8,7 @@ use enums::common::view_as::ViewAs;
use errors::{anyhow, AnyhowResult};
use mysql_queries::queries::media_files::delete::delete_media_file::delete_media_file_as_mod;
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
use crate::cli_args::Args;
@@ -42,23 +43,25 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
info!("Querying page {page_index} of files...");
let media_files = list_media_files_for_user(ListMediaFileForUserArgs {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
page_size: 100,
page_index,
filters: UserMediaFileFilters {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
view_as: ViewAs::Moderator,
},
limit: 100,
offset: page_index * 100,
sort_ascending: false,
view_as: ViewAs::Moderator,
mysql_pool: &mysql,
mysql_executor: mysql,
}).await?;
if media_files.records.is_empty() {
if media_files.is_empty() {
break;
}
for file in media_files.records {
for file in media_files {
info!("Deleting file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
@@ -0,0 +1,20 @@
use sqlx::{Executor, MySql, QueryBuilder};
use super::user_media_file_filters::UserMediaFileFilters;
pub struct CountMediaFilesForUserArgs<'a, T> {
pub filters: UserMediaFileFilters<'a>,
pub mysql_executor: T,
}
/// Legacy numbered pagination needs an exact total. Count only this user's rows,
/// without related-table lookups or global low-selectivity index intersections.
pub async fn count_media_files_for_user<'c, T>(args: CountMediaFilesForUserArgs<'_, T>) -> Result<i64, sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
let mut query =
QueryBuilder::<MySql>::new("SELECT COUNT(*) FROM media_files m FORCE INDEX (fk_maybe_creator_user_token)");
args.filters.push_predicates(&mut query);
query.build_query_scalar().fetch_one(args.mysql_executor).await
}
@@ -1,191 +1,10 @@
use std::collections::HashSet;
use sqlx::{Executor, MySql, QueryBuilder};
use chrono::{DateTime, Utc};
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
use sqlx::mysql::MySqlRow;
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
use super::user_media_file_filters::UserMediaFileFilters;
use super::user_media_file_list_item::{MediaFileListItem, MediaFileListItemInternal};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
use crate::helpers::boolean_converters::i8_to_bool;
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
pub struct MediaFileListPage {
pub records: Vec<MediaFileListItem>,
pub sort_ascending: bool,
pub current_page: usize,
pub total_page_count: usize,
}
pub struct MediaFileListItem {
pub token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_engine_category: Option<MediaFileEngineCategory>,
pub maybe_animation_type: Option<MediaFileAnimationType>,
pub origin_category: MediaFileOriginCategory,
pub origin_product_category: MediaFileOriginProductCategory,
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
pub maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
pub maybe_origin_model_title: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
pub maybe_prompt_args: Option<PromptInnerPayload>,
pub maybe_duration_millis: Option<u64>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
pub maybe_bookmark_count: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct ListMediaFileForUserArgs<'a> {
pub username: &'a str,
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
pub include_user_uploads: bool,
pub page_size: usize,
pub page_index: usize,
pub sort_ascending: bool,
pub view_as: ViewAs,
pub mysql_pool: &'a MySqlPool,
}
pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult<MediaFileListPage> {
/// Let's figure out how many results we could have returned total
let count_fields = select_total_count_field();
let mut count_query_builder = query_builder(
args.maybe_filter_media_types,
args.maybe_filter_media_classes,
args.maybe_filter_engine_categories,
args.include_user_uploads,
args.username,
false,
0,
0,
args.sort_ascending,
args.view_as,
count_fields.as_str(),
true,
);
let row_count_query = count_query_builder.build_query_scalar::<i64>();
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
/// Now fetch the actual results with all the fields
let result_fields = select_result_fields();
let mut query = query_builder(
args.maybe_filter_media_types,
args.maybe_filter_media_classes,
args.maybe_filter_engine_categories,
args.include_user_uploads,
args.username,
true,
args.page_index,
args.page_size,
args.sort_ascending,
args.view_as,
result_fields.as_str(),
false,
);
let query = query.build_query_as::<MediaFileListItemInternal>();
let results = query.fetch_all(args.mysql_pool).await?;
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
let results = results.into_iter()
.map(|record| {
MediaFileListItem {
token: record.token,
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
maybe_origin_model_title: record.maybe_origin_model_title,
public_bucket_directory_hash: record.public_bucket_directory_hash,
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
maybe_prompt_token: record.maybe_prompt_token,
creator_set_visibility: record.creator_set_visibility,
is_user_upload: i8_to_bool(record.is_user_upload),
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_prompt_args: record.maybe_other_prompt_args
.as_deref()
.map(|args| PromptInnerPayload::from_json(args))
.transpose()
.ok() // NB: Fail open
.flatten(),
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
maybe_bookmark_count: record.maybe_bookmark_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>();
Ok(MediaFileListPage {
records: results,
sort_ascending: args.sort_ascending,
current_page: args.page_index,
total_page_count: number_of_pages,
})
}
fn select_result_fields() -> String {
r#"
const RESULT_FIELDS: &str = r#"
m.id,
m.token,
@@ -229,258 +48,50 @@ fn select_result_fields() -> String {
m.created_at,
m.updated_at
"#
.to_string()
"#;
pub struct ListMediaFileForUserArgs<'a, T> {
pub filters: UserMediaFileFilters<'a>,
pub limit: usize,
pub offset: usize,
pub sort_ascending: bool,
pub mysql_executor: T,
}
fn select_total_count_field() -> String {
r#"
COUNT(m.id) AS total_count
"#
.to_string()
}
fn query_builder<'a>(
maybe_filter_media_types: Option<&HashSet<MediaFileType>>,
maybe_filter_media_classes: Option<&HashSet<MediaFileClass>>,
maybe_filter_engine_categories: Option<&HashSet<MediaFileEngineCategory>>,
include_user_uploads: bool,
username: &'a str,
enforce_limits: bool,
page_index: usize,
page_size: usize,
sort_ascending: bool,
view_as: ViewAs,
select_fields: &'a str,
count_only: bool,
) -> QueryBuilder<'a, MySql> {
// NB: The count query only needs the `users` join (for the username predicate).
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
let from_clause = if count_only {
r#"
FROM media_files AS m
LEFT OUTER JOIN users AS u
ON m.maybe_creator_user_token = u.token
"#
} else {
r#"
FROM media_files AS m
LEFT OUTER JOIN users AS u
ON m.maybe_creator_user_token = u.token
LEFT OUTER JOIN model_weights as w
ON m.maybe_origin_model_token = w.token
LEFT OUTER JOIN media_files as media_file_cover_image
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
LEFT OUTER JOIN entity_stats
ON entity_stats.entity_type = "media_file"
AND entity_stats.entity_token = m.token
LEFT OUTER JOIN prompts
ON prompts.token = m.maybe_prompt_token
"#
};
// NB: Query cannot be statically checked by sqlx
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
format!(r#"
SELECT
{select_fields}
{from_clause}"#
));
query_builder.push(" WHERE u.username = ");
query_builder.push_bind(username);
//if let Some(media_type) = maybe_filter_media_type {
// // FIXME: Binding shouldn't require to_str().
// // Otherwise, it's calling the Display trait on the raw type which is resulting in an
// // incorrect binding and runtime error.
// query_builder.push(" AND m.media_type = ");
// query_builder.push_bind(media_type.to_str());
//}
if let Some(media_types) = maybe_filter_media_types {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !media_types.is_empty() {
query_builder.push(" AND m.media_type IN ( ");
let mut separated = query_builder.separated(", ");
for media_type in media_types.iter() {
separated.push_bind(media_type.to_str());
}
separated.push_unseparated(") ");
}
}
if let Some(media_classes) = maybe_filter_media_classes {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !media_classes.is_empty() {
query_builder.push(" AND m.media_class IN ( ");
let mut separated = query_builder.separated(", ");
for media_class in media_classes.iter() {
separated.push_bind(media_class.to_str());
}
separated.push_unseparated(") ");
}
}
if let Some(engine_categories) = maybe_filter_engine_categories {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !engine_categories.is_empty() {
query_builder.push(" AND m.maybe_engine_category IN ( ");
let mut separated = query_builder.separated(", ");
for engine_category in engine_categories.iter() {
separated.push_bind(engine_category.to_str());
}
separated.push_unseparated(") ");
}
}
if !include_user_uploads {
query_builder.push(" AND NOT m.is_user_upload ");
}
// TODO: Argument to control
query_builder.push(" AND NOT m.is_intermediate_system_file ");
match view_as {
ViewAs::Author | ViewAs::Moderator => {
// NB(bt): Actually, mods don't want to see deleted files. We'll improve the moderator UI later.
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
}
ViewAs::AnotherUser => {
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
// FIXME: Binding shouldn't require to_str().
// Otherwise, it's calling the Display trait on the raw type which is resulting in an
// incorrect binding and runtime error.
query_builder.push(" AND m.creator_set_visibility = ");
query_builder.push_bind(Visibility::Public.to_str());
}
}
// NB: Ordering is meaningless for the single-row COUNT aggregate.
if !count_only {
if sort_ascending {
query_builder.push(" ORDER BY m.created_at ASC ");
} else {
query_builder.push(" ORDER BY m.created_at DESC ");
}
}
if enforce_limits {
let offset = page_index * page_size;
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
}
query_builder
}
struct MediaFileListItemInternal {
id: i64,
token: MediaFileToken,
media_class: MediaFileClass,
media_type: MediaFileType,
maybe_engine_category: Option<MediaFileEngineCategory>,
maybe_animation_type: Option<MediaFileAnimationType>,
origin_category: MediaFileOriginCategory,
origin_product_category: MediaFileOriginProductCategory,
maybe_origin_model_type: Option<MediaFileOriginModelType>,
maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
maybe_origin_model_title: Option<String>,
maybe_other_prompt_args: Option<String>,
maybe_duration_millis: Option<i32>,
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_prompt_token: Option<PromptToken>,
creator_set_visibility: Visibility,
is_user_upload: i8,
is_intermediate_system_file: i8,
maybe_file_cover_image_public_bucket_hash: Option<String>,
maybe_file_cover_image_public_bucket_prefix: Option<String>,
maybe_file_cover_image_public_bucket_extension: Option<String>,
maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
maybe_text_transcript: Option<String>,
maybe_ratings_positive_count: Option<u32>,
maybe_ratings_negative_count: Option<u32>,
maybe_bookmark_count: Option<u32>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
// cannot have type hints, eg. the following:
//
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
//
// This results in the automatic mapping not being able to be found by name (for macro derive), and
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
// full "as" clause).
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
id: row.try_get("id")?,
token: MediaFileToken::new(row.try_get("token")?),
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
is_user_upload: row.try_get("is_user_upload")?,
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
maybe_title: row.try_get("maybe_title")?,
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
/// Select a bounded page before joining potentially large prompt/metadata rows.
/// `LIMIT` prevents MySQL from merging the derived table into the outer query.
pub async fn list_media_files_for_user<'c, T>(
args: ListMediaFileForUserArgs<'_, T>,
) -> Result<Vec<MediaFileListItem>, sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
let order = if args.sort_ascending { " ASC" } else { " DESC" };
let mut query = QueryBuilder::<MySql>::new(format!("SELECT {RESULT_FIELDS} FROM (SELECT m.id "));
// This index is in the July 2026 migrations. Restricting the scan to this user
// avoids index-merge scans of the global deletion/class indexes on large tables.
query.push("FROM media_files m FORCE INDEX (idx_creator_created_at) ");
args.filters.push_predicates(&mut query);
query.push(" ORDER BY m.created_at").push(order).push(", m.id").push(order);
query.push(" LIMIT ").push_bind(args.limit as u64);
query.push(" OFFSET ").push_bind(args.offset as u64);
query.push(
r#") AS page
JOIN media_files m ON m.id = page.id
LEFT JOIN model_weights w ON m.maybe_origin_model_token = w.token
LEFT JOIN media_files media_file_cover_image ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
LEFT JOIN entity_stats ON entity_stats.entity_type = "#,
);
query.push_bind(StatsEntityType::MediaFile.to_str());
query
.push(
r#" AND entity_stats.entity_token = m.token
LEFT JOIN prompts ON prompts.token = m.maybe_prompt_token
ORDER BY m.created_at"#,
)
.push(order)
.push(", m.id")
.push(order);
let records = query.build_query_as::<MediaFileListItemInternal>().fetch_all(args.mysql_executor).await?;
Ok(records.into_iter().map(MediaFileListItem::from).collect())
}
@@ -1,3 +1,4 @@
pub mod count_media_files_for_user;
pub mod list_batch_generated_redux_media_files;
pub mod list_featured_media_files;
pub mod list_media_files;
@@ -7,3 +8,5 @@ pub mod list_media_files_for_user;
pub mod list_session_media_files_by_class;
pub mod list_session_project_media_files;
pub mod media_file_list_row;
pub mod user_media_file_filters;
pub mod user_media_file_list_item;
@@ -0,0 +1,59 @@
use std::collections::HashSet;
use sqlx::{MySql, QueryBuilder};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
/// Shared predicates keep the optional total and the returned page consistent.
#[derive(Clone, Copy)]
pub struct UserMediaFileFilters<'a> {
pub username: &'a str,
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
pub include_user_uploads: bool,
pub view_as: ViewAs,
}
impl<'a> UserMediaFileFilters<'a> {
pub(super) fn push_predicates(&self, query: &mut QueryBuilder<'a, MySql>) {
// Resolve the unique username once; subsequent scans are scoped to its token.
query.push(" WHERE m.maybe_creator_user_token = (SELECT token FROM users WHERE username = ");
query.push_bind(self.username).push(")");
if let Some(values) = self.maybe_filter_media_types.filter(|values| !values.is_empty()) {
query.push(" AND m.media_type IN (");
let mut separated = query.separated(", ");
for value in values {
separated.push_bind(value.to_str());
}
separated.push_unseparated(")");
}
if let Some(values) = self.maybe_filter_media_classes.filter(|values| !values.is_empty()) {
query.push(" AND m.media_class IN (");
let mut separated = query.separated(", ");
for value in values {
separated.push_bind(value.to_str());
}
separated.push_unseparated(")");
}
if let Some(values) = self.maybe_filter_engine_categories.filter(|values| !values.is_empty()) {
query.push(" AND m.maybe_engine_category IN (");
let mut separated = query.separated(", ");
for value in values {
separated.push_bind(value.to_str());
}
separated.push_unseparated(")");
}
if !self.include_user_uploads {
query.push(" AND NOT m.is_user_upload");
}
query.push(" AND NOT m.is_intermediate_system_file AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL");
if self.view_as == ViewAs::AnotherUser {
query.push(" AND m.creator_set_visibility = ").push_bind(Visibility::Public.to_str());
}
}
}
@@ -0,0 +1,206 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, Row};
use sqlx::mysql::MySqlRow;
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
use crate::helpers::boolean_converters::i8_to_bool;
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
pub struct MediaFileListItem {
pub token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_engine_category: Option<MediaFileEngineCategory>,
pub maybe_animation_type: Option<MediaFileAnimationType>,
pub origin_category: MediaFileOriginCategory,
pub origin_product_category: MediaFileOriginProductCategory,
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
pub maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
pub maybe_origin_model_title: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
pub maybe_prompt_args: Option<PromptInnerPayload>,
pub maybe_duration_millis: Option<u64>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
pub maybe_bookmark_count: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub(super) struct MediaFileListItemInternal {
id: i64,
token: MediaFileToken,
media_class: MediaFileClass,
media_type: MediaFileType,
maybe_engine_category: Option<MediaFileEngineCategory>,
maybe_animation_type: Option<MediaFileAnimationType>,
origin_category: MediaFileOriginCategory,
origin_product_category: MediaFileOriginProductCategory,
maybe_origin_model_type: Option<MediaFileOriginModelType>,
maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
maybe_origin_model_title: Option<String>,
maybe_other_prompt_args: Option<String>,
maybe_duration_millis: Option<i32>,
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_prompt_token: Option<PromptToken>,
creator_set_visibility: Visibility,
is_user_upload: i8,
is_intermediate_system_file: i8,
maybe_file_cover_image_public_bucket_hash: Option<String>,
maybe_file_cover_image_public_bucket_prefix: Option<String>,
maybe_file_cover_image_public_bucket_extension: Option<String>,
maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
maybe_text_transcript: Option<String>,
maybe_ratings_positive_count: Option<u32>,
maybe_ratings_negative_count: Option<u32>,
maybe_bookmark_count: Option<u32>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
// cannot have type hints, eg. the following:
//
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
//
// This results in the automatic mapping not being able to be found by name (for macro derive), and
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
// full "as" clause).
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
id: row.try_get("id")?,
token: MediaFileToken::new(row.try_get("token")?),
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
is_user_upload: row.try_get("is_user_upload")?,
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
maybe_title: row.try_get("maybe_title")?,
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
}
impl From<MediaFileListItemInternal> for MediaFileListItem {
fn from(record: MediaFileListItemInternal) -> Self {
MediaFileListItem {
token: record.token,
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
maybe_origin_model_title: record.maybe_origin_model_title,
public_bucket_directory_hash: record.public_bucket_directory_hash,
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
maybe_prompt_token: record.maybe_prompt_token,
creator_set_visibility: record.creator_set_visibility,
is_user_upload: i8_to_bool(record.is_user_upload),
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_prompt_args: record.maybe_other_prompt_args
.as_deref()
.map(|args| PromptInnerPayload::from_json(args))
.transpose()
.ok() // NB: Fail open
.flatten(),
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
maybe_bookmark_count: record.maybe_bookmark_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}
@@ -0,0 +1,309 @@
//! Executes the actual library queries against migrated, disposable local MySQL.
//! IsolatedTestDatabase uses a fixed Unix socket, never DATABASE_URL/MYSQL_URL.
use std::collections::HashSet;
use std::time::Instant;
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use mysql_queries::queries::media_files::list::count_media_files_for_user::{
count_media_files_for_user, CountMediaFilesForUserArgs,
};
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
list_media_files_for_user, ListMediaFileForUserArgs,
};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use mysql_testing::fixtures::users::create_test_user;
use mysql_testing::isolated::IsolatedTestDatabase;
use sqlx::{MySql, MySqlPool, QueryBuilder};
use tokens::tokens::users::UserToken;
const CREATED_AT: &str = "2025-01-01 12:00:00";
#[tokio::test]
async fn user_media_library_preserves_filters_visibility_joins_and_pagination() {
let db = IsolatedTestDatabase::create().await;
let owner = create_test_user(&db.pool).await.unwrap();
let other = create_test_user(&db.pool).await.unwrap();
let rows = [
("m_public_image", MediaFileClass::Image, MediaFileType::Png, Visibility::Public, true),
("m_private_video", MediaFileClass::Video, MediaFileType::Mp4, Visibility::Private, false),
("m_hidden_audio", MediaFileClass::Audio, MediaFileType::Mp3, Visibility::Hidden, false),
("m_public_mesh", MediaFileClass::Mesh, MediaFileType::Glb, Visibility::Public, false),
("m_private_splat", MediaFileClass::Splat, MediaFileType::Spz, Visibility::Private, false),
("m_legacy_mesh", MediaFileClass::Dimensional, MediaFileType::Glb, Visibility::Public, false),
];
for (token, class, kind, visibility, upload) in rows {
insert_fixture(&db.pool, Fixture { owner: &owner.user_token, token, class, kind, visibility, upload }).await;
}
for token in ["m_intermediate", "m_user_deleted", "m_mod_deleted"] {
insert_fixture(&db.pool, Fixture::image(&owner.user_token, token)).await;
}
insert_fixture(&db.pool, Fixture::image(&other.user_token, "m_other_user")).await;
sqlx::query("UPDATE media_files SET is_intermediate_system_file = true WHERE token = 'm_intermediate'")
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_user_deleted'")
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE media_files SET mod_deleted_at = NOW() WHERE token = 'm_mod_deleted'")
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE media_files SET maybe_engine_category = ? WHERE token = 'm_public_mesh'")
.bind(MediaFileEngineCategory::Object.to_str())
.execute(&db.pool)
.await
.unwrap();
sqlx::query("INSERT INTO prompts (token, prompt_type, creator_ip_address, maybe_other_args) VALUES ('p_library', ?, '127.0.0.1', ?)")
.bind(PromptType::ArtcraftApp.to_str()).bind(r#"{"fd":true}"#).execute(&db.pool).await.unwrap();
sqlx::query("UPDATE media_files SET maybe_cover_image_media_file_token = 'm_intermediate', maybe_prompt_token = 'p_library', maybe_duration_millis = 1234, maybe_title = 'Fixture title' WHERE token = 'm_private_video'").execute(&db.pool).await.unwrap();
sqlx::query("INSERT INTO entity_stats (entity_type, entity_token, ratings_positive_count, bookmark_count) VALUES (?, 'm_private_video', 3, 7)")
.bind(StatsEntityType::MediaFile.to_str()).execute(&db.pool).await.unwrap();
let filters = filters(&owner.username);
for (view_as, expected) in [
(
ViewAs::Author,
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
),
(
ViewAs::Moderator,
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
),
(ViewAs::AnotherUser, vec!["m_legacy_mesh", "m_public_mesh", "m_public_image"]),
] {
let selected = UserMediaFileFilters { view_as, ..filters };
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters: selected,
limit: 60,
offset: 0,
sort_ascending: false,
mysql_executor: &db.pool,
})
.await
.unwrap();
assert_eq!(records.iter().map(|r| r.token.as_str()).collect::<Vec<_>>(), expected);
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
.await
.unwrap(),
expected.len() as i64
);
}
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 60,
offset: 0,
sort_ascending: true,
mysql_executor: &db.pool,
})
.await
.unwrap();
assert_eq!(records.first().unwrap().token.as_str(), "m_public_image");
let video = &records[1];
assert_eq!(video.maybe_file_cover_image_public_bucket_hash.as_deref(), Some("fixture_hash"));
assert_eq!(video.maybe_prompt_args.as_ref().unwrap().used_face_detailer, Some(true));
assert_eq!(video.maybe_ratings_positive_count, Some(3));
assert_eq!(video.maybe_bookmark_count, Some(7));
assert_eq!(video.maybe_duration_millis, Some(1234));
assert_eq!(video.maybe_title.as_deref(), Some("Fixture title"));
// All timestamps tie. The id tiebreaker must avoid duplicates/omissions.
let mut paged = vec![];
for offset in [0, 2, 4, 6] {
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 2,
offset,
sort_ascending: true,
mysql_executor: &db.pool,
})
.await
.unwrap();
paged.extend(records.into_iter().map(|r| r.token.to_string()));
}
assert_eq!(paged, rows.iter().map(|r| r.0.to_string()).collect::<Vec<_>>());
let classes = HashSet::from([MediaFileClass::Image, MediaFileClass::Video]);
let types = HashSet::from([MediaFileType::Glb]);
let engines = HashSet::from([MediaFileEngineCategory::Object]);
let empty = HashSet::new();
for (selected, expected) in [
(UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters }, 2),
(UserMediaFileFilters { maybe_filter_media_types: Some(&types), ..filters }, 2),
(UserMediaFileFilters { maybe_filter_engine_categories: Some(&engines), ..filters }, 1),
(UserMediaFileFilters { include_user_uploads: false, ..filters }, 5),
(UserMediaFileFilters { maybe_filter_media_classes: Some(&empty), ..filters }, 6),
(UserMediaFileFilters { username: "' OR 1=1 --", ..filters }, 0),
] {
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters: selected,
limit: 60,
offset: 0,
sort_ascending: false,
mysql_executor: &db.pool,
})
.await
.unwrap();
assert_eq!(records.len(), expected);
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
.await
.unwrap(),
expected as i64
);
}
// Queries must honor an existing transaction, including uncommitted changes.
let mut tx = db.pool.begin().await.unwrap();
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_private_video'")
.execute(&mut *tx)
.await
.unwrap();
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *tx }).await.unwrap(),
5
);
assert_eq!(
list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 60,
offset: 0,
sort_ascending: false,
mysql_executor: &mut *tx
})
.await
.unwrap()
.len(),
5
);
tx.rollback().await.unwrap();
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &db.pool }).await.unwrap(),
6
);
db.destroy().await;
}
#[tokio::test]
#[ignore = "large local fixture; run explicitly with --ignored --nocapture"]
async fn user_media_library_large_page_reads_are_bounded() {
let db = IsolatedTestDatabase::create().await;
let owner = create_test_user(&db.pool).await.unwrap();
let other = create_test_user(&db.pool).await.unwrap();
// 60k owner rows, followed by 240k newer files from another account.
for chunk in 0..300 {
let mut query = QueryBuilder::<MySql>::new("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) ");
query.push_values(0..1000, |mut row, index| {
row
.push_bind(format!("m_bench_{}", chunk * 1000 + index))
.push_bind(MediaFileOriginCategory::Upload.to_str())
.push_bind(MediaFileOriginProductCategory::Unknown.to_str())
.push_bind(MediaFileType::Png.to_str())
.push_bind(MediaFileClass::Image.to_str())
.push_bind("0".repeat(64))
.push_bind("fixture_hash")
.push_bind("127.0.0.1")
.push_bind(if chunk < 60 { owner.user_token.as_str() } else { other.user_token.as_str() })
.push_bind(Visibility::Private.to_str())
.push_bind(false)
.push_bind(true)
.push_bind(CREATED_AT);
});
query.build().execute(&db.pool).await.unwrap();
}
sqlx::query("ANALYZE TABLE media_files").execute(&db.pool).await.unwrap();
let classes = HashSet::from([
MediaFileClass::Image,
MediaFileClass::Video,
MediaFileClass::Audio,
MediaFileClass::Dimensional,
MediaFileClass::Mesh,
MediaFileClass::Splat,
]);
let filters = UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters(&owner.username) };
let mut conn = db.pool.acquire().await.unwrap();
let before = scan_reads(&mut conn).await;
let start = Instant::now();
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 61,
offset: 0,
sort_ascending: false,
mysql_executor: &mut *conn,
})
.await
.unwrap();
let elapsed = start.elapsed();
let scanned = scan_reads(&mut conn).await - before;
assert_eq!(records.len(), 61);
assert_eq!(records.first().unwrap().token.as_str(), "m_bench_59999");
assert!(scanned < 200, "page query scanned {scanned} sequential index entries");
let start = Instant::now();
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *conn }).await.unwrap(),
60_000
);
println!(
"300k fixtures / 60k owned: page={elapsed:?}, sequential index reads={scanned}; optional count={:?}",
start.elapsed()
);
drop(conn);
db.destroy().await;
}
struct Fixture<'a> {
owner: &'a UserToken,
token: &'a str,
class: MediaFileClass,
kind: MediaFileType,
visibility: Visibility,
upload: bool,
}
impl<'a> Fixture<'a> {
fn image(owner: &'a UserToken, token: &'a str) -> Self {
Self {
owner,
token,
class: MediaFileClass::Image,
kind: MediaFileType::Png,
visibility: Visibility::Public,
upload: true,
}
}
}
fn filters(username: &str) -> UserMediaFileFilters<'_> {
UserMediaFileFilters {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
view_as: ViewAs::Author,
}
}
async fn insert_fixture(pool: &MySqlPool, fixture: Fixture<'_>) {
sqlx::query("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) VALUES (?, ?, ?, ?, ?, ?, 'fixture_hash', '127.0.0.1', ?, ?, false, ?, ?)")
.bind(fixture.token).bind(MediaFileOriginCategory::Upload.to_str()).bind(MediaFileOriginProductCategory::Unknown.to_str())
.bind(fixture.kind.to_str()).bind(fixture.class.to_str()).bind("0".repeat(64)).bind(fixture.owner.as_str())
.bind(fixture.visibility.to_str()).bind(fixture.upload).bind(CREATED_AT).execute(pool).await.unwrap();
}
async fn scan_reads(conn: &mut sqlx::MySqlConnection) -> u64 {
let values: Vec<(String, String)> =
sqlx::query_as("SHOW SESSION STATUS WHERE Variable_name IN ('Handler_read_next', 'Handler_read_prev')")
.fetch_all(conn)
.await
.unwrap();
values.into_iter().map(|(_, value)| value.parse::<u64>().unwrap()).sum()
}
@@ -59,6 +59,7 @@ use crate::http_server::endpoints::media_files::get::get_media_file_handler::*;
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::*;
use crate::http_server::endpoints::media_files::list::list_media_files_handler::*;
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::*;
use crate::http_server::endpoints::media_files::search::search_featured_media_files_handler::*;
@@ -519,6 +520,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler,
crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler,
crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler,
crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler,
crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler,
crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler,
crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler,
@@ -1182,6 +1184,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListMediaFilesForUserPathInfo,
ListMediaFilesForUserQueryParams,
ListMediaFilesForUserSuccessResponse,
ListMediaFilesForUserV2PathInfo,
ListMediaFilesForUserV2QueryParams,
ListMediaFilesForUserV2SuccessResponse,
UserMediaFileV2Pagination,
ListMediaFilesQueryParams,
ListMediaFilesSuccessResponse,
ListPinnedMediaFilesSuccessResponse,
@@ -3,7 +3,6 @@ use std::sync::Arc;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpMessage, HttpRequest, HttpResponse};
use artcraft_api_defs::common::responses::media_links::MediaLinks;
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use chrono::{DateTime, Utc};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
@@ -15,14 +14,17 @@ use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use log::{info, warn};
use log::info;
use std::time::Instant;
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
use mysql_queries::queries::media_files::list::count_media_files_for_user::{count_media_files_for_user, CountMediaFilesForUserArgs};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
use tokens::tokens::media_files::MediaFileToken;
use utoipa::{IntoParams, ToSchema};
use tokens::tokens::prompts::PromptToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
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::common_responses::media_file_origin_details::MediaFileOriginDetails;
use crate::http_server::common_responses::pagination_page::PaginationPage;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
@@ -30,7 +32,6 @@ use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_m
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
use crate::state::server_state::ServerState;
use crate::util::allowed_studio_access::allowed_studio_access;
@@ -238,117 +239,48 @@ pub async fn list_media_files_for_user_handler(
let mut maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
let mut maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
info!("Querying media files for user: {:?} type: {:?} as: {:?}", path.username, maybe_filter_media_types, view_as);
let query_results = list_media_files_for_user(ListMediaFileForUserArgs {
let filters = UserMediaFileFilters {
username: &path.username,
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
include_user_uploads: query.include_user_uploads.unwrap_or(false),
page_size,
page_index,
sort_ascending,
view_as,
mysql_pool: &server_state.mysql_pool,
}).await;
let results_page = match query_results {
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(CommonWebError::from_anyhow_error(e));
}
};
let count_started = Instant::now();
let total_count = count_media_files_for_user(CountMediaFilesForUserArgs {
filters,
mysql_executor: &server_state.mysql_pool,
}).await?;
let count_ms = count_started.elapsed().as_millis();
let page_started = Instant::now();
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: page_size,
offset: page_index * page_size,
sort_ascending,
mysql_executor: &server_state.mysql_pool,
}).await?;
info!("Listed legacy user media: username={} page={} count_ms={} page_ms={}",
path.username, page_index, count_ms, page_started.elapsed().as_millis());
let media_domain = get_media_domain(&http_request);
let results = results_page.records.into_iter()
.filter(|record| {
if is_allowed_studio_access {
return true;
}
// Don't allow access to certain media types.
match record.media_type {
MediaFileType::Bvh |
MediaFileType::Fbx |
MediaFileType::Glb |
MediaFileType::Gltf |
MediaFileType::SceneRon => return false,
_ => {},
}
// // Don't allow access to certain products.
// match record.origin_product_category {
// MediaFileOriginProductCategory::VideoFilter |
// MediaFileOriginProductCategory::Mocap |
// MediaFileOriginProductCategory::Workflow => return false,
// _ => {},
// }
true
})
.map(|record| {
let public_bucket_path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
MediaFileForUserListItem {
token: record.token.clone(),
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin: MediaFileOriginDetails::from_db_fields_str(
record.origin_category,
record.origin_product_category,
record.maybe_origin_model_type,
record.maybe_origin_model_token.as_deref(),
record.maybe_origin_model_title.as_deref()),
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
media_links: MediaLinksBuilder::from_media_path_and_env(
media_domain, server_state.server_environment, &public_bucket_path),
maybe_prompt_token: record.maybe_prompt_token,
public_bucket_path: public_bucket_path
.get_full_object_path_str()
.to_string(),
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_state.server_environment),
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
&record.token,
media_domain,
server_state.server_environment,
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
),
creator_set_visibility: record.creator_set_visibility,
is_user_upload: record.is_user_upload,
is_intermediate_system_file: record.is_intermediate_system_file,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_style_name: record.maybe_prompt_args
.as_ref()
.and_then(|args| args.style_name.as_ref())
.and_then(|style| style.to_style_name()),
maybe_duration_millis: record.maybe_duration_millis,
stats: SimpleEntityStats {
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
},
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>();
let results = build_user_media_file_response(UserMediaFileResponseArgs {
records,
is_allowed_studio_access,
media_domain,
server_environment: server_state.server_environment,
});
Ok(Json(ListMediaFilesForUserSuccessResponse {
success: true,
results,
pagination: PaginationPage {
current: results_page.current_page,
total_page_count: results_page.total_page_count,
current: page_index,
// Preserve v1's historical calculation, including exact multiples.
// A pagination behavior change belongs in a new endpoint version.
total_page_count: 1 + (total_count / page_size as i64) as usize,
}
}))
}
@@ -0,0 +1,197 @@
use std::sync::Arc;
use std::time::Instant;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use enums::common::view_as::ViewAs;
use log::info;
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
list_media_files_for_user, ListMediaFileForUserArgs,
};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use utoipa::{IntoParams, ToSchema};
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
use super::user_media_file_response::{build_user_media_file_response, UserMediaFileResponseArgs};
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories;
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes;
use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types;
use crate::state::server_state::ServerState;
use crate::util::allowed_studio_access::allowed_studio_access;
#[derive(Deserialize, ToSchema)]
pub struct ListMediaFilesForUserV2PathInfo {
username: String,
}
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListMediaFilesForUserV2QueryParams {
pub sort_ascending: Option<bool>,
pub page_size: Option<usize>,
pub page_index: Option<usize>,
/// NB: This can be one (or more comma-separated values) from `MediaFileClass`,
/// which are the broad category of media files: image, video, etc.
///
/// Usage:
/// - `?filter_media_classes=audio`
/// - `?filter_media_classes=image,video`
/// - `?filter_media_classes=dimensional`
/// - etc.
pub filter_media_classes: Option<String>,
/// NB: This can be one (or more comma-separated values) from `MediaFileType`,
/// which are mimetype-like / format-like categories of media files: glb, gltf,
/// scene_json, jpg, png, mp4, wav, etc.
///
/// Usage:
/// - `?filter_media_type=scene_json`
/// - `?filter_media_type=glb,gltf,fbx`
/// - `?filter_media_type=pmd,vmd,pmx`
/// - `?filter_media_type=jpg,png,gif`
/// - `?filter_media_type=wav,mp3`
/// - `?filter_media_type=mp4`
/// - etc.
pub filter_media_type: Option<String>,
/// NB: This can be one (or more comma-separated values) from `MediaFileEngineCategory`.
///
/// Usage:
/// - `?filter_engine_categories=scene`
/// - `?filter_engine_categories=animation,character,object`
/// - etc.
pub filter_engine_categories: Option<String>,
/// Include user uploaded files in the results.
/// By default, we do not return them unless this flag is set to true.
pub include_user_uploads: Option<bool>,
}
#[derive(Serialize, ToSchema)]
pub struct ListMediaFilesForUserV2SuccessResponse {
pub success: bool,
pub results: Vec<MediaFileForUserListItem>,
pub pagination: UserMediaFileV2Pagination,
}
#[derive(Serialize, ToSchema)]
pub struct UserMediaFileV2Pagination {
pub current: usize,
pub has_more: bool,
}
/// Faster user-media listing for infinite scroll.
///
/// Unlike `/v1/media_files/list/user/{username}`, this endpoint skips the exact
/// `COUNT(*)` over the user's entire matching library. It fetches `page_size + 1`
/// records to return `pagination.has_more`, without a total-page count. Removing
/// that scan makes loading pages faster for large libraries.
///
/// Both endpoints use the same optimized per-user indexes and load related
/// metadata only after selecting a page. The original endpoint retains its
/// request/response contract and pagination behavior for deployed clients.
///
/// Page indexes remain offset based; very deep pages still scan preceding
/// matching records. Page size defaults to 25 and must be between 1 and 100.
#[utoipa::path(
get,
tag = "Media Files",
path = "/v1/media_files/list_v2/user/{username}",
params(ListMediaFilesForUserV2QueryParams),
responses(
(status = 200, description = "Faster user-media page with has_more; no full-library count", body = ListMediaFilesForUserV2SuccessResponse),
(status = 400, description = "Invalid pagination"),
(status = 500, description = "Server error"),
),
)]
pub async fn list_media_files_for_user_v2_handler(
http_request: HttpRequest,
path: Path<ListMediaFilesForUserV2PathInfo>,
query: Query<ListMediaFilesForUserV2QueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListMediaFilesForUserV2SuccessResponse>, CommonWebError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
let mut is_author = false;
let mut is_mod = false;
// NB: Temporary rollout flag for certain file types (BVH, etc).
let is_allowed_studio_access = allowed_studio_access(maybe_user_session.as_ref(), &server_state.flags);
match maybe_user_session {
None => {}
Some(session) => {
is_author = session.username == path.username;
is_mod = session.can_ban_users;
}
};
let sort_ascending = query.sort_ascending.unwrap_or(false);
let page_size = query.page_size.unwrap_or_else(|| 25);
let page_index = query.page_index.unwrap_or_else(|| 0);
if !(1..=100).contains(&page_size) {
return Err(CommonWebError::BadInputWithSimpleMessage(
"page_size must be between 1 and 100".to_string(),
));
}
let offset = page_index
.checked_mul(page_size)
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("page_index is too large".to_string()))?;
let view_as = if is_author {
ViewAs::Author
} else if is_mod {
ViewAs::Moderator
} else {
ViewAs::AnotherUser
};
let maybe_filter_media_types = get_scoped_media_types(query.filter_media_type.as_deref());
let maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
let maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
let filters = UserMediaFileFilters {
username: &path.username,
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
include_user_uploads: query.include_user_uploads.unwrap_or(false),
view_as,
};
let page_started = Instant::now();
let mut records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: page_size + 1,
offset,
sort_ascending,
mysql_executor: &server_state.mysql_pool,
})
.await?;
let has_more = records.len() > page_size;
records.truncate(page_size);
info!(
"Listed list_v2 user media: username={} page={} page_ms={}",
path.username,
page_index,
page_started.elapsed().as_millis()
);
let results = build_user_media_file_response(UserMediaFileResponseArgs {
records,
is_allowed_studio_access,
media_domain: get_media_domain(&http_request),
server_environment: server_state.server_environment,
});
Ok(Json(ListMediaFilesForUserV2SuccessResponse {
success: true,
results,
pagination: UserMediaFileV2Pagination {
current: page_index,
has_more,
},
}))
}
@@ -6,3 +6,5 @@ pub mod list_media_files_for_user_handler;
pub mod list_media_files_handler;
pub mod list_pinned_media_files_handler;
pub mod list_session_project_media_files_handler;
pub mod list_media_files_for_user_v2_handler;
mod user_media_file_response;
@@ -0,0 +1,108 @@
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::media_files::media_file_type::MediaFileType;
use mysql_queries::queries::media_files::list::user_media_file_list_item::MediaFileListItem;
use server_environment::ServerEnvironment;
use super::list_media_files_for_user_handler::MediaFileForUserListItem;
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::common_responses::media_file_origin_details::MediaFileOriginDetails;
use crate::http_server::common_responses::simple_entity_stats::SimpleEntityStats;
use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path;
pub(super) struct UserMediaFileResponseArgs {
pub records: Vec<MediaFileListItem>,
pub is_allowed_studio_access: bool,
pub media_domain: MediaDomain,
pub server_environment: ServerEnvironment,
}
/// Shared media-item representation; pagination contracts remain versioned.
pub(super) fn build_user_media_file_response(args: UserMediaFileResponseArgs) -> Vec<MediaFileForUserListItem> {
let UserMediaFileResponseArgs {
records,
is_allowed_studio_access,
media_domain,
server_environment,
} = args;
records
.into_iter()
.filter(|record| {
if is_allowed_studio_access {
return true;
}
// Don't allow access to certain media types.
match record.media_type {
MediaFileType::Bvh
| MediaFileType::Fbx
| MediaFileType::Glb
| MediaFileType::Gltf
| MediaFileType::SceneRon => return false,
_ => {}
}
// // Don't allow access to certain products.
// match record.origin_product_category {
// MediaFileOriginProductCategory::VideoFilter |
// MediaFileOriginProductCategory::Mocap |
// MediaFileOriginProductCategory::Workflow => return false,
// _ => {},
// }
true
})
.map(|record| {
let public_bucket_path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
MediaFileForUserListItem {
token: record.token.clone(),
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin: MediaFileOriginDetails::from_db_fields_str(
record.origin_category,
record.origin_product_category,
record.maybe_origin_model_type,
record.maybe_origin_model_token.as_deref(),
record.maybe_origin_model_title.as_deref(),
),
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
media_links: MediaLinksBuilder::from_media_path_and_env(media_domain, server_environment, &public_bucket_path),
maybe_prompt_token: record.maybe_prompt_token,
public_bucket_path: public_bucket_path.get_full_object_path_str().to_string(),
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_environment),
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
&record.token,
media_domain,
server_environment,
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
),
creator_set_visibility: record.creator_set_visibility,
is_user_upload: record.is_user_upload,
is_intermediate_system_file: record.is_intermediate_system_file,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_style_name: record
.maybe_prompt_args
.as_ref()
.and_then(|args| args.style_name.as_ref())
.and_then(|style| style.to_style_name()),
maybe_duration_millis: record.maybe_duration_millis,
stats: SimpleEntityStats {
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
},
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>()
}
@@ -548,7 +548,7 @@ fn fixture_video_bytes(duration_millis: u64) -> Vec<u8> {
/// the stub Kinovi server. Everything else is inert dummy configuration:
/// Redis/Elasticsearch/Stripe/bucket clients are constructed lazily and are
/// never called on the video generate path.
fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
pub(crate) fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
.expect("session cookie manager");
let avt_cookie_manager =
@@ -17,6 +17,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler;
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler;
use crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler;
@@ -115,6 +116,10 @@ pub fn add_media_file_routes<T, B> (app: App<T>) -> App<T>
.route(web::get().to(list_media_files_for_user_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/list_v2/user/{username}")
.route(web::get().to(list_media_files_for_user_v2_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/mesh/list")
.route(web::get().to(list_session_mesh_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -28,4 +28,6 @@ mod wallet_routes;
mod web_referrals_routes;
mod webhook_routes;
mod weights_routes;
pub (super) mod add_application_routes;
pub (super) mod add_application_routes;
#[cfg(test)]
mod user_media_endpoint_tests;
@@ -0,0 +1,177 @@
//! Real routes, signed sessions, and migrated local database fixtures.
//! The fixed-socket isolated harness cannot use production database URLs.
use std::sync::Arc;
use actix_http::Request;
use actix_web::body::MessageBody;
use actix_web::dev::{Service, ServiceResponse};
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use mysql_testing::fixtures::media_files::create_test_video_media_file;
use mysql_testing::fixtures::users::create_test_user;
use mysql_testing::isolated::IsolatedTestDatabase;
use serde_json::{json, Value};
use super::media_files_routes::add_media_file_routes;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::build_test_server_state;
const LEGACY_ITEM_FIELDS: &[&str] = &[
"token",
"media_class",
"media_type",
"maybe_engine_category",
"maybe_animation_type",
"origin",
"origin_category",
"origin_product_category",
"maybe_origin_model_type",
"maybe_origin_model_token",
"public_bucket_path",
"public_bucket_url",
"media_links",
"maybe_prompt_token",
"cover_image",
"creator_set_visibility",
"is_user_upload",
"is_intermediate_system_file",
"maybe_title",
"maybe_text_transcript",
"maybe_style_name",
"maybe_duration_millis",
"stats",
"created_at",
"updated_at",
];
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn user_media_endpoint_versions_preserve_legacy_contract() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let db = IsolatedTestDatabase::create().await;
let owner = create_test_user(&db.pool).await.unwrap();
for _ in 0..26 {
create_test_video_media_file(&db.pool, &owner.user_token, Some(1234))
.await
.unwrap();
}
let state = build_test_server_state(db.pool.clone(), "http://127.0.0.1:1".to_string());
let cookie = state
.session_cookie_manager
.create_cookie(&owner.session_token, &owner.user_token)
.unwrap()
.into_owned();
let app = test::init_service(add_media_file_routes(
App::new().app_data(web::Data::new(Arc::new(state))),
))
.await;
let v1 = format!("/v1/media_files/list/user/{}", owner.username);
let v2 = format!("/v1/media_files/list_v2/user/{}", owner.username);
let session = Some(cookie.value());
// Default upload exclusion, page size, and original JSON shape stay intact.
let (status, excluded) = get(&app, &v1, session).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
excluded,
json!({"success": true, "results": [], "pagination": {"current": 0, "total_page_count": 1}})
);
let (status, legacy) = get(&app, &format!("{v1}?include_user_uploads=true"), session).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(legacy.as_object().unwrap().len(), 3);
assert_eq!(legacy["results"].as_array().unwrap().len(), 25);
assert_eq!(legacy["pagination"], json!({"current": 0, "total_page_count": 2}));
assert_legacy_item_keys(&legacy["results"][0]);
// Exact multiples intentionally keep v1's historical extra page.
let (_, exact) = get(&app, &format!("{v1}?include_user_uploads=true&page_size=26"), session).await;
assert_eq!(exact["results"].as_array().unwrap().len(), 26);
assert_eq!(exact["pagination"], json!({"current": 0, "total_page_count": 2}));
// V2's size cap must never spill into the legacy endpoint.
let (status, large) = get(&app, &format!("{v1}?include_user_uploads=true&page_size=101"), session).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(large["results"].as_array().unwrap().len(), 26);
assert_eq!(large["pagination"], json!({"current": 0, "total_page_count": 1}));
let (_, last) = get(&app, &format!("{v1}?include_user_uploads=true&page_index=1"), session).await;
assert_eq!(last["results"].as_array().unwrap().len(), 1);
assert_eq!(last["pagination"], json!({"current": 1, "total_page_count": 2}));
// The rejected count-mode option has no effect on the v1 contract.
let (_, ignored_option) = get(
&app,
&format!("{v1}?include_user_uploads=true&include_total_count=false"),
session,
)
.await;
assert_eq!(ignored_option, legacy);
let (status, first_v2) = get(&app, &format!("{v2}?include_user_uploads=true"), session).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(first_v2.as_object().unwrap().len(), 3);
assert_eq!(first_v2["results"], legacy["results"]);
assert_eq!(first_v2["pagination"], json!({"current": 0, "has_more": true}));
let (_, last_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=1"), session).await;
assert_eq!(last_v2["results"], last["results"]);
assert_eq!(last_v2["pagination"], json!({"current": 1, "has_more": false}));
let (_, exact_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_size=26"), session).await;
assert_eq!(exact_v2["pagination"], json!({"current": 0, "has_more": false}));
let (_, empty_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=2"), session).await;
assert_eq!(empty_v2["results"], json!([]));
assert_eq!(empty_v2["pagination"], json!({"current": 2, "has_more": false}));
for operation in ["list", "list_v2"] {
let path = format!(
"/v1/media_files/{operation}/user/{}?include_user_uploads=true",
owner.username
);
let (status, anonymous) = get(&app, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
anonymous["results"],
json!([]),
"private fixtures must require their owner session"
);
let (status, missing) = get(
&app,
&format!("/v1/media_files/{operation}/user/missing_fixture?include_user_uploads=true"),
session,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(missing["results"], json!([]));
let (status, filtered) = get(&app, &format!("{path}&filter_media_classes=image"), session).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(filtered["results"], json!([]));
}
for query in [
"page_size=0".to_string(),
"page_size=101".to_string(),
format!("page_size=60&page_index={}", usize::MAX),
] {
let (status, _) = get(&app, &format!("{v2}?{query}"), session).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
drop(app);
db.destroy().await;
}
async fn get<S, B>(app: &S, path: &str, maybe_session: Option<&str>) -> (StatusCode, Value)
where
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
B: MessageBody,
B::Error: std::fmt::Debug,
{
let mut request = test::TestRequest::get().uri(path);
if let Some(session) = maybe_session {
request = request.insert_header(("session", session));
}
let response = test::call_service(app, request.to_request()).await;
let status = response.status();
(status, test::read_body_json(response).await)
}
fn assert_legacy_item_keys(item: &Value) {
let mut actual = item.as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>();
actual.sort_unstable();
let mut expected = LEGACY_ITEM_FIELDS.to_vec();
expected.sort_unstable();
assert_eq!(actual, expected);
}
+101
View File
@@ -0,0 +1,101 @@
# User media library query
`GET /v1/media_files/list/user/{username}` runs an exact count before fetching
each page. The webapp continues to use it for infinite scroll. MySQL can choose
an index-merge plan that intersects the creator index with global indexes on
NULL deletion timestamps and the intermediate-file flag. That makes the count
scan unrelated users' index entries.
The queries now force the creator index for counts and the creator/date index
for page selection. A limited derived table selects IDs before joining prompt,
cover, model, and stats data. Ordering uses `(created_at, id)` to break timestamp
ties consistently. Shared predicates preserve author/moderator/public visibility,
deleted-file exclusions, uploads, and class/type/engine filters.
The legacy `GET /v1/media_files/list/user/{username}` contract is unchanged:
original query fields and defaults, no new page-size cap, and the original
`pagination: { current, total_page_count }` with a numeric total. Its historical
`1 + floor(count / page_size)` calculation is intentionally preserved, including
exact multiples. It always performs the optimized exact count.
The separate `GET /v1/media_files/list_v2/user/{username}` endpoint is available
for adoption after backend deployment. `list_v2` fetches one extra record
and returns `pagination: { current, has_more }`. It never counts the library and
has no count-mode flag or total-page field. `list_v2` accepts page sizes from 1 to 100.
Both endpoints return the same media-item fields and enforce the same visibility
and media filters.
Skipping the count is the additional speed improvement: even the optimized
legacy count must visit all matching rows before returning an exact total. The
new endpoint avoids that work on every request, including the first page. For a
60-item page it fetches at most 61 result records; it can still scan additional
index entries to apply filters or skip an offset. Compatible index-selection
and deferred-join improvements remain shared by both endpoints.
Pagination remains offset based, so very deep pages still scan preceding
matching entries. `list_v2` removes the full-library count from infinite-scroll
requests, but does not remove that offset cost.
## Deployment
The frontend switch is deferred. The webapp continues using
`GalleryModalApi.listUserMediaFiles` and the existing
`/v1/media_files/list/user/{username}` endpoint with numbered pagination.
The `UserMediaFilesV2Api` library binding and its tests are retained, with no live
frontend callers.
Deploy and verify storyteller-web with the new `list_v2` route before a separate
frontend change adopts `UserMediaFilesV2Api` and its `has_more` pagination.
Both routes remain in API v1; this is an endpoint-local revision. Existing
clients and the original bindings remain unchanged.
No new migration is needed. The existing migration
`2026-07-03-074740-0000_alter_media_files_add_user_created_at_index`
must already be applied: both page queries require `idx_creator_created_at`.
The legacy count query uses the longstanding `fk_maybe_creator_user_token` index.
Endpoint logs distinguish legacy/list_v2 and report `page_ms`; the legacy route
also reports `count_ms`.
Use those timings to verify the actual production improvement after deployment.
## Local validation
Database tests use `IsolatedTestDatabase`: a fixed `/tmp/mysql.sock`, new random
schemas, and checked-in migrations. They do not read production connection URLs
or modify existing schemas. From the repository root:
```sh
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library -- --ignored --nocapture
```
The large fixture creates 300,000 rows, 60,000 owned by the target account. On
local MySQL 8.4.11, the production page query returned 61 rows in 3.4 ms with 91
sequential index reads. Its test asserts bounded reads rather than wall-clock
latency. The legacy user-scoped count took 38.5 ms.
A separate local EXPLAIN ANALYZE comparison with that distribution measured the
old count at 166 ms and the forced creator-index count at 41.5 ms. The old plan
scanned 300,000 entries in each of three global indexes. These are synthetic
local measurements, not production response-time promises.
Functional fixtures cover public/private/hidden visibility, other owners,
deleted and intermediate files, all library media classes including legacy 3D,
upload/type/engine filters, same-timestamp pagination, joined metadata, unknown
usernames, and caller-owned transactions.
The real Actix route test uses signed sessions and disposable MySQL fixtures to
lock down the original request defaults, response keys/types, exact-multiple page counts,
and page sizes above 100. It also checks `list_v2` first/last/empty pages, validation,
media-item parity, filters, and private-file access:
```sh
SQLX_OFFLINE=true cargo test -p storyteller-web --bin storyteller-web user_media_endpoint_versions
```
Frontend HTTP-binding tests:
```sh
cd frontend
node_modules/.bin/vitest run --config libs/api/vite.config.ts src/lib/MediaFilesApi.spec.ts
```
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Share previews must work from the raw <head>, even for an unknown crawler
// or curl. Wait for generateMetadata instead of streaming it into the body.
// Media lookups have a five-second timeout; static marketing pages do not
// perform that lookup. https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
htmlLimitedBots: /.*/,
// Pin the project root: the repo has lockfiles both here and in the Nx
// workspace above, and letting Next guess picks the wrong one (breaks
// output file tracing on serverless deploys).
@@ -10,7 +10,7 @@
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"test:media": "playwright test tests/media.spec.ts"
"test:media": "playwright test tests/media.spec.ts tests/media-metadata.spec.ts"
},
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.22",
@@ -0,0 +1,39 @@
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaTitle } from "@/lib/media";
import { mediaEmbed, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
const HEADERS = { "Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow" };
export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const query = new URL(request.url).searchParams;
if (query.has("format") && query.get("format") !== "json") {
return Response.json({ error: "Only JSON is supported" }, { status: 501, headers: HEADERS });
}
if (query.get("url") !== `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}`) {
return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
}
const media = await fetchPublicMediaFile(token);
const embed = media && mediaEmbed(media);
if (!media || !embed) return Response.json({ error: "Media unavailable" }, { status: 404, headers: HEADERS });
const playerUrl = `${MEDIA_SHARE_ORIGIN}/media/${encodeURIComponent(token)}/player`;
const scale = Math.min(1, maximum(query.get("maxwidth")) / embed.width, maximum(query.get("maxheight")) / embed.height);
const width = Math.max(1, Math.floor(embed.width * scale));
const height = Math.max(1, Math.floor(embed.height * scale));
return Response.json({
version: "1.0",
type: embed.kind === "video" ? "video" : "rich",
provider_name: "ArtCraft",
provider_url: MEDIA_SHARE_ORIGIN,
title: mediaTitle(media),
author_name: media.maybe_creator_user?.display_name || media.maybe_creator_user?.username,
width,
height,
html: `<iframe src="${playerUrl}" width="${width}" height="${height}" title="ArtCraft media player" frameborder="0" allow="fullscreen" allowfullscreen></iframe>`,
}, { headers: HEADERS });
}
function maximum(value: string | null): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : Infinity;
}
@@ -1,40 +1,78 @@
import type { Metadata } from "next";
import MediaPage from "@/components/media/media-page";
import { API_HOST } from "@/lib/api";
import { mediaFilePath, mediaKind, mediaThumbnail, safeMediaUrl, type SharedMedia } from "@/lib/media";
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
import { mediaTitle } from "@/lib/media";
const OG_IMAGE_WIDTH = 1200;
const FALLBACK_IMAGE = { url: "/images/og-image.png", width: 1200, height: 630, alt: "ArtCraft" };
type Props = { params: Promise<{ token: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { token } = await params;
const url = `/media/${encodeURIComponent(token)}`;
const fallbackTitle = "Shared media";
const fallbackDescription = "View images, video, music, and interactive 3D creations shared with ArtCraft.";
const metadata: Metadata = {
title: "Shared media",
description: "View images, video, music, and interactive 3D creations shared with ArtCraft.",
alternates: { canonical: `/media/${encodeURIComponent(token)}` },
title: fallbackTitle,
description: fallbackDescription,
alternates: { canonical: url },
// A shared link is not a request to index a user's creation.
robots: { index: false, follow: true },
openGraph: {
type: "website", siteName: "ArtCraft", url,
title: `${fallbackTitle} — ArtCraft`, description: fallbackDescription,
images: [FALLBACK_IMAGE],
},
twitter: {
card: "summary_large_image", title: `${fallbackTitle} — ArtCraft`,
description: fallbackDescription, images: [FALLBACK_IMAGE],
},
};
const media = await fetchPublicMediaFile(token);
if (!media) return metadata;
const image = mediaKind(media) === "image"
? safeMediaUrl(media.media_links.cdn_url)
: mediaThumbnail(media.media_links, OG_IMAGE_WIDTH);
const title = mediaTitle(media);
const image = mediaShareImage(media);
const embed = mediaEmbed(media);
const images = image ? [{ url: image, alt: title }] : [FALLBACK_IMAGE];
const creator = media.maybe_creator_user;
const description = creator
? `Made with ArtCraft by ${creator.display_name || creator.username}.`
: "Made with ArtCraft.";
return {
...metadata,
title,
description,
openGraph: {
title: "Shared Media — ArtCraft",
type: embed?.kind === "video" ? "video.other" : "website",
...(embed?.kind === "video" && { videos: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
...(embed?.kind === "audio" && { audio: [{ url: embed.url, secureUrl: embed.url, type: embed.contentType }] }),
siteName: "ArtCraft",
title: `${title} — ArtCraft`,
description,
url: `/media/${encodeURIComponent(token)}`,
...(image && { images: [{ url: image }] }),
url,
images,
},
twitter: { card: image ? "summary_large_image" : "summary" },
twitter: {
...(embed ? { card: "player" as const, players: [] } : { card: "summary_large_image" as const }),
title: `${title} — ArtCraft`,
description,
images,
},
...(embed && {
alternates: { canonical: url, types: { "application/json+oembed": `${MEDIA_SHARE_ORIGIN}${url}/oembed?url=${encodeURIComponent(`${MEDIA_SHARE_ORIGIN}${url}`)}&format=json` } },
// Next's player descriptor requires a stream URL, while X's optional
// native stream must be MP4. Audio/WebM use the HTML player instead.
other: {
"twitter:player": `${MEDIA_SHARE_ORIGIN}${url}/player`,
"twitter:player:width": embed.width,
"twitter:player:height": embed.height,
...(embed.contentType === "video/mp4" && {
"twitter:player:stream": embed.url,
"twitter:player:stream:content_type": embed.contentType,
}),
},
}),
};
}
@@ -44,22 +82,3 @@ export default async function Media({ params }: Props) {
// viewer. Never cache one visitor's private media response in server HTML.
return <MediaPage key={token} token={token} />;
}
// Social cards use only the public API response. Never forward a visitor's
// cookies or signed session into server metadata; private media stays client-side.
async function fetchPublicMediaFile(token: string): Promise<SharedMedia | null> {
try {
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const payload = await response.json() as { success?: boolean; media_file?: SharedMedia };
const media = payload.media_file;
return payload.success && media?.token && safeMediaUrl(media.media_links?.cdn_url) ? media : null;
} catch {
return null;
}
}
@@ -0,0 +1,35 @@
import { fetchPublicMediaFile } from "@/lib/fetch-public-media";
import { mediaTitle } from "@/lib/media";
import { mediaEmbed, mediaShareImage, MEDIA_SHARE_ORIGIN } from "@/lib/media-sharing";
const HEADERS = {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"X-Robots-Tag": "noindex, nofollow",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src https:; media-src https:; base-uri 'none'; form-action 'none'",
};
// A standalone HTML document bypasses the site layout and needs no scripts.
// Platforms that support iframe Player Cards can use native media controls.
export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const media = await fetchPublicMediaFile(token);
const embed = media && mediaEmbed(media);
if (!media || !embed) return new Response("Media unavailable", { status: 404, headers: HEADERS });
const title = escapeHtml(mediaTitle(media));
const poster = escapeHtml(mediaShareImage(media) ?? `${MEDIA_SHARE_ORIGIN}/images/og-image.png`);
const source = `<source src="${escapeHtml(embed.url)}" type="${embed.contentType}">`;
const player = embed.kind === "video"
? `<video controls playsinline preload="metadata" poster="${poster}" aria-label="${title}">${source}</video>`
: `<div class="audio"><img src="${poster}" alt=""><div><p>${title}</p><audio controls preload="metadata" aria-label="${title}">${source}</audio></div></div>`;
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} — ArtCraft</title><style>
:root{color-scheme:light dark}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;font:14px system-ui;background:light-dark(#f2f1ee,#121316);color:light-dark(#222,#eee)}
video{display:block;width:100%;height:100%;object-fit:contain;background:#000}.audio{display:flex;align-items:center;gap:16px;padding:16px;height:100%}.audio img{width:80px;height:80px;object-fit:cover}.audio div{min-width:0;flex:1}p{margin:0 0 12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}audio{width:100%}@media(max-width:360px){.audio img{display:none}}
</style></head><body>${player}</body></html>`, { headers: HEADERS });
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
@@ -0,0 +1,24 @@
import { API_HOST } from "./api";
import { mediaFilePath, safeMediaUrl, type SharedMedia } from "./media";
// Social cards use only the public API response. Never forward a visitor's
// cookies or signed session into server metadata; private media stays client-side.
export async function fetchPublicMediaFile(token: string): Promise<SharedMedia | null> {
try {
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const payload = await response.json() as { success?: boolean; media_file?: SharedMedia };
const media = payload.media_file;
if (!payload.success || media?.token !== token || !safeMediaUrl(media.media_links?.cdn_url)) return null;
// Hidden means unlisted but shareable by URL. Private files must never
// appear in social cards, even if an API response includes their fields.
return media.creator_set_visibility === "public" || media.creator_set_visibility === "hidden" ? media : null;
} catch {
return null;
}
}
@@ -0,0 +1,35 @@
import { mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type SharedMedia } from "./media";
export const MEDIA_SHARE_ORIGIN = "https://getartcraft.com";
const PREVIEW_WIDTH = 1200;
const VIDEO_TYPES: Record<string, string> = { mp4: "video/mp4", webm: "video/webm" };
const AUDIO_TYPES: Record<string, string> = {
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac",
ogg: "audio/ogg", opus: "audio/ogg", flac: "audio/flac",
};
type MediaEmbed = {
kind: "video" | "audio";
url: string;
contentType: string;
// These are iframe viewport dimensions, not invented source dimensions.
width: number;
height: number;
};
export function mediaEmbed(media: SharedMedia): MediaEmbed | null {
const kind = mediaKind(media);
if (kind !== "video" && kind !== "audio") return null;
const contentType = (kind === "video" ? VIDEO_TYPES : AUDIO_TYPES)[mediaFormat(media)];
const url = safeMediaUrl(media.media_links.cdn_url);
if (!contentType || !url?.startsWith("https://")) return null;
return { kind, contentType, url, width: 640, height: kind === "video" ? 360 : 180 };
}
export function mediaShareImage(media: SharedMedia): string | undefined {
const cover = media.cover_image?.maybe_links;
return mediaThumbnail(media.media_links, PREVIEW_WIDTH)
?? (mediaKind(media) === "image" ? safeMediaUrl(media.media_links.cdn_url) : undefined)
?? safeMediaUrl(cover?.thumbnail_template?.replace("{WIDTH}", String(PREVIEW_WIDTH)))
?? safeMediaUrl(cover?.cdn_url);
}
@@ -8,6 +8,7 @@ export type SharedMedia = {
media_type?: string | null;
maybe_engine_category?: string | null;
maybe_title?: string | null;
creator_set_visibility?: string | null;
maybe_original_filename?: string | null;
maybe_prompt_token?: string | null;
maybe_text_transcript?: string | null;
@@ -18,6 +19,9 @@ export type SharedMedia = {
} | null;
maybe_model_weight_info?: { title: string } | null;
created_at?: string;
cover_image?: {
maybe_links?: { cdn_url: string; thumbnail_template?: string | null } | null;
} | null;
media_links: {
cdn_url: string;
maybe_thumbnail_template?: string | null;
@@ -13,3 +13,32 @@ on `127.0.0.1:4203`, which has no upstream. No production API or database is use
Run `npm run test:media` with Google Chrome installed and ports 4202 and 4203 free.
The suite starts its own Next server with the fixture API host, so it refuses to
reuse a running development server. Playwright uses installed Chrome.
`media-metadata.spec.ts` inspects raw HTTP response heads for Discord, Twitter,
Meta, Slack, an unknown crawler, curl, and a browser user agent. It executes no
page JavaScript. The fixtures cover image titles, video stills, 3D cover images,
unlisted links, audio without artwork, private media, API errors/timeouts, and
updates to an existing creation. The fixture API rejects forwarded session
credentials. Keep these tests independent of the browser's intercepted requests.
The route's `generateMetadata` performs the anonymous server-side lookup.
`htmlLimitedBots: /.*/` keeps metadata in the initial `<head>` for every user
agent. Keep the five-second API timeout and generic fallback so an API outage
does not leave crawlers waiting indefinitely. `cache: "no-store"` avoids stale
server metadata; social platforms can still cache cards independently.
Public audio/video links also advertise a standalone `/media/<token>/player`
document and `/media/<token>/oembed` discovery endpoint. The player uses native
HTML controls without scripts or the marketing layout. Tests play its fixture
video and audio in Chrome with page JavaScript disabled, check oEmbed sizing,
and verify that private media cannot be embedded. Twitter's optional native
stream URL is emitted only for MP4; audio and WebM use the iframe player.
These tests verify our responses and native player, not another platform's
acceptance of an embed. Validate a deployed URL in each platform. Discord's
audio-tag support is still an open request:
https://github.com/discord/discord-api-docs/discussions/3262
Discord-playable audio needs a generated video derivative (for example, H.264
cover art plus AAC audio in an MP4). Merely declaring an MP3 to be a video does
not create that derivative. Instagram feed/Reel publishing is a separate media
upload workflow; website metadata does not publish an Instagram post.
@@ -6,20 +6,43 @@ const CDN = "https://media-fixture.invalid";
const publicMedia = {
m_fixture_image: {
token: "m_fixture_image", media_class: "image",
creator_set_visibility: "public", maybe_title: "Night sky & stars",
maybe_creator_user: { username: "fixture_artist", display_name: "Fixture Artist" },
media_links: { cdn_url: `${CDN}/asset.png?fixture=1` },
},
m_fixture_video: {
token: "m_fixture_video", media_class: "video",
creator_set_visibility: "public", maybe_title: "A short film",
media_links: { cdn_url: `${CDN}/asset.mp4`, maybe_video_previews: {
still: `${CDN}/still.jpg`, still_thumbnail_template: `${CDN}/still-{WIDTH}.jpg`,
} },
},
m_fixture_audio: {
token: "m_fixture_audio", media_class: "audio",
creator_set_visibility: "public", maybe_title: "A song",
media_links: { cdn_url: `${CDN}/asset.wav` },
},
m_fixture_mesh: {
token: "m_fixture_mesh", media_class: "mesh", creator_set_visibility: "public",
media_links: { cdn_url: `${CDN}/asset.glb` },
cover_image: { maybe_links: { cdn_url: `${CDN}/cover.jpg`, thumbnail_template: `${CDN}/cover-{WIDTH}.jpg` } },
},
m_fixture_splat: {
token: "m_fixture_splat", media_class: "splat", creator_set_visibility: "public",
media_links: { cdn_url: `${CDN}/asset.spz` },
cover_image: { maybe_links: { cdn_url: `${CDN}/splat-cover.jpg` } },
},
m_fixture_private: {
token: "m_fixture_private", media_class: "image", creator_set_visibility: "private",
maybe_title: "Secret creation", maybe_creator_user: { username: "secret_artist" },
media_links: { cdn_url: `${CDN}/secret-image.png` },
},
m_fixture_hidden: {
token: "m_fixture_hidden", media_class: "image", creator_set_visibility: "hidden",
media_links: { cdn_url: `${CDN}/unlisted.png`, maybe_thumbnail_template: `${CDN}/unlisted-{WIDTH}.png` },
},
};
let revision = 0;
createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
@@ -33,6 +56,25 @@ createServer((request, response) => {
return;
}
const token = request.url?.match(/^\/v1\/media_files\/file\/([^/?]+)$/)?.[1];
if (token === "m_fixture_changing") {
response.end(JSON.stringify({ success: true, media_file: {
...publicMedia.m_fixture_image, token, maybe_title: `Revision ${++revision}`,
} }));
return;
}
if (token === "m_fixture_malformed") {
response.end("not json");
return;
}
if (token === "m_fixture_timeout") {
setTimeout(() => response.end(JSON.stringify({ success: false })), 6000);
return;
}
const errorStatus = { m_fixture_unauthorized: 401, m_fixture_forbidden: 403, m_fixture_failure: 500 }[token];
if (errorStatus) {
response.writeHead(errorStatus).end(JSON.stringify({ success: false }));
return;
}
const media = publicMedia[token];
response.writeHead(media ? 200 : 404).end(JSON.stringify(media
? { success: true, media_file: media }
@@ -0,0 +1,10 @@
export function wav() {
const buffer = Buffer.alloc(44 + 80000);
buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(8000, 24); buffer.writeUInt32LE(16000, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34);
buffer.write("data", 36); buffer.writeUInt32LE(80000, 40);
for (let i = 0; i < 40000; i++) buffer.writeInt16LE(Math.round(Math.sin(i * 2 * Math.PI * 440 / 8000) * 12000), 44 + i * 2);
return buffer;
}
@@ -0,0 +1,171 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { wav } from "./fixtures/audio";
const CDN = "https://media-fixture.invalid";
const SITE = "https://getartcraft.com";
const CRAWLERS = [
["Discord", "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"],
["Twitter", "Twitterbot/1.0"],
["Meta", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"],
["Slack", "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"],
["unknown crawler", "ArtCraftPreviewTest/1.0"],
["curl", "curl/8.7.1"],
["browser", "Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36"],
] as const;
// Raw HTTP only: no browser, hydration, JavaScript, or DOM repair can make
// these assertions pass. Tags must be inside the server response's <head>.
for (const [name, userAgent] of CRAWLERS) {
test(`${name} gets complete media social tags in the raw HTML head`, async ({ request }) => {
const head = await requestHead(request, "m_fixture_image", userAgent);
expect(head).toContain("<title>Night sky &amp; stars — ArtCraft</title>");
expect(head).toContain('property="og:title" content="Night sky &amp; stars — ArtCraft"');
expect(head).toContain('property="og:description" content="Made with ArtCraft by Fixture Artist."');
expect(head).toContain('property="og:type" content="website"');
expect(head).toContain('property="og:site_name" content="ArtCraft"');
expect(head).toContain(`property="og:url" content="${SITE}/media/m_fixture_image"`);
expect(head).toContain(`property="og:image" content="${CDN}/asset.png?fixture=1"`);
expect(head).toContain('property="og:image:alt" content="Night sky &amp; stars"');
expect(head).toContain('name="twitter:card" content="summary_large_image"');
expect(head).toContain('name="twitter:title" content="Night sky &amp; stars — ArtCraft"');
expect(head).toContain('name="twitter:description" content="Made with ArtCraft by Fixture Artist."');
expect(head).toContain(`name="twitter:image" content="${CDN}/asset.png?fixture=1"`);
expect(head).toContain(`rel="canonical" href="${SITE}/media/m_fixture_image"`);
expect(head).toContain('name="robots" content="noindex, follow"');
});
}
test("video, mesh, splat, and unlisted cards use image previews", async ({ request }) => {
for (const [token, preview] of [
["m_fixture_video", "still-1200.jpg"],
["m_fixture_mesh", "cover-1200.jpg"],
["m_fixture_splat", "splat-cover.jpg"],
["m_fixture_hidden", "unlisted-1200.png"],
]) {
const head = await requestHead(request, token);
expect(head).toContain(`property="og:image" content="${CDN}/${preview}"`);
expect(head).toContain(`name="twitter:image" content="${CDN}/${preview}"`);
}
});
test("audio without cover art uses the site image and its own title", async ({ request }) => {
const head = await requestHead(request, "m_fixture_audio");
expect(head).toContain('property="og:title" content="A song — ArtCraft"');
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
expect(head).not.toContain(`property="og:image" content="${CDN}/asset.wav"`);
});
test("video and audio advertise direct media and iframe players", async ({ request }) => {
for (const [, userAgent] of CRAWLERS) {
const video = await requestHead(request, "m_fixture_video", userAgent);
expect(video).toContain('property="og:type" content="video.other"');
expect(video).toContain(`property="og:video" content="${CDN}/asset.mp4"`);
expect(video).toContain(`property="og:video:secure_url" content="${CDN}/asset.mp4"`);
expect(video).toContain('property="og:video:type" content="video/mp4"');
expect(video).toContain('name="twitter:card" content="player"');
expect(video).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_video/player"`);
expect(video).toContain(`name="twitter:player:stream" content="${CDN}/asset.mp4"`);
expect(video).toContain('name="twitter:player:stream:content_type" content="video/mp4"');
expect(video).toContain('name="twitter:player:width" content="640"');
expect(video).toContain('name="twitter:player:height" content="360"');
expect(video).toContain('type="application/json+oembed"');
const audio = await requestHead(request, "m_fixture_audio", userAgent);
expect(audio).toContain(`property="og:audio" content="${CDN}/asset.wav"`);
expect(audio).toContain('property="og:audio:type" content="audio/wav"');
expect(audio).toContain('name="twitter:card" content="player"');
expect(audio).toContain(`name="twitter:player" content="${SITE}/media/m_fixture_audio/player"`);
expect(audio).not.toContain('name="twitter:player:stream"');
expect(audio).not.toContain('property="og:video"');
}
});
test("oEmbed returns a sized iframe and rejects unrelated URLs and unsupported formats", async ({ request }) => {
for (const [token, type] of [["m_fixture_video", "video"], ["m_fixture_audio", "rich"]]) {
const response = await request.get(`/media/${token}/oembed`, { params: { url: `${SITE}/media/${token}`, format: "json", maxwidth: 320, maxheight: 90 } });
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toMatchObject({ version: "1.0", type, provider_name: "ArtCraft", provider_url: SITE });
expect(data.width).toBeLessThanOrEqual(320);
expect(data.height).toBeLessThanOrEqual(90);
expect(data.html).toContain(`src="${SITE}/media/${token}/player"`);
expect(data.html).toContain(`width="${data.width}" height="${data.height}"`);
}
const unrelated = await request.get("/media/m_fixture_video/oembed", { params: { url: "https://unrelated.invalid/" } });
expect(unrelated.status()).toBe(404);
const xml = await request.get("/media/m_fixture_video/oembed?format=xml");
expect(xml.status()).toBe(501);
});
test("private and non-playable media cannot be embedded", async ({ request }) => {
for (const token of ["m_fixture_private", "m_missing", "m_fixture_image", "m_fixture_mesh"]) {
for (const route of ["player", "oembed"]) {
const response = await request.get(`/media/${token}/${route}`, { params: { url: `${SITE}/media/${token}` } });
expect(response.status()).toBe(404);
const body = await response.text();
expect(body).not.toContain(CDN);
expect(body).not.toContain("Secret creation");
}
}
});
test("standalone video and audio players load and play with JavaScript disabled", async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
try {
const page = await context.newPage();
const png = readFileSync(join(__dirname, "../public/images/og-image.png"));
await page.route(`${CDN}/**`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`${SITE}/images/og-image.png`, (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`${CDN}/asset.mp4`, (route) => route.fulfill({ body: readFileSync(join(__dirname, "fixtures/clip.mp4")), contentType: "video/mp4" }));
await page.route(`${CDN}/asset.wav`, (route) => route.fulfill({ body: wav(), contentType: "audio/wav" }));
for (const kind of ["video", "audio"]) {
const response = await page.goto(`http://localhost:4202/media/m_fixture_${kind}/player`);
expect(response?.headers()["content-security-policy"]).toContain("default-src 'none'");
expect(await response!.text()).not.toContain("<script");
const player = page.locator(kind);
await expect(player).toHaveAttribute("controls", "");
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.readyState)).toBeGreaterThanOrEqual(1);
// Invoke the native media API; the document itself contains no JavaScript.
await player.evaluate((element: HTMLMediaElement) => { element.muted = true; return element.play(); });
await expect.poll(() => player.evaluate((element: HTMLMediaElement) => element.currentTime)).toBeGreaterThan(0);
}
} finally {
await context.close();
}
});
test("private files, missing files, and API failures get a generic card", async ({ request }) => {
for (const token of ["m_fixture_private", "m_missing", "m_fixture_unauthorized", "m_fixture_forbidden", "m_fixture_failure", "m_fixture_malformed", "m_fixture_timeout"]) {
const head = await requestHead(request, token);
expect(head).toContain('property="og:title" content="Shared media — ArtCraft"');
expect(head).toContain(`property="og:url" content="${SITE}/media/${token}"`);
expect(head).toContain(`property="og:image" content="${SITE}/images/og-image.png"`);
expect(head).toContain(`name="twitter:image" content="${SITE}/images/og-image.png"`);
expect(head).not.toContain(CDN);
expect(head).not.toContain("Secret creation");
expect(head).not.toContain("secret_artist");
}
});
test("metadata is looked up again when a public creation changes", async ({ request }) => {
const first = await requestHead(request, "m_fixture_changing");
const second = await requestHead(request, "m_fixture_changing");
const revision = (head: string) => Number(head.match(/property="og:title" content="Revision (\d+)/)?.[1]);
expect(revision(first)).toBeGreaterThan(0);
expect(revision(second)).toBeGreaterThan(revision(first));
});
async function requestHead(request: APIRequestContext, token: string, userAgent = "Discordbot/2.0") {
const response = await request.get(`/media/${token}`, {
// The fixture API rejects these headers if the website forwards them.
headers: { "User-Agent": userAgent, Cookie: "session=private-fixture", session: "private-fixture" },
});
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("text/html");
const html = await response.text();
const head = html.match(/<head\b[^>]*>([\s\S]*?)<\/head>/i)?.[1];
expect(head).toBeDefined();
return head!;
}
@@ -2,6 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { wav } from "./fixtures/audio";
import { corsMediaUrl, mediaKind, safeMediaUrl, type SharedMedia } from "../src/lib/media";
const API = "http://127.0.0.1:4203";
@@ -35,21 +36,6 @@ test("legacy query share links redirect to the canonical media page", async ({ p
await expect(page.getByAltText("Fixture image")).toBeVisible();
});
test("social cards use public images and video stills without forwarding visitor credentials", async ({ request }) => {
const headers = { "User-Agent": "Twitterbot", Cookie: "session=private-fixture", session: "private-fixture" };
const image = await request.get("/media/m_fixture_image", { headers });
const imageHtml = await image.text();
expect(imageHtml).toContain('property="og:image" content="https://media-fixture.invalid/asset.png?fixture=1"');
expect(imageHtml).toContain("Made with ArtCraft by Fixture Artist.");
expect(imageHtml).toContain('name="robots" content="noindex, follow"');
const video = await request.get("/media/m_fixture_video", { headers });
expect(await video.text()).toContain('property="og:image" content="https://media-fixture.invalid/still-1200.jpg"');
for (const token of ["m_fixture_audio", "m_private"]) {
const response = await request.get(`/media/${token}`, { headers });
expect(await response.text()).not.toContain('property="og:image" content="https://media-fixture.invalid/');
}
});
test("prompt and reference media are restored without blocking the preview", async ({ page }) => {
await mockMedia(page, { ...fixture("image", "png"), maybe_prompt_token: "p_fixture" });
await page.route(`${API}/v1/prompts/p_fixture`, (route) => route.fulfill({ json: {
@@ -254,16 +240,6 @@ async function mockMedia(page: Page, media: SharedMedia) {
await page.route(`${API}/v1/media_files/file/**`, (route) => route.fulfill({ json: { success: true, media_file: media } }));
}
function wav() {
const buffer = Buffer.alloc(44 + 80000);
buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8);
buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(8000, 24); buffer.writeUInt32LE(16000, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34);
buffer.write("data", 36); buffer.writeUInt32LE(80000, 40);
for (let i = 0; i < 40000; i++) buffer.writeInt16LE(Math.round(Math.sin(i * 2 * Math.PI * 440 / 8000) * 12000), 44 + i * 2);
return buffer;
}
async function serveBytes(route: Route, bytes: Buffer, contentType: string) {
const range = route.request().headers().range?.match(/bytes=(\d+)-(\d*)/);
const start = range ? Number(range[1]) : 0;
+1
View File
@@ -9,6 +9,7 @@ export * from "./lib/GenerationApi.js";
export * from "./lib/EngineApi.js";
export * from "./lib/JobsApi.js";
export * from "./lib/MediaFilesApi.js";
export * from "./lib/UserMediaFilesV2Api.js";
export * from "./lib/MediaUploadApi.js";
export * from "./lib/MiscApi.js";
export * from "./lib/LocalApi.js";
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GalleryModalApi } from "./GalleryModalApi";
import { UserMediaFilesV2Api } from "./UserMediaFilesV2Api";
import { FilterMediaClasses } from "./enums/QueryFilters";
const { fetchProxy } = vi.hoisted(() => ({ fetchProxy: vi.fn() }));
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: fetchProxy }));
beforeEach(() => {
fetchProxy.mockReset();
localStorage.clear();
});
describe("versioned user library HTTP contracts", () => {
it("keeps existing clients on v1 with numeric numbered pagination", async () => {
fetchProxy.mockResolvedValue(new Response(JSON.stringify({
success: true, results: [], pagination: { current: 2, total_page_count: 3 },
})));
const response = await new GalleryModalApi().listUserMediaFiles({ username: "fixture_owner", page_index: 2 });
const url = new URL(fetchProxy.mock.calls[0][0]);
expect(url.pathname).toBe("/v1/media_files/list/user/fixture_owner");
expect(url.searchParams.has("include_total_count")).toBe(false);
expect(response.pagination).toEqual({ current: 2, total_page_count: 3 });
expect(response.success).toBe(true);
});
it.each([true, false])("requests count-free pages from list_v2 (has_more: %s)", async (hasMore) => {
fetchProxy.mockResolvedValue(new Response(JSON.stringify({
success: true, results: [], pagination: { current: 1, has_more: hasMore },
})));
const response = await new UserMediaFilesV2Api().ListUserMediaFiles({
username: "fixture_owner", include_user_uploads: true,
filter_media_classes: [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO],
page_size: 60, page_index: 1,
});
const url = new URL(fetchProxy.mock.calls[0][0]);
expect(url.pathname).toBe("/v1/media_files/list_v2/user/fixture_owner");
expect(url.searchParams.has("include_total_count")).toBe(false);
expect(url.searchParams.get("filter_media_classes")).toBe("image,video");
expect(url.searchParams.get("page_size")).toBe("60");
expect(url.searchParams.get("page_index")).toBe("1");
expect(response.pagination).toEqual({ current: 1, has_more: hasMore });
expect(fetchProxy.mock.calls[0][1].credentials).toBe("include");
});
});
@@ -0,0 +1,53 @@
import { ApiManager, type ApiResponse } from "./ApiManager.js";
import type { MediaFile } from "./models/MediaFile.js";
import type { FilterEngineCategories, FilterMediaClasses, FilterMediaType } from "./enums/QueryFilters.js";
export interface ListUserMediaFilesV2Query {
username: string;
sort_ascending?: boolean;
page_size?: number;
page_index?: number;
filter_media_classes?: FilterMediaClasses[];
filter_media_type?: FilterMediaType[];
filter_engine_categories?: FilterEngineCategories[];
include_user_uploads?: boolean;
}
export interface UserMediaFilesV2Pagination {
current: number;
has_more: boolean;
}
/**
* Faster /v1/media_files/list_v2 pages: fetches one extra row for has_more,
* avoiding the full-library count required by the original list endpoint.
*/
export class UserMediaFilesV2Api extends ApiManager {
public async ListUserMediaFiles(
query: ListUserMediaFilesV2Query,
): Promise<ApiResponse<MediaFile[], UserMediaFilesV2Pagination>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list_v2/user/${encodeURIComponent(query.username)}`;
return this.get<{
success: boolean;
results: MediaFile[];
pagination: UserMediaFilesV2Pagination;
}>({
endpoint,
query: {
sort_ascending: query.sort_ascending,
page_size: query.page_size,
page_index: query.page_index,
include_user_uploads: query.include_user_uploads,
filter_media_classes: query.filter_media_classes?.join(","),
filter_media_type: query.filter_media_type?.join(","),
filter_engine_categories: query.filter_engine_categories?.join(","),
},
})
.then((response) => ({
success: response.success,
data: response.results ?? [],
pagination: response.pagination,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
}