Hermes Agent commited on
Commit
edd1509
·
1 Parent(s): 4ed7f0f

fix(metadata): strip channel handle prefixes and sanitize BaseItems titles in Jellyfin

Browse files
apex/apex_stream/extractor.py CHANGED
@@ -37,11 +37,23 @@ _QUALITY_BOUNDARY_RE = re.compile(
37
 
38
  # Outer envelope spam (channels, domains, watermarks)
39
  _ENVELOPE_RE = re.compile(
40
- r"(?i)(?:^|[\s_\.\-\[])(?:@[a-z0-9_]+|www\.[a-z0-9\-]+(?:\.[a-z0-9\-]+)+|"
 
41
  r"[a-z0-9\-]+\.(?:com|org|net|vip|xyz|top|site|club|online|store|link))(?=[\]\s_\.\-]|$)"
42
  )
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _anchor_parse(filename: str) -> Dict[str, Any]:
46
  """Deterministic structural anchor parsing for filenames."""
47
  if not filename:
@@ -49,7 +61,9 @@ def _anchor_parse(filename: str) -> Dict[str, Any]:
49
 
50
  # 1. Clean outer extensions and channel handles
51
  clean = re.sub(r"(?i)\.(?:mkv|mp4|avi|mov|webm|strm|ts|m2ts)\s*$", "", filename.strip())
 
52
  clean = _ENVELOPE_RE.sub(" ", clean)
 
53
  clean = re.sub(r"^\[[^\]]+\]", "", clean).strip(" -._")
54
 
55
  # 2. Check for TV episode anchors
@@ -93,8 +107,10 @@ def _anchor_parse(filename: str) -> Dict[str, Any]:
93
 
94
  # 5. Clean punctuation to spaces
95
  title_clean = re.sub(r"[._\+]+", " ", title_raw)
 
96
  title_clean = re.sub(r"\s+", " ", title_clean).strip(" -._:,()[]{}")
97
  title_clean = re.sub(r"\s*\(\d{4}\)$", "", title_clean).strip()
 
98
 
99
  if not title_clean:
100
  title_clean = "Unknown_Media"
@@ -117,14 +133,14 @@ def extract_media_info(filename: str) -> Dict[str, Any]:
117
  if HAS_GUESSIT and _guessit_fn:
118
  try:
119
  g = _guessit_fn(filename)
120
- g_title = g.get("title")
121
- if g_title and len(str(g_title).strip()) >= 2:
122
  is_tv = g.get("type") == "episode" or "episode" in g or "season" in g
123
  season = int(g["season"]) if "season" in g else (1 if is_tv else None)
124
  episode = int(g["episode"]) if "episode" in g else (1 if is_tv else None)
125
  year = int(g["year"]) if "year" in g else None
126
  return {
127
- "title": str(g_title).strip(),
128
  "is_tv": is_tv,
129
  "season": season,
130
  "episode": episode,
 
37
 
38
  # Outer envelope spam (channels, domains, watermarks)
39
  _ENVELOPE_RE = re.compile(
40
+ r"(?i)(?:^|[\s_\.\-\[])(?:@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?|"
41
+ r"www\.[a-z0-9\-]+(?:\.[a-z0-9\-]+)+|"
42
  r"[a-z0-9\-]+\.(?:com|org|net|vip|xyz|top|site|club|online|store|link))(?=[\]\s_\.\-]|$)"
43
  )
44
 
45
 
46
+ def _clean_channel_title(text: str) -> str:
47
+ if not text:
48
+ return ""
49
+ t = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", text)
50
+ t = re.sub(r"^@[a-z0-9_]+\s*", "", t, flags=re.IGNORECASE)
51
+ t = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", t)
52
+ t = re.sub(r"^[^\w]+", "", t)
53
+ t = re.sub(r"\s+", " ", t).strip()
54
+ return t
55
+
56
+
57
  def _anchor_parse(filename: str) -> Dict[str, Any]:
58
  """Deterministic structural anchor parsing for filenames."""
59
  if not filename:
 
61
 
62
  # 1. Clean outer extensions and channel handles
63
  clean = re.sub(r"(?i)\.(?:mkv|mp4|avi|mov|webm|strm|ts|m2ts)\s*$", "", filename.strip())
64
+ clean = re.sub(r"(?i)@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]+", " ", clean)
65
  clean = _ENVELOPE_RE.sub(" ", clean)
66
+ clean = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", clean)
67
  clean = re.sub(r"^\[[^\]]+\]", "", clean).strip(" -._")
68
 
69
  # 2. Check for TV episode anchors
 
107
 
108
  # 5. Clean punctuation to spaces
109
  title_clean = re.sub(r"[._\+]+", " ", title_raw)
110
+ title_clean = _clean_channel_title(title_clean)
111
  title_clean = re.sub(r"\s+", " ", title_clean).strip(" -._:,()[]{}")
112
  title_clean = re.sub(r"\s*\(\d{4}\)$", "", title_clean).strip()
113
+ title_clean = _clean_channel_title(title_clean)
114
 
115
  if not title_clean:
116
  title_clean = "Unknown_Media"
 
133
  if HAS_GUESSIT and _guessit_fn:
134
  try:
135
  g = _guessit_fn(filename)
136
+ g_title = _clean_channel_title(str(g.get("title") or ""))
137
+ if g_title and len(g_title) >= 2:
138
  is_tv = g.get("type") == "episode" or "episode" in g or "season" in g
139
  season = int(g["season"]) if "season" in g else (1 if is_tv else None)
140
  episode = int(g["episode"]) if "episode" in g else (1 if is_tv else None)
141
  year = int(g["year"]) if "year" in g else None
142
  return {
143
+ "title": g_title,
144
  "is_tv": is_tv,
145
  "season": season,
146
  "episode": episode,
apex/apex_stream/organize.py CHANGED
@@ -124,11 +124,25 @@ def tmdb_language_code(language_name: str) -> str:
124
  return _TMDB_LANG_CODE.get(language_name or "", "")
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def safe_folder(name: str) -> str:
128
  """Folders/Jellyfin-safe version of a title or language name."""
129
  if not name:
130
  return "Unknown"
131
- cleaned = re.sub(r"[^\w .'(),\-!]+", "", name, flags=re.UNICODE).strip()
 
 
132
  cleaned = re.sub(r"\s+", " ", cleaned).strip(" .") if cleaned else "Unknown"
133
  return cleaned[:120] or "Unknown"
134
 
@@ -147,6 +161,7 @@ def folder_name(title: Optional[str], year: Optional[int] = None) -> str:
147
  except (ValueError, TypeError):
148
  pass
149
  name = name[:m.start()].strip()
 
150
  if year:
151
  name = f"{name} ({year})"
152
  return name
@@ -229,7 +244,8 @@ def strm_base(entry: Dict) -> str:
229
  episode = int(entry.get("episode") or 1)
230
  return safe_folder(f"{clean_show} - S{season:02d}E{episode:02d}")
231
  title = entry.get("clean_title") or entry.get("title") or "Unknown"
232
- return folder_name(title, entry.get("year"))
 
233
 
234
 
235
  def strm_dir(movies_dir: str, shows_dir: str, entry: Dict) -> str:
 
124
  return _TMDB_LANG_CODE.get(language_name or "", "")
125
 
126
 
127
+ def _clean_channel_prefix(text: str) -> str:
128
+ if not text:
129
+ return ""
130
+ s = str(text).strip()
131
+ s = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", s)
132
+ s = re.sub(r"^@[a-z0-9_]+\s*", "", s, flags=re.IGNORECASE)
133
+ s = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", s)
134
+ s = re.sub(r"^[^\w]+", "", s)
135
+ s = re.sub(r"\s+", " ", s).strip()
136
+ return s
137
+
138
+
139
  def safe_folder(name: str) -> str:
140
  """Folders/Jellyfin-safe version of a title or language name."""
141
  if not name:
142
  return "Unknown"
143
+ cleaned_name = _clean_channel_prefix(name) or name
144
+ cleaned = re.sub(r"[^\w .'(),\-!]+", "", cleaned_name, flags=re.UNICODE).strip()
145
+ cleaned = _clean_channel_prefix(cleaned) or cleaned
146
  cleaned = re.sub(r"\s+", " ", cleaned).strip(" .") if cleaned else "Unknown"
147
  return cleaned[:120] or "Unknown"
148
 
 
161
  except (ValueError, TypeError):
162
  pass
163
  name = name[:m.start()].strip()
164
+ name = _clean_channel_prefix(name) or name
165
  if year:
166
  name = f"{name} ({year})"
167
  return name
 
244
  episode = int(entry.get("episode") or 1)
245
  return safe_folder(f"{clean_show} - S{season:02d}E{episode:02d}")
246
  title = entry.get("clean_title") or entry.get("title") or "Unknown"
247
+ clean_title = _clean_channel_prefix(title) or title
248
+ return folder_name(clean_title, entry.get("year"))
249
 
250
 
251
  def strm_dir(movies_dir: str, shows_dir: str, entry: Dict) -> str:
apex/apex_stream/tmdb_meta.py CHANGED
@@ -236,7 +236,11 @@ def _strip_noise(text: str) -> str:
236
  t = _URL_NOISE_RE.sub(" ", t)
237
  t = _WATERMARK_PAREN_RE.sub(" ", t)
238
  t = re.sub(r"\[[^\]]*\]", " ", t)
239
- t = re.sub(r"@[\w.\-]+", " ", t)
 
 
 
 
240
  t = _MEDIA_EXT_RE.sub(" ", t)
241
  # "+" and "&" are release/present separators too ("Dd+5.1 H" -> "Dd 5 1 H",
242
  # "Birds of Prey & Fantabulous" -> "Birds of Prey Fantabulous"). A dash is a
@@ -378,6 +382,7 @@ def _clean_search_title(text: str) -> str:
378
  """A title ready to send to the TMDB search API (no year/release tags)."""
379
  text = _SIZE_RE.sub(" ", text)
380
  text = _strip_noise(_drop_dotted_group_tail(text))
 
381
  text = YEAR_RE.sub(" ", text)
382
  text = re.sub(r"\(\s*\)", " ", text)
383
  # Strip leading "Episode NN -" or "Ep NN -" prefix (e.g. "Episode 3 - Green Lantern")
@@ -539,9 +544,10 @@ def parse_media_meta(filename_or_caption: Optional[str]) -> Dict:
539
  if tv:
540
  show_raw, season, episode = tv
541
  clean_show = _clean_search_title(show_raw)
 
542
  if not clean_show:
543
  clean_show = "Unknown_Show"
544
- show_title = safe_folder(_clean_search_title(show_raw).title() or clean_show)
545
  title = f"{show_title} - S{int(season):02d}E{int(episode):02d}"
546
  return {
547
  "is_tv": True,
@@ -557,6 +563,7 @@ def parse_media_meta(filename_or_caption: Optional[str]) -> Dict:
557
  }
558
 
559
  movie_title = _clean_search_title(raw)
 
560
  if not movie_title:
561
  # Numeric-only/recovered titles: "2018.2018.Malayalam.1080p" is the
562
  # Malayalam film "2018", not a bare year; TMDB knows it by that name.
@@ -791,6 +798,10 @@ def build_movie_nfo(data: Dict) -> str:
791
  country_line = " <country>%s</country>\n" % xml_escape(country) if country else ""
792
  imdb_id = xml_escape(str(data.get("imdb_id") or ""))
793
  imdb_unique = f' <uniqueid type="imdb" default="true">{imdb_id}</uniqueid>\n' if imdb_id else ""
 
 
 
 
794
  return """<?xml version="1.0" encoding="utf-8" standalone="yes"?>
795
  <movie>
796
  <title>%(title)s</title>
@@ -813,8 +824,8 @@ def build_movie_nfo(data: Dict) -> str:
813
  </art>
814
  </movie>
815
  """ % {
816
- "title": xml_escape(g("title")),
817
- "original_title": xml_escape(g("original_title")),
818
  "year": xml_escape(g("release_date"))[:4],
819
  "premiered": xml_escape(g("release_date")),
820
  "releasedate": xml_escape(g("release_date")),
@@ -840,7 +851,9 @@ def build_tvshow_nfo(data: Dict) -> str:
840
  imdb_unique = f' <uniqueid type="imdb" default="true">{imdb_id}</uniqueid>\n' if imdb_id else ""
841
  tmdb_unique = f' <uniqueid type="tmdb">{tmdb_id}</uniqueid>\n' if tmdb_id else ""
842
  title_val = g("title") or g("name") or g("original_name") or g("original_title") or "Unknown"
843
- orig_title_val = g("original_name") or g("original_title") or title_val
 
 
844
  premiered_val = g("first_air_date") or g("premiered") or g("release_date") or ""
845
  year_val = str(data.get("year") or "") or premiered_val[:4]
846
  plot_val = g("overview") or g("plot") or ""
 
236
  t = _URL_NOISE_RE.sub(" ", t)
237
  t = _WATERMARK_PAREN_RE.sub(" ", t)
238
  t = re.sub(r"\[[^\]]*\]", " ", t)
239
+ # Strip channel handles (@Handle, @WM_Movies, etc.) without consuming adjacent title words
240
+ t = re.sub(r"(?i)@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]+", " ", t)
241
+ t = re.sub(r"@[a-z0-9_]+", " ", t, flags=re.IGNORECASE)
242
+ # Strip standalone known release / channel prefixes like WMR, WM, MM Linkz, 1TamilMV
243
+ t = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", t)
244
  t = _MEDIA_EXT_RE.sub(" ", t)
245
  # "+" and "&" are release/present separators too ("Dd+5.1 H" -> "Dd 5 1 H",
246
  # "Birds of Prey & Fantabulous" -> "Birds of Prey Fantabulous"). A dash is a
 
382
  """A title ready to send to the TMDB search API (no year/release tags)."""
383
  text = _SIZE_RE.sub(" ", text)
384
  text = _strip_noise(_drop_dotted_group_tail(text))
385
+ text = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", text)
386
  text = YEAR_RE.sub(" ", text)
387
  text = re.sub(r"\(\s*\)", " ", text)
388
  # Strip leading "Episode NN -" or "Ep NN -" prefix (e.g. "Episode 3 - Green Lantern")
 
544
  if tv:
545
  show_raw, season, episode = tv
546
  clean_show = _clean_search_title(show_raw)
547
+ clean_show = re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", clean_show).strip()
548
  if not clean_show:
549
  clean_show = "Unknown_Show"
550
+ show_title = safe_folder(clean_show.title() or clean_show)
551
  title = f"{show_title} - S{int(season):02d}E{int(episode):02d}"
552
  return {
553
  "is_tv": True,
 
563
  }
564
 
565
  movie_title = _clean_search_title(raw)
566
+ movie_title = re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", movie_title).strip()
567
  if not movie_title:
568
  # Numeric-only/recovered titles: "2018.2018.Malayalam.1080p" is the
569
  # Malayalam film "2018", not a bare year; TMDB knows it by that name.
 
798
  country_line = " <country>%s</country>\n" % xml_escape(country) if country else ""
799
  imdb_id = xml_escape(str(data.get("imdb_id") or ""))
800
  imdb_unique = f' <uniqueid type="imdb" default="true">{imdb_id}</uniqueid>\n' if imdb_id else ""
801
+ raw_title = g("title")
802
+ clean_nfo_title = re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", raw_title).strip() or raw_title
803
+ raw_orig = g("original_title") or raw_title
804
+ clean_orig_title = re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", raw_orig).strip() or clean_nfo_title
805
  return """<?xml version="1.0" encoding="utf-8" standalone="yes"?>
806
  <movie>
807
  <title>%(title)s</title>
 
824
  </art>
825
  </movie>
826
  """ % {
827
+ "title": xml_escape(clean_nfo_title),
828
+ "original_title": xml_escape(clean_orig_title),
829
  "year": xml_escape(g("release_date"))[:4],
830
  "premiered": xml_escape(g("release_date")),
831
  "releasedate": xml_escape(g("release_date")),
 
851
  imdb_unique = f' <uniqueid type="imdb" default="true">{imdb_id}</uniqueid>\n' if imdb_id else ""
852
  tmdb_unique = f' <uniqueid type="tmdb">{tmdb_id}</uniqueid>\n' if tmdb_id else ""
853
  title_val = g("title") or g("name") or g("original_name") or g("original_title") or "Unknown"
854
+ clean_tv_title = re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", title_val).strip() or title_val
855
+ orig_title_val = g("original_name") or g("original_title") or clean_tv_title
856
+ clean_tv_orig = re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", orig_title_val).strip() or clean_tv_title
857
  premiered_val = g("first_air_date") or g("premiered") or g("release_date") or ""
858
  year_val = str(data.get("year") or "") or premiered_val[:4]
859
  plot_val = g("overview") or g("plot") or ""
apex/repair_jellyfin_libraries.py CHANGED
@@ -401,8 +401,66 @@ def heal_subtitle_files(roots=None):
401
  print(f"[REPAIR] Purged {purged} corrupt/broken subtitle files across media directories.")
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  def heal_mismatched_media(roots=None, databases=None):
405
- """Clean folder/file release-size noise non-destructively and update DB paths."""
406
  search_roots = roots or [
407
  "/data/jellyfin/media/Movies",
408
  "/data/jellyfin/media/TV Shows",
@@ -422,44 +480,49 @@ def heal_mismatched_media(roots=None, databases=None):
422
  if not dirname or dirpath == root_dir:
423
  continue
424
 
425
- # 1. Rename directories containing release-size noise: "Iron Man 3 780Mb (2013)" -> "Iron Man 3 (2013)"
426
- if size_re.search(dirname):
427
- clean_name = size_re.sub("", dirname)
428
- clean_name = re.sub(r"\s+", " ", clean_name).strip(" -_.")
429
- if clean_name and clean_name != dirname:
430
- parent_dir = os.path.dirname(dirpath)
431
- new_dirpath = os.path.join(parent_dir, clean_name)
432
- if not os.path.exists(new_dirpath):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  try:
434
- os.rename(dirpath, new_dirpath)
435
- print(f"[REPAIR] Cleaned folder name: '{dirpath}' -> '{new_dirpath}'")
436
- for db_p in db_list:
437
- if os.path.exists(db_p):
438
- try:
439
- conn = sqlite3.connect(db_p, timeout=5)
440
- cur = conn.cursor()
441
- cur.execute("SELECT Id, Path FROM BaseItems WHERE Path LIKE ?", (dirpath + "%",))
442
- for row_id, p in cur.fetchall():
443
- updated_p = new_dirpath + p[len(dirpath):]
444
- conn.execute("UPDATE BaseItems SET Path = ? WHERE Id = ?", (updated_p, row_id))
445
- conn.commit()
446
- conn.close()
447
- except Exception:
448
- pass
449
- dirpath = new_dirpath
450
- dirname = clean_name
451
- try:
452
- filenames = os.listdir(new_dirpath)
453
- except Exception:
454
- pass
455
- except Exception as ren_err:
456
- print(f"[REPAIR] Note renaming folder {dirpath}: {ren_err}")
457
 
458
- # 2. Rename .strm files inside directory that contain release-size noise
459
  for fn in list(filenames):
460
- if fn.endswith(".strm") and size_re.search(fn):
461
  base_no_ext, ext = os.path.splitext(fn)
462
- clean_strm_base = size_re.sub("", base_no_ext)
 
 
 
463
  clean_strm_base = re.sub(r"\s+", " ", clean_strm_base).strip(" -_.")
464
  if clean_strm_base and clean_strm_base != base_no_ext:
465
  old_strm = os.path.join(dirpath, fn)
@@ -479,6 +542,66 @@ def heal_mismatched_media(roots=None, databases=None):
479
  pass
480
  except Exception as strm_err:
481
  print(f"[REPAIR] Note renaming strm {old_strm}: {strm_err}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
 
484
  def repair_user_subtitle_preferences(configs=None):
@@ -648,6 +771,7 @@ def main():
648
  cleanup_empty_media_folders()
649
  heal_subtitle_files()
650
  heal_mismatched_media()
 
651
  repair_user_subtitle_preferences()
652
  repair_user_policy_preferences()
653
  repair_system_xml()
 
401
  print(f"[REPAIR] Purged {purged} corrupt/broken subtitle files across media directories.")
402
 
403
 
404
+ CHANNEL_PREFIX_RE = re.compile(
405
+ r"(?i)^(?:@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*|@[a-z0-9_]+\s*|(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\s+)"
406
+ )
407
+
408
+
409
+ def _sanitize_media_title(text: str) -> str:
410
+ """Strip channel prefixes, handles, and clean duplicate years."""
411
+ if not text:
412
+ return ""
413
+ s = text.strip()
414
+ while True:
415
+ m = CHANNEL_PREFIX_RE.match(s)
416
+ if not m:
417
+ break
418
+ s = s[m.end():].strip()
419
+ s = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b[._\s-]*", "", s).strip()
420
+ s = re.sub(r"^[^\w]+", "", s).strip()
421
+ # Normalize duplicate years like "Title (2008) (2008)" -> "Title (2008)"
422
+ years = re.findall(r"\((\d{4})\)", s)
423
+ if len(years) >= 2 and len(set(years)) == 1:
424
+ y = years[0]
425
+ s = re.sub(r"\s*\(\d{4}\)", "", s).strip()
426
+ s = f"{s} ({y})"
427
+ return s.strip()
428
+
429
+
430
+ def _heal_nfo_file(nfo_path: str):
431
+ """Clean corrupted/channel-prefixed <title> and <originaltitle> in NFO files."""
432
+ if not os.path.isfile(nfo_path):
433
+ return
434
+ try:
435
+ with open(nfo_path, "r", encoding="utf-8", errors="ignore") as f:
436
+ content = f.read()
437
+ changed = False
438
+ tm = re.search(r"<title>(.*?)</title>", content)
439
+ if tm:
440
+ t_val = tm.group(1)
441
+ clean_t = _sanitize_media_title(t_val)
442
+ clean_t = re.sub(r"\s*\(\d{4}\)$", "", clean_t).strip()
443
+ if clean_t and clean_t != t_val:
444
+ content = content.replace(f"<title>{t_val}</title>", f"<title>{clean_t}</title>")
445
+ changed = True
446
+ om = re.search(r"<originaltitle>(.*?)</originaltitle>", content)
447
+ if om:
448
+ o_val = om.group(1)
449
+ clean_o = _sanitize_media_title(o_val)
450
+ clean_o = re.sub(r"\s*\(\d{4}\)$", "", clean_o).strip()
451
+ if clean_o and clean_o != o_val:
452
+ content = content.replace(f"<originaltitle>{o_val}</originaltitle>", f"<originaltitle>{clean_o}</originaltitle>")
453
+ changed = True
454
+ if changed:
455
+ with open(nfo_path, "w", encoding="utf-8") as f:
456
+ f.write(content)
457
+ print(f"[REPAIR] Healed titles in NFO: {nfo_path}")
458
+ except Exception as e:
459
+ print(f"[REPAIR] Note healing NFO {nfo_path}: {e}")
460
+
461
+
462
  def heal_mismatched_media(roots=None, databases=None):
463
+ """Clean folder/file release-size noise and channel prefixes non-destructively and update DB paths."""
464
  search_roots = roots or [
465
  "/data/jellyfin/media/Movies",
466
  "/data/jellyfin/media/TV Shows",
 
480
  if not dirname or dirpath == root_dir:
481
  continue
482
 
483
+ # 1. Rename directories containing release-size or channel prefix noise
484
+ clean_name = dirname
485
+ if size_re.search(clean_name):
486
+ clean_name = size_re.sub("", clean_name)
487
+ clean_name = _sanitize_media_title(clean_name)
488
+ clean_name = re.sub(r"\s+", " ", clean_name).strip(" -_.")
489
+ if clean_name and clean_name != dirname:
490
+ parent_dir = os.path.dirname(dirpath)
491
+ new_dirpath = os.path.join(parent_dir, clean_name)
492
+ if not os.path.exists(new_dirpath):
493
+ try:
494
+ os.rename(dirpath, new_dirpath)
495
+ print(f"[REPAIR] Cleaned folder name: '{dirpath}' -> '{new_dirpath}'")
496
+ for db_p in db_list:
497
+ if os.path.exists(db_p):
498
+ try:
499
+ conn = sqlite3.connect(db_p, timeout=5)
500
+ cur = conn.cursor()
501
+ cur.execute("SELECT Id, Path FROM BaseItems WHERE Path LIKE ?", (dirpath + "%",))
502
+ for row_id, p in cur.fetchall():
503
+ updated_p = new_dirpath + p[len(dirpath):]
504
+ conn.execute("UPDATE BaseItems SET Path = ? WHERE Id = ?", (updated_p, row_id))
505
+ conn.commit()
506
+ conn.close()
507
+ except Exception:
508
+ pass
509
+ dirpath = new_dirpath
510
+ dirname = clean_name
511
  try:
512
+ filenames = os.listdir(new_dirpath)
513
+ except Exception:
514
+ pass
515
+ except Exception as ren_err:
516
+ print(f"[REPAIR] Note renaming folder {dirpath}: {ren_err}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
 
518
+ # 2. Rename .strm files inside directory and heal NFOs
519
  for fn in list(filenames):
520
+ if fn.endswith(".strm"):
521
  base_no_ext, ext = os.path.splitext(fn)
522
+ clean_strm_base = base_no_ext
523
+ if size_re.search(clean_strm_base):
524
+ clean_strm_base = size_re.sub("", clean_strm_base)
525
+ clean_strm_base = _sanitize_media_title(clean_strm_base)
526
  clean_strm_base = re.sub(r"\s+", " ", clean_strm_base).strip(" -_.")
527
  if clean_strm_base and clean_strm_base != base_no_ext:
528
  old_strm = os.path.join(dirpath, fn)
 
542
  pass
543
  except Exception as strm_err:
544
  print(f"[REPAIR] Note renaming strm {old_strm}: {strm_err}")
545
+ elif fn.lower() in ("movie.nfo", "tvshow.nfo"):
546
+ _heal_nfo_file(os.path.join(dirpath, fn))
547
+
548
+
549
+ def heal_channel_and_db_names(databases=None):
550
+ """Directly sanitize BaseItems.Name and CleanName in Jellyfin databases."""
551
+ db_list = databases or TARGET_DATABASES
552
+ for db_p in db_list:
553
+ if not os.path.exists(db_p):
554
+ continue
555
+ try:
556
+ conn = sqlite3.connect(db_p, timeout=10)
557
+ conn.isolation_level = None
558
+ cur = conn.cursor()
559
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='BaseItems'")
560
+ if not cur.fetchone():
561
+ conn.close()
562
+ continue
563
+
564
+ cur.execute(
565
+ "SELECT Id, Name, Path, type FROM BaseItems "
566
+ "WHERE (type LIKE '%Movie%' OR type LIKE '%Episode%' OR type LIKE '%Series%')"
567
+ )
568
+ rows = cur.fetchall()
569
+ updated = 0
570
+ for row_id, name, path, itype in rows:
571
+ if not name:
572
+ continue
573
+ name_str = str(name).strip()
574
+ needs_clean = (
575
+ name_str.startswith("@")
576
+ or name_str.lower().startswith("wmr")
577
+ or name_str.lower().startswith("wm ")
578
+ or name_str.lower().startswith("mm linkz")
579
+ or bool(re.search(r"\(\d{4}\)\s*\(\d{4}\)", name_str))
580
+ )
581
+ if needs_clean:
582
+ candidate = name_str
583
+ if "..." in name_str and path:
584
+ parts = [p for p in path.replace("\\", "/").split("/") if p]
585
+ for part in reversed(parts):
586
+ p_clean = os.path.splitext(part)[0]
587
+ if not p_clean.lower().endswith((".strm", ".mkv", ".mp4", ".avi")):
588
+ candidate = p_clean
589
+ break
590
+ clean_name = _sanitize_media_title(candidate)
591
+ clean_name = re.sub(r"\s*\(\d{4}\)$", "", clean_name).strip()
592
+ if clean_name and clean_name != name_str:
593
+ cur.execute("UPDATE BaseItems SET Name = ? WHERE Id = ?", (clean_name, row_id))
594
+ try:
595
+ cur.execute("UPDATE BaseItems SET CleanName = ? WHERE Id = ?", (clean_name, row_id))
596
+ except Exception:
597
+ pass
598
+ updated += 1
599
+ print(f"[REPAIR] Healed BaseItems.Name: '{name_str}' -> '{clean_name}' (Id={row_id})")
600
+ conn.close()
601
+ if updated:
602
+ print(f"[REPAIR] Healed {updated} BaseItems name(s) in {db_p}")
603
+ except Exception as e:
604
+ print(f"[REPAIR] Error healing BaseItems names in {db_p}: {e}")
605
 
606
 
607
  def repair_user_subtitle_preferences(configs=None):
 
771
  cleanup_empty_media_folders()
772
  heal_subtitle_files()
773
  heal_mismatched_media()
774
+ heal_channel_and_db_names()
775
  repair_user_subtitle_preferences()
776
  repair_user_policy_preferences()
777
  repair_system_xml()
apex/tests/test_repair_jellyfin_libraries.py CHANGED
@@ -9,6 +9,7 @@ from repair_jellyfin_libraries import (
9
  cleanup_empty_media_folders,
10
  heal_subtitle_files,
11
  heal_mismatched_media,
 
12
  repair_user_subtitle_preferences,
13
  repair_user_policy_preferences,
14
  repair_system_xml,
@@ -613,6 +614,65 @@ def test_cleanup_empty_media_folders_never_deletes_non_empty_unrecognized_format
613
  assert (folder_with_xml / "data.xml").exists()
614
 
615
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
 
617
 
618
 
 
9
  cleanup_empty_media_folders,
10
  heal_subtitle_files,
11
  heal_mismatched_media,
12
+ heal_channel_and_db_names,
13
  repair_user_subtitle_preferences,
14
  repair_user_policy_preferences,
15
  repair_system_xml,
 
614
  assert (folder_with_xml / "data.xml").exists()
615
 
616
 
617
+ def test_heal_channel_names_and_database(tmp_path):
618
+ movies_root = tmp_path / "Movies"
619
+ movies_root.mkdir()
620
+
621
+ # Create folder and strm with WMR and double-year
622
+ folder = movies_root / "WMR Iron Man (2008) (2008)"
623
+ folder.mkdir()
624
+ strm = folder / "WMR Iron Man (2008) (2008).strm"
625
+ strm.write_text("http://stream/1")
626
+ nfo = folder / "movie.nfo"
627
+ nfo.write_text("<movie><title>@WMR Iron Man (2008)</title><originaltitle>@WMR Iron Man</originaltitle></movie>")
628
+
629
+ db_file = tmp_path / "jellyfin.db"
630
+ conn = sqlite3.connect(str(db_file))
631
+ cur = conn.cursor()
632
+ cur.execute("""
633
+ CREATE TABLE BaseItems (
634
+ Id TEXT PRIMARY KEY,
635
+ Path TEXT,
636
+ Name TEXT,
637
+ CleanName TEXT,
638
+ type TEXT
639
+ )
640
+ """)
641
+ cur.execute("INSERT INTO BaseItems VALUES (?, ?, ?, ?, ?)", (
642
+ "item-ironman", str(strm), "@WM...", "@WM...", "MediaBrowser.Controller.Entities.Movies.Movie"
643
+ ))
644
+ conn.commit()
645
+ conn.close()
646
+
647
+ heal_mismatched_media(roots=[str(movies_root)], databases=[str(db_file)])
648
+ heal_channel_and_db_names(databases=[str(db_file)])
649
+
650
+ # Directory was renamed
651
+ healed_folder = movies_root / "Iron Man (2008)"
652
+ assert healed_folder.exists()
653
+ assert not folder.exists()
654
+ assert (healed_folder / "Iron Man (2008).strm").exists()
655
+
656
+ # NFO was healed
657
+ healed_nfo_text = (healed_folder / "movie.nfo").read_text()
658
+ assert "<title>Iron Man</title>" in healed_nfo_text
659
+ assert "@" not in healed_nfo_text
660
+ assert "WMR" not in healed_nfo_text
661
+
662
+ # Database name was healed
663
+ conn = sqlite3.connect(str(db_file))
664
+ cur = conn.cursor()
665
+ cur.execute("SELECT Name, CleanName, Path FROM BaseItems WHERE Id = ?", ("item-ironman",))
666
+ row = cur.fetchone()
667
+ conn.close()
668
+
669
+ assert row is not None
670
+ assert row[0] == "Iron Man"
671
+ assert "@" not in row[0]
672
+ assert "WMR" not in row[0]
673
+
674
+
675
+
676
 
677
 
678
 
apex/tests/test_tmdb_meta.py CHANGED
@@ -29,6 +29,26 @@ class TestParseMediaMeta:
29
  assert "malayalam" not in meta["search_title"]
30
  assert "wmr" not in meta["search_title"]
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def test_tv_sxe(self):
33
  meta = parse_media_meta("Breaking Bad S01E01 720p x264")
34
  assert meta["is_tv"] is True
@@ -783,4 +803,4 @@ class TestNfoRepair:
783
  monkeypatch.setattr("aiohttp.ClientSession", FailingClientSession)
784
  fail_res = await tg_streamer._ai_disambiguate_candidates("Other Movie 2026", candidates)
785
  assert fail_res is None
786
-
 
29
  assert "malayalam" not in meta["search_title"]
30
  assert "wmr" not in meta["search_title"]
31
 
32
+ def test_movie_channel_handles_stripped_top_three(self):
33
+ # Top 3 items from Jellyfin screenshot
34
+ meta1 = parse_media_meta("@WM_Movies_Iron_Man_2_2008_1080p.mkv")
35
+ assert not meta1["clean_title"].startswith("@")
36
+ assert not meta1["clean_title"].lower().startswith("wm")
37
+ assert "Iron Man 2" in meta1["clean_title"]
38
+ assert meta1["year"] == 2008
39
+
40
+ meta2 = parse_media_meta("@WMR Resident Evil Apocalypse (2004).mkv")
41
+ assert not meta2["clean_title"].startswith("@")
42
+ assert not meta2["clean_title"].lower().startswith("wmr")
43
+ assert "Resident Evil" in meta2["clean_title"]
44
+ assert meta2["year"] == 2004
45
+
46
+ meta3 = parse_media_meta("@WM.Spider-Man.Homecoming.2017.mkv")
47
+ assert not meta3["clean_title"].startswith("@")
48
+ assert not meta3["clean_title"].lower().startswith("wm")
49
+ assert "Spider" in meta3["clean_title"]
50
+ assert meta3["year"] == 2017
51
+
52
  def test_tv_sxe(self):
53
  meta = parse_media_meta("Breaking Bad S01E01 720p x264")
54
  assert meta["is_tv"] is True
 
803
  monkeypatch.setattr("aiohttp.ClientSession", FailingClientSession)
804
  fail_res = await tg_streamer._ai_disambiguate_candidates("Other Movie 2026", candidates)
805
  assert fail_res is None
806
+
apex/tg_streamer.py CHANGED
@@ -1537,6 +1537,14 @@ async def _fetch_imdb_suggestion(query: str, year: Optional[int] = None, media_t
1537
  """Query official IMDb suggestion API with composite disambiguation scoring."""
1538
  if not query:
1539
  return None
 
 
 
 
 
 
 
 
1540
  clean = re.sub(r"[^a-zA-Z0-9]+", "_", query).strip("_").lower()
1541
  if not clean:
1542
  return None
@@ -3089,7 +3097,13 @@ def _meta_to_entry(msg_id, chat_id, file_id, file_size, file_name):
3089
  ext = extract_media_info(file_name)
3090
 
3091
  is_tv = bool(ext.get("is_tv") or meta.get("is_tv"))
3092
- clean_title = ext.get("title") or meta.get("clean_title") or meta.get("title")
 
 
 
 
 
 
3093
  year = ext.get("year") or meta.get("year")
3094
  season = ext.get("season") or meta.get("season")
3095
  episode = ext.get("episode") or meta.get("episode")
@@ -3106,6 +3120,11 @@ def _meta_to_entry(msg_id, chat_id, file_id, file_size, file_name):
3106
  title = f"{clean_title} ({year})" if year else clean_title
3107
 
3108
  candidates = _search_candidates(file_name)
 
 
 
 
 
3109
  if clean_title and clean_title not in candidates:
3110
  candidates.insert(0, clean_title)
3111
 
@@ -5619,6 +5638,12 @@ async def reindex_all_with_imdb(force_all: bool = True) -> int:
5619
  is_tv = bool(ext.get("is_tv") or data.get("is_tv") or parsed.get("is_tv"))
5620
  mtype = "tv" if is_tv else "movie"
5621
  title = ext.get("title") or parsed.get("clean_title") or data.get("clean_title") or data.get("title") or ""
 
 
 
 
 
 
5622
  year = ext.get("year") or parsed.get("year") or data.get("year")
5623
  season = ext.get("season") or data.get("season") or 1
5624
  episode = ext.get("episode") or data.get("episode") or 1
@@ -5628,9 +5653,20 @@ async def reindex_all_with_imdb(force_all: bool = True) -> int:
5628
  if not imdb_hit and year:
5629
  imdb_hit = await _fetch_imdb_suggestion(title, None, mtype)
5630
  if not imdb_hit and parsed.get("title") and parsed["title"] != title:
5631
- imdb_hit = await _fetch_imdb_suggestion(parsed["title"], year, mtype)
 
 
 
 
 
5632
 
5633
  official_title = (imdb_hit.get("l") if imdb_hit else None) or title
 
 
 
 
 
 
5634
  official_year = (imdb_hit.get("y") if imdb_hit else None) or year
5635
  imdb_id = (imdb_hit.get("id") if imdb_hit else None) or data.get("imdb_id")
5636
  poster_url = ((imdb_hit.get("i") or {}).get("imageUrl") if imdb_hit else None) or data.get("poster_url")
 
1537
  """Query official IMDb suggestion API with composite disambiguation scoring."""
1538
  if not query:
1539
  return None
1540
+ # Strip channel prefixes before searching IMDb
1541
+ q_clean = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", query)
1542
+ q_clean = re.sub(r"^@[a-z0-9_]+\s*", "", q_clean, flags=re.IGNORECASE)
1543
+ q_clean = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", q_clean)
1544
+ q_clean = re.sub(r"^[^\w]+", "", q_clean)
1545
+ q_clean = re.sub(r"\s+", " ", q_clean).strip(" -._")
1546
+ if q_clean:
1547
+ query = q_clean
1548
  clean = re.sub(r"[^a-zA-Z0-9]+", "_", query).strip("_").lower()
1549
  if not clean:
1550
  return None
 
3097
  ext = extract_media_info(file_name)
3098
 
3099
  is_tv = bool(ext.get("is_tv") or meta.get("is_tv"))
3100
+ clean_title = ext.get("title") or meta.get("clean_title") or meta.get("title") or ""
3101
+ if clean_title:
3102
+ clean_title = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", clean_title)
3103
+ clean_title = re.sub(r"^@[a-z0-9_]+\s*", "", clean_title, flags=re.IGNORECASE)
3104
+ clean_title = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", clean_title)
3105
+ clean_title = re.sub(r"^[^\w]+", "", clean_title)
3106
+ clean_title = re.sub(r"\s+", " ", clean_title).strip(" -._")
3107
  year = ext.get("year") or meta.get("year")
3108
  season = ext.get("season") or meta.get("season")
3109
  episode = ext.get("episode") or meta.get("episode")
 
3120
  title = f"{clean_title} ({year})" if year else clean_title
3121
 
3122
  candidates = _search_candidates(file_name)
3123
+ candidates = [
3124
+ re.sub(r"(?i)^(?:@\S+\s*|(?:wmr|wm|mm\s*linkz)\s+)", "", c).strip(" -._")
3125
+ for c in candidates
3126
+ ]
3127
+ candidates = [c for c in candidates if c]
3128
  if clean_title and clean_title not in candidates:
3129
  candidates.insert(0, clean_title)
3130
 
 
5638
  is_tv = bool(ext.get("is_tv") or data.get("is_tv") or parsed.get("is_tv"))
5639
  mtype = "tv" if is_tv else "movie"
5640
  title = ext.get("title") or parsed.get("clean_title") or data.get("clean_title") or data.get("title") or ""
5641
+ if title:
5642
+ title = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", title)
5643
+ title = re.sub(r"^@[a-z0-9_]+\s*", "", title, flags=re.IGNORECASE)
5644
+ title = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", title)
5645
+ title = re.sub(r"^[^\w]+", "", title)
5646
+ title = re.sub(r"\s+", " ", title).strip(" -._")
5647
  year = ext.get("year") or parsed.get("year") or data.get("year")
5648
  season = ext.get("season") or data.get("season") or 1
5649
  episode = ext.get("episode") or data.get("episode") or 1
 
5653
  if not imdb_hit and year:
5654
  imdb_hit = await _fetch_imdb_suggestion(title, None, mtype)
5655
  if not imdb_hit and parsed.get("title") and parsed["title"] != title:
5656
+ p_cand = parsed["title"]
5657
+ p_cand = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", p_cand)
5658
+ p_cand = re.sub(r"^@[a-z0-9_]+\s*", "", p_cand, flags=re.IGNORECASE)
5659
+ p_cand = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", p_cand)
5660
+ p_cand = re.sub(r"^[^\w]+", "", p_cand).strip(" -._")
5661
+ imdb_hit = await _fetch_imdb_suggestion(p_cand, year, mtype)
5662
 
5663
  official_title = (imdb_hit.get("l") if imdb_hit else None) or title
5664
+ if official_title:
5665
+ official_title = re.sub(r"(?i)^@(?:[a-z0-9]+)(?:[._-](?:movies|cinema|films|channel|linkz|official|hub|club|media|drive|hd|store|flim))?[._\s-]*", "", official_title)
5666
+ official_title = re.sub(r"^@[a-z0-9_]+\s*", "", official_title, flags=re.IGNORECASE)
5667
+ official_title = re.sub(r"(?i)\b(?:wmr|wm|mm\s*linkz|tamilmv|tamilblasters|1tamilmv)\b", " ", official_title)
5668
+ official_title = re.sub(r"^[^\w]+", "", official_title)
5669
+ official_title = re.sub(r"\s+", " ", official_title).strip(" -._")
5670
  official_year = (imdb_hit.get("y") if imdb_hit else None) or year
5671
  imdb_id = (imdb_hit.get("id") if imdb_hit else None) or data.get("imdb_id")
5672
  poster_url = ((imdb_hit.get("i") or {}).get("imageUrl") if imdb_hit else None) or data.get("poster_url")