diff --git a/.sqlx/query-cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a.json b/.sqlx/query-cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a.json new file mode 100644 index 0000000000..9573a50612 --- /dev/null +++ b/.sqlx/query-cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a.json @@ -0,0 +1,179 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n m.token as `token: tokens::tokens::media_files::MediaFileToken`,\n\n m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`,\n m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`,\n\n m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`,\n m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n m.public_bucket_directory_hash,\n m.maybe_public_bucket_prefix,\n m.maybe_public_bucket_extension,\n\n media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash,\n media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix,\n media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension,\n\n m.maybe_origin_filename,\n m.maybe_duration_millis as `maybe_duration_millis: u64`,\n\n m.created_at,\n m.updated_at\n\nFROM media_files AS m\nLEFT OUTER JOIN media_files as media_file_cover_image\n ON media_file_cover_image.token = m.maybe_cover_image_media_file_token\nWHERE\n m.maybe_source_job_token = ?\n AND m.maybe_creator_user_token = ?\n AND m.user_deleted_at IS NULL\n AND m.mod_deleted_at IS NULL\n AND NOT m.is_intermediate_system_file\nORDER BY m.id ASC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token: tokens::tokens::media_files::MediaFileToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 1, + "name": "media_class: enums::by_table::media_files::media_file_class::MediaFileClass", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 2, + "name": "media_type: enums::by_table::media_files::media_file_type::MediaFileType", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 3, + "name": "maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 4, + "name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "public_bucket_directory_hash", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 6, + "name": "maybe_public_bucket_prefix", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 7, + "name": "maybe_public_bucket_extension", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 8, + "name": "maybe_file_cover_image_public_bucket_hash", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 9, + "name": "maybe_file_cover_image_public_bucket_prefix", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 10, + "name": "maybe_file_cover_image_public_bucket_extension", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 11, + "name": "maybe_origin_filename", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 12, + "name": "maybe_duration_millis: u64", + "type_info": { + "type": "Long", + "flags": "", + "char_set": 63, + "max_size": 10 + } + }, + { + "ordinal": 13, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 14, + "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, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "cd04f4f7b48ae9da53b6a210111f8e3edff94dee30263e22b45d5e29dbe9245a" +} \ No newline at end of file diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/list_media_files_by_job.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/list_media_files_by_job.rs new file mode 100644 index 0000000000..327d47213d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/list_media_files_by_job.rs @@ -0,0 +1,61 @@ +//! Types for `GET /v1/media_files/by_job/{job_token}` — list the media files +//! produced by an inference job. + +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +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::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; + +use crate::common::responses::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::common::responses::media_links::MediaLinks; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct ListMediaFilesByJobSuccessResponse { + pub success: bool, + + /// The job's output files, oldest first. + pub media_files: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct JobMediaFileInfo { + 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 the file was generated as part of a batch, this is the token for the batch. + pub maybe_batch_token: Option, + + /// The foreign key to the prompt used to generate the media, if applicable. + pub maybe_prompt_token: Option, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// 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, + + /// The original filename for uploaded files, if they were provided. + /// In the future we'll provide our own internal optional filenames. + pub maybe_original_filename: Option, + + /// Duration for audio and video files, if available. + /// Measured in milliseconds. + pub maybe_duration_millis: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/mod.rs new file mode 100644 index 0000000000..f026a6a007 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/job/mod.rs @@ -0,0 +1 @@ +pub mod list_media_files_by_job; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs index b2a2cd3b70..1cac0362c9 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/media_file/mod.rs @@ -1,4 +1,5 @@ pub mod delete_media_file; +pub mod job; pub mod list; pub mod list_batch_generated_media_files; pub mod list_session_project_media_files; diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/list_media_files_by_source_job.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/list_media_files_by_source_job.rs new file mode 100644 index 0000000000..f9671870a5 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/list_media_files_by_source_job.rs @@ -0,0 +1,110 @@ +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 tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::prompts::PromptToken; +use tokens::tokens::users::UserToken; + +/// A job has a bounded number of outputs (the largest batches are single +/// digits), so the list is capped rather than paginated. +const MAX_RESULTS: u32 = 100; + +pub struct JobMediaFileRecord { + pub token: MediaFileToken, + + pub media_class: MediaFileClass, + pub media_type: MediaFileType, + + pub maybe_batch_token: Option, + pub maybe_prompt_token: Option, + + pub public_bucket_directory_hash: String, + pub maybe_public_bucket_prefix: Option, + pub maybe_public_bucket_extension: Option, + + pub maybe_file_cover_image_public_bucket_hash: Option, + pub maybe_file_cover_image_public_bucket_prefix: Option, + pub maybe_file_cover_image_public_bucket_extension: Option, + + pub maybe_origin_filename: Option, + pub maybe_duration_millis: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct ListMediaFilesBySourceJobArgs<'a, 'c, E> +where + E: 'a + Executor<'c, Database = MySql>, +{ + /// The inference job whose outputs to list. + pub job_token: &'a InferenceJobToken, + + /// Only return files created by this user (the session user). Enforces that + /// callers can only list the outputs of their own jobs. + pub creator_user_token: &'a UserToken, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// List the media files produced by an inference job (`maybe_source_job_token`), +/// oldest first. Deliberately lean: no user/model/stats joins — just the file +/// fields plus a self-join for the cover image. +pub async fn list_media_files_by_source_job<'a, 'c: 'a, E>( + args: ListMediaFilesBySourceJobArgs<'a, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'a + Executor<'c, Database = MySql>, +{ + sqlx::query_as!( + JobMediaFileRecord, + r#" +SELECT + m.token as `token: tokens::tokens::media_files::MediaFileToken`, + + m.media_class as `media_class: enums::by_table::media_files::media_file_class::MediaFileClass`, + m.media_type as `media_type: enums::by_table::media_files::media_file_type::MediaFileType`, + + m.maybe_batch_token as `maybe_batch_token: tokens::tokens::batch_generations::BatchGenerationToken`, + m.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`, + + m.public_bucket_directory_hash, + m.maybe_public_bucket_prefix, + m.maybe_public_bucket_extension, + + media_file_cover_image.public_bucket_directory_hash as maybe_file_cover_image_public_bucket_hash, + media_file_cover_image.maybe_public_bucket_prefix as maybe_file_cover_image_public_bucket_prefix, + media_file_cover_image.maybe_public_bucket_extension as maybe_file_cover_image_public_bucket_extension, + + m.maybe_origin_filename, + m.maybe_duration_millis as `maybe_duration_millis: u64`, + + m.created_at, + m.updated_at + +FROM media_files AS m +LEFT OUTER JOIN media_files as media_file_cover_image + ON media_file_cover_image.token = m.maybe_cover_image_media_file_token +WHERE + m.maybe_source_job_token = ? + AND m.maybe_creator_user_token = ? + AND m.user_deleted_at IS NULL + AND m.mod_deleted_at IS NULL + AND NOT m.is_intermediate_system_file +ORDER BY m.id ASC +LIMIT ? + "#, + args.job_token, + args.creator_user_token, + MAX_RESULTS, + ) + .fetch_all(args.mysql_executor) + .await +} diff --git a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs index 248fed1e77..63375eae68 100644 --- a/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/generic_inference/job/mod.rs @@ -1,6 +1,7 @@ pub mod _keys; pub mod count_untried_jobs_of_type; pub mod list_available_generic_inference_jobs; +pub mod list_media_files_by_source_job; pub mod mark_generic_inference_job_completely_failed; pub mod mark_generic_inference_job_failure; pub mod mark_job_failed_by_token; 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 c4a50fe4ca..fe493ccf31 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -356,6 +356,7 @@ use artcraft_api_defs::credits::get_session_credits::*; use artcraft_api_defs::subscriptions::get_session_subscription::*; use artcraft_api_defs::media_file::list_batch_generated_media_files::*; 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_session_project_media_files::*; @@ -495,6 +496,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::media_files::edit::set_media_file_cover_image_handler::set_media_file_cover_image_handler, crate::http_server::endpoints::media_files::get::batch_get_media_files_handler::batch_get_media_files_handler, crate::http_server::endpoints::media_files::get::get_media_file_handler::get_media_file_handler, + crate::http_server::endpoints::media_files::job::list_media_files_by_job_handler::list_media_files_by_job_handler, 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, @@ -791,6 +793,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Common response structs JobDetailsLivePortraitRequest, + JobMediaFileInfo, JobDetailsLipsyncRequest, MediaFileLivePortraitDetails, MediaFileModelDetails, @@ -1125,6 +1128,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux ListUserImpersonationRequestsSuccessResponse, ListMediaFilesByBatchPathInfo, ListMediaFilesByBatchSuccessResponse, + ListMediaFilesByJobSuccessResponse, ListMediaFilesForUserPathInfo, ListMediaFilesForUserQueryParams, ListMediaFilesForUserSuccessResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/job/list_media_files_by_job_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/job/list_media_files_by_job_handler.rs new file mode 100644 index 0000000000..9cd48c2adb --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/job/list_media_files_by_job_handler.rs @@ -0,0 +1,122 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::{error, warn}; + +use artcraft_api_defs::media_file::job::list_media_files_by_job::{ + JobMediaFileInfo, ListMediaFilesByJobSuccessResponse, +}; +use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath; +use mysql_queries::queries::generic_inference::job::list_media_files_by_source_job::{ + list_media_files_by_source_job, ListMediaFilesBySourceJobArgs, +}; +use tokens::tokens::generic_inference_jobs::InferenceJobToken; +use utoipa::ToSchema; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::common_responses::media::media_file_cover_image_details_builder::MediaFileCoverImageDetailsBuilder; +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::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session; +use crate::state::server_state::ServerState; + +#[derive(Deserialize, ToSchema)] +pub struct ListMediaFilesByJobPathInfo { + job_token: InferenceJobToken, +} + +/// List the media files produced by an inference job. +/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) +/// user, and only returns files from the caller's own jobs. +#[utoipa::path( + get, + tag = "Media Files", + path = "/v1/media_files/by_job/{job_token}", + responses( + (status = 200, description = "Success", body = ListMediaFilesByJobSuccessResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Server error"), + ), + params( + ("path" = ListMediaFilesByJobPathInfo, description = "Path for Request") + ) +)] +pub async fn list_media_files_by_job_handler( + http_request: HttpRequest, + path: Path, + server_state: web::Data>, +) -> Result, CommonWebError> { + let job_token = &path.job_token; + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + error!("MySql pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let session = require_api_or_web_session( + &http_request, + &server_state.session_checker, + &server_state.avt_cookie_manager, + &mut *mysql_connection, + ).await?; + + // NB: Jobs another user owns (or unknown job tokens) simply return an empty + // list — the query filters on the session user's ownership. + let records = list_media_files_by_source_job(ListMediaFilesBySourceJobArgs { + job_token, + creator_user_token: &session.user_token, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await + .map_err(|err| { + warn!("List media files by job {:?} query error: {:?}", job_token, err); + CommonWebError::from_error(err) + })?; + + let media_domain = get_media_domain(&http_request); + + let media_files = records.into_iter() + .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(), + ); + JobMediaFileInfo { + media_links: MediaLinksBuilder::from_media_path_and_env( + media_domain, + server_state.server_environment, + &public_bucket_path, + ), + cover_image: MediaFileCoverImageDetailsBuilder::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(), + ), + token: record.token, + media_class: record.media_class, + media_type: record.media_type, + maybe_batch_token: record.maybe_batch_token, + maybe_prompt_token: record.maybe_prompt_token, + maybe_original_filename: record.maybe_origin_filename, + maybe_duration_millis: record.maybe_duration_millis, + created_at: record.created_at, + updated_at: record.updated_at, + } + }) + .collect::>(); + + Ok(Json(ListMediaFilesByJobSuccessResponse { + success: true, + media_files, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/job/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/job/mod.rs new file mode 100644 index 0000000000..0911274ac5 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/job/mod.rs @@ -0,0 +1 @@ +pub mod list_media_files_by_job_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs index 296b508bfb..f74264c67c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/mod.rs @@ -3,6 +3,7 @@ pub mod delete; pub mod edit; pub mod get; pub mod helpers; +pub mod job; pub mod list; pub mod search; pub mod upload; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/media_files_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/media_files_routes.rs index 9cb97be4f9..4e73390120 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/media_files_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/media_files_routes.rs @@ -12,6 +12,7 @@ use crate::http_server::endpoints::media_files::edit::set_media_file_cover_image use crate::http_server::endpoints::media_files::edit::update_media_file_handler::update_media_file_handler; use crate::http_server::endpoints::media_files::get::batch_get_media_files_handler::batch_get_media_files_handler; use crate::http_server::endpoints::media_files::get::get_media_file_handler::get_media_file_handler; +use crate::http_server::endpoints::media_files::job::list_media_files_by_job_handler::list_media_files_by_job_handler; use crate::http_server::endpoints::media_files::list::list_batch_generated_redux_media_files_handler::list_batch_generated_redux_media_files_handler; 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; @@ -98,6 +99,10 @@ pub fn add_media_file_routes (app: App) -> App .route(web::get().to(list_media_files_by_batch_token_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/by_job/{job_token}") + .route(web::get().to(list_media_files_by_job_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) .service(web::resource("/list_featured") .route(web::get().to(list_featured_media_files_handler)) .route(web::head().to(|| HttpResponse::Ok()))