mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
397 lines
13 KiB
Python
397 lines
13 KiB
Python
import asyncio
|
|
import hashlib
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
import dramatiq
|
|
|
|
from db.config import settings
|
|
from db.models import TorrentStreams, MediaFusionMetaData
|
|
from db.schemas import UserData
|
|
from scrapers.base_scraper import BaseScraper
|
|
from scrapers.bt4g import BT4GScraper
|
|
from scrapers.imdb_data import get_imdb_title_data, search_imdb, search_multiple_imdb
|
|
from scrapers.jackett import JackettScraper
|
|
from scrapers.mediafusion import MediafusionScraper
|
|
from scrapers.prowlarr import ProwlarrScraper
|
|
from scrapers.tmdb_data import (
|
|
get_tmdb_data_by_imdb,
|
|
search_tmdb,
|
|
get_tmdb_data,
|
|
get_imdb_id_from_tmdb,
|
|
search_multiple_tmdb,
|
|
)
|
|
from scrapers.torrentio import TorrentioScraper
|
|
from scrapers.yts import YTSScraper
|
|
from scrapers.zilean import ZileanScraper
|
|
from utils import runtime_const
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SCRAPERS = [
|
|
(settings.is_scrap_from_prowlarr, ProwlarrScraper),
|
|
(settings.is_scrap_from_zilean, ZileanScraper),
|
|
(settings.is_scrap_from_torrentio, TorrentioScraper),
|
|
(settings.is_scrap_from_mediafusion, MediafusionScraper),
|
|
(settings.is_scrap_from_yts, YTSScraper),
|
|
(settings.is_scrap_from_bt4g, BT4GScraper),
|
|
(settings.is_scrap_from_jackett, JackettScraper),
|
|
]
|
|
|
|
CACHED_DATA = [
|
|
(ProwlarrScraper.cache_key_prefix, runtime_const.PROWLARR_SEARCH_TTL),
|
|
(TorrentioScraper.cache_key_prefix, runtime_const.TORRENTIO_SEARCH_TTL),
|
|
(ZileanScraper.cache_key_prefix, runtime_const.ZILEAN_SEARCH_TTL),
|
|
(MediafusionScraper.cache_key_prefix, runtime_const.MEDIAFUSION_SEARCH_TTL),
|
|
(YTSScraper.cache_key_prefix, runtime_const.YTS_SEARCH_TTL),
|
|
(BT4GScraper.cache_key_prefix, runtime_const.BT4G_SEARCH_TTL),
|
|
(JackettScraper.cache_key_prefix, runtime_const.JACKETT_SEARCH_TTL),
|
|
]
|
|
|
|
|
|
async def run_scrapers(
|
|
user_data: UserData,
|
|
metadata: MediaFusionMetaData,
|
|
catalog_type: str,
|
|
season: int = None,
|
|
episode: int = None,
|
|
) -> set[TorrentStreams]:
|
|
"""Run all enabled scrapers and return unique streams"""
|
|
all_streams = []
|
|
failed_scrapers = []
|
|
|
|
async with asyncio.TaskGroup() as tg:
|
|
# Create tasks for enabled scrapers
|
|
tasks = [
|
|
tg.create_task(
|
|
scraper_cls().scrape_and_parse(
|
|
user_data, metadata, catalog_type, season, episode
|
|
),
|
|
name=f"{scraper_cls.__name__}",
|
|
)
|
|
for is_enabled, scraper_cls in SCRAPERS
|
|
if is_enabled
|
|
]
|
|
|
|
# Process results after all tasks complete
|
|
for task in tasks:
|
|
try:
|
|
streams = task.result()
|
|
all_streams.extend(streams)
|
|
logging.info(
|
|
f"Successfully scraped {len(streams)} streams from {task.get_name()}"
|
|
)
|
|
except Exception as exc:
|
|
# Log the error and keep track of failed scrapers
|
|
failed_scrapers.append(task.get_name())
|
|
logging.error(f"Error in scraper {task.get_name()}: {str(exc)}")
|
|
|
|
# Log summary of failures if any occurred
|
|
if failed_scrapers:
|
|
logging.error(f"Failed scrapers: {', '.join(failed_scrapers)}")
|
|
|
|
unique_streams = set(all_streams)
|
|
logging.info(
|
|
f"Successfully scraped {len(all_streams)} total streams "
|
|
f"({len(unique_streams)} unique) for {metadata.title}"
|
|
)
|
|
return unique_streams
|
|
|
|
|
|
@dramatiq.actor(
|
|
time_limit=5 * 60 * 1000, # 5 minutes
|
|
priority=20,
|
|
)
|
|
async def cleanup_expired_scraper_task(**kwargs):
|
|
"""Cleanup expired items from all scrapers"""
|
|
logging.info("Cleaning up expired scraper items")
|
|
for cache_key_prefix, ttl in CACHED_DATA:
|
|
await BaseScraper.remove_expired_items(cache_key_prefix, ttl)
|
|
|
|
|
|
class MetadataSource(Enum):
|
|
IMDB = "imdb"
|
|
TMDB = "tmdb"
|
|
|
|
|
|
class MetadataConfig:
|
|
def __init__(
|
|
self,
|
|
primary_source: MetadataSource = MetadataSource.IMDB,
|
|
fallback_enabled: bool = True,
|
|
):
|
|
self.primary_source = primary_source
|
|
self.fallback_enabled = fallback_enabled
|
|
|
|
@property
|
|
def can_use_tmdb(self) -> bool:
|
|
return bool(settings.tmdb_api_key)
|
|
|
|
def get_source_order(self) -> List[MetadataSource]:
|
|
"""Determine the order of metadata sources based on configuration and availability"""
|
|
if not self.fallback_enabled:
|
|
return [self.primary_source]
|
|
|
|
if self.primary_source == MetadataSource.TMDB and not self.can_use_tmdb:
|
|
return [MetadataSource.IMDB]
|
|
|
|
sources = [self.primary_source]
|
|
fallback = (
|
|
MetadataSource.IMDB
|
|
if self.primary_source == MetadataSource.TMDB
|
|
else MetadataSource.TMDB
|
|
)
|
|
|
|
if fallback == MetadataSource.TMDB and not self.can_use_tmdb:
|
|
return sources
|
|
|
|
return sources + [fallback]
|
|
|
|
|
|
@dataclass
|
|
class CacheEntry:
|
|
data: Dict[str, Any]
|
|
expires_at: datetime
|
|
|
|
|
|
class MetadataCache:
|
|
def __init__(self, ttl_minutes: int = 30):
|
|
self.cache: Dict[str, CacheEntry] = {}
|
|
self.ttl = timedelta(minutes=ttl_minutes)
|
|
|
|
def _generate_key(self, **kwargs) -> str:
|
|
"""Generate a cache key from the search parameters"""
|
|
# Sort kwargs to ensure consistent key generation
|
|
sorted_items = sorted(
|
|
(k, str(v)) for k, v in kwargs.items() if v is not None and k != "self"
|
|
)
|
|
key_string = ",".join(f"{k}:{v}" for k, v in sorted_items)
|
|
return hashlib.md5(key_string.encode()).hexdigest()
|
|
|
|
def get(self, **kwargs) -> Optional[Dict[str, Any]]:
|
|
"""Get cached data if it exists and is not expired"""
|
|
key = self._generate_key(**kwargs)
|
|
if key in self.cache:
|
|
entry = self.cache[key]
|
|
if datetime.now() < entry.expires_at:
|
|
return entry.data
|
|
else:
|
|
# Clean up expired entry
|
|
del self.cache[key]
|
|
return None
|
|
|
|
def set(self, data: Dict[str, Any], **kwargs) -> None:
|
|
"""Cache the data with expiration time"""
|
|
if not data: # Don't cache empty results
|
|
return
|
|
key = self._generate_key(**kwargs)
|
|
self.cache[key] = CacheEntry(data=data, expires_at=datetime.now() + self.ttl)
|
|
|
|
def clear_expired(self) -> None:
|
|
"""Remove all expired entries from the cache"""
|
|
now = datetime.now()
|
|
expired_keys = [
|
|
key for key, entry in self.cache.items() if now >= entry.expires_at
|
|
]
|
|
for key in expired_keys:
|
|
del self.cache[key]
|
|
|
|
|
|
class MetadataFetcher:
|
|
def __init__(self, cache_ttl_minutes: int = 30):
|
|
self.config = MetadataConfig(MetadataSource(settings.metadata_primary_source))
|
|
self.cache = MetadataCache(ttl_minutes=cache_ttl_minutes)
|
|
|
|
async def get_metadata(
|
|
self,
|
|
title_id: str,
|
|
media_type: str,
|
|
source_type: str = "imdb", # "imdb" or "tmdb" depending on ID type
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Main method to fetch metadata using configured sources and fallback logic.
|
|
"""
|
|
# Check cache first
|
|
cached_data = self.cache.get(
|
|
method="get_metadata",
|
|
title_id=title_id,
|
|
media_type=media_type,
|
|
source_type=source_type,
|
|
)
|
|
if cached_data:
|
|
logger.info(f"Cache hit for metadata: {title_id}")
|
|
return cached_data
|
|
|
|
metadata = None
|
|
sources = self.config.get_source_order()
|
|
|
|
for source in sources:
|
|
try:
|
|
if source == MetadataSource.IMDB:
|
|
if source_type == "tmdb":
|
|
# Need to get IMDB ID first
|
|
tmdb_data = await get_imdb_id_from_tmdb(title_id, media_type)
|
|
if not tmdb_data:
|
|
continue
|
|
title_id = tmdb_data
|
|
|
|
metadata = await get_imdb_title_data(title_id, media_type)
|
|
|
|
elif source == MetadataSource.TMDB and self.config.can_use_tmdb:
|
|
if source_type == "imdb":
|
|
metadata = await get_tmdb_data_by_imdb(title_id, media_type)
|
|
else:
|
|
metadata = await get_tmdb_data(title_id, media_type)
|
|
|
|
if metadata:
|
|
logger.info(
|
|
f"Successfully fetched metadata from {source.value} for {title_id}: {metadata['title']}"
|
|
)
|
|
# Cache the successful result
|
|
self.cache.set(
|
|
metadata,
|
|
method="get_metadata",
|
|
title_id=title_id,
|
|
media_type=media_type,
|
|
source_type=source_type,
|
|
)
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error fetching from {source.value}: {e}")
|
|
continue
|
|
|
|
return metadata
|
|
|
|
async def search_metadata(
|
|
self,
|
|
title: str,
|
|
year: Optional[int] = None,
|
|
media_type: Optional[str] = None,
|
|
created_at: Optional[str | datetime] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Search for metadata across configured sources with caching support.
|
|
"""
|
|
# Process created_year before cache check
|
|
created_year = None
|
|
if isinstance(created_at, datetime):
|
|
created_year = created_at.year
|
|
elif isinstance(created_at, str):
|
|
try:
|
|
created_year = int(created_at.split("-")[0])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
# Check cache first
|
|
cached_data = self.cache.get(
|
|
method="search_metadata",
|
|
title=title,
|
|
year=year,
|
|
media_type=media_type,
|
|
created_year=created_year,
|
|
)
|
|
if cached_data:
|
|
logger.info(f"Cache hit for search: {title}")
|
|
return cached_data
|
|
|
|
metadata = None
|
|
sources = self.config.get_source_order()
|
|
|
|
for source in sources:
|
|
try:
|
|
if source == MetadataSource.IMDB:
|
|
metadata = await search_imdb(
|
|
title, year, media_type, created_year=created_year
|
|
)
|
|
elif source == MetadataSource.TMDB and self.config.can_use_tmdb:
|
|
metadata = await search_tmdb(
|
|
title, year, media_type, created_year=created_year
|
|
)
|
|
|
|
if metadata:
|
|
logger.info(
|
|
f"Successfully searched metadata from {source.value}: {title}:{metadata['imdb_id']}"
|
|
)
|
|
# Cache the successful result
|
|
self.cache.set(
|
|
metadata,
|
|
method="search_metadata",
|
|
title=title,
|
|
year=year,
|
|
media_type=media_type,
|
|
created_year=created_year,
|
|
)
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching in {source.value}: {e}")
|
|
continue
|
|
|
|
return metadata
|
|
|
|
def clear_expired_cache(self) -> None:
|
|
"""Clear expired cache entries"""
|
|
self.cache.clear_expired()
|
|
|
|
async def search_multiple_results(
|
|
self,
|
|
title: str,
|
|
limit: int = 10,
|
|
year: Optional[int] = None,
|
|
media_type: Optional[str] = None,
|
|
created_year: Optional[int] = None,
|
|
min_similarity: int = 60,
|
|
) -> list[dict]:
|
|
"""
|
|
Search for multiple matching titles across IMDB and TMDB.
|
|
|
|
Args:
|
|
title: Title to search for
|
|
limit: Maximum number of results to return per source
|
|
year: Specific year to match (exact matching)
|
|
media_type: Type of media ('movie' or 'series')
|
|
created_year: Year used for sorting when exact year match isn't required
|
|
min_similarity: Minimum title similarity score (0-100) for fuzzy matching
|
|
"""
|
|
|
|
async def get_tmdb_candidates() -> List[Dict[str, Any]]:
|
|
try:
|
|
return await search_multiple_tmdb(
|
|
title=title,
|
|
limit=limit,
|
|
year=year,
|
|
media_type=media_type,
|
|
created_year=created_year,
|
|
min_similarity=min_similarity,
|
|
)
|
|
except Exception as e:
|
|
logging.error(f"Error searching TMDB: {e}")
|
|
return []
|
|
|
|
async def get_imdb_candidates() -> List[Dict[str, Any]]:
|
|
try:
|
|
return await search_multiple_imdb(
|
|
title=title,
|
|
limit=limit,
|
|
year=year,
|
|
media_type=media_type,
|
|
created_year=created_year,
|
|
min_similarity=min_similarity,
|
|
)
|
|
except Exception as e:
|
|
logging.error(f"Error searching IMDB: {e}")
|
|
return []
|
|
|
|
imdb_candidates, tmdb_candidates = await asyncio.gather(
|
|
get_imdb_candidates(), get_tmdb_candidates()
|
|
)
|
|
return imdb_candidates + tmdb_candidates
|
|
|
|
|
|
# Create a singleton instance with 30-minute cache TTL
|
|
meta_fetcher = MetadataFetcher(cache_ttl_minutes=30)
|