a
File size: 13,673 Bytes
0fcb5c2
 
be73249
0fcb5c2
 
9fb029b
0fcb5c2
c1dd29e
0fcb5c2
4612e64
 
0c21886
 
edfac31
0c21886
dd7a3f0
5f0e2ae
fbc8659
7f22b24
331eded
4256175
eb8ae7b
5cc036a
ef14e02
 
 
 
 
 
 
 
0fcb5c2
 
be73249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f22b24
be73249
 
 
5cc036a
ef14e02
 
 
 
 
be73249
 
 
 
24bde80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef14e02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0fcb5c2
 
9fb029b
0fcb5c2
24bde80
0fcb5c2
36ac815
0fcb5c2
4612e64
0fcb5c2
 
 
4612e64
 
 
 
0c21886
0fcb5c2
 
 
0c21886
0fcb5c2
 
 
24bde80
0c21886
54e4063
 
 
dd7a3f0
5f0e2ae
24bde80
 
331eded
 
 
4256175
 
 
eb8ae7b
 
 
5cc036a
 
 
ef14e02
 
9fb029b
 
7f22b24
 
 
 
 
 
 
0fcb5c2
9fb029b
 
0fcb5c2
 
6d06b8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f22b24
6d06b8a
 
 
5cc036a
6d06b8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24bde80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d06b8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f22b24
 
 
6d06b8a
 
 
 
 
 
 
 
 
 
 
 
 
 
5cc036a
6d06b8a
 
 
 
5cc036a
6d06b8a
 
 
 
 
 
 
 
 
 
 
 
5cc036a
6d06b8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
use std::env;

#[derive(Clone)]
pub struct Config {
    pub tmdb_api_key: String,
    pub omdb_api_key: Option<String>,
    pub cache_dir: String,
    pub db_dir: String,
    pub listen_addr: String,
    pub ratings_min_stale_secs: u64,
    pub ratings_max_age_secs: u64,
    pub image_stale_secs: u64,
    pub image_quality: u8,
    pub mdblist_api_key: Option<String>,
    pub image_mem_cache_mb: u64,
    pub static_dir: Option<String>,
    pub cors_origin: Option<String>,
    pub fanart_api_key: Option<String>,
    pub trakt_client_id: Option<String>,
    pub enable_cdn_redirects: bool,
    pub external_cache_only: bool,
    pub free_key_enabled: Option<bool>,
    pub disable_public_pages: bool,
    pub stremio_accounts: Vec<StremioAccount>,
    pub stremio_purge_exclude_addons: Vec<String>,
}

#[derive(Clone)]
pub struct StremioAccount {
    pub label: String,
    pub auth_key: String,
}

impl std::fmt::Debug for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Config")
            .field("tmdb_api_key", &"[REDACTED]")
            .field("omdb_api_key", &self.omdb_api_key.as_ref().map(|_| "[REDACTED]"))
            .field("cache_dir", &self.cache_dir)
            .field("db_dir", &self.db_dir)
            .field("listen_addr", &self.listen_addr)
            .field("ratings_min_stale_secs", &self.ratings_min_stale_secs)
            .field("ratings_max_age_secs", &self.ratings_max_age_secs)
            .field("image_stale_secs", &self.image_stale_secs)
            .field("image_quality", &self.image_quality)
            .field("mdblist_api_key", &self.mdblist_api_key.as_ref().map(|_| "[REDACTED]"))
            .field("image_mem_cache_mb", &self.image_mem_cache_mb)
            .field("static_dir", &self.static_dir)
            .field("cors_origin", &self.cors_origin)
            .field("fanart_api_key", &self.fanart_api_key.as_ref().map(|_| "[REDACTED]"))
            .field("trakt_client_id", &self.trakt_client_id.as_ref().map(|_| "[REDACTED]"))
            .field("enable_cdn_redirects", &self.enable_cdn_redirects)
            .field("external_cache_only", &self.external_cache_only)
            .field("free_key_enabled", &self.free_key_enabled)
            .field("disable_public_pages", &self.disable_public_pages)
            .field(
                "stremio_accounts",
                &self.stremio_accounts.iter().map(|a| a.label.clone()).collect::<Vec<_>>(),
            )
            .field("stremio_purge_exclude_addons", &self.stremio_purge_exclude_addons)
            .finish()
    }
}

