a
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)] = &[];