a / api /src /handlers /admin.rs
FTA7700
Add Deno Deploy control test to isolate Cloudflare-specific vs edge-network-general stall
ed0e1f4
Raw History Blame Contribute Delete
53.8 kB
use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::{Deserialize, Serialize}; use crate::cache; use crate::error::AppError; use crate::image::serve::{self, LogoBackdropKind}; use crate::services::db::{self, default_ratings_limit, default_logo_backdrop_ratings_limit, default_ratings_order, BadgeBackground, BadgeDirection, BadgeShape, BadgeSize, BadgeStyle, LabelStyle, BadgePosition, ImageSource, PosterFit}; use crate::AppState; #[derive(Serialize)] pub struct StatsResponse { pub total_images: u64, pub total_api_keys: u64, pub mem_cache_entries: u64, pub id_cache_entries: u64, pub ratings_cache_entries: u64, pub image_mem_cache_mb: u64, } pub async fn stats(State(state): State<Arc<AppState>>) -> Result<Json<StatsResponse>, AppError> { let total_images = db::count_image_meta(&state.db).await?; let total_api_keys = db::count_api_keys(&state.db).await?; let mem_cache_entries = state.image_mem_cache.entry_count(); let id_cache_entries = state.id_cache.entry_count(); let ratings_cache_entries = state.ratings_cache.entry_count(); let image_mem_cache_mb = state.image_mem_cache.weighted_size() / (1024 * 1024); Ok(Json(StatsResponse { total_images, total_api_keys, mem_cache_entries, id_cache_entries, ratings_cache_entries, image_mem_cache_mb, })) } #[derive(Deserialize)] pub struct ListImagesQuery { #[serde(default = "default_page")] pub page: u64, #[serde(default = "default_page_size")] pub page_size: u64, } fn default_page() -> u64 { 1 } fn default_page_size() -> u64 { 50 } #[derive(Serialize)] pub struct ImageMetaItem { pub cache_key: String, pub release_date: Option<String>, pub created_at: i64, pub updated_at: i64, pub api_key_alias: Option<String>, } #[derive(Serialize)] pub struct ListImagesResponse { pub items: Vec<ImageMetaItem>, pub total: u64, pub page: u64, pub page_size: u64, } async fn list_images( state: &AppState, query: &ListImagesQuery, image_type: cache::ImageType, ) -> Result<Json<ListImagesResponse>, AppError> { let page = query.page.max(1); let page_size = query.page_size.clamp(1, 100); let (items, total) = db::list_image_meta_by_kind(&state.db, image_type, page, page_size).await?; let key_ids: Vec<i32> = items.iter().filter_map(|m| m.api_key_id).collect(); let alias_map = db::get_api_key_names(&state.db, &key_ids).await?; let items = items .into_iter() .map(|m| ImageMetaItem { cache_key: m.cache_key, release_date: m.release_date, created_at: m.created_at, updated_at: m.updated_at, api_key_alias: m.api_key_id.and_then(|id| alias_map.get(&id).cloned()), }) .collect(); Ok(Json(ListImagesResponse { items, total, page, page_size })) } pub async fn list_posters( State(state): State<Arc<AppState>>, Query(query): Query<ListImagesQuery>, ) -> Result<Json<ListImagesResponse>, AppError> { list_images(&state, &query, cache::ImageType::Poster).await } pub async fn poster_image( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { image_from_cache_key(&state, &id_type, &id_value, cache::ImageType::Poster, "image/jpeg").await } #[derive(Serialize)] pub struct GlobalSettingsResponse { pub image_source: ImageSource, pub lang: String, pub textless: bool, pub fanart_available: bool, pub ratings_limit: i32, pub ratings_order: String, pub ratings_exclude: String, pub free_api_key_enabled: bool, pub free_api_key_locked: bool, pub poster_position: BadgePosition, pub logo_ratings_limit: i32, pub backdrop_ratings_limit: i32, pub poster_badge_style: BadgeStyle, pub logo_badge_style: BadgeStyle, pub backdrop_badge_style: BadgeStyle, pub poster_label_style: LabelStyle, pub logo_label_style: LabelStyle, pub backdrop_label_style: LabelStyle, pub poster_badge_direction: BadgeDirection, pub poster_badge_split: bool, pub poster_fit: PosterFit, pub poster_badge_size: BadgeSize, pub logo_badge_size: BadgeSize, pub backdrop_badge_size: BadgeSize, pub backdrop_position: BadgePosition, pub backdrop_badge_direction: BadgeDirection, pub backdrop_edge_inset_x: i32, pub backdrop_edge_inset_y: i32, pub episode_ratings_limit: i32, pub episode_badge_style: BadgeStyle, pub episode_label_style: LabelStyle, pub episode_badge_size: BadgeSize, pub episode_position: BadgePosition, pub episode_badge_direction: BadgeDirection, pub episode_blur: bool, pub poster_badge_shape: BadgeShape, pub logo_badge_shape: BadgeShape, pub backdrop_badge_shape: BadgeShape, pub episode_badge_shape: BadgeShape, pub poster_badge_background: BadgeBackground, pub logo_badge_background: BadgeBackground, pub backdrop_badge_background: BadgeBackground, pub episode_badge_background: BadgeBackground, } pub async fn get_settings( State(state): State<Arc<AppState>>, ) -> Result<Json<GlobalSettingsResponse>, AppError> { let db_ref = state.db.clone(); let settings = state .global_settings_cache .try_get_with((), async move { let globals = db::get_global_settings(&db_ref).await?; Ok::<_, AppError>(Arc::new(db::parse_global_render_settings(&globals))) }) .await .map_err(|e| AppError::Other(e.to_string()))?; let free_api_key_locked = state.config.free_key_enabled.is_some(); let free_api_key_enabled = state.is_free_api_key_enabled().await; Ok(Json(GlobalSettingsResponse { image_source: settings.image_source, lang: settings.lang.to_string(), textless: settings.textless, fanart_available: state.fanart.is_some(), ratings_limit: settings.ratings_limit, ratings_order: settings.ratings_order.to_string(), ratings_exclude: settings.ratings_exclude.to_string(), free_api_key_enabled, free_api_key_locked, poster_position: settings.poster_position, logo_ratings_limit: settings.logo_ratings_limit, backdrop_ratings_limit: settings.backdrop_ratings_limit, poster_badge_style: settings.poster_badge_style, logo_badge_style: settings.logo_badge_style, backdrop_badge_style: settings.backdrop_badge_style, poster_label_style: settings.poster_label_style, logo_label_style: settings.logo_label_style, backdrop_label_style: settings.backdrop_label_style, poster_badge_direction: settings.poster_badge_direction, poster_badge_split: settings.poster_badge_split, poster_fit: settings.poster_fit, poster_badge_size: settings.poster_badge_size, logo_badge_size: settings.logo_badge_size, backdrop_badge_size: settings.backdrop_badge_size, backdrop_position: settings.backdrop_position, backdrop_badge_direction: settings.backdrop_badge_direction, backdrop_edge_inset_x: settings.backdrop_edge_inset_x, backdrop_edge_inset_y: settings.backdrop_edge_inset_y, episode_ratings_limit: settings.episode_ratings_limit, episode_badge_style: settings.episode_badge_style, episode_label_style: settings.episode_label_style, episode_badge_size: settings.episode_badge_size, episode_position: settings.episode_position, episode_badge_direction: settings.episode_badge_direction, episode_blur: settings.episode_blur, poster_badge_shape: settings.poster_badge_shape, logo_badge_shape: settings.logo_badge_shape, backdrop_badge_shape: settings.backdrop_badge_shape, episode_badge_shape: settings.episode_badge_shape, poster_badge_background: settings.poster_badge_background, logo_badge_background: settings.logo_badge_background, backdrop_badge_background: settings.backdrop_badge_background, episode_badge_background: settings.episode_badge_background, })) } #[derive(Deserialize)] pub struct UpdateGlobalSettingsRequest { #[serde(alias = "poster_source")] pub image_source: ImageSource, #[serde(default = "db::default_lang", alias = "fanart_lang")] pub lang: String, #[serde(default, alias = "fanart_textless")] pub textless: bool, #[serde(default = "default_ratings_limit")] pub ratings_limit: i32, #[serde(default = "default_ratings_order")] pub ratings_order: String, #[serde(default = "db::default_ratings_exclude")] pub ratings_exclude: String, pub free_api_key_enabled: Option<bool>, #[serde(default = "db::default_poster_position")] pub poster_position: BadgePosition, #[serde(default = "default_logo_backdrop_ratings_limit")] pub logo_ratings_limit: i32, #[serde(default = "default_logo_backdrop_ratings_limit")] pub backdrop_ratings_limit: i32, #[serde(default = "db::default_poster_badge_style")] pub poster_badge_style: BadgeStyle, #[serde(default = "db::default_logo_badge_style")] pub logo_badge_style: BadgeStyle, #[serde(default = "db::default_backdrop_badge_style")] pub backdrop_badge_style: BadgeStyle, #[serde(default = "db::default_label_style")] pub poster_label_style: LabelStyle, #[serde(default = "db::default_label_style")] pub logo_label_style: LabelStyle, #[serde(default = "db::default_label_style")] pub backdrop_label_style: LabelStyle, #[serde(default = "db::default_poster_badge_direction")] pub poster_badge_direction: BadgeDirection, #[serde(default)] pub poster_badge_split: bool, #[serde(default = "db::default_poster_fit")] pub poster_fit: PosterFit, #[serde(default = "db::default_badge_size")] pub poster_badge_size: BadgeSize, #[serde(default = "db::default_badge_size")] pub logo_badge_size: BadgeSize, #[serde(default = "db::default_badge_size")] pub backdrop_badge_size: BadgeSize, #[serde(default = "db::default_backdrop_position")] pub backdrop_position: BadgePosition, #[serde(default = "db::default_backdrop_badge_direction")] pub backdrop_badge_direction: BadgeDirection, #[serde(default = "db::default_backdrop_edge_inset")] pub backdrop_edge_inset_x: i32, #[serde(default = "db::default_backdrop_edge_inset")] pub backdrop_edge_inset_y: i32, #[serde(default = "db::default_episode_ratings_limit")] pub episode_ratings_limit: i32, #[serde(default = "db::default_episode_badge_style")] pub episode_badge_style: BadgeStyle, #[serde(default = "db::default_label_style")] pub episode_label_style: LabelStyle, #[serde(default = "db::default_episode_badge_size")] pub episode_badge_size: BadgeSize, #[serde(default = "db::default_episode_position")] pub episode_position: BadgePosition, #[serde(default = "db::default_episode_badge_direction")] pub episode_badge_direction: BadgeDirection, #[serde(default)] pub episode_blur: bool, #[serde(default = "db::default_badge_shape")] pub poster_badge_shape: BadgeShape, #[serde(default = "db::default_badge_shape")] pub logo_badge_shape: BadgeShape, #[serde(default = "db::default_badge_shape")] pub backdrop_badge_shape: BadgeShape, #[serde(default = "db::default_badge_shape")] pub episode_badge_shape: BadgeShape, #[serde(default = "db::default_badge_background")] pub poster_badge_background: BadgeBackground, #[serde(default = "db::default_badge_background")] pub logo_badge_background: BadgeBackground, #[serde(default = "db::default_badge_background")] pub backdrop_badge_background: BadgeBackground, #[serde(default = "db::default_badge_background")] pub episode_badge_background: BadgeBackground, } pub async fn update_settings( State(state): State<Arc<AppState>>, Json(req): Json<UpdateGlobalSettingsRequest>, ) -> Result<Json<serde_json::Value>, AppError> { db::validate_render_settings(&req.lang, req.ratings_limit, &req.ratings_order, &req.ratings_exclude, req.logo_ratings_limit, req.backdrop_ratings_limit, req.episode_ratings_limit)?; let textless_str = if req.textless { "true" } else { "false" }; let limit_str = req.ratings_limit.to_string(); let logo_limit_str = req.logo_ratings_limit.to_string(); let backdrop_limit_str = req.backdrop_ratings_limit.to_string(); let episode_limit_str = req.episode_ratings_limit.to_string(); let episode_blur_str = if req.episode_blur { "true" } else { "false" }; let poster_badge_split_str = if req.poster_badge_split { "true" } else { "false" }; let backdrop_edge_inset_x_str = db::clamp_edge_inset(req.backdrop_edge_inset_x).to_string(); let backdrop_edge_inset_y_str = db::clamp_edge_inset(req.backdrop_edge_inset_y).to_string(); let mut batch: Vec<(&str, &str)> = vec![ ("image_source", req.image_source.as_str()), ("lang", &req.lang), ("textless", textless_str), ("ratings_limit", &limit_str), ("ratings_order", &req.ratings_order), ("ratings_exclude", &req.ratings_exclude), ("poster_position", req.poster_position.as_str()), ("logo_ratings_limit", &logo_limit_str), ("backdrop_ratings_limit", &backdrop_limit_str), ("poster_badge_style", req.poster_badge_style.as_str()), ("logo_badge_style", req.logo_badge_style.as_str()), ("backdrop_badge_style", req.backdrop_badge_style.as_str()), ("poster_label_style", req.poster_label_style.as_str()), ("logo_label_style", req.logo_label_style.as_str()), ("backdrop_label_style", req.backdrop_label_style.as_str()), ("poster_badge_direction", req.poster_badge_direction.as_str()), ("poster_badge_split", poster_badge_split_str), ("poster_fit", req.poster_fit.as_str()), ("poster_badge_size", req.poster_badge_size.as_str()), ("logo_badge_size", req.logo_badge_size.as_str()), ("backdrop_badge_size", req.backdrop_badge_size.as_str()), ("backdrop_position", req.backdrop_position.as_str()), ("backdrop_badge_direction", req.backdrop_badge_direction.as_str()), ("backdrop_edge_inset_x", &backdrop_edge_inset_x_str), ("backdrop_edge_inset_y", &backdrop_edge_inset_y_str), ("episode_ratings_limit", &episode_limit_str), ("episode_badge_style", req.episode_badge_style.as_str()), ("episode_label_style", req.episode_label_style.as_str()), ("episode_badge_size", req.episode_badge_size.as_str()), ("episode_position", req.episode_position.as_str()), ("episode_badge_direction", req.episode_badge_direction.as_str()), ("episode_blur", episode_blur_str), ("poster_badge_shape", req.poster_badge_shape.as_str()), ("logo_badge_shape", req.logo_badge_shape.as_str()), ("backdrop_badge_shape", req.backdrop_badge_shape.as_str()), ("episode_badge_shape", req.episode_badge_shape.as_str()), ("poster_badge_background", req.poster_badge_background.as_str()), ("logo_badge_background", req.logo_badge_background.as_str()), ("backdrop_badge_background", req.backdrop_badge_background.as_str()), ("episode_badge_background", req.episode_badge_background.as_str()), ]; let free_key_str; if state.config.free_key_enabled.is_none() { if let Some(enabled) = req.free_api_key_enabled { free_key_str = if enabled { "true" } else { "false" }; batch.push(("free_api_key_enabled", free_key_str)); } } db::set_global_settings_batch(&state.db, &batch).await?; // Invalidate caches (preview_cache needs no invalidation — keys encode the config) state.global_settings_cache.invalidate(&()).await; state.settings_cache.invalidate_all(); if req.free_api_key_enabled.is_some() && state.config.free_key_enabled.is_none() { state.free_api_key_cache.invalidate(&()).await; } Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn fetch_poster( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { // Validate id_type crate::id::IdType::parse(&id_type)?; // Load global settings (cached) let db_ref = state.db.clone(); let settings = state .global_settings_cache .try_get_with((), async move { let globals = db::get_global_settings(&db_ref).await?; Ok::<_, AppError>(Arc::new(db::parse_global_render_settings(&globals))) }) .await .map_err(|e| AppError::Other(e.to_string()))?; let (bytes, _) = serve::handle_inner(&state, &id_type, &id_value, (*settings).clone(), None, None).await?; Ok(serve::image_response(bytes, "image/jpeg")) } // --- Logos --- pub async fn list_logos( State(state): State<Arc<AppState>>, Query(query): Query<ListImagesQuery>, ) -> Result<Json<ListImagesResponse>, AppError> { list_images(&state, &query, cache::ImageType::Logo).await } pub async fn logo_image( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { image_from_cache_key(&state, &id_type, &id_value, cache::ImageType::Logo, "image/png").await } pub async fn fetch_logo( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { fetch_logo_backdrop_image(&state, &id_type, &id_value, LogoBackdropKind::Logo, "image/png").await } // --- Backdrops --- pub async fn list_backdrops( State(state): State<Arc<AppState>>, Query(query): Query<ListImagesQuery>, ) -> Result<Json<ListImagesResponse>, AppError> { list_images(&state, &query, cache::ImageType::Backdrop).await } pub async fn backdrop_image( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { image_from_cache_key(&state, &id_type, &id_value, cache::ImageType::Backdrop, "image/jpeg").await } pub async fn fetch_backdrop( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { fetch_logo_backdrop_image(&state, &id_type, &id_value, LogoBackdropKind::Backdrop, "image/jpeg").await } // --- Episodes --- pub async fn list_episodes( State(state): State<Arc<AppState>>, Query(query): Query<ListImagesQuery>, ) -> Result<Json<ListImagesResponse>, AppError> { list_images(&state, &query, cache::ImageType::Episode).await } pub async fn episode_image( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { image_from_cache_key(&state, &id_type, &id_value, cache::ImageType::Episode, "image/jpeg").await } pub async fn fetch_episode( State(state): State<Arc<AppState>>, Path((id_type, id_value)): Path<(String, String)>, ) -> Result<Response, AppError> { crate::id::IdType::parse(&id_type)?; let db_ref = state.db.clone(); let settings = state .global_settings_cache .try_get_with((), async move { let globals = db::get_global_settings(&db_ref).await?; Ok::<_, AppError>(Arc::new(db::parse_global_render_settings(&globals))) }) .await .map_err(|e| AppError::Other(e.to_string()))?; let (bytes, _) = serve::handle_episode_inner(&state, &id_type, &id_value, (*settings).clone(), None, None).await?; Ok(serve::image_response(bytes, "image/jpeg")) } // --- Helpers --- async fn image_from_cache_key( state: &Arc<AppState>, id_type: &str, id_value: &str, image_type: cache::ImageType, content_type: &str, ) -> Result<Response, AppError> { crate::id::IdType::parse(id_type)?; // id_value contains colons (e.g. "tt123:logo:fanart:en:r_imdb"). // Replace colons with underscores to get the filesystem filename base. let file_base = id_value.replace(':', "_"); let path = cache::typed_cache_path(&state.config.cache_dir, image_type, id_type, &file_base)?; let canonical_cache_dir = tokio::fs::canonicalize(&state.config.cache_dir) .await .map_err(|e| AppError::Other(format!("Failed to resolve cache dir: {e}")))?; // Try the on-disk cached render first - this still works fine if EXTERNAL_CACHE_ONLY is off. let disk_result: Result<Vec<u8>, std::io::ErrorKind> = async { let canonical_path = tokio::fs::canonicalize(&path).await.map_err(|e| e.kind())?; if !canonical_path.starts_with(&canonical_cache_dir) { return Err(std::io::ErrorKind::NotFound); } tokio::fs::read(&canonical_path).await.map_err(|e| e.kind()) } .await; let bytes: Vec<u8> = match disk_result { Ok(bytes) => bytes, Err(std::io::ErrorKind::NotFound) => { let db_ref = state.db.clone(); let clean_id_value = id_value.split('@').next().unwrap_or(id_value); let clean_id_value = clean_id_value.split('_').next().unwrap_or(clean_id_value); let cache_key_lookup = format!("{id_type}/{id_value}"); let entry_key_id = cache::read_meta_api_key_id(&state.db, &cache_key_lookup).await; let settings: Arc<db::RenderSettings> = if let Some(kid) = entry_key_id { Arc::new(db::get_effective_render_settings(&state.db, kid, None).await) } else { state .global_settings_cache .try_get_with((), async move { let globals = db::get_global_settings(&db_ref).await?; Ok::<_, AppError>(Arc::new(db::parse_global_render_settings(&globals))) }) .await .map_err(|e| AppError::Other(e.to_string()))? }; let (live_bytes, _) = crate::handlers::image::dispatch_image( state, id_type, clean_id_value, &*settings, image_type, None, entry_key_id, ) .await?; live_bytes.to_vec() } Err(_) => { return Err(AppError::IdNotFound(format!("Image not found: {id_type}/{id_value}"))); } }; Ok(( [(axum::http::header::CONTENT_TYPE, content_type.to_string())], bytes, ).into_response()) } async fn fetch_logo_backdrop_image( state: &AppState, id_type: &str, id_value: &str, lb_kind: LogoBackdropKind, content_type: &str, ) -> Result<Response, AppError> { crate::id::IdType::parse(id_type)?; let db_ref = state.db.clone(); let settings = state .global_settings_cache .try_get_with((), async move { let globals = db::get_global_settings(&db_ref).await?; Ok::<_, AppError>(Arc::new(db::parse_global_render_settings(&globals))) }) .await .map_err(|e| AppError::Other(e.to_string()))?; let (bytes, _) = serve::handle_logo_backdrop_inner(state, id_type, id_value, &settings, lb_kind, None, None).await?; Ok(( [(axum::http::header::CONTENT_TYPE, content_type)], bytes, ).into_response()) } // --- Purge (catalogs + library, sourced live from configured Stremio accounts) --- #[derive(Serialize)] pub struct PurgeCatalogEntry { pub account_label: String, pub addon_name: String, pub transport_url: String, pub catalog_id: String, pub catalog_type: String, pub catalog_name: String, } #[derive(Serialize)] pub struct PurgeSourcesResponse { pub catalogs: Vec<PurgeCatalogEntry>, pub library_accounts: Vec<String>, } pub async fn purge_sources( State(state): State<Arc<AppState>>, ) -> Result<Json<PurgeSourcesResponse>, AppError> { let mut catalogs = Vec::new(); let mut library_accounts = Vec::new(); for account in &state.config.stremio_accounts { library_accounts.push(account.label.clone()); let addons = crate::services::stremio_account::get_addon_collection(&state.http, &account.auth_key).await?; let filtered = crate::services::stremio_account::filter_catalog_addons( addons, &state.config.stremio_purge_exclude_addons, ); for addon in filtered { for cat in &addon.manifest.catalogs { let id = cat.get("id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let ctype = cat.get("type").and_then(|v| v.as_str()).unwrap_or_default().to_string(); if id.is_empty() || ctype.is_empty() { continue; } let name = cat .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| format!("{} ({})", addon.manifest.name, ctype)); catalogs.push(PurgeCatalogEntry { account_label: account.label.clone(), addon_name: addon.manifest.name.clone(), transport_url: addon.transport_url.clone(), catalog_id: id, catalog_type: ctype, catalog_name: name, }); } } } Ok(Json(PurgeSourcesResponse { catalogs, library_accounts })) } #[derive(Deserialize)] pub struct LibraryPurgeItemRef { pub id: String, pub item_type: String, } #[derive(Deserialize)] #[serde(tag = "scope", rename_all = "snake_case")] pub enum PurgeRequest { Catalog { transport_url: String, catalog_id: String, catalog_type: String, }, Library { account_label: String, }, LibraryItems { items: Vec<LibraryPurgeItemRef>, }, } #[derive(Serialize)] pub struct PurgeResponse { pub purged: u64, pub cloudflare_ok: bool, pub cloudflare_error: Option<String>, } pub async fn purge( State(state): State<Arc<AppState>>, Json(req): Json<PurgeRequest>, ) -> Result<Json<PurgeResponse>, AppError> { let prefixes: Vec<String> = match req { PurgeRequest::Catalog { transport_url, catalog_id, catalog_type } => { let ids = crate::services::stremio_account::fetch_catalog_ids( &state.http, &transport_url, &catalog_type, &catalog_id, ) .await?; ids.iter() .flat_map(|id| crate::services::stremio_account::id_to_cache_prefixes(id, &catalog_type)) .collect() } PurgeRequest::Library { account_label } => { let account = state .config .stremio_accounts .iter() .find(|a| a.label == account_label) .ok_or_else(|| AppError::BadRequest(format!("unknown stremio account: {account_label}")))?; let items = crate::services::stremio_account::get_library_items(&state.http, &account.auth_key).await?; items.iter().map(|i| format!("imdb/{}", i.id)).collect() } PurgeRequest::LibraryItems { items } => items .iter() .flat_map(|i| crate::services::stremio_account::id_to_cache_prefixes(&i.id, &i.item_type)) .collect(), }; let purged = cache::purge_by_id_prefixes(&state.db, &prefixes).await?; let _ratings_purged = cache::purge_available_ratings(&state.db, &prefixes).await?; let (cloudflare_ok, cloudflare_error) = match crate::services::stremio_account::purge_cloudflare_tags(&state.http, &prefixes).await { Ok(_) => (true, None), Err(e) => (false, Some(e)), }; Ok(Json(PurgeResponse { purged, cloudflare_ok, cloudflare_error })) } pub async fn network_diagnostics() -> axum::Json<serde_json::Value> { use std::time::Instant; let host = "opdb-proxy.fta7700.workers.dev"; let mut results = serde_json::Map::new(); // Stage 1: DNS resolve (IPv4-only, hickory) — isolate resolver timing let mut opts = hickory_resolver::config::ResolverOpts::default(); opts.ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4Only; let resolver = hickory_resolver::TokioAsyncResolver::tokio( hickory_resolver::config::ResolverConfig::default(), opts, ); let t0 = Instant::now(); let lookup = resolver.lookup_ip(host).await; results.insert("dns_ipv4_ms".into(), serde_json::json!(t0.elapsed().as_millis())); let ipv4_addr = match &lookup { Ok(l) => l.iter().next(), Err(e) => { results.insert("dns_ipv4_error".into(), serde_json::json!(e.to_string())); None } }; if let Some(ip) = ipv4_addr { results.insert("resolved_ipv4".into(), serde_json::json!(ip.to_string())); let addr = std::net::SocketAddr::new(ip, 443); // Stage 2: raw TCP connect only, no TLS — isolates routing/firewall from TLS let t1 = Instant::now(); let connect_result = tokio::time::timeout( std::time::Duration::from_secs(10), tokio::net::TcpStream::connect(addr), ).await; let connect_ms = t1.elapsed().as_millis(); match connect_result { Ok(Ok(_stream)) => { results.insert("tcp_connect_ms".into(), serde_json::json!(connect_ms)); results.insert("tcp_connect_ok".into(), serde_json::json!(true)); } Ok(Err(e)) => { results.insert("tcp_connect_ms".into(), serde_json::json!(connect_ms)); results.insert("tcp_connect_ok".into(), serde_json::json!(false)); results.insert("tcp_connect_error".into(), serde_json::json!(e.to_string())); } Err(_) => { results.insert("tcp_connect_ms".into(), serde_json::json!(connect_ms)); results.insert("tcp_connect_ok".into(), serde_json::json!(false)); results.insert("tcp_connect_error".into(), serde_json::json!("timed out after 10s")); } } // Stage 3: full HTTPS round trip via reqwest (default, HTTP/2-eligible), IP pinned // to the resolved address — includes TLS handshake + request send + response. let t2 = Instant::now(); match reqwest::Client::builder() .resolve(host, addr) .timeout(std::time::Duration::from_secs(12)) .build() { Ok(client) => { let resp = client.get(format!("https://{host}/health")).send().await; let full_ms = t2.elapsed().as_millis(); match resp { Ok(r) => { results.insert("https_full_ms".into(), serde_json::json!(full_ms)); results.insert("https_status".into(), serde_json::json!(r.status().as_u16())); } Err(e) => { results.insert("https_full_ms".into(), serde_json::json!(full_ms)); results.insert("https_error".into(), serde_json::json!(e.to_string())); } } } Err(e) => { results.insert("https_client_build_error".into(), serde_json::json!(e.to_string())); } } // Stage 2b: raw TLS handshake only, no HTTP request — isolates TLS itself // from anything reqwest/HTTP-specific. let t1b = Instant::now(); let tls_result = tokio::time::timeout(std::time::Duration::from_secs(10), async { let tcp = tokio::net::TcpStream::connect(addr).await?; let native_connector = native_tls::TlsConnector::new() .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; let connector = tokio_native_tls::TlsConnector::from(native_connector); let _tls_stream = connector.connect(host, tcp).await .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; Ok::<(), std::io::Error>(()) }).await; let tls_ms = t1b.elapsed().as_millis(); match tls_result { Ok(Ok(())) => { results.insert("tls_handshake_only_ms".into(), serde_json::json!(tls_ms)); results.insert("tls_handshake_only_ok".into(), serde_json::json!(true)); } Ok(Err(e)) => { results.insert("tls_handshake_only_ms".into(), serde_json::json!(tls_ms)); results.insert("tls_handshake_only_ok".into(), serde_json::json!(false)); results.insert("tls_handshake_only_error".into(), serde_json::json!(e.to_string())); } Err(_) => { results.insert("tls_handshake_only_ms".into(), serde_json::json!(tls_ms)); results.insert("tls_handshake_only_ok".into(), serde_json::json!(false)); results.insert("tls_handshake_only_error".into(), serde_json::json!("timed out after 10s")); } } // Stage 3b: same request, but forced to HTTP/1.1 only — isolates whether a // PMTU/fragmentation blackhole on larger HTTP/2 handshake/frame packets is the cause. let t2b = Instant::now(); match reqwest::Client::builder() .resolve(host, addr) .timeout(std::time::Duration::from_secs(12)) .http1_only() .build() { Ok(client) => { let resp = client.get(format!("https://{host}/health")).send().await; let full_ms = t2b.elapsed().as_millis(); match resp { Ok(r) => { results.insert("https_h1_full_ms".into(), serde_json::json!(full_ms)); results.insert("https_h1_status".into(), serde_json::json!(r.status().as_u16())); } Err(e) => { results.insert("https_h1_full_ms".into(), serde_json::json!(full_ms)); results.insert("https_h1_error".into(), serde_json::json!(e.to_string())); } } } Err(e) => { results.insert("https_h1_client_build_error".into(), serde_json::json!(e.to_string())); } } } // Stage 2c: control-group TLS handshake test against a completely different, unrelated // HTTPS destination (TMDB — already known-reachable via this backend's normal ratings // fetches). If THIS also hangs, the problem is platform-wide outbound TLS, not // Cloudflare/Worker-specific. If this succeeds while the Worker one hangs, it's specific // to opdb-proxy.fta7700.workers.dev (or Cloudflare's anycast network generally). { let control_host = "api.themoviedb.org"; let t_ctrl = Instant::now(); let control_lookup = resolver.lookup_ip(control_host).await; match control_lookup { Ok(l) => { if let Some(ctrl_ip) = l.iter().next() { let ctrl_addr = std::net::SocketAddr::new(ctrl_ip, 443); results.insert("control_resolved_ip".into(), serde_json::json!(ctrl_ip.to_string())); let tls_result = tokio::time::timeout(std::time::Duration::from_secs(10), async { let tcp = tokio::net::TcpStream::connect(ctrl_addr).await?; let native_connector = native_tls::TlsConnector::new() .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; let connector = tokio_native_tls::TlsConnector::from(native_connector); let _tls_stream = connector.connect(control_host, tcp).await .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; Ok::<(), std::io::Error>(()) }).await; let ctrl_ms = t_ctrl.elapsed().as_millis(); match tls_result { Ok(Ok(())) => { results.insert("control_tls_ms".into(), serde_json::json!(ctrl_ms)); results.insert("control_tls_ok".into(), serde_json::json!(true)); } Ok(Err(e)) => { results.insert("control_tls_ms".into(), serde_json::json!(ctrl_ms)); results.insert("control_tls_ok".into(), serde_json::json!(false)); results.insert("control_tls_error".into(), serde_json::json!(e.to_string())); } Err(_) => { results.insert("control_tls_ms".into(), serde_json::json!(ctrl_ms)); results.insert("control_tls_ok".into(), serde_json::json!(false)); results.insert("control_tls_error".into(), serde_json::json!("timed out after 10s")); } } } } Err(e) => { results.insert("control_dns_error".into(), serde_json::json!(e.to_string())); } } } // Stage 2d: second control — a DIFFERENT Cloudflare-fronted destination (cloudflare.com // itself, not your Worker). If this hangs too, the problem is Cloudflare's network broadly // as seen from this container. If this succeeds while the Worker still hangs, the problem // is specific to opdb-proxy.fta7700.workers.dev / its particular anycast route or zone. { let cf_control_host = "www.cloudflare.com"; let t_cf = Instant::now(); let cf_lookup = resolver.lookup_ip(cf_control_host).await; match cf_lookup { Ok(l) => { if let Some(cf_ip) = l.iter().next() { let cf_addr = std::net::SocketAddr::new(cf_ip, 443); results.insert("cf_control_resolved_ip".into(), serde_json::json!(cf_ip.to_string())); let tls_result = tokio::time::timeout(std::time::Duration::from_secs(10), async { let tcp = tokio::net::TcpStream::connect(cf_addr).await?; let native_connector = native_tls::TlsConnector::new() .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; let connector = tokio_native_tls::TlsConnector::from(native_connector); let _tls_stream = connector.connect(cf_control_host, tcp).await .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; Ok::<(), std::io::Error>(()) }).await; let cf_ms = t_cf.elapsed().as_millis(); match tls_result { Ok(Ok(())) => { results.insert("cf_control_tls_ms".into(), serde_json::json!(cf_ms)); results.insert("cf_control_tls_ok".into(), serde_json::json!(true)); } Ok(Err(e)) => { results.insert("cf_control_tls_ms".into(), serde_json::json!(cf_ms)); results.insert("cf_control_tls_ok".into(), serde_json::json!(false)); results.insert("cf_control_tls_error".into(), serde_json::json!(e.to_string())); } Err(_) => { results.insert("cf_control_tls_ms".into(), serde_json::json!(cf_ms)); results.insert("cf_control_tls_ok".into(), serde_json::json!(false)); results.insert("cf_control_tls_error".into(), serde_json::json!("timed out after 10s")); } } } } Err(e) => { results.insert("cf_control_dns_error".into(), serde_json::json!(e.to_string())); } } } // Stage 2e: third control — Deno Deploy (a different edge/anycast network entirely, // not Cloudflare, not AWS-hosted-origin like TMDB). If this also hangs, the problem // generalizes beyond Cloudflare to edge/anycast networks broadly from this container. // If it succeeds, the problem is specific to Cloudflare's network. { let deno_host = "stable-crocodile-7927.fta.deno.net"; let t_deno = Instant::now(); let deno_lookup = resolver.lookup_ip(deno_host).await; match deno_lookup { Ok(l) => { if let Some(deno_ip) = l.iter().next() { let deno_addr = std::net::SocketAddr::new(deno_ip, 443); results.insert("deno_control_resolved_ip".into(), serde_json::json!(deno_ip.to_string())); let tls_result = tokio::time::timeout(std::time::Duration::from_secs(10), async { let tcp = tokio::net::TcpStream::connect(deno_addr).await?; let native_connector = native_tls::TlsConnector::new() .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; let connector = tokio_native_tls::TlsConnector::from(native_connector); let _tls_stream = connector.connect(deno_host, tcp).await .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; Ok::<(), std::io::Error>(()) }).await; let deno_ms = t_deno.elapsed().as_millis(); match tls_result { Ok(Ok(())) => { results.insert("deno_control_tls_ms".into(), serde_json::json!(deno_ms)); results.insert("deno_control_tls_ok".into(), serde_json::json!(true)); } Ok(Err(e)) => { results.insert("deno_control_tls_ms".into(), serde_json::json!(deno_ms)); results.insert("deno_control_tls_ok".into(), serde_json::json!(false)); results.insert("deno_control_tls_error".into(), serde_json::json!(e.to_string())); } Err(_) => { results.insert("deno_control_tls_ms".into(), serde_json::json!(deno_ms)); results.insert("deno_control_tls_ok".into(), serde_json::json!(false)); results.insert("deno_control_tls_error".into(), serde_json::json!("timed out after 10s")); } } } } Err(e) => { results.insert("deno_control_dns_error".into(), serde_json::json!(e.to_string())); } } } // Stage 4: raw IPv6 connect test against a known-good public IPv6 address (Cloudflare 1.1.1.1), // bypassing DNS entirely — directly answers whether this container can route outbound IPv6 at all // (worklog Open Item 25 — never directly tested before now). let ipv6_target: std::net::SocketAddr = "[2606:4700:4700::1111]:443".parse().unwrap(); let t3 = Instant::now(); let ipv6_result = tokio::time::timeout( std::time::Duration::from_secs(8), tokio::net::TcpStream::connect(ipv6_target), ).await; let ipv6_ms = t3.elapsed().as_millis(); match ipv6_result { Ok(Ok(_)) => { results.insert("ipv6_raw_connect_ms".into(), serde_json::json!(ipv6_ms)); results.insert("ipv6_raw_connect_ok".into(), serde_json::json!(true)); } Ok(Err(e)) => { results.insert("ipv6_raw_connect_ms".into(), serde_json::json!(ipv6_ms)); results.insert("ipv6_raw_connect_ok".into(), serde_json::json!(false)); results.insert("ipv6_raw_connect_error".into(), serde_json::json!(e.to_string())); } Err(_) => { results.insert("ipv6_raw_connect_ms".into(), serde_json::json!(ipv6_ms)); results.insert("ipv6_raw_connect_ok".into(), serde_json::json!(false)); results.insert("ipv6_raw_connect_error".into(), serde_json::json!("timed out after 8s")); } } axum::Json(serde_json::Value::Object(results)) } // --- Library Cleanup (audit poster origin, soft-delete stale/duplicate entries) --- #[derive(Serialize)] pub struct LibraryAuditItem { pub account_label: String, pub id: String, pub name: String, pub item_type: String, pub poster: String, pub origin: String, } #[derive(Serialize)] pub struct LibraryAuditResponse { pub items: Vec<LibraryAuditItem>, } pub async fn library_audit( State(state): State<Arc<AppState>>, ) -> Result<Json<LibraryAuditResponse>, AppError> { let mut items = Vec::new(); for account in &state.config.stremio_accounts { let raw = crate::services::stremio_account::get_library_items_raw(&state.http, &account.auth_key).await?; for item in raw { let id = item.get("_id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let name = item.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let poster = item.get("poster").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let origin = crate::services::stremio_account::classify_poster_origin(&poster).to_string(); items.push(LibraryAuditItem { account_label: account.label.clone(), id, name, item_type, poster, origin, }); } } Ok(Json(LibraryAuditResponse { items })) } #[derive(Deserialize)] pub struct LibraryRefreshRequest { pub account_label: String, pub ids: Vec<String>, } #[derive(Serialize)] pub struct LibraryRefreshResult { pub id: String, pub name: String, pub success: bool, pub error: Option<String>, } #[derive(Serialize)] pub struct LibraryRefreshResponse { pub results: Vec<LibraryRefreshResult>, pub refreshed: usize, } /// Non-destructive: patches poster/background/logo on existing Library items /// via AIOMetadata's current data. Never sets `removed` -- watch state, /// group membership, and the item itself are always preserved. pub async fn library_refresh( State(state): State<Arc<AppState>>, Json(req): Json<LibraryRefreshRequest>, ) -> Result<Json<LibraryRefreshResponse>, AppError> { let account = state .config .stremio_accounts .iter() .find(|a| a.label == req.account_label) .ok_or_else(|| AppError::BadRequest(format!("unknown stremio account: {}", req.account_label)))?; let raw = crate::services::stremio_account::get_library_items_raw(&state.http, &account.auth_key).await?; let mut changes = Vec::new(); let mut results = Vec::new(); for mut item in raw { let id = item.get("_id").and_then(|v| v.as_str()).unwrap_or_default().to_string(); if !req.ids.contains(&id) { continue; } let name = item.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("movie").to_string(); match crate::services::stremio_account::fetch_fresh_meta(&state.http, &id, &item_type).await { Ok((Some(poster), background, logo)) if poster.contains("opdb-proxy") => { if let Some(obj) = item.as_object_mut() { obj.insert("poster".to_string(), serde_json::Value::String(poster)); if let Some(bg) = background { obj.insert("background".to_string(), serde_json::Value::String(bg)); } if let Some(lg) = logo { obj.insert("logo".to_string(), serde_json::Value::String(lg)); } } changes.push(item); results.push(LibraryRefreshResult { id, name, success: true, error: None }); } Ok((Some(_), _, _)) => { results.push(LibraryRefreshResult { id, name, success: false, error: Some("AIOMetadata poster not OPDB-served yet".into()) }); } Ok((None, _, _)) => { results.push(LibraryRefreshResult { id, name, success: false, error: Some("no poster returned by AIOMetadata".into()) }); } Err(e) => { results.push(LibraryRefreshResult { id, name, success: false, error: Some(e.to_string()) }); } } } let refreshed = changes.len(); if !changes.is_empty() { crate::services::stremio_account::datastore_put(&state.http, &account.auth_key, changes).await?; } Ok(Json(LibraryRefreshResponse { results, refreshed })) } #[derive(serde::Deserialize)] pub struct LibraryStalenessQuery { pub account_label: String, pub key_id: i32, } #[derive(Serialize)] pub struct LibraryStalenessItem { pub id: String, pub name: String, pub item_type: String, /// "actual" (rendered after current settings), "outdated" (rendered /// before current settings), or "not_rendered" (no render exists at /// all — never viewed, or purged and not yet re-requested). pub status: &'static str, } #[derive(Serialize)] pub struct LibraryStalenessResponse { pub items: Vec<LibraryStalenessItem>, } /// For each Library item on the given account, computes the `cache_key` it /// would have if rendered under `key_id`'s *current* settings, then checks /// which of those keys actually exist in `image_meta`. Missing = stale (only /// old-settings renders exist, or the title has never been rendered at all). /// /// Approximation, not exact: the predicted ratings suffix assumes all /// configured rating sources are available for every title. Titles with /// partial rating-source availability can render under a slightly different /// actual suffix, so this can occasionally misclassify a handful of items. /// Scoped to posters only for v1. /// Staleness is determined by comparing timestamps, not by predicting the /// rendered cache_key: predicting the exact suffix requires assuming which /// rating sources will actually have data for a given title, and that /// assumption is frequently wrong (e.g. Letterboxd has no ratings for TV /// series at all, so any series' real suffix diverges from an "all sources /// present" prediction regardless of whether it's actually up to date). /// Instead: a Library item is fresh if it has an `image_meta` row (any /// suffix) whose `updated_at` is newer than the API key's settings /// `updated_at`. Coarser than exact-key matching (doesn't know whether a /// specific setting change affects a specific title's badges), but immune /// to the suffix-prediction-mismatch problem. pub async fn library_staleness( State(state): State<Arc<AppState>>, Query(query): Query<LibraryStalenessQuery>, ) -> Result<Json<LibraryStalenessResponse>, AppError> { let account = state .config .stremio_accounts .iter() .find(|a| a.label == query.account_label) .ok_or_else(|| AppError::Other("account not found".into()))?; let raw_items = crate::services::stremio_account::get_library_items_raw( &state.http, &account.auth_key, ) .await?; let settings_updated_at = db::get_api_key_settings_updated_at(&state.db, query.key_id) .await? .unwrap_or(0); let mut id_prefixes: Vec<(String, String, String, String)> = Vec::new(); for raw in &raw_items { let id = raw .get("_id") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let name = raw .get("name") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let item_type = raw .get("type") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let prefixes = crate::services::stremio_account::id_to_cache_prefixes(&id, &item_type); if let Some(prefix) = prefixes.first() { id_prefixes.push((id, name, item_type, prefix.clone())); } } let last_rendered = cache::max_updated_at_by_prefix(&state.db, &id_prefixes.iter().map(|(_, _, _, p)| p.clone()).collect::<Vec<_>>()) .await?; let items = id_prefixes .into_iter() .map(|(id, name, item_type, prefix)| { let status = match last_rendered.get(&prefix) { None => "not_rendered", Some(&rendered_at) if rendered_at < settings_updated_at => "outdated", Some(_) => "actual", }; LibraryStalenessItem { status, id, name, item_type, } }) .collect(); Ok(Json(LibraryStalenessResponse { items })) }
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::cache;
use crate::error::AppError;
use crate::image::serve::{self, LogoBackdropKind};
use crate::services::db::{self, default_ratings_limit, default_logo_backdrop_ratings_limit, default_ratings_order, BadgeBackground, BadgeDirection, BadgeShape, BadgeSize, BadgeStyle, LabelStyle, BadgePosition, ImageSource, PosterFit};
use crate::AppState;
#[derive(Serialize)]
pub struct StatsResponse {
pub total_images: u64,
pub total_api_keys: u64,
pub mem_cache_entries: u64,
pub id_cache_entries: u64,
pub ratings_cache_entries: u64,
pub image_mem_cache_mb: u64,
}
pub async fn stats(State(state): State<Arc<AppState>>) -> Result<Json<StatsResponse>, AppError> {
let total_images = db::count_image_meta(&state.db).await?;
let total_api_keys = db::count_api_keys(&state.db).await?;
let mem_cache_entries = state.image_mem_cache.entry_count();
let id_cache_entries = state.id_cache.entry_count();
let ratings_cache_entries = state.ratings_cache.entry_count();
let image_mem_cache_mb = state.image_mem_cache.weighted_size() / (1024 * 1024);
Ok(Json(StatsResponse {
total_images,
total_api_keys,
mem_cache_entries,
id_cache_entries,
ratings_cache_entries,
image_mem_cache_mb,
}))
}
#[derive(Deserialize)]
pub struct ListImagesQuery {
#[serde(default = "default_page")]
pub page: u64,
#[serde(default = "default_page_size")]
pub page_size: u64,
}
fn default_page() -> u64 {
1
}
fn default_page_size() -> u64 {
50
}
#[derive(Serialize)]
pub struct ImageMetaItem {
pub cache_key: String,
pub release_date: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub api_key_alias: Option<String>,
}
#[derive(Serialize)]
pub struct ListImagesResponse {
pub items: Vec<ImageMetaItem>,
pub total: u64,
pub page: u64,
pub page_size: u64,
}
async fn list_images(
state: &AppState,
query: &ListImagesQuery,
image_type: cache::ImageType,
) -> Result<Json<ListImagesResponse>, AppError> {
let page = query.page.max(1);
let page_size = query.page_size.clamp(1, 100);
let (items, total) = db::list_image_meta_by_kind(&state.db, image_type, page, page_size).await?;
let key_ids: Vec<i32> = items.iter().filter_map(|m| m.api_key_id).collect();
let alias_map = db::get_api_key_names(&state.db, &key_ids).await?;
let items = items
.into_iter()
.map(|m| ImageMetaItem {
cache_key: m.cache_key,
release_date: m.release_date,
created_at: m.created_at,
updated_at: m.updated_at,
api_key_alias: m.api_key_id.and_then(|id| alias_map.get(&id).cloned()),
})
.collect();
Ok(Json(ListImagesResponse { items, total, page, page_size }))
}