Karan Adhikari commited on
Combined file Support
Browse filesSupport combined episode files under a special season for Stremio
Combined episode files (e.g. "S01 [E04-06]") and whole-season
"Combined" files are now indexed under a special season, so they
appear and play in Stremio.
Recommended setup — give this addon priority over Cinemeta:
- Stremio: arrange the Telegram Stremio addon above the Cinemeta
addon using https://addon-manager.dontwanttos.top/
- Nuvio: in the Addons section, move Telegram Stremio to the top
(before the Cinemeta addon).
Limitation -
Combined files doesn't work in global search.
- Backend/helper/metadata.py +25 -4
- Backend/helper/split_files.py +42 -0
Backend/helper/metadata.py
CHANGED
|
@@ -10,7 +10,7 @@ from Backend.logger import LOGGER
|
|
| 10 |
from Backend.helper.imdb import get_detail, get_season, search_title, search_title_multi
|
| 11 |
from Backend.helper.settings_manager import SettingsManager
|
| 12 |
from Backend.helper.encrypt import encode_string
|
| 13 |
-
from Backend.helper.split_files import parse_split_info
|
| 14 |
from themoviedb import aioTMDb
|
| 15 |
from rapidfuzz import fuzz
|
| 16 |
from guessit import guessit as _guessit
|
|
@@ -47,6 +47,21 @@ API_SEMAPHORE = asyncio.Semaphore(12)
|
|
| 47 |
|
| 48 |
_MULTIPART_RE = re.compile(r"(?:part|cd|disc|disk)[s._-]*\d+(?=\.\w+$)", re.IGNORECASE)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
_tmdb_client: aioTMDb | None = None
|
| 51 |
_tmdb_client_key: str | None = None
|
| 52 |
|
|
@@ -531,12 +546,14 @@ async def metadata(filename: str, channel: int, msg_id, override_id: str = None)
|
|
| 531 |
LOGGER.info(f"Skipping {filename}: split video file not meant to be combined in Stremio")
|
| 532 |
return None
|
| 533 |
|
|
|
|
|
|
|
| 534 |
excess = parsed.get("excess")
|
| 535 |
-
if excess and any("combined" in item.lower() for item in excess):
|
| 536 |
LOGGER.info(f"Skipping {filename}: contains 'combined'")
|
| 537 |
return None
|
| 538 |
|
| 539 |
-
split_info = parse_split_info(filename)
|
| 540 |
part_number = split_info[1] if split_info else None
|
| 541 |
|
| 542 |
title = parsed.get("title")
|
|
@@ -545,7 +562,9 @@ async def metadata(filename: str, channel: int, msg_id, override_id: str = None)
|
|
| 545 |
year = parsed.get("year")
|
| 546 |
quality = parsed.get("quality")
|
| 547 |
|
| 548 |
-
if
|
|
|
|
|
|
|
| 549 |
LOGGER.warning(f"Invalid season/episode format for {filename}: {parsed}")
|
| 550 |
return None
|
| 551 |
if season and not episode:
|
|
@@ -571,6 +590,8 @@ async def metadata(filename: str, channel: int, msg_id, override_id: str = None)
|
|
| 571 |
if season and episode:
|
| 572 |
LOGGER.info(f"Fetching TV metadata: {title} S{season:02d}E{episode:02d} (year={year})")
|
| 573 |
result = await fetch_tv_metadata(title, season, episode, encoded_string, year, quality, default_id)
|
|
|
|
|
|
|
| 574 |
else:
|
| 575 |
LOGGER.info(f"Fetching Movie metadata: {title} (year={year})")
|
| 576 |
result = await fetch_movie_metadata(title, encoded_string, year, quality, default_id)
|
|
|
|
| 10 |
from Backend.helper.imdb import get_detail, get_season, search_title, search_title_multi
|
| 11 |
from Backend.helper.settings_manager import SettingsManager
|
| 12 |
from Backend.helper.encrypt import encode_string
|
| 13 |
+
from Backend.helper.split_files import parse_split_info, parse_combined_episodes
|
| 14 |
from themoviedb import aioTMDb
|
| 15 |
from rapidfuzz import fuzz
|
| 16 |
from guessit import guessit as _guessit
|
|
|
|
| 47 |
|
| 48 |
_MULTIPART_RE = re.compile(r"(?:part|cd|disc|disk)[s._-]*\d+(?=\.\w+$)", re.IGNORECASE)
|
| 49 |
|
| 50 |
+
# Combined episode files (e.g. "S01 [E04-06]" or "S01 Combined") are grouped under a special season.
|
| 51 |
+
COMBINED_SEASON = 0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Re-file a combined entry under the special season with a descriptive title.
|
| 55 |
+
def _apply_combined_override(payload: dict, combined: dict) -> None:
|
| 56 |
+
season, start, end = combined["season"], combined["start"], combined["end"]
|
| 57 |
+
payload["season_number"] = COMBINED_SEASON
|
| 58 |
+
if start is None:
|
| 59 |
+
payload["episode_number"] = season * 1000
|
| 60 |
+
payload["episode_title"] = f"S{season:02d} Complete (Combined)"
|
| 61 |
+
else:
|
| 62 |
+
payload["episode_number"] = season * 1000 + start
|
| 63 |
+
payload["episode_title"] = f"S{season:02d} E{start:02d}-E{end:02d} (Combined)"
|
| 64 |
+
|
| 65 |
_tmdb_client: aioTMDb | None = None
|
| 66 |
_tmdb_client_key: str | None = None
|
| 67 |
|
|
|
|
| 546 |
LOGGER.info(f"Skipping {filename}: split video file not meant to be combined in Stremio")
|
| 547 |
return None
|
| 548 |
|
| 549 |
+
combined = parse_combined_episodes(filename)
|
| 550 |
+
|
| 551 |
excess = parsed.get("excess")
|
| 552 |
+
if not combined and excess and any("combined" in item.lower() for item in excess):
|
| 553 |
LOGGER.info(f"Skipping {filename}: contains 'combined'")
|
| 554 |
return None
|
| 555 |
|
| 556 |
+
split_info = None if combined else parse_split_info(filename)
|
| 557 |
part_number = split_info[1] if split_info else None
|
| 558 |
|
| 559 |
title = parsed.get("title")
|
|
|
|
| 562 |
year = parsed.get("year")
|
| 563 |
quality = parsed.get("quality")
|
| 564 |
|
| 565 |
+
if combined:
|
| 566 |
+
season, episode = combined["season"], combined["start"] or 1
|
| 567 |
+
elif isinstance(season, list) or isinstance(episode, list):
|
| 568 |
LOGGER.warning(f"Invalid season/episode format for {filename}: {parsed}")
|
| 569 |
return None
|
| 570 |
if season and not episode:
|
|
|
|
| 590 |
if season and episode:
|
| 591 |
LOGGER.info(f"Fetching TV metadata: {title} S{season:02d}E{episode:02d} (year={year})")
|
| 592 |
result = await fetch_tv_metadata(title, season, episode, encoded_string, year, quality, default_id)
|
| 593 |
+
if result is not None and combined:
|
| 594 |
+
_apply_combined_override(result, combined)
|
| 595 |
else:
|
| 596 |
LOGGER.info(f"Fetching Movie metadata: {title} (year={year})")
|
| 597 |
result = await fetch_movie_metadata(title, encoded_string, year, quality, default_id)
|
Backend/helper/split_files.py
CHANGED
|
@@ -39,6 +39,48 @@ def parse_split_info(filename: str) -> Optional[Tuple[str, int]]:
|
|
| 39 |
return _normalize(remainder), part_num
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def strip_part_suffix(filename: str) -> str:
|
| 43 |
if not filename:
|
| 44 |
return filename
|
|
|
|
| 39 |
return _normalize(remainder), part_num
|
| 40 |
|
| 41 |
|
| 42 |
+
_COMBINED_EPISODES_RE = re.compile(
|
| 43 |
+
r"E(?:P|PISODE)?[\s._-]*0*(\d{1,4})[\s._-]*(?:-|–|~|to)+[\s._-]*(?:E(?:P|PISODE)?[\s._-]*)?0*(\d{1,4})(?=\D|$)",
|
| 44 |
+
re.IGNORECASE,
|
| 45 |
+
)
|
| 46 |
+
_COMBINED_SEASON_RE = re.compile(r"S(?:EASON)?[\s._-]*0*(\d{1,3})", re.IGNORECASE)
|
| 47 |
+
_COMBINED_KEYWORD_RE = re.compile(r"\bcombined\b", re.IGNORECASE)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _combined_season(name: str) -> Optional[int]:
|
| 51 |
+
match = _COMBINED_SEASON_RE.search(name)
|
| 52 |
+
return int(match.group(1)) if match else None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Detect a combined file: an episode range ("S01 E04-06") or a whole-season
|
| 56 |
+
# "Combined" file. Returns {season, start, end} (start/end None for whole season).
|
| 57 |
+
def parse_combined_episodes(filename: str) -> Optional[dict]:
|
| 58 |
+
if not filename:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
match = _COMBINED_EPISODES_RE.search(filename)
|
| 62 |
+
if match:
|
| 63 |
+
start, end = int(match.group(1)), int(match.group(2))
|
| 64 |
+
if 1 <= start < end <= 99:
|
| 65 |
+
return {"season": _combined_season(filename) or 1, "start": start, "end": end}
|
| 66 |
+
|
| 67 |
+
if _COMBINED_KEYWORD_RE.search(filename):
|
| 68 |
+
season = _combined_season(filename)
|
| 69 |
+
if season is not None:
|
| 70 |
+
return {"season": season, "start": None, "end": None}
|
| 71 |
+
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# True when a combined file covers the requested season/episode.
|
| 76 |
+
def combined_covers(combined: dict, season: Optional[int], episode: Optional[int]) -> bool:
|
| 77 |
+
if season is not None and combined["season"] != int(season):
|
| 78 |
+
return False
|
| 79 |
+
if episode is None or combined["start"] is None:
|
| 80 |
+
return True
|
| 81 |
+
return combined["start"] <= int(episode) <= combined["end"]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def strip_part_suffix(filename: str) -> str:
|
| 85 |
if not filename:
|
| 86 |
return filename
|