diff --git a/pyproject.toml b/pyproject.toml index 8ef1e76..6d1cc37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "ipython", "scrapy-fake-useragent>=1.4.4", "humanize>=4.12.1", + "dateparser>=1.2.1", ] [dependency-groups] diff --git a/scrapers/routes.py b/scrapers/routes.py index fd5a1b9..a5c7ae5 100644 --- a/scrapers/routes.py +++ b/scrapers/routes.py @@ -32,14 +32,11 @@ from scrapers.tv import add_tv_metadata, parse_m3u_playlist from utils import const, torrent from utils.network import get_request_namespace from utils.parser import calculate_max_similarity_ratio, convert_bytes_to_readable -from utils.runtime_const import TEMPLATES, SPORTS_ARTIFACTS +from utils.runtime_const import TEMPLATES, SPORTS_ARTIFACTS, DATE_STR_REGEX from utils.telegram_bot import telegram_notifier from utils.validation_helper import validate_image_url router = APIRouter() -DATE_STR_REGEX = re.compile( - r"\d{4}\.\d{2}\.\d{2}|\d{4}-\d{2}-\d{2}|\d{4}_\d{2}_\d{2}|\d{2}\.\d{2}\.\d{4}|\d{2}-\d{2}-\d{4}|\d{2}_\d{2}_\d{4}", -) def validate_api_password(api_password: str): diff --git a/streaming_providers/parser.py b/streaming_providers/parser.py index 5733f11..673fe96 100644 --- a/streaming_providers/parser.py +++ b/streaming_providers/parser.py @@ -1,15 +1,17 @@ import logging import re from dataclasses import dataclass -from datetime import datetime, timezone, timedelta +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 +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 @@ -149,6 +151,7 @@ class TorrentFileProcessor: 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.""" @@ -174,11 +177,12 @@ class TorrentFileProcessor: video_files = self.get_video_files() return max(video_files, key=lambda x: x.size) if video_files else None - def _parse_season_episode_info( + async def _parse_season_episode_info( self, file_info: FileInfo, - torrent_title: Optional[str] = None, - default_season: Optional[int] = None, + 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 @@ -186,50 +190,64 @@ class TorrentFileProcessor: seasons = parsed_data.get("seasons", []) episodes = parsed_data.get("episodes", []) - season = seasons[0] if seasons else None - episode = episodes[0] if episodes else None - - # If no season/episode found, try from torrent title - if torrent_title and (not season or not episode): - 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: - season = season or title_seasons[0] - - # If only one episode in title, use it - if len(title_episodes) == 1: - episode = episode or title_episodes[0] - - # If PTT failed to identify season or episode, try the fallback parser - if not season or not episode: - fallback_season, fallback_episode = fallback_parse_season_episode( - file_info.filename, default_season - ) - - # Use fallback results only if they're not None - if fallback_season is not None: - season = fallback_season - if fallback_episode is not None: - episode = fallback_episode - - # Log when fallback parser is used - if fallback_season is not None or fallback_episode is not None: - logger.info( - f"Used fallback parser for file '{file_info.filename}'. " - f"Detected: S{season}E{episode}" - ) + if seasons and episodes: + return seasons[0], episodes[0] # For season packs without explicit season number - if not season and episode: - season = default_season or 1 + 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 - def find_specific_episode( - self, season: int, episode: int, torrent_title: Optional[str] = None + 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: @@ -237,13 +255,14 @@ class TorrentFileProcessor: 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 = self._parse_season_episode_info( - file_info, torrent_title, default_season=season + 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 @@ -252,14 +271,14 @@ class TorrentFileProcessor: return video_files[0] return None - def parse_all_episodes( - self, torrent_title: str, default_season: Optional[int] = 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 = self._parse_season_episode_info( - file_info, torrent_title, default_season + season, episode = await self._parse_season_episode_info( + file_info, torrent_title, meta_id, default_season ) if season and episode: episodes.append( @@ -341,8 +360,8 @@ async def select_file_index_from_torrent( # Try to find by season/episode if season and episode: - selected_file = processor.find_specific_episode( - season, episode, torrent_stream.torrent_name + selected_file = await processor.find_specific_episode( + season, episode, torrent_stream.torrent_name, torrent_stream.meta_id ) if selected_file: return selected_file.index @@ -409,8 +428,10 @@ async def update_torrent_streams_metadata( logger.error(f"No video files found in torrent {torrent_stream.id}") return False - episodes = processor.parse_all_episodes( - torrent_stream.torrent_name, default_season=season + episodes = await processor.parse_all_episodes( + torrent_stream.torrent_name, + torrent_stream.meta_id, + default_season=season, ) if not episodes: @@ -462,8 +483,6 @@ 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. """ - # Common hash indicators - hash_indicators = ["[", "]", "(", ")", "{", "}", "#", "0x"] # If the match is surrounded by hash indicators or hex characters, it's likely a hash match_pos = filename.find(match_str) diff --git a/utils/runtime_const.py b/utils/runtime_const.py index abdaedb..59acd1f 100644 --- a/utils/runtime_const.py +++ b/utils/runtime_const.py @@ -58,3 +58,7 @@ BT4G_SEARCH_TTL = int( JACKETT_SEARCH_TTL = int( timedelta(hours=settings.jackett_search_interval_hour).total_seconds() ) + +DATE_STR_REGEX = re.compile( + r"\d{4}\.\d{2}\.\d{2}|\d{4}-\d{2}-\d{2}|\d{4}_\d{2}_\d{2}|\d{2}\.\d{2}\.\d{4}|\d{2}-\d{2}-\d{4}|\d{2}_\d{2}_\d{4}", +) diff --git a/uv.lock b/uv.lock index 0800e94..04ddfd4 100644 --- a/uv.lock +++ b/uv.lock @@ -452,6 +452,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/a9/2da08717a6862c48f1d61ef957a7bba171e7eefa6c0aa0ceb96a140c2a6b/cssselect-1.2.0-py2.py3-none-any.whl", hash = "sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e", size = 18687 }, ] +[[package]] +name = "dateparser" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3f/d3207a05f5b6a78c66d86631e60bfba5af163738a599a5b9aa2c2737a09e/dateparser-1.2.1.tar.gz", hash = "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", size = 309924 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/0a/981c438c4cd84147c781e4e96c1d72df03775deb1bc76c5a6ee8afa89c62/dateparser-1.2.1-py3-none-any.whl", hash = "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c", size = 295658 }, +] + [[package]] name = "decorator" version = "5.1.1" @@ -1081,7 +1096,7 @@ wheels = [ [[package]] name = "mediafusion" -version = "4.3.22" +version = "4.3.25" source = { virtual = "." } dependencies = [ { name = "aiohttp-socks" }, @@ -1093,6 +1108,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "bencodepy" }, { name = "cinemagoerng" }, + { name = "dateparser" }, { name = "demagnetize" }, { name = "diskcache" }, { name = "dnspython" }, @@ -1152,6 +1168,7 @@ requires-dist = [ { name = "beautifulsoup4" }, { name = "bencodepy" }, { name = "cinemagoerng", git = "https://github.com/mhdzumair/cinemagoerng.git" }, + { name = "dateparser", specifier = ">=1.2.1" }, { name = "demagnetize" }, { name = "diskcache" }, { name = "dnspython" },