Spaces:
Running
Running
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 +20 -4
- apex/apex_stream/organize.py +18 -2
- apex/apex_stream/tmdb_meta.py +18 -5
- apex/repair_jellyfin_libraries.py +159 -35
- apex/tests/test_repair_jellyfin_libraries.py +60 -0
- apex/tests/test_tmdb_meta.py +21 -1
- apex/tg_streamer.py +38 -2
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-
|
|
|
|
| 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(
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 817 |
-
"original_title": xml_escape(
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
clean_name =
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
try:
|
| 434 |
-
os.
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 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
|
| 459 |
for fn in list(filenames):
|
| 460 |
-
if fn.endswith(".strm")
|
| 461 |
base_no_ext, ext = os.path.splitext(fn)
|
| 462 |
-
clean_strm_base =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|