-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathconfig.py
More file actions
379 lines (324 loc) · 17.7 KB
/
Copy pathconfig.py
File metadata and controls
379 lines (324 loc) · 17.7 KB
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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
#config.py
# If you're looking to change the highlighted directors, studios and cast:
# - Source editors: edit the lists in discovery.py directly.
# - Docker operators (no source editing): place a JSON file at
# /app/cache/discovery_overrides.json (inside the existing cache volume,
# no extra mount needed).
# See the docstring at the top of discovery.py for the full format,
# or the project README for a ready-made sample.
import os
# Storage
DB_PATH = "/app/cache/cache.db"
BADGE_DIR = "/app/badges"
TMDB_POSTER_CACHE_DIR = "/app/cache/tmdb_posters" # base posters from TMDB
TMDB_LOGO_CACHE_DIR = "/app/cache/tmdb_logos" # base logos from TMDB
# Composite blob cache (ElfHosted fork) — the local blobstore backend uses
# this dir; the S3 backend ignores it. Composite JPEG bytes live here (or in
# an S3 bucket when OBJECT_STORE_URL is set) instead of in the relational DB.
COMPOSITE_BLOB_DIR = "/app/cache/composites"
# Environment
ACCESS_KEY = os.environ.get("ACCESS_KEY")
AIOSTREAMS_URL = os.environ.get("AIOSTREAMS_URL", "")
AIOSTREAMS_AUTH = os.environ.get("AIOSTREAMS_AUTH", "")
# Quality source selection.
# QUALITY_SOURCE: "aiostreams" (default) or "scraper".
# SCRAPER_URL: Stremio addon manifest/base URL — only used when QUALITY_SOURCE=scraper.
# Example: https://torrentio.stremio.ru/{config}/manifest.json
# Setting QUALITY_SOURCE=scraper while AIOSTREAMS_URL/AUTH are also set is a
# misconfiguration — the scraper path is ignored and a warning is logged at startup.
QUALITY_SOURCE = os.environ.get("QUALITY_SOURCE", "aiostreams").lower().strip()
SCRAPER_URL = os.environ.get("SCRAPER_URL", "").strip()
SERVER_TMDB_KEY = os.environ.get("TMDB_API_KEY", "").strip()
SERVER_MDBLIST_KEY = os.environ.get("MDBLIST_API_KEY", "").strip()
SERVER_MDBLIST_KEY_2 = os.environ.get("MDBLIST_API_KEY_2", "").strip()
# Ordered list of all configured server-side MDBList keys (primary first).
# Used by the key-rotation logic in main.py to fall back when a key is exhausted.
SERVER_MDBLIST_KEYS: list[str] = [k for k in [SERVER_MDBLIST_KEY, SERVER_MDBLIST_KEY_2] if k]
# --- Hosted-mode pluggable backends (ElfHosted fork) ----------------------
# All opt-in: unset, every selector falls back to the upstream-equivalent
# default (SQLite / in-process / local filesystem), so a vanilla deploy is
# byte-for-byte upstream behaviour.
# Storage backend. A postgresql:// URL switches the cache layer from SQLite
# to PostgreSQL. See storage/__init__.py.
DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
DB_POOL_MIN_SIZE = int(os.environ.get("DB_POOL_MIN_SIZE", "1"))
DB_POOL_MAX_SIZE = int(os.environ.get("DB_POOL_MAX_SIZE", "10"))
# Coordination backend. When REDIS_URL is set, MDBList rate-limit backoff,
# the fleet-wide 429 cooldown, background-quality claims, leader-election
# leases, and per-tenant rate limits are stored in Redis so replicas share
# state. Unset keeps per-process state. See coordination/__init__.py.
REDIS_URL = os.environ.get("REDIS_URL", "").strip()
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "postersplus").strip() or "postersplus"
# Blob store for composite poster bytes. When OBJECT_STORE_URL is set, bytes
# go to an S3-compatible object store instead of COMPOSITE_BLOB_DIR.
# URL format: s3://<bucket>?endpoint=<https://...>®ion=<region>&prefix=<prefix>
OBJECT_STORE_URL = os.environ.get("OBJECT_STORE_URL", "").strip()
# Optional CDN public URL — when set, the /poster and /p paths can 302 to the
# CDN for composite bytes instead of proxying them through the app pod.
OBJECT_STORE_PUBLIC_URL = os.environ.get("OBJECT_STORE_PUBLIC_URL", "").strip()
# --- Hosted-mode resource ceilings (ElfHosted fork) -----------------------
# RENDER_CONCURRENCY caps Pillow renders in flight at once. Each render pins a
# CPU core + ~10MB transient RAM; a burst of unique-param requests would
# otherwise saturate every core and stall the event loop. Default = cpu_count
# so single-tenant deploys behave like upstream (no artificial cap).
RENDER_CONCURRENCY = int(os.environ.get("RENDER_CONCURRENCY", "0")) or (os.cpu_count() or 2)
# How long /poster waits for a render slot before returning 503. 0 = wait
# forever; default 30s gives saturated clients a clear back-off signal.
RENDER_QUEUE_TIMEOUT = float(os.environ.get("RENDER_QUEUE_TIMEOUT", "30"))
# --- Hosted-mode observability (ElfHosted fork) ---------------------------
# Optional shared secret guarding /metrics. Unset leaves it open (gate at the
# ingress). LOG_FORMAT=json emits structured JSON log lines (Loki/ES);
# default "text" is upstream behaviour.
METRICS_ACCESS_KEY = os.environ.get("METRICS_ACCESS_KEY", "").strip()
LOG_FORMAT = os.environ.get("LOG_FORMAT", "text").strip().lower()
# --- Per-tenant rate limit (ElfHosted fork) -------------------------------
# Max /poster (and /p) requests per tenant per second. 0 disables (upstream
# behaviour). Tenant identity = sha256(user-key)[:16] when users bring their
# own TMDB/MDBList key; otherwise "operator" (/poster) or "preset" (/p).
RATE_LIMIT_RPS = int(os.environ.get("RATE_LIMIT_RPS", "0"))
# --- Static-preset moat (ElfHosted fork) ----------------------------------
# When PRESET_ENABLED, anonymous requests can hit a small set of named visual
# presets via /p/{preset}/{type}/{imdb_id}.jpg without supplying any key — the
# operator's server keys are used, rating/quality/text-detection are read from
# cache only (never fetched), and PRESET_CDN_CACHE_TTL sets the Cache-Control
# max-age on cached preset responses (deterministic per preset+title, so a far
# longer TTL than CDN_CACHE_TTL is safe).
PRESET_ENABLED = os.environ.get("PRESET_ENABLED", "").strip().lower() in ("1", "true", "yes")
PRESET_CDN_CACHE_TTL = int(os.environ.get("PRESET_CDN_CACHE_TTL", "86400"))
# Floor on anonymous /search and /resolve-imdb (the public preset flow needs
# the title picker). RATE_LIMIT_RPS only gates /poster + /p; without this
# independent floor an operator who left RATE_LIMIT_RPS=0 would leave the TMDB
# proxy endpoints unthrottled. 0 disables (fully private deploys).
ANONYMOUS_TMDB_RPS = int(os.environ.get("ANONYMOUS_TMDB_RPS", "5"))
# Workers
# CDN cache TTL (seconds). When > 0, poster responses include a
# Cache-Control: public header so Cloudflare (or any CDN) caches them at the
# edge. Set to 0 to disable (e.g. when running without a CDN).
CDN_CACHE_TTL = int(os.environ.get("CDN_CACHE_TTL", "0"))
# JPEG output quality for composited posters (70–95). Higher = better quality, larger files.
JPEG_QUALITY = max(70, min(95, int(os.environ.get("JPEG_QUALITY", "85"))))
# Feature Defaults
SHOW_RATING_DISPLAY_MODE = 1
SHOW_AWARD_SASH = True
BADGE_DISPLAY_MODE = 4
# Poster Dimensions (500x750)
POSTER_WIDTH = 500
POSTER_HEIGHT = 750
# Rating & Genre Label Defaults
ACCENT_BAR_MODE_FONT_SIZE_RATIO = 0.08 # font size in accent bar mode
NUMERIC_SCORE_MODE_FONT_SIZE_RATIO = 0.10 # font size in numeric mode
MINIMALIST_MODE_FONT_SIZE_RATIO = 0.055 # font size in minimalist mode
ACCENT_BAR_MODE_FONT_Y_OFFSET = 0.90 # vertical alignment in accent bar mode
NUMERIC_SCORE_MODE_FONT_Y_OFFSET = 0.90 # vertical alignment in numeric score mode
MINIMALIST_MODE_FONT_X_OFFSET = 0.05 # horizontal distance from right edge in minimalist mode
MINIMALIST_MODE_FONT_Y_OFFSET = 0.92 # vertical position in minimalist mode (0=top, 1=bottom)
SCORE_GLOW_THRESHOLD = 85 # score threshold to activate glow
SCORE_GLOW_BLUR = 1 # blur applied in glow mode
SCORE_GLOW_ALPHA = 40 # alpha of the glow applied
# Logo Defaults
LOGO_MAX_W_RATIO = 0.75 # target/max width of logo — the span every logo normalises to
LOGO_MAX_H_RATIO = 0.25 # max height of logo (paired with LOGO_ABS_MAX_H px cap)
LOGO_BOTTOM_RATIO = 0.28 # distance of logo from the bottom
DEFAULT_LOGO_LANGUAGE = os.environ.get("DEFAULT_LOGO_LANGUAGE", "en")
# Quality Badge Defaults
BADGE_HEIGHT = 20 # quality badge height in pixels
BADGE_GAP = 8 # gap between horizontal stack badges in pixels
BADGE_ANCHOR_X_RATIO = 0.050 # x offset from left
BADGE_ANCHOR_Y_RATIO = 0.050 # y offset from top
# TTL Settings
TMDB_POSTER_CACHE_DURATION = 60
TMDB_LOGO_CACHE_DURATION = 60
TMDB_METADATA_CACHE_DURATION = 7 # re-check textless status / logos weekly
DAYS_CONSIDERED_NEW = 14
NEW_CACHE_DURATION = 1
OLD_CACHE_DURATION = 14
TRENDING_CACHE_DURATION = 1
# Quality (AIOStreams) TTL — separate from rating TTL because stream availability
# for older titles is very stable. New content keeps the 1-day window so fresh
# encodes are picked up quickly; old content is cached for much longer.
QUALITY_OLD_CACHE_DURATION = int(os.environ.get("QUALITY_OLD_CACHE_DURATION", "90")) # days
# Max concurrent background quality fetches. Caps the burst when many uncached
# titles scroll into view simultaneously so AIOStreams isn't overwhelmed.
QUALITY_BG_CONCURRENCY = int(os.environ.get("QUALITY_BG_CONCURRENCY", "5"))
# Seconds to wait for a quality fetch when wait_for_quality=true is requested.
# Should be generous enough to allow for slow scrapers (Torrentio, Comet) but
# not so long it stalls a poster-warm run indefinitely.
QUALITY_WAIT_TIMEOUT = float(os.environ.get("QUALITY_WAIT_TIMEOUT", "30"))
# Max concurrent outbound MDBlist API calls. MDBlist queues or drops requests
# when hit with too many simultaneous connections from the same key, causing
# ReadTimeouts even when the service is healthy. 3 is comfortably within their
# apparent per-key concurrency limit while still allowing good parallelism.
MDBLIST_CONCURRENCY = int(os.environ.get("MDBLIST_CONCURRENCY", "3"))
# Digital release (r/movieleaks) scraper settings
DIGITAL_RELEASE_MIN_AGE_DAYS = 1 # ignore posts younger than this (mods still cleaning up)
DIGITAL_RELEASE_MAX_AGE_DAYS = 30 # expire entries older than this from the cache
# Composite poster cache TTL (seconds).
# How long a fully composited poster is kept before being re-rendered.
# Each unique combination of title + rendering parameters gets its own entry,
# so changing settings immediately produces a fresh render on next request.
# Override with COMPOSITE_CACHE_TTL=X in your .env file.
COMPOSITE_CACHE_TTL = int(os.environ.get("COMPOSITE_CACHE_TTL", "604800")) # 7 days
# Maximum number of composite cache entries. When exceeded the oldest entries are
# evicted on each insert to keep the table at this size. 0 = no cap (rely on TTL alone).
COMPOSITE_MAX_ENTRIES = int(os.environ.get("COMPOSITE_MAX_ENTRIES", "0"))
# Set to any truthy value (1, true, yes) to skip composite cache reads and writes
# entirely. Every request re-renders from scratch. Useful during development when
# iterating on rendering changes and you don't want stale renders served.
DISABLE_COMPOSITE_CACHE = os.environ.get("DISABLE_COMPOSITE_CACHE", "").strip().lower() in ("1", "true", "yes")
def _parse_bool_env(key: str, default: bool = False) -> bool:
val = os.environ.get(key, "").strip().lower()
if not val:
return default
return val not in ("0", "false", "no")
# Logo legibility: when a flat logo's average colour is too close to the poster
# background, recolour it (white / black / complementary accent) so it reads.
# Experimental and off by default while it's being tested — it can mis-handle
# some logos. Set LOGO_CONTRAST_RESCUE=true to enable.
LOGO_CONTRAST_RESCUE = _parse_bool_env("LOGO_CONTRAST_RESCUE", False)
# Emit per-logo sizing telemetry (source dims, aspect, final dims) at INFO level.
# Off by default — handy when tuning the logo size caps.
DEBUG_LOGO_SIZING = _parse_bool_env("DEBUG_LOGO_SIZING", False)
# Prefer textless posters with enough votes to be meaningful, but never allow
# vote count alone to select art rated far below the best available option.
TMDB_POSTER_MIN_VOTES = max(0, int(os.environ.get("TMDB_POSTER_MIN_VOTES", "3")))
TMDB_POSTER_MAX_SCORE_DROP = max(
0.0, float(os.environ.get("TMDB_POSTER_MAX_SCORE_DROP", "1.0"))
)
# Logo fill-stretch: a slim logo whose clamped size leaves it looking lost may be
# enlarged toward its size cap by up to this factor (one axis only) so it has more
# presence. 1.0 = no enlargement. Off by default — set LOGO_STRETCH_DISABLED=false
# to enable it; LOGO_STRETCH_FACTOR then sets how aggressive the enlargement is.
LOGO_STRETCH_DISABLED = _parse_bool_env("LOGO_STRETCH_DISABLED", True)
LOGO_STRETCH_FACTOR = max(1.0, float(os.environ.get("LOGO_STRETCH_FACTOR", "1.2")))
# Detect burned-in title text on posters TMDB mislabelled as "textless". When
# detected, PostersPlus skips compositing its own logo/title so you don't get a
# double title. Uses the PP-OCRv5 Mobile detector (one-time ~4.6MB model
# download). Foreground scans are vote-gated to protect burst latency; skipped
# assets are scanned later by the idle background queue.
#
# On by default; set TEXTLESS_TEXT_DETECTION=false to opt out.
#
# 3000 covers most titles while excluding the high-vote bulk of large libraries.
# Raise it for maximum foreground accuracy or lower it for faster stale-cache bursts.
# Changing it invalidates cached composites.
TEXTLESS_TEXT_DETECTION = _parse_bool_env("TEXTLESS_TEXT_DETECTION", True)
TEXTLESS_DETECTION_MAX_VOTES = max(0, int(os.environ.get("TEXTLESS_DETECTION_MAX_VOTES", "3000")))
# Keep a small, deduplicated list of TMDB posters rejected by OCR so operators
# can review and correct upstream metadata manually.
TEXTLESS_FAKE_REPORT = _parse_bool_env("TEXTLESS_FAKE_REPORT", True)
TEXTLESS_FAKE_REPORT_PATH = os.environ.get(
"TEXTLESS_FAKE_REPORT_PATH",
"/app/cache/fake_textless_posters.txt",
).strip() or "/app/cache/fake_textless_posters.txt"
# Minimum PP-OCR box confidence. Higher is stricter (fewer false positives,
# lower recall). Wide title-shaped regions use the PPOCR_WIDE_* fallback.
PPOCR_BOX_THRESHOLD = max(0.0, min(
1.0, float(os.environ.get("PPOCR_BOX_THRESHOLD", "0.70"))
))
# Independent PP-OCR sessions used for parallel cold-cache scans. Sessions run in
# a dedicated executor and split available ONNX threads between them. Each extra
# session costs roughly 25-40 MB with the bundled mobile model. Capped at four and at the detected CPU count.
# Default 2 suits typical 3+ core hosts; use 1 on smaller hosts. Across worker
# processes, keep WORKERS x this value at or below available CPU cores.
TEXTLESS_DETECTION_CONCURRENCY = max(1, min(
4, os.cpu_count() or 1,
int(os.environ.get("TEXTLESS_DETECTION_CONCURRENCY", "2")),
))
# Rating Score Weight Defaults
# Keep zero-weight providers here: they remain available as user-configurable options.
MOVIE_WEIGHTS = { # set weight of movie ranking providers, must sum to 1
"letterboxd": 0.8,
"trakt": 0,
"tomatoes": 0.2,
"popcorn": 0, # popcorn is the api response MDblist uses for tomatoes audience
"imdb": 0,
"metacritic": 0,
"metacriticuser": 0,
"tmdb": 0,
"rogerebert": 0,
"myanimelist": 0,
}
TV_WEIGHTS = { # set weight of TV ranking providers, must sum to 1
"trakt": 0.8,
"tomatoes": 0.2,
"popcorn": 0,
"imdb": 0,
"metacritic": 0,
"metacriticuser": 0,
"tmdb": 0,
"myanimelist": 0,
}
RATING_MIN_VOTES = max(0, int(os.environ.get("RATING_MIN_VOTES", "10")))
# Map badge file names to strings (no need to touch)
BADGE_FILES: dict[str, str] = {
"4K": "4K",
"1080P": "1080p",
"REMUX": "Remux",
"WEBDL": "Web",
"DV": "DV",
"HDR10+": "HDR10+",
"HDR10": "HDR10",
}
# Maps TMDB categories to numerics (no need to touch in most cases)
GENRE_MAP = {
28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy",
80: "Crime", 99: "Documentary", 18: "Drama", 10751: "Family",
14: "Fantasy", 36: "History", 27: "Horror", 10402: "Music",
9648: "Mystery", 10749: "Romance", 878: "Sci-Fi", 53: "Thriller",
10752: "War", 37: "Western",
10759: "Action", 10762: "Kids", 10763: "News", 10764: "Reality",
10765: "Sci-Fi", 10766: "Soap", 10767: "Talk", 10768: "War",
}
# Can re-order to change the priority that genres appear with (reference genre map above)
# Default Horror, Thriller, Mystery, Sci-Fi, Crime, Comedy, Fantasy, Adventure, Family, Action, History
# Music, War, Western, Documentary, Drama, Adventure, Reality, Kids, News, Soap, Talk
# Duplicate entries are not an accident, for certain genres TMDB uses two numbers, one for movies, one for shows.
GENRE_PRIORITY = [
27, 53, 9648, 878, 10765, 80, 35, 10749, 14, 16, 10751,
28, 10759, 36, 10402, 10752, 10768, 37, 99, 18, 12,
10764, 10762, 10763, 10766, 10767,
]
# Text based fallback, not important if everything is working properly
QUALITY_LABELS: dict[str, str] = {
"4K": "4K",
"1080P": "1080p",
"REMUX": "Remux",
"WEBDL": "Web",
"DV": "DV",
"HDR10+": "HDR10+",
"HDR10": "HDR10",
"ATMOS": "Atmos",
"DTSX": "DTS:X",
}
# Normalizes all scores to be out of 100
SCORE_NORMALISERS = {
"imdb": lambda v: (v / 10) * 100,
"letterboxd": lambda v: (v / 5) * 100,
"trakt": lambda v: v,
"tomatoes": lambda v: v,
"popcorn": lambda v: v,
"metacritic": lambda v: v,
"metacriticuser": lambda v: (v / 10) * 100,
"tmdb": lambda v: v,
"rogerebert": lambda v: (v / 4) * 100,
"myanimelist": lambda v: (v / 10) * 100,
}
# Default Sash Priority
SASH_PRIORITY: list[str] = [
"wins",
"gg_wins",
"festival",
"pic_noms",
"gg_noms",
"studio",
"director",
"cast",
"trending",
"cult",
"foreign",
"new_release",
"metacritic",
"true_story",
"structural",
"release_status",
]