mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
1519 lines
54 KiB
Python
1519 lines
54 KiB
Python
import abc
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from datetime import timedelta
|
|
from functools import wraps
|
|
from typing import Any, Literal, AsyncGenerator, AsyncIterable
|
|
from typing import Dict
|
|
from typing import List
|
|
from typing import Optional
|
|
|
|
import PTT
|
|
import httpx
|
|
from ratelimit import limits, sleep_and_retry
|
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
from torf import Magnet, MagnetError
|
|
|
|
from db.config import settings
|
|
from db.enums import TorrentType
|
|
from db.models import (
|
|
MediaFusionMovieMetaData,
|
|
MediaFusionSeriesMetaData,
|
|
EpisodeFile,
|
|
TorrentStreams,
|
|
MediaFusionMetaData,
|
|
)
|
|
from db.redis_database import REDIS_ASYNC_CLIENT
|
|
from db.schemas import UserData
|
|
from scrapers import torrent_info
|
|
from scrapers.imdb_data import get_episode_by_date
|
|
from utils.network import batch_process_with_circuit_breaker, CircuitBreaker
|
|
from utils.parser import calculate_max_similarity_ratio, is_contain_18_plus_keywords
|
|
from utils.torrent import extract_torrent_metadata, info_hashes_to_torrent_metadata
|
|
|
|
|
|
@dataclass
|
|
class ScraperMetrics:
|
|
scraper_name: str
|
|
meta_data: MediaFusionMetaData = None
|
|
season: int = None
|
|
episode: int = None
|
|
start_time: datetime = field(default_factory=datetime.now)
|
|
end_time: Optional[datetime] = None
|
|
total_items_found: int = 0
|
|
total_items_processed: int = 0
|
|
error_counts: Counter = field(default_factory=Counter)
|
|
skip_reasons: Counter = field(default_factory=Counter)
|
|
quality_stats: Counter = field(default_factory=Counter)
|
|
source_stats: Counter = field(default_factory=Counter)
|
|
skip_scraping: bool = False
|
|
indexer_stats: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
|
|
def start(self):
|
|
"""Reset and start new metrics collection"""
|
|
self.start_time = datetime.now()
|
|
self.end_time = None
|
|
self.total_items_found = 0
|
|
self.total_items_processed = 0
|
|
self.error_counts.clear()
|
|
self.skip_reasons.clear()
|
|
self.quality_stats.clear()
|
|
self.source_stats.clear()
|
|
self.skip_scraping = False
|
|
|
|
def stop(self):
|
|
"""Stop metrics collection and record end time"""
|
|
self.end_time = datetime.now()
|
|
|
|
def record_found_items(self, count: int):
|
|
"""Record number of items initially found"""
|
|
self.total_items_found += count
|
|
|
|
def record_processed_item(self):
|
|
"""Record a successfully processed item"""
|
|
self.total_items_processed += 1
|
|
|
|
def record_error(self, error_type: str):
|
|
"""Record an error occurrence"""
|
|
self.error_counts[error_type] += 1
|
|
|
|
def record_skip(self, reason: str):
|
|
"""Record a skipped item and the reason"""
|
|
self.skip_reasons[reason] += 1
|
|
|
|
def record_quality(self, quality: str):
|
|
"""Record quality statistics"""
|
|
self.quality_stats[str(quality)] += 1
|
|
|
|
def record_source(self, source: str):
|
|
"""Record source statistics"""
|
|
if source:
|
|
self.source_stats[source] += 1
|
|
|
|
def skip_scrape(self):
|
|
"""Skip scraping the item"""
|
|
self.skip_scraping = True
|
|
|
|
def record_indexer_success(self, indexer_name: str, results_count: int):
|
|
"""Record successful results from an indexer"""
|
|
if indexer_name not in self.indexer_stats:
|
|
self.indexer_stats[indexer_name] = {
|
|
"success_count": 0,
|
|
"error_count": 0,
|
|
"results_count": 0,
|
|
"errors": Counter(),
|
|
}
|
|
|
|
self.indexer_stats[indexer_name]["success_count"] += 1
|
|
self.indexer_stats[indexer_name]["results_count"] += results_count
|
|
|
|
def record_indexer_error(self, indexer_name: str, error: str):
|
|
"""Record an error from an indexer"""
|
|
if indexer_name not in self.indexer_stats:
|
|
self.indexer_stats[indexer_name] = {
|
|
"success_count": 0,
|
|
"error_count": 0,
|
|
"results_count": 0,
|
|
"errors": Counter(),
|
|
}
|
|
|
|
self.indexer_stats[indexer_name]["error_count"] += 1
|
|
self.indexer_stats[indexer_name]["errors"][error] += 1
|
|
|
|
def get_summary(self) -> Dict:
|
|
"""Generate a summary of the metrics"""
|
|
duration = (self.end_time or datetime.now()) - self.start_time
|
|
|
|
return {
|
|
"scraper_name": self.scraper_name,
|
|
"duration_seconds": duration.total_seconds(),
|
|
"total_items": {
|
|
"found": self.total_items_found,
|
|
"processed": self.total_items_processed,
|
|
"skipped": sum(self.skip_reasons.values()),
|
|
"errors": sum(self.error_counts.values()),
|
|
},
|
|
"error_counts": dict(self.error_counts),
|
|
"skip_reasons": dict(self.skip_reasons),
|
|
"quality_distribution": dict(self.quality_stats),
|
|
"source_distribution": dict(self.source_stats),
|
|
}
|
|
|
|
def format_summary(self) -> str:
|
|
"""Format the metrics summary as a nicely formatted string"""
|
|
if self.skip_scraping:
|
|
return f"{self.scraper_name} scraping was skipped due to recent scraping"
|
|
|
|
summary = self.get_summary()
|
|
lines = [""]
|
|
|
|
# Header
|
|
lines.extend(
|
|
[
|
|
"=" * 80,
|
|
f"{self.scraper_name.upper()} Scraping Metrics Summary".center(80),
|
|
"=" * 80,
|
|
"",
|
|
]
|
|
)
|
|
|
|
if self.meta_data:
|
|
lines.append(f"Meta ID: {self.meta_data.id}")
|
|
lines.append(f"Title: {self.meta_data.title}")
|
|
lines.append(f"Year: {self.meta_data.year}")
|
|
|
|
if self.season:
|
|
lines.append(f"Season: {self.season}")
|
|
lines.append(f"Episode: {self.episode}")
|
|
lines.append("")
|
|
|
|
# Duration
|
|
lines.append(f"Duration: {summary['duration_seconds']:.2f} seconds")
|
|
lines.append("")
|
|
|
|
# Items Summary
|
|
lines.extend(
|
|
[
|
|
"Items:",
|
|
f" ├─ Found : {summary['total_items']['found']}",
|
|
f" ├─ Processed : {summary['total_items']['processed']}",
|
|
f" ├─ Skipped : {summary['total_items']['skipped']}",
|
|
f" └─ Errors : {summary['total_items']['errors']}",
|
|
"",
|
|
]
|
|
)
|
|
|
|
# Error Distribution
|
|
if self.error_counts:
|
|
lines.append("Error Distribution:")
|
|
for i, (error_type, count) in enumerate(self.error_counts.most_common(), 1):
|
|
prefix = " └─" if i == len(self.error_counts) else " ├─"
|
|
lines.append(f"{prefix} {error_type:<20} : {count}")
|
|
lines.append("")
|
|
|
|
# Skip Reasons
|
|
if self.skip_reasons:
|
|
lines.append("Skip Reasons:")
|
|
for i, (reason, count) in enumerate(self.skip_reasons.most_common(), 1):
|
|
prefix = " └─" if i == len(self.skip_reasons) else " ├─"
|
|
lines.append(f"{prefix} {reason:<20} : {count}")
|
|
lines.append("")
|
|
|
|
# Quality Distribution
|
|
if self.quality_stats:
|
|
lines.append("Quality Distribution:")
|
|
for i, (quality, count) in enumerate(self.quality_stats.most_common(), 1):
|
|
prefix = " └─" if i == len(self.quality_stats) else " ├─"
|
|
lines.append(f"{prefix} {quality:<10} : {count:>4}")
|
|
lines.append("")
|
|
|
|
# Source Distribution
|
|
if self.source_stats:
|
|
lines.append("Source Distribution:")
|
|
for i, (source, count) in enumerate(self.source_stats.most_common(), 1):
|
|
prefix = " └─" if i == len(self.source_stats) else " ├─"
|
|
lines.append(f"{prefix} {source:<15} : {count:>4} ")
|
|
lines.append("")
|
|
|
|
# Add Indexer Statistics section
|
|
if self.indexer_stats:
|
|
lines.extend(["Indexer Statistics:", ""])
|
|
|
|
for indexer_name, stats in self.indexer_stats.items():
|
|
|
|
lines.extend(
|
|
[
|
|
f" {indexer_name}:",
|
|
f" └─ Results :{stats['results_count']:>6} Successes :{stats['success_count']:>6} Errors :{stats['error_count']:>6}",
|
|
]
|
|
)
|
|
|
|
if stats["errors"]:
|
|
lines.append(" Error Details:")
|
|
for error, count in stats["errors"].most_common():
|
|
lines.append(f" └─ {error}: {count}")
|
|
lines.append("")
|
|
|
|
# Footer
|
|
lines.extend(["=" * 80, ""])
|
|
|
|
return "\n".join(lines)
|
|
|
|
def log_summary(self, logger):
|
|
"""Log the metrics summary using the provided logger"""
|
|
logger.info(self.format_summary())
|
|
|
|
|
|
class ScraperError(Exception):
|
|
pass
|
|
|
|
|
|
class BaseScraper(abc.ABC):
|
|
def __init__(self, cache_key_prefix: str, logger_name: str):
|
|
self.logger = logging.getLogger(logger_name)
|
|
self.http_client = httpx.AsyncClient(timeout=30)
|
|
self.cache_key_prefix = cache_key_prefix
|
|
self.metrics = ScraperMetrics(cache_key_prefix)
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
await self.http_client.aclose()
|
|
|
|
async def scrape_and_parse(
|
|
self,
|
|
user_data: UserData,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> list[TorrentStreams] | list | None:
|
|
"""
|
|
Scrape data and parse it into TorrentStreams objects.
|
|
"""
|
|
self.metrics.start()
|
|
self.metrics.meta_data = metadata
|
|
self.metrics.season = season
|
|
self.metrics.episode = episode
|
|
try:
|
|
result = await self._scrape_and_parse(
|
|
user_data, metadata, catalog_type, season, episode
|
|
)
|
|
if isinstance(result, list):
|
|
return result
|
|
self.logger.error(
|
|
f"Invalid result received from {self.cache_key_prefix}: {result}"
|
|
)
|
|
except Exception as e:
|
|
self.metrics.record_error("unexpected_error")
|
|
self.logger.exception(f"An error occurred while scraping: {e}")
|
|
finally:
|
|
self.metrics.stop()
|
|
self.metrics.log_summary(self.logger)
|
|
return []
|
|
|
|
async def process_streams(
|
|
self,
|
|
*stream_generators: AsyncGenerator[TorrentStreams, None],
|
|
max_process: int = None,
|
|
max_process_time: int = None,
|
|
catalog_type: str = None,
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""
|
|
Process streams from multiple generators and yield them as they become available.
|
|
"""
|
|
queue = asyncio.Queue()
|
|
streams_processed = 0
|
|
active_generators = len(stream_generators)
|
|
processed_info_hashes = set()
|
|
|
|
async def producer(gen: AsyncIterable, generator_id: int):
|
|
try:
|
|
async for stream_item in gen:
|
|
await queue.put((stream_item, generator_id))
|
|
self.logger.debug("Generator % produced a stream", generator_id)
|
|
except Exception as err:
|
|
self.logger.exception(f"Error in generator {generator_id}: {err}")
|
|
finally:
|
|
await queue.put(("DONE", generator_id))
|
|
self.logger.debug("Generator %s finished", generator_id)
|
|
|
|
async def queue_processor():
|
|
nonlocal active_generators, streams_processed
|
|
while active_generators > 0:
|
|
item, gen_id = await queue.get()
|
|
|
|
if item == "DONE":
|
|
active_generators -= 1
|
|
self.logger.debug(
|
|
"Generator %s completed. %s generators remaining",
|
|
gen_id,
|
|
active_generators,
|
|
)
|
|
elif (
|
|
isinstance(item, TorrentStreams)
|
|
and item.id not in processed_info_hashes
|
|
):
|
|
processed_info_hashes.add(item.id)
|
|
if catalog_type != "series" or item.get_episodes(season, episode):
|
|
streams_processed += 1
|
|
self.logger.debug(
|
|
f"Processed stream from generator {gen_id}. Total streams processed: {streams_processed}"
|
|
)
|
|
yield item
|
|
|
|
if max_process and streams_processed >= max_process:
|
|
self.logger.info(f"Reached max process limit of {max_process}")
|
|
raise MaxProcessLimitReached("Max process limit reached")
|
|
|
|
try:
|
|
async with asyncio.timeout(max_process_time):
|
|
async with asyncio.TaskGroup() as tg:
|
|
# Create tasks for each stream generator
|
|
[
|
|
tg.create_task(producer(gen, i))
|
|
for i, gen in enumerate(stream_generators)
|
|
]
|
|
|
|
# Yield items as they become available
|
|
async for stream in queue_processor():
|
|
yield stream
|
|
except asyncio.TimeoutError:
|
|
self.logger.warning(
|
|
f"Stream processing timed out after {max_process_time} seconds. "
|
|
f"Processed {streams_processed} streams"
|
|
)
|
|
self.metrics.record_skip("Max process time")
|
|
except ExceptionGroup as eg:
|
|
for e in eg.exceptions:
|
|
if isinstance(e, MaxProcessLimitReached):
|
|
self.logger.info(
|
|
f"Stream processing cancelled after reaching max process limit of {max_process}"
|
|
)
|
|
self.metrics.record_skip("Max process limit")
|
|
else:
|
|
self.logger.exception(
|
|
f"An error occurred during stream processing: {e}"
|
|
)
|
|
self.metrics.record_error(f"unexpected_stream_processing_error {e}")
|
|
except Exception as e:
|
|
self.logger.exception(f"An error occurred during stream processing: {e}")
|
|
self.metrics.record_error(f"unexpected_stream_processing_error {e}")
|
|
self.logger.info(
|
|
f"Finished processing {streams_processed} streams from "
|
|
f"{len(stream_generators)} generators"
|
|
)
|
|
|
|
@abc.abstractmethod
|
|
async def _scrape_and_parse(self, *args, **kwargs) -> List[TorrentStreams]:
|
|
"""
|
|
Internal method for actual scraping implementation.
|
|
This should be implemented by each scraper.
|
|
"""
|
|
pass
|
|
|
|
@staticmethod
|
|
def cache(ttl: int = 3600):
|
|
"""
|
|
Decorator for caching the scraping status using Redis Sorted Sets with timestamps.
|
|
:param ttl: Time to live for the cache in seconds
|
|
"""
|
|
|
|
def decorator(func):
|
|
@wraps(func)
|
|
async def wrapper(self, *args, **kwargs):
|
|
cache_key = self.get_cache_key(*args, **kwargs)
|
|
current_time = int(time.time())
|
|
|
|
# Check if the item has been scraped recently
|
|
score = await REDIS_ASYNC_CLIENT.zscore(
|
|
self.cache_key_prefix, cache_key
|
|
)
|
|
|
|
if score and current_time - score < ttl:
|
|
self.metrics.skip_scrape()
|
|
return [] # Item has been scraped recently, no need to scrape again
|
|
|
|
result = await func(self, *args, **kwargs)
|
|
|
|
# Mark the item as scraped with the current timestamp
|
|
await REDIS_ASYNC_CLIENT.zadd(
|
|
self.cache_key_prefix, {cache_key: current_time}
|
|
)
|
|
|
|
return result
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
@staticmethod
|
|
def rate_limit(calls: int, period: timedelta):
|
|
"""
|
|
Decorator for rate limiting method calls.
|
|
:param calls: Number of calls allowed in the period
|
|
:param period: Time period for the rate limit
|
|
"""
|
|
|
|
def decorator(func):
|
|
@sleep_and_retry
|
|
@limits(calls=calls, period=period.total_seconds())
|
|
@wraps(func)
|
|
async def wrapper(self, *args, **kwargs):
|
|
return await func(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)
|
|
)
|
|
async def make_request(
|
|
self, url: str, method: str = "GET", is_expected_to_fail: bool = False, **kwargs
|
|
) -> httpx.Response:
|
|
"""
|
|
Make an HTTP request with retry logic.
|
|
"""
|
|
try:
|
|
response = await self.http_client.request(method, url, **kwargs)
|
|
response.raise_for_status()
|
|
return response
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404 and is_expected_to_fail:
|
|
return e.response
|
|
self.logger.error(f"HTTP error occurred: {e}")
|
|
raise ScraperError(f"HTTP error occurred: {e}")
|
|
except httpx.RequestError as e:
|
|
self.logger.error(f"An error occurred while requesting {e.request.url!r}.")
|
|
raise ScraperError(f"An error occurred while requesting {e.request.url!r}.")
|
|
|
|
def validate_response(self, response: Dict[str, Any]) -> bool:
|
|
"""
|
|
Validate the response from the scraper.
|
|
:param response: Response dictionary
|
|
:return: True if valid, False otherwise
|
|
"""
|
|
pass
|
|
|
|
async def parse_response(
|
|
self,
|
|
response: Dict[str, Any],
|
|
user_data,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> List[TorrentStreams]:
|
|
"""
|
|
Parse the response into TorrentStreams objects.
|
|
:param response: Response dictionary
|
|
:param user_data: UserData object
|
|
:param metadata: MediaFusionMetaData object
|
|
:param catalog_type: Catalog type (movie, series)
|
|
:param season: Season number (for series)
|
|
:param episode: Episode number (for series)
|
|
:return: List of TorrentStreams objects
|
|
"""
|
|
pass
|
|
|
|
def get_cache_key(
|
|
self,
|
|
user_data,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: str = None,
|
|
episode: str = None,
|
|
*_args,
|
|
**_kwargs,
|
|
) -> str:
|
|
"""
|
|
Generate a cache key for the given arguments.
|
|
:return: Cache key string
|
|
"""
|
|
if catalog_type == "movie":
|
|
return f"{catalog_type}:{metadata.id}"
|
|
|
|
return f"{catalog_type}:{metadata.id}:{season}:{episode}"
|
|
|
|
def validate_title_and_year(
|
|
self,
|
|
parsed_data: dict,
|
|
metadata: MediaFusionMovieMetaData | MediaFusionSeriesMetaData,
|
|
catalog_type: str,
|
|
torrent_title: str,
|
|
expected_ratio: int = 87,
|
|
) -> bool:
|
|
"""
|
|
Validate the title and year of the parsed data against the metadata.
|
|
:param parsed_data: Parsed data dictionary
|
|
:param metadata: MediaFusionMetaData object
|
|
:param catalog_type: Catalog type (movie, series)
|
|
:param torrent_title: Torrent title
|
|
:param expected_ratio: Expected similarity ratio
|
|
|
|
:return: True if valid, False otherwise
|
|
"""
|
|
# Check similarity ratios
|
|
max_similarity_ratio = calculate_max_similarity_ratio(
|
|
parsed_data["title"], metadata.title, metadata.aka_titles
|
|
)
|
|
|
|
# Log and return False if similarity ratios is below the expected threshold
|
|
if max_similarity_ratio < expected_ratio:
|
|
self.metrics.record_skip("Title mismatch")
|
|
self.logger.debug(
|
|
f"Title mismatch: '{parsed_data['title']}' vs. '{metadata.title}'. Torrent title: '{torrent_title}'"
|
|
)
|
|
return False
|
|
|
|
# Validate year based on a catalog type
|
|
if catalog_type == "movie":
|
|
if parsed_data.get("year") != metadata.year:
|
|
self.metrics.record_skip("Year mismatch")
|
|
self.logger.debug(
|
|
f"Year mismatch for movie: {parsed_data['title']} ({parsed_data.get('year')}) vs. {metadata.title} ({metadata.year}). Torrent title: '{torrent_title}'"
|
|
)
|
|
return False
|
|
if parsed_data.get("season"):
|
|
self.metrics.record_skip("Year mismatch")
|
|
self.logger.debug(
|
|
f"Season found for movie: {parsed_data['title']} ({parsed_data.get('season')}). Torrent title: '{torrent_title}'"
|
|
)
|
|
return False
|
|
|
|
parsed_year = parsed_data.get("year")
|
|
if (
|
|
catalog_type == "series"
|
|
and parsed_year
|
|
and (
|
|
(
|
|
metadata.end_year
|
|
and metadata.year
|
|
and not (metadata.year <= parsed_year <= metadata.end_year)
|
|
)
|
|
or (
|
|
metadata.year
|
|
and not metadata.end_year
|
|
and parsed_year < metadata.year
|
|
)
|
|
)
|
|
):
|
|
self.metrics.record_skip("Year mismatch")
|
|
self.logger.debug(
|
|
f"Year mismatch for series: {parsed_data['title']} ({parsed_year}) vs. {metadata.title} ({metadata.year} - {metadata.end_year}). Torrent title: '{torrent_title}'"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
async def store_streams(streams: List[TorrentStreams]):
|
|
"""
|
|
Store the parsed streams in the database.
|
|
:param streams: List of TorrentStreams objects
|
|
"""
|
|
from db.crud import store_new_torrent_streams
|
|
|
|
await store_new_torrent_streams(streams)
|
|
|
|
@staticmethod
|
|
async def remove_expired_items(scraper_prefix: str, ttl: int = 3600):
|
|
"""
|
|
Remove expired items from the cache.
|
|
"""
|
|
current_time = int(time.time())
|
|
await REDIS_ASYNC_CLIENT.zremrangebyscore(scraper_prefix, 0, current_time - ttl)
|
|
|
|
|
|
class BackgroundScraperManager:
|
|
def __init__(self):
|
|
self.movie_hash_key = "background_search:movies"
|
|
self.series_hash_key = "background_search:series"
|
|
self.processing_set_key = "background_search:processing"
|
|
self.batch_size = 10 # Number of items to process in each batch
|
|
|
|
async def add_movie_to_queue(self, meta_id: str) -> None:
|
|
"""Add a movie to the background search queue"""
|
|
await REDIS_ASYNC_CLIENT.hset(
|
|
self.movie_hash_key,
|
|
meta_id,
|
|
json.dumps({"last_scrape": None, "added_at": datetime.now().timestamp()}),
|
|
)
|
|
|
|
async def add_series_to_queue(
|
|
self, meta_id: str, season: int, episode: int
|
|
) -> None:
|
|
"""Add a series episode to the background search queue"""
|
|
key = f"{meta_id}:{season}:{episode}"
|
|
await REDIS_ASYNC_CLIENT.hset(
|
|
self.series_hash_key,
|
|
key,
|
|
json.dumps({"last_scrape": None, "added_at": datetime.now().timestamp()}),
|
|
)
|
|
|
|
async def get_pending_items(self, item_type: str) -> List[Dict]:
|
|
"""Get items that need to be scraped"""
|
|
hash_key = self.movie_hash_key if item_type == "movie" else self.series_hash_key
|
|
cutoff_time = datetime.now() - timedelta(
|
|
hours=settings.background_search_interval_hours
|
|
)
|
|
|
|
# Get all items
|
|
all_items = await REDIS_ASYNC_CLIENT.hgetall(hash_key)
|
|
pending_items = []
|
|
|
|
for item_key, item_data in all_items.items():
|
|
data = json.loads(item_data)
|
|
last_scrape = data.get("last_scrape")
|
|
|
|
# Check if item needs scraping
|
|
if not last_scrape or datetime.fromtimestamp(last_scrape) < cutoff_time:
|
|
|
|
# Check if not currently processing
|
|
if not await REDIS_ASYNC_CLIENT.sismember(
|
|
self.processing_set_key, item_key
|
|
):
|
|
pending_items.append(
|
|
{"key": item_key.decode("utf-8"), "data": data}
|
|
)
|
|
|
|
return pending_items[: self.batch_size]
|
|
|
|
async def mark_as_processing(self, item_key: str) -> None:
|
|
"""Mark an item as currently being processed"""
|
|
await REDIS_ASYNC_CLIENT.sadd(self.processing_set_key, item_key)
|
|
|
|
async def mark_as_completed(self, item_key: str, hash_key: str) -> None:
|
|
"""Mark an item as completed and update last scrape time"""
|
|
# Update last scrape time
|
|
item_data = await REDIS_ASYNC_CLIENT.hget(hash_key, item_key)
|
|
if item_data:
|
|
data = json.loads(item_data)
|
|
data["last_scrape"] = datetime.now().timestamp()
|
|
await REDIS_ASYNC_CLIENT.hset(hash_key, item_key, json.dumps(data))
|
|
|
|
# Remove from processing set
|
|
await REDIS_ASYNC_CLIENT.srem(self.processing_set_key, item_key)
|
|
|
|
async def cleanup_stale_processing(self, max_processing_time: int = 3600) -> None:
|
|
"""Clean up items stuck in processing state"""
|
|
processing_items = await REDIS_ASYNC_CLIENT.smembers(self.processing_set_key)
|
|
for item_key in processing_items:
|
|
await REDIS_ASYNC_CLIENT.srem(self.processing_set_key, item_key)
|
|
|
|
|
|
class MaxProcessLimitReached(Exception):
|
|
pass
|
|
|
|
|
|
class IndexerBaseScraper(BaseScraper, abc.ABC):
|
|
"""Base class for indexer-based scrapers (Prowlarr, Jackett)"""
|
|
|
|
# Blocklist & Allowlist of keywords to identify non-video files
|
|
# fmt: off
|
|
blocklist_keywords = [
|
|
".exe", ".zip", ".rar", ".iso", ".bin", ".tar", ".7z", ".pdf", ".xyz",
|
|
".epub", ".mobi", ".azw3", ".doc", ".docx", ".txt", ".rtf",
|
|
"setup", "install", "crack", "patch", "trainer", "readme",
|
|
"manual", "keygen", "license", "tutorial", "ebook", "software", "epub", "book",
|
|
]
|
|
|
|
allowlist_keywords = [
|
|
"mkv", "mp4", "avi", ".webm", ".mov", ".flv", "webdl", "web-dl", "webrip", "bluray",
|
|
"brrip", "bdrip", "dvdrip", "hdtv", "hdcam", "hdrip", "1080p", "720p", "480p", "360p",
|
|
"2160p", "4k", "x264", "x265", "hevc", "h264", "h265", "aac", "xvid", "movie", "series", "season",
|
|
]
|
|
|
|
# fmt: on
|
|
|
|
MOVIE_SEARCH_QUERY_TEMPLATES = [
|
|
"{title} ({year})", # Exact match with year
|
|
"{title} {year}", # Title with year (without parentheses)
|
|
"{title}", # Title-only fallback
|
|
]
|
|
|
|
SERIES_SEARCH_QUERY_TEMPLATES = [
|
|
"{title} S{season:02d}E{episode:02d}", # Standard SXXEYY format
|
|
"{title} Season {season} Episode {episode}", # Verbose format
|
|
"{title} {season}x{episode}", # Alternate XXxYY format
|
|
"{title} S{season:02d}", # Season search
|
|
"{title}", # Title-only fallback
|
|
]
|
|
|
|
MOVIE_CATEGORY_IDS = [
|
|
2000,
|
|
2010,
|
|
2020,
|
|
2030,
|
|
2040,
|
|
2045,
|
|
2050,
|
|
2060,
|
|
2070,
|
|
2080,
|
|
2090,
|
|
]
|
|
SERIES_CATEGORY_IDS = [
|
|
5000,
|
|
5010,
|
|
5020,
|
|
5030,
|
|
5040,
|
|
5045,
|
|
5050,
|
|
5060,
|
|
5070,
|
|
5080,
|
|
5090,
|
|
]
|
|
OTHER_CATEGORY_IDS = [8000, 8010, 8020]
|
|
|
|
def __init__(self, cache_key_prefix: str, base_url: str):
|
|
super().__init__(cache_key_prefix=cache_key_prefix, logger_name=__name__)
|
|
self.base_url = base_url
|
|
self.indexer_status = {}
|
|
self.indexer_circuit_breakers = {}
|
|
self.background_scraper_manager = BackgroundScraperManager()
|
|
|
|
async def _scrape_and_parse(
|
|
self,
|
|
user_data: UserData,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> List[TorrentStreams]:
|
|
results = []
|
|
processed_info_hashes: set[str] = set()
|
|
|
|
# Get list of healthy indexers
|
|
healthy_indexers = await self.get_healthy_indexers()
|
|
if not healthy_indexers:
|
|
self.metrics.record_error("No healthy indexers")
|
|
self.logger.warning("No healthy indexers available")
|
|
return results
|
|
|
|
# Split indexers into chunks of 3
|
|
indexer_chunks = list(self.split_indexers_into_chunks(healthy_indexers, 3))
|
|
self.logger.info(
|
|
f"Processing {len(healthy_indexers)} indexers in {len(indexer_chunks)} chunks"
|
|
)
|
|
|
|
try:
|
|
if catalog_type == "movie":
|
|
async for stream in self.scrape_movie(
|
|
processed_info_hashes, metadata, indexer_chunks
|
|
):
|
|
results.append(stream)
|
|
elif catalog_type == "series":
|
|
async for stream in self.scrape_series(
|
|
processed_info_hashes, metadata, season, episode, indexer_chunks
|
|
):
|
|
results.append(stream)
|
|
|
|
except httpx.ReadTimeout:
|
|
self.metrics.record_error("timeout")
|
|
self.logger.warning("Timeout while fetching search results")
|
|
except httpx.HTTPStatusError as e:
|
|
self.metrics.record_error("http_error")
|
|
self.logger.error(
|
|
f"Error fetching search results: {e.response.text}, status code: {e.response.status_code}"
|
|
)
|
|
except Exception as e:
|
|
self.metrics.record_error("unexpected_error")
|
|
self.logger.exception(f"An error occurred during scraping: {str(e)}")
|
|
|
|
self.logger.info(
|
|
f"Returning {len(results)} scraped streams for {metadata.title}"
|
|
)
|
|
return results
|
|
|
|
async def scrape_movie(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
indexer_chunks: List[List[dict]],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Common movie scraping logic"""
|
|
search_generators = []
|
|
|
|
# Add IMDB search for each chunk
|
|
for chunk in indexer_chunks:
|
|
search_generators.append(
|
|
self.scrape_movie_by_imdb(processed_info_hashes, metadata, chunk)
|
|
)
|
|
|
|
# Add title-based searches if enabled
|
|
if self.live_title_search_enabled:
|
|
for chunk in indexer_chunks:
|
|
for query_template in self.MOVIE_SEARCH_QUERY_TEMPLATES:
|
|
search_query = query_template.format(
|
|
title=metadata.title, year=metadata.year
|
|
)
|
|
search_generators.append(
|
|
self.scrape_movie_by_title(
|
|
processed_info_hashes,
|
|
metadata,
|
|
search_query=search_query,
|
|
indexers=chunk,
|
|
)
|
|
)
|
|
if settings.scrape_with_aka_titles:
|
|
for aka_title in metadata.aka_titles:
|
|
search_generators.append(
|
|
self.scrape_movie_by_title(
|
|
processed_info_hashes,
|
|
metadata,
|
|
search_query=aka_title,
|
|
indexers=chunk,
|
|
)
|
|
)
|
|
|
|
async for stream in self.process_streams(
|
|
*search_generators,
|
|
max_process=self.immediate_max_process,
|
|
max_process_time=self.immediate_max_process_time,
|
|
):
|
|
yield stream
|
|
|
|
if self.background_title_search_enabled:
|
|
await self.background_scraper_manager.add_movie_to_queue(metadata.id)
|
|
|
|
async def scrape_series(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
season: int,
|
|
episode: int,
|
|
indexer_chunks: List[List[dict]],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Common series scraping logic"""
|
|
search_generators = []
|
|
|
|
# Add IMDB search for each chunk
|
|
for chunk in indexer_chunks:
|
|
search_generators.append(
|
|
self.scrape_series_by_imdb(
|
|
processed_info_hashes, metadata, season, episode, chunk
|
|
)
|
|
)
|
|
|
|
# Add title-based searches if enabled
|
|
if self.live_title_search_enabled:
|
|
for chunk in indexer_chunks:
|
|
for query_template in self.SERIES_SEARCH_QUERY_TEMPLATES:
|
|
search_query = query_template.format(
|
|
title=metadata.title, season=season, episode=episode
|
|
)
|
|
search_generators.append(
|
|
self.scrape_series_by_title(
|
|
processed_info_hashes,
|
|
metadata,
|
|
season,
|
|
episode,
|
|
search_query=search_query,
|
|
indexers=chunk,
|
|
)
|
|
)
|
|
if settings.scrape_with_aka_titles:
|
|
for aka_title in metadata.aka_titles:
|
|
search_generators.append(
|
|
self.scrape_series_by_title(
|
|
processed_info_hashes,
|
|
metadata,
|
|
season,
|
|
episode,
|
|
search_query=aka_title,
|
|
indexers=chunk,
|
|
)
|
|
)
|
|
|
|
async for stream in self.process_streams(
|
|
*search_generators,
|
|
max_process=self.immediate_max_process,
|
|
max_process_time=self.immediate_max_process_time,
|
|
catalog_type="series",
|
|
season=season,
|
|
episode=episode,
|
|
):
|
|
yield stream
|
|
|
|
if self.background_title_search_enabled:
|
|
await self.background_scraper_manager.add_series_to_queue(
|
|
metadata.id, season, episode
|
|
)
|
|
|
|
@abc.abstractmethod
|
|
async def get_healthy_indexers(self) -> List[dict]:
|
|
"""Get list of healthy indexer IDs"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def fetch_search_results(
|
|
self, params: dict, indexer_ids: List[int], timeout: Optional[int] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""Fetch search results from the indexer"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def build_search_params(
|
|
self,
|
|
video_id: str,
|
|
search_type: Literal["search", "tvsearch", "movie"],
|
|
categories: list[int],
|
|
search_query: str = None,
|
|
) -> dict:
|
|
"""Build search parameters for the indexer"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def parse_indexer_data(
|
|
self, indexer_data: dict, catalog_type: str, parsed_data: dict
|
|
) -> dict:
|
|
"""Parse indexer-specific data"""
|
|
pass
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def live_title_search_enabled(self) -> bool:
|
|
"""Whether live title search is enabled"""
|
|
pass
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def background_title_search_enabled(self) -> bool:
|
|
"""Whether background title search is enabled"""
|
|
pass
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def immediate_max_process(self) -> int:
|
|
"""Maximum number of items to process immediately"""
|
|
pass
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def immediate_max_process_time(self) -> int:
|
|
"""Maximum time to spend processing items immediately"""
|
|
pass
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def search_query_timeout(self) -> int:
|
|
"""Timeout for search queries"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_info_hash(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_guid(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_title(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_imdb_id(self, item: dict) -> str | None:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_category_ids(self, item: dict) -> List[int]:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_magent_link(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_download_link(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_info_url(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_indexer(self, item: dict) -> str:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_torrent_type(self, item: dict) -> TorrentType:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_created_at(self, item: dict) -> datetime:
|
|
pass
|
|
|
|
async def scrape_movie_by_imdb(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
indexers: List[dict],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Scrape movie using IMDB ID"""
|
|
async for stream in self.run_scrape_and_parse(
|
|
processed_info_hashes=processed_info_hashes,
|
|
metadata=metadata,
|
|
search_type="movie",
|
|
categories=self.MOVIE_CATEGORY_IDS,
|
|
catalog_type="movie",
|
|
indexers=indexers,
|
|
requires_imdb=True,
|
|
):
|
|
yield stream
|
|
|
|
async def scrape_movie_by_title(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
search_query: str,
|
|
indexers: List[dict],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Scrape movie using title search"""
|
|
async for stream in self.run_scrape_and_parse(
|
|
processed_info_hashes=processed_info_hashes,
|
|
metadata=metadata,
|
|
search_type="search",
|
|
categories=self.MOVIE_CATEGORY_IDS + self.OTHER_CATEGORY_IDS,
|
|
catalog_type="movie",
|
|
search_query=search_query,
|
|
indexers=indexers,
|
|
requires_imdb=False,
|
|
):
|
|
yield stream
|
|
|
|
async def scrape_series_by_imdb(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
season: int,
|
|
episode: int,
|
|
indexers: List[dict],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Scrape series using IMDB ID"""
|
|
async for stream in self.run_scrape_and_parse(
|
|
processed_info_hashes=processed_info_hashes,
|
|
metadata=metadata,
|
|
search_type="tvsearch",
|
|
categories=self.SERIES_CATEGORY_IDS,
|
|
catalog_type="series",
|
|
season=season,
|
|
episode=episode,
|
|
indexers=indexers,
|
|
requires_imdb=True,
|
|
):
|
|
yield stream
|
|
|
|
async def scrape_series_by_title(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
season: int,
|
|
episode: int,
|
|
search_query: str,
|
|
indexers: List[dict],
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Scrape series using title search"""
|
|
async for stream in self.run_scrape_and_parse(
|
|
processed_info_hashes=processed_info_hashes,
|
|
metadata=metadata,
|
|
search_type="search",
|
|
categories=self.SERIES_CATEGORY_IDS + self.OTHER_CATEGORY_IDS,
|
|
catalog_type="series",
|
|
season=season,
|
|
episode=episode,
|
|
search_query=search_query,
|
|
indexers=indexers,
|
|
requires_imdb=False,
|
|
):
|
|
yield stream
|
|
|
|
def filter_indexers_by_capability(
|
|
self,
|
|
indexers: List[dict],
|
|
search_type: Literal["search", "tvsearch", "movie"],
|
|
categories: list[int],
|
|
requires_imdb: bool = False,
|
|
) -> List[dict]:
|
|
"""Filter indexers based on their capabilities"""
|
|
filtered_indexers = []
|
|
|
|
# Map our search types to indexer search types
|
|
search_type_map = {
|
|
"search": "search",
|
|
"tvsearch": "tv-search",
|
|
"movie": "movie-search",
|
|
}
|
|
indexer_search_type = search_type_map[search_type]
|
|
|
|
for indexer in indexers:
|
|
# Check if indexer supports the required search type
|
|
search_caps = indexer["search_capabilities"]
|
|
if indexer_search_type not in search_caps:
|
|
continue
|
|
|
|
# If we need IMDB support, check if it's available
|
|
if requires_imdb and "imdbid" not in search_caps[indexer_search_type]:
|
|
continue
|
|
|
|
# Check if indexer supports any of the required categories
|
|
if not any(cat in indexer["categories"] for cat in categories):
|
|
continue
|
|
|
|
filtered_indexers.append(indexer)
|
|
|
|
return filtered_indexers
|
|
|
|
async def run_scrape_and_parse(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
search_type: Literal["search", "tvsearch", "movie"],
|
|
categories: list[int],
|
|
catalog_type: str,
|
|
indexers: List[dict],
|
|
season: int = None,
|
|
episode: int = None,
|
|
search_query: str = None,
|
|
requires_imdb: bool = False,
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Common method to run scraping and parsing process"""
|
|
# Filter indexers based on capabilities
|
|
filtered_indexers = self.filter_indexers_by_capability(
|
|
indexers, search_type, categories, requires_imdb
|
|
)
|
|
|
|
if not filtered_indexers:
|
|
self.logger.warning(
|
|
f"No indexers support {search_type} search with required capabilities, Requires IMDB: {requires_imdb}"
|
|
)
|
|
return
|
|
|
|
self.logger.info(
|
|
f"Found {len(filtered_indexers)} indexers supporting {search_type}."
|
|
)
|
|
|
|
params = await self.build_search_params(
|
|
metadata.id,
|
|
search_type,
|
|
categories,
|
|
search_query,
|
|
)
|
|
|
|
# Use only the IDs from filtered indexers
|
|
indexer_ids = [indexer["id"] for indexer in filtered_indexers]
|
|
search_results = await self.fetch_search_results(
|
|
params, indexer_ids=indexer_ids, timeout=self.search_query_timeout
|
|
)
|
|
|
|
self.metrics.record_found_items(len(search_results))
|
|
self.logger.info(
|
|
f"Found {len(search_results)} streams for {metadata.title} ({metadata.year}) "
|
|
f"with {search_type} Search, params: {params}"
|
|
)
|
|
|
|
async for stream in self.parse_streams(
|
|
processed_info_hashes,
|
|
metadata,
|
|
search_results,
|
|
catalog_type,
|
|
season,
|
|
episode,
|
|
):
|
|
yield stream
|
|
|
|
async def parse_streams(
|
|
self,
|
|
processed_info_hashes: set[str],
|
|
metadata: MediaFusionMetaData,
|
|
search_results: List[Dict[str, Any]],
|
|
catalog_type: str,
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> AsyncGenerator[TorrentStreams, None]:
|
|
"""Parse stream results with circuit breaker"""
|
|
circuit_breaker = CircuitBreaker(
|
|
failure_threshold=2, recovery_timeout=10, half_open_attempts=3
|
|
)
|
|
|
|
async for result in batch_process_with_circuit_breaker(
|
|
self.process_stream,
|
|
search_results,
|
|
5, # batch_size
|
|
3, # max_concurrent_batches
|
|
circuit_breaker,
|
|
5, # max_retries
|
|
metadata=metadata,
|
|
catalog_type=catalog_type,
|
|
season=season,
|
|
episode=episode,
|
|
processed_info_hashes=processed_info_hashes,
|
|
):
|
|
if result is not None:
|
|
yield result
|
|
|
|
async def process_stream(
|
|
self,
|
|
stream_data: Dict[str, Any],
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
processed_info_hashes: set[str],
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> Optional[TorrentStreams]:
|
|
"""Common process stream implementation for all indexers"""
|
|
try:
|
|
torrent_title = self.get_title(stream_data)
|
|
if not torrent_title:
|
|
return None
|
|
|
|
if is_contain_18_plus_keywords(torrent_title):
|
|
self.logger.warning(
|
|
f"Adult content found in torrent title: {torrent_title}"
|
|
)
|
|
self.metrics.record_skip("Adult content")
|
|
return None
|
|
|
|
parsed_data = self.parse_title_data(torrent_title)
|
|
if not self.validate_title_and_year(
|
|
parsed_data,
|
|
metadata,
|
|
catalog_type,
|
|
torrent_title,
|
|
):
|
|
return None
|
|
|
|
# Get indexer-specific parsed data
|
|
parsed_data = await self.parse_indexer_data(
|
|
stream_data, catalog_type, parsed_data
|
|
)
|
|
if not parsed_data or parsed_data["info_hash"] in processed_info_hashes:
|
|
self.metrics.record_skip("Duplicated info_hash")
|
|
return None
|
|
|
|
torrent_type = self.get_torrent_type(stream_data)
|
|
|
|
torrent_stream = TorrentStreams(
|
|
id=parsed_data["info_hash"],
|
|
meta_id=metadata.id,
|
|
torrent_name=parsed_data["torrent_name"],
|
|
size=parsed_data["total_size"],
|
|
filename=(
|
|
parsed_data.get("largest_file", {}).get("filename")
|
|
if catalog_type == "movie"
|
|
else None
|
|
),
|
|
file_index=(
|
|
parsed_data.get("largest_file", {}).get("index")
|
|
if catalog_type == "movie"
|
|
else None
|
|
),
|
|
languages=parsed_data.get("languages"),
|
|
resolution=parsed_data.get("resolution"),
|
|
codec=parsed_data.get("codec"),
|
|
quality=parsed_data.get("quality"),
|
|
audio=parsed_data.get("audio"),
|
|
hdr=parsed_data.get("hdr"),
|
|
source=parsed_data["source"],
|
|
uploader=parsed_data.get("uploader"),
|
|
catalog=parsed_data["catalog"],
|
|
seeders=parsed_data["seeders"],
|
|
created_at=parsed_data["created_at"],
|
|
announce_list=parsed_data["announce_list"],
|
|
torrent_type=torrent_type,
|
|
torrent_file=(
|
|
parsed_data.get("torrent_file")
|
|
if torrent_type in [TorrentType.PRIVATE, TorrentType.SEMI_PRIVATE]
|
|
else None
|
|
),
|
|
)
|
|
|
|
if catalog_type == "series":
|
|
seasons = parsed_data.get("seasons")
|
|
episode_files = []
|
|
if parsed_data.get("file_data"):
|
|
episode_files = [
|
|
EpisodeFile(
|
|
season_number=file["season_number"],
|
|
episode_number=file["episode_number"],
|
|
filename=file.get("filename"),
|
|
size=file.get("size"),
|
|
file_index=file.get("index"),
|
|
)
|
|
for file in parsed_data["file_data"]
|
|
if file.get("episode_number") is not None
|
|
and file.get("season_number") is not None
|
|
]
|
|
elif episodes := parsed_data.get("episodes") and seasons:
|
|
episode_files = [
|
|
EpisodeFile(season_number=seasons[0], episode_number=ep)
|
|
for ep in episodes
|
|
]
|
|
elif parsed_data.get("date"):
|
|
# search with date for episode
|
|
episode_date = datetime.strptime(parsed_data["date"], "%Y-%m-%d")
|
|
imdb_episode = await get_episode_by_date(
|
|
metadata.id,
|
|
parsed_data["title"],
|
|
episode_date.date(),
|
|
)
|
|
if imdb_episode and imdb_episode.season and imdb_episode.episode:
|
|
self.logger.info(
|
|
f"Episode found by {episode_date} date for {parsed_data.get('title')} ({metadata.id})"
|
|
)
|
|
episode_files = [
|
|
EpisodeFile(
|
|
season_number=int(imdb_episode.season),
|
|
episode_number=int(imdb_episode.episode),
|
|
title=imdb_episode.title,
|
|
released=episode_date,
|
|
)
|
|
]
|
|
else:
|
|
# if no episode data found, then try to get torrent metadata from trackers
|
|
torrent_data = await info_hashes_to_torrent_metadata(
|
|
[parsed_data["info_hash"]], parsed_data["announce_list"]
|
|
)
|
|
if torrent_data:
|
|
torrent_file_metadata = torrent_data[0]
|
|
episode_files = [
|
|
EpisodeFile(
|
|
season_number=file["season_number"],
|
|
episode_number=file["episode_number"],
|
|
filename=file.get("filename"),
|
|
size=file.get("size"),
|
|
file_index=file.get("index"),
|
|
)
|
|
for file in torrent_file_metadata.get("file_data", [])
|
|
if file.get("episode_number") is not None
|
|
and file.get("season_number") is not None
|
|
]
|
|
elif seasons:
|
|
# Some pack contains few episodes. We can't determine exact episode number
|
|
episode_files = [
|
|
EpisodeFile(season_number=season_number, episode_number=1)
|
|
for season_number in seasons
|
|
]
|
|
|
|
if episode_files:
|
|
torrent_stream.episode_files = episode_files
|
|
else:
|
|
self.metrics.record_skip("Missing episode info")
|
|
self.logger.warning(
|
|
f"Episode not found in stream: '{torrent_title}' "
|
|
f"Scraping for: S{season}E{episode}"
|
|
)
|
|
return None
|
|
else:
|
|
# For the Movies, should not have seasons and episodes
|
|
if parsed_data.get("seasons") or parsed_data.get("episodes"):
|
|
self.metrics.record_skip("Unexpected season/episode info")
|
|
return None
|
|
|
|
self.metrics.record_processed_item()
|
|
self.metrics.record_quality(torrent_stream.quality)
|
|
self.metrics.record_source(torrent_stream.source)
|
|
|
|
processed_info_hashes.add(parsed_data["info_hash"])
|
|
self.logger.info(
|
|
f"Successfully parsed stream: {parsed_data.get('title')} "
|
|
f"({parsed_data.get('year')}) ({metadata.id}) "
|
|
f"info_hash: {parsed_data.get('info_hash')}"
|
|
)
|
|
return torrent_stream
|
|
|
|
except httpx.ReadTimeout:
|
|
self.metrics.record_error("timeout")
|
|
self.logger.warning("Timeout while processing search result")
|
|
return None
|
|
except Exception as e:
|
|
self.metrics.record_error("result_processing_error")
|
|
self.logger.exception(f"Error processing search result: {e}")
|
|
return None
|
|
|
|
async def get_download_url(self, indexer_data):
|
|
"""Get download URL from Jackett indexer data"""
|
|
guid = self.get_guid(indexer_data) or ""
|
|
magnet_url = self.get_magent_link(indexer_data) or ""
|
|
download_url = self.get_download_link(indexer_data) or ""
|
|
torrent_type = self.get_torrent_type(indexer_data)
|
|
|
|
if torrent_type in [TorrentType.PRIVATE, TorrentType.SEMI_PRIVATE]:
|
|
return download_url
|
|
|
|
if guid and guid.startswith("magnet:"):
|
|
return guid
|
|
|
|
if not magnet_url.startswith("magnet:") and not download_url.startswith(
|
|
"magnet:"
|
|
):
|
|
torrent_info_data = await torrent_info.get_torrent_info(
|
|
self.get_info_url(indexer_data), self.get_indexer(indexer_data)
|
|
)
|
|
return (
|
|
torrent_info_data.get("magnetUrl")
|
|
or torrent_info_data.get("downloadUrl")
|
|
or magnet_url
|
|
or download_url
|
|
)
|
|
|
|
return magnet_url or download_url
|
|
|
|
async def get_torrent_data(
|
|
self, download_url: str, indexer: str, parsed_data: dict
|
|
) -> tuple[dict, bool]:
|
|
"""Common method to get torrent data from magnet or URL"""
|
|
if download_url.startswith("magnet:"):
|
|
try:
|
|
magnet = Magnet.from_string(download_url)
|
|
except MagnetError:
|
|
return {}, False
|
|
return {"info_hash": magnet.infohash, "announce_list": magnet.tr}, False
|
|
|
|
response = await self.http_client.get(
|
|
download_url,
|
|
follow_redirects=False,
|
|
timeout=self.search_query_timeout,
|
|
)
|
|
if response.status_code in [301, 302, 303, 307, 308]:
|
|
redirect_url = response.headers.get("Location")
|
|
return await self.get_torrent_data(redirect_url, indexer, parsed_data)
|
|
response.raise_for_status()
|
|
if response.headers.get("Content-Type") == "application/x-bittorrent":
|
|
return extract_torrent_metadata(response.content, parsed_data), True
|
|
return {}, False
|
|
|
|
@staticmethod
|
|
def parse_title_data(title: str) -> dict:
|
|
"""Parse torrent title using PTT"""
|
|
parsed = PTT.parse_title(title, True)
|
|
return {"torrent_name": title, **parsed}
|
|
|
|
def validate_category_with_title(
|
|
self,
|
|
indexer_data: dict,
|
|
category_ids: list = None,
|
|
is_filter_with_blocklist: bool = True,
|
|
) -> bool:
|
|
"""Validate category against title"""
|
|
category_ids = (
|
|
[category["id"] for category in indexer_data.get("categories", [])]
|
|
if not category_ids
|
|
else category_ids
|
|
)
|
|
|
|
if any(
|
|
[
|
|
category_id in category_ids
|
|
for category_id in IndexerBaseScraper.OTHER_CATEGORY_IDS
|
|
]
|
|
):
|
|
title = self.get_title(indexer_data).lower()
|
|
|
|
if is_filter_with_blocklist:
|
|
return not any(keyword in title for keyword in self.blocklist_keywords)
|
|
else:
|
|
return any(keyword in title for keyword in self.allowlist_keywords)
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def split_indexers_into_chunks(
|
|
indexers: List[dict], chunk_size: int
|
|
) -> List[List[dict]]:
|
|
"""Split indexers into chunks of specified size"""
|
|
return [
|
|
indexers[i : i + chunk_size] for i in range(0, len(indexers), chunk_size)
|
|
]
|