Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293a41c7d4 | ||
|
|
55ab800542 | ||
|
|
2c93b4cf42 | ||
|
|
9169e4d662 | ||
|
|
4c7af51851 |
No files matched your search
@@ -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
|
||||
|
||||
+14
-11
@@ -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?;
|
||||
}
|
||||
|
||||
+20
@@ -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
|
||||
}
|
||||
+49
-438
@@ -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;
|
||||
+59
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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,
|
||||
|
||||
+32
-100
@@ -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,
|
||||
}
|
||||
}))
|
||||
}
|
||||
+197
@@ -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;
|
||||
+108
@@ -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<_>>()
|
||||
}
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+5
@@ -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;
|
||||
+177
@@ -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);
|
||||
}
|
||||
@@ -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("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
@@ -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 & stars — ArtCraft</title>");
|
||||
expect(head).toContain('property="og:title" content="Night sky & 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 & stars"');
|
||||
expect(head).toContain('name="twitter:card" content="summary_large_image"');
|
||||
expect(head).toContain('name="twitter:title" content="Night sky & 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;
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user