mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
558 lines
20 KiB
Python
558 lines
20 KiB
Python
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from os.path import basename
|
|
from typing import Any, Optional, TypedDict, Tuple, Pattern, Callable
|
|
|
|
import PTT
|
|
import dateparser
|
|
|
|
from db.models import TorrentStreams, EpisodeFile, KnownFile, MediaFusionSeriesMetaData
|
|
from streaming_providers.exceptions import ProviderException
|
|
from utils.lock import acquire_redis_lock
|
|
from utils.runtime_const import DATE_STR_REGEX
|
|
from utils.telegram_bot import telegram_notifier
|
|
from utils.validation_helper import is_video_file
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Precompile all regex patterns for better performance
|
|
SEASON_EPISODE_PATTERNS: dict[
|
|
str, Tuple[Pattern, Callable[[re.Match, int], Tuple[int, int]]]
|
|
] = {
|
|
# Pattern 1: SxxExx format (most common and reliable)
|
|
# Examples: "S01E04", "s01e04", "S1E04", "s1e04"
|
|
"standard": (
|
|
re.compile(r"[sS](\d{1,2})[eE](\d{1,2})"),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 2: xxXxx format
|
|
# Examples: "1x01", "01x01", "1X01", "01X01"
|
|
"separator_x": (
|
|
re.compile(r"(?<!\w)(\d{1,2})[xX](\d{1,2})(?!\w)"),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 3: Season X Episode Y format (text-based)
|
|
# Example: "Season 1 Episode 04", "season 01 episode 4"
|
|
"text_based": (
|
|
re.compile(r"[sS]eason\s+(\d{1,2}).*?[eE]pisode\s+(\d{1,2})"),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 4: Season/Series followed by episode number
|
|
# Example: "Season 02 - 05", "Series.1.Ep.03"
|
|
"season_ep": (
|
|
re.compile(
|
|
r"(?:[sS]eason|[sS]eries)[.\s-]*(\d{1,2}).*?(?:[eE]p|[eE]pisode)?[.\s-]*(\d{1,2})"
|
|
),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 5: SXXEXX format (no separator)
|
|
# Examples: "S01E04", "S1E4"
|
|
"no_separator": (
|
|
re.compile(r"S(\d{1,2})E(\d{1,2})", re.IGNORECASE),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 6: Show name followed by episode number
|
|
# Examples: "Show 01", "Show - 01", "Show.01"
|
|
# Only apply this when it's likely an actual episode number, not part of a hash
|
|
"simple_episode": (
|
|
re.compile(r"(?<=\s)(\d{1,2})(?:\s|$|\.)"),
|
|
lambda m, s: (s, int(m.group(1))), # Default to season s
|
|
),
|
|
# Pattern 7: bracketed season and episode
|
|
# Examples: "[S01E04]", "(S1.E4)", "[1x04]"
|
|
"bracketed": (
|
|
re.compile(r"[\[\(](?:[sS])?(\d{1,2})[.\s]?(?:[eExX])(\d{1,2})[\]\)]"),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 8: season and episode separated by period
|
|
# Examples: "1.04", "01.04"
|
|
"period_sep": (
|
|
re.compile(r"(?<!\d|\w)(\d{1,2})\.(\d{2})(?!\d|\w)"),
|
|
lambda m, _: (int(m.group(1)), int(m.group(2))),
|
|
),
|
|
# Pattern 9: episode identifier with underscore or dash
|
|
# Examples: "_e01", "-e01", "_ep01", "-ep01"
|
|
"episode_only": (
|
|
re.compile(r"[_-][eE](?:p)?(\d{1,2})"),
|
|
lambda m, s: (s, int(m.group(1))), # Default to season s
|
|
),
|
|
# Pattern 10: Absolute episode numbering
|
|
# Examples: "Episode.123", "Ep 123", "EP.123"
|
|
"absolute_ep": (
|
|
re.compile(r"[eE]p(?:isode)?[.\s](\d{1,3})(?!\w)", re.IGNORECASE),
|
|
lambda m, s: (s, int(m.group(1))), # Default to season s
|
|
),
|
|
# Pattern 11: Zero-padded episode numbers
|
|
# Examples: "00022"
|
|
"zero_padded": (
|
|
re.compile(r"(?<!\d)(\d{2,})(?!\d)"),
|
|
lambda m, s: (s, int(m.group(1))), # Default to season s
|
|
),
|
|
}
|
|
# Additional patterns specifically for anime that should be tried after the general patterns
|
|
ANIME_PATTERNS: dict[
|
|
str, Tuple[Pattern, Callable[[re.Match, int], Tuple[int, int]]]
|
|
] = {
|
|
# Common pattern for standalone anime episodes, avoid matching hashes
|
|
# This should identify patterns like " 01 ", " - 01", etc.
|
|
"standalone_episode": (
|
|
re.compile(r"(?:^|\s|\[|\()(\d{1,2})(?:\s|$|\]|\))"),
|
|
lambda m, s: (s, int(m.group(1))), # Default to season s
|
|
),
|
|
}
|
|
|
|
|
|
class TorrentFile(TypedDict):
|
|
name: str
|
|
size: int
|
|
|
|
|
|
@dataclass
|
|
class FileInfo:
|
|
index: int
|
|
filename: str
|
|
size: int
|
|
season: Optional[int] = None
|
|
episode: Optional[int] = None
|
|
is_video: bool = False
|
|
|
|
@classmethod
|
|
def from_torrent_file(
|
|
cls,
|
|
index: int,
|
|
file_data: dict[str, Any],
|
|
name_key: str = "name",
|
|
size_key: str = "size",
|
|
) -> "FileInfo":
|
|
filename = basename(file_data[name_key])
|
|
return cls(
|
|
index=index,
|
|
filename=filename,
|
|
size=file_data[size_key],
|
|
is_video=is_video_file(filename),
|
|
)
|
|
|
|
|
|
class TorrentFileProcessor:
|
|
def __init__(
|
|
self,
|
|
torrent_info: dict[str, Any],
|
|
file_key: str = "files",
|
|
name_key: str = "name",
|
|
size_key: str = "size",
|
|
):
|
|
self.torrent_info = torrent_info
|
|
self.file_key = file_key
|
|
self.name_key = name_key
|
|
self.size_key = size_key
|
|
self.files = torrent_info[file_key]
|
|
self.file_infos = self._process_files()
|
|
self._video_files: Optional[list[FileInfo]] = None
|
|
self._episodes: Optional[list[EpisodeFile]] = None
|
|
self._metadata: Optional[MediaFusionSeriesMetaData] = None
|
|
|
|
def _process_files(self) -> list[FileInfo]:
|
|
"""Process all files in the torrent and create FileInfo objects."""
|
|
file_infos = []
|
|
for idx, file in enumerate(self.files):
|
|
file_info = FileInfo.from_torrent_file(
|
|
idx, file, self.name_key, self.size_key
|
|
)
|
|
if file_info.is_video:
|
|
# Don't parse season/episode info here - do it only when needed
|
|
file_infos.append(file_info)
|
|
|
|
return file_infos
|
|
|
|
def get_video_files(self) -> list[FileInfo]:
|
|
"""Get all video files from the torrent, caching the result."""
|
|
if self._video_files is None:
|
|
self._video_files = [f for f in self.file_infos if f.is_video]
|
|
return self._video_files
|
|
|
|
def get_largest_video_file(self) -> Optional[FileInfo]:
|
|
"""Get the largest video file from the torrent."""
|
|
video_files = self.get_video_files()
|
|
return max(video_files, key=lambda x: x.size) if video_files else None
|
|
|
|
async def _parse_season_episode_info(
|
|
self,
|
|
file_info: FileInfo,
|
|
torrent_title: str,
|
|
meta_id: str,
|
|
default_season: Optional[int] = 1,
|
|
) -> tuple[Optional[int], Optional[int]]:
|
|
"""Parse season and episode information from filename and torrent title."""
|
|
# First try from filename with PTT
|
|
parsed_data = PTT.parse_title(file_info.filename)
|
|
seasons = parsed_data.get("seasons", [])
|
|
episodes = parsed_data.get("episodes", [])
|
|
|
|
if seasons and episodes:
|
|
return seasons[0], episodes[0]
|
|
|
|
# For season packs without explicit season number
|
|
if not seasons and episodes:
|
|
return default_season, episodes[0]
|
|
|
|
# If no season/episode found, try from torrent title
|
|
title_parsed = PTT.parse_title(torrent_title)
|
|
title_seasons = title_parsed.get("seasons", [])
|
|
title_episodes = title_parsed.get("episodes", [])
|
|
|
|
# If only one season in title, use it
|
|
if len(title_seasons) == 1 and len(title_episodes) == 1:
|
|
return title_seasons[0], title_episodes[0]
|
|
|
|
# if we have date in the title, use it to find the episode from metadata
|
|
if not parsed_data.get("date"):
|
|
date_str_match = DATE_STR_REGEX.search(file_info.filename)
|
|
if date_str_match:
|
|
parsed_data["date"] = dateparser.parse(
|
|
date_str_match.group(0)
|
|
).strftime("%Y-%m-%d")
|
|
|
|
if parsed_data.get("date"):
|
|
if not self._metadata:
|
|
self._metadata = await MediaFusionSeriesMetaData.get(meta_id)
|
|
|
|
filtered_episode = next(
|
|
(
|
|
episode
|
|
for episode in self._metadata.episodes
|
|
if episode.released.strftime("%Y-%m-%d") == parsed_data["date"]
|
|
),
|
|
None,
|
|
)
|
|
if filtered_episode:
|
|
return filtered_episode.season_number, filtered_episode.episode_number
|
|
|
|
# if we have date in the title but no metadata, return None to avoid false detection
|
|
return None, None
|
|
|
|
# If PTT failed to identify season or episode, try the fallback parser
|
|
season, episode = fallback_parse_season_episode(
|
|
file_info.filename, default_season
|
|
)
|
|
|
|
# Log when fallback parser is used
|
|
if season is not None or episode is not None:
|
|
logger.info(
|
|
f"Used fallback parser for file '{file_info.filename}'. "
|
|
f"Detected: S{season}E{episode}"
|
|
)
|
|
|
|
return season, episode
|
|
|
|
async def find_specific_episode(
|
|
self, season: int, episode: int, torrent_title: str, meta_id: str
|
|
) -> Optional[FileInfo]:
|
|
"""Find a specific episode in the video files."""
|
|
if self._episodes:
|
|
for episode_file in self._episodes:
|
|
if (
|
|
episode_file.season_number == season
|
|
and episode_file.episode_number == episode
|
|
and 0 <= episode_file.file_index < len(self.file_infos)
|
|
):
|
|
return self.file_infos[episode_file.file_index]
|
|
|
|
video_files = self.get_video_files()
|
|
for file_info in video_files:
|
|
parsed_season, parsed_episode = await self._parse_season_episode_info(
|
|
file_info, torrent_title, meta_id, default_season=season
|
|
)
|
|
if parsed_season == season and parsed_episode == episode:
|
|
return file_info
|
|
if len(video_files):
|
|
# in some cases only one video file is present and no season/episode info
|
|
return video_files[0]
|
|
return None
|
|
|
|
async def parse_all_episodes(
|
|
self, torrent_title: str, meta_id: str, default_season: Optional[int] = None
|
|
) -> list[EpisodeFile]:
|
|
"""Parse all episode information from video files."""
|
|
episodes = []
|
|
for file_info in self.get_video_files():
|
|
season, episode = await self._parse_season_episode_info(
|
|
file_info, torrent_title, meta_id, default_season
|
|
)
|
|
if season and episode:
|
|
episodes.append(
|
|
EpisodeFile(
|
|
season_number=season,
|
|
episode_number=episode,
|
|
filename=file_info.filename,
|
|
size=file_info.size,
|
|
file_index=file_info.index,
|
|
)
|
|
)
|
|
self._episodes = episodes
|
|
return episodes
|
|
|
|
def find_file_by_name(self, filename: str) -> Optional[FileInfo]:
|
|
"""Find a file by its filename."""
|
|
for file_info in self.file_infos:
|
|
if file_info.filename == filename:
|
|
return file_info
|
|
return None
|
|
|
|
|
|
async def select_file_index_from_torrent(
|
|
torrent_info: dict[str, Any],
|
|
torrent_stream: TorrentStreams,
|
|
filename: Optional[str] = None,
|
|
season: Optional[int] = None,
|
|
episode: Optional[int] = None,
|
|
file_key: str = "files",
|
|
name_key: str = "name",
|
|
size_key: str = "size",
|
|
file_size_callback: Optional[callable] = None,
|
|
is_filename_trustable: bool = False,
|
|
is_index_trustable: bool = False,
|
|
) -> int:
|
|
"""
|
|
Select the file index from the torrent info with minimal processing.
|
|
Only processes all files if initial filename match fails.
|
|
"""
|
|
files = torrent_info[file_key]
|
|
|
|
# Quick filename match without full processing
|
|
if filename:
|
|
for idx, file in enumerate(files):
|
|
file_name = basename(file[name_key])
|
|
if file_name == filename:
|
|
return idx
|
|
|
|
# Initialize processor for further processing
|
|
# get file sizes if callback provided
|
|
if file_size_callback:
|
|
await file_size_callback(files)
|
|
processor = TorrentFileProcessor(torrent_info, file_key, name_key, size_key)
|
|
|
|
# Check if there are any video files
|
|
video_files = processor.get_video_files()
|
|
if not video_files:
|
|
if is_filename_trustable:
|
|
torrent_stream.is_blocked = True
|
|
await torrent_stream.save()
|
|
raise ProviderException(
|
|
"No valid video files found in torrent. Torrent has been blocked.",
|
|
"no_matching_file.mp4",
|
|
)
|
|
else:
|
|
raise ProviderException(
|
|
"No valid video files found in torrent",
|
|
"no_matching_file.mp4",
|
|
)
|
|
|
|
# Update metadata if filename is trustable
|
|
if is_filename_trustable:
|
|
await update_torrent_streams_metadata(
|
|
torrent_stream=torrent_stream,
|
|
processor=processor,
|
|
season=season,
|
|
is_index_trustable=is_index_trustable,
|
|
)
|
|
|
|
# Try to find by season/episode
|
|
if season and episode:
|
|
selected_file = await processor.find_specific_episode(
|
|
season, episode, torrent_stream.torrent_name, torrent_stream.meta_id
|
|
)
|
|
if selected_file:
|
|
return selected_file.index
|
|
else:
|
|
# Found video files but couldn't match season/episode
|
|
raise ProviderException(
|
|
"Found video files but couldn't match season/episode. "
|
|
"Annotation has been requested.",
|
|
"episode_not_found.mp4",
|
|
)
|
|
|
|
# Default to largest video file
|
|
selected_file = processor.get_largest_video_file()
|
|
if selected_file:
|
|
return selected_file.index
|
|
|
|
raise ProviderException(
|
|
"No valid video file found in torrent",
|
|
"no_matching_file.mp4",
|
|
)
|
|
|
|
|
|
async def update_torrent_streams_metadata(
|
|
torrent_stream: TorrentStreams,
|
|
processor: TorrentFileProcessor,
|
|
season: Optional[int] = None,
|
|
is_index_trustable: bool = False,
|
|
) -> bool:
|
|
"""
|
|
Update torrent stream metadata using the provided processor instance.
|
|
Returns True if update was successful, False if annotation was requested.
|
|
"""
|
|
# Check if annotation was recently requested with redis lock for 3 days
|
|
acquired, _ = await acquire_redis_lock(
|
|
f"annotation_lock_{torrent_stream.id}", timeout=259200, block=False
|
|
)
|
|
if not acquired:
|
|
logger.info(
|
|
f"Skipping metadata update for {torrent_stream.id} - recent annotation request"
|
|
)
|
|
return False
|
|
|
|
try:
|
|
if season is None:
|
|
# Movie handling
|
|
file_info = processor.get_largest_video_file()
|
|
if not file_info:
|
|
torrent_stream.is_blocked = True
|
|
await torrent_stream.save()
|
|
logger.error(f"No video files found in torrent {torrent_stream.id}")
|
|
return False
|
|
|
|
torrent_stream.filename = file_info.filename
|
|
if is_index_trustable:
|
|
torrent_stream.file_index = file_info.index
|
|
torrent_stream.size = file_info.size
|
|
|
|
else:
|
|
# Series handling
|
|
video_files = processor.get_video_files()
|
|
if not video_files:
|
|
torrent_stream.is_blocked = True
|
|
await torrent_stream.save()
|
|
logger.error(f"No video files found in torrent {torrent_stream.id}")
|
|
return False
|
|
|
|
episodes = await processor.parse_all_episodes(
|
|
torrent_stream.torrent_name,
|
|
torrent_stream.meta_id,
|
|
default_season=season,
|
|
)
|
|
|
|
if not episodes:
|
|
if len(video_files) == 1 and len(torrent_stream.episode_files) == 1:
|
|
episodes = torrent_stream.episode_files
|
|
episodes[0].filename = video_files[0].filename
|
|
episodes[0].size = video_files[0].size
|
|
episodes[0].file_index = (
|
|
video_files[0].index if is_index_trustable else None
|
|
)
|
|
else:
|
|
await _request_annotation(torrent_stream, processor.file_infos)
|
|
return False
|
|
|
|
torrent_stream.episode_files = episodes
|
|
total_size = sum(f.size for f in processor.get_video_files())
|
|
if total_size > torrent_stream.size:
|
|
torrent_stream.size = total_size
|
|
|
|
torrent_stream.updated_at = datetime.now(tz=timezone.utc)
|
|
await torrent_stream.save()
|
|
logger.info(f"Successfully updated {torrent_stream.id} metadata")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error updating metadata for {torrent_stream.id}: {str(e)}", exc_info=True
|
|
)
|
|
return False
|
|
|
|
|
|
async def _request_annotation(
|
|
torrent_stream: TorrentStreams, file_infos: list[FileInfo]
|
|
):
|
|
"""Request manual annotation for the torrent stream."""
|
|
file_details = [KnownFile(filename=f.filename, size=f.size) for f in file_infos]
|
|
torrent_stream.known_file_details = file_details
|
|
await torrent_stream.save()
|
|
|
|
# Notify contributors
|
|
await telegram_notifier.send_file_annotation_request(
|
|
torrent_stream.id, torrent_stream.torrent_name
|
|
)
|
|
logger.info(f"Requested annotation for {torrent_stream.id}")
|
|
|
|
|
|
def is_likely_hash(match_str: str, filename: str) -> bool:
|
|
"""
|
|
Check if a matched string is likely part of a hash by looking at surrounding context.
|
|
Returns True if it's likely a hash, False otherwise.
|
|
"""
|
|
|
|
# If the match is surrounded by hash indicators or hex characters, it's likely a hash
|
|
match_pos = filename.find(match_str)
|
|
if match_pos == -1:
|
|
return False
|
|
|
|
# Check if it's inside brackets which often contain hashes
|
|
bracket_match = re.search(
|
|
r"\[[^\]]*" + re.escape(match_str) + r"[^\[]*\]", filename
|
|
)
|
|
if bracket_match:
|
|
# If the bracketed content is short, it might be an episode indicator
|
|
# If it's long, it's more likely a hash
|
|
content = bracket_match.group(0)
|
|
if len(content) > 10 and any(c.isalpha() for c in content):
|
|
return True
|
|
|
|
# If the match contains letters (like in hex), it's likely a hash
|
|
if any(c.isalpha() for c in match_str):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def fallback_parse_season_episode(
|
|
filename: str, default_season: int = 1
|
|
) -> tuple[Optional[int], Optional[int]]:
|
|
"""
|
|
Fallback parser for season and episode information.
|
|
Returns (season_number, episode_number) tuple where each can be None if not detected.
|
|
|
|
Uses precompiled patterns stored in a global dictionary for better performance.
|
|
Patterns are ordered from most reliable/common to least reliable/specific.
|
|
"""
|
|
# Extract base filename without path and remove common extensions
|
|
base_filename = re.sub(r"\.(mkv|mp4|avi|mov|wmv|flv)$", "", filename.split("/")[-1])
|
|
|
|
# First try specific, high-confidence patterns
|
|
for pattern_name, (regex, extractor) in SEASON_EPISODE_PATTERNS.items():
|
|
match = regex.search(base_filename)
|
|
if match:
|
|
# Ensure the match isn't likely part of a hash
|
|
if pattern_name in ["simple_episode"] and is_likely_hash(
|
|
match.group(0), base_filename
|
|
):
|
|
continue
|
|
|
|
season, episode = extractor(match, default_season)
|
|
return season, episode
|
|
|
|
# Additional patterns specifically for anime titles should be matched only as a last resort
|
|
# and avoided if the string looks like it contains a hash
|
|
# First check if there's a standalone number that's likely an episode
|
|
# But be very careful with anime files to avoid matching hash portions
|
|
for pattern_name, (regex, extractor) in ANIME_PATTERNS.items():
|
|
for match in regex.finditer(base_filename):
|
|
# Skip if the match is inside a hash-like structure
|
|
if is_likely_hash(match.group(0), base_filename):
|
|
continue
|
|
|
|
# Find position relative to the title
|
|
# Look for numbers that appear after the anime title but before any hash
|
|
match_pos = match.start()
|
|
|
|
# Very simple heuristic: if it's early in the filename or has space around it
|
|
# and is a small number (less than 100), it's likely an episode number
|
|
if match_pos > 5 and int(match.group(1)) < 100:
|
|
season, episode = extractor(match, default_season)
|
|
return season, episode
|
|
|
|
# No match found
|
|
return None, None
|