Compare commits

..
Author SHA1 Message Date
Brandon Thomas f67fb7db31 add desktop marketing website 2026-09-27 22:10:27 -04:00
Brandon Thomas 7d4e89ced9 Merge pull request #39 from storytold/bt-query-perf-2
fixes to new endpoint
2026-09-27 22:01:25 -04:00
Brandon Thomas b9ee1bd225 fixes to new endpoint 2026-09-27 22:01:01 -04:00
Brandon Thomas 68886b62fe Merge pull request #37 from storytold/bt-query-perf
more performant list user files endpoint
2026-09-27 20:22:51 -04:00
Brandon Thomas 293a41c7d4 let's actually not call the new endpoint prematurely 2026-09-27 17:35:00 -04:00
Brandon Thomas 35a859529f Merge pull request #38 from storytold/bt-tauri-cors
cors for tauri localhost + tauri desktop
2026-09-27 17:28:25 -04:00
Brandon Thomas 55ab800542 more performant list user files endpoint 2026-09-27 15:02:19 -04:00
37 changed files with 2484 additions and 657 deletions

No files matched your search

+13
View File
@@ -35,6 +35,7 @@ artcraft-services/
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
│ ├── apps/artcraft-desktop-website/ # Static landing page at desktop.getartcraft.com (Vite)
│ └── libs/ # Shared React components and TypeScript libraries
└── Cargo.toml # Rust workspace
```
@@ -96,6 +97,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
@@ -0,0 +1,30 @@
use serde_derive::{Deserialize, Serialize};
use url::Url;
use utoipa::ToSchema;
use crate::common::responses::cover_image_links::CoverImageLinks;
use crate::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
// User-media list endpoints retain the deprecated bucket fields for compatibility.
/// Everything we need to create a cover image.
/// Cover images are small descriptive images that can be set for any media file.
/// If a cover image is set, this is the path to the asset.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct LegacyMediaFileCoverImageDetails {
/// (DEPRECATED) URL path to the media file
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
pub maybe_cover_image_public_bucket_path: Option<String>,
/// (DEPRECATED) Full URL to the media file
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
pub maybe_cover_image_public_bucket_url: Option<Url>,
/// Links to the cover image (CDN direct link, thumbnail template)
/// If a cover image is set, this is the path to the asset.
/// If a cover image is not set, use the information in `default_cover` instead.
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub maybe_links: Option<CoverImageLinks>,
/// For items without a cover image, we can use one of our own.
pub default_cover: MediaFileDefaultCover,
}
@@ -0,0 +1,44 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
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 tokens::tokens::model_weights::ModelWeightToken;
/// Fields useful for enriching media file listings
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct MediaFileOriginDetails {
/// Where the file came from (broadly)
pub origin_category: MediaFileOriginCategory,
/// Where the file came from (specifically, a product area, eg. "face_animator")
pub product_category: MediaFileOriginProductCategory,
/// If the file was produced by a model, the details.
pub maybe_model: Option<MediaFileModelDetails>,
}
/// Information about the model weights
/// https://serde.rs/enum-representations.html#untagged
#[derive(Clone, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum MediaFileModelDetails {
// Try the specific shape first so deserialization retains token and title.
/// A model weight is typically a user-submitted model.
/// We store lots of these.
ModelWeight {
/// The type of model weight
model_type: MediaFileOriginModelType,
/// The model token
token: ModelWeightToken,
/// The model title (typically only populated for `model_weights` models, not legacy tables such as `tts_models`.)
title: String,
},
/// A system model is one that we maintain and update,
/// eg. SadTalker, Wav2Lip, MocapNet, etc.
SystemModel {
/// The type of model weight
model_type: MediaFileOriginModelType,
},
}
@@ -1,8 +1,10 @@
pub mod cover_image_links;
pub mod job_details;
pub mod legacy_media_file_cover_image_details;
pub mod media_file_cover_image_details;
pub mod media_file_origin_details;
pub mod media_links;
pub mod pagination_cursors;
pub mod simple_entity_stats;
pub mod simple_generic_json_success;
pub mod user_details_light;
pub mod user_details_light;
@@ -1,9 +1,10 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
// TODO(bt,2025-08-20): Replace the storyteller-web version of this.
/// Simple stats that can be attached to any entity
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct SimpleEntityStats {
/// Number of positive ratings (or "likes") for this item
pub positive_rating_count: u32,
@@ -0,0 +1,67 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
pub const LIST_MEDIA_FILES_FOR_USER_V2_URL_PATH: &str = "/v1/media_files/list_v2/user/{username}";
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ListMediaFilesForUserV2PathInfo {
pub username: String,
}
#[derive(Serialize, 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, Deserialize, ToSchema)]
pub struct ListMediaFilesForUserV2SuccessResponse {
pub success: bool,
pub results: Vec<MediaFileForUserListItem>,
pub pagination: UserMediaFileV2Pagination,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UserMediaFileV2Pagination {
pub current: usize,
pub has_more: bool,
}
@@ -1 +1,3 @@
pub mod by_type;
pub mod list_media_files_for_user_v2;
pub mod user_media_file_list_item;
@@ -0,0 +1,106 @@
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;
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 enums::no_table::style_transfer::style_transfer_name::StyleTransferName;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use utoipa::ToSchema;
use crate::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
use crate::common::responses::media_file_origin_details::MediaFileOriginDetails;
use crate::common::responses::media_links::MediaLinks;
use crate::common::responses::simple_entity_stats::SimpleEntityStats;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MediaFileForUserListItem {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If this is an engine/3D asset, this is the broad category (scene,
/// animation, etc.) of that object.
/// This can also be used for filtering in list/batch endpoints.
pub maybe_engine_category: Option<MediaFileEngineCategory>,
/// If this is an engine/3D asset for an animation or a rig that can
/// be animated with either (or both) skeletal or blend shape animations,
/// this describes the animation regime used or supported.
pub maybe_animation_type: Option<MediaFileAnimationType>,
/// Details where the media file came from.
pub origin: MediaFileOriginDetails,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub origin_category: MediaFileOriginCategory,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub origin_product_category: MediaFileOriginProductCategory,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub maybe_origin_model_token: Option<String>,
/// (DEPRECATED) URL path to the media file
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
pub public_bucket_path: String,
/// (DEPRECATED) Full URL to the media file
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
pub public_bucket_url: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: LegacyMediaFileCoverImageDetails,
pub creator_set_visibility: Visibility,
/// The file was uploaded by the user.
/// This does not include files generated on the client side, like studio renders.
pub is_user_upload: bool,
/// The file was created by the system.
/// This includes files generated on the client side, like studio renders.
pub is_intermediate_system_file: bool,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
/// For Comfy / Video Style Transfer jobs, this might include
/// the name of the selected style.
pub maybe_style_name: Option<StyleTransferName>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
/// Statistics about the media file
pub stats: SimpleEntityStats,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -8,6 +8,7 @@ use enums::common::view_as::ViewAs;
use errors::{anyhow, AnyhowResult};
use mysql_queries::queries::media_files::delete::delete_media_file::delete_media_file_as_mod;
use mysql_queries::queries::media_files::list::list_media_files_for_user::{list_media_files_for_user, ListMediaFileForUserArgs};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use mysql_queries::queries::users::user_profiles::get_user_profile_by_username::get_user_profile_by_username;
use crate::cli_args::Args;
@@ -42,23 +43,25 @@ pub async fn delete_all_files(username: &str, mysql: &Pool<MySql>) -> AnyhowResu
info!("Querying page {page_index} of files...");
let media_files = list_media_files_for_user(ListMediaFileForUserArgs {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
page_size: 100,
page_index,
filters: UserMediaFileFilters {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
view_as: ViewAs::Moderator,
},
limit: 100,
offset: page_index * 100,
sort_ascending: false,
view_as: ViewAs::Moderator,
mysql_pool: &mysql,
mysql_executor: mysql,
}).await?;
if media_files.records.is_empty() {
if media_files.is_empty() {
break;
}
for file in media_files.records {
for file in media_files {
info!("Deleting file: {:?}", &file.token);
delete_media_file_as_mod(&file.token, ECHELON_USER_TOKEN, mysql).await?;
}
@@ -0,0 +1,20 @@
use sqlx::{Executor, MySql, QueryBuilder};
use super::user_media_file_filters::UserMediaFileFilters;
pub struct CountMediaFilesForUserArgs<'a, T> {
pub filters: UserMediaFileFilters<'a>,
pub mysql_executor: T,
}
/// Legacy numbered pagination needs an exact total. Count only this user's rows,
/// without related-table lookups or global low-selectivity index intersections.
pub async fn count_media_files_for_user<'c, T>(args: CountMediaFilesForUserArgs<'_, T>) -> Result<i64, sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
let mut query =
QueryBuilder::<MySql>::new("SELECT COUNT(*) FROM media_files m FORCE INDEX (fk_maybe_creator_user_token)");
args.filters.push_predicates(&mut query);
query.build_query_scalar().fetch_one(args.mysql_executor).await
}
@@ -1,191 +1,10 @@
use std::collections::HashSet;
use sqlx::{Executor, MySql, QueryBuilder};
use chrono::{DateTime, Utc};
use sqlx::{FromRow, MySql, MySqlPool, QueryBuilder, Row};
use sqlx::mysql::MySqlRow;
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
use super::user_media_file_filters::UserMediaFileFilters;
use super::user_media_file_list_item::{MediaFileListItem, MediaFileListItemInternal};
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
use crate::helpers::boolean_converters::i8_to_bool;
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
pub struct MediaFileListPage {
pub records: Vec<MediaFileListItem>,
pub sort_ascending: bool,
pub current_page: usize,
pub total_page_count: usize,
}
pub struct MediaFileListItem {
pub token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_engine_category: Option<MediaFileEngineCategory>,
pub maybe_animation_type: Option<MediaFileAnimationType>,
pub origin_category: MediaFileOriginCategory,
pub origin_product_category: MediaFileOriginProductCategory,
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
pub maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
pub maybe_origin_model_title: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
pub maybe_prompt_args: Option<PromptInnerPayload>,
pub maybe_duration_millis: Option<u64>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
pub maybe_bookmark_count: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub struct ListMediaFileForUserArgs<'a> {
pub username: &'a str,
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
pub include_user_uploads: bool,
pub page_size: usize,
pub page_index: usize,
pub sort_ascending: bool,
pub view_as: ViewAs,
pub mysql_pool: &'a MySqlPool,
}
pub async fn list_media_files_for_user(args: ListMediaFileForUserArgs<'_>) -> AnyhowResult<MediaFileListPage> {
/// Let's figure out how many results we could have returned total
let count_fields = select_total_count_field();
let mut count_query_builder = query_builder(
args.maybe_filter_media_types,
args.maybe_filter_media_classes,
args.maybe_filter_engine_categories,
args.include_user_uploads,
args.username,
false,
0,
0,
args.sort_ascending,
args.view_as,
count_fields.as_str(),
true,
);
let row_count_query = count_query_builder.build_query_scalar::<i64>();
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
/// Now fetch the actual results with all the fields
let result_fields = select_result_fields();
let mut query = query_builder(
args.maybe_filter_media_types,
args.maybe_filter_media_classes,
args.maybe_filter_engine_categories,
args.include_user_uploads,
args.username,
true,
args.page_index,
args.page_size,
args.sort_ascending,
args.view_as,
result_fields.as_str(),
false,
);
let query = query.build_query_as::<MediaFileListItemInternal>();
let results = query.fetch_all(args.mysql_pool).await?;
let number_of_pages = 1 + (row_count_result / args.page_size as i64) as usize;
let results = results.into_iter()
.map(|record| {
MediaFileListItem {
token: record.token,
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
maybe_origin_model_title: record.maybe_origin_model_title,
public_bucket_directory_hash: record.public_bucket_directory_hash,
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
maybe_prompt_token: record.maybe_prompt_token,
creator_set_visibility: record.creator_set_visibility,
is_user_upload: i8_to_bool(record.is_user_upload),
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_prompt_args: record.maybe_other_prompt_args
.as_deref()
.map(|args| PromptInnerPayload::from_json(args))
.transpose()
.ok() // NB: Fail open
.flatten(),
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
maybe_bookmark_count: record.maybe_bookmark_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>();
Ok(MediaFileListPage {
records: results,
sort_ascending: args.sort_ascending,
current_page: args.page_index,
total_page_count: number_of_pages,
})
}
fn select_result_fields() -> String {
r#"
const RESULT_FIELDS: &str = r#"
m.id,
m.token,
@@ -229,258 +48,50 @@ fn select_result_fields() -> String {
m.created_at,
m.updated_at
"#
.to_string()
"#;
pub struct ListMediaFileForUserArgs<'a, T> {
pub filters: UserMediaFileFilters<'a>,
pub limit: usize,
pub offset: usize,
pub sort_ascending: bool,
pub mysql_executor: T,
}
fn select_total_count_field() -> String {
r#"
COUNT(m.id) AS total_count
"#
.to_string()
}
fn query_builder<'a>(
maybe_filter_media_types: Option<&HashSet<MediaFileType>>,
maybe_filter_media_classes: Option<&HashSet<MediaFileClass>>,
maybe_filter_engine_categories: Option<&HashSet<MediaFileEngineCategory>>,
include_user_uploads: bool,
username: &'a str,
enforce_limits: bool,
page_index: usize,
page_size: usize,
sort_ascending: bool,
view_as: ViewAs,
select_fields: &'a str,
count_only: bool,
) -> QueryBuilder<'a, MySql> {
// NB: The count query only needs the `users` join (for the username predicate).
// The other joins are all 1:1 and contribute nothing to the count — MySQL does
// not eliminate LEFT JOINs, so including them costs four index lookups per row.
let from_clause = if count_only {
r#"
FROM media_files AS m
LEFT OUTER JOIN users AS u
ON m.maybe_creator_user_token = u.token
"#
} else {
r#"
FROM media_files AS m
LEFT OUTER JOIN users AS u
ON m.maybe_creator_user_token = u.token
LEFT OUTER JOIN model_weights as w
ON m.maybe_origin_model_token = w.token
LEFT OUTER JOIN media_files as media_file_cover_image
ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
LEFT OUTER JOIN entity_stats
ON entity_stats.entity_type = "media_file"
AND entity_stats.entity_token = m.token
LEFT OUTER JOIN prompts
ON prompts.token = m.maybe_prompt_token
"#
};
// NB: Query cannot be statically checked by sqlx
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
format!(r#"
SELECT
{select_fields}
{from_clause}"#
));
query_builder.push(" WHERE u.username = ");
query_builder.push_bind(username);
//if let Some(media_type) = maybe_filter_media_type {
// // FIXME: Binding shouldn't require to_str().
// // Otherwise, it's calling the Display trait on the raw type which is resulting in an
// // incorrect binding and runtime error.
// query_builder.push(" AND m.media_type = ");
// query_builder.push_bind(media_type.to_str());
//}
if let Some(media_types) = maybe_filter_media_types {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !media_types.is_empty() {
query_builder.push(" AND m.media_type IN ( ");
let mut separated = query_builder.separated(", ");
for media_type in media_types.iter() {
separated.push_bind(media_type.to_str());
}
separated.push_unseparated(") ");
}
}
if let Some(media_classes) = maybe_filter_media_classes {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !media_classes.is_empty() {
query_builder.push(" AND m.media_class IN ( ");
let mut separated = query_builder.separated(", ");
for media_class in media_classes.iter() {
separated.push_bind(media_class.to_str());
}
separated.push_unseparated(") ");
}
}
if let Some(engine_categories) = maybe_filter_engine_categories {
// NB: `WHERE IN` comma separated syntax will be wrong if list has zero length
// We'll skip the predicate if the list isn't empty.
if !engine_categories.is_empty() {
query_builder.push(" AND m.maybe_engine_category IN ( ");
let mut separated = query_builder.separated(", ");
for engine_category in engine_categories.iter() {
separated.push_bind(engine_category.to_str());
}
separated.push_unseparated(") ");
}
}
if !include_user_uploads {
query_builder.push(" AND NOT m.is_user_upload ");
}
// TODO: Argument to control
query_builder.push(" AND NOT m.is_intermediate_system_file ");
match view_as {
ViewAs::Author | ViewAs::Moderator => {
// NB(bt): Actually, mods don't want to see deleted files. We'll improve the moderator UI later.
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
}
ViewAs::AnotherUser => {
query_builder.push(" AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL ");
// FIXME: Binding shouldn't require to_str().
// Otherwise, it's calling the Display trait on the raw type which is resulting in an
// incorrect binding and runtime error.
query_builder.push(" AND m.creator_set_visibility = ");
query_builder.push_bind(Visibility::Public.to_str());
}
}
// NB: Ordering is meaningless for the single-row COUNT aggregate.
if !count_only {
if sort_ascending {
query_builder.push(" ORDER BY m.created_at ASC ");
} else {
query_builder.push(" ORDER BY m.created_at DESC ");
}
}
if enforce_limits {
let offset = page_index * page_size;
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
}
query_builder
}
struct MediaFileListItemInternal {
id: i64,
token: MediaFileToken,
media_class: MediaFileClass,
media_type: MediaFileType,
maybe_engine_category: Option<MediaFileEngineCategory>,
maybe_animation_type: Option<MediaFileAnimationType>,
origin_category: MediaFileOriginCategory,
origin_product_category: MediaFileOriginProductCategory,
maybe_origin_model_type: Option<MediaFileOriginModelType>,
maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
maybe_origin_model_title: Option<String>,
maybe_other_prompt_args: Option<String>,
maybe_duration_millis: Option<i32>,
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_prompt_token: Option<PromptToken>,
creator_set_visibility: Visibility,
is_user_upload: i8,
is_intermediate_system_file: i8,
maybe_file_cover_image_public_bucket_hash: Option<String>,
maybe_file_cover_image_public_bucket_prefix: Option<String>,
maybe_file_cover_image_public_bucket_extension: Option<String>,
maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
maybe_text_transcript: Option<String>,
maybe_ratings_positive_count: Option<u32>,
maybe_ratings_negative_count: Option<u32>,
maybe_bookmark_count: Option<u32>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
// cannot have type hints, eg. the following:
//
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
//
// This results in the automatic mapping not being able to be found by name (for macro derive), and
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
// full "as" clause).
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
id: row.try_get("id")?,
token: MediaFileToken::new(row.try_get("token")?),
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
is_user_upload: row.try_get("is_user_upload")?,
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
maybe_title: row.try_get("maybe_title")?,
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
/// Select a bounded page before joining potentially large prompt/metadata rows.
/// `LIMIT` prevents MySQL from merging the derived table into the outer query.
pub async fn list_media_files_for_user<'c, T>(
args: ListMediaFileForUserArgs<'_, T>,
) -> Result<Vec<MediaFileListItem>, sqlx::Error>
where
T: Executor<'c, Database = MySql>,
{
let order = if args.sort_ascending { " ASC" } else { " DESC" };
let mut query = QueryBuilder::<MySql>::new(format!("SELECT {RESULT_FIELDS} FROM (SELECT m.id "));
// This index is in the July 2026 migrations. Restricting the scan to this user
// avoids index-merge scans of the global deletion/class indexes on large tables.
query.push("FROM media_files m FORCE INDEX (idx_creator_created_at) ");
args.filters.push_predicates(&mut query);
query.push(" ORDER BY m.created_at").push(order).push(", m.id").push(order);
query.push(" LIMIT ").push_bind(args.limit as u64);
query.push(" OFFSET ").push_bind(args.offset as u64);
query.push(
r#") AS page
JOIN media_files m ON m.id = page.id
LEFT JOIN model_weights w ON m.maybe_origin_model_token = w.token
LEFT JOIN media_files media_file_cover_image ON media_file_cover_image.token = m.maybe_cover_image_media_file_token
LEFT JOIN entity_stats ON entity_stats.entity_type = "#,
);
query.push_bind(StatsEntityType::MediaFile.to_str());
query
.push(
r#" AND entity_stats.entity_token = m.token
LEFT JOIN prompts ON prompts.token = m.maybe_prompt_token
ORDER BY m.created_at"#,
)
.push(order)
.push(", m.id")
.push(order);
let records = query.build_query_as::<MediaFileListItemInternal>().fetch_all(args.mysql_executor).await?;
Ok(records.into_iter().map(MediaFileListItem::from).collect())
}
@@ -1,3 +1,4 @@
pub mod count_media_files_for_user;
pub mod list_batch_generated_redux_media_files;
pub mod list_featured_media_files;
pub mod list_media_files;
@@ -7,3 +8,5 @@ pub mod list_media_files_for_user;
pub mod list_session_media_files_by_class;
pub mod list_session_project_media_files;
pub mod media_file_list_row;
pub mod user_media_file_filters;
pub mod user_media_file_list_item;
@@ -0,0 +1,59 @@
use std::collections::HashSet;
use sqlx::{MySql, QueryBuilder};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
/// Shared predicates keep the optional total and the returned page consistent.
#[derive(Clone, Copy)]
pub struct UserMediaFileFilters<'a> {
pub username: &'a str,
pub maybe_filter_media_types: Option<&'a HashSet<MediaFileType>>,
pub maybe_filter_media_classes: Option<&'a HashSet<MediaFileClass>>,
pub maybe_filter_engine_categories: Option<&'a HashSet<MediaFileEngineCategory>>,
pub include_user_uploads: bool,
pub view_as: ViewAs,
}
impl<'a> UserMediaFileFilters<'a> {
pub(super) fn push_predicates(&self, query: &mut QueryBuilder<'a, MySql>) {
// Resolve the unique username once; subsequent scans are scoped to its token.
query.push(" WHERE m.maybe_creator_user_token = (SELECT token FROM users WHERE username = ");
query.push_bind(self.username).push(")");
if let Some(values) = self.maybe_filter_media_types.filter(|values| !values.is_empty()) {
query.push(" AND m.media_type IN (");
let mut separated = query.separated(", ");
for value in values {
separated.push_bind(value.to_str());
}
separated.push_unseparated(")");
}
if let Some(values) = self.maybe_filter_media_classes.filter(|values| !values.is_empty()) {
query.push(" AND m.media_class IN (");
let mut separated = query.separated(", ");
for value in values {
separated.push_bind(value.to_str());
}
separated.push_unseparated(")");
}
if let Some(values) = self.maybe_filter_engine_categories.filter(|values| !values.is_empty()) {
query.push(" AND m.maybe_engine_category IN (");
let mut separated = query.separated(", ");
for value in values {
separated.push_bind(value.to_str());
}
separated.push_unseparated(")");
}
if !self.include_user_uploads {
query.push(" AND NOT m.is_user_upload");
}
query.push(" AND NOT m.is_intermediate_system_file AND m.user_deleted_at IS NULL AND m.mod_deleted_at IS NULL");
if self.view_as == ViewAs::AnotherUser {
query.push(" AND m.creator_set_visibility = ").push_bind(Visibility::Public.to_str());
}
}
}
@@ -0,0 +1,206 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, Row};
use sqlx::mysql::MySqlRow;
use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType;
use enums::traits::mysql_from_row::MySqlFromRow as _;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_model_type::MediaFileOriginModelType;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::prompts::PromptToken;
use tokens::traits::mysql_token_from_row::MySqlTokenFromRow;
use crate::helpers::boolean_converters::i8_to_bool;
use crate::payloads::prompt_args::prompt_inner_payload::PromptInnerPayload;
pub struct MediaFileListItem {
pub token: MediaFileToken,
pub media_class: MediaFileClass,
pub media_type: MediaFileType,
pub maybe_engine_category: Option<MediaFileEngineCategory>,
pub maybe_animation_type: Option<MediaFileAnimationType>,
pub origin_category: MediaFileOriginCategory,
pub origin_product_category: MediaFileOriginProductCategory,
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
pub maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
pub maybe_origin_model_title: Option<String>,
pub public_bucket_directory_hash: String,
pub maybe_public_bucket_prefix: Option<String>,
pub maybe_public_bucket_extension: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub creator_set_visibility: Visibility,
pub is_user_upload: bool,
pub is_intermediate_system_file: bool,
pub maybe_file_cover_image_public_bucket_hash: Option<String>,
pub maybe_file_cover_image_public_bucket_prefix: Option<String>,
pub maybe_file_cover_image_public_bucket_extension: Option<String>,
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
pub maybe_prompt_args: Option<PromptInnerPayload>,
pub maybe_duration_millis: Option<u64>,
pub maybe_ratings_positive_count: Option<u32>,
pub maybe_ratings_negative_count: Option<u32>,
pub maybe_bookmark_count: Option<u32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub(super) struct MediaFileListItemInternal {
id: i64,
token: MediaFileToken,
media_class: MediaFileClass,
media_type: MediaFileType,
maybe_engine_category: Option<MediaFileEngineCategory>,
maybe_animation_type: Option<MediaFileAnimationType>,
origin_category: MediaFileOriginCategory,
origin_product_category: MediaFileOriginProductCategory,
maybe_origin_model_type: Option<MediaFileOriginModelType>,
maybe_origin_model_token: Option<String>,
// NB: The title won't be populated for `tts_models` records or non-`model_weights` records.
maybe_origin_model_title: Option<String>,
maybe_other_prompt_args: Option<String>,
maybe_duration_millis: Option<i32>,
public_bucket_directory_hash: String,
maybe_public_bucket_prefix: Option<String>,
maybe_public_bucket_extension: Option<String>,
maybe_prompt_token: Option<PromptToken>,
creator_set_visibility: Visibility,
is_user_upload: i8,
is_intermediate_system_file: i8,
maybe_file_cover_image_public_bucket_hash: Option<String>,
maybe_file_cover_image_public_bucket_prefix: Option<String>,
maybe_file_cover_image_public_bucket_extension: Option<String>,
maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
maybe_text_transcript: Option<String>,
maybe_ratings_positive_count: Option<u32>,
maybe_ratings_negative_count: Option<u32>,
maybe_bookmark_count: Option<u32>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
// NB(bt,2023-12-05): There's an issue with type hinting in the `as` clauses with QueryBuilder (or
// raw query strings) and sqlx::FromRow, regardless of whether it is derived of manually
// implemented. Perhaps this will improve in the future, but for now manually constructed queries
// cannot have type hints, eg. the following:
//
// m.token as `token: tokens::tokens::media_files::MediaFileToken`,
// m.origin_category as `origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
// m.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,
//
// This results in the automatic mapping not being able to be found by name (for macro derive), and
// in the manual case `row.try_get()` etc. won't have the correct column name (since the name is the
// full "as" clause).
impl FromRow<'_, MySqlRow> for MediaFileListItemInternal {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
id: row.try_get("id")?,
token: MediaFileToken::new(row.try_get("token")?),
media_class: MediaFileClass::try_from_mysql_row(row, "media_class")?,
media_type: MediaFileType::try_from_mysql_row(row, "media_type")?,
maybe_engine_category: MediaFileEngineCategory::try_from_mysql_row_nullable(row, "maybe_engine_category")?,
maybe_animation_type: MediaFileAnimationType::try_from_mysql_row_nullable(row, "maybe_animation_type")?,
origin_category: MediaFileOriginCategory::try_from_mysql_row(row, "origin_category")?,
origin_product_category: MediaFileOriginProductCategory::try_from_mysql_row(row, "origin_product_category")?,
maybe_origin_model_type: MediaFileOriginModelType::try_from_mysql_row_nullable(row, "maybe_origin_model_type")?,
maybe_origin_model_token: row.try_get("maybe_origin_model_token")?,
maybe_origin_model_title: row.try_get("maybe_origin_model_title")?,
maybe_other_prompt_args: row.try_get("maybe_other_prompt_args")?,
maybe_duration_millis: row.try_get("maybe_duration_millis")?,
public_bucket_directory_hash: row.try_get("public_bucket_directory_hash")?,
maybe_public_bucket_prefix: row.try_get("maybe_public_bucket_prefix")?,
maybe_public_bucket_extension: row.try_get("maybe_public_bucket_extension")?,
maybe_prompt_token: PromptToken::try_from_mysql_row_nullable(row, "maybe_prompt_token")?,
creator_set_visibility: Visibility::try_from_mysql_row(row, "creator_set_visibility")?,
is_user_upload: row.try_get("is_user_upload")?,
is_intermediate_system_file: row.try_get("is_intermediate_system_file")?,
maybe_file_cover_image_public_bucket_hash: row.try_get("maybe_file_cover_image_public_bucket_hash")?,
maybe_file_cover_image_public_bucket_prefix: row.try_get("maybe_file_cover_image_public_bucket_prefix")?,
maybe_file_cover_image_public_bucket_extension: row.try_get("maybe_file_cover_image_public_bucket_extension")?,
maybe_title: row.try_get("maybe_title")?,
maybe_text_transcript: row.try_get("maybe_text_transcript")?,
maybe_ratings_positive_count: row.try_get("maybe_ratings_positive_count")?,
maybe_ratings_negative_count: row.try_get("maybe_ratings_negative_count")?,
maybe_bookmark_count: row.try_get("maybe_bookmark_count")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
}
impl From<MediaFileListItemInternal> for MediaFileListItem {
fn from(record: MediaFileListItemInternal) -> Self {
MediaFileListItem {
token: record.token,
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
maybe_origin_model_title: record.maybe_origin_model_title,
public_bucket_directory_hash: record.public_bucket_directory_hash,
maybe_public_bucket_prefix: record.maybe_public_bucket_prefix,
maybe_public_bucket_extension: record.maybe_public_bucket_extension,
maybe_prompt_token: record.maybe_prompt_token,
creator_set_visibility: record.creator_set_visibility,
is_user_upload: i8_to_bool(record.is_user_upload),
is_intermediate_system_file: i8_to_bool(record.is_intermediate_system_file),
maybe_file_cover_image_public_bucket_hash: record.maybe_file_cover_image_public_bucket_hash,
maybe_file_cover_image_public_bucket_prefix: record.maybe_file_cover_image_public_bucket_prefix,
maybe_file_cover_image_public_bucket_extension: record.maybe_file_cover_image_public_bucket_extension,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_prompt_args: record.maybe_other_prompt_args
.as_deref()
.map(|args| PromptInnerPayload::from_json(args))
.transpose()
.ok() // NB: Fail open
.flatten(),
maybe_duration_millis: record.maybe_duration_millis.map(|d| d as u64),
maybe_ratings_positive_count: record.maybe_ratings_positive_count,
maybe_ratings_negative_count: record.maybe_ratings_negative_count,
maybe_bookmark_count: record.maybe_bookmark_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}
@@ -0,0 +1,309 @@
//! Executes the actual library queries against migrated, disposable local MySQL.
//! IsolatedTestDatabase uses a fixed Unix socket, never DATABASE_URL/MYSQL_URL.
use std::collections::HashSet;
use std::time::Instant;
use enums::by_table::entity_stats::stats_entity_type::StatsEntityType;
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::view_as::ViewAs;
use enums::common::visibility::Visibility;
use mysql_queries::queries::media_files::list::count_media_files_for_user::{
count_media_files_for_user, CountMediaFilesForUserArgs,
};
use mysql_queries::queries::media_files::list::list_media_files_for_user::{
list_media_files_for_user, ListMediaFileForUserArgs,
};
use mysql_queries::queries::media_files::list::user_media_file_filters::UserMediaFileFilters;
use mysql_testing::fixtures::users::create_test_user;
use mysql_testing::isolated::IsolatedTestDatabase;
use sqlx::{MySql, MySqlPool, QueryBuilder};
use tokens::tokens::users::UserToken;
const CREATED_AT: &str = "2025-01-01 12:00:00";
#[tokio::test]
async fn user_media_library_preserves_filters_visibility_joins_and_pagination() {
let db = IsolatedTestDatabase::create().await;
let owner = create_test_user(&db.pool).await.unwrap();
let other = create_test_user(&db.pool).await.unwrap();
let rows = [
("m_public_image", MediaFileClass::Image, MediaFileType::Png, Visibility::Public, true),
("m_private_video", MediaFileClass::Video, MediaFileType::Mp4, Visibility::Private, false),
("m_hidden_audio", MediaFileClass::Audio, MediaFileType::Mp3, Visibility::Hidden, false),
("m_public_mesh", MediaFileClass::Mesh, MediaFileType::Glb, Visibility::Public, false),
("m_private_splat", MediaFileClass::Splat, MediaFileType::Spz, Visibility::Private, false),
("m_legacy_mesh", MediaFileClass::Dimensional, MediaFileType::Glb, Visibility::Public, false),
];
for (token, class, kind, visibility, upload) in rows {
insert_fixture(&db.pool, Fixture { owner: &owner.user_token, token, class, kind, visibility, upload }).await;
}
for token in ["m_intermediate", "m_user_deleted", "m_mod_deleted"] {
insert_fixture(&db.pool, Fixture::image(&owner.user_token, token)).await;
}
insert_fixture(&db.pool, Fixture::image(&other.user_token, "m_other_user")).await;
sqlx::query("UPDATE media_files SET is_intermediate_system_file = true WHERE token = 'm_intermediate'")
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_user_deleted'")
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE media_files SET mod_deleted_at = NOW() WHERE token = 'm_mod_deleted'")
.execute(&db.pool)
.await
.unwrap();
sqlx::query("UPDATE media_files SET maybe_engine_category = ? WHERE token = 'm_public_mesh'")
.bind(MediaFileEngineCategory::Object.to_str())
.execute(&db.pool)
.await
.unwrap();
sqlx::query("INSERT INTO prompts (token, prompt_type, creator_ip_address, maybe_other_args) VALUES ('p_library', ?, '127.0.0.1', ?)")
.bind(PromptType::ArtcraftApp.to_str()).bind(r#"{"fd":true}"#).execute(&db.pool).await.unwrap();
sqlx::query("UPDATE media_files SET maybe_cover_image_media_file_token = 'm_intermediate', maybe_prompt_token = 'p_library', maybe_duration_millis = 1234, maybe_title = 'Fixture title' WHERE token = 'm_private_video'").execute(&db.pool).await.unwrap();
sqlx::query("INSERT INTO entity_stats (entity_type, entity_token, ratings_positive_count, bookmark_count) VALUES (?, 'm_private_video', 3, 7)")
.bind(StatsEntityType::MediaFile.to_str()).execute(&db.pool).await.unwrap();
let filters = filters(&owner.username);
for (view_as, expected) in [
(
ViewAs::Author,
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
),
(
ViewAs::Moderator,
vec!["m_legacy_mesh", "m_private_splat", "m_public_mesh", "m_hidden_audio", "m_private_video", "m_public_image"],
),
(ViewAs::AnotherUser, vec!["m_legacy_mesh", "m_public_mesh", "m_public_image"]),
] {
let selected = UserMediaFileFilters { view_as, ..filters };
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters: selected,
limit: 60,
offset: 0,
sort_ascending: false,
mysql_executor: &db.pool,
})
.await
.unwrap();
assert_eq!(records.iter().map(|r| r.token.as_str()).collect::<Vec<_>>(), expected);
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
.await
.unwrap(),
expected.len() as i64
);
}
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 60,
offset: 0,
sort_ascending: true,
mysql_executor: &db.pool,
})
.await
.unwrap();
assert_eq!(records.first().unwrap().token.as_str(), "m_public_image");
let video = &records[1];
assert_eq!(video.maybe_file_cover_image_public_bucket_hash.as_deref(), Some("fixture_hash"));
assert_eq!(video.maybe_prompt_args.as_ref().unwrap().used_face_detailer, Some(true));
assert_eq!(video.maybe_ratings_positive_count, Some(3));
assert_eq!(video.maybe_bookmark_count, Some(7));
assert_eq!(video.maybe_duration_millis, Some(1234));
assert_eq!(video.maybe_title.as_deref(), Some("Fixture title"));
// All timestamps tie. The id tiebreaker must avoid duplicates/omissions.
let mut paged = vec![];
for offset in [0, 2, 4, 6] {
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 2,
offset,
sort_ascending: true,
mysql_executor: &db.pool,
})
.await
.unwrap();
paged.extend(records.into_iter().map(|r| r.token.to_string()));
}
assert_eq!(paged, rows.iter().map(|r| r.0.to_string()).collect::<Vec<_>>());
let classes = HashSet::from([MediaFileClass::Image, MediaFileClass::Video]);
let types = HashSet::from([MediaFileType::Glb]);
let engines = HashSet::from([MediaFileEngineCategory::Object]);
let empty = HashSet::new();
for (selected, expected) in [
(UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters }, 2),
(UserMediaFileFilters { maybe_filter_media_types: Some(&types), ..filters }, 2),
(UserMediaFileFilters { maybe_filter_engine_categories: Some(&engines), ..filters }, 1),
(UserMediaFileFilters { include_user_uploads: false, ..filters }, 5),
(UserMediaFileFilters { maybe_filter_media_classes: Some(&empty), ..filters }, 6),
(UserMediaFileFilters { username: "' OR 1=1 --", ..filters }, 0),
] {
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters: selected,
limit: 60,
offset: 0,
sort_ascending: false,
mysql_executor: &db.pool,
})
.await
.unwrap();
assert_eq!(records.len(), expected);
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters: selected, mysql_executor: &db.pool })
.await
.unwrap(),
expected as i64
);
}
// Queries must honor an existing transaction, including uncommitted changes.
let mut tx = db.pool.begin().await.unwrap();
sqlx::query("UPDATE media_files SET user_deleted_at = NOW() WHERE token = 'm_private_video'")
.execute(&mut *tx)
.await
.unwrap();
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *tx }).await.unwrap(),
5
);
assert_eq!(
list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 60,
offset: 0,
sort_ascending: false,
mysql_executor: &mut *tx
})
.await
.unwrap()
.len(),
5
);
tx.rollback().await.unwrap();
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &db.pool }).await.unwrap(),
6
);
db.destroy().await;
}
#[tokio::test]
#[ignore = "large local fixture; run explicitly with --ignored --nocapture"]
async fn user_media_library_large_page_reads_are_bounded() {
let db = IsolatedTestDatabase::create().await;
let owner = create_test_user(&db.pool).await.unwrap();
let other = create_test_user(&db.pool).await.unwrap();
// 60k owner rows, followed by 240k newer files from another account.
for chunk in 0..300 {
let mut query = QueryBuilder::<MySql>::new("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) ");
query.push_values(0..1000, |mut row, index| {
row
.push_bind(format!("m_bench_{}", chunk * 1000 + index))
.push_bind(MediaFileOriginCategory::Upload.to_str())
.push_bind(MediaFileOriginProductCategory::Unknown.to_str())
.push_bind(MediaFileType::Png.to_str())
.push_bind(MediaFileClass::Image.to_str())
.push_bind("0".repeat(64))
.push_bind("fixture_hash")
.push_bind("127.0.0.1")
.push_bind(if chunk < 60 { owner.user_token.as_str() } else { other.user_token.as_str() })
.push_bind(Visibility::Private.to_str())
.push_bind(false)
.push_bind(true)
.push_bind(CREATED_AT);
});
query.build().execute(&db.pool).await.unwrap();
}
sqlx::query("ANALYZE TABLE media_files").execute(&db.pool).await.unwrap();
let classes = HashSet::from([
MediaFileClass::Image,
MediaFileClass::Video,
MediaFileClass::Audio,
MediaFileClass::Dimensional,
MediaFileClass::Mesh,
MediaFileClass::Splat,
]);
let filters = UserMediaFileFilters { maybe_filter_media_classes: Some(&classes), ..filters(&owner.username) };
let mut conn = db.pool.acquire().await.unwrap();
let before = scan_reads(&mut conn).await;
let start = Instant::now();
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: 61,
offset: 0,
sort_ascending: false,
mysql_executor: &mut *conn,
})
.await
.unwrap();
let elapsed = start.elapsed();
let scanned = scan_reads(&mut conn).await - before;
assert_eq!(records.len(), 61);
assert_eq!(records.first().unwrap().token.as_str(), "m_bench_59999");
assert!(scanned < 200, "page query scanned {scanned} sequential index entries");
let start = Instant::now();
assert_eq!(
count_media_files_for_user(CountMediaFilesForUserArgs { filters, mysql_executor: &mut *conn }).await.unwrap(),
60_000
);
println!(
"300k fixtures / 60k owned: page={elapsed:?}, sequential index reads={scanned}; optional count={:?}",
start.elapsed()
);
drop(conn);
db.destroy().await;
}
struct Fixture<'a> {
owner: &'a UserToken,
token: &'a str,
class: MediaFileClass,
kind: MediaFileType,
visibility: Visibility,
upload: bool,
}
impl<'a> Fixture<'a> {
fn image(owner: &'a UserToken, token: &'a str) -> Self {
Self {
owner,
token,
class: MediaFileClass::Image,
kind: MediaFileType::Png,
visibility: Visibility::Public,
upload: true,
}
}
}
fn filters(username: &str) -> UserMediaFileFilters<'_> {
UserMediaFileFilters {
username,
maybe_filter_media_types: None,
maybe_filter_media_classes: None,
maybe_filter_engine_categories: None,
include_user_uploads: true,
view_as: ViewAs::Author,
}
}
async fn insert_fixture(pool: &MySqlPool, fixture: Fixture<'_>) {
sqlx::query("INSERT INTO media_files (token, origin_category, origin_product_category, media_type, media_class, checksum_sha2, public_bucket_directory_hash, creator_ip_address, maybe_creator_user_token, creator_set_visibility, is_intermediate_system_file, is_user_upload, created_at) VALUES (?, ?, ?, ?, ?, ?, 'fixture_hash', '127.0.0.1', ?, ?, false, ?, ?)")
.bind(fixture.token).bind(MediaFileOriginCategory::Upload.to_str()).bind(MediaFileOriginProductCategory::Unknown.to_str())
.bind(fixture.kind.to_str()).bind(fixture.class.to_str()).bind("0".repeat(64)).bind(fixture.owner.as_str())
.bind(fixture.visibility.to_str()).bind(fixture.upload).bind(CREATED_AT).execute(pool).await.unwrap();
}
async fn scan_reads(conn: &mut sqlx::MySqlConnection) -> u64 {
let values: Vec<(String, String)> =
sqlx::query_as("SHOW SESSION STATUS WHERE Variable_name IN ('Handler_read_next', 'Handler_read_prev')")
.fetch_all(conn)
.await
.unwrap();
values.into_iter().map(|(_, value)| value.parse::<u64>().unwrap()).sum()
}
@@ -378,6 +378,12 @@ use artcraft_api_defs::media_file::list::by_type::list_session_common::*;
use artcraft_api_defs::media_file::job::list_media_files_by_job::*;
use artcraft_api_defs::media_file::list::by_type::list_session_mesh_media_files::*;
use artcraft_api_defs::media_file::list::by_type::list_session_splat_media_files::*;
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::{
ListMediaFilesForUserV2PathInfo, ListMediaFilesForUserV2QueryParams,
ListMediaFilesForUserV2SuccessResponse, UserMediaFileV2Pagination,
};
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
use artcraft_api_defs::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
use artcraft_api_defs::media_file::list_session_project_media_files::*;
use artcraft_api_defs::media_file::project::upload_updated_mood_board_project::*;
use artcraft_api_defs::media_file::project::upload_new_mood_board_project::*;
@@ -519,6 +525,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,
@@ -1140,6 +1147,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
InferTtsSuccessResponse,
InferenceJobStatusResponsePayload,
InferenceJobTokenType,
LegacyMediaFileCoverImageDetails,
LegacyQueueDetails,
ListActiveUserSubscriptionsResponse,
ListAvailableWeightsQuery,
@@ -1182,6 +1190,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListMediaFilesForUserPathInfo,
ListMediaFilesForUserQueryParams,
ListMediaFilesForUserSuccessResponse,
ListMediaFilesForUserV2PathInfo,
ListMediaFilesForUserV2QueryParams,
ListMediaFilesForUserV2SuccessResponse,
UserMediaFileV2Pagination,
ListMediaFilesQueryParams,
ListMediaFilesSuccessResponse,
ListPinnedMediaFilesSuccessResponse,
@@ -1,36 +1,22 @@
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;
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 actix_web::{web, HttpRequest};
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
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 tokens::tokens::media_files::MediaFileToken;
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 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;
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::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;
@@ -89,93 +75,6 @@ pub struct ListMediaFilesForUserSuccessResponse {
pub pagination: PaginationPage,
}
#[derive(Serialize, ToSchema)]
pub struct MediaFileForUserListItem {
pub token: MediaFileToken,
/// The coarse-grained class of media file: image, video, etc.
pub media_class: MediaFileClass,
/// Type of media will dictate which fields are populated and what
/// the frontend should display (eg. video player vs audio player).
/// This is closer in meaning to a "mime type".
pub media_type: MediaFileType,
/// If this is an engine/3D asset, this is the broad category (scene,
/// animation, etc.) of that object.
/// This can also be used for filtering in list/batch endpoints.
pub maybe_engine_category: Option<MediaFileEngineCategory>,
/// If this is an engine/3D asset for an animation or a rig that can
/// be animated with either (or both) skeletal or blend shape animations,
/// this describes the animation regime used or supported.
pub maybe_animation_type: Option<MediaFileAnimationType>,
/// Details where the media file came from.
pub origin: MediaFileOriginDetails,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub origin_category: MediaFileOriginCategory,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub origin_product_category: MediaFileOriginProductCategory,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub maybe_origin_model_type: Option<MediaFileOriginModelType>,
#[deprecated(note="Use MediaFileOriginDetails instead")]
pub maybe_origin_model_token: Option<String>,
/// (DEPRECATED) URL path to the media file
#[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")]
pub public_bucket_path: String,
/// (DEPRECATED) Full URL to the media file
#[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")]
pub public_bucket_url: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
/// The foreign key to the prompt used to generate the media, if applicable.
pub maybe_prompt_token: Option<PromptToken>,
/// Information about the cover image. Many media files do not require a cover image,
/// e.g. image files, video files with thumbnails, audio files, etc.
/// 3D files require them.
pub cover_image: MediaFileCoverImageDetails,
pub creator_set_visibility: Visibility,
/// The file was uploaded by the user.
/// This does not include files generated on the client side, like studio renders.
pub is_user_upload: bool,
/// The file was created by the system.
/// This includes files generated on the client side, like studio renders.
pub is_intermediate_system_file: bool,
/// The name or title of the media file (optional)
pub maybe_title: Option<String>,
/// Text transcripts for TTS, etc.
pub maybe_text_transcript: Option<String>,
/// For Comfy / Video Style Transfer jobs, this might include
/// the name of the selected style.
pub maybe_style_name: Option<StyleTransferName>,
/// Duration for audio and video files, if available.
/// Measured in milliseconds.
pub maybe_duration_millis: Option<u64>,
/// Statistics about the media file
pub stats: SimpleEntityStats,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// List all of a user's media files (paginated).
///
/// This endpoint uses the session to automatically show all files for the given user, but only
@@ -238,117 +137,48 @@ pub async fn list_media_files_for_user_handler(
let mut maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
let mut maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
info!("Querying media files for user: {:?} type: {:?} as: {:?}", path.username, maybe_filter_media_types, view_as);
let query_results = list_media_files_for_user(ListMediaFileForUserArgs {
let filters = UserMediaFileFilters {
username: &path.username,
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
include_user_uploads: query.include_user_uploads.unwrap_or(false),
page_size,
page_index,
sort_ascending,
view_as,
mysql_pool: &server_state.mysql_pool,
}).await;
let results_page = match query_results {
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(CommonWebError::from_anyhow_error(e));
}
};
let count_started = Instant::now();
let total_count = count_media_files_for_user(CountMediaFilesForUserArgs {
filters,
mysql_executor: &server_state.mysql_pool,
}).await?;
let count_ms = count_started.elapsed().as_millis();
let page_started = Instant::now();
let records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: page_size,
offset: page_index * page_size,
sort_ascending,
mysql_executor: &server_state.mysql_pool,
}).await?;
info!("Listed legacy user media: username={} page={} count_ms={} page_ms={}",
path.username, page_index, count_ms, page_started.elapsed().as_millis());
let media_domain = get_media_domain(&http_request);
let results = results_page.records.into_iter()
.filter(|record| {
if is_allowed_studio_access {
return true;
}
// Don't allow access to certain media types.
match record.media_type {
MediaFileType::Bvh |
MediaFileType::Fbx |
MediaFileType::Glb |
MediaFileType::Gltf |
MediaFileType::SceneRon => return false,
_ => {},
}
// // Don't allow access to certain products.
// match record.origin_product_category {
// MediaFileOriginProductCategory::VideoFilter |
// MediaFileOriginProductCategory::Mocap |
// MediaFileOriginProductCategory::Workflow => return false,
// _ => {},
// }
true
})
.map(|record| {
let public_bucket_path = MediaFileBucketPath::from_object_hash(
&record.public_bucket_directory_hash,
record.maybe_public_bucket_prefix.as_deref(),
record.maybe_public_bucket_extension.as_deref(),
);
MediaFileForUserListItem {
token: record.token.clone(),
media_class: record.media_class,
media_type: record.media_type,
maybe_engine_category: record.maybe_engine_category,
maybe_animation_type: record.maybe_animation_type,
origin: MediaFileOriginDetails::from_db_fields_str(
record.origin_category,
record.origin_product_category,
record.maybe_origin_model_type,
record.maybe_origin_model_token.as_deref(),
record.maybe_origin_model_title.as_deref()),
origin_category: record.origin_category,
origin_product_category: record.origin_product_category,
maybe_origin_model_type: record.maybe_origin_model_type,
maybe_origin_model_token: record.maybe_origin_model_token,
media_links: MediaLinksBuilder::from_media_path_and_env(
media_domain, server_state.server_environment, &public_bucket_path),
maybe_prompt_token: record.maybe_prompt_token,
public_bucket_path: public_bucket_path
.get_full_object_path_str()
.to_string(),
public_bucket_url: bucket_url_string_from_media_path(&public_bucket_path, media_domain, server_state.server_environment),
cover_image: MediaFileCoverImageDetails::from_optional_db_fields(
&record.token,
media_domain,
server_state.server_environment,
record.maybe_file_cover_image_public_bucket_hash.as_deref(),
record.maybe_file_cover_image_public_bucket_prefix.as_deref(),
record.maybe_file_cover_image_public_bucket_extension.as_deref(),
),
creator_set_visibility: record.creator_set_visibility,
is_user_upload: record.is_user_upload,
is_intermediate_system_file: record.is_intermediate_system_file,
maybe_title: record.maybe_title,
maybe_text_transcript: record.maybe_text_transcript,
maybe_style_name: record.maybe_prompt_args
.as_ref()
.and_then(|args| args.style_name.as_ref())
.and_then(|style| style.to_style_name()),
maybe_duration_millis: record.maybe_duration_millis,
stats: SimpleEntityStats {
positive_rating_count: record.maybe_ratings_positive_count.unwrap_or(0),
bookmark_count: record.maybe_bookmark_count.unwrap_or(0),
},
created_at: record.created_at,
updated_at: record.updated_at,
}
})
.collect::<Vec<_>>();
let results = build_user_media_file_response(UserMediaFileResponseArgs {
records,
is_allowed_studio_access,
media_domain,
server_environment: server_state.server_environment,
});
Ok(Json(ListMediaFilesForUserSuccessResponse {
success: true,
results,
pagination: PaginationPage {
current: results_page.current_page,
total_page_count: results_page.total_page_count,
current: page_index,
// Preserve v1's historical calculation, including exact multiples.
// A pagination behavior change belongs in a new endpoint version.
total_page_count: 1 + (total_count / page_size as i64) as usize,
}
}))
}
@@ -0,0 +1,138 @@
use std::sync::Arc;
use std::time::Instant;
use actix_web::web::{Json, Path, Query};
use actix_web::{web, HttpRequest};
use artcraft_api_defs::media_file::list::list_media_files_for_user_v2::{
ListMediaFilesForUserV2PathInfo, ListMediaFilesForUserV2QueryParams,
ListMediaFilesForUserV2SuccessResponse, UserMediaFileV2Pagination,
};
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 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;
/// Faster user-media listing for infinite scroll.
///
/// Unlike `/v1/media_files/list/user/{username}`, this endpoint skips the exact
/// `COUNT(*)` over the user's entire matching library. It fetches `page_size + 1`
/// records to return `pagination.has_more`, without a total-page count. Removing
/// that scan makes loading pages faster for large libraries.
///
/// Both endpoints use the same optimized per-user indexes and load related
/// metadata only after selecting a page. The original endpoint retains its
/// request/response contract and pagination behavior for deployed clients.
///
/// Page indexes remain offset based; very deep pages still scan preceding
/// matching records. Page size defaults to 25 and must be between 1 and 100.
#[utoipa::path(
get,
tag = "Media Files",
path = "/v1/media_files/list_v2/user/{username}",
params(ListMediaFilesForUserV2QueryParams),
responses(
(status = 200, description = "Faster user-media page with has_more; no full-library count", body = ListMediaFilesForUserV2SuccessResponse),
(status = 400, description = "Invalid pagination"),
(status = 500, description = "Server error"),
),
)]
pub async fn list_media_files_for_user_v2_handler(
http_request: HttpRequest,
path: Path<ListMediaFilesForUserV2PathInfo>,
query: Query<ListMediaFilesForUserV2QueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListMediaFilesForUserV2SuccessResponse>, CommonWebError> {
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await?;
let mut is_author = false;
let mut is_mod = false;
// NB: Temporary rollout flag for certain file types (BVH, etc).
let is_allowed_studio_access = allowed_studio_access(maybe_user_session.as_ref(), &server_state.flags);
match maybe_user_session {
None => {}
Some(session) => {
is_author = session.username == path.username;
is_mod = session.can_ban_users;
}
};
let sort_ascending = query.sort_ascending.unwrap_or(false);
let page_size = query.page_size.unwrap_or_else(|| 25);
let page_index = query.page_index.unwrap_or_else(|| 0);
if !(1..=100).contains(&page_size) {
return Err(CommonWebError::BadInputWithSimpleMessage(
"page_size must be between 1 and 100".to_string(),
));
}
let offset = page_index
.checked_mul(page_size)
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("page_index is too large".to_string()))?;
let view_as = if is_author {
ViewAs::Author
} else if is_mod {
ViewAs::Moderator
} else {
ViewAs::AnotherUser
};
let maybe_filter_media_types = get_scoped_media_types(query.filter_media_type.as_deref());
let maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref());
let maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref());
let filters = UserMediaFileFilters {
username: &path.username,
maybe_filter_media_types: maybe_filter_media_types.as_ref(),
maybe_filter_media_classes: maybe_filter_media_classes.as_ref(),
maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(),
include_user_uploads: query.include_user_uploads.unwrap_or(false),
view_as,
};
let page_started = Instant::now();
let mut records = list_media_files_for_user(ListMediaFileForUserArgs {
filters,
limit: page_size + 1,
offset,
sort_ascending,
mysql_executor: &server_state.mysql_pool,
})
.await?;
let has_more = records.len() > page_size;
records.truncate(page_size);
info!(
"Listed list_v2 user media: username={} page={} page_ms={}",
path.username,
page_index,
page_started.elapsed().as_millis()
);
let results = build_user_media_file_response(UserMediaFileResponseArgs {
records,
is_allowed_studio_access,
media_domain: get_media_domain(&http_request),
server_environment: server_state.server_environment,
});
Ok(Json(ListMediaFilesForUserV2SuccessResponse {
success: true,
results,
pagination: UserMediaFileV2Pagination {
current: page_index,
has_more,
},
}))
}
@@ -6,3 +6,5 @@ pub mod list_media_files_for_user_handler;
pub mod list_media_files_handler;
pub mod list_pinned_media_files_handler;
pub mod list_session_project_media_files_handler;
pub mod list_media_files_for_user_v2_handler;
mod user_media_file_response;
@@ -0,0 +1,197 @@
use artcraft_api_defs::common::responses::cover_image_links::CoverImageLinks;
use artcraft_api_defs::common::responses::legacy_media_file_cover_image_details::LegacyMediaFileCoverImageDetails;
use artcraft_api_defs::common::responses::media_file_cover_image_details::MediaFileDefaultCover;
use artcraft_api_defs::common::responses::media_file_origin_details::{
MediaFileModelDetails as MediaFileModelDetailsApi, MediaFileOriginDetails as MediaFileOriginDetailsApi,
};
use artcraft_api_defs::common::responses::simple_entity_stats::SimpleEntityStats;
use artcraft_api_defs::media_file::list::user_media_file_list_item::MediaFileForUserListItem;
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 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::{MediaFileModelDetails, MediaFileOriginDetails};
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: into_api_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: into_api_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<_>>()
}
fn into_api_origin(origin: MediaFileOriginDetails) -> MediaFileOriginDetailsApi {
MediaFileOriginDetailsApi {
origin_category: origin.origin_category,
product_category: origin.product_category,
maybe_model: origin.maybe_model.map(|model| match model {
MediaFileModelDetails::SystemModel { model_type } => MediaFileModelDetailsApi::SystemModel { model_type },
MediaFileModelDetails::ModelWeight { model_type, token, title } => {
MediaFileModelDetailsApi::ModelWeight { model_type, token, title }
}
}),
}
}
fn into_api_cover_image(cover: MediaFileCoverImageDetails) -> LegacyMediaFileCoverImageDetails {
LegacyMediaFileCoverImageDetails {
maybe_cover_image_public_bucket_path: cover.maybe_cover_image_public_bucket_path,
maybe_cover_image_public_bucket_url: cover.maybe_cover_image_public_bucket_url,
maybe_links: cover.maybe_links.map(|links| CoverImageLinks {
cdn_url: links.cdn_url,
thumbnail_template: links.thumbnail_template,
}),
default_cover: MediaFileDefaultCover {
image_index: cover.default_cover.image_index,
color_index: cover.default_cover.color_index,
},
}
}
#[cfg(test)]
mod tests {
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 tokens::tokens::media_files::MediaFileToken;
use super::*;
mod conversions {
use super::*;
#[test]
fn origin_details_preserve_system_and_user_models() {
for (model_type, model_token, model_title) in [
(None, None, None),
(Some(MediaFileOriginModelType::Tacotron2), None, None),
(Some(MediaFileOriginModelType::Tacotron2), Some("weight_fixture"), Some("Fixture model")),
] {
let origin = MediaFileOriginDetails::from_db_fields_str(
MediaFileOriginCategory::Upload,
MediaFileOriginProductCategory::Unknown,
model_type,
model_token,
model_title,
);
let original = serde_json::to_value(&origin).unwrap();
let converted = serde_json::to_value(into_api_origin(origin)).unwrap();
assert_eq!(converted, original);
let decoded: MediaFileOriginDetailsApi = serde_json::from_value(converted).unwrap();
assert_eq!(serde_json::to_value(decoded).unwrap(), original);
}
}
#[test]
fn cover_details_preserve_legacy_fields_and_cdn_links() {
for maybe_hash in [None, Some("fixture_cover_hash")] {
let cover = MediaFileCoverImageDetails::from_optional_db_fields(
&MediaFileToken::new_from_str("m_fixture"),
MediaDomain::FakeYou,
ServerEnvironment::Production,
maybe_hash,
Some("image_"),
Some(".png"),
);
let original = serde_json::to_value(&cover).unwrap();
let converted = serde_json::to_value(into_api_cover_image(cover)).unwrap();
assert_eq!(converted, original);
let decoded: LegacyMediaFileCoverImageDetails = serde_json::from_value(converted).unwrap();
assert_eq!(serde_json::to_value(decoded).unwrap(), original);
}
}
}
}
@@ -548,7 +548,7 @@ fn fixture_video_bytes(duration_millis: u64) -> Vec<u8> {
/// the stub Kinovi server. Everything else is inert dummy configuration:
/// Redis/Elasticsearch/Stripe/bucket clients are constructed lazily and are
/// never called on the video generate path.
fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
pub(crate) fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> ServerState {
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
.expect("session cookie manager");
let avt_cookie_manager =
@@ -17,6 +17,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
use crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_for_user_v2_handler::list_media_files_for_user_v2_handler;
use crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler;
use crate::http_server::endpoints::media_files::list::list_pinned_media_files_handler::list_pinned_media_files_handler;
use crate::http_server::endpoints::media_files::list::by_type::list_session_mesh_media_files_handler::list_session_mesh_media_files_handler;
@@ -115,6 +116,10 @@ pub fn add_media_file_routes<T, B> (app: App<T>) -> App<T>
.route(web::get().to(list_media_files_for_user_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/list_v2/user/{username}")
.route(web::get().to(list_media_files_for_user_v2_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/mesh/list")
.route(web::get().to(list_session_mesh_media_files_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -28,4 +28,6 @@ mod wallet_routes;
mod web_referrals_routes;
mod webhook_routes;
mod weights_routes;
pub (super) mod add_application_routes;
pub (super) mod add_application_routes;
#[cfg(test)]
mod user_media_endpoint_tests;
@@ -0,0 +1,183 @@
//! 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 artcraft_api_defs::media_file::list::list_media_files_for_user_v2::ListMediaFilesForUserV2SuccessResponse;
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}));
// A Rust API client can decode and re-encode the full response without
// dropping nested or deprecated fields from either endpoint's media items.
let typed_v2: ListMediaFilesForUserV2SuccessResponse = serde_json::from_value(first_v2.clone()).unwrap();
assert_eq!(typed_v2.results.len(), 25);
assert_eq!(serde_json::to_value(typed_v2).unwrap(), first_v2);
let (_, last_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=1"), session).await;
assert_eq!(last_v2["results"], last["results"]);
assert_eq!(last_v2["pagination"], json!({"current": 1, "has_more": false}));
let (_, exact_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_size=26"), session).await;
assert_eq!(exact_v2["pagination"], json!({"current": 0, "has_more": false}));
let (_, empty_v2) = get(&app, &format!("{v2}?include_user_uploads=true&page_index=2"), session).await;
assert_eq!(empty_v2["results"], json!([]));
assert_eq!(empty_v2["pagination"], json!({"current": 2, "has_more": false}));
for operation in ["list", "list_v2"] {
let path = format!(
"/v1/media_files/{operation}/user/{}?include_user_uploads=true",
owner.username
);
let (status, anonymous) = get(&app, &path, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
anonymous["results"],
json!([]),
"private fixtures must require their owner session"
);
let (status, missing) = get(
&app,
&format!("/v1/media_files/{operation}/user/missing_fixture?include_user_uploads=true"),
session,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(missing["results"], json!([]));
let (status, filtered) = get(&app, &format!("{path}&filter_media_classes=image"), session).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(filtered["results"], json!([]));
}
for query in [
"page_size=0".to_string(),
"page_size=101".to_string(),
format!("page_size=60&page_index={}", usize::MAX),
] {
let (status, _) = get(&app, &format!("{v2}?{query}"), session).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
drop(app);
db.destroy().await;
}
async fn get<S, B>(app: &S, path: &str, maybe_session: Option<&str>) -> (StatusCode, Value)
where
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
B: MessageBody,
B::Error: std::fmt::Debug,
{
let mut request = test::TestRequest::get().uri(path);
if let Some(session) = maybe_session {
request = request.insert_header(("session", session));
}
let response = test::call_service(app, request.to_request()).await;
let status = response.status();
(status, test::read_body_json(response).await)
}
fn assert_legacy_item_keys(item: &Value) {
let mut actual = item.as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>();
actual.sort_unstable();
let mut expected = LEGACY_ITEM_FIELDS.to_vec();
expected.sort_unstable();
assert_eq!(actual, expected);
}
+101
View File
@@ -0,0 +1,101 @@
# User media library query
`GET /v1/media_files/list/user/{username}` runs an exact count before fetching
each page. The webapp continues to use it for infinite scroll. MySQL can choose
an index-merge plan that intersects the creator index with global indexes on
NULL deletion timestamps and the intermediate-file flag. That makes the count
scan unrelated users' index entries.
The queries now force the creator index for counts and the creator/date index
for page selection. A limited derived table selects IDs before joining prompt,
cover, model, and stats data. Ordering uses `(created_at, id)` to break timestamp
ties consistently. Shared predicates preserve author/moderator/public visibility,
deleted-file exclusions, uploads, and class/type/engine filters.
The legacy `GET /v1/media_files/list/user/{username}` contract is unchanged:
original query fields and defaults, no new page-size cap, and the original
`pagination: { current, total_page_count }` with a numeric total. Its historical
`1 + floor(count / page_size)` calculation is intentionally preserved, including
exact multiples. It always performs the optimized exact count.
The separate `GET /v1/media_files/list_v2/user/{username}` endpoint is available
for adoption after backend deployment. `list_v2` fetches one extra record
and returns `pagination: { current, has_more }`. It never counts the library and
has no count-mode flag or total-page field. `list_v2` accepts page sizes from 1 to 100.
Both endpoints return the same media-item fields and enforce the same visibility
and media filters.
Skipping the count is the additional speed improvement: even the optimized
legacy count must visit all matching rows before returning an exact total. The
new endpoint avoids that work on every request, including the first page. For a
60-item page it fetches at most 61 result records; it can still scan additional
index entries to apply filters or skip an offset. Compatible index-selection
and deferred-join improvements remain shared by both endpoints.
Pagination remains offset based, so very deep pages still scan preceding
matching entries. `list_v2` removes the full-library count from infinite-scroll
requests, but does not remove that offset cost.
## Deployment
The frontend switch is deferred. The webapp continues using
`GalleryModalApi.listUserMediaFiles` and the existing
`/v1/media_files/list/user/{username}` endpoint with numbered pagination.
The `UserMediaFilesV2Api` library binding and its tests are retained, with no live
frontend callers.
Deploy and verify storyteller-web with the new `list_v2` route before a separate
frontend change adopts `UserMediaFilesV2Api` and its `has_more` pagination.
Both routes remain in API v1; this is an endpoint-local revision. Existing
clients and the original bindings remain unchanged.
No new migration is needed. The existing migration
`2026-07-03-074740-0000_alter_media_files_add_user_created_at_index`
must already be applied: both page queries require `idx_creator_created_at`.
The legacy count query uses the longstanding `fk_maybe_creator_user_token` index.
Endpoint logs distinguish legacy/list_v2 and report `page_ms`; the legacy route
also reports `count_ms`.
Use those timings to verify the actual production improvement after deployment.
## Local validation
Database tests use `IsolatedTestDatabase`: a fixed `/tmp/mysql.sock`, new random
schemas, and checked-in migrations. They do not read production connection URLs
or modify existing schemas. From the repository root:
```sh
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library
SQLX_OFFLINE=true cargo test -p mysql_testing --test user_media_library -- --ignored --nocapture
```
The large fixture creates 300,000 rows, 60,000 owned by the target account. On
local MySQL 8.4.11, the production page query returned 61 rows in 3.4 ms with 91
sequential index reads. Its test asserts bounded reads rather than wall-clock
latency. The legacy user-scoped count took 38.5 ms.
A separate local EXPLAIN ANALYZE comparison with that distribution measured the
old count at 166 ms and the forced creator-index count at 41.5 ms. The old plan
scanned 300,000 entries in each of three global indexes. These are synthetic
local measurements, not production response-time promises.
Functional fixtures cover public/private/hidden visibility, other owners,
deleted and intermediate files, all library media classes including legacy 3D,
upload/type/engine filters, same-timestamp pagination, joined metadata, unknown
usernames, and caller-owned transactions.
The real Actix route test uses signed sessions and disposable MySQL fixtures to
lock down the original request defaults, response keys/types, exact-multiple page counts,
and page sizes above 100. It also checks `list_v2` first/last/empty pages, validation,
media-item parity, filters, and private-file access:
```sh
SQLX_OFFLINE=true cargo test -p storyteller-web --bin storyteller-web user_media_endpoint_versions
```
Frontend HTTP-binding tests:
```sh
cd frontend
node_modules/.bin/vitest run --config libs/api/vite.config.ts src/lib/MediaFilesApi.spec.ts
```
@@ -0,0 +1,182 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ArtCraft Desktop — Open Source AI for Creators</title>
<meta
name="description"
content="ArtCraft Desktop is the best Open Source AI tool for creators. Generate AI images and video with real control, right on your desktop."
/>
<link rel="canonical" href="https://desktop.getartcraft.com/" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://desktop.getartcraft.com/" />
<meta property="og:site_name" content="ArtCraft" />
<meta property="og:title" content="ArtCraft Desktop" />
<meta
property="og:description"
content="ArtCraft Desktop is the best Open Source AI tool for creators."
/>
<meta property="og:image" content="https://desktop.getartcraft.com/images/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f2f1ee" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0b0b0c" />
<link rel="icon" href="/artcraft-icon.svg" type="image/svg+xml" />
<!-- Same type system as getartcraft.com: Archivo (with its width axis,
for the stretched wordmark), Instrument Serif italic accents, Inter
body, Geist Mono HUD labels. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Geist+Mono:wght@400..600&family=Instrument+Serif:ital@0;1&family=Inter:wght@400..700&display=swap"
/>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<nav class="site-nav">
<a class="nav-logo" href="https://getartcraft.com/" aria-label="ArtCraft home">
<svg viewBox="0 0 116.34 97.5" fill="currentColor" aria-hidden="true">
<path d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z" />
</svg>
</a>
<div class="nav-links">
<a class="nav-link hud-label" href="https://getartcraft.com/download"><span>Download</span></a>
<a class="nav-link hud-label" href="https://github.com/storytold/artcraft"><span>GitHub</span></a>
<a class="nav-cta hud-label" href="https://getartcraft.com/">Go to ArtCraft</a>
</div>
</nav>
<main>
<section class="shell">
<div class="rail">
<span class="tick tick-half" style="top: -5px; left: -6px" aria-hidden="true"></span>
<span class="tick tick-half" style="top: -5px; right: -6px" aria-hidden="true"></span>
<div class="eyebrow">
<p class="hud-label muted"><span class="faint">01</span><span class="faint slash" aria-hidden="true">/</span>Desktop</p>
<p class="hud-label faint hide-sm">macOS · Windows · Open source</p>
</div>
<div class="hero">
<!-- The wordmark: the brand mark stands in for the leading A,
set in Archivo at the poster width like the main site. -->
<div class="wordmark-wrap">
<div class="wordmark" role="img" aria-label="ArtCraft">
<svg class="wordmark-logo" viewBox="0 0 116.34 97.5" fill="currentColor" aria-hidden="true">
<path d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z" />
</svg><span aria-hidden="true">RTCRAFT</span>
</div>
</div>
<p class="hud-label accent-ink wordmark-sub">Desktop</p>
<h1 class="headline">
ArtCraft Desktop is the best <em>Open Source</em> AI tool for
creators
</h1>
<div class="ctas">
<a class="btn btn-primary" href="https://getartcraft.com/download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 17V3" /><path d="m6 11 6 6 6-6" /><path d="M19 21H5" />
</svg>
Go to downloads
</a>
<a class="btn btn-secondary" href="https://getartcraft.com/">
Go to ArtCraft
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />
</svg>
</a>
</div>
<p class="hud-label faint meta">Free · Open source · macOS &amp; Windows</p>
</div>
<figure class="shot">
<div class="shot-bar">
<figcaption class="hud-label faint">Viewport — 3D scene editor</figcaption>
<p class="hud-label faint">ArtCraft Desktop</p>
</div>
<div class="shot-frame">
<img
src="/images/artcraft-desktop-screenshot.jpg"
alt="Screenshot of the ArtCraft Desktop app showing the 3D scene editor"
width="1920"
height="945"
/>
<span class="tick" style="top: 8px; left: 8px" aria-hidden="true"></span>
<span class="tick" style="top: 8px; right: 8px" aria-hidden="true"></span>
<span class="tick" style="bottom: 8px; left: 8px" aria-hidden="true"></span>
<span class="tick" style="bottom: 8px; right: 8px" aria-hidden="true"></span>
</div>
</figure>
</div>
</section>
<section class="shell">
<div class="rail">
<span class="tick tick-half" style="top: -5px; left: -6px" aria-hidden="true"></span>
<span class="tick tick-half" style="top: -5px; right: -6px" aria-hidden="true"></span>
<div class="eyebrow">
<p class="hud-label muted"><span class="faint">02</span><span class="faint slash" aria-hidden="true">/</span>Community</p>
<p class="hud-label faint hide-sm">Built in the open</p>
</div>
<ul class="socials">
<li>
<a class="social invert-block" href="https://github.com/storytold/artcraft" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>
<span class="hud-label">GitHub</span>
<span class="social-arrow" aria-hidden="true">↗</span>
</a>
</li>
<li>
<a class="social invert-block" href="https://discord.gg/artcraft" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" /></svg>
<span class="hud-label">Discord</span>
<span class="social-arrow" aria-hidden="true">↗</span>
</a>
</li>
<li>
<a class="social invert-block" href="https://www.youtube.com/@OfficialArtCraftStudios" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /></svg>
<span class="hud-label">YouTube</span>
<span class="social-arrow" aria-hidden="true">↗</span>
</a>
</li>
<li>
<a class="social invert-block" href="https://www.instagram.com/get_artcraft" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="20" x="2" y="2" rx="5" ry="5" /><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" /><line x1="17.5" x2="17.51" y1="6.5" y2="6.5" /></svg>
<span class="hud-label">Instagram</span>
<span class="social-arrow" aria-hidden="true">↗</span>
</a>
</li>
<li>
<a class="social invert-block" href="https://www.tiktok.com/@artcraft.studios" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" /></svg>
<span class="hud-label">TikTok</span>
<span class="social-arrow" aria-hidden="true">↗</span>
</a>
</li>
</ul>
</div>
</section>
</main>
<footer class="shell">
<div class="rail footer-bar">
<p class="hud-label faint">© <span id="year">2026</span> ArtCraft</p>
<a class="hud-label faint footer-mail" href="mailto:hello@storyteller.ai">hello@storyteller.ai</a>
<p class="hud-label faint">Made by artists, for artists</p>
</div>
</footer>
<script>
document.getElementById('year').textContent = String(new Date().getFullYear());
</script>
</body>
</html>
@@ -0,0 +1,24 @@
# Netlify config for desktop.getartcraft.com (Nx + Vite static page).
#
# Netlify settings used to discover this file:
# Base directory: frontend
# Package directory: apps/artcraft-desktop-website (relative to the base directory)
# Manage the build command and publish directory here; leave those UI fields empty.
# Paths below are relative to the frontend workspace.
[build]
command = "bash ./apps/artcraft-desktop-website/script/netlify_build.sh"
publish = "apps/artcraft-desktop-website/dist/"
# Vite emits content-hashed files under dist/assets — safe to cache forever.
[[headers]]
for = "/assets/*"
[headers.values]
"Cache-Control" = "public, max-age=31536000, immutable"
# Single-page site: unknown paths land on the page instead of a 404.
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
@@ -0,0 +1,6 @@
{
"name": "@frontend/artcraft-desktop-website",
"version": "0.0.1",
"private": true,
"dependencies": {}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
<defs>
<style>
.cls-1 {
fill: #4e7bfb;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,11 @@
#!/bin/bash
set -euxo pipefail
# Runs from the Netlify base directory (= the Nx workspace root, `frontend/`).
echo "Building artcraft-desktop-website"
npx nx build @frontend/artcraft-desktop-website
echo "Final build files:"
find apps/artcraft-desktop-website/dist/
@@ -0,0 +1,523 @@
/*
* desktop.getartcraft.com — ported from the getartcraft.com design tokens
* (apps/artcraft-website-next/src/app/globals.css). Same language: hairline
* rules, hard edges, mono HUD labels, a single blue accent, light "blueprint"
* and dark "viewport" themes following the system preference.
*/
:root {
--bg: #f2f1ee;
--bg-raised: #faf9f7;
--bg-sunken: #e9e8e4;
--ink: #101014;
--ink-strong: #000000;
--muted: rgba(16, 16, 20, 0.62);
--faint: rgba(16, 16, 20, 0.42);
--line: rgba(16, 16, 20, 0.16);
--line-strong: rgba(16, 16, 20, 0.4);
--accent: #2d81ff;
--accent-ink: #1659c4;
--invert-bg: #101014;
--invert-fg: #f2f1ee;
--font-display: 'Archivo', system-ui, sans-serif;
--font-serif: 'Instrument Serif', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--rail: 1280px;
--pad: 24px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121316;
--bg-raised: #101014;
--bg-sunken: #060607;
--ink: #f2f1ee;
--ink-strong: #ffffff;
--muted: rgba(242, 241, 238, 0.6);
--faint: rgba(242, 241, 238, 0.38);
--line: rgba(242, 241, 238, 0.15);
--line-strong: rgba(242, 241, 238, 0.4);
--accent-ink: #74aaff;
--invert-bg: #ffffff;
--invert-fg: #0b0b0c;
color-scheme: dark;
}
}
@media (min-width: 768px) {
:root {
--pad: 40px;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
background: var(--bg);
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
overflow-x: clip;
}
p,
h1,
figure,
ul {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
::selection {
background: var(--accent);
color: #ffffff;
}
:focus-visible {
outline: 2px solid var(--accent-ink);
outline-offset: 2px;
}
/* ---------- Micro vocabulary ---------- */
.hud-label {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.muted {
color: var(--muted);
}
.faint {
color: var(--faint);
}
.accent-ink {
color: var(--accent-ink);
}
.slash {
margin: 0 0.5rem;
}
/* Crosshair tick straddling a grid intersection. */
.tick {
position: absolute;
width: 11px;
height: 11px;
pointer-events: none;
z-index: 2;
}
.tick::before,
.tick::after {
content: '';
position: absolute;
background: var(--line-strong);
}
.tick::before {
left: 5px;
top: 0;
bottom: 0;
width: 1px;
}
.tick::after {
top: 5px;
left: 0;
right: 0;
height: 1px;
}
/* Half tick for marks sitting on a top edge. */
.tick-half::before {
top: 5px;
}
/* Signature interaction: blocks invert on hover. */
.invert-block {
transition:
background-color 120ms linear,
color 120ms linear;
}
.invert-block:hover {
background: var(--invert-bg);
color: var(--invert-fg);
}
/* ---------- Nav ---------- */
.site-nav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: stretch;
justify-content: space-between;
height: 48px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 95%, transparent);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.nav-logo {
display: flex;
align-items: center;
padding: 0 20px;
border-right: 1px solid var(--line);
color: var(--ink-strong);
}
.nav-logo:hover {
opacity: 0.7;
}
.nav-logo svg {
height: 24px;
width: auto;
}
.nav-links {
display: flex;
align-items: stretch;
}
.nav-link {
display: none;
align-items: center;
padding: 0 10px;
color: var(--muted);
}
.nav-link span {
padding: 2px 6px;
}
.nav-link:hover {
color: var(--ink);
}
.nav-link:hover span {
background: var(--invert-bg);
color: var(--invert-fg);
}
@media (min-width: 640px) {
.nav-link {
display: flex;
}
}
.nav-cta {
display: flex;
align-items: center;
margin-left: 8px;
padding: 0 20px;
background: var(--invert-bg);
color: var(--invert-fg);
font-weight: 700;
}
.nav-cta:hover {
opacity: 0.8;
}
/* ---------- Rails ---------- */
.shell {
position: relative;
border-top: 1px solid var(--line);
}
.rail {
position: relative;
max-width: var(--rail);
margin: 0 auto;
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
}
.eyebrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px var(--pad);
border-bottom: 1px solid var(--line);
}
.hide-sm {
display: none;
}
@media (min-width: 640px) {
.hide-sm {
display: block;
}
}
/* ---------- Hero ---------- */
.hero {
padding: 56px var(--pad) 64px;
}
@media (min-width: 768px) {
.hero {
padding-top: 80px;
padding-bottom: 88px;
}
}
/* Poster wordmark: Archivo at the poster width (wdth 125) with the logo-A
standing in for the first letter — the getartcraft.com hero setting.
The word's advance run measures ~7.0em at this cut, so sizing the font
from the container width justifies it flush across the rail. */
.wordmark-wrap {
container-type: inline-size;
}
.wordmark {
display: flex;
align-items: baseline;
color: var(--ink-strong);
font-family: var(--font-display);
font-weight: 750;
font-stretch: 125%;
font-size: calc(100cqw / 7.05);
line-height: 1;
white-space: nowrap;
animation: fade-up 0.7s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.wordmark-logo {
flex: none;
width: calc(0.7em * 116.34 / 97.5);
height: 0.7em;
margin-right: 0.05em;
align-self: baseline;
transform: translateY(0.006em);
}
.wordmark-sub {
margin-top: 16px;
animation: fade-up 0.7s 0.08s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.headline {
max-width: 48rem;
margin-top: 40px;
color: var(--ink-strong);
font-family: var(--font-display);
font-size: 2.25rem;
font-weight: 620;
font-stretch: 118%;
line-height: 1.02;
letter-spacing: -0.035em;
animation: fade-up 0.7s 0.16s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.headline em {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-stretch: normal;
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.headline {
font-size: 3rem;
}
}
@media (min-width: 768px) {
.headline {
font-size: 3.75rem;
}
}
.ctas {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 32px;
animation: fade-up 0.7s 0.24s cubic-bezier(0.2, 0.9, 0.2, 1) both;
}
.meta {
margin-top: 20px;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
padding: 0 24px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
transition:
background-color 150ms linear,
color 150ms linear,
border-color 150ms linear,
opacity 150ms linear;
}
.btn svg {
width: 16px;
height: 16px;
}
.btn-primary {
background: var(--invert-bg);
color: var(--invert-fg);
}
.btn-primary:hover {
opacity: 0.8;
}
.btn-secondary {
border: 1px solid var(--line-strong);
background: var(--bg);
color: var(--ink);
}
.btn-secondary:hover {
border-color: transparent;
background: var(--invert-bg);
color: var(--invert-fg);
}
/* ---------- Screenshot ---------- */
.shot {
border-top: 1px solid var(--line);
}
.shot-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px var(--pad);
border-bottom: 1px solid var(--line);
}
.shot-frame {
position: relative;
overflow: hidden;
background: var(--bg-sunken);
}
.shot-frame img {
display: block;
width: 100%;
height: auto;
}
.shot-frame .tick {
opacity: 0.6;
}
/* ---------- Socials ---------- */
.socials {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
padding: 0;
list-style: none;
background: var(--line);
}
@media (min-width: 768px) {
.socials {
grid-template-columns: repeat(5, 1fr);
}
}
/* Odd count on the 2-up grid: the last cell spans the row. */
.socials li:last-child {
grid-column: 1 / -1;
}
@media (min-width: 768px) {
.socials li:last-child {
grid-column: auto;
}
}
.social {
display: flex;
align-items: center;
gap: 12px;
height: 100%;
padding: 24px var(--pad);
background: var(--bg);
color: var(--ink);
}
@media (min-width: 768px) {
.social {
padding: 32px 28px;
}
}
.social svg {
flex: none;
width: 20px;
height: 20px;
}
.social-arrow {
margin-left: auto;
color: var(--faint);
font-family: var(--font-mono);
}
.social:hover .social-arrow {
color: inherit;
}
/* ---------- Footer ---------- */
.footer-bar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px var(--pad);
}
@media (min-width: 768px) {
.footer-bar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.footer-mail:hover {
color: var(--ink);
}
/* ---------- Motion ---------- */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0s !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
// Static landing page for desktop.getartcraft.com. No framework: index.html
// plus one stylesheet, so Vite is only here for dev serving and hashed
// production assets.
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/artcraft-desktop-website',
server: {
port: 4260,
host: 'localhost',
},
preview: {
port: 4360,
host: 'localhost',
},
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
},
}));
+1
View File
@@ -9,6 +9,7 @@ export * from "./lib/GenerationApi.js";
export * from "./lib/EngineApi.js";
export * from "./lib/JobsApi.js";
export * from "./lib/MediaFilesApi.js";
export * from "./lib/UserMediaFilesV2Api.js";
export * from "./lib/MediaUploadApi.js";
export * from "./lib/MiscApi.js";
export * from "./lib/LocalApi.js";
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GalleryModalApi } from "./GalleryModalApi";
import { UserMediaFilesV2Api } from "./UserMediaFilesV2Api";
import { FilterMediaClasses } from "./enums/QueryFilters";
const { fetchProxy } = vi.hoisted(() => ({ fetchProxy: vi.fn() }));
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: fetchProxy }));
beforeEach(() => {
fetchProxy.mockReset();
localStorage.clear();
});
describe("versioned user library HTTP contracts", () => {
it("keeps existing clients on v1 with numeric numbered pagination", async () => {
fetchProxy.mockResolvedValue(new Response(JSON.stringify({
success: true, results: [], pagination: { current: 2, total_page_count: 3 },
})));
const response = await new GalleryModalApi().listUserMediaFiles({ username: "fixture_owner", page_index: 2 });
const url = new URL(fetchProxy.mock.calls[0][0]);
expect(url.pathname).toBe("/v1/media_files/list/user/fixture_owner");
expect(url.searchParams.has("include_total_count")).toBe(false);
expect(response.pagination).toEqual({ current: 2, total_page_count: 3 });
expect(response.success).toBe(true);
});
it.each([true, false])("requests count-free pages from list_v2 (has_more: %s)", async (hasMore) => {
fetchProxy.mockResolvedValue(new Response(JSON.stringify({
success: true, results: [], pagination: { current: 1, has_more: hasMore },
})));
const response = await new UserMediaFilesV2Api().ListUserMediaFiles({
username: "fixture_owner", include_user_uploads: true,
filter_media_classes: [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO],
page_size: 60, page_index: 1,
});
const url = new URL(fetchProxy.mock.calls[0][0]);
expect(url.pathname).toBe("/v1/media_files/list_v2/user/fixture_owner");
expect(url.searchParams.has("include_total_count")).toBe(false);
expect(url.searchParams.get("filter_media_classes")).toBe("image,video");
expect(url.searchParams.get("page_size")).toBe("60");
expect(url.searchParams.get("page_index")).toBe("1");
expect(response.pagination).toEqual({ current: 1, has_more: hasMore });
expect(fetchProxy.mock.calls[0][1].credentials).toBe("include");
});
});
@@ -0,0 +1,53 @@
import { ApiManager, type ApiResponse } from "./ApiManager.js";
import type { MediaFile } from "./models/MediaFile.js";
import type { FilterEngineCategories, FilterMediaClasses, FilterMediaType } from "./enums/QueryFilters.js";
export interface ListUserMediaFilesV2Query {
username: string;
sort_ascending?: boolean;
page_size?: number;
page_index?: number;
filter_media_classes?: FilterMediaClasses[];
filter_media_type?: FilterMediaType[];
filter_engine_categories?: FilterEngineCategories[];
include_user_uploads?: boolean;
}
export interface UserMediaFilesV2Pagination {
current: number;
has_more: boolean;
}
/**
* Faster /v1/media_files/list_v2 pages: fetches one extra row for has_more,
* avoiding the full-library count required by the original list endpoint.
*/
export class UserMediaFilesV2Api extends ApiManager {
public async ListUserMediaFiles(
query: ListUserMediaFilesV2Query,
): Promise<ApiResponse<MediaFile[], UserMediaFilesV2Pagination>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list_v2/user/${encodeURIComponent(query.username)}`;
return this.get<{
success: boolean;
results: MediaFile[];
pagination: UserMediaFilesV2Pagination;
}>({
endpoint,
query: {
sort_ascending: query.sort_ascending,
page_size: query.page_size,
page_index: query.page_index,
include_user_uploads: query.include_user_uploads,
filter_media_classes: query.filter_media_classes?.join(","),
filter_media_type: query.filter_media_type?.join(","),
filter_engine_categories: query.filter_engine_categories?.join(","),
},
})
.then((response) => ({
success: response.success,
data: response.results ?? [],
pagination: response.pagination,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
}