pub mod cache; pub mod cleanup; pub mod config; pub mod entity; pub mod error; pub mod handlers; pub mod id; pub mod image; pub mod routes; pub mod services; pub mod upgrade; use std::sync::Arc; use ab_glyph::FontArc; use dashmap::DashMap; use sea_orm::DatabaseConnection; use zeroize::Zeroizing; use cache::MemCacheEntry; use config::Config; use id::ResolvedId; use services::db::RenderSettings; use services::fanart::{FanartClient, FanartImages}; use services::mdblist::MdblistClient; use services::omdb::OmdbClient; use services::tmdb::TmdbClient; use services::trakt::TraktClient; pub use routes::build_app; #[derive(Clone)] pub struct AppState { pub config: Config, pub http: reqwest::Client, pub tmdb: TmdbClient, pub omdb: Option, pub mdblist: Option, pub trakt: Option, pub font: FontArc, pub refresh_locks: moka::sync::Cache, pub db: DatabaseConnection, pub jwt_secret: Zeroizing>, pub secure_cookies: bool, pub api_key_cache: moka::future::Cache>, pub image_inflight: moka::future::Cache, pub id_cache: moka::future::Cache, pub ratings_cache: moka::future::Cache, pub image_mem_cache: moka::future::Cache, pub pending_last_used: Arc>, pub fanart: Option, pub fanart_cache: moka::future::Cache>, /// Tracks negative fanart results — e.g. "movie:123:textless" means no textless poster exists. /// Entries expire after the same TTL as fanart_cache so we recheck periodically. pub fanart_negative: moka::future::Cache, pub tmdb_images_cache: moka::future::Cache>, pub settings_cache: moka::future::Cache>, pub global_settings_cache: moka::future::Cache<(), Arc>, pub preview_cache: moka::future::Cache, pub free_api_key_cache: moka::future::Cache<(), bool>, pub render_semaphore: Arc, pub cross_id_semaphore: Arc, /// Maps settings hash → RenderSettings for content-addressed `/c/` CDN routes. /// Populated lazily when API key requests produce redirects. pub settings_hash_registry: moka::future::Cache, Option)>, /// In-memory cache for `available_ratings` lookups. /// Avoids hitting the database on every image request when the entry is already known. pub available_ratings_cache: moka::future::Cache>, } impl AppState { pub async fn is_free_api_key_enabled(&self) -> bool { if let Some(val) = self.config.free_key_enabled { return val; } let db_ref = self.db.clone(); self.free_api_key_cache .try_get_with((), async move { let globals = services::db::get_global_settings(&db_ref).await?; let val = globals.get("free_api_key_enabled").map(|v| v.as_str()); Ok::<_, error::AppError>(val == Some("true")) }) .await .unwrap_or(false) } } pub static FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/Inter-Bold.ttf"); #[derive(utoipa::OpenApi)] #[openapi( info( title = "OpenPosterDB API", description = "API for generating and serving posters, logos, and backdrops with rating overlays for movies, TV shows, and collections.", license(name = "MIT"), ), tags( (name = "Images", description = "Poster, logo, backdrop, and episode image endpoints"), (name = "Auth", description = "API key validation"), ), servers((url = "/", description = "This instance")), paths( handlers::image::handler, handlers::image::logo_handler, handlers::image::backdrop_handler, handlers::image::episode_handler, handlers::image::is_valid_handler, ), components(schemas( handlers::image::IdTypeParam, handlers::image::ImageSizeParam, )), )] pub struct ApiDoc; #[cfg(test)] mod tests { use super::*; use utoipa::OpenApi; #[test] fn openapi_spec_has_expected_paths() { let spec = ApiDoc::openapi(); let paths: Vec<&str> = spec.paths.paths.keys().map(|k: &String| k.as_str()).collect(); assert!(paths.contains(&"/{api_key}/{id_type}/poster-default/{id_value}")); assert!(paths.contains(&"/{api_key}/{id_type}/logo-default/{id_value}")); assert!(paths.contains(&"/{api_key}/{id_type}/backdrop-default/{id_value}")); assert!(paths.contains(&"/{api_key}/{id_type}/episode-default/{id_value}")); assert!(paths.contains(&"/{api_key}/isValid")); assert_eq!(paths.len(), 5); } #[test] fn openapi_spec_serializes_to_valid_json() { let spec = ApiDoc::openapi(); let json = spec.to_json().expect("spec should serialize to JSON"); let parsed: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON"); assert_eq!(parsed["openapi"], "3.1.0"); assert_eq!(parsed["info"]["title"], "OpenPosterDB API"); } } pub const SCHEMA_SQL: &[&str] = &[ "CREATE TABLE IF NOT EXISTS image_meta ( cache_key TEXT PRIMARY KEY, release_date TEXT, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, image_type TEXT NOT NULL DEFAULT 'poster', api_key_id INTEGER )", "CREATE INDEX IF NOT EXISTS idx_image_meta_type_created ON image_meta(image_type, created_at DESC)", "CREATE TABLE IF NOT EXISTS admin_users ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (NOW()::TEXT) )", "CREATE TABLE IF NOT EXISTS refresh_tokens ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (NOW()::TEXT) )", "CREATE TABLE IF NOT EXISTS api_keys ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, key_prefix TEXT NOT NULL, created_by INTEGER NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (NOW()::TEXT), last_used_at TEXT )", "CREATE TABLE IF NOT EXISTS global_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL )", "CREATE TABLE IF NOT EXISTS available_ratings ( id_key TEXT PRIMARY KEY, sources TEXT NOT NULL, updated_at BIGINT NOT NULL, release_date TEXT )", "CREATE INDEX IF NOT EXISTS idx_available_ratings_updated_at ON available_ratings(updated_at)", "CREATE TABLE IF NOT EXISTS api_key_settings ( api_key_id INTEGER PRIMARY KEY REFERENCES api_keys(id) ON DELETE CASCADE, image_source TEXT NOT NULL DEFAULT 't', lang TEXT NOT NULL DEFAULT 'en', textless BOOLEAN NOT NULL DEFAULT FALSE, ratings_limit INTEGER NOT NULL DEFAULT 3, ratings_order TEXT NOT NULL DEFAULT 'mal,imdb,lb,rt,mc,rta,tmdb,trakt,mdblist,ebert', poster_position TEXT NOT NULL DEFAULT 'bc', logo_ratings_limit INTEGER NOT NULL DEFAULT 5, backdrop_ratings_limit INTEGER NOT NULL DEFAULT 5, poster_badge_style TEXT NOT NULL DEFAULT 'h', logo_badge_style TEXT NOT NULL DEFAULT 'v', backdrop_badge_style TEXT NOT NULL DEFAULT 'v', poster_label_style TEXT NOT NULL DEFAULT 'i', logo_label_style TEXT NOT NULL DEFAULT 'i', backdrop_label_style TEXT NOT NULL DEFAULT 'i', poster_badge_direction TEXT NOT NULL DEFAULT 'd', poster_badge_size TEXT NOT NULL DEFAULT 'm', logo_badge_size TEXT NOT NULL DEFAULT 'm', backdrop_badge_size TEXT NOT NULL DEFAULT 'm', backdrop_position TEXT NOT NULL DEFAULT 'tr', backdrop_badge_direction TEXT NOT NULL DEFAULT 'd', episode_ratings_limit INTEGER NOT NULL DEFAULT 1, episode_badge_style TEXT NOT NULL DEFAULT 'v', episode_label_style TEXT NOT NULL DEFAULT 'o', episode_badge_size TEXT NOT NULL DEFAULT 'l', episode_position TEXT NOT NULL DEFAULT 'tr', episode_badge_direction TEXT NOT NULL DEFAULT 'v', episode_blur BOOLEAN NOT NULL DEFAULT FALSE, ratings_exclude TEXT NOT NULL DEFAULT '', poster_badge_shape TEXT NOT NULL DEFAULT 'r', logo_badge_shape TEXT NOT NULL DEFAULT 'r', backdrop_badge_shape TEXT NOT NULL DEFAULT 'r', episode_badge_shape TEXT NOT NULL DEFAULT 'r', poster_badge_background TEXT NOT NULL DEFAULT 'd', logo_badge_background TEXT NOT NULL DEFAULT 'd', backdrop_badge_background TEXT NOT NULL DEFAULT 'd', episode_badge_background TEXT NOT NULL DEFAULT 'd', poster_badge_split BOOLEAN NOT NULL DEFAULT FALSE, poster_fit TEXT NOT NULL DEFAULT 'native', backdrop_edge_inset_x INTEGER NOT NULL DEFAULT 0, backdrop_edge_inset_y INTEGER NOT NULL DEFAULT 0 )", ]; /// No incremental migrations needed — all columns are defined in SCHEMA_SQL /// for fresh Postgres installs. This array is kept for structural compatibility /// with the startup migration loop in main.rs. pub const MIGRATIONS: &[(&str, &str)] = &[];