Spaces:
Running
Running
Hermes Agent commited on
Commit ·
2bc80a8
1
Parent(s): 37a6b13
feat(perf): implement direct-play bypass, HD artwork pipeline, multi-bot pooling, WAL checkpoints and read-ahead buffering
Browse files- apex/apex_stream/stream.py +24 -0
- apex/apex_stream/tmdb_meta.py +11 -1
- apex/combined-entrypoint.sh +29 -0
- apex/gateway/main.py +7 -0
- apex/gateway/utils.py +14 -0
- apex/tests/test_gateway_plugin_routing.py +56 -0
- apex/tg_streamer.py +67 -13
apex/apex_stream/stream.py
CHANGED
|
@@ -254,6 +254,14 @@ class StreamDriver:
|
|
| 254 |
for _ in range(to_skip):
|
| 255 |
await run.read_chunk()
|
| 256 |
while chunk_index <= run_end:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
chunk = await run.read_chunk()
|
| 258 |
chunk_index += 1
|
| 259 |
if emitted_window == 0:
|
|
@@ -334,6 +342,22 @@ class StreamDriver:
|
|
| 334 |
|
| 335 |
return _factory
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
# --- convenience ---------------------------------------------------
|
| 338 |
|
| 339 |
def status_snapshot(self) -> dict:
|
|
|
|
| 254 |
for _ in range(to_skip):
|
| 255 |
await run.read_chunk()
|
| 256 |
while chunk_index <= run_end:
|
| 257 |
+
# Predictive Read-Ahead: when reaching the end of the current run,
|
| 258 |
+
# asynchronously prefetch the subsequent run into cache
|
| 259 |
+
if chunk_index == run_end and run_end < layout.end_chunk:
|
| 260 |
+
next_run_start = run_end + 1
|
| 261 |
+
next_run_count = min(cfg.run_size, layout.end_chunk - next_run_start + 1)
|
| 262 |
+
asyncio.create_task(
|
| 263 |
+
self._prefetch_next_run(source_key, next_run_start, next_run_count, plan.dc_id)
|
| 264 |
+
)
|
| 265 |
chunk = await run.read_chunk()
|
| 266 |
chunk_index += 1
|
| 267 |
if emitted_window == 0:
|
|
|
|
| 342 |
|
| 343 |
return _factory
|
| 344 |
|
| 345 |
+
async def _prefetch_next_run(self, source_key: tuple, next_run_start: int, next_run_count: int, dc_id: Optional[int]) -> None:
|
| 346 |
+
"""Asynchronously pre-warm the next chunk run in RAM cache ahead of client playback."""
|
| 347 |
+
try:
|
| 348 |
+
client = await self.pool.select(dc_id=dc_id)
|
| 349 |
+
try:
|
| 350 |
+
factory = self._factory_for(client.client, source_key[0], source_key[1])
|
| 351 |
+
source_run = await self.registry.get_run(source_key, next_run_start, next_run_count, factory)
|
| 352 |
+
try:
|
| 353 |
+
await asyncio.wait_for(source_run.read_chunk(), timeout=2.0)
|
| 354 |
+
finally:
|
| 355 |
+
await source_run.close()
|
| 356 |
+
finally:
|
| 357 |
+
await self.pool.release(client.client_id)
|
| 358 |
+
except Exception:
|
| 359 |
+
pass
|
| 360 |
+
|
| 361 |
# --- convenience ---------------------------------------------------
|
| 362 |
|
| 363 |
def status_snapshot(self) -> dict:
|
apex/apex_stream/tmdb_meta.py
CHANGED
|
@@ -185,6 +185,14 @@ _MEDIA_EXT_RE = re.compile(
|
|
| 185 |
)
|
| 186 |
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
def _strip_noise(text: str) -> str:
|
| 189 |
"""Strip group tags ([...], @Handle), the real media extension and collapse
|
| 190 |
whitespace.
|
|
@@ -196,7 +204,9 @@ def _strip_noise(text: str) -> str:
|
|
| 196 |
keeping them produces dotted folder names and breaks both Jellyfin
|
| 197 |
matching and TMDB search queries.
|
| 198 |
"""
|
| 199 |
-
t =
|
|
|
|
|
|
|
| 200 |
t = re.sub(r"@[\w.\-]+", " ", t)
|
| 201 |
t = _MEDIA_EXT_RE.sub(" ", t)
|
| 202 |
# "+" and "&" are release/present separators too ("Dd+5.1 H" -> "Dd 5 1 H",
|
|
|
|
| 185 |
)
|
| 186 |
|
| 187 |
|
| 188 |
+
_URL_NOISE_RE = re.compile(
|
| 189 |
+
r"(?i)\b(?:https?://\S+|www\.\S+|\S+\.(?:com|org|net|vip|in|me|xyz|top|site|cc|to|is|pm|ms|link))\b"
|
| 190 |
+
)
|
| 191 |
+
_WATERMARK_PAREN_RE = re.compile(
|
| 192 |
+
r"(?i)\s*\([^)]*(?:www\.|t\.me|https?://|\.com|\.org|\.net|\.vip|\.in|\.xyz|tamil|bolly|cinema|esub|dual[- ]?audio|clean[- ]?audio|hq[- ]?line|rip)[^)]*\)"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
def _strip_noise(text: str) -> str:
|
| 197 |
"""Strip group tags ([...], @Handle), the real media extension and collapse
|
| 198 |
whitespace.
|
|
|
|
| 204 |
keeping them produces dotted folder names and breaks both Jellyfin
|
| 205 |
matching and TMDB search queries.
|
| 206 |
"""
|
| 207 |
+
t = _URL_NOISE_RE.sub(" ", text)
|
| 208 |
+
t = _WATERMARK_PAREN_RE.sub(" ", t)
|
| 209 |
+
t = re.sub(r"\[[^\]]*\]", " ", t)
|
| 210 |
t = re.sub(r"@[\w.\-]+", " ", t)
|
| 211 |
t = _MEDIA_EXT_RE.sub(" ", t)
|
| 212 |
# "+" and "&" are release/present separators too ("Dd+5.1 H" -> "Dd 5 1 H",
|
apex/combined-entrypoint.sh
CHANGED
|
@@ -42,6 +42,7 @@ restore_jellyfin_local() {
|
|
| 42 |
\( -name metadata -o -name metadata.snapshot \) -prune -o \
|
| 43 |
-exec cp -a -f {} "$LOCAL_ROOT/data/" \; 2>/dev/null || true
|
| 44 |
echo "[PERSISTENCE] Jellyfin database snapshot found ($(stat -c '%s' /data/jellyfin/data/data/jellyfin.db 2>/dev/null || echo unknown) bytes)."
|
|
|
|
| 45 |
else
|
| 46 |
echo "[PERSISTENCE] No Jellyfin database snapshot found; Jellyfin will run first-time initialization."
|
| 47 |
fi
|
|
@@ -111,6 +112,7 @@ sync_jellyfin_back() {
|
|
| 111 |
while :; do
|
| 112 |
sleep 20
|
| 113 |
if [ -f "$LOCAL_ROOT/data/data/jellyfin.db" ]; then
|
|
|
|
| 114 |
mkdir -p /data/jellyfin/data/data
|
| 115 |
snapshot="/data/jellyfin/data/data/jellyfin.db.tmp"
|
| 116 |
if sqlite3 "$LOCAL_ROOT/data/data/jellyfin.db" ".backup '$snapshot'" 2>/dev/null; then
|
|
@@ -228,6 +230,33 @@ for bcfg in "$LOCAL_ROOT/config/branding.xml" "/data/jellyfin/config/branding.xm
|
|
| 228 |
fi
|
| 229 |
done
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
echo "[HEALTH] Jellyfin starting in background (local datadir)..."
|
| 232 |
launch_jellyfin
|
| 233 |
|
|
|
|
| 42 |
\( -name metadata -o -name metadata.snapshot \) -prune -o \
|
| 43 |
-exec cp -a -f {} "$LOCAL_ROOT/data/" \; 2>/dev/null || true
|
| 44 |
echo "[PERSISTENCE] Jellyfin database snapshot found ($(stat -c '%s' /data/jellyfin/data/data/jellyfin.db 2>/dev/null || echo unknown) bytes)."
|
| 45 |
+
sqlite3 "$LOCAL_ROOT/data/data/jellyfin.db" "PRAGMA journal_mode=WAL; PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true
|
| 46 |
else
|
| 47 |
echo "[PERSISTENCE] No Jellyfin database snapshot found; Jellyfin will run first-time initialization."
|
| 48 |
fi
|
|
|
|
| 112 |
while :; do
|
| 113 |
sleep 20
|
| 114 |
if [ -f "$LOCAL_ROOT/data/data/jellyfin.db" ]; then
|
| 115 |
+
sqlite3 "$LOCAL_ROOT/data/data/jellyfin.db" "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true
|
| 116 |
mkdir -p /data/jellyfin/data/data
|
| 117 |
snapshot="/data/jellyfin/data/data/jellyfin.db.tmp"
|
| 118 |
if sqlite3 "$LOCAL_ROOT/data/data/jellyfin.db" ".backup '$snapshot'" 2>/dev/null; then
|
|
|
|
| 230 |
fi
|
| 231 |
done
|
| 232 |
|
| 233 |
+
# Configure encoding.xml for direct play / stream remuxing (prevents CPU-heavy video transcode)
|
| 234 |
+
ENCODING_XML="$LOCAL_ROOT/config/encoding.xml"
|
| 235 |
+
python3 - "$ENCODING_XML" <<'PY'
|
| 236 |
+
import sys
|
| 237 |
+
from pathlib import Path
|
| 238 |
+
|
| 239 |
+
path = Path(sys.argv[1])
|
| 240 |
+
content = """<?xml version="1.0" encoding="utf-8"?>
|
| 241 |
+
<EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
| 242 |
+
<EncodingThreadCount>0</EncodingThreadCount>
|
| 243 |
+
<TranscodingTempPath>/opt/jellyfin-local/cache/transcodes</TranscodingTempPath>
|
| 244 |
+
<DownMixAudioBoost>2</DownMixAudioBoost>
|
| 245 |
+
<EnableThrottling>true</EnableThrottling>
|
| 246 |
+
<ThrottleDelaySeconds>180</ThrottleDelaySeconds>
|
| 247 |
+
<HardwareAccelerationType>none</HardwareAccelerationType>
|
| 248 |
+
<EnableVideoEncoding>false</EnableVideoEncoding>
|
| 249 |
+
<EnableAudioEncoding>true</EnableAudioEncoding>
|
| 250 |
+
<AllowVideoStreamCopy>true</AllowVideoStreamCopy>
|
| 251 |
+
<AllowAudioStreamCopy>true</AllowAudioStreamCopy>
|
| 252 |
+
</EncodingOptions>
|
| 253 |
+
"""
|
| 254 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 255 |
+
path.write_text(content.strip(), encoding="utf-8")
|
| 256 |
+
PY
|
| 257 |
+
cp -f "$ENCODING_XML" "/data/jellyfin/config/encoding.xml" 2>/dev/null || true
|
| 258 |
+
echo "[JELLYFIN] Configured zero-CPU video encoding & direct stream remux in $ENCODING_XML"
|
| 259 |
+
|
| 260 |
echo "[HEALTH] Jellyfin starting in background (local datadir)..."
|
| 261 |
launch_jellyfin
|
| 262 |
|
apex/gateway/main.py
CHANGED
|
@@ -66,6 +66,13 @@ JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", "8096"))
|
|
| 66 |
TG_PORT = int(os.environ.get("TG_LISTEN_PORT", "8082"))
|
| 67 |
FILETOLINK_PORT = int(os.environ.get("FILETOLINK_PORT", "8081"))
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
@app.get("/health")
|
| 70 |
@app.get("/debug/status")
|
| 71 |
async def health_check():
|
|
|
|
| 66 |
TG_PORT = int(os.environ.get("TG_LISTEN_PORT", "8082"))
|
| 67 |
FILETOLINK_PORT = int(os.environ.get("FILETOLINK_PORT", "8081"))
|
| 68 |
|
| 69 |
+
@app.get("/ping")
|
| 70 |
+
@app.get("/keepalive")
|
| 71 |
+
@app.get("/heartbeat")
|
| 72 |
+
async def keepalive_ping():
|
| 73 |
+
"""Fast, zero-overhead heartbeat endpoint for UptimeRobot / cron pings to prevent Space sleep."""
|
| 74 |
+
return JSONResponse({"status": "ok", "service": "Apex-Jellyfin", "alive": True})
|
| 75 |
+
|
| 76 |
@app.get("/health")
|
| 77 |
@app.get("/debug/status")
|
| 78 |
async def health_check():
|
apex/gateway/utils.py
CHANGED
|
@@ -298,6 +298,20 @@ async def proxy_http_request(
|
|
| 298 |
except Exception:
|
| 299 |
pass
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
normal_resp = Response(
|
| 302 |
content=content,
|
| 303 |
status_code=status_code,
|
|
|
|
| 298 |
except Exception:
|
| 299 |
pass
|
| 300 |
|
| 301 |
+
# Direct Play Optimization: Force DirectPlay & DirectStream in PlaybackInfo responses
|
| 302 |
+
# for .strm items so modern browsers and devices stream natively without HLS CPU transcoding
|
| 303 |
+
if "application/json" in content_type and "playbackinfo" in request.url.path.lower() and content:
|
| 304 |
+
try:
|
| 305 |
+
import json
|
| 306 |
+
pb_data = json.loads(content.decode("utf-8"))
|
| 307 |
+
for m_src in pb_data.get("MediaSources") or []:
|
| 308 |
+
if isinstance(m_src, dict):
|
| 309 |
+
m_src["SupportsDirectPlay"] = True
|
| 310 |
+
m_src["SupportsDirectStream"] = True
|
| 311 |
+
content = json.dumps(pb_data).encode("utf-8")
|
| 312 |
+
except Exception:
|
| 313 |
+
pass
|
| 314 |
+
|
| 315 |
normal_resp = Response(
|
| 316 |
content=content,
|
| 317 |
status_code=status_code,
|
apex/tests/test_gateway_plugin_routing.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import pytest
|
|
|
|
| 2 |
from starlette.datastructures import Headers
|
| 3 |
from unittest.mock import AsyncMock, patch
|
| 4 |
|
|
@@ -90,3 +91,58 @@ async def test_jellyfin_ui_assets_rewrite_to_web():
|
|
| 90 |
target_url = mock_proxy.call_args[0][0]
|
| 91 |
assert "8096" in target_url
|
| 92 |
assert "/web/ui/abyss.css" in target_url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
+
import httpx
|
| 3 |
from starlette.datastructures import Headers
|
| 4 |
from unittest.mock import AsyncMock, patch
|
| 5 |
|
|
|
|
| 91 |
target_url = mock_proxy.call_args[0][0]
|
| 92 |
assert "8096" in target_url
|
| 93 |
assert "/web/ui/abyss.css" in target_url
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@pytest.mark.asyncio
|
| 97 |
+
async def test_keepalive_endpoints():
|
| 98 |
+
from gateway.main import keepalive_ping
|
| 99 |
+
resp = await keepalive_ping()
|
| 100 |
+
assert resp.status_code == 200
|
| 101 |
+
import json
|
| 102 |
+
data = json.loads(resp.body.decode("utf-8"))
|
| 103 |
+
assert data["status"] == "ok"
|
| 104 |
+
assert data["alive"] is True
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@pytest.mark.asyncio
|
| 108 |
+
async def test_playbackinfo_directplay_override():
|
| 109 |
+
import json
|
| 110 |
+
from unittest.mock import MagicMock
|
| 111 |
+
from gateway.utils import proxy_http_request
|
| 112 |
+
|
| 113 |
+
# Mock response with MediaSources having SupportsDirectPlay=False
|
| 114 |
+
mock_resp = MagicMock()
|
| 115 |
+
mock_resp.status_code = 200
|
| 116 |
+
mock_resp.headers = httpx.Headers({"content-type": "application/json"})
|
| 117 |
+
payload = {
|
| 118 |
+
"MediaSources": [
|
| 119 |
+
{
|
| 120 |
+
"Id": "item1",
|
| 121 |
+
"Path": "/data/media/Movies/Spider-Man.strm",
|
| 122 |
+
"SupportsDirectPlay": False,
|
| 123 |
+
"SupportsDirectStream": False,
|
| 124 |
+
}
|
| 125 |
+
]
|
| 126 |
+
}
|
| 127 |
+
mock_resp.aread = AsyncMock(return_value=json.dumps(payload).encode("utf-8"))
|
| 128 |
+
mock_resp.aclose = AsyncMock()
|
| 129 |
+
|
| 130 |
+
mock_client = MagicMock()
|
| 131 |
+
mock_client.build_request.return_value = MagicMock()
|
| 132 |
+
mock_client.send = AsyncMock(return_value=mock_resp)
|
| 133 |
+
|
| 134 |
+
with patch("gateway.utils.get_http_client", return_value=mock_client):
|
| 135 |
+
from starlette.datastructures import QueryParams
|
| 136 |
+
req = AsyncMock()
|
| 137 |
+
req.url.path = "/jellyfin/Items/123/PlaybackInfo"
|
| 138 |
+
req.headers = Headers({"accept": "application/json"})
|
| 139 |
+
req.method = "POST"
|
| 140 |
+
req.body = AsyncMock(return_value=b"{}")
|
| 141 |
+
req.query_params = QueryParams([])
|
| 142 |
+
|
| 143 |
+
result = await proxy_http_request("http://127.0.0.1:8096/Items/123/PlaybackInfo", req)
|
| 144 |
+
assert result.status_code == 200
|
| 145 |
+
out_data = json.loads(result.body.decode("utf-8"))
|
| 146 |
+
src = out_data["MediaSources"][0]
|
| 147 |
+
assert src["SupportsDirectPlay"] is True
|
| 148 |
+
assert src["SupportsDirectStream"] is True
|
apex/tg_streamer.py
CHANGED
|
@@ -759,10 +759,25 @@ apex_driver = StreamDriver(
|
|
| 759 |
# they never consume updates so the webhook/primary bot owns ingestion.
|
| 760 |
extra_clients = []
|
| 761 |
if not APEX_CFG.extra_tokens:
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
if
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
for _ec_idx, _ec_token in enumerate(APEX_CFG.extra_tokens, start=2):
|
| 767 |
if API_ID and API_HASH and _ec_token:
|
| 768 |
try:
|
|
@@ -1444,10 +1459,24 @@ async def _enrich_media_metadata(entry):
|
|
| 1444 |
f.write(build_tvshow_nfo(detail))
|
| 1445 |
poster_url = _best_image("poster_path")
|
| 1446 |
if poster_url and not os.path.exists(poster_path):
|
| 1447 |
-
await _download_image(session, _resize_tmdb_image(poster_url), poster_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1448 |
backdrop_url = _best_image("backdrop_path")
|
| 1449 |
if backdrop_url and not os.path.exists(backdrop_path):
|
| 1450 |
-
await _download_image(session, _resize_tmdb_image(backdrop_url, "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1451 |
|
| 1452 |
season_detail = None
|
| 1453 |
season_number = int(entry.get("season") or 1)
|
|
@@ -1476,12 +1505,35 @@ async def _enrich_media_metadata(entry):
|
|
| 1476 |
)
|
| 1477 |
with open(nfo_path, "w", encoding="utf-8") as f:
|
| 1478 |
f.write(build_movie_nfo(detail))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1479 |
poster_url = _best_image("poster_path")
|
| 1480 |
-
|
| 1481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1482 |
backdrop_url = _best_image("backdrop_path")
|
| 1483 |
-
|
| 1484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1485 |
|
| 1486 |
prev = FILE_ID_CACHE.get(key)
|
| 1487 |
if isinstance(prev, dict):
|
|
@@ -1542,9 +1594,11 @@ def _tmdb_year(detail):
|
|
| 1542 |
return None
|
| 1543 |
|
| 1544 |
|
| 1545 |
-
def _resize_tmdb_image(url, size="
|
| 1546 |
-
"""Rewrite a TMDB
|
| 1547 |
-
if "image.tmdb.org/" not in url:
|
|
|
|
|
|
|
| 1548 |
return url
|
| 1549 |
return url.replace("/original", f"/{size}", 1)
|
| 1550 |
|
|
|
|
| 759 |
# they never consume updates so the webhook/primary bot owns ingestion.
|
| 760 |
extra_clients = []
|
| 761 |
if not APEX_CFG.extra_tokens:
|
| 762 |
+
discovered_tokens = []
|
| 763 |
+
for index in range(1, 100):
|
| 764 |
+
if val := os.environ.get(f"JELLYFIN_MULTI_TOKEN{index}", "").strip():
|
| 765 |
+
if val not in discovered_tokens:
|
| 766 |
+
discovered_tokens.append(val)
|
| 767 |
+
for prefix in ("BOT_TOKEN_", "TG_BOT_TOKEN_", "MULTI_TOKEN_"):
|
| 768 |
+
for index in range(1, 25):
|
| 769 |
+
if val := os.environ.get(f"{prefix}{index}", "").strip():
|
| 770 |
+
if val not in discovered_tokens and val != BOT_TOKEN:
|
| 771 |
+
discovered_tokens.append(val)
|
| 772 |
+
for env_name in ("EXTRA_BOT_TOKENS", "MULTI_BOT_TOKENS", "APEX_EXTRA_TOKENS", "APEX_TELEGRAM_EXTRA_TOKENS"):
|
| 773 |
+
if val := os.environ.get(env_name, "").strip():
|
| 774 |
+
for tok in val.split(","):
|
| 775 |
+
tok_clean = tok.strip()
|
| 776 |
+
if tok_clean and tok_clean not in discovered_tokens and tok_clean != BOT_TOKEN:
|
| 777 |
+
discovered_tokens.append(tok_clean)
|
| 778 |
+
APEX_CFG.extra_tokens = discovered_tokens
|
| 779 |
+
if APEX_CFG.extra_tokens:
|
| 780 |
+
logger.info(f"[POOL] Discovered {len(APEX_CFG.extra_tokens)} extra streaming bot token(s).")
|
| 781 |
for _ec_idx, _ec_token in enumerate(APEX_CFG.extra_tokens, start=2):
|
| 782 |
if API_ID and API_HASH and _ec_token:
|
| 783 |
try:
|
|
|
|
| 1459 |
f.write(build_tvshow_nfo(detail))
|
| 1460 |
poster_url = _best_image("poster_path")
|
| 1461 |
if poster_url and not os.path.exists(poster_path):
|
| 1462 |
+
if await _download_image(session, _resize_tmdb_image(poster_url, "w780"), poster_path):
|
| 1463 |
+
folder_path = os.path.join(show_root, "folder.jpg")
|
| 1464 |
+
if not os.path.exists(folder_path):
|
| 1465 |
+
try:
|
| 1466 |
+
with open(poster_path, "rb") as src, open(folder_path, "wb") as dst:
|
| 1467 |
+
dst.write(src.read())
|
| 1468 |
+
except OSError:
|
| 1469 |
+
pass
|
| 1470 |
backdrop_url = _best_image("backdrop_path")
|
| 1471 |
if backdrop_url and not os.path.exists(backdrop_path):
|
| 1472 |
+
if await _download_image(session, _resize_tmdb_image(backdrop_url, "original"), backdrop_path):
|
| 1473 |
+
fanart_path = os.path.join(show_root, "fanart.jpg")
|
| 1474 |
+
if not os.path.exists(fanart_path):
|
| 1475 |
+
try:
|
| 1476 |
+
with open(backdrop_path, "rb") as src, open(fanart_path, "wb") as dst:
|
| 1477 |
+
dst.write(src.read())
|
| 1478 |
+
except OSError:
|
| 1479 |
+
pass
|
| 1480 |
|
| 1481 |
season_detail = None
|
| 1482 |
season_number = int(entry.get("season") or 1)
|
|
|
|
| 1505 |
)
|
| 1506 |
with open(nfo_path, "w", encoding="utf-8") as f:
|
| 1507 |
f.write(build_movie_nfo(detail))
|
| 1508 |
+
movie_nfo = os.path.join(target_dir, "movie.nfo")
|
| 1509 |
+
if not os.path.exists(movie_nfo):
|
| 1510 |
+
try:
|
| 1511 |
+
with open(nfo_path, "rb") as src, open(movie_nfo, "wb") as dst:
|
| 1512 |
+
dst.write(src.read())
|
| 1513 |
+
except OSError:
|
| 1514 |
+
pass
|
| 1515 |
poster_url = _best_image("poster_path")
|
| 1516 |
+
movie_poster = os.path.join(target_dir, "poster.jpg")
|
| 1517 |
+
if poster_url and not os.path.exists(movie_poster):
|
| 1518 |
+
if await _download_image(session, _resize_tmdb_image(poster_url, "w780"), movie_poster):
|
| 1519 |
+
folder_path = os.path.join(target_dir, "folder.jpg")
|
| 1520 |
+
if not os.path.exists(folder_path):
|
| 1521 |
+
try:
|
| 1522 |
+
with open(movie_poster, "rb") as src, open(folder_path, "wb") as dst:
|
| 1523 |
+
dst.write(src.read())
|
| 1524 |
+
except OSError:
|
| 1525 |
+
pass
|
| 1526 |
backdrop_url = _best_image("backdrop_path")
|
| 1527 |
+
movie_backdrop = os.path.join(target_dir, "backdrop.jpg")
|
| 1528 |
+
if backdrop_url and not os.path.exists(movie_backdrop):
|
| 1529 |
+
if await _download_image(session, _resize_tmdb_image(backdrop_url, "original"), movie_backdrop):
|
| 1530 |
+
fanart_path = os.path.join(target_dir, "fanart.jpg")
|
| 1531 |
+
if not os.path.exists(fanart_path):
|
| 1532 |
+
try:
|
| 1533 |
+
with open(movie_backdrop, "rb") as src, open(fanart_path, "wb") as dst:
|
| 1534 |
+
dst.write(src.read())
|
| 1535 |
+
except OSError:
|
| 1536 |
+
pass
|
| 1537 |
|
| 1538 |
prev = FILE_ID_CACHE.get(key)
|
| 1539 |
if isinstance(prev, dict):
|
|
|
|
| 1594 |
return None
|
| 1595 |
|
| 1596 |
|
| 1597 |
+
def _resize_tmdb_image(url, size="w780"):
|
| 1598 |
+
"""Rewrite a TMDB image URL to requested size (or original)."""
|
| 1599 |
+
if not url or "image.tmdb.org/" not in url:
|
| 1600 |
+
return url
|
| 1601 |
+
if size == "original":
|
| 1602 |
return url
|
| 1603 |
return url.replace("/original", f"/{size}", 1)
|
| 1604 |
|