/// Read an optional secret env var, trimming surrounding whitespace and treating
/// an empty (or whitespace-only) value as absent.
///
/// Without this a blank or whitespace/CR/quote-contaminated value (a common
/// deployment foot-gun: CRLF `.env` files, mis-quoted shell) would still be
/// `Some(_)` and construct a live client that sends a junk key on every request.
/// For Trakt that surfaces as a 403 on *every* title rather than the provider
/// simply being disabled.
fn optional_secret(key: &str) -> Option<String> {
    env::var(key)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Parses up to 10 numbered Stremio account pairs:
/// STREMIO_ACCOUNT_USER_{n} and STREMIO_ACCOUNT_AUTHKEY_{n} (n = 1..10).
/// Both must be present (and non-empty) for slot n to count.
fn parse_stremio_accounts() -> Vec<StremioAccount> {
    let mut accounts = Vec::new();
    for i in 1..=10 {
        let label = optional_secret(&format!("STREMIO_ACCOUNT_USER_{i}"));
        let auth_key = optional_secret(&format!("STREMIO_ACCOUNT_AUTHKEY_{i}"));
        if let (Some(label), Some(auth_key)) = (label, auth_key) {
            accounts.push(StremioAccount { label, auth_key });
        }
    }
    accounts
}

/// Parses a comma-separated list env var into trimmed, non-empty entries.
fn parse_exclude_list(key: &str) -> Vec<String> {
    env::var(key)
        .ok()
        .map(|v| {
            v.split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

impl Config {
    pub fn from_env() -> Self {
        let config = Self {
            tmdb_api_key: env::var("TMDB_API_KEY").expect("TMDB_API_KEY must be set"),
            omdb_api_key: optional_secret("OMDB_API_KEY"),
            cache_dir: env::var("CACHE_DIR").unwrap_or_else(|_| "./cache".into()),
            db_dir: env::var("DB_DIR").unwrap_or_else(|_| "./db".into()),
            listen_addr: env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
            ratings_min_stale_secs: env::var("RATINGS_STALE_SECS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(86400),
            ratings_max_age_secs: env::var("RATINGS_MAX_AGE_SECS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(31_536_000),
            image_stale_secs: env::var("IMAGE_STALE_SECS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(0),
            image_quality: env::var("IMAGE_QUALITY")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(85),
            mdblist_api_key: optional_secret("MDBLIST_API_KEY"),
            image_mem_cache_mb: env::var("IMAGE_MEM_CACHE_MB")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(512),
            static_dir: env::var("STATIC_DIR").ok(),
            cors_origin: env::var("CORS_ORIGIN").ok(),
            fanart_api_key: optional_secret("FANART_API_KEY"),
            trakt_client_id: optional_secret("TRAKT_CLIENT_ID"),
            enable_cdn_redirects: env::var("ENABLE_CDN_REDIRECTS")
                .map(|v| v == "true" || v == "1")
                .unwrap_or(false),
            external_cache_only: env::var("EXTERNAL_CACHE_ONLY")
                .map(|v| v == "true" || v == "1")
                .unwrap_or(false),
            free_key_enabled: env::var("FREE_KEY_ENABLED")
                .ok()
                .map(|v| v == "true" || v == "1"),
            disable_public_pages: env::var("DISABLE_PUBLIC_PAGES")
                .map(|v| v == "true" || v == "1")
                .unwrap_or(false),
            stremio_accounts: parse_stremio_accounts(),
            stremio_purge_exclude_addons: parse_exclude_list("STREMIO_PURGE_EXCLUDE_ADDONS"),
        };

        if config.omdb_api_key.is_none()
            && config.mdblist_api_key.is_none()
            && config.trakt_client_id.is_none()
        {
            panic!(
                "at least one of OMDB_API_KEY, MDBLIST_API_KEY, or TRAKT_CLIENT_ID must be set"
            );
        }

        config
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use std::env;

    /// Helper: remove all env vars that Config::from_env reads.
    ///
    /// # Safety
    /// Tests using this must run serially (via `#[serial]`) to avoid races.
    unsafe fn clear_config_env() {
        for key in [
            "TMDB_API_KEY",
            "OMDB_API_KEY",
            "MDBLIST_API_KEY",
            "CACHE_DIR",
            "DB_DIR",
            "LISTEN_ADDR",
            "RATINGS_STALE_SECS",
            "RATINGS_MAX_AGE_SECS",
            "IMAGE_STALE_SECS",
            "IMAGE_QUALITY",
            "IMAGE_MEM_CACHE_MB",
            "STATIC_DIR",
            "CORS_ORIGIN",
            "FANART_API_KEY",
            "TRAKT_CLIENT_ID",
            "ENABLE_CDN_REDIRECTS",
            "EXTERNAL_CACHE_ONLY",
            "FREE_KEY_ENABLED",
            "DISABLE_PUBLIC_PAGES",
        ] {
            unsafe { env::remove_var(key) };
        }
    }

    #[test]
    #[serial]
    fn test_valid_config_with_omdb() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };

        let cfg = Config::from_env();
        assert_eq!(cfg.tmdb_api_key, "tmdb_test");
        assert_eq!(cfg.omdb_api_key.as_deref(), Some("omdb_test"));
        assert!(cfg.mdblist_api_key.is_none());
        assert_eq!(cfg.cache_dir, "./cache");
        assert_eq!(cfg.db_dir, "./db");
        assert_eq!(cfg.listen_addr, "0.0.0.0:3000");
        assert_eq!(cfg.ratings_min_stale_secs, 86400);
        assert_eq!(cfg.ratings_max_age_secs, 31_536_000);
        assert_eq!(cfg.image_stale_secs, 0);
        assert_eq!(cfg.image_quality, 85);
        assert_eq!(cfg.image_mem_cache_mb, 512);
        assert!(!cfg.enable_cdn_redirects);
        assert!(!cfg.external_cache_only);
        assert!(cfg.free_key_enabled.is_none());
    }

    #[test]
    #[serial]
    fn test_valid_config_with_mdblist() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("MDBLIST_API_KEY", "mdblist_test") };

        let cfg = Config::from_env();
        assert!(cfg.omdb_api_key.is_none());
        assert_eq!(cfg.mdblist_api_key.as_deref(), Some("mdblist_test"));
    }

    #[test]
    #[serial]
    fn test_optional_secret_trims_and_drops_empty() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        // A whitespace-padded value is trimmed (CRLF .env / mis-quoted shell).
        unsafe { env::set_var("OMDB_API_KEY", "  omdb_test  ") };
        // Empty / whitespace-only optional keys are treated as absent so they
        // never build a client that 403s on every request (the Trakt symptom).
        unsafe { env::set_var("MDBLIST_API_KEY", "") };
        unsafe { env::set_var("TRAKT_CLIENT_ID", "   ") };

        let cfg = Config::from_env();
        assert_eq!(cfg.omdb_api_key.as_deref(), Some("omdb_test"), "value is trimmed");
        assert!(cfg.mdblist_api_key.is_none(), "empty key treated as absent");
        assert!(cfg.trakt_client_id.is_none(), "whitespace-only key treated as absent");
    }

    #[test]
    #[serial]
    fn test_custom_numeric_overrides() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };
        unsafe { env::set_var("RATINGS_STALE_SECS", "100") };
        unsafe { env::set_var("RATINGS_MAX_AGE_SECS", "200") };
        unsafe { env::set_var("IMAGE_STALE_SECS", "300") };
        unsafe { env::set_var("IMAGE_QUALITY", "50") };
        unsafe { env::set_var("IMAGE_MEM_CACHE_MB", "1024") };

        let cfg = Config::from_env();
        assert_eq!(cfg.ratings_min_stale_secs, 100);
        assert_eq!(cfg.ratings_max_age_secs, 200);
        assert_eq!(cfg.image_stale_secs, 300);
        assert_eq!(cfg.image_quality, 50);
        assert_eq!(cfg.image_mem_cache_mb, 1024);
    }

    #[test]
    #[serial]
    fn test_invalid_numeric_falls_back_to_default() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };
        unsafe { env::set_var("IMAGE_QUALITY", "notanumber") };
        unsafe { env::set_var("RATINGS_STALE_SECS", "abc") };

        let cfg = Config::from_env();
        assert_eq!(cfg.image_quality, 85);
        assert_eq!(cfg.ratings_min_stale_secs, 86400);
    }

    #[test]
    #[serial]
    #[should_panic(expected = "TMDB_API_KEY must be set")]
    fn test_panics_without_tmdb_key() {
        unsafe { clear_config_env() };
        Config::from_env();
    }

    #[test]
    #[serial]
    #[should_panic(
        expected = "at least one of OMDB_API_KEY, MDBLIST_API_KEY, or TRAKT_CLIENT_ID must be set"
    )]
    fn test_panics_without_ratings_provider() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        Config::from_env();
    }

    #[test]
    #[serial]
    fn test_boolean_env_true() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };
        unsafe { env::set_var("ENABLE_CDN_REDIRECTS", "true") };
        unsafe { env::set_var("EXTERNAL_CACHE_ONLY", "1") };
        unsafe { env::set_var("DISABLE_PUBLIC_PAGES", "true") };

        let cfg = Config::from_env();
        assert!(cfg.enable_cdn_redirects);
        assert!(cfg.external_cache_only);
        assert!(cfg.disable_public_pages);
    }

    #[test]
    #[serial]
    fn test_boolean_env_defaults_false() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };

        let cfg = Config::from_env();
        assert!(!cfg.enable_cdn_redirects);
        assert!(!cfg.external_cache_only);
        assert!(!cfg.disable_public_pages);
    }

    #[test]
    #[serial]
    fn test_free_key_enabled_some() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };
        unsafe { env::set_var("FREE_KEY_ENABLED", "true") };

        let cfg = Config::from_env();
        assert_eq!(cfg.free_key_enabled, Some(true));
    }

    #[test]
    #[serial]
    fn test_free_key_enabled_none() {
        unsafe { clear_config_env() };
        unsafe { env::set_var("TMDB_API_KEY", "tmdb_test") };
        unsafe { env::set_var("OMDB_API_KEY", "omdb_test") };

        let cfg = Config::from_env();
        assert!(cfg.free_key_enabled.is_none());
    }
}