Spaces:
Paused
Paused
FTA7700
Add Deno Deploy control test to isolate Cloudflare-specific vs edge-network-general stall
ed0e1f4 Download api/src/handlers/admin.rs from stremio-db/a: direct link, hf CLI and curl.
- Browser
- Download file 53.8 kB
-
https://huggingface.co/spaces/stremio-db/a/resolve/main/api/src/handlers/admin.rs
- Command line
-
hf download hf://spaces/stremio-db/a/api/src/handlers/admin.rs
-
curl -L -o admin.rs https://huggingface.co/spaces/stremio-db/a/resolve/main/api/src/handlers/admin.rs
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;
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,
}))
}
pub struct ListImagesQuery {
pub page: u64,
pub page_size: u64,
}
fn default_page() -> u64 {
1
}
fn default_page_size() -> u64 {
50
}
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>,
}
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 }))
}