Spaces:
Paused
Paused
File size: 9,985 Bytes
1ad3b12 dd7a3f0 0c21886 dd7a3f0 094c13f dd7a3f0 0c21886 a5bc115 dd7a3f0 7f22b24 dd7a3f0 b309110 dd7a3f0 ef14e02 dd7a3f0 7f22b24 65c3047 dd7a3f0 0c21886 dd7a3f0 a8a0f13 0c21886 dd7a3f0 fbc8659 a5bc115 fbc8659 094c13f 0c21886 c63b398 cb0280f d41428b a8a0f13 0c21886 331eded 6948b18 8e0d072 0c21886 84c880b cb0280f eb8ae7b cb0280f dd7a3f0 5203922 76b8a0b 5203922 76b8a0b 5203922 676a62f 5203922 76b8a0b 5203922 76b8a0b 5203922 dd7a3f0 0c21886 8e0d072 dd7a3f0 8e0d072 bb0b9d3 dd7a3f0 8e0d072 dd7a3f0 8e0d072 dd7a3f0 8e0d072 dd7a3f0 8e0d072 dd7a3f0 8e0d072 dd7a3f0 8e0d072 dd7a3f0 8e0d072 dd7a3f0 fbc8659 84c880b 8e0d072 84c880b 8e0d072 fbc8659 8e0d072 fbc8659 dd7a3f0 8e0d072 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | 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<OmdbClient>,
pub mdblist: Option<MdblistClient>,
pub trakt: Option<TraktClient>,
pub font: FontArc,
pub refresh_locks: moka::sync::Cache<String, ()>,
pub db: DatabaseConnection,
pub jwt_secret: Zeroizing<Vec<u8>>,
pub secure_cookies: bool,
pub api_key_cache: moka::future::Cache<String, Option<i32>>,
pub image_inflight: moka::future::Cache<String, bytes::Bytes>,
pub id_cache: moka::future::Cache<String, ResolvedId>,
pub ratings_cache: moka::future::Cache<String, services::ratings::RatingsResult>,
pub image_mem_cache: moka::future::Cache<String, MemCacheEntry>,
pub pending_last_used: Arc<DashMap<i32, ()>>,
pub fanart: Option<FanartClient>,
pub fanart_cache: moka::future::Cache<String, Arc<FanartImages>>,
/// 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<String, ()>,
pub tmdb_images_cache: moka::future::Cache<String, Arc<services::tmdb::TmdbImagesResponse>>,
pub settings_cache: moka::future::Cache<i32, Arc<RenderSettings>>,
pub global_settings_cache: moka::future::Cache<(), Arc<RenderSettings>>,
pub preview_cache: moka::future::Cache<String, bytes::Bytes>,
pub free_api_key_cache: moka::future::Cache<(), bool>,
pub render_semaphore: Arc<tokio::sync::Semaphore>,
pub cross_id_semaphore: Arc<tokio::sync::Semaphore>,
/// 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<String, (Arc<RenderSettings>, Option<i32>)>,
/// 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<String, Option<String>>,
}
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)] = &[];
|