Files

291 lines
11 KiB
Python

import logging
from abc import ABC, abstractmethod
from typing import List, Optional, Type
import dramatiq
import httpx
from db.config import settings
from db.crud import (
get_movie_data_by_id,
get_series_data_by_id,
store_new_torrent_streams,
get_or_create_metadata,
)
from db.redis_database import REDIS_ASYNC_CLIENT
from scrapers.base_scraper import IndexerBaseScraper
from scrapers.jackett import JackettScraper
from scrapers.prowlarr import ProwlarrScraper
from utils.crypto import get_text_hash
from utils.network import CircuitBreaker, batch_process_with_circuit_breaker
from utils.parser import is_contain_18_plus_keywords
from utils.wrappers import minimum_run_interval
logger = logging.getLogger(__name__)
class FeedScraper(ABC):
def __init__(
self, name: str, scraper_class: Type[ProwlarrScraper | JackettScraper]
):
self.name = name
self.scraper = scraper_class()
self.processed_items_key = f"{name}_feed_scraper:processed_items"
self.processed_items_expiry = 60 * 60 * 24 * 3 # 3 days
async def is_item_processed(self, item_id: str) -> bool:
return bool(
await REDIS_ASYNC_CLIENT.sismember(self.processed_items_key, item_id)
)
async def mark_item_as_processed(self, item_id: str):
await REDIS_ASYNC_CLIENT.sadd(self.processed_items_key, item_id)
await REDIS_ASYNC_CLIENT.expire(
self.processed_items_key, self.processed_items_expiry
)
async def scrape_feed(self):
"""Main feed scraping logic"""
self.scraper.metrics.start()
try:
healthy_indexers = await self.scraper.get_healthy_indexers()
if not healthy_indexers:
logger.warning(
f"No healthy indexers available for {self.name} feed scraping"
)
return
indexer_chunks = list(
self.scraper.split_indexers_into_chunks(healthy_indexers, 3)
)
logger.info(
f"Starting {self.name} feed scraper with {len(healthy_indexers)} indexers "
f"in {len(indexer_chunks)} chunks"
)
all_results = []
processed_info_hashes = set()
for chunk in indexer_chunks:
indexer_ids = [indexer["id"] for indexer in chunk]
try:
chunk_results = await self.get_chunk_results(indexer_ids)
self.scraper.metrics.record_found_items(len(chunk_results))
all_results.extend(chunk_results)
logger.info(
f"Scraped {len(chunk_results)} items from {self.name} indexer chunk "
f"{[self.scraper.indexer_status[i]['name'] for i in indexer_ids]}"
)
except Exception as e:
self.handle_chunk_error(e, chunk)
logger.info(f"Total items scraped from all chunks: {len(all_results)}")
# Process items with circuit breaker
circuit_breaker = CircuitBreaker(
failure_threshold=5, recovery_timeout=30, half_open_attempts=3
)
async for processed_item in batch_process_with_circuit_breaker(
self.process_feed_item,
all_results,
batch_size=20,
cb=circuit_breaker,
rate_limit_delay=1,
retry_exceptions=[httpx.HTTPStatusError],
scraper=self.scraper,
processed_info_hashes=processed_info_hashes,
):
if processed_item:
await self.mark_item_as_processed(processed_item)
self.scraper.metrics.record_processed_item()
logger.info(
f"Successfully processed {self.name} feed item: {processed_item}"
)
except Exception as e:
self.scraper.metrics.record_error("feed_scraping_error")
logger.exception(f"Error scraping {self.name} feed: {e}")
finally:
self.scraper.metrics.stop()
self.scraper.metrics.log_summary(logger)
@abstractmethod
async def get_chunk_results(self, indexer_ids: List) -> List[dict]:
"""Get results for a specific indexer chunk"""
pass
def handle_chunk_error(self, error: Exception, chunk: List):
"""Handle errors during chunk processing"""
if isinstance(error, httpx.ReadTimeout):
self.scraper.metrics.record_error("timeout")
logger.warning(
f"Timeout while fetching results from {self.name} indexer chunk {chunk}"
)
elif isinstance(error, httpx.HTTPStatusError):
self.scraper.metrics.record_error("http_error")
logger.error(
f"HTTP error while fetching from {self.name} chunk {chunk}: "
f"{error.response.text}, status code: {error.response.status_code}"
)
else:
self.scraper.metrics.record_error("unexpected_error")
logger.exception(f"Error fetching from {self.name} chunk {chunk}: {error}")
async def process_feed_item(
self, item: dict, scraper: IndexerBaseScraper, processed_info_hashes: set
) -> Optional[str]:
"""Process a single feed item"""
try:
item_id = scraper.get_info_hash(item) or get_text_hash(
scraper.get_guid(item), full_hash=True
)
title = scraper.get_title(item)
if await self.is_item_processed(item_id):
logger.info(f"Skipping already processed {title} feed item")
scraper.metrics.record_skip("Already processed info_hash")
return None
parsed_title_data = scraper.parse_title_data(title)
if not self.validate_item(title, parsed_title_data, scraper):
return item_id
media_type = self.get_media_type(item, parsed_title_data, scraper)
if not media_type:
logger.info(f"Skipping unsupported category for {title}")
scraper.metrics.record_skip("Unsupported category")
return item_id
metadata = await self.get_item_metadata(
item, parsed_title_data, media_type, scraper
)
if not metadata:
logger.info(f"Unable to find or create metadata for {title}")
scraper.metrics.record_skip("Unable to find or create metadata")
return item_id
stream = await scraper.process_stream(
item, metadata, media_type, processed_info_hashes=processed_info_hashes
)
if stream:
await store_new_torrent_streams([stream])
scraper.metrics.record_quality(stream.quality)
scraper.metrics.record_source(stream.source)
return item_id
return item_id
except Exception as e:
scraper.metrics.record_error("item_processing_error")
logger.exception(f"Error processing {self.name} feed item: {e}")
return None
def validate_item(
self, title: str, parsed_title_data: dict, scraper: IndexerBaseScraper
) -> bool:
"""Validate feed item"""
if is_contain_18_plus_keywords(title):
logger.info(f"Skipping adult content: {title}")
scraper.metrics.record_skip("Adult Content")
return False
return True
def get_media_type(
self, item: dict, parsed_title_data: dict, scraper: IndexerBaseScraper
) -> Optional[str]:
category_ids = scraper.get_category_ids(item)
if any(cat in category_ids for cat in self.scraper.MOVIE_CATEGORY_IDS):
return "movie"
elif any(cat in category_ids for cat in self.scraper.SERIES_CATEGORY_IDS):
return "series"
elif any(cat in category_ids for cat in self.scraper.OTHER_CATEGORY_IDS):
if not scraper.validate_category_with_title(
item, category_ids, is_filter_with_blocklist=False
):
return None
return "series" if parsed_title_data.get("seasons") else "movie"
return None
async def get_item_metadata(
self,
item: dict,
parsed_title_data: dict,
media_type: str,
scraper: IndexerBaseScraper,
):
"""Get or create metadata for item"""
imdb_id = scraper.get_imdb_id(item)
if imdb_id:
return await get_metadata_by_id(imdb_id, media_type)
parsed_title_data["created_at"] = scraper.get_created_at(item)
metadata = await get_or_create_metadata(
parsed_title_data, media_type, is_search_imdb_title=True, is_imdb_only=True
)
if not metadata:
return None
return await get_metadata_by_id(metadata["id"], media_type)
class ProwlarrFeedScraper(FeedScraper):
def __init__(self):
super().__init__("prowlarr", ProwlarrScraper)
async def get_chunk_results(self, indexer_ids: List) -> List[dict]:
params = {
"type": "search",
"categories": [2000, 5000, 8000], # Movies, TV, and Other categories
"offset": 0,
"limit": 100,
}
return await self.scraper.fetch_search_results(params, indexer_ids=indexer_ids)
class JackettFeedScraper(FeedScraper):
def __init__(self):
super().__init__("jackett", JackettScraper)
async def get_chunk_results(self, indexer_ids: List) -> List[dict]:
params = {
"t": "search",
"cat": [2000, 5000, 8000], # Movies, TV, and Other categories
"offset": 0,
"limit": 100,
}
return await self.scraper.fetch_search_results(params, indexer_ids=indexer_ids)
async def get_metadata_by_id(imdb_id: str, media_type: str):
"""Get metadata by IMDB ID"""
if media_type == "movie":
return await get_movie_data_by_id(imdb_id)
return await get_series_data_by_id(imdb_id)
# Dramatiq actors for scheduling
@dramatiq.actor(time_limit=60 * 60 * 1000, priority=5, queue_name="scrapy")
@minimum_run_interval(hours=settings.prowlarr_feed_scrape_interval_hour)
async def run_prowlarr_feed_scraper(**kwargs):
if not settings.is_scrap_from_prowlarr:
return
logger.info("Running Prowlarr feed scraper")
scraper = ProwlarrFeedScraper()
await scraper.scrape_feed()
@dramatiq.actor(time_limit=60 * 60 * 1000, priority=5, queue_name="scrapy")
@minimum_run_interval(hours=settings.jackett_feed_scrape_interval_hour)
async def run_jackett_feed_scraper(**kwargs):
if not settings.is_scrap_from_jackett:
return
logger.info("Running Jackett feed scraper")
scraper = JackettFeedScraper()
await scraper.scrape_feed()