-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathmain.py
More file actions
3912 lines (3538 loc) · 183 KB
/
Copy pathmain.py
File metadata and controls
3912 lines (3538 loc) · 183 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
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#main.py
import asyncio
import hashlib
import hmac
import io
import logging
import os
import re
import httpx
import numpy as np
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, Response, HTMLResponse
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageDraw, ImageFont, ImageOps
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
# ElfHosted fork: optional structured JSON logs (LOG_FORMAT=json) for shipping
# to Loki/Elasticsearch. Read straight from the env so logging is configured
# before config.py is imported. Falls back to the text format above if
# python-json-logger isn't installed. Default (text) is upstream behaviour.
if os.environ.get("LOG_FORMAT", "text").strip().lower() == "json":
try:
from pythonjsonlogger.json import JsonFormatter
except Exception:
try:
from pythonjsonlogger.jsonlogger import JsonFormatter # older versions
except Exception:
JsonFormatter = None
if JsonFormatter is not None:
_json_fmt = JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
for _h in logging.getLogger().handlers:
_h.setFormatter(_json_fmt)
else:
logging.getLogger(__name__).warning(
"LOG_FORMAT=json but python-json-logger is not installed — "
"falling back to text logs."
)
# Pull uvicorn's loggers into our root handler so all output shares the same format.
for _uv_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
_uv_logger = logging.getLogger(_uv_name)
_uv_logger.handlers = []
_uv_logger.propagate = True
class _TruncateUrlFilter(logging.Filter):
"""
Redact API keys and truncate long URL paths in log records.
Two responsibilities:
1. For uvicorn.access records, truncate the request path so long URLs
don't fill the log.
2. For ALL records, redact every common API-key query parameter pattern
in both record.msg and record.args. This catches keys that slip
through when an httpx exception is logged (its __str__ includes the
full upstream URL with our outbound api_key=) as well as anything
else that might inadvertently include a key.
"""
_MAX = 80
# Match query params we hold (tmdb_key, mdblist_key, access_key) AND the
# upstream parameter names we forward keys under (api_key, apikey).
_KEY_RE = re.compile(
r'((?:tmdb_key|mdblist_key|access_key|api_key|apikey)=)[^&\s\'\"]*',
re.IGNORECASE,
)
@classmethod
def _redact(cls, value):
if isinstance(value, str):
return cls._KEY_RE.sub(r'\1***', value)
return value
def filter(self, record: logging.LogRecord) -> bool:
# uvicorn.access records: args = (client_addr, method, path, http_version, status_code, ...)
if (
record.name == "uvicorn.access"
and isinstance(record.args, tuple)
and len(record.args) >= 3
):
path = record.args[2]
if isinstance(path, str):
path = self._KEY_RE.sub(r'\1***', path)
if len(path) > self._MAX:
path = path[: self._MAX] + "…"
record.args = (record.args[0], record.args[1], path) + record.args[3:]
# Generic redaction for every other record (application logs).
# We redact in msg and args so the formatted output is safe regardless
# of whether the record uses % substitution or pre-formatted strings.
if isinstance(record.msg, str):
record.msg = self._redact(record.msg)
if isinstance(record.args, tuple):
record.args = tuple(self._redact(a) for a in record.args)
elif isinstance(record.args, dict):
record.args = {k: self._redact(v) for k, v in record.args.items()}
# Tracebacks (logger.exception / exc_info=True) are formatted lazily
# by the handler. Pre-format and redact exc_text here so the
# downstream formatter uses our sanitised copy rather than re-rendering.
if record.exc_info and not record.exc_text:
import traceback
record.exc_text = self._redact(
"".join(traceback.format_exception(*record.exc_info))
)
elif record.exc_text:
record.exc_text = self._redact(record.exc_text)
return True
# Attach to the root handler, not the root logger — propagation calls
# callHandlers() directly on parent loggers, skipping their logger-level filters.
_url_filter = _TruncateUrlFilter()
for _handler in logging.getLogger().handlers:
_handler.addFilter(_url_filter)
# httpx logs every outbound HTTP request at INFO level, including full URLs with
# API keys in query strings. Raise its level to WARNING so those lines are never
# written to the log — our own try/except blocks capture errors explicitly.
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request coalescing
# ---------------------------------------------------------------------------
# Maps final_cache_key -> Future[bytes] for in-flight renders.
# When multiple requests arrive simultaneously for the same uncached poster
# (common during a burst from AIOMetadata loading a library), only the first
# runs the full pipeline; the rest await its Future and get the result for free.
# This dict is per-worker-process — cross-process deduplication would require
# a shared store like Redis, but intra-process coalescing handles the common
# burst pattern well enough at this scale.
_render_inflight: dict[str, "asyncio.Future[bytes]"] = {}
# ElfHosted fork: bounds Pillow renders in flight (config.RENDER_CONCURRENCY).
# Created lazily inside the event loop on first /poster (or /p) render.
_render_semaphore: "asyncio.Semaphore | None" = None
# ---------------------------------------------------------------------------
# Background quality fetching
# ---------------------------------------------------------------------------
# Quality data (AIOStreams / scrapers) is fetched in the background so poster
# responses are never blocked by a slow scraper call. The poster is served
# immediately without quality badges on a cache miss; the next request for the
# same title will find the quality cached and render badges normally.
#
# _quality_bg_inflight: tracks imdb_ids with an active background fetch so
# scroll bursts don't launch duplicate fetches for the same title.
# _quality_bg_semaphore: caps concurrent AIOStreams calls so a large burst
# doesn't hammer the scrapers with hundreds of simultaneous requests.
_quality_bg_inflight: set[str] = set()
_quality_bg_semaphore: "asyncio.Semaphore | None" = None # created inside event loop
_quality_source_backoff_until: dict[str, float] = {}
_quality_source_fail_count: dict[str, int] = {}
# ---------------------------------------------------------------------------
# Rating fetch deduplication
# ---------------------------------------------------------------------------
# Prevents concurrent requests for the same imdb_id (different raw_params /
# final_cache_key) from triggering duplicate MDBlist API calls. The most
# common burst: AIOMetadata requests many posters simultaneously; several
# share an uncached title with different user-config hashes so render
# coalescing alone doesn't protect them.
#
# _rating_fetch_inflight: maps imdb_id -> asyncio.Event that fires once the
# first fetch completes. Subsequent requests wait, then re-read the DB.
# _rating_backoff: maps (imdb_id, API key) -> loop-time after which a new
# attempt is allowed. Scoping by key lets a rotated or replaced key retry
# the same title immediately. Network failures use an escalating ladder
# (30s/2m/8m/1h); rate-limit responses use Retry-After or 1h flat.
_rating_fetch_inflight: dict[str, asyncio.Event] = {}
_rating_backoff: dict[tuple[str, str], float] = {}
_rating_fail_count: dict[tuple[str, str], int] = {}
_mdblist_semaphore: "asyncio.Semaphore | None" = None # caps concurrent MDBlist HTTP calls; created inside event loop
# Caps parallel burned-in-text scans. Each slot owns an independent RapidOCR
# session in a dedicated executor, so cold-cache OCR cannot occupy render workers.
# Created inside the event loop.
_detect_semaphore: "asyncio.Semaphore | None" = None
_detect_executor: "ThreadPoolExecutor | None" = None
# Maps immutable image/detector keys to active OCR tasks. Different poster
# configurations often render the same source image during a burst; they should
# share one scan even when their final composite cache keys differ.
_text_detection_inflight: dict[str, "asyncio.Task[bool | None]"] = {}
_foreground_detection_count = 0
_active_poster_renders = 0
_background_detection_queue: "asyncio.Queue[_DeferredTextDetection] | None" = None
_background_detection_keys: set[str] = set()
_background_detection_task: "asyncio.Task[None] | None" = None
@dataclass(frozen=True)
class _DeferredTextDetection:
cache_key: str
image_cache_key: str
title: tuple[str, ...]
source: str
tmdb_id: str
media_type: str
image_path: str
vote_count: int | None
source_key: str
def _get_detect_semaphore() -> "asyncio.Semaphore":
"""Lazily create the detection-admission semaphore inside the event loop."""
global _detect_semaphore
if _detect_semaphore is None:
_detect_semaphore = asyncio.Semaphore(_cfg.TEXTLESS_DETECTION_CONCURRENCY)
return _detect_semaphore
def _get_detect_executor() -> ThreadPoolExecutor:
"""Dedicated workers so OCR bursts cannot starve poster compositing."""
global _detect_executor
if _detect_executor is None:
_detect_executor = ThreadPoolExecutor(
max_workers=_cfg.TEXTLESS_DETECTION_CONCURRENCY,
thread_name_prefix="text-detect",
)
return _detect_executor
def _shutdown_detect_executor() -> None:
global _detect_executor
if _detect_executor is not None:
_detect_executor.shutdown(wait=True, cancel_futures=True)
_detect_executor = None
def _reserve_foreground_detection() -> None:
global _foreground_detection_count
_foreground_detection_count += 1
def _release_foreground_detection() -> None:
global _foreground_detection_count
_foreground_detection_count = max(0, _foreground_detection_count - 1)
def _start_text_detection(
cache_key: str,
image: Image.Image,
*,
title: tuple[str, ...],
source: str,
tmdb_id: str,
vote_count: int | None,
source_key: str,
media_type: str | None = None,
image_path: str | None = None,
foreground: bool = True,
foreground_reserved: bool = False,
) -> "asyncio.Task[bool | None]":
"""Start or join one OCR scan for an immutable source image."""
cached = get_cached_text_detection(cache_key)
if cached is not None:
if foreground and foreground_reserved:
_release_foreground_detection()
async def _cached_result() -> bool:
return cached
return asyncio.create_task(_cached_result())
existing = _text_detection_inflight.get(cache_key)
if existing is not None:
if foreground and foreground_reserved:
_release_foreground_detection()
logger.info(
f"Coalescing burned-in text scan for {tmdb_id} "
f"(votes={vote_count}, source={source_key})"
)
return existing
if foreground and not foreground_reserved:
_reserve_foreground_detection()
async def _scan() -> bool | None:
from text_detect import poster_has_burned_in_text
try:
async with _get_detect_semaphore():
result = await asyncio.get_running_loop().run_in_executor(
_get_detect_executor(),
lambda: poster_has_burned_in_text(
image,
conf=_cfg.PPOCR_BOX_THRESHOLD,
title=title,
source=source,
debug=True,
),
)
if result is not None:
set_cached_text_detection(cache_key, result)
if result is True and source == "poster" and media_type and image_path:
from textless_report import report_fake_textless_poster
report_fake_textless_poster(
media_type=media_type,
tmdb_id=tmdb_id,
image_path=image_path,
vote_count=vote_count,
)
return result
finally:
if foreground:
_release_foreground_detection()
logger.info(
f"Scanning textless poster {tmdb_id} for burned-in text "
f"(votes={vote_count}, source={source_key}, "
f"priority={'foreground' if foreground else 'background'})"
)
task = asyncio.create_task(_scan())
_text_detection_inflight[cache_key] = task
def _cleanup(done: "asyncio.Task[bool | None]") -> None:
if _text_detection_inflight.get(cache_key) is done:
_text_detection_inflight.pop(cache_key, None)
if not done.cancelled():
done.exception()
task.add_done_callback(_cleanup)
return task
def _queue_background_text_detection(item: _DeferredTextDetection) -> None:
"""Queue one vote-gated scan without retaining its decoded image."""
if get_cached_text_detection(item.cache_key) is not None:
return
if item.cache_key in _background_detection_keys:
return
if _background_detection_queue is None:
logger.warning(
f"Background text-detection queue unavailable for {item.tmdb_id}; "
"scan will retry on the next request"
)
return
_background_detection_keys.add(item.cache_key)
_background_detection_queue.put_nowait(item)
logger.info(
f"Queued vote-gated text scan for {item.tmdb_id} "
f"(votes={item.vote_count}, pending={_background_detection_queue.qsize()})"
)
def _load_detection_image(image_cache_key: str) -> Image.Image | None:
cached_bytes = get_cached_tmdb_poster(image_cache_key)
if not cached_bytes:
return None
return Image.open(io.BytesIO(cached_bytes)).convert("RGBA")
async def _background_text_detection_worker() -> None:
"""Drain vote-gated scans only while no foreground scan is queued or running."""
assert _background_detection_queue is not None
while True:
item = await _background_detection_queue.get()
try:
if get_cached_text_detection(item.cache_key) is not None:
continue
while _foreground_detection_count > 0 or _active_poster_renders > 0:
await asyncio.sleep(0.1)
image = await asyncio.get_running_loop().run_in_executor(
None, _load_detection_image, item.image_cache_key
)
if image is None:
logger.warning(
f"Deferred text scan source unavailable for {item.tmdb_id}; "
"scan will retry on the next request"
)
continue
# A poster render may have arrived while the image was loading.
while _foreground_detection_count > 0 or _active_poster_renders > 0:
await asyncio.sleep(0.1)
await asyncio.shield(_start_text_detection(
item.cache_key,
image,
title=item.title,
source=item.source,
tmdb_id=item.tmdb_id,
vote_count=item.vote_count,
media_type=item.media_type,
image_path=item.image_path,
source_key=item.source_key,
foreground=False,
))
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
f"Deferred text scan failed for {item.tmdb_id}: {exc}"
)
finally:
_background_detection_keys.discard(item.cache_key)
_background_detection_queue.task_done()
# Per-key cooldown timestamps (event-loop time). Keyed by the API key string so
# rotation is independent — a rate-limited key stands down while the other serves.
_mdblist_key_cooldown: dict[str, float] = {}
# Index into _cfg.SERVER_MDBLIST_KEYS for the currently active server-side key.
_mdblist_active_key_idx: int = 0
def _quality_source_name() -> str:
return "scraper" if _cfg.QUALITY_SOURCE == "scraper" else "aiostreams"
def _quality_backoff_remaining(now: float | None = None) -> float:
if now is None:
now = asyncio.get_running_loop().time()
return max(0.0, _quality_source_backoff_until.get(_quality_source_name(), 0.0) - now)
def _record_quality_result(result) -> None:
source = _quality_source_name()
if result is not FETCH_FAILED:
_quality_source_backoff_until.pop(source, None)
_quality_source_fail_count.pop(source, None)
return
now = asyncio.get_running_loop().time()
if _quality_source_backoff_until.get(source, 0.0) > now:
return
failures = _quality_source_fail_count.get(source, 0) + 1
_quality_source_fail_count[source] = failures
delay = min(30.0 * (4 ** (failures - 1)), 1800.0)
_quality_source_backoff_until[source] = now + delay
logger.warning(f"Quality source {source} unavailable; backing off for {delay:.0f}s")
def _next_mdblist_server_key(current_key: str, now: float | None = None) -> str | None:
"""Select a healthy configured server key after *current_key*."""
global _mdblist_active_key_idx
keys = _cfg.SERVER_MDBLIST_KEYS
if len(keys) < 2 or current_key not in keys:
return None
if now is None:
now = asyncio.get_running_loop().time()
start = keys.index(current_key)
for offset in range(1, len(keys)):
idx = (start + offset) % len(keys)
candidate = keys[idx]
if now >= _mdblist_key_cooldown.get(candidate, 0.0):
_mdblist_active_key_idx = idx
return candidate
return None
def _mark_mdblist_rate_limit(
imdb_id: str, key: str, result
) -> tuple[float, str | None]:
"""Cool down a rate-limited key and select a healthy configured fallback."""
if result.retry_after:
backoff_secs = min(float(result.retry_after), 3600.0)
else:
backoff_secs = 3600.0
now = asyncio.get_running_loop().time()
_mdblist_key_cooldown[key] = now + backoff_secs
_rating_backoff[_rating_retry_key(imdb_id, key)] = now + backoff_secs
return backoff_secs, _next_mdblist_server_key(key, now)
async def _background_quality_fetch(
imdb_id: str,
media_type: str,
season: int,
episode: int,
release_date: str | None,
) -> None:
"""Fetch quality tokens from the configured quality source and cache them. Never raises."""
global _quality_bg_semaphore
if _quality_bg_semaphore is None:
_quality_bg_semaphore = asyncio.Semaphore(_cfg.QUALITY_BG_CONCURRENCY)
try:
async with _quality_bg_semaphore:
if _HTTP_CLIENT is None:
return
remaining = _quality_backoff_remaining()
if remaining > 0:
logger.debug(
f"Quality fetch skipped for {imdb_id}; source cooldown has {remaining:.0f}s remaining"
)
return
if _cfg.QUALITY_SOURCE == "scraper" and _cfg.SCRAPER_URL:
result = await _with_retry(
fetch_quality_from_scraper,
_HTTP_CLIENT, _cfg.SCRAPER_URL, imdb_id, media_type, season, episode, release_date,
)
else:
result = await _with_retry(
fetch_quality_from_aiostreams,
_HTTP_CLIENT, imdb_id, media_type, season, episode, release_date,
)
_record_quality_result(result)
if result is not FETCH_FAILED:
logger.info(f"Background quality fetch complete for {imdb_id}")
except Exception as exc:
_record_quality_result(FETCH_FAILED)
logger.warning(f"Background quality fetch failed for {imdb_id}: {exc}")
finally:
_quality_bg_inflight.discard(imdb_id)
# Local imports
from age_badge import draw_quality_age_badge, draw_tier_bar, _score_points
from awards import sample_frosted_notch_rgb, sample_frosted_sash_rgb
from ratings import sample_frosted_bar_rgb
from awards import FETCH_FAILED, _RateLimited, draw_award_badge, draw_award_sash, parse_mdblist_awards, _STAR_WIN_AWARDS
from i18n import load_languages, translate_genre, translate_sash
from cache import (
get_cached_quality,
get_cached_rating,
get_cached_final_poster,
get_cached_final_poster_url,
is_cached_final_poster_fresh,
get_cached_tmdb_poster,
set_cached_final_poster,
get_cached_text_detection,
set_cached_text_detection,
init_db,
close as close_db,
BACKEND_KIND as _STORAGE_KIND,
is_digital_release,
set_cached_rating,
delete_cached_tmdb_metadata,
prune_caches,
get_cache_stats,
)
import blobstore
import coordination as coord
import metrics as _metrics
from digital_release import digital_release_poll_loop
import config as _cfg
from discovery import (
ALL_PRIORITY_SLOTS,
FESTIVAL_KEYWORDS,
DiscoveryMeta,
extract_discovery_meta,
pick_sash,
)
from quality import (
BadgeItem,
fetch_quality_from_aiostreams,
fetch_quality_from_scraper,
get_resized_badge,
parse_quality,
render_badges_left,
)
from ratings import calculate_weighted_score, draw_score_bar, fetch_rating, draw_score_bar_vertical, _draw_solid_pip, draw_frosted_bar, _score_color, _score_color_alt, _score_color_metal
from tmdb import composite_logo, logo_centre_y, fetch_logo, image_language_order, fetch_poster_metadata, fetch_poster_image, fetch_backdrop_image, fetch_trending_rank, fetch_release_status, svg_logo_supported, tmdb_metadata_cache_key, _CROP_VERSION, resolve_imdb_to_tmdb
from presets import get_preset, preset_names, preset_catalog
# ---------------------------------------------------------------------------
# Persistent HTTP client
# ---------------------------------------------------------------------------
# One client for the lifetime of the process. httpx keeps TCP connections
# alive in its connection pool, so repeated requests to the same host
# (TMDB, MDblist, AIOStreams) reuse the existing socket rather than paying
# TLS + TCP handshake overhead on every poster request.
#
# Timeouts are split:
# connect=5s — fail fast when a host is unreachable
# read=12s — allow slow responses from external APIs
# pool=5s — don't block forever waiting for a pool slot
_HTTP_CLIENT: httpx.AsyncClient | None = None
def _make_http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=12.0, write=5.0, pool=5.0),
limits=httpx.Limits(
max_connections=40,
max_keepalive_connections=20,
keepalive_expiry=30,
),
headers={
"Accept-Encoding": "identity",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
},
http2=False, # most poster APIs don't support h2; skip the negotiation
)
# ---------------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------------
_TMDB_ID_RE = re.compile(r'^\d{1,10}$')
_IMDB_ID_RE = re.compile(r'^tt\d{1,10}$')
_VALID_TYPES = frozenset({"movie", "tv", "series"})
def _check_tmdb_id(val: str) -> None:
if not _TMDB_ID_RE.match(val):
raise HTTPException(status_code=400, detail="Invalid tmdb_id")
def _check_imdb_id(val: str) -> None:
if not _IMDB_ID_RE.match(val):
raise HTTPException(status_code=400, detail="Invalid imdb_id")
def _check_type(val: str) -> None:
if val not in _VALID_TYPES:
raise HTTPException(status_code=400, detail="Invalid type")
# ---------------------------------------------------------------------------
# Key resolution helpers
# ---------------------------------------------------------------------------
def _resolve_tmdb_key(query_key: str) -> str | None:
if query_key:
return query_key
if _cfg.SERVER_TMDB_KEY:
return _cfg.SERVER_TMDB_KEY
return None
def _resolve_mdblist_key(query_key: str) -> str | None:
if query_key:
return query_key
if _cfg.SERVER_MDBLIST_KEYS:
return _cfg.SERVER_MDBLIST_KEYS[_mdblist_active_key_idx % len(_cfg.SERVER_MDBLIST_KEYS)]
return None
def _rating_retry_key(imdb_id: str, mdblist_key: str) -> tuple[str, str]:
"""Identify retry state for one title on one MDBList API key."""
return imdb_id, mdblist_key
def _detection_vote_ok(vote_count: int | None) -> bool:
"""True when an asset should be scanned during the foreground request."""
return vote_count is not None and vote_count <= _cfg.TEXTLESS_DETECTION_MAX_VOTES
# ---------------------------------------------------------------------------
# Per-request configuration
# ---------------------------------------------------------------------------
_CLIENT_EDGE_INSETS = {
"stremio_tv_nuvio": (0.0, 0.0),
"stremio_desktop_web": (0.007, 0.004),
# Plex renders posters uncropped in its grid/details views — no edge
# compensation needed. Used by the plex_sync.py companion script.
"plex": (0.0, 0.0),
# Same story for Jellyfin's web/desktop clients — posters render
# uncropped in the library grid and detail views. Used by the
# jellyfin_sync.py companion script.
"jellyfin": (0.0, 0.0),
}
@dataclass
class RequestConfig:
"""
Holds all user-tuneable config values for a single request.
Defaults come from the global config module; query params override them.
"""
show_award_sash: bool = field(default_factory=lambda: _cfg.SHOW_AWARD_SASH)
sash_poster_color: bool = False # diagonal sash colour derived from poster art
cinema_greyscale: bool = True # greyscale art when release_status == "Cinema"
cinema_greyscale_skip_if_available: bool = False # keep colour if Web/Remux source found
release_status_cinema_only: bool = True # only show release status when "Cinema"
badge_display_mode: int = field(default_factory=lambda: _cfg.BADGE_DISPLAY_MODE)
rating_display_mode: int = field(default_factory=lambda: _cfg.SHOW_RATING_DISPLAY_MODE)
accent_bar_font_size_ratio: float = field(default_factory=lambda: _cfg.ACCENT_BAR_MODE_FONT_SIZE_RATIO)
# Score Bar mode label suffix: 0 = Year (legacy default), 1 = Info sash, 2 = Year + Info sash
accent_bar_append_mode: int = 0
# Score Bar position knob — distance from poster bottom edge as fraction of height.
# Default matches the legacy hardcoded 30px on a 500x750 poster.
accent_bar_bottom_ratio: float = 0.04
numeric_score_font_size_ratio: float = field(default_factory=lambda: _cfg.NUMERIC_SCORE_MODE_FONT_SIZE_RATIO)
# Clean mode (mode 2) numeric format. When True, the rating is divided by
# 10 and shown to one decimal (87 → "8.7", 100 → "10.0"). Default keeps
# the legacy 0-100 integer form.
score_out_of_10: bool = False
accent_bar_y_offset: float = field(default_factory=lambda: _cfg.ACCENT_BAR_MODE_FONT_Y_OFFSET)
numeric_score_y_offset: float = field(default_factory=lambda: _cfg.NUMERIC_SCORE_MODE_FONT_Y_OFFSET)
score_glow_threshold: int = field(default_factory=lambda: _cfg.SCORE_GLOW_THRESHOLD)
score_glow_blur: int = field(default_factory=lambda: _cfg.SCORE_GLOW_BLUR)
score_glow_alpha: int = field(default_factory=lambda: _cfg.SCORE_GLOW_ALPHA)
minimalist_mode_font_size_ratio: float = field(default_factory=lambda: _cfg.MINIMALIST_MODE_FONT_SIZE_RATIO)
minimalist_mode_font_x_offset: float = field(default_factory=lambda: _cfg.MINIMALIST_MODE_FONT_X_OFFSET)
minimalist_mode_font_y_offset: float = field(default_factory=lambda: _cfg.MINIMALIST_MODE_FONT_Y_OFFSET)
# What to append after the genre in Minimalist mode:
# 0 = Year (Genre + year, rating as a colour-coded pip — the original look)
# 1 = Rating (Genre | Score, score printed as text)
# 2 = Year + Rating (Genre | Year | Score)
minimalist_append_mode: int = 0
# Frosted bar (rating_display_mode == 4)
bar_height_ratio: float = 0.080
bar_font_size_ratio: float = 0.55
bar_frost_opacity: float = 0.85
bar_bottom_inset: float = 0.0
bar_style: str = "frosted" # "frosted"|"silver"|"gold"|"rating_black"|"rating_frosted"
bar_accent: str = "silver" # "silver"|"gold"|"palette_0"|"palette_1"|"palette_2"
bar_score_out_of_10: bool = False
bar_match_notch: bool = False # share one frosted tint with the sash notch
bar_append: str = "rating_year" # "rating_year"|"rating"|"year"|"sash"
logo_max_w_ratio: float = field(default_factory=lambda: _cfg.LOGO_MAX_W_RATIO)
logo_max_h_ratio: float = field(default_factory=lambda: _cfg.LOGO_MAX_H_RATIO)
logo_bottom_ratio: float = field(default_factory=lambda: _cfg.LOGO_BOTTOM_RATIO)
badge_height: int = field(default_factory=lambda: _cfg.BADGE_HEIGHT)
badge_gap: int = field(default_factory=lambda: _cfg.BADGE_GAP)
badge_anchor_x: float = field(default_factory=lambda: _cfg.BADGE_ANCHOR_X_RATIO)
badge_anchor_y: float = field(default_factory=lambda: _cfg.BADGE_ANCHOR_Y_RATIO)
badge_min_score: int = 2
combined_badge_stacked: bool = False
movie_weights: dict | None = None
tv_weights: dict | None = None
fallback_to_imdb: bool = False
logo_language: str = field(default_factory=lambda: _cfg.DEFAULT_LOGO_LANGUAGE)
# Logo resolution priority. "native" = the viewer's chosen logo_language
# (e.g. en); "original" = the content's own original language (e.g. ja for
# an anime). "text" = render the translated title as text.
# "native_original" (default): native → original → text
# "original_native": original → native → text
# "native_if_original_english": native if content is native, else English
# → original → text
# "native_text": native → text (no original-language logo)
logo_priority: str = "native_original"
# Fallback-poster style for titles with no art: "minimal" (procedural textured
# backdrop) or "photoreal" (hand-made photographic art that blends with real
# posters). Missing photoreal art degrades to the minimal set.
fallback_bg_style: str = "minimal"
# Original-art mode: serve TMDB's primary poster (title/logo baked into the
# art) as-is, skipping our own logo overlay, text detection and the textless/
# backdrop fallbacks. The logo is part of the art in this mode.
use_original_art: bool = False
# Which poster original-art mode serves:
# "primary" = TMDB's designated default poster (most recognisable)
# "top_rated" = highest-voted poster, by logo_priority language order
original_art_source: str = "primary"
sash_priority: list[str] = field(default_factory=lambda: list(_cfg.SASH_PRIORITY))
muted: bool = False
textless: bool = False
score_color_mode: int = 2
top_gradient: str = "high" # off | low | medium | high — strength of the top vignette
bottom_gradient: str = "high" # off | low | medium | high — strength of the bottom vignette
sash_badge: bool = False # legacy; superseded by sash_mode (kept for back-compat parsing)
sash_mode: str = "sash" # "sash" (diagonal) | "notch"
sash_badge_style: str = "frosted" # "silver" | "gold" | "frosted"
sash_badge_size_w: float = 1.05 # horizontal scale of badge
sash_badge_size_h: float = 1.05 # vertical scale of badge
sash_badge_inset: float = 0.0 # top-edge offset as fraction of poster height (± small)
sash_badge_font_ratio: float = 0.43 # font size as fraction of badge height
sash_badge_frost_opacity: float = 0.75 # frosted overlay opacity (0.0–1.0)
sash_length_ratio: float = 1.15 # diagonal sash length as fraction of poster width
sash_height_ratio: float = 0.12 # diagonal sash height (thickness) as fraction of poster width
wait_for_quality: bool = False # block response until quality is fetched (for poster-warm workflows)
greyscale_no_quality: bool = False # greyscale art when no quality found (needs wait_for_quality)
def _parse_bool(val: str | None, default: bool) -> bool:
if val is None:
return default
return val.strip().lower() not in ("0", "false", "no")
def _parse_weights(raw: str | None, sources: list[str]) -> dict | None:
if not raw:
return None
out = {}
try:
for part in raw.split(","):
part = part.strip()
if ":" not in part:
continue
key, val = part.split(":", 1)
key = key.strip().lower()
if key in sources:
out[key] = max(0.0, min(1.0, float(val)))
except Exception:
return None
return out if out else None
def _parse_sash_priority(raw: str | None) -> list[str]:
if not raw:
return list(_cfg.SASH_PRIORITY)
tokens = [s.strip() for s in raw.split(",") if s.strip()]
# Tokens prefixed with "-" are explicit exclusions
excluded = {t[1:] for t in tokens if t.startswith("-") and t[1:] in ALL_PRIORITY_SLOTS}
active = [t for t in tokens if not t.startswith("-") and t in ALL_PRIORITY_SLOTS]
if not active and not excluded:
return list(_cfg.SASH_PRIORITY)
# Append any default slots that weren't explicitly listed or excluded
active_set = set(active)
for slot in _cfg.SASH_PRIORITY:
if slot not in active_set and slot not in excluded:
active.append(slot)
return active
def build_request_config(params: dict) -> RequestConfig:
"""Build a RequestConfig from raw query-param strings.
All numeric overrides are clamped to a sensible range so a malicious or
careless caller can't pass values that would melt a worker (e.g.
score_glow_blur=99999 turning into a Gaussian kernel of that radius, or
badge_height=99999 triggering a multi-GB image resize). Bounds are
deliberately a little more generous than the configurator sliders so
power users can push past UI limits without bypassing safety.
"""
cfg = RequestConfig()
# Client profiles provide defaults only; explicit inset parameters below
# remain authoritative for users who fine-tune either edge manually.
_client_insets = _CLIENT_EDGE_INSETS.get(
(params.get("primary_client") or "").strip().lower()
)
if _client_insets is not None:
cfg.bar_bottom_inset, cfg.sash_badge_inset = _client_insets
def _b(key, default): return _parse_bool(params.get(key), default)
def _f(key, default, lo: float, hi: float):
"""Float param with hard clamp to [lo, hi]; invalid → default."""
try:
return max(lo, min(hi, float(params[key]))) if key in params else default
except (ValueError, TypeError):
return default
def _i(key, default, lo: int, hi: int):
"""Int param with hard clamp to [lo, hi]; invalid → default."""
try:
return max(lo, min(hi, int(params[key]))) if key in params else default
except (ValueError, TypeError):
return default
cfg.show_award_sash = _b("show_award_sash", cfg.show_award_sash)
cfg.sash_poster_color = _b("sash_poster_color", cfg.sash_poster_color)
cfg.cinema_greyscale = _b("cinema_greyscale", cfg.cinema_greyscale)
cfg.cinema_greyscale_skip_if_available = _b("cinema_greyscale_skip_if_available", cfg.cinema_greyscale_skip_if_available)
cfg.release_status_cinema_only = _b("release_status_cinema_only", cfg.release_status_cinema_only)
cfg.muted = _b("muted", cfg.muted)
cfg.score_out_of_10 = _b("score_out_of_10", cfg.score_out_of_10)
cfg.textless = _b("textless", cfg.textless)
# top_gradient accepts off / low / medium / high. Legacy boolean values
# (true / false) from pre-v1.0.4 URLs map to high / off respectively so
# cached configurator links keep working.
_tg_raw = (params.get("top_gradient") or "").strip().lower()
if _tg_raw in _TOP_GRADIENT_LEVELS:
cfg.top_gradient = _tg_raw
elif _tg_raw in ("true", "1", "yes"):
cfg.top_gradient = "high"
elif _tg_raw in ("false", "0", "no"):
cfg.top_gradient = "off"
# else: leave RequestConfig default ("high")
# bottom_gradient — same four-level enum as top. Brand-new param so no
# legacy boolean form to honour; unknown values fall through to the
# RequestConfig default ("high") which matches the legacy behaviour.
_bg_raw = (params.get("bottom_gradient") or "").strip().lower()
if _bg_raw in _BOTTOM_GRADIENT_LEVELS:
cfg.bottom_gradient = _bg_raw
cfg.sash_badge = _b("sash_badge", cfg.sash_badge)
# sash_mode supersedes the legacy sash_badge bool; fall back to it for old
# URLs/presets (sash_badge=true → notch, false → diagonal sash).
_sm_raw = (params.get("sash_mode") or "").strip().lower()
if _sm_raw in ("hidden", "sash", "notch"):
cfg.sash_mode = _sm_raw
elif "show_award_sash" in params and not cfg.show_award_sash:
cfg.sash_mode = "hidden" # legacy: sashes turned off
elif "sash_badge" in params:
cfg.sash_mode = "notch" if cfg.sash_badge else "sash"
cfg.sash_badge_inset = _f("sash_badge_inset", cfg.sash_badge_inset, -0.02, 0.02)
cfg.sash_badge_font_ratio = _f("sash_badge_font_ratio", cfg.sash_badge_font_ratio, 0.10, 1.0)
cfg.sash_badge_frost_opacity = _f("sash_badge_frost_opacity", cfg.sash_badge_frost_opacity, 0.0, 1.0)
cfg.sash_badge_size_w = _f("sash_badge_size_w", cfg.sash_badge_size_w, 0.5, 2.0)
cfg.sash_badge_size_h = _f("sash_badge_size_h", cfg.sash_badge_size_h, 0.5, 2.0)
_style_raw = params.get("sash_badge_style", cfg.sash_badge_style)
if _style_raw in ("silver", "gold", "frosted", "black"):
cfg.sash_badge_style = _style_raw
cfg.sash_length_ratio = _f("sash_length_ratio", cfg.sash_length_ratio, 0.8, 1.5)
cfg.sash_height_ratio = _f("sash_height_ratio", cfg.sash_height_ratio, 0.06, 0.20)
cfg.wait_for_quality = _b("wait_for_quality", cfg.wait_for_quality)
cfg.greyscale_no_quality = _b("greyscale_no_quality", cfg.greyscale_no_quality)
cfg.score_color_mode = _i("score_color_mode", cfg.score_color_mode, 0, 2)
cfg.badge_display_mode = _i("badge_display_mode", cfg.badge_display_mode, 0, 5)
cfg.rating_display_mode = _i("rating_display_mode", cfg.rating_display_mode, 0, 4)
if "show_quality_badges" in params and "badge_display_mode" not in params:
if _parse_bool(params.get("show_quality_badges"), True):
cfg.badge_display_mode = 1
else:
cfg.badge_display_mode = 0
# Font-size ratios are multiplied by the poster width — anything above ~0.3
# would overflow the poster; we cap at 0.5 to leave headroom for experimentation.
cfg.accent_bar_font_size_ratio = _f("accent_bar_font_size_ratio", cfg.accent_bar_font_size_ratio, 0.0, 0.5)
cfg.accent_bar_append_mode = _i("accent_bar_append_mode", cfg.accent_bar_append_mode, 0, 2)
cfg.accent_bar_bottom_ratio = _f("accent_bar_bottom_ratio", cfg.accent_bar_bottom_ratio, 0.0, 0.5)
cfg.numeric_score_font_size_ratio = _f("numeric_score_font_size_ratio", cfg.numeric_score_font_size_ratio, 0.0, 0.5)
cfg.accent_bar_y_offset = _f("accent_bar_y_offset", cfg.accent_bar_y_offset, 0.0, 1.0)
cfg.numeric_score_y_offset = _f("numeric_score_y_offset", cfg.numeric_score_y_offset, 0.0, 1.0)
cfg.score_glow_threshold = _i("score_glow_threshold", cfg.score_glow_threshold, 0, 100)
# Glow blur is a Gaussian kernel radius — cost is O(r²) per pixel, so anything
# above ~50 starts measurably slowing the render. Hard cap at 50.
cfg.score_glow_blur = _i("score_glow_blur", cfg.score_glow_blur, 0, 50)
cfg.score_glow_alpha = _i("score_glow_alpha", cfg.score_glow_alpha, 0, 255)
cfg.minimalist_mode_font_size_ratio = _f("minimalist_mode_font_size_ratio", cfg.minimalist_mode_font_size_ratio, 0.0, 0.5)
cfg.minimalist_mode_font_x_offset = _f("minimalist_mode_font_x_offset", cfg.minimalist_mode_font_x_offset, 0.0, 1.0)
cfg.minimalist_mode_font_y_offset = _f("minimalist_mode_font_y_offset", cfg.minimalist_mode_font_y_offset, 0.0, 1.0)
cfg.minimalist_append_mode = _i("minimalist_append_mode", cfg.minimalist_append_mode, 0, 2)
cfg.bar_height_ratio = _f("bar_height_ratio", cfg.bar_height_ratio, 0.04, 0.20)
cfg.bar_font_size_ratio = _f("bar_font_size_ratio", cfg.bar_font_size_ratio, 0.15, 0.70)
cfg.bar_frost_opacity = _f("bar_frost_opacity", cfg.bar_frost_opacity, 0.0, 1.0)
cfg.bar_bottom_inset = _f("bar_bottom_inset", cfg.bar_bottom_inset, 0.0, 0.10)
_bst = (params.get("bar_style") or "").strip().lower()
if _bst in ("frosted", "pure_black", "silver", "gold", "rating_black", "rating_frosted"):
cfg.bar_style = _bst
_bac = (params.get("bar_accent") or "").strip().lower()
if _bac in ("silver", "gold", "sample", "palette_0", "palette_1", "palette_2"):
cfg.bar_accent = _bac
cfg.bar_score_out_of_10 = _b("bar_score_out_of_10", cfg.bar_score_out_of_10)
cfg.bar_match_notch = _b("bar_match_notch", cfg.bar_match_notch)
_bap = (params.get("bar_append") or "").strip().lower()
if _bap in ("rating_year", "rating", "year", "sash"):
cfg.bar_append = _bap
cfg.logo_max_w_ratio = _f("logo_max_w_ratio", cfg.logo_max_w_ratio, 0.0, 1.5)
cfg.logo_max_h_ratio = _f("logo_max_h_ratio", cfg.logo_max_h_ratio, 0.0, 1.0)
cfg.logo_bottom_ratio = _f("logo_bottom_ratio", cfg.logo_bottom_ratio, 0.0, 1.0)
# badge_height in pixels — generous enough to cover any reasonable customisation
# but well below the size that would cost real memory on resize.
cfg.badge_height = _i("badge_height", cfg.badge_height, 1, 200)
cfg.badge_gap = _i("badge_gap", cfg.badge_gap, 0, 100)
cfg.badge_anchor_x = _f("badge_anchor_x", cfg.badge_anchor_x, 0.0, 1.0)
cfg.badge_anchor_y = _f("badge_anchor_y", cfg.badge_anchor_y, 0.0, 1.0)
cfg.badge_min_score = _i("badge_min_score",
_i("combined_badge_min_score", cfg.badge_min_score, 2, 6),
2, 6)
cfg.combined_badge_stacked = _b("combined_badge_stacked", cfg.combined_badge_stacked)
all_sources = list(_cfg.MOVIE_WEIGHTS.keys())
cfg.movie_weights = _parse_weights(params.get("movie_weights"), all_sources)
tv_sources = list(_cfg.TV_WEIGHTS.keys())
cfg.tv_weights = _parse_weights(params.get("tv_weights"), tv_sources)
cfg.fallback_to_imdb = _b("fallback_to_imdb", cfg.fallback_to_imdb)
cfg.logo_language = (params.get("logo_language", cfg.logo_language).strip().lower())
_lp = params.get("logo_priority")
if _lp in (
"native_original",
"original_native",
"native_if_original_english",
"native_text",
):
cfg.logo_priority = _lp
elif "logo_native_fallback" in params:
# Legacy param (boolean): true → native_original, false → native_text.
cfg.logo_priority = "native_original" if _b("logo_native_fallback", True) else "native_text"
_fbs = (params.get("fallback_bg_style") or "").strip().lower()
if _fbs in ("minimal", "photoreal"):
cfg.fallback_bg_style = _fbs
cfg.use_original_art = _b("use_original_art", cfg.use_original_art)
_oas = (params.get("original_art_source") or "").strip().lower()
if _oas in ("primary", "top_rated"):
cfg.original_art_source = _oas
cfg.sash_priority = _parse_sash_priority(params.get("sash_priority"))
return cfg
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _resolved(value):
return value