diff --git a/db/config.py b/db/config.py index 90d463b..bad8ce8 100644 --- a/db/config.py +++ b/db/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): prowlarr_url: str = "http://prowlarr-service:9696" zilean_url: str = "http://zilean.zilean:8181" playwright_cdp_url: str = "ws://browserless:3000?blockAds=true&stealth=true" + flaresolverr_url: str = "http://flaresolverr:8191/v1" # External API keys and secrets secret_key: str = Field(..., max_length=32, min_length=32) @@ -65,7 +66,7 @@ class Settings(BaseSettings): disable_validate_tv_streams_in_db: bool = False sport_video_scheduler_crontab: str = "*/20 * * * *" disable_sport_video_scheduler: bool = False - streamed_scheduler_crontab: str = "*/15 * * * *" + streamed_scheduler_crontab: str = "*/30 * * * *" disable_streamed_scheduler: bool = False mrgamingstreams_scheduler_crontab: str = "*/15 * * * *" disable_mrgamingstreams_scheduler: bool = True # Disabled due to site being down. diff --git a/db/crud.py b/db/crud.py index 0d05167..32d0d40 100644 --- a/db/crud.py +++ b/db/crud.py @@ -116,7 +116,7 @@ async def get_tv_meta_list( # Define query filters for TVStreams query_filters = { "is_working": True, - "namespace": {"$in": [namespace, "mediafusion", None]}, + "namespaces": {"$in": [namespace, "mediafusion", None]}, } poster_path = f"{settings.poster_host_url}/poster/tv/" @@ -428,7 +428,7 @@ async def get_tv_streams(video_id: str, namespace: str, user_data) -> list[Strea { "meta_id": video_id, "is_working": True, - "namespace": {"$in": [namespace, "mediafusion", None]}, + "namespaces": {"$in": [namespace, "mediafusion", None]}, }, ).to_list() @@ -848,7 +848,7 @@ async def process_tv_search_query(search_query: str, namespace: str) -> dict: { "$match": { "is_working": True, - "namespace": {"$in": [namespace, "mediafusion", None]}, + "namespaces": {"$in": [namespace, "mediafusion", None]}, } }, {"$count": "num_working_streams"}, @@ -907,11 +907,25 @@ async def save_tv_channel_metadata(tv_metadata: schemas.TVMetaData) -> str: # Ensure the channel document is upserted try: - await MediaFusionTVMetaData.find_one( - MediaFusionTVMetaData.id == channel_id - ).upsert( - Set({}), # Update operation is a no-op for now - on_insert=MediaFusionTVMetaData( + channel_data = await MediaFusionTVMetaData.get(channel_id) + if channel_data: + if channel_data.is_poster_working is False: + background = ( + {"background": tv_metadata.background} + if tv_metadata.background + else {} + ) + await channel_data.update( + Set( + { + "poster": tv_metadata.poster, + "is_poster_working": True, + **background, + } + ) + ) + else: + channel_data = MediaFusionTVMetaData( id=channel_id, title=tv_metadata.title, poster=tv_metadata.poster, @@ -922,13 +936,13 @@ async def save_tv_channel_metadata(tv_metadata: schemas.TVMetaData) -> str: genres=genres, type="tv", streams=[], - ), - ) + ) + await channel_data.create() except DuplicateKeyError: pass # Stream processing - stream_ids = [] + bulk_writer = BulkWriter() for stream in tv_metadata.streams: # Define stream document with meta_id stream_doc = TVStreams( @@ -943,7 +957,9 @@ async def save_tv_channel_metadata(tv_metadata: schemas.TVMetaData) -> str: source=stream.source, country=stream.country, meta_id=channel_id, - namespace=tv_metadata.namespace, + namespaces=[tv_metadata.namespace], + drm_key_id=stream.drm_key_id, + drm_key=stream.drm_key, ) # Check if the stream exists (by URL or ytId) and upsert accordingly @@ -951,29 +967,29 @@ async def save_tv_channel_metadata(tv_metadata: schemas.TVMetaData) -> str: TVStreams.url == stream.url, TVStreams.ytId == stream.ytId, ) - if existing_stream: - stream_ids.append(existing_stream.id) - else: - inserted_stream = await stream_doc.insert() - stream_ids.append(inserted_stream.id) - - # Update the TV channel with new stream links, if there are any new streams - if stream_ids: - await MediaFusionTVMetaData.find_one( - MediaFusionTVMetaData.id == channel_id - ).update( - { - "$addToSet": { - "streams": { - "$each": [ - TVStreams.link_from_id(stream_id) - for stream_id in stream_ids - ] - } + if existing_stream == stream_doc: + update_data = {} + if ( + stream.drm_key_id != existing_stream.drm_key_id + or stream.drm_key != existing_stream.drm_key + ) and tv_metadata.namespace in existing_stream.namespaces: + update_data = { + "drm_key_id": stream.drm_key_id, + "drm_key": stream.drm_key, } - } - ) + if tv_metadata.namespace not in existing_stream.namespaces: + existing_stream.namespaces.append(tv_metadata.namespace) + update_data.update({"namespaces": existing_stream.namespaces}) + if update_data: + await existing_stream.update( + Set(update_data), + bulk_writer=bulk_writer, + ) + else: + await TVStreams.insert_one(stream_doc, bulk_writer=bulk_writer) + + await bulk_writer.commit() logging.info(f"Processed TV channel {tv_metadata.title}") return channel_id @@ -991,24 +1007,17 @@ async def save_events_data(metadata: dict) -> str: existing_event_data = MediaFusionEventsMetaData.model_validate_json( existing_event_json ) - # Use a dictionary keyed by 'url' to ensure uniqueness of streams - existing_streams = { - stream.url or stream.ytId or stream.externalUrl: stream - for stream in existing_event_data.streams - } + existing_streams = set(existing_event_data.streams) else: - existing_streams = {} + existing_streams = set() # Update or add streams based on the uniqueness of 'url' for stream in metadata["streams"]: # Create a TVStreams instance for each stream stream_instance = TVStreams(meta_id=meta_id, **stream) - existing_streams[ - stream_instance.url or stream_instance.ytId or stream_instance.externalUrl - ] = stream_instance.model_dump() + existing_streams.add(stream_instance) - # Update the event metadata with the updated list of streams - streams = list(existing_streams.values()) + streams = list(existing_streams) event_start_timestamp = metadata.get("event_start_timestamp", 0) diff --git a/db/models.py b/db/models.py index 1d7311d..06c2e8b 100644 --- a/db/models.py +++ b/db/models.py @@ -104,7 +104,38 @@ class TVStreams(Document): country: str | None = None is_working: Optional[bool] = True test_failure_count: int = 0 - namespace: str = "mediafusion" + namespaces: list[str] = Field(default_factory=lambda: ["mediafusion"]) + drm_key_id: str | None = None + drm_key: str | None = None + + def __eq__(self, other): + if not isinstance(other, TVStreams): + return False + return ( + self.url == other.url + and self.ytId == other.ytId + and self.externalUrl == other.externalUrl + and self.drm_key_id == other.drm_key_id + and self.drm_key == other.drm_key + ) + + def __hash__(self): + return hash( + (self.url, self.ytId, self.externalUrl, self.drm_key_id, self.drm_key) + ) + + class Settings: + indexes = [ + IndexModel( + [ + ("meta_id", ASCENDING), + ("created_at", DESCENDING), + ("namespaces", ASCENDING), + ("is_working", ASCENDING), + ] + ), + IndexModel([("url", pymongo.TEXT), ("ytId", pymongo.TEXT)]), + ] class MediaFusionMetaData(Document): diff --git a/db/schemas.py b/db/schemas.py index 7392b44..1132851 100644 --- a/db/schemas.py +++ b/db/schemas.py @@ -258,6 +258,11 @@ class MetaIdProjection(BaseModel): id: str = Field(alias="_id") +class TVMetaProjection(BaseModel): + id: str = Field(alias="_id") + title: str + + class TVStreams(BaseModel): name: str url: str | None = None @@ -265,6 +270,8 @@ class TVStreams(BaseModel): source: str country: str | None = None behaviorHints: StreamBehaviorHints | None = None + drm_key_id: str | None = None + drm_key: str | None = None @model_validator(mode="after") def validate_url_or_yt_id(self) -> "TVStreams": @@ -282,7 +289,7 @@ class TVMetaData(BaseModel): logo: Optional[str] = None genres: list[str] = [] streams: list[TVStreams] - namespace: str = "mediafusion" + namespace: str = Field(default="mediafusion") class TorrentStreamsList(BaseModel): diff --git a/mediafusion_scrapy/middlewares.py b/mediafusion_scrapy/middlewares.py index daec773..8a6b964 100644 --- a/mediafusion_scrapy/middlewares.py +++ b/mediafusion_scrapy/middlewares.py @@ -2,12 +2,14 @@ # # See documentation in: # https://docs.scrapy.org/en/latest/topics/spider-middleware.html +import asyncio import random +from urllib.parse import urlparse -# useful for handling different item types with a single interface - -from scrapy import signals +import httpx +from scrapy import signals, Request from scrapy.downloadermiddlewares.retry import RetryMiddleware +from scrapy.exceptions import IgnoreRequest from scrapy.utils.response import response_status_message from twisted.internet import reactor, defer @@ -128,7 +130,7 @@ class TooManyRequestsRetryMiddleware(RetryMiddleware): """ DEFAULT_DELAY = 30 # Default initial delay in seconds. - MAX_DELAY = 300 # Max delay between retries. + MAX_DELAY = 180 # Max delay between retries. BACKOFF_FACTOR = 3 # Exponential backoff factor. def __init__(self, settings): @@ -162,14 +164,14 @@ class TooManyRequestsRetryMiddleware(RetryMiddleware): # Calculate the delay with exponential backoff retry_after = response.headers.get("retry-after") try: - retry_after = int(retry_after) + retry_after = int(retry_after) + random.randint(1, 10) except (ValueError, TypeError): delay = min( self.MAX_DELAY, self.DEFAULT_DELAY * (self.BACKOFF_FACTOR**retries) ) else: delay = min( - self.MAX_DELAY, retry_after + random.randint(0, 10) + self.MAX_DELAY, retry_after + random.randint(5, 120) ) # Add some jitter spider.logger.info( @@ -191,3 +193,102 @@ class TooManyRequestsRetryMiddleware(RetryMiddleware): return deferred return response + + +class FlaresolverrMiddleware: + + def __init__(self, flaresolverr_url, cache_duration, max_timeout, max_attempts): + self.flaresolverr_url = flaresolverr_url + self.cache_duration = cache_duration + self.max_timeout = max_timeout + self.max_attempts = max_attempts + self.solved_domains = {} + self.client = httpx.AsyncClient() + + @classmethod + def from_crawler(cls, crawler): + return cls( + flaresolverr_url=crawler.settings.get( + "FLARESOLVERR_URL", "http://localhost:8191/v1" + ), + cache_duration=crawler.settings.get("FLARESOLVERR_CACHE_DURATION", 3600), + max_timeout=crawler.settings.get("FLARESOLVERR_MAX_TIMEOUT", 60000), + max_attempts=crawler.settings.get("FLARESOLVERR_MAX_ATTEMPTS", 3), + ) + + async def process_request(self, request, spider): + return None + + async def process_response(self, request, response, spider): + if not hasattr(spider, "use_flaresolverr") or not spider.use_flaresolverr: + return response + + if response.status == 403 or ( + response.status == 503 and "cloudflare" in response.text.lower() + ): + return await self._handle_cloudflare(request, spider) + + return response + + async def _handle_cloudflare(self, request, spider): + domain = urlparse(request.url).netloc + current_time = reactor.seconds() + + if domain in self.solved_domains: + last_solved_time, solution = self.solved_domains[domain] + if current_time - last_solved_time < self.cache_duration: + return self._apply_solution(request, solution) + + for attempt in range(self.max_attempts): + try: + timeout = min( + self.max_timeout, 30000 * (2**attempt) + ) # Exponential backoff + flaresolverr_response = await self.client.post( + self.flaresolverr_url, + headers={"Content-Type": "application/json"}, + json={ + "cmd": "request.get", + "url": request.url, + "maxTimeout": timeout, + }, + timeout=timeout / 1000 + 5, + ) + + if flaresolverr_response.status_code == 200: + solution = flaresolverr_response.json() + if solution.get("status") == "ok": + self.solved_domains[domain] = (current_time, solution) + return self._apply_solution(request, solution) + + spider.logger.error( + f"FlareSolverr attempt {attempt + 1} failed: {flaresolverr_response.text}" + ) + await asyncio.sleep(2**attempt) # Wait before next attempt + + except httpx.RequestError as e: + spider.logger.error( + f"FlareSolverr request error on attempt {attempt + 1}: {e}" + ) + await asyncio.sleep(2**attempt) + + spider.logger.error( + f"Failed to solve Cloudflare challenge for {request.url} after {self.max_attempts} attempts" + ) + raise IgnoreRequest() + + def _apply_solution(self, original_request, solution): + solution_response = solution.get("solution", {}).get("response", {}) + return Request( + url=original_request.url, + headers=solution_response.get("headers", {}), + cookies={ + cookie["name"]: cookie["value"] + for cookie in solution_response.get("cookies", []) + }, + dont_filter=True, + meta={"flaresolverr_solved": True, **original_request.meta}, + ) + + async def close_spider(self, spider): + await self.client.aclose() diff --git a/mediafusion_scrapy/pipelines/live_stream_resolver_pipeline.py b/mediafusion_scrapy/pipelines/live_stream_resolver_pipeline.py index afe5a93..8a97772 100644 --- a/mediafusion_scrapy/pipelines/live_stream_resolver_pipeline.py +++ b/mediafusion_scrapy/pipelines/live_stream_resolver_pipeline.py @@ -32,7 +32,7 @@ class LiveStreamResolverPipeline: ) content_type = response.headers.get("Content-Type", b"").decode().lower() - if response.status == 200 and content_type in const.M3U8_VALID_CONTENT_TYPES: + if response.status == 200 and content_type in const.IPTV_VALID_CONTENT_TYPES: stream_headers.update( { "User-Agent": response.request.headers.get("User-Agent").decode(), diff --git a/mediafusion_scrapy/settings.py b/mediafusion_scrapy/settings.py index 3c4ef67..7513d32 100644 --- a/mediafusion_scrapy/settings.py +++ b/mediafusion_scrapy/settings.py @@ -6,6 +6,7 @@ # https://docs.scrapy.org/en/latest/topics/settings.html # https://docs.scrapy.org/en/latest/topics/downloader-middleware.html # https://docs.scrapy.org/en/latest/topics/spider-middleware.html +from db.config import settings BOT_NAME = "mediafusion_scrapy" @@ -51,6 +52,7 @@ SPIDER_MIDDLEWARES = { # Enable or disable downloader middlewares # See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html DOWNLOADER_MIDDLEWARES = { + "mediafusion_scrapy.middlewares.FlaresolverrMiddleware": 542, "mediafusion_scrapy.middlewares.TooManyRequestsRetryMiddleware": 543, } @@ -107,5 +109,8 @@ RETRY_HTTP_CODES = [ 522, 524, 408, + 403, ] # 429 is handled by the middleware RETRY_TIMES = 5 + +FLARESOLVERR_URL = settings.flaresolverr_url diff --git a/mediafusion_scrapy/spiders/live_tv.py b/mediafusion_scrapy/spiders/live_tv.py index a5b9780..0581411 100644 --- a/mediafusion_scrapy/spiders/live_tv.py +++ b/mediafusion_scrapy/spiders/live_tv.py @@ -1,5 +1,5 @@ import re -from urllib.parse import urljoin, urlparse +from urllib.parse import urljoin, urlparse, parse_qs import scrapy @@ -9,7 +9,13 @@ from utils import const class LiveTVSpider(scrapy.Spider): fallback_pattern = re.compile( - r"source: ['\"](.*?)['\"],\s*[\s\S]*?mimeType: ['\"]application/x-mpegURL['\"]" + r"source: ['\"](.*?)['\"],\s*[\s\S]*?mimeType: ['\"]application/(x-mpegURL|vnd\.apple\.mpegURL|dash\+xml)['\"]", + re.IGNORECASE, + ) + + any_m3u8_pattern = re.compile( + r'(?:"|\')?(https?://.*?\.m3u8(?:\?[^"\']*)?)(?:"|\')?', + re.IGNORECASE, ) # this site sometimes returns html instead of image @@ -92,7 +98,7 @@ class LiveTVSpider(scrapy.Spider): def parse_channel_page(self, response): channel_data = self.extract_channel_data(response) - player_api_base = self.extract_player_api_base(response) + player_api_base, player_api_method = self.extract_player_api_base(response) if not player_api_base: self.logger.error(f"Player API base URL not found for {response.url}") return @@ -106,6 +112,7 @@ class LiveTVSpider(scrapy.Spider): meta={ "channel_data": channel_data, "player_api_base": player_api_base, + "player_api_method": player_api_method, "response": response, # Pass the original response to access player options later }, dont_filter=True, @@ -116,6 +123,7 @@ class LiveTVSpider(scrapy.Spider): original_response = meta["response"] channel_data = meta["channel_data"] player_api_base = meta["player_api_base"] + player_api_method = meta["player_api_method"] content_type = response.headers.get("Content-Type", b"").decode().lower() is_image = "image" in content_type @@ -125,7 +133,7 @@ class LiveTVSpider(scrapy.Spider): if is_image or is_allowed_url: yield from self.process_player_options( - original_response, channel_data, player_api_base + original_response, channel_data, player_api_base, player_api_method ) else: self.logger.error(f"Invalid poster URL: {response.url}") @@ -146,12 +154,26 @@ class LiveTVSpider(scrapy.Spider): return channel_data def extract_player_api_base(self, response): - """Extracts the player API base URL.""" - return ( - response.xpath("//script[contains(text(), 'player_api')]/text()") - .re_first(r'"player_api":"([^"]+)"', default="") - .replace("\\/", "/") - ) + """Extracts the admin-ajax URL or player API base for GET or POST requests.""" + # Directly extract the URL used for admin-ajax POST requests + admin_ajax = response.xpath("//script[contains(text(), 'player_api')]/text()") + admin_ajax_method = admin_ajax.re_first(r'"play_method"\s*:\s*"([^"]+)"') + if admin_ajax_method == "wp_json": + admin_ajax_url = admin_ajax.re_first(r'"player_api"\s*:\s*"([^"]+)"') + elif admin_ajax_method == "admin_ajax": + admin_ajax_url = admin_ajax.re_first(r'"url"\s*:\s*"([^"]+)"') + else: + admin_ajax_url = None + + if admin_ajax_url: + # Correctly format and return the full URL + admin_ajax_full_url = urljoin( + response.url, admin_ajax_url.replace("\\/", "/") + ) + return admin_ajax_full_url, admin_ajax_method + else: + self.logger.error("Admin AJAX URL not found for TamilUltra.") + return None, None def extract_stream_details(self, element): """Extracts stream title and country name from an element.""" @@ -163,13 +185,17 @@ class LiveTVSpider(scrapy.Spider): country_name = get_country_name(country_code) return stream_title, country_name - def process_player_options(self, response, channel_data, player_api_base): + def process_player_options( + self, response, channel_data, player_api_base, player_api_method + ): for element in response.css("#playeroptionsul > li.dooplay_player_option"): yield from self.process_player_option( - element, channel_data, player_api_base + element, channel_data, player_api_base, player_api_method ) - def process_player_option(self, element, channel_data, player_api_base): + def process_player_option( + self, element, channel_data, player_api_base, player_api_method + ): """Processes each player option element to yield API request.""" stream_title, country_name = self.extract_stream_details(element) data_post, data_nume, data_type = ( @@ -179,16 +205,34 @@ class LiveTVSpider(scrapy.Spider): ) if all([data_post, data_nume, data_type]): - api_url = f"{player_api_base}{data_post}/{data_type}/{data_nume}" - yield scrapy.Request( - url=api_url, - callback=self.parse_api_response, - meta={ - "channel_data": channel_data, - "stream_title": stream_title, - "country_name": country_name, - }, - ) + if player_api_method == "wp_json": + api_url = f"{player_api_base}{data_post}/{data_type}/{data_nume}" + yield scrapy.Request( + url=api_url, + callback=self.parse_api_response, + meta={ + "channel_data": channel_data, + "stream_title": stream_title, + "country_name": country_name, + }, + ) + else: + form_data = { + "action": "doo_player_ajax", + "post": data_post, + "nume": data_nume, + "type": data_type, + } + yield scrapy.FormRequest( + url=player_api_base, + formdata=form_data, + callback=self.parse_api_response, + meta={ + "channel_data": channel_data, + "stream_title": stream_title, + "country_name": country_name, + }, + ) def parse_api_response(self, response): channel_data = response.meta.get("channel_data") @@ -213,17 +257,95 @@ class LiveTVSpider(scrapy.Spider): }, ) - def extract_m3u8_urls(self, response): - """Extracts M3U8 URLs using direct and fallback regex patterns.""" + def extract_m3u8_or_mpd_urls(self, response) -> tuple[list[dict], dict]: + """Extracts M3U8 and MPD URLs with appropriate metadata.""" parsed_url = urlparse(response.url) - channel_id = parsed_url.query.split("=")[-1] + parsed_query = parse_qs(parsed_url.query) - m3u8_urls = re.findall( - rf"{re.escape(channel_id)}['\"]:\s*{{\s*url:\s*['\"](.*?\.m3u8.*?)['\"]", - response.text, - ) - if not m3u8_urls: - m3u8_urls = self.fallback_pattern.findall(response.text) + if ( + response.headers.get("Content-Type", b"").decode().lower() + in const.IPTV_VALID_CONTENT_TYPES[:2] + ): + # If the content type is M3U8, return the URL directly + return [{"url": response.url, "type": "m3u8"}], {} + + stream_data = [] + + if "source" in parsed_query: + stream_data.append( + { + "url": urljoin(response.url, parsed_query["source"][0]), + "type": "unknown", # We'll determine the type later + } + ) + elif "zy" in parsed_query and ".mpd``" in parsed_query["zy"][0]: + data = parsed_query["zy"][0] + url, key_data = data.split("``") + drm_key_id, drm_key = key_data.split(":") + stream_data.append( + { + "url": url, + "type": "mpd", + "drm_key_id": drm_key_id, + "drm_key": drm_key, + } + ) + elif "tamilultra" in response.url: + query_string = parsed_url.query + stream_data.append( + { + "url": urljoin(response.url, query_string), + "type": "m3u8", + } + ) + + else: + channel_id = parsed_query.get("id", [""])[0] + + # Pattern to match both M3U8 and MPD URLs + pattern = rf"{re.escape(channel_id)}['\"]:\s*{{\s*['\"]?url['\"]?\s*:\s*['\"](.*?)['\"]" + matches = re.findall(pattern, response.text, re.DOTALL) + + if not matches: + matches = self.fallback_pattern.findall(response.text) + + if not matches: + matches = self.any_m3u8_pattern.findall(response.text) + + for match in matches: + url = match[0] if isinstance(match, tuple) else match + stream_info = { + "url": url, + "type": "unknown", # We'll determine the type later + } + + # Extract clearkeys if it's an MPD stream + if url.endswith(".mpd"): + drm_data = self.extract_drm_keys(response.text, channel_id) + if not drm_data: + self.logger.error( + f"Clearkeys not found for MPD URL: {url} on channel page: {response.url}" + ) + continue + stream_info.update(drm_data) + + stream_data.append(stream_info) + + if not stream_data: + self.logger.error( + "No M3U8 or MPD URLs found for channel page: %s", + response.meta["channel_data"]["channel_page_url"], + ) + return [], {} + + # Determine stream types + for stream in stream_data: + if stream["url"].endswith(".mpd"): + stream["type"] = "mpd" + elif any(ext in stream["url"] for ext in [".m3u8", ".m3u"]): + stream["type"] = "m3u8" + else: + stream["type"] = "m3u8" user_agent = response.request.headers.get("User-Agent").decode() parsed_url = urlparse(response.url) @@ -239,34 +361,37 @@ class LiveTVSpider(scrapy.Spider): }, } - return m3u8_urls, behavior_hints + return stream_data, behavior_hints def request_and_extract_video_url(self, response): channel_data = response.meta.get("channel_data") stream_title = response.meta.get("stream_title") country_name = response.meta.get("country_name") - # Extract M3U8 URLs - m3u8_urls, behavior_hints = self.extract_m3u8_urls(response) - if not m3u8_urls: + # Extract M3U8 & MPD URLs and behavior hints + streams_data, behavior_hints = self.extract_m3u8_or_mpd_urls(response) + if not streams_data: self.logger.error( - "No M3U8 URLs found for channel url: %s, stream title: %s", + "No M3U8 or MPD URLs found for channel url: %s, stream title: %s", channel_data["channel_page_url"], stream_title, ) return - for index, url in enumerate(m3u8_urls, 1): + for index, stream_data in enumerate(streams_data, 1): + url = stream_data["url"] full_url = urljoin(response.url, url) # Instead of appending to streams_info, initiate validation request yield scrapy.Request( url=full_url, - headers=behavior_hints["proxyHeaders"]["request"], - callback=self.validate_m3u8_url, - errback=self.handle_m3u8_failure, + headers=behavior_hints.get("proxyHeaders", {}).get("request", {}), + callback=self.validate_m3u8_or_mpd_url, + errback=self.handle_m3u8_or_mpd_failure, meta={ - "index": index if len(m3u8_urls) > 1 else None, + "index": index if len(streams_data) > 1 else None, "stream_title": stream_title, + "drm_key_id": stream_data.get("drm_key_id"), + "drm_key": stream_data.get("drm_key"), "full_url": full_url, "country_name": country_name, "channel_data": channel_data, @@ -275,17 +400,21 @@ class LiveTVSpider(scrapy.Spider): dont_filter=True, ) - def validate_m3u8_url(self, response): + def validate_m3u8_or_mpd_url(self, response): meta = response.meta content_type = response.headers.get("Content-Type", b"").decode().lower() - if response.status == 200 and content_type in const.M3U8_VALID_CONTENT_TYPES: + if response.status == 200 and content_type in const.IPTV_VALID_CONTENT_TYPES: # Content type is valid, proceed with adding the stream stream_info = { - "name": f"{meta['stream_title']} - {meta['index']}" - if meta["index"] - else meta["stream_title"], + "name": ( + f"{meta['stream_title']} - {meta['index']}" + if meta["index"] + else meta["stream_title"] + ), "url": meta["full_url"], + "drm_key_id": meta["drm_key_id"], + "drm_key": meta["drm_key"], "country": meta["country_name"], "behaviorHints": meta["behavior_hints"], "source": meta["channel_data"]["source"], @@ -300,9 +429,9 @@ class LiveTVSpider(scrapy.Spider): f"Invalid M3U8 URL: {meta['full_url']} with Content-Type: {content_type}" ) - def handle_m3u8_failure(self, failure): + def handle_m3u8_or_mpd_failure(self, failure): self.logger.error( - "Failed to get m3u8 URL from channel page: %s stream title: %s", + "Failed to get m3u8 or MPD URL from channel page: %s stream title: %s", failure.request.meta["channel_data"]["channel_page_url"], failure.request.meta["stream_title"], ) @@ -317,3 +446,35 @@ class LiveTVSpider(scrapy.Spider): channel_data_copy["country"] = country_name return channel_data_copy + + @staticmethod + def extract_drm_keys(response_text: str, channel_id: str) -> dict: + stream_info = {} + + # Pattern for channel entry + channel_pattern = rf'"{re.escape(channel_id)}"\s*:\s*{{[^}}]+}}' + channel_match = re.search(channel_pattern, response_text, re.DOTALL) + + if channel_match: + channel_data = channel_match.group(0) + + # Pattern for clearkeys + clearkey_pattern = ( + r'["\']?clearkeys["\']?\s*:\s*{\s*["\'](.+?)["\']\s*:\s*["\'](.+?)["\']' + ) + clearkey_match = re.search(clearkey_pattern, channel_data, re.DOTALL) + + # Pattern for k1 and k2 + k1k2_pattern = r'["\']?k1["\']?\s*:\s*["\'](.+?)["\'],\s*["\']?k2["\']?\s*:\s*["\'](.+?)["\']' + k1k2_match = re.search(k1k2_pattern, channel_data) + + if clearkey_match: + key_id, key = clearkey_match.groups() + stream_info["drm_key_id"] = key_id + stream_info["drm_key"] = key + elif k1k2_match: + key_id, key = k1k2_match.groups() + stream_info["drm_key_id"] = key_id + stream_info["drm_key"] = key + + return stream_info diff --git a/mediafusion_scrapy/spiders/streamed.py b/mediafusion_scrapy/spiders/streamed.py index 9eaaf22..10b6125 100644 --- a/mediafusion_scrapy/spiders/streamed.py +++ b/mediafusion_scrapy/spiders/streamed.py @@ -1,34 +1,48 @@ +import json import random import re - -import scrapy from datetime import datetime +from urllib.parse import urljoin + +import pytz +import scrapy from utils.runtime_const import SPORTS_ARTIFACTS class StreamedSpider(scrapy.Spider): name = "streamed" - allowed_domains = ["streamed.su"] - categories = { - "American Football": "https://streamed.su/category/american-football", - "Basketball": "https://streamed.su/category/basketball", - "Baseball": "https://streamed.su/category/baseball", - "Cricket": "https://streamed.su/category/cricket", - "Football": "https://streamed.su/category/football", - "Fighting": "https://streamed.su/category/fight", - "Hockey": "https://streamed.su/category/hockey", - "Tennis": "https://streamed.su/category/tennis", - "Rugby": "https://streamed.su/category/rugby", - "Golf": "https://streamed.su/category/golf", - "Dart": "https://streamed.su/category/darts", - "Afl": "https://streamed.su/category/afl", - "Motor Sport": "https://streamed.su/category/motor-sports", - "Other Sports": "https://streamed.su/category/other", + + api_base_url = "https://streamed.su/api" + live_matches_url = f"{api_base_url}/matches/live" + stream_url_template = f"{api_base_url}/stream/{{source}}/{{id}}" + image_url_template = ( + f"{api_base_url}/images/poster/{{batch_id_1}}/{{batch_id_2}}.webp" + ) + + m3u8_base_url = "https://{domain}/{source}/js/{id}/{stream_no}/playlist.m3u8" + mediafusion_referer = "https://mediafusion.addon/" + + category_mapping = { + "afl": "Afl", + "american-football": "American Football", + "baseball": "Baseball", + "basketball": "Basketball", + "billiards": "Billiards", + "cricket": "Cricket", + "darts": "Dart", + "fight": "Fighting", + "football": "Football", + "golf": "Golf", + "hockey": "Hockey", + "motor-sports": "Motor Sport", + "other": "Other Sports", + "rugby": "Rugby", + "tennis": "Tennis", } - m3u8_base_url = "https://rr.vipstreams.in/alpha/js" - mediafusion_referer = "https://mediafusion.addon/" + domains = None + domain_host = None custom_settings = { "ITEM_PIPELINES": { @@ -39,85 +53,122 @@ class StreamedSpider(scrapy.Spider): "DEFAULT_REQUEST_HEADERS": {"Referer": mediafusion_referer}, } - def __init__(self, *args, **kwargs): - super(StreamedSpider, self).__init__(*args, **kwargs) - def start_requests(self): - for category, url in self.categories.items(): - yield scrapy.Request(url, self.parse, meta={"category": category}) + yield scrapy.Request(self.live_matches_url, self.parse_live_matches) - def parse(self, response, **kwargs): - category = response.meta["category"] - events = response.xpath('//a[contains(@href,"/watch/")]') + def parse_live_matches(self, response): + matches = response.json() + for match in matches: + if not match.get("sources"): + self.logger.info(f"No sources available for match: {match['title']}") + continue - for event in events: - event_name = event.xpath(".//h1/text()").get().strip() - event_url = event.xpath(".//@href").get() + category = self.category_mapping.get(match["category"], "Other") item = { "stream_source": "Streamed (streamed.su)", "genres": [category], - "poster": random.choice(SPORTS_ARTIFACTS[category]["poster"]), - "background": random.choice(SPORTS_ARTIFACTS[category]["background"]), - "logo": random.choice(SPORTS_ARTIFACTS[category]["logo"]), - "is_add_title_to_poster": True, - "title": event_name, - "url": response.urljoin(event_url), + "title": match["title"], + "event_start_timestamp": match["date"] / 1000, # Convert to seconds "streams": [], } - yield response.follow( - event_url, - self.parse_event, - meta={"item": item}, - headers={"Referer": self.mediafusion_referer}, - ) + # Handle poster image + if "poster" in match and match["poster"]: + item["poster"] = urljoin(self.api_base_url, match["poster"]) + elif ( + match.get("teams") + and match["teams"].get("home", {}).get("badge") + and match["teams"].get("away", {}).get("badge") + ): + item["poster"] = self.image_url_template.format( + batch_id_1=match["teams"]["home"]["badge"], + batch_id_2=match["teams"]["away"]["badge"], + ) + else: + item["poster"] = random.choice(SPORTS_ARTIFACTS[category]["poster"]) - def parse_event(self, response): - script_text = response.xpath( - '//script[contains(text(), "const data =")]/text()' - ).get() + item["background"] = random.choice(SPORTS_ARTIFACTS[category]["background"]) + item["logo"] = random.choice(SPORTS_ARTIFACTS[category]["logo"]) + item["is_add_title_to_poster"] = True - event_start_timestamp = 0 - if script_text: - # Use a regular expression to find the timestamp - timestamp_match = re.search(r"date:(\d+)", script_text) + for source in match["sources"]: + yield scrapy.Request( + self.stream_url_template.format( + source=source["source"], id=source["id"] + ), + self.parse_stream, + meta={"item": item}, + ) - if timestamp_match: - # Extract the timestamp and convert to UTC datetime - event_timestamp_ms = int(timestamp_match.group(1)) - event_start_timestamp = event_timestamp_ms / 1000 + def parse_stream(self, response): + item = response.meta["item"].copy() + stream_data_list = response.json() - if event_start_timestamp != 0: - event_start_time = datetime.fromtimestamp(event_start_timestamp).strftime( - "%I:%M%p GMT" - ) - description = f'{response.meta["item"]["title"]} - {event_start_time}' + for stream_data in stream_data_list: + if self.domains and self.domain_host: + yield self.create_stream_item(stream_data, item) + else: + yield scrapy.Request( + stream_data["embedUrl"], + self.parse_embed, + meta={"item": item, "stream_data": stream_data}, + headers={"Referer": self.mediafusion_referer}, + ) + + def parse_embed(self, response): + item = response.meta["item"] + stream_data = response.meta["stream_data"] + + if self.domains is None or self.domain_host is None: + self.extract_domain_info(response) + + if self.domains and self.domain_host: + yield self.create_stream_item(stream_data, item) else: - description = response.meta["item"]["title"] - - # If no timer, proceed to scrape available stream links - stream_links = response.xpath('//a[contains(@href, "/watch/")]') - if not stream_links: - # No streams available atm - self.logger.info(f"No streams available for this event yet. {response.url}") - return - - for link in stream_links: - stream_name = link.xpath(".//h1/text()").get().strip() - stream_url = link.xpath(".//@href").get() - stream_quality = link.xpath(".//h2/text()").get().strip() - language = link.xpath(".//div[last()]/text()").get().strip() - - m3u8_url = f"{self.m3u8_base_url}{stream_url.replace('/watch', '').replace('/alpha', '')}/playlist.m3u8" - item = response.meta["item"].copy() - item.update( - { - "stream_name": f"{stream_name}\nšŗ {stream_quality} - š {language}", - "stream_url": m3u8_url, - "referer": self.mediafusion_referer, - "description": description, - "event_start_timestamp": event_start_timestamp, - } + self.logger.error( + f"Failed to extract domain information for stream: {stream_data['id']}" ) - yield item + + def extract_domain_info(self, response): + script_content = response.xpath( + '//script[contains(text(), "var k=")]/text()' + ).get() + if script_content: + vars_match = re.search( + r'var k="(\w+)",i="([^"]+)",s="(\d+)",l=(\[.+?\]),h="([^"]+)";', + script_content, + ) + if vars_match: + _, _, _, domains, domain_host = vars_match.groups() + self.domains = json.loads(domains) + self.domain_host = domain_host + else: + self.logger.warning( + "Failed to extract domain variables from script content." + ) + else: + self.logger.warning( + "Failed to find script content with domain variables in response." + ) + + def create_stream_item(self, stream_data, item): + m3u8_url = self.m3u8_base_url.format( + domain=f"{random.choice(self.domains)}.{self.domain_host}", + source=stream_data["source"], + id=stream_data["id"], + stream_no=stream_data["streamNo"], + ) + + stream_item = item.copy() + stream_item.update( + { + "stream_name": f"{'HD' if stream_data['hd'] else 'SD'} - š {stream_data['language']}\n" + f"š {stream_data['source'].title()} Stream {stream_data['streamNo']}", + "stream_url": m3u8_url, + "referer": self.mediafusion_referer, + "description": f"{item['title']} - {datetime.fromtimestamp(item['event_start_timestamp'], tz=pytz.utc).strftime('%I:%M%p GMT')}", + } + ) + + return stream_item diff --git a/mediafusion_scrapy/spiders/tamilbulb.py b/mediafusion_scrapy/spiders/tamilbulb.py new file mode 100644 index 0000000..1eec8d9 --- /dev/null +++ b/mediafusion_scrapy/spiders/tamilbulb.py @@ -0,0 +1,7 @@ +from mediafusion_scrapy.spiders.live_tv import LiveTVSpider + + +class TamilBulbSpider(LiveTVSpider): + name = "tamilbulb" + start_urls = ["https://tamilbulb.tv/"] + use_flaresolverr = True diff --git a/mediafusion_scrapy/spiders/tamilultra.py b/mediafusion_scrapy/spiders/tamilultra.py index bf77f64..5696893 100644 --- a/mediafusion_scrapy/spiders/tamilultra.py +++ b/mediafusion_scrapy/spiders/tamilultra.py @@ -1,74 +1,6 @@ -from urllib.parse import urljoin, urlparse - -import scrapy - from mediafusion_scrapy.spiders.live_tv import LiveTVSpider class TamilUltraSpider(LiveTVSpider): name = "tamilultra" start_urls = ["https://tamilultra.tv/"] - - def extract_player_api_base(self, response): - """Extracts the admin-ajax URL for POST requests.""" - # Directly extract the URL used for admin-ajax POST requests - admin_ajax_url = response.xpath( - "//script[contains(text(), 'player_api')]/text()" - ).re_first(r'"url":"([^"]+)"') - if admin_ajax_url: - # Correctly format and return the full URL - admin_ajax_full_url = urljoin( - response.url, admin_ajax_url.replace("\\/", "/") - ) - return admin_ajax_full_url - else: - self.logger.error("Admin AJAX URL not found for TamilUltra.") - return None - - def process_player_option(self, element, channel_data, player_api_post_url): - """Processes each player option element to send a POST request.""" - stream_title, country_name = self.extract_stream_details(element) - data_post, data_nume, data_type = ( - element.attrib.get("data-post"), - element.attrib.get("data-nume"), - element.attrib.get("data-type"), - ) - - if all([data_post, data_nume, data_type]): - form_data = { - "action": "doo_player_ajax", - "post": data_post, - "nume": data_nume, - "type": data_type, - } - yield scrapy.FormRequest( - url=player_api_post_url, - formdata=form_data, - callback=self.parse_api_response, - meta={ - "channel_data": channel_data, - "stream_title": stream_title, - "country_name": country_name, - }, - ) - - def extract_m3u8_urls(self, response): - """Extracts M3U8 URLs using direct and fallback regex patterns.""" - query_string = urlparse(response.url).query - m3u8_urls = [urljoin(response.url, query_string)] - - user_agent = response.request.headers.get("User-Agent").decode() - parsed_url = urlparse(response.url) - referer = f"{parsed_url.scheme}://{parsed_url.netloc}" - - behavior_hints = { - "notWebReady": True, - "proxyHeaders": { - "request": { - "User-Agent": user_agent, - "Referer": referer, - } - }, - } - - return m3u8_urls, behavior_hints diff --git a/resources/exceptions/mediaflow_proxy_required.mp4 b/resources/exceptions/mediaflow_proxy_required.mp4 new file mode 100644 index 0000000..a2a45c8 Binary files /dev/null and b/resources/exceptions/mediaflow_proxy_required.mp4 differ diff --git a/resources/js/scraperControl.js b/resources/js/scraperControl.js index 38bbf91..0994221 100644 --- a/resources/js/scraperControl.js +++ b/resources/js/scraperControl.js @@ -57,7 +57,7 @@ function addStreamInput() {