Spaces:
Running
Running
Hermes Agent commited on
Commit ·
701239e
1
Parent(s): 6231a0a
fix(meta): self-heal stale NFOs with ASCII-mangled original titles
Browse filesNon-ASCII original titles (Malayalam/Tamil/Hindi scripts) were written as
a run of '?' by an earlier writer and never repaired: the enrichment
idempotency gate treated any existing NFO as cached and the movie/episode
NFO writes were guarded by os.path.exists. Now:
- nfo_needs_repair() flags any NFO whose originaltitle/original_name is a
run of '?'); such NFOs are excluded from the idempotency gate and are
rewritten on the next enrich pass
- movie/episode NFO writes are unconditional when a fresh detail was
fetched (self-healing), tvshow.nfo rewrites when malformed
- streamer launched with PYTHONUTF8=1 PYTHONIOENCODING=utf-8
- apex/apex_stream/tmdb_meta.py +18 -0
- apex/combined-entrypoint.sh +2 -2
- apex/entrypoint.sh +2 -2
- apex/tests/test_tmdb_meta.py +31 -1
- apex/tg_streamer.py +20 -15
apex/apex_stream/tmdb_meta.py
CHANGED
|
@@ -29,6 +29,24 @@ TPL_BASE = "https://image.tmdb.org/t/p"
|
|
| 29 |
|
| 30 |
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# TV patterns, in priority order. All return (show_name, season, episode) or None.
|
| 33 |
_SX_E = re.compile(r"(?i)\bS(\d{1,2})\s*(?:\.|\s|_|-)+\s*E(\d{1,3})\b")
|
| 34 |
_SX_EX = re.compile(r"(?i)\bS(\d{1,2})\s*E(\d{1,3})\b")
|
|
|
|
| 29 |
|
| 30 |
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
|
| 31 |
|
| 32 |
+
_NFO_BAD_TITLE_RE = re.compile(
|
| 33 |
+
r"<\s*(?:originaltitle|original_name)\s*>[^<]*\?{2,}[^<]*<", re.IGNORECASE
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def nfo_needs_repair(nfo_path) -> bool:
|
| 38 |
+
"""True when an existing NFO's original title looks malformed (non-ASCII
|
| 39 |
+
script collapsed into a run of '?'), so enrichment re-fetches and rewrites
|
| 40 |
+
it instead of treating the NFO as already-cached."""
|
| 41 |
+
if not nfo_path or not os.path.isfile(nfo_path):
|
| 42 |
+
return False
|
| 43 |
+
try:
|
| 44 |
+
with open(nfo_path, "r", encoding="utf-8", errors="replace") as fh:
|
| 45 |
+
text = fh.read(0x4000)
|
| 46 |
+
except OSError:
|
| 47 |
+
return False
|
| 48 |
+
return bool(_NFO_BAD_TITLE_RE.search(text))
|
| 49 |
+
|
| 50 |
# TV patterns, in priority order. All return (show_name, season, episode) or None.
|
| 51 |
_SX_E = re.compile(r"(?i)\bS(\d{1,2})\s*(?:\.|\s|_|-)+\s*E(\d{1,3})\b")
|
| 52 |
_SX_EX = re.compile(r"(?i)\bS(\d{1,2})\s*E(\d{1,3})\b")
|
apex/combined-entrypoint.sh
CHANGED
|
@@ -185,7 +185,7 @@ sync_jellyfin_back &
|
|
| 185 |
SYNC_PID=$!
|
| 186 |
|
| 187 |
echo "[HEALTH] Telegram streamer starting in background..."
|
| 188 |
-
python3 /opt/apex/tg_streamer.py &
|
| 189 |
TG_STREAMER_PID=$!
|
| 190 |
|
| 191 |
echo "[HEALTH] FastAPI Gateway starting..."
|
|
@@ -213,7 +213,7 @@ while true; do
|
|
| 213 |
|
| 214 |
if [ -n "$TG_STREAMER_PID" ] && ! kill -0 $TG_STREAMER_PID 2>/dev/null; then
|
| 215 |
echo "[CRITICAL] TG Streamer process died! Restarting..."
|
| 216 |
-
python3 /opt/apex/tg_streamer.py &
|
| 217 |
TG_STREAMER_PID=$!
|
| 218 |
fi
|
| 219 |
|
|
|
|
| 185 |
SYNC_PID=$!
|
| 186 |
|
| 187 |
echo "[HEALTH] Telegram streamer starting in background..."
|
| 188 |
+
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python3 /opt/apex/tg_streamer.py &
|
| 189 |
TG_STREAMER_PID=$!
|
| 190 |
|
| 191 |
echo "[HEALTH] FastAPI Gateway starting..."
|
|
|
|
| 213 |
|
| 214 |
if [ -n "$TG_STREAMER_PID" ] && ! kill -0 $TG_STREAMER_PID 2>/dev/null; then
|
| 215 |
echo "[CRITICAL] TG Streamer process died! Restarting..."
|
| 216 |
+
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python3 /opt/apex/tg_streamer.py &
|
| 217 |
TG_STREAMER_PID=$!
|
| 218 |
fi
|
| 219 |
|
apex/entrypoint.sh
CHANGED
|
@@ -153,7 +153,7 @@ sync_jellyfin_back &
|
|
| 153 |
SYNC_PID=$!
|
| 154 |
|
| 155 |
echo "[HEALTH] Telegram streamer starting in background..."
|
| 156 |
-
python3 /tg_streamer.py &
|
| 157 |
TG_STREAMER_PID=$!
|
| 158 |
|
| 159 |
echo "[HEALTH] FastAPI Gateway starting..."
|
|
@@ -181,7 +181,7 @@ while true; do
|
|
| 181 |
|
| 182 |
if [ -n "$TG_STREAMER_PID" ] && ! kill -0 $TG_STREAMER_PID 2>/dev/null; then
|
| 183 |
echo "[CRITICAL] TG Streamer process died! Restarting..."
|
| 184 |
-
python3 /tg_streamer.py &
|
| 185 |
TG_STREAMER_PID=$!
|
| 186 |
fi
|
| 187 |
|
|
|
|
| 153 |
SYNC_PID=$!
|
| 154 |
|
| 155 |
echo "[HEALTH] Telegram streamer starting in background..."
|
| 156 |
+
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python3 /tg_streamer.py &
|
| 157 |
TG_STREAMER_PID=$!
|
| 158 |
|
| 159 |
echo "[HEALTH] FastAPI Gateway starting..."
|
|
|
|
| 181 |
|
| 182 |
if [ -n "$TG_STREAMER_PID" ] && ! kill -0 $TG_STREAMER_PID 2>/dev/null; then
|
| 183 |
echo "[CRITICAL] TG Streamer process died! Restarting..."
|
| 184 |
+
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python3 /tg_streamer.py &
|
| 185 |
TG_STREAMER_PID=$!
|
| 186 |
fi
|
| 187 |
|
apex/tests/test_tmdb_meta.py
CHANGED
|
@@ -7,6 +7,7 @@ from apex_stream.tmdb_meta import (
|
|
| 7 |
build_episode_nfo,
|
| 8 |
build_movie_nfo,
|
| 9 |
build_tvshow_nfo,
|
|
|
|
| 10 |
parse_media_meta,
|
| 11 |
tmdb_image_url,
|
| 12 |
)
|
|
@@ -298,4 +299,33 @@ class TestNfoBuilders:
|
|
| 298 |
|
| 299 |
def test_image_url(self):
|
| 300 |
assert tmdb_image_url("/abc123.jpg") == "https://image.tmdb.org/t/p/w500/abc123.jpg"
|
| 301 |
-
assert tmdb_image_url(None) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
build_episode_nfo,
|
| 8 |
build_movie_nfo,
|
| 9 |
build_tvshow_nfo,
|
| 10 |
+
nfo_needs_repair,
|
| 11 |
parse_media_meta,
|
| 12 |
tmdb_image_url,
|
| 13 |
)
|
|
|
|
| 299 |
|
| 300 |
def test_image_url(self):
|
| 301 |
assert tmdb_image_url("/abc123.jpg") == "https://image.tmdb.org/t/p/w500/abc123.jpg"
|
| 302 |
+
assert tmdb_image_url(None) is None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
class TestNfoRepair:
|
| 306 |
+
def test_healthy_nfo_is_not_flagged(self, tmp_path):
|
| 307 |
+
nfo = tmp_path / "movie.nfo"
|
| 308 |
+
nfo.write_text(
|
| 309 |
+
'<movie><title>Mollywood Times</title><originaltitle>മോളിവുഡ് ടൈംസ്</originaltitle></movie>',
|
| 310 |
+
encoding="utf-8",
|
| 311 |
+
)
|
| 312 |
+
assert nfo_needs_repair(str(nfo)) is False
|
| 313 |
+
|
| 314 |
+
def test_malformed_originaltitle_is_flagged(self, tmp_path):
|
| 315 |
+
nfo = tmp_path / "movie.nfo"
|
| 316 |
+
nfo.write_text(
|
| 317 |
+
'<movie><title>Mollywood Times</title><originaltitle>?????</originaltitle></movie>',
|
| 318 |
+
encoding="utf-8",
|
| 319 |
+
)
|
| 320 |
+
assert nfo_needs_repair(str(nfo)) is True
|
| 321 |
+
|
| 322 |
+
def test_malformed_tvshow_original_name_is_flagged(self, tmp_path):
|
| 323 |
+
nfo = tmp_path / "tvshow.nfo"
|
| 324 |
+
nfo.write_text(
|
| 325 |
+
'<tvshow><name>Test</name><original_name>??????</original_name></tvshow>',
|
| 326 |
+
encoding="utf-8",
|
| 327 |
+
)
|
| 328 |
+
assert nfo_needs_repair(str(nfo)) is True
|
| 329 |
+
|
| 330 |
+
def test_missing_file_not_flagged(self, tmp_path):
|
| 331 |
+
assert nfo_needs_repair(str(tmp_path / "nope.nfo")) is False
|
apex/tg_streamer.py
CHANGED
|
@@ -48,6 +48,7 @@ from apex_stream.tmdb_meta import (
|
|
| 48 |
build_episode_nfo,
|
| 49 |
build_movie_nfo,
|
| 50 |
build_tvshow_nfo,
|
|
|
|
| 51 |
parse_media_meta,
|
| 52 |
)
|
| 53 |
|
|
@@ -1045,14 +1046,16 @@ async def _download_image(session, url, save_path):
|
|
| 1045 |
return False
|
| 1046 |
|
| 1047 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1048 |
async def _enrich_media_metadata(entry):
|
| 1049 |
"""Fetch full TMDB details for a cache entry and write nfo + poster/backdrop.
|
| 1050 |
|
| 1051 |
-
Writes:
|
| 1052 |
-
- movies: <target_dir>/{strm_base}.nfo + poster.jpg + backdrop.jpg
|
| 1053 |
-
- tv: <show_root>/tvshow.nfo + <show_root>/poster.jpg + <show_root>/backdrop.jpg
|
| 1054 |
-
+ <target_dir>/{strm_base}.nfo (episodedetails)
|
| 1055 |
-
|
| 1056 |
Idempotent: skips when the .nfo + poster already exist so restarts don't
|
| 1057 |
hammer the API. Updates the cache entry's tmdb_id/tmdb fields.
|
| 1058 |
"""
|
|
@@ -1068,15 +1071,19 @@ async def _enrich_media_metadata(entry):
|
|
| 1068 |
return False
|
| 1069 |
|
| 1070 |
# Idempotency gate: if the metadata is already on disk, avoid re-hitting
|
| 1071 |
-
# the TMDB API on every restart.
|
|
|
|
|
|
|
| 1072 |
if is_tv and show_root:
|
| 1073 |
-
|
|
|
|
| 1074 |
return True
|
| 1075 |
elif not is_tv:
|
| 1076 |
movie_nfo = os.path.join(
|
| 1077 |
target_dir, f"{entry.get('strm_base', strm_base(entry))}.nfo"
|
| 1078 |
)
|
| 1079 |
-
if os.path.exists(movie_nfo) and os.path.exists(os.path.join(target_dir, "poster.jpg"))
|
|
|
|
| 1080 |
return True
|
| 1081 |
|
| 1082 |
query = entry.get("search_title") or entry.get("show_name") or entry.get("title")
|
|
@@ -1259,7 +1266,7 @@ async def _enrich_media_metadata(entry):
|
|
| 1259 |
nfo_path = os.path.join(show_root, "tvshow.nfo")
|
| 1260 |
poster_path = os.path.join(show_root, "poster.jpg")
|
| 1261 |
backdrop_path = os.path.join(show_root, "backdrop.jpg")
|
| 1262 |
-
if not os.path.exists(nfo_path):
|
| 1263 |
with open(nfo_path, "w", encoding="utf-8") as f:
|
| 1264 |
f.write(build_tvshow_nfo(detail))
|
| 1265 |
poster_url = _best_image("poster_path")
|
|
@@ -1288,16 +1295,14 @@ async def _enrich_media_metadata(entry):
|
|
| 1288 |
break
|
| 1289 |
if ep_detail:
|
| 1290 |
ep_nfo = os.path.join(target_dir, f"{entry.get('strm_base', strm_base(entry))}.nfo")
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
f.write(build_episode_nfo(ep_detail))
|
| 1294 |
else:
|
| 1295 |
nfo_path = os.path.join(
|
| 1296 |
target_dir, f"{entry.get('strm_base', strm_base(entry))}.nfo"
|
| 1297 |
)
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
f.write(build_movie_nfo(detail))
|
| 1301 |
poster_url = _best_image("poster_path")
|
| 1302 |
if poster_url and not os.path.exists(os.path.join(target_dir, "poster.jpg")):
|
| 1303 |
await _download_image(session, _resize_tmdb_image(poster_url), os.path.join(target_dir, "poster.jpg"))
|
|
|
|
| 48 |
build_episode_nfo,
|
| 49 |
build_movie_nfo,
|
| 50 |
build_tvshow_nfo,
|
| 51 |
+
nfo_needs_repair,
|
| 52 |
parse_media_meta,
|
| 53 |
)
|
| 54 |
|
|
|
|
| 1046 |
return False
|
| 1047 |
|
| 1048 |
|
| 1049 |
+
def _nfo_needs_repair(nfo_path):
|
| 1050 |
+
"""True when an existing NFO's original title looks malformed (non-ASCII
|
| 1051 |
+
script collapsed into a run of '?'), so enrichment re-fetches and rewrites
|
| 1052 |
+
it instead of treating the NFO as cached."""
|
| 1053 |
+
return nfo_needs_repair(nfo_path)
|
| 1054 |
+
|
| 1055 |
+
|
| 1056 |
async def _enrich_media_metadata(entry):
|
| 1057 |
"""Fetch full TMDB details for a cache entry and write nfo + poster/backdrop.
|
| 1058 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
Idempotent: skips when the .nfo + poster already exist so restarts don't
|
| 1060 |
hammer the API. Updates the cache entry's tmdb_id/tmdb fields.
|
| 1061 |
"""
|
|
|
|
| 1071 |
return False
|
| 1072 |
|
| 1073 |
# Idempotency gate: if the metadata is already on disk, avoid re-hitting
|
| 1074 |
+
# the TMDB API on every restart. A malformed NFO (e.g. non-ASCII
|
| 1075 |
+
# original titles written as a run of '?') is NOT treated as cached so
|
| 1076 |
+
# it gets re-fetched and repaired.
|
| 1077 |
if is_tv and show_root:
|
| 1078 |
+
tv_nfo = os.path.join(show_root, "tvshow.nfo")
|
| 1079 |
+
if os.path.exists(tv_nfo) and os.path.exists(os.path.join(show_root, "poster.jpg")) and not _nfo_needs_repair(tv_nfo):
|
| 1080 |
return True
|
| 1081 |
elif not is_tv:
|
| 1082 |
movie_nfo = os.path.join(
|
| 1083 |
target_dir, f"{entry.get('strm_base', strm_base(entry))}.nfo"
|
| 1084 |
)
|
| 1085 |
+
if (os.path.exists(movie_nfo) and os.path.exists(os.path.join(target_dir, "poster.jpg"))
|
| 1086 |
+
and not _nfo_needs_repair(movie_nfo)):
|
| 1087 |
return True
|
| 1088 |
|
| 1089 |
query = entry.get("search_title") or entry.get("show_name") or entry.get("title")
|
|
|
|
| 1266 |
nfo_path = os.path.join(show_root, "tvshow.nfo")
|
| 1267 |
poster_path = os.path.join(show_root, "poster.jpg")
|
| 1268 |
backdrop_path = os.path.join(show_root, "backdrop.jpg")
|
| 1269 |
+
if not os.path.exists(nfo_path) or _nfo_needs_repair(nfo_path):
|
| 1270 |
with open(nfo_path, "w", encoding="utf-8") as f:
|
| 1271 |
f.write(build_tvshow_nfo(detail))
|
| 1272 |
poster_url = _best_image("poster_path")
|
|
|
|
| 1295 |
break
|
| 1296 |
if ep_detail:
|
| 1297 |
ep_nfo = os.path.join(target_dir, f"{entry.get('strm_base', strm_base(entry))}.nfo")
|
| 1298 |
+
with open(ep_nfo, "w", encoding="utf-8") as f:
|
| 1299 |
+
f.write(build_episode_nfo(ep_detail))
|
|
|
|
| 1300 |
else:
|
| 1301 |
nfo_path = os.path.join(
|
| 1302 |
target_dir, f"{entry.get('strm_base', strm_base(entry))}.nfo"
|
| 1303 |
)
|
| 1304 |
+
with open(nfo_path, "w", encoding="utf-8") as f:
|
| 1305 |
+
f.write(build_movie_nfo(detail))
|
|
|
|
| 1306 |
poster_url = _best_image("poster_path")
|
| 1307 |
if poster_url and not os.path.exists(os.path.join(target_dir, "poster.jpg")):
|
| 1308 |
await _download_image(session, _resize_tmdb_image(poster_url), os.path.join(target_dir, "poster.jpg"))
|