Hermes Agent commited on
Commit
701239e
·
1 Parent(s): 6231a0a

fix(meta): self-heal stale NFOs with ASCII-mangled original titles

Browse files

Non-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 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
- if os.path.exists(os.path.join(show_root, "tvshow.nfo")) and os.path.exists(os.path.join(show_root, "poster.jpg")):
 
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
- if not os.path.exists(ep_nfo):
1292
- with open(ep_nfo, "w", encoding="utf-8") as f:
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
- if not os.path.exists(nfo_path):
1299
- with open(nfo_path, "w", encoding="utf-8") as f:
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"))