diff --git a/.sqlx/query-491980e0a555a900354e60525947d77548a6cb8e4305d13886c8ad7e6787476c.json b/.sqlx/query-491980e0a555a900354e60525947d77548a6cb8e4305d13886c8ad7e6787476c.json deleted file mode 100644 index 6a46fa0102..0000000000 --- a/.sqlx/query-491980e0a555a900354e60525947d77548a6cb8e4305d13886c8ad7e6787476c.json +++ /dev/null @@ -1,157 +0,0 @@ -{ - "db_name": "MySQL", - "query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime`,\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_type,\n mf.media_class,\n mf.maybe_mime_type,\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n mf.maybe_duration_millis,\n mf.maybe_title\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "membership_id: u64", - "type_info": { - "type": "LongLong", - "flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT", - "char_set": 63, - "max_size": 20 - } - }, - { - "ordinal": 1, - "name": "added_to_folder_at: DateTime", - "type_info": { - "type": "Timestamp", - "flags": "NOT_NULL | BINARY | TIMESTAMP", - "char_set": 63, - "max_size": 19 - } - }, - { - "ordinal": 2, - "name": "media_file_token: MediaFileToken", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", - "char_set": 224, - "max_size": 128 - } - }, - { - "ordinal": 3, - "name": "media_type", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 4, - "name": "media_class", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 5, - "name": "maybe_mime_type", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 128 - } - }, - { - "ordinal": 6, - "name": "public_bucket_directory_hash", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", - "char_set": 224, - "max_size": 128 - } - }, - { - "ordinal": 7, - "name": "maybe_public_bucket_prefix", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 8, - "name": "maybe_public_bucket_extension", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 9, - "name": "maybe_frame_width", - "type_info": { - "type": "Long", - "flags": "", - "char_set": 63, - "max_size": 5 - } - }, - { - "ordinal": 10, - "name": "maybe_frame_height", - "type_info": { - "type": "Long", - "flags": "", - "char_set": 63, - "max_size": 5 - } - }, - { - "ordinal": 11, - "name": "maybe_duration_millis", - "type_info": { - "type": "Long", - "flags": "", - "char_set": 63, - "max_size": 10 - } - }, - { - "ordinal": 12, - "name": "maybe_title", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 1020 - } - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - false, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "491980e0a555a900354e60525947d77548a6cb8e4305d13886c8ad7e6787476c" -} diff --git a/.sqlx/query-88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a.json b/.sqlx/query-88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a.json new file mode 100644 index 0000000000..ec55f7dbc1 --- /dev/null +++ b/.sqlx/query-88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a.json @@ -0,0 +1,267 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "membership_id: u64", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT", + "char_set": 63, + "max_size": 20 + } + }, + { + "ordinal": 1, + "name": "added_to_folder_at: DateTime", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 2, + "name": "media_file_token: MediaFileToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 3, + "name": "media_class: MediaFileClass", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 4, + "name": "media_type: MediaFileType", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 5, + "name": "maybe_batch_token: BatchGenerationToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 6, + "name": "public_bucket_directory_hash", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 7, + "name": "maybe_public_bucket_prefix", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 8, + "name": "maybe_public_bucket_extension", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 9, + "name": "maybe_cover_public_bucket_directory_hash?", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 10, + "name": "maybe_cover_public_bucket_prefix?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 11, + "name": "maybe_cover_public_bucket_extension?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 12, + "name": "creator_set_visibility: Visibility", + "type_info": { + "type": "String", + "flags": "NOT_NULL | BINARY | ENUM", + "char_set": 224, + "max_size": 28 + } + }, + { + "ordinal": 13, + "name": "is_user_upload: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL | MULTIPLE_KEY", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 14, + "name": "is_intermediate_system_file: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL | MULTIPLE_KEY", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 15, + "name": "maybe_title", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 16, + "name": "maybe_prompt_token: PromptToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 17, + "name": "maybe_origin_filename", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 18, + "name": "maybe_duration_millis", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 10 + } + }, + { + "ordinal": 19, + "name": "maybe_frame_width", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 20, + "name": "maybe_frame_height", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 21, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 22, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a" +} diff --git a/.sqlx/query-ae417955113b385031181772b29f234ab9297cf583583f74fe287bf9c24b1e6a.json b/.sqlx/query-ae417955113b385031181772b29f234ab9297cf583583f74fe287bf9c24b1e6a.json new file mode 100644 index 0000000000..3f8d3106e2 --- /dev/null +++ b/.sqlx/query-ae417955113b385031181772b29f234ab9297cf583583f74fe287bf9c24b1e6a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE folders\nSET\n maybe_last_media_file_token_1 = (\n SELECT mf.token\n FROM folder_media_files fmf\n JOIN media_files mf\n ON mf.token = fmf.media_file_token\n WHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n ORDER BY fmf.id DESC\n LIMIT 1 OFFSET 0\n ),\n maybe_last_media_file_token_2 = (\n SELECT mf.token\n FROM folder_media_files fmf\n JOIN media_files mf\n ON mf.token = fmf.media_file_token\n WHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n ORDER BY fmf.id DESC\n LIMIT 1 OFFSET 1\n ),\n maybe_last_media_file_token_3 = (\n SELECT mf.token\n FROM folder_media_files fmf\n JOIN media_files mf\n ON mf.token = fmf.media_file_token\n WHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n ORDER BY fmf.id DESC\n LIMIT 1 OFFSET 2\n ),\n maybe_last_media_file_token_4 = (\n SELECT mf.token\n FROM folder_media_files fmf\n JOIN media_files mf\n ON mf.token = fmf.media_file_token\n WHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n ORDER BY fmf.id DESC\n LIMIT 1 OFFSET 3\n )\nWHERE token = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "ae417955113b385031181772b29f234ab9297cf583583f74fe287bf9c24b1e6a" +} diff --git a/.sqlx/query-d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211.json b/.sqlx/query-d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211.json new file mode 100644 index 0000000000..08fca70a35 --- /dev/null +++ b/.sqlx/query-d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211.json @@ -0,0 +1,267 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "membership_id: u64", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT", + "char_set": 63, + "max_size": 20 + } + }, + { + "ordinal": 1, + "name": "added_to_folder_at: DateTime", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 2, + "name": "media_file_token: MediaFileToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 3, + "name": "media_class: MediaFileClass", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 4, + "name": "media_type: MediaFileType", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 5, + "name": "maybe_batch_token: BatchGenerationToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 6, + "name": "public_bucket_directory_hash", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 7, + "name": "maybe_public_bucket_prefix", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 8, + "name": "maybe_public_bucket_extension", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 9, + "name": "maybe_cover_public_bucket_directory_hash?", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 10, + "name": "maybe_cover_public_bucket_prefix?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 11, + "name": "maybe_cover_public_bucket_extension?", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 12, + "name": "creator_set_visibility: Visibility", + "type_info": { + "type": "String", + "flags": "NOT_NULL | BINARY | ENUM", + "char_set": 224, + "max_size": 28 + } + }, + { + "ordinal": 13, + "name": "is_user_upload: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL | MULTIPLE_KEY", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 14, + "name": "is_intermediate_system_file: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL | MULTIPLE_KEY", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 15, + "name": "maybe_title", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 16, + "name": "maybe_prompt_token: PromptToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 17, + "name": "maybe_origin_filename", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 18, + "name": "maybe_duration_millis", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 10 + } + }, + { + "ordinal": 19, + "name": "maybe_frame_width", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 20, + "name": "maybe_frame_height", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 5 + } + }, + { + "ordinal": 21, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 22, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211" +} diff --git a/.sqlx/query-ea5f5e5b3caf990e9c25a3e8fe2e9625c12d6a36dd5dd91fc4615a45acdd33b8.json b/.sqlx/query-ea5f5e5b3caf990e9c25a3e8fe2e9625c12d6a36dd5dd91fc4615a45acdd33b8.json deleted file mode 100644 index f9f21ca599..0000000000 --- a/.sqlx/query-ea5f5e5b3caf990e9c25a3e8fe2e9625c12d6a36dd5dd91fc4615a45acdd33b8.json +++ /dev/null @@ -1,157 +0,0 @@ -{ - "db_name": "MySQL", - "query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime`,\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_type,\n mf.media_class,\n mf.maybe_mime_type,\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n mf.maybe_duration_millis,\n mf.maybe_title\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "membership_id: u64", - "type_info": { - "type": "LongLong", - "flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT", - "char_set": 63, - "max_size": 20 - } - }, - { - "ordinal": 1, - "name": "added_to_folder_at: DateTime", - "type_info": { - "type": "Timestamp", - "flags": "NOT_NULL | BINARY | TIMESTAMP", - "char_set": 63, - "max_size": 19 - } - }, - { - "ordinal": 2, - "name": "media_file_token: MediaFileToken", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", - "char_set": 224, - "max_size": 128 - } - }, - { - "ordinal": 3, - "name": "media_type", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 4, - "name": "media_class", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 5, - "name": "maybe_mime_type", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 128 - } - }, - { - "ordinal": 6, - "name": "public_bucket_directory_hash", - "type_info": { - "type": "VarString", - "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", - "char_set": 224, - "max_size": 128 - } - }, - { - "ordinal": 7, - "name": "maybe_public_bucket_prefix", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 8, - "name": "maybe_public_bucket_extension", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 64 - } - }, - { - "ordinal": 9, - "name": "maybe_frame_width", - "type_info": { - "type": "Long", - "flags": "", - "char_set": 63, - "max_size": 5 - } - }, - { - "ordinal": 10, - "name": "maybe_frame_height", - "type_info": { - "type": "Long", - "flags": "", - "char_set": 63, - "max_size": 5 - } - }, - { - "ordinal": 11, - "name": "maybe_duration_millis", - "type_info": { - "type": "Long", - "flags": "", - "char_set": 63, - "max_size": 10 - } - }, - { - "ordinal": 12, - "name": "maybe_title", - "type_info": { - "type": "VarString", - "flags": "BINARY", - "char_set": 224, - "max_size": 1020 - } - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - false, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "ea5f5e5b3caf990e9c25a3e8fe2e9625c12d6a36dd5dd91fc4615a45acdd33b8" -} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs index ce0aaad76c..0c6a9c0051 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/common.rs @@ -1,11 +1,19 @@ use chrono::{DateTime, Utc}; use serde_derive::{Deserialize, Serialize}; +use url::Url; use utoipa::ToSchema; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; use tokens::tokens::users::UserToken; +// NB: The "folder media file" list-item shape lives in storyteller_web +// (see `endpoints/folders/media_files/list_folder_media_files_handler.rs`) +// because it embeds `MediaFileCoverImageDetails` and other domain types +// that depend on the request's `MediaDomain` + `ServerEnvironment`. + /// Canonical wire shape for a folder. Used by single-folder GETs, create /// responses, list rows, and subfolder list rows. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] @@ -15,12 +23,16 @@ pub struct FolderInfo { pub owner_user_token: UserToken, pub maybe_parent_folder_token: Option, - pub maybe_last_media_file_token_1: Option, - pub maybe_last_media_file_token_2: Option, - pub maybe_last_media_file_token_3: Option, - pub maybe_last_media_file_token_4: Option, + /// The four most-recent media files added to the folder, in slot order + /// (the schema stores them as `maybe_last_media_file_token_1..4`). + /// Slots whose media file has been deleted are skipped, so the vec + /// may contain 0-4 entries. + pub last_media_thumbnails: Vec, - pub maybe_cover_image_custom_media_token: Option, + /// User-chosen cover image. When present, the frontend should use this + /// as the folder's primary thumbnail instead of the + /// `last_media_thumbnails` grid. + pub maybe_custom_cover_thumbnail: Option, pub maybe_color_code: Option, pub has_star: bool, @@ -33,22 +45,25 @@ pub struct FolderInfo { pub is_orphaned: bool, } -/// Canonical wire shape for a media file inside a folder. Lean enough for -/// thumbnail rendering; callers that need richer fields should hit the -/// media-file batch-get endpoint. +/// A compact thumbnail descriptor for a media file referenced by a +/// folder (either one of the `last_media_*` slots or the custom cover). +/// +/// Intentionally narrower than `MediaLinks` / `CoverImageLinks` — folders +/// only need the bare minimum to render a thumbnail card. The CDN URL is +/// the direct asset link; `maybe_thumbnail_template` carries the resizing +/// template URL (with `{{width}}` / `{{height}}` placeholders) when the +/// media class supports it. #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct FolderMediaFileInfo { - pub media_file_token: MediaFileToken, - pub added_to_folder_at: DateTime, +pub struct FolderThumbnail { + pub token: MediaFileToken, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, - pub media_type: String, - pub media_class: String, - pub maybe_mime_type: Option, - pub public_bucket_directory_hash: String, - pub maybe_public_bucket_prefix: Option, - pub maybe_public_bucket_extension: Option, - pub maybe_frame_width: Option, - pub maybe_frame_height: Option, - pub maybe_duration_millis: Option, - pub maybe_title: Option, + /// Direct CDN URL to the underlying media asset. + pub cdn_url: Url, + + /// Resizing template URL (with `{{width}}` / `{{height}}` placeholders) + /// when the media class supports it. `None` for media classes without + /// a thumbnail variant. + pub maybe_thumbnail_template: Option, } diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs index 5af1111fcd..3a98e06492 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/folders/media_files.rs @@ -4,9 +4,12 @@ use utoipa::{IntoParams, ToSchema}; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; -use crate::folders::common::FolderMediaFileInfo; - // ── GET /v1/folders/media_files/{folder_token} ── +// +// NB: `ListFolderMediaFilesSuccessResponse` and `FolderMediaFileListItem` +// live in `storyteller_web`'s handler because the wire shape embeds +// `MediaFileCoverImageDetails` and `MediaLinks` constructors that depend +// on the request's `MediaDomain` + `ServerEnvironment`. #[derive(Deserialize, ToSchema)] pub struct FolderMediaFilesPathInfo { @@ -19,13 +22,6 @@ pub struct ListFolderMediaFilesQueryParams { pub limit: Option, } -#[derive(Serialize, ToSchema)] -pub struct ListFolderMediaFilesSuccessResponse { - pub success: bool, - pub media_files: Vec, - pub maybe_cursor: Option, -} - // ── PUT /v1/folders/media_files/{folder_token}/bulk_add ── #[derive(Deserialize, ToSchema)] diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs index 62c5e31f38..b04946524c 100644 --- a/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/list_folder_media_files.rs @@ -3,29 +3,57 @@ use std::marker::PhantomData; use chrono::{DateTime, Utc}; use sqlx::{Executor, MySql}; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; +use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::folders::FolderToken; use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +/// One folder-media-file row + the joined media-file fields the list +/// endpoint needs to materialize MediaLinks, MediaFileCoverImageDetails, +/// and the rest of the typed response shape. #[derive(Debug, Clone)] pub struct FolderMediaFileRow { - /// The `folder_media_files.id` of the membership row. Use this as the - /// cursor for the next page. + /// `folder_media_files.id` — used as the pagination cursor. pub membership_id: u64, /// When the media file was added to the folder. pub added_to_folder_at: DateTime, pub media_file_token: MediaFileToken, - pub media_type: String, - pub media_class: String, - pub maybe_mime_type: Option, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + + pub maybe_batch_token: Option, + + // The media file's own bucket fields (caller uses these to build MediaLinks). pub public_bucket_directory_hash: String, pub maybe_public_bucket_prefix: Option, pub maybe_public_bucket_extension: Option, + + // Cover image's bucket fields, from a LEFT JOIN on + // mf.maybe_cover_image_media_file_token. All three are Some-or-None + // together (matched / unmatched join row). + pub maybe_cover_public_bucket_directory_hash: Option, + pub maybe_cover_public_bucket_prefix: Option, + pub maybe_cover_public_bucket_extension: Option, + + pub creator_set_visibility: Visibility, + pub is_user_upload: bool, + pub is_intermediate_system_file: bool, + + pub maybe_title: Option, + pub maybe_prompt_token: Option, + pub maybe_origin_filename: Option, + + pub maybe_duration_millis: Option, pub maybe_frame_width: Option, pub maybe_frame_height: Option, - pub maybe_duration_millis: Option, - pub maybe_title: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, } pub struct ListFolderMediaFilesArgs<'e, 'c, E> @@ -40,9 +68,12 @@ where } /// Paginated list of media files in the given folder. Joins `media_files` -/// and filters out rows that are soft-deleted on the media-file side. -/// Most-recently-added first. The caller is expected to have already -/// authorized the folder access. +/// for the file's own metadata and LEFT JOINs `media_files` again on +/// `maybe_cover_image_media_file_token` so the cover-image bucket fields +/// come back in a single round-trip. Filters out media files that are +/// soft-deleted on the media-file side. Most-recently-added first. +/// +/// The caller is expected to have already authorized the folder access. pub async fn list_folder_media_files<'e, 'c: 'e, E>( args: ListFolderMediaFilesArgs<'e, 'c, E>, ) -> Result, sqlx::Error> @@ -58,20 +89,40 @@ where SELECT fmf.id as `membership_id: u64`, fmf.created_at as `added_to_folder_at: DateTime`, + mf.token as `media_file_token: MediaFileToken`, - mf.media_type, - mf.media_class, - mf.maybe_mime_type, + mf.media_class as `media_class: MediaFileClass`, + mf.media_type as `media_type: MediaFileType`, + + mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`, + mf.public_bucket_directory_hash, mf.maybe_public_bucket_prefix, mf.maybe_public_bucket_extension, + + cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`, + cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`, + cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`, + + mf.creator_set_visibility as `creator_set_visibility: Visibility`, + mf.is_user_upload as `is_user_upload: bool`, + mf.is_intermediate_system_file as `is_intermediate_system_file: bool`, + + mf.maybe_title, + mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`, + mf.maybe_origin_filename, + + mf.maybe_duration_millis, mf.maybe_frame_width, mf.maybe_frame_height, - mf.maybe_duration_millis, - mf.maybe_title + + mf.created_at, + mf.updated_at FROM folder_media_files fmf JOIN media_files mf ON mf.token = fmf.media_file_token +LEFT JOIN media_files cover + ON cover.token = mf.maybe_cover_image_media_file_token WHERE fmf.folder_token = ? AND mf.user_deleted_at IS NULL AND mf.mod_deleted_at IS NULL @@ -90,16 +141,26 @@ LIMIT ? membership_id: r.membership_id, added_to_folder_at: r.added_to_folder_at, media_file_token: r.media_file_token, - media_type: r.media_type, media_class: r.media_class, - maybe_mime_type: r.maybe_mime_type, + media_type: r.media_type, + maybe_batch_token: r.maybe_batch_token, public_bucket_directory_hash: r.public_bucket_directory_hash, maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, + maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, + maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, + creator_set_visibility: r.creator_set_visibility, + is_user_upload: r.is_user_upload, + is_intermediate_system_file: r.is_intermediate_system_file, + maybe_title: r.maybe_title, + maybe_prompt_token: r.maybe_prompt_token, + maybe_origin_filename: r.maybe_origin_filename, + maybe_duration_millis: r.maybe_duration_millis, maybe_frame_width: r.maybe_frame_width, maybe_frame_height: r.maybe_frame_height, - maybe_duration_millis: r.maybe_duration_millis, - maybe_title: r.maybe_title, + created_at: r.created_at, + updated_at: r.updated_at, }) .collect::>() } @@ -109,20 +170,40 @@ LIMIT ? SELECT fmf.id as `membership_id: u64`, fmf.created_at as `added_to_folder_at: DateTime`, + mf.token as `media_file_token: MediaFileToken`, - mf.media_type, - mf.media_class, - mf.maybe_mime_type, + mf.media_class as `media_class: MediaFileClass`, + mf.media_type as `media_type: MediaFileType`, + + mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`, + mf.public_bucket_directory_hash, mf.maybe_public_bucket_prefix, mf.maybe_public_bucket_extension, + + cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`, + cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`, + cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`, + + mf.creator_set_visibility as `creator_set_visibility: Visibility`, + mf.is_user_upload as `is_user_upload: bool`, + mf.is_intermediate_system_file as `is_intermediate_system_file: bool`, + + mf.maybe_title, + mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`, + mf.maybe_origin_filename, + + mf.maybe_duration_millis, mf.maybe_frame_width, mf.maybe_frame_height, - mf.maybe_duration_millis, - mf.maybe_title + + mf.created_at, + mf.updated_at FROM folder_media_files fmf JOIN media_files mf ON mf.token = fmf.media_file_token +LEFT JOIN media_files cover + ON cover.token = mf.maybe_cover_image_media_file_token WHERE fmf.folder_token = ? AND mf.user_deleted_at IS NULL AND mf.mod_deleted_at IS NULL @@ -139,16 +220,26 @@ LIMIT ? membership_id: r.membership_id, added_to_folder_at: r.added_to_folder_at, media_file_token: r.media_file_token, - media_type: r.media_type, media_class: r.media_class, - maybe_mime_type: r.maybe_mime_type, + media_type: r.media_type, + maybe_batch_token: r.maybe_batch_token, public_bucket_directory_hash: r.public_bucket_directory_hash, maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, maybe_public_bucket_extension: r.maybe_public_bucket_extension, + maybe_cover_public_bucket_directory_hash: r.maybe_cover_public_bucket_directory_hash, + maybe_cover_public_bucket_prefix: r.maybe_cover_public_bucket_prefix, + maybe_cover_public_bucket_extension: r.maybe_cover_public_bucket_extension, + creator_set_visibility: r.creator_set_visibility, + is_user_upload: r.is_user_upload, + is_intermediate_system_file: r.is_intermediate_system_file, + maybe_title: r.maybe_title, + maybe_prompt_token: r.maybe_prompt_token, + maybe_origin_filename: r.maybe_origin_filename, + maybe_duration_millis: r.maybe_duration_millis, maybe_frame_width: r.maybe_frame_width, maybe_frame_height: r.maybe_frame_height, - maybe_duration_millis: r.maybe_duration_millis, - maybe_title: r.maybe_title, + created_at: r.created_at, + updated_at: r.updated_at, }) .collect::>() } diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs index 66c52f360b..e237e2c553 100644 --- a/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/mod.rs @@ -2,3 +2,4 @@ pub mod bulk_delete_folder_media_files; pub mod bulk_insert_folder_media_files; pub mod filter_existing_media_file_tokens; pub mod list_folder_media_files; +pub mod recompute_folder_last_media_files; diff --git a/crates/schema/database/mysql_queries/src/queries/folders/media_files/recompute_folder_last_media_files.rs b/crates/schema/database/mysql_queries/src/queries/folders/media_files/recompute_folder_last_media_files.rs new file mode 100644 index 0000000000..4a2f926367 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/folders/media_files/recompute_folder_last_media_files.rs @@ -0,0 +1,99 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::folders::FolderToken; + +pub struct RecomputeFolderLastMediaFilesArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub folder_token: &'e FolderToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Recompute the four denormalized `maybe_last_media_file_token_1..4` +/// columns on `folders` for the given folder by scanning the current +/// (non-soft-deleted) members of `folder_media_files` and grabbing the +/// four with the largest `folder_media_files.id` (most-recently-added). +/// +/// Single-statement: four correlated `LIMIT 1 OFFSET k` scalar subqueries +/// in the SET clause. Each subquery walks the `index_folder_token` index +/// on `folder_media_files`, joins `media_files` by primary key, and +/// stops at the (offset+1)-th matching row. Folders with fewer than four +/// live members get NULL automatically for the empty slots — that's the +/// scalar-subquery / no-rows-found semantics. +/// +/// Intended to run inside the same transaction as the bulk add / remove / +/// move that necessitated the recompute, so the cached columns and the +/// real membership stay consistent under concurrent writers. +pub async fn recompute_folder_last_media_files<'e, 'c: 'e, E>( + args: RecomputeFolderLastMediaFilesArgs<'e, 'c, E>, +) -> Result<(), sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let folder_token_str = args.folder_token.as_str(); + + sqlx::query!( + r#" +UPDATE folders +SET + maybe_last_media_file_token_1 = ( + SELECT mf.token + FROM folder_media_files fmf + JOIN media_files mf + ON mf.token = fmf.media_file_token + WHERE fmf.folder_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + ORDER BY fmf.id DESC + LIMIT 1 OFFSET 0 + ), + maybe_last_media_file_token_2 = ( + SELECT mf.token + FROM folder_media_files fmf + JOIN media_files mf + ON mf.token = fmf.media_file_token + WHERE fmf.folder_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + ORDER BY fmf.id DESC + LIMIT 1 OFFSET 1 + ), + maybe_last_media_file_token_3 = ( + SELECT mf.token + FROM folder_media_files fmf + JOIN media_files mf + ON mf.token = fmf.media_file_token + WHERE fmf.folder_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + ORDER BY fmf.id DESC + LIMIT 1 OFFSET 2 + ), + maybe_last_media_file_token_4 = ( + SELECT mf.token + FROM folder_media_files fmf + JOIN media_files mf + ON mf.token = fmf.media_file_token + WHERE fmf.folder_token = ? + AND mf.user_deleted_at IS NULL + AND mf.mod_deleted_at IS NULL + ORDER BY fmf.id DESC + LIMIT 1 OFFSET 3 + ) +WHERE token = ? + "#, + folder_token_str, + folder_token_str, + folder_token_str, + folder_token_str, + folder_token_str, + ) + .execute(args.mysql_executor) + .await?; + + Ok(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs b/crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs new file mode 100644 index 0000000000..47588ab44e --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/media_files/get/batch_get_media_file_thumbnails_by_tokens.rs @@ -0,0 +1,91 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql, QueryBuilder}; + +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use tokens::tokens::media_files::MediaFileToken; + +/// Minimal columns the folder-thumbnail builder needs from `media_files`. +#[derive(Debug, Clone)] +pub struct MediaFileThumbnailRow { + pub token: MediaFileToken, + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, +} + +pub struct BatchGetMediaFileThumbnailsByTokensArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub candidate_tokens: &'e [MediaFileToken], + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up the bucket-path / class / type for each of the given media +/// file tokens. Silently drops rows that don't exist or are soft-deleted. +/// Caller is responsible for de-duplicating the input set if needed; the +/// SQL doesn't care about duplicates. +/// +/// Uses runtime `QueryBuilder` because the IN-list size is dynamic. +pub async fn batch_get_media_file_thumbnails_by_tokens<'e, 'c: 'e, E>( + args: BatchGetMediaFileThumbnailsByTokensArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + if args.candidate_tokens.is_empty() { + return Ok(Vec::new()); + } + + let mut builder = QueryBuilder::::new( + "SELECT \ + token, \ + media_class, \ + media_type, \ + public_bucket_directory_hash, \ + maybe_public_bucket_prefix, \ + maybe_public_bucket_extension \ + FROM media_files \ + WHERE user_deleted_at IS NULL \ + AND mod_deleted_at IS NULL \ + AND token IN (", + ); + + let mut separated = builder.separated(", "); + for token in args.candidate_tokens { + separated.push_bind(token.as_str()); + } + separated.push_unseparated(")"); + + let rows = builder + .build_query_as::() + .fetch_all(args.mysql_executor) + .await?; + + Ok(rows.into_iter().map(|r| MediaFileThumbnailRow { + token: MediaFileToken::new(r.token), + media_class: MediaFileClass::from_str(&r.media_class).unwrap_or(MediaFileClass::Image), + media_type: MediaFileType::from_str(&r.media_type).unwrap_or(MediaFileType::Image), + public_bucket_directory_hash: r.public_bucket_directory_hash, + maybe_public_bucket_prefix: r.maybe_public_bucket_prefix, + maybe_public_bucket_extension: r.maybe_public_bucket_extension, + }).collect()) +} + +/// Internal row shape used by the dynamic `QueryBuilder` call. We hand-roll +/// the deserialization here because `QueryBuilder` can't ride the +/// compile-time type checks of `sqlx::query!`/`query_as!`. +#[derive(Debug, sqlx::FromRow)] +struct RawRow { + token: String, + media_class: String, + media_type: String, + public_bucket_directory_hash: String, + maybe_public_bucket_prefix: Option, + maybe_public_bucket_extension: Option, +} diff --git a/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs b/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs index 62a0202f3f..f860ee36f4 100644 --- a/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/media_files/get/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_get_media_file_thumbnails_by_tokens; pub mod batch_get_media_files; pub mod batch_get_media_files_by_tokens; pub mod get_media_file; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 206ff27bc9..70cfa28bf7 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -1246,7 +1246,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Folders FolderInfo, - FolderMediaFileInfo, + FolderThumbnail, + FolderMediaFileListItem, FolderPathInfo, FolderMediaFilesPathInfo, SubfolderPathInfo, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs index 1fa9a3fbf8..b922986788 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/create_folder_handler.rs @@ -13,7 +13,10 @@ use mysql_queries::queries::folders::folder::get_folder_for_owner::{ use mysql_queries::queries::folders::folder::insert_folder::{insert_folder, InsertFolderArgs}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; @@ -101,7 +104,20 @@ pub async fn create_folder_handler( })? .ok_or_else(|| CommonWebError::server_error_with_message("folder vanished after insert"))?; - let folder: FolderInfo = folder_row_to_info(row); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + std::slice::from_ref(&row), + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let folder: FolderInfo = folder_row_to_info(row, &thumbnails); Ok(Json(CreateFolderSuccessResponse { success: true, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs index c78579e677..7d1100da7b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/folder_info_conversion.rs @@ -1,18 +1,87 @@ -use artcraft_api_defs::folders::common::{FolderInfo, FolderMediaFileInfo}; +use std::collections::{HashMap, HashSet}; +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail}; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; use mysql_queries::queries::folders::folder::folder_row::FolderRow; -use mysql_queries::queries::folders::media_files::list_folder_media_files::FolderMediaFileRow; +use mysql_queries::queries::media_files::get::batch_get_media_file_thumbnails_by_tokens::{ + batch_get_media_file_thumbnails_by_tokens, BatchGetMediaFileThumbnailsByTokensArgs, + MediaFileThumbnailRow, +}; +use server_environment::ServerEnvironment; +use tokens::tokens::media_files::MediaFileToken; + +use crate::http_server::common_responses::media::media_domain::MediaDomain; +use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; + +/// Gather every media-file token referenced by the given folder rows +/// (the four `last_media_*` slots + the custom cover) and batch-fetch +/// the thumbnail descriptors so we can build [`FolderInfo`]s in one go. +/// +/// Returns a token → thumbnail map. Tokens for media files that don't +/// exist or are soft-deleted are silently absent from the map; the +/// caller treats them as "slot skipped". +pub async fn build_folder_thumbnails_lookup<'e, 'c: 'e, E>( + folder_rows: &[FolderRow], + mysql_executor: E, + media_domain: MediaDomain, + server_environment: ServerEnvironment, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql> + 'c, +{ + let candidate_tokens = collect_candidate_thumbnail_tokens(folder_rows); + + if candidate_tokens.is_empty() { + return Ok(HashMap::new()); + } + + let thumbnail_rows = batch_get_media_file_thumbnails_by_tokens( + BatchGetMediaFileThumbnailsByTokensArgs { + candidate_tokens: &candidate_tokens, + mysql_executor, + phantom: PhantomData, + }, + ).await?; + + Ok(thumbnail_rows.into_iter() + .map(|row| { + let token = row.token.clone(); + (token, build_folder_thumbnail(row, media_domain, server_environment)) + }) + .collect()) +} + +pub fn folder_row_to_info( + row: FolderRow, + thumbnails_by_token: &HashMap, +) -> FolderInfo { + // Vec rather than array so `.into_iter()` yields owned `Option`s on + // every edition. + let last_media_thumbnails: Vec = vec![ + row.maybe_last_media_file_token_1, + row.maybe_last_media_file_token_2, + row.maybe_last_media_file_token_3, + row.maybe_last_media_file_token_4, + ] + .into_iter() + .filter_map(|maybe_token| { + maybe_token.and_then(|t| thumbnails_by_token.get(&t).cloned()) + }) + .collect(); + + let maybe_custom_cover_thumbnail = row.maybe_cover_image_custom_media_token + .and_then(|t| thumbnails_by_token.get(&t).cloned()); -pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { FolderInfo { token: row.token, name: row.name, owner_user_token: row.owner_user_token, maybe_parent_folder_token: row.maybe_parent_folder_token, - maybe_last_media_file_token_1: row.maybe_last_media_file_token_1, - maybe_last_media_file_token_2: row.maybe_last_media_file_token_2, - maybe_last_media_file_token_3: row.maybe_last_media_file_token_3, - maybe_last_media_file_token_4: row.maybe_last_media_file_token_4, - maybe_cover_image_custom_media_token: row.maybe_cover_image_custom_media_token, + last_media_thumbnails, + maybe_custom_cover_thumbnail, maybe_color_code: row.maybe_color_code, has_star: row.has_star, created_at: row.created_at, @@ -21,19 +90,52 @@ pub fn folder_row_to_info(row: FolderRow) -> FolderInfo { } } -pub fn folder_media_file_row_to_info(row: FolderMediaFileRow) -> FolderMediaFileInfo { - FolderMediaFileInfo { - media_file_token: row.media_file_token, - added_to_folder_at: row.added_to_folder_at, - media_type: row.media_type, +fn collect_candidate_thumbnail_tokens(rows: &[FolderRow]) -> Vec { + // Dedup with a `HashSet` so a folder that reuses the same token across + // multiple slots (or two folders that share a thumbnail) doesn't pad + // the IN-list bound parameter list. + let mut seen: HashSet = HashSet::with_capacity(rows.len() * 5); + for row in rows { + for maybe_token in [ + row.maybe_last_media_file_token_1.as_ref(), + row.maybe_last_media_file_token_2.as_ref(), + row.maybe_last_media_file_token_3.as_ref(), + row.maybe_last_media_file_token_4.as_ref(), + row.maybe_cover_image_custom_media_token.as_ref(), + ] { + if let Some(token) = maybe_token { + seen.insert(token.clone()); + } + } + } + seen.into_iter().collect() +} + +fn build_folder_thumbnail( + row: MediaFileThumbnailRow, + media_domain: MediaDomain, + server_environment: ServerEnvironment, +) -> FolderThumbnail { + let bucket_path = MediaFileBucketPath::from_object_hash( + &row.public_bucket_directory_hash, + row.maybe_public_bucket_prefix.as_deref(), + row.maybe_public_bucket_extension.as_deref(), + ); + + // Reuse `MediaLinksBuilder` to produce the CDN URL + thumbnail + // template, then pull just those two fields onto the compact + // FolderThumbnail wire shape. + let media_links = MediaLinksBuilder::from_media_path_and_env( + media_domain, + server_environment, + &bucket_path, + ); + + FolderThumbnail { + token: row.token, media_class: row.media_class, - maybe_mime_type: row.maybe_mime_type, - public_bucket_directory_hash: row.public_bucket_directory_hash, - maybe_public_bucket_prefix: row.maybe_public_bucket_prefix, - maybe_public_bucket_extension: row.maybe_public_bucket_extension, - maybe_frame_width: row.maybe_frame_width, - maybe_frame_height: row.maybe_frame_height, - maybe_duration_millis: row.maybe_duration_millis, - maybe_title: row.maybe_title, + media_type: row.media_type, + cdn_url: media_links.cdn_url, + maybe_thumbnail_template: media_links.maybe_thumbnail_template, } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs index 6926ec4f27..d808b219ee 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/get_folder_handler.rs @@ -12,7 +12,10 @@ use mysql_queries::queries::folders::folder::get_folder_for_owner::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; @@ -43,7 +46,6 @@ pub async fn get_folder_handler( &http_request, &server_state.session_checker, &mut conn, ).await.map_err(|_| CommonWebError::NotAuthorized)?; - let row = get_folder_for_owner(GetFolderForOwnerArgs { folder_token: &path.folder_token, owner_user_token: &user_session.user_token, @@ -55,8 +57,21 @@ pub async fn get_folder_handler( })? .ok_or(CommonWebError::NotFound)?; + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + std::slice::from_ref(&row), + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + Ok(Json(GetFolderSuccessResponse { success: true, - folder: folder_row_to_info(row), + folder: folder_row_to_info(row, &thumbnails), })) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs index 001e34601b..9805075274 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/folder/list_folders_handler.rs @@ -13,7 +13,10 @@ use mysql_queries::queries::folders::folder::list_folders_for_user::{ }; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; @@ -82,7 +85,22 @@ pub async fn list_folders_handler( CommonWebError::server_error_with_message("Failed to encode cursor") })?; - let folders = rows.into_iter().map(folder_row_to_info).collect(); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + &rows, + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let folders = rows.into_iter() + .map(|row| folder_row_to_info(row, &thumbnails)) + .collect(); Ok(Json(ListFoldersSuccessResponse { success: true, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs index 77964f35e5..f5ce0f0d03 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_add_folder_media_files_handler.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use actix_web::web::{Json, Path}; use actix_web::{web, HttpRequest}; -use log::warn; +use log::{error, warn}; +use sqlx::pool::PoolConnection; +use sqlx::{Acquire, MySql, Transaction}; +use tokens::tokens::media_files::MediaFileToken; use artcraft_api_defs::folders::media_files::{ BulkAddFolderMediaFilesRequest, BulkAddFolderMediaFilesSuccessResponse, @@ -18,6 +21,9 @@ use mysql_queries::queries::folders::media_files::bulk_insert_folder_media_files use mysql_queries::queries::folders::media_files::filter_existing_media_file_tokens::{ filter_existing_media_file_tokens, FilterExistingMediaFileTokensArgs, }; +use mysql_queries::queries::folders::media_files::recompute_folder_last_media_files::{ + recompute_folder_last_media_files, RecomputeFolderLastMediaFilesArgs, +}; use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; @@ -88,16 +94,10 @@ pub async fn bulk_add_folder_media_files_handler( CommonWebError::from_error(err) })?; + // Empty `accepted` is a no-op — no membership change means no + // recompute needed and no transaction to open. if !accepted.is_empty() { - bulk_insert_folder_media_files(BulkInsertFolderMediaFilesArgs { - folder_token: &path.folder_token, - media_file_tokens: &accepted, - mysql_executor: &mut *conn, - phantom: PhantomData, - }).await.map_err(|err| { - warn!("bulk_insert_folder_media_files failed: {:?}", err); - CommonWebError::from_error(err) - })?; + perform_atomic_add(&mut conn, &path.folder_token, &accepted).await?; } Ok(Json(BulkAddFolderMediaFilesSuccessResponse { @@ -105,3 +105,68 @@ pub async fn bulk_add_folder_media_files_handler( accepted_media_file_tokens: accepted, })) } + +/// Open a transaction, run the insert + recompute as one unit, and +/// commit on success. On any failure inside [`perform_add_work`] the +/// transaction is explicitly rolled back before the original error is +/// re-raised — sqlx would roll back on drop too, but doing it explicitly +/// makes the failure path obvious and surfaces any rollback error in +/// the log. +async fn perform_atomic_add( + conn: &mut PoolConnection, + folder_token: &FolderToken, + media_file_tokens: &[MediaFileToken], +) -> Result<(), CommonWebError> { + let mut tx = conn.begin().await.map_err(|err| { + warn!("Failed to begin transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + let work_result = perform_add_work(&mut tx, folder_token, media_file_tokens).await; + + match work_result { + Ok(()) => { + tx.commit().await.map_err(|err| { + warn!("Failed to commit bulk_add transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + Ok(()) + } + Err(err) => { + if let Err(rollback_err) = tx.rollback().await { + error!( + "Rollback after bulk_add failure also failed: {:?} (original error: {:?})", + rollback_err, err, + ); + } + Err(err) + } + } +} + +async fn perform_add_work( + tx: &mut Transaction<'_, MySql>, + folder_token: &FolderToken, + media_file_tokens: &[MediaFileToken], +) -> Result<(), CommonWebError> { + bulk_insert_folder_media_files(BulkInsertFolderMediaFilesArgs { + folder_token, + media_file_tokens, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("bulk_insert_folder_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + recompute_folder_last_media_files(RecomputeFolderLastMediaFilesArgs { + folder_token, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("recompute_folder_last_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(()) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs index 545c8f2b93..2b57dee5a7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_move_folder_media_files_handler.rs @@ -24,6 +24,9 @@ use mysql_queries::queries::folders::media_files::bulk_insert_folder_media_files use mysql_queries::queries::folders::media_files::filter_existing_media_file_tokens::{ filter_existing_media_file_tokens, FilterExistingMediaFileTokensArgs, }; +use mysql_queries::queries::folders::media_files::recompute_folder_last_media_files::{ + recompute_folder_last_media_files, RecomputeFolderLastMediaFilesArgs, +}; use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; @@ -229,6 +232,28 @@ async fn perform_move_work( })? }; + // Recompute the denormalized `maybe_last_media_file_token_1..4` on + // BOTH folders inside the same transaction. The source needs it because + // we may have removed one of its cached recent items; the destination + // needs it because we just added new recent items. + recompute_folder_last_media_files(RecomputeFolderLastMediaFilesArgs { + folder_token: source_folder_token, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("recompute_folder_last_media_files (source) failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + recompute_folder_last_media_files(RecomputeFolderLastMediaFilesArgs { + folder_token: destination_folder_token, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("recompute_folder_last_media_files (destination) failed: {:?}", err); + CommonWebError::from_error(err) + })?; + Ok(MoveOutcome { removed_from_source_count, added_to_destination_count, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs index 0e681bdc5c..57b47c8103 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/bulk_remove_folder_media_files_handler.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use actix_web::web::{Json, Path}; use actix_web::{web, HttpRequest}; -use log::warn; +use log::{error, warn}; use artcraft_api_defs::folders::media_files::{ BulkRemoveFolderMediaFilesRequest, BulkRemoveFolderMediaFilesSuccessResponse, @@ -15,7 +15,13 @@ use mysql_queries::queries::folders::folder::get_folder_for_owner::{ use mysql_queries::queries::folders::media_files::bulk_delete_folder_media_files::{ bulk_delete_folder_media_files, BulkDeleteFolderMediaFilesArgs, }; +use mysql_queries::queries::folders::media_files::recompute_folder_last_media_files::{ + recompute_folder_last_media_files, RecomputeFolderLastMediaFilesArgs, +}; +use sqlx::pool::PoolConnection; +use sqlx::{Acquire, MySql, Transaction}; use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; @@ -74,18 +80,87 @@ pub async fn bulk_remove_folder_media_files_handler( return Err(CommonWebError::NotFound); } - let removed_count = bulk_delete_folder_media_files(BulkDeleteFolderMediaFilesArgs { - folder_token: &path.folder_token, - media_file_tokens: &request.media_file_tokens, - mysql_executor: &mut *conn, - phantom: PhantomData, - }).await.map_err(|err| { - warn!("bulk_delete_folder_media_files failed: {:?}", err); - CommonWebError::from_error(err) - })?; + // Empty input short-circuits with zero work — no transaction needed. + if request.media_file_tokens.is_empty() { + return Ok(Json(BulkRemoveFolderMediaFilesSuccessResponse { + success: true, + removed_count: 0, + })); + } + + let removed_count = perform_atomic_remove( + &mut conn, + &path.folder_token, + &request.media_file_tokens, + ).await?; Ok(Json(BulkRemoveFolderMediaFilesSuccessResponse { success: true, removed_count, })) } + +/// Open a transaction, run the delete + recompute as one unit, and +/// commit on success. On any failure inside [`perform_remove_work`] the +/// transaction is explicitly rolled back before the original error is +/// re-raised — sqlx would roll back on drop too, but doing it explicitly +/// makes the failure path obvious and surfaces any rollback error in +/// the log. +async fn perform_atomic_remove( + conn: &mut PoolConnection, + folder_token: &FolderToken, + media_file_tokens: &[MediaFileToken], +) -> Result { + let mut tx = conn.begin().await.map_err(|err| { + warn!("Failed to begin transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + + let work_result = perform_remove_work(&mut tx, folder_token, media_file_tokens).await; + + match work_result { + Ok(removed_count) => { + tx.commit().await.map_err(|err| { + warn!("Failed to commit bulk_remove transaction: {:?}", err); + CommonWebError::from_error(err) + })?; + Ok(removed_count) + } + Err(err) => { + if let Err(rollback_err) = tx.rollback().await { + error!( + "Rollback after bulk_remove failure also failed: {:?} (original error: {:?})", + rollback_err, err, + ); + } + Err(err) + } + } +} + +async fn perform_remove_work( + tx: &mut Transaction<'_, MySql>, + folder_token: &FolderToken, + media_file_tokens: &[MediaFileToken], +) -> Result { + let removed_count = bulk_delete_folder_media_files(BulkDeleteFolderMediaFilesArgs { + folder_token, + media_file_tokens, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("bulk_delete_folder_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + recompute_folder_last_media_files(RecomputeFolderLastMediaFilesArgs { + folder_token, + mysql_executor: &mut **tx, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("recompute_folder_last_media_files failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(removed_count) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs index cd507fe848..84bcde4871 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/media_files/list_folder_media_files_handler.rs @@ -3,21 +3,35 @@ use std::sync::Arc; use actix_web::web::{Json, Path, Query}; use actix_web::{web, HttpRequest}; +use chrono::{DateTime, Utc}; use log::warn; +use serde_derive::Serialize; +use utoipa::ToSchema; +use artcraft_api_defs::common::responses::media_links::MediaLinks; use artcraft_api_defs::folders::media_files::{ - FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, ListFolderMediaFilesSuccessResponse, + FolderMediaFilesPathInfo, ListFolderMediaFilesQueryParams, }; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::common::visibility::Visibility; use mysql_queries::queries::folders::folder::get_folder_for_owner::{ get_folder_for_owner, GetFolderForOwnerArgs, }; use mysql_queries::queries::folders::media_files::list_folder_media_files::{ - list_folder_media_files, ListFolderMediaFilesArgs, + list_folder_media_files, FolderMediaFileRow, ListFolderMediaFilesArgs, }; +use tokens::tokens::batch_generations::BatchGenerationToken; use tokens::tokens::folders::FolderToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_media_file_row_to_info; +use crate::http_server::common_responses::media::media_domain::MediaDomain; +use crate::http_server::common_responses::media::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; @@ -25,6 +39,67 @@ const CURSOR_NAME: &str = "folder_mf"; const DEFAULT_LIMIT: u32 = 100; const MAX_LIMIT: u32 = 1000; +// ── Response shape ── + +#[derive(Serialize, ToSchema)] +pub struct ListFolderMediaFilesSuccessResponse { + pub success: bool, + pub media_files: Vec, + pub maybe_cursor: Option, +} + +/// One media file as it appears inside a folder. Lean version of the +/// "by batch" / "by user" list-item shape — keeps the fields that matter +/// for folder browsing and drops the bucket-path noise. +#[derive(Serialize, ToSchema)] +pub struct FolderMediaFileListItem { + pub token: MediaFileToken, + + /// Coarse-grained class (image / video / audio / dimensional). + pub media_class: MediaFileClass, + + /// Specific format (jpg, png, mp4, etc.) — closer to a MIME type. + pub media_type: MediaFileType, + + /// Link to the prompt + pub maybe_prompt_token: Option, + + /// If this file was generated as part of a batch, the batch token — + /// useful for showing "siblings" in the UI. + pub maybe_batch_token: Option, + + /// Rich CDN links to the media itself (full URL, thumbnail template, + /// video previews when applicable). + pub media_links: MediaLinks, + + /// Cover image details. For files that don't carry their own cover + /// image, this still gives the deterministic default-cover spec + /// (image_index + color_index) the frontend uses for placeholders. + pub cover_image: MediaFileCoverImageDetails, + + pub maybe_title: Option, + pub maybe_original_filename: Option, + + /// Original pixel width / height for image and video files when known. + pub maybe_frame_width: Option, + pub maybe_frame_height: Option, + + /// Duration for audio and video files, if available. Milliseconds. + pub maybe_duration_millis: Option, + + pub creator_set_visibility: Visibility, + pub is_user_upload: bool, + pub is_intermediate_system_file: bool, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// When the media file was added to the folder. + pub added_to_folder_at: DateTime, +} + +// ── Handler ── + #[utoipa::path( get, tag = "Folders (Media File Management)", @@ -65,7 +140,7 @@ pub async fn list_folder_media_files_handler( warn!("Folder lookup failed: {:?}", err); CommonWebError::from_error(err) })?; - + if folder.is_none() { return Err(CommonWebError::NotFound); } @@ -103,7 +178,12 @@ pub async fn list_folder_media_files_handler( CommonWebError::server_error_with_message("Failed to encode cursor") })?; - let media_files = rows.into_iter().map(folder_media_file_row_to_info).collect(); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let media_files = rows.into_iter() + .map(|row| folder_media_file_row_to_list_item(row, media_domain, server_environment)) + .collect(); Ok(Json(ListFolderMediaFilesSuccessResponse { success: true, @@ -111,3 +191,52 @@ pub async fn list_folder_media_files_handler( maybe_cursor, })) } + +fn folder_media_file_row_to_list_item( + row: FolderMediaFileRow, + media_domain: MediaDomain, + server_environment: server_environment::ServerEnvironment, +) -> FolderMediaFileListItem { + let bucket_path = MediaFileBucketPath::from_object_hash( + &row.public_bucket_directory_hash, + row.maybe_public_bucket_prefix.as_deref(), + row.maybe_public_bucket_extension.as_deref(), + ); + + let media_links = MediaLinksBuilder::from_media_path_and_env( + media_domain, + server_environment, + &bucket_path, + ); + + let cover_image = MediaFileCoverImageDetails::from_optional_db_fields( + &row.media_file_token, + media_domain, + server_environment, + row.maybe_cover_public_bucket_directory_hash.as_deref(), + row.maybe_cover_public_bucket_prefix.as_deref(), + row.maybe_cover_public_bucket_extension.as_deref(), + ); + + FolderMediaFileListItem { + token: row.media_file_token, + added_to_folder_at: row.added_to_folder_at, + media_class: row.media_class, + media_type: row.media_type, + maybe_batch_token: row.maybe_batch_token, + media_links, + cover_image, + creator_set_visibility: row.creator_set_visibility, + is_user_upload: row.is_user_upload, + is_intermediate_system_file: row.is_intermediate_system_file, + maybe_title: row.maybe_title, + maybe_prompt_token: row.maybe_prompt_token, + maybe_original_filename: row.maybe_origin_filename, + // Schema stores `INT(10)`; widen to u64 for the wire shape. + maybe_duration_millis: row.maybe_duration_millis.map(|n| n as u64), + maybe_frame_width: row.maybe_frame_width, + maybe_frame_height: row.maybe_frame_height, + created_at: row.created_at, + updated_at: row.updated_at, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs index 087836a8a8..ec047e4417 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/folders/subfolder/list_subfolders_handler.rs @@ -17,7 +17,10 @@ use mysql_queries::queries::folders::subfolder::list_subfolders::{ use tokens::tokens::folders::FolderToken; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::endpoints::folders::folder::folder_info_conversion::folder_row_to_info; +use crate::http_server::endpoints::folders::folder::folder_info_conversion::{ + build_folder_thumbnails_lookup, folder_row_to_info, +}; +use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; use crate::state::server_state::ServerState; @@ -105,7 +108,22 @@ pub async fn list_subfolders_handler( CommonWebError::server_error_with_message("Failed to encode cursor") })?; - let subfolders = rows.into_iter().map(folder_row_to_info).collect(); + let media_domain = get_media_domain(&http_request); + let server_environment = server_state.server_environment; + + let thumbnails = build_folder_thumbnails_lookup( + &rows, + &mut *conn, + media_domain, + server_environment, + ).await.map_err(|err| { + warn!("Folder thumbnail lookup failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let subfolders = rows.into_iter() + .map(|row| folder_row_to_info(row, &thumbnails)) + .collect(); Ok(Json(ListSubfoldersSuccessResponse { success: true,