From 709dc25d94e7e744b1811d5a1fadbe7a46c54dc7 Mon Sep 17 00:00:00 2001 From: Mohamed Zumair Date: Fri, 27 Sep 2024 08:13:10 +0530 Subject: [PATCH] Enhance TV streaming functionality with MPD DRM support, and updated spiders & Bugfixes (#298) * Refactor stream parsing and handling for DRM and MediaFlow * Add encryption support for mediaflow url and refactor URL validation * Enhance LiveTV spider to support MPD and handle new player API logic * Update TV stream validation and add DRM fields in UI * Add mediaflow_proxy_required.mp4 exception video * Add FlareSolverr integration to scrapy spiders * [WIP] TamilBulb spider to scraper * Update StreamedSpider to new API and adjust schedulers --- db/config.py | 3 +- db/crud.py | 97 ++++--- db/models.py | 33 ++- db/schemas.py | 9 +- mediafusion_scrapy/middlewares.py | 113 +++++++- .../live_stream_resolver_pipeline.py | 2 +- mediafusion_scrapy/settings.py | 5 + mediafusion_scrapy/spiders/live_tv.py | 259 ++++++++++++++---- mediafusion_scrapy/spiders/streamed.py | 223 +++++++++------ mediafusion_scrapy/spiders/tamilbulb.py | 7 + mediafusion_scrapy/spiders/tamilultra.py | 68 ----- .../exceptions/mediaflow_proxy_required.mp4 | Bin 0 -> 101139 bytes resources/js/scraperControl.js | 12 +- resources/json/sports_artifacts.json | 13 + resources/manifest.json | 25 +- scrapers/tv.py | 76 ++++- utils/const.py | 3 +- utils/crypto.py | 19 ++ utils/network.py | 17 +- utils/parser.py | 144 +++++++--- utils/validation_helper.py | 31 ++- 21 files changed, 826 insertions(+), 333 deletions(-) create mode 100644 mediafusion_scrapy/spiders/tamilbulb.py create mode 100644 resources/exceptions/mediaflow_proxy_required.mp4 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 0000000000000000000000000000000000000000..a2a45c808dec8366bf01ceb5274058e338648905 GIT binary patch literal 101139 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 literal 0 HcmV?d00001 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() {
- +
@@ -76,6 +76,14 @@ function addStreamInput() {
+
+ + +
+
+ + +
@@ -266,6 +274,8 @@ function constructTvMetadata() { ytId: document.getElementById(`streamYtId-${index}`).value.trim(), source: document.getElementById(`streamSource-${index}`).value.trim(), country: document.getElementById(`streamCountry-${index}`).value.trim(), + drm_key_id: document.getElementById(`streamDrmKeyId-${index}`).value.trim(), + drm_key: document.getElementById(`streamDrmKey-${index}`).value.trim(), behaviorHints: { proxyHeaders: document.getElementById(`streamProxyHeaders-${index}`) ? JSON.parse(document.getElementById(`streamProxyHeaders-${index}`).value || "{}") : {}, notWebReady: true diff --git a/resources/json/sports_artifacts.json b/resources/json/sports_artifacts.json index d252454..6376789 100644 --- a/resources/json/sports_artifacts.json +++ b/resources/json/sports_artifacts.json @@ -88,6 +88,19 @@ "https://external-content.duckduckgo.com/iu/?u=http%3A//cliparts.co/cliparts/Aib/Kr4/AibKr4gGT.png&f=1&nofb=1" ] }, + "Billiards": { + "background": [ + "https://external-content.duckduckgo.com/iu/?u=http%3A%2F%2Feskipaper.com%2Fimages%2Fbilliards-15.jpg&f=1&nofb=1&ipt=8b9307b8db760ec15d07a6ed66717495b73db55a0dfcc4bd688a901dcff463ed&ipo=images" + ], + "poster": [ + "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fak.picdn.net%2Fshutterstock%2Fvideos%2F9725927%2Fthumb%2F1.jpg%3Fip%3Dx480&f=1&nofb=1&ipt=36b55f7a9456378df6799f8f9edab09a45cb9c8ad7670353c30c3c10049b01c1&ipo=images", + "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fwallpapercave.com%2Fwp%2Fwp3177240.jpg&f=1&nofb=1&ipt=5dc29a21348a01f83b30f5b67ad204fbd1371b3109cf69eaa90e0803ba7c4f6d&ipo=images" + ], + "logo": [ + "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fstatic.vecteezy.com%2Fsystem%2Fresources%2Fthumbnails%2F009%2F730%2F725%2Fsmall%2Fbilliards-championship-sports-badge-design-logo-and-simple-text-billiard-room-or-pool-club-and-team-billiard-ball-icon-symbol-template-vector.jpg&f=1&nofb=1&ipt=e7e888ffd1287f424e7cd3d05bd0bce1a8dbd2f4af4cfd2d82c4a51e8875d519&ipo=images", + "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse1.mm.bing.net%2Fth%3Fid%3DOIP.D6r9x55zsgJtybnoheuHZAAAAA%26pid%3DApi&f=1&ipt=9ab2e7fd4ce8760177a69b6d493646f9142b3657b899685fed8d36f8b230b616&ipo=images" + ] + }, "Bowling": { "background": [ "https://i.pinimg.com/736x/fa/2d/21/fa2d21f56d7c44e03760a3db74a306e6.jpg" diff --git a/resources/manifest.json b/resources/manifest.json index 3c04768..1efa49c 100644 --- a/resources/manifest.json +++ b/resources/manifest.json @@ -1403,20 +1403,21 @@ "name": "genre", "isRequired": false, "options": [ - "American Football", - "Basketball", - "Baseball", - "Cricket", - "Football", - "Fighting", - "Hockey", - "Tennis", - "Rugby", - "Golf", - "Dart", "Afl", + "American Football", + "Baseball", + "Basketball", + "Billiards", + "Cricket", + "Dart", + "Fighting", + "Football", + "Golf", + "Hockey", "Motor Sport", - "Other Sports" + "Other Sports", + "Rugby", + "Tennis" ] } ] diff --git a/scrapers/tv.py b/scrapers/tv.py index fd014c5..51eefac 100644 --- a/scrapers/tv.py +++ b/scrapers/tv.py @@ -1,18 +1,21 @@ import asyncio +import difflib import logging import re import dramatiq +import httpx from beanie import BulkWriter +from beanie.odm.operators.update.general import Set from ipytv import playlist from ipytv.channel import IPTVAttr from db import schemas, crud -from db.models import TVStreams +from db.models import TVStreams, MediaFusionTVMetaData from utils import validation_helper from utils.parser import is_contain_18_plus_keywords from utils.runtime_const import REDIS_ASYNC_CLIENT -from utils.validation_helper import validate_m3u8_url +from utils.validation_helper import validate_live_stream_url async def add_tv_metadata(batch, namespace: str): @@ -133,23 +136,25 @@ async def validate_tv_streams_in_db(page=0, page_size=25, *args, **kwargs): logging.info(f"No TV streams to validate on page {page}") return - async def validate_and_update_tv_stream(stream, bulk_writer): - is_valid = await validate_m3u8_url(stream.url, stream.behaviorHints or {}) + async def validate_and_update_tv_stream(stream, bw): + is_valid = await validate_live_stream_url( + stream.url, stream.behaviorHints or {} + ) logging.info(f"Stream: {stream.name}, Status: {is_valid}") if is_valid: stream.is_working = is_valid stream.test_failure_count = 0 - await stream.replace(bulk_writer=bulk_writer) + await stream.replace(bulk_writer=bw) return stream.test_failure_count += 1 if stream.test_failure_count >= 3: - await stream.delete(bulk_writer=bulk_writer) + await stream.delete(bulk_writer=bw) logging.error(f"{stream.name} has failed 3 times and deleting it.") return stream.is_working = is_valid - await stream.replace(bulk_writer=bulk_writer) + await stream.replace(bulk_writer=bw) logging.error(f"Stream failed validation: {stream.name}") bulk_writer = BulkWriter() @@ -166,3 +171,60 @@ async def validate_tv_streams_in_db(page=0, page_size=25, *args, **kwargs): validate_tv_streams_in_db.send_with_options( args=(page + 1, page_size), delay=2 * 6000 ) + + +@dramatiq.actor(time_limit=30 * 60 * 1000, priority=5) +async def update_tv_posters_in_db(*args, **kwargs): + """Validate TV posters in the database.""" + not_working_posters = await MediaFusionTVMetaData.find( + MediaFusionTVMetaData.is_poster_working == False + ).to_list() + logging.info(f"Found {len(not_working_posters)} TV posters to update.") + + if not not_working_posters: + logging.info("No TV posters to update.") + return + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://iptv-org.github.io/api/channels.json", timeout=30 + ) + iptv_channels = response.json() + + iptv_org_channel_data = {} + for channel in iptv_channels: + iptv_org_channel_data[channel["name"].casefold()] = channel + if channel.get("alt_names"): + for alt_name in channel["alt_names"]: + iptv_org_channel_data[alt_name.casefold()] = channel + iptv_org_channel_names = iptv_org_channel_data.keys() + + def get_similar_channel_name(name, cutoff=0.8): + name = name.casefold() + name = re.sub(r"\s*\[.*?]|\s*\(.*?\)", "", name) + name = name.split(" – ")[0] + matches = difflib.get_close_matches( + name, iptv_org_channel_names, n=1, cutoff=cutoff + ) + return matches[0] if matches else None + + bulk_writer = BulkWriter() + for stream in not_working_posters: + iptv_org_channel_name = get_similar_channel_name(stream.title, cutoff=0.8) + if not iptv_org_channel_name: + logging.error(f"Channel not found in iptv-org: {stream.title}") + continue + + iptv_org_channel = iptv_org_channel_data[iptv_org_channel_name] + poster = iptv_org_channel.get("logo") + if not poster: + logging.error(f"Poster not found for channel: {stream.title}") + continue + + await stream.update( + Set({"poster": poster, "is_poster_working": True}), bulk_writer=bulk_writer + ) + + logging.info(f"Committing {len(bulk_writer.operations)} updates to the database") + await bulk_writer.commit() + logging.info("Updated TV posters in the database.") diff --git a/utils/const.py b/utils/const.py index 03c5eec..5a63504 100644 --- a/utils/const.py +++ b/utils/const.py @@ -147,11 +147,12 @@ UA_HEADER = { } -M3U8_VALID_CONTENT_TYPES = [ +IPTV_VALID_CONTENT_TYPES = [ "application/vnd.apple.mpegurl", "application/x-mpegurl", "video/mp2t", "application/octet-stream", + "application/dash+xml", ] SCRAPY_SPIDERS = { diff --git a/utils/crypto.py b/utils/crypto.py index 8b1e00b..1b57ac8 100644 --- a/utils/crypto.py +++ b/utils/crypto.py @@ -1,9 +1,14 @@ +import base64 import hashlib +import json +import time + import zlib from base64 import urlsafe_b64encode, urlsafe_b64decode from Crypto.Cipher import AES from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad from db.schemas import UserData from utils.runtime_const import SECRET_KEY @@ -45,3 +50,17 @@ def decrypt_user_data(secret_str: str | None = None) -> UserData: def get_text_hash(text: str, full_hash: bool = False) -> str: hash_str = hashlib.sha256(text.encode()).hexdigest() return hash_str if full_hash else hash_str[:10] + + +def encrypt_data( + secret_key: str, data: dict, expiration: int = None, ip: str = None +) -> str: + if expiration: + data["exp"] = int(time.time()) + expiration + if ip: + data["ip"] = ip + json_data = json.dumps(data).encode("utf-8") + iv = get_random_bytes(16) + cipher = AES.new(secret_key.encode("utf-8").ljust(32)[:32], AES.MODE_CBC, iv) + encrypted_data = cipher.encrypt(pad(json_data, AES.block_size)) + return base64.urlsafe_b64encode(iv + encrypted_data).decode("utf-8") diff --git a/utils/network.py b/utils/network.py index 643bd2c..70f3640 100644 --- a/utils/network.py +++ b/utils/network.py @@ -2,12 +2,14 @@ import asyncio import logging from typing import Callable, AsyncGenerator, Any, Tuple from urllib import parse +from urllib.parse import urlencode import httpx from fastapi.requests import Request from db.schemas import UserData from utils import crypto +from utils.crypto import encrypt_data from utils.runtime_const import PRIVATE_CIDR, REDIS_ASYNC_CLIENT @@ -253,6 +255,9 @@ def encode_mediaflow_proxy_url( query_params: dict | None = None, request_headers: dict | None = None, response_headers: dict | None = None, + encryption_api_password: str = None, + expiration: int = None, + ip: str = None, ) -> str: query_params = query_params or {} if destination_url is not None: @@ -267,8 +272,16 @@ def encode_mediaflow_proxy_url( query_params.update( {f"r_{key}": value for key, value in response_headers.items()} ) - # Encode the query parameters - encoded_params = parse.urlencode(query_params, quote_via=parse.quote) + + if encryption_api_password: + if "api_password" not in query_params: + query_params["api_password"] = encryption_api_password + encrypted_token = encrypt_data( + encryption_api_password, query_params, expiration, ip + ) + encoded_params = urlencode({"token": encrypted_token}) + else: + encoded_params = urlencode(query_params) # Construct the full URL base_url = parse.urljoin(mediaflow_proxy_url, endpoint) diff --git a/utils/parser.py b/utils/parser.py index 58097e9..f44403c 100644 --- a/utils/parser.py +++ b/utils/parser.py @@ -1,6 +1,8 @@ import asyncio import functools import logging +from typing import Optional, List + import math import re @@ -14,7 +16,7 @@ from utils import const from utils.const import STREAMING_PROVIDERS_SHORT_NAMES from utils.network import encode_mediaflow_proxy_url from utils.runtime_const import ADULT_CONTENT_KEYWORDS, TRACKERS -from utils.validation_helper import validate_m3u8_url_with_cache +from utils.validation_helper import validate_m3u8_or_mpd_url_with_cache async def filter_and_sort_streams( @@ -317,60 +319,124 @@ def convert_size_to_bytes(size_str: str) -> int: async def parse_tv_stream_data( - tv_streams: list[TVStreams], user_data: UserData -) -> list[Stream]: - stream_list = [] + tv_streams: List[TVStreams], user_data: UserData +) -> List[Stream]: is_mediaflow_proxy_enabled = ( user_data.mediaflow_config and user_data.mediaflow_config.proxy_live_streams ) addon_name = ( f"{settings.addon_name} {'šŸ•µšŸ¼ā€ā™‚ļø' if is_mediaflow_proxy_enabled else 'šŸ“”'}" ) - for stream in tv_streams[::-1]: - if settings.validate_m3u8_urls_liveness: - is_working = await validate_m3u8_url_with_cache( - stream.url, stream.behaviorHints or {} - ) - if not is_working: - continue - if is_mediaflow_proxy_enabled: - stream.url = encode_mediaflow_proxy_url( - user_data.mediaflow_config.proxy_url, - "/proxy/hls", - stream.url, - request_headers=stream.behaviorHints.get("proxyHeaders", {}).get( - "request", {} - ), - query_params={"api_password": user_data.mediaflow_config.api_password}, - ) - stream.behaviorHints.update({"proxyHeaders": {}}) + stream_processor = functools.partial( + process_stream, + is_mediaflow_proxy_enabled=is_mediaflow_proxy_enabled, + mediaflow_config=user_data.mediaflow_config, + addon_name=addon_name, + ) - country_info = f"\n🌐 {stream.country}" if stream.country else "" + processed_streams = await asyncio.gather( + *[stream_processor(stream) for stream in reversed(tv_streams)] + ) - stream_list.append( - Stream( - name=addon_name, - description=f"šŸ“ŗ {stream.name}{country_info}\nšŸ”— {stream.source}", - url=stream.url, - ytId=stream.ytId, - behaviorHints=stream.behaviorHints, - ) - ) + stream_list = [] + is_mediaflow_needed = False + + for result in processed_streams: + if result: + if isinstance(result, Stream): + stream_list.append(result) + elif result == "MEDIAFLOW_NEEDED": + is_mediaflow_needed = True if not stream_list: - stream_list.append( - Stream( - name=settings.addon_name, - description="🚫 No streams are live at the moment.", - url=f"{settings.host_url}/static/exceptions/no_streams_live.mp4", - behaviorHints={"notWebReady": True}, + if is_mediaflow_needed: + stream_list.append( + create_exception_stream( + addon_name, + "🚫 MediaFlow Proxy is required to watch this stream.", + "mediaflow_proxy_required.mp4", + ) + ) + else: + stream_list.append( + create_exception_stream( + addon_name, + "🚫 No streams are live at the moment.", + "no_streams_live.mp4", + ) ) - ) return stream_list +async def process_stream( + stream: TVStreams, + is_mediaflow_proxy_enabled: bool, + mediaflow_config, + addon_name: str, +) -> Optional[Stream | str]: + if settings.validate_m3u8_urls_liveness: + is_working = await validate_m3u8_or_mpd_url_with_cache( + stream.url, stream.behaviorHints or {} + ) + if not is_working: + return None + + stream_url, behavior_hints = stream.url, stream.behaviorHints + behavior_hints = behavior_hints if behavior_hints else {} + + if stream.drm_key or "dlhd" in stream.source: + if not is_mediaflow_proxy_enabled: + return "MEDIAFLOW_NEEDED" + stream_url = get_proxy_url(stream, mediaflow_config) + behavior_hints["proxyHeaders"] = None + elif is_mediaflow_proxy_enabled: + stream_url = get_proxy_url(stream, mediaflow_config) + behavior_hints["proxyHeaders"] = None + + country_info = f"\n🌐 {stream.country}" if stream.country else "" + + return Stream( + name=addon_name, + description=f"šŸ“ŗ {stream.name}{country_info}\nšŸ”— {stream.source}", + url=stream_url, + ytId=stream.ytId, + behaviorHints=behavior_hints, + ) + + +def get_proxy_url(stream: TVStreams, mediaflow_config) -> str: + endpoint = ( + "/proxy/mpd/manifest.m3u8" if stream.drm_key else "/proxy/hls/manifest.m3u8" + ) + query_params = {} + if stream.drm_key: + query_params = {"key_id": stream.drm_key_id, "key": stream.drm_key} + elif "dlhd" in stream.source: + query_params = {"use_request_proxy": False} + + return encode_mediaflow_proxy_url( + mediaflow_config.proxy_url, + endpoint, + stream.url, + query_params=query_params, + request_headers=stream.behaviorHints.get("proxyHeaders", {}).get("request", {}), + encryption_api_password=mediaflow_config.api_password, + ) + + +def create_exception_stream( + addon_name: str, description: str, exc_file_name: str +) -> Stream: + return Stream( + name=addon_name, + description=description, + url=f"{settings.host_url}/static/exceptions/{exc_file_name}", + behaviorHints={"notWebReady": True}, + ) + + async def fetch_downloaded_info_hashes( user_data: UserData, user_ip: str | None ) -> list[str]: diff --git a/utils/validation_helper.py b/utils/validation_helper.py index cb470c1..f113aa1 100644 --- a/utils/validation_helper.py +++ b/utils/validation_helper.py @@ -34,7 +34,7 @@ async def validate_image_url(url: str) -> bool: return is_valid_url(url) and await does_url_exist(url) -async def validate_m3u8_url( +async def validate_live_stream_url( url: str, behaviour_hint: dict, validate_url: bool = False ) -> bool: if validate_url and not is_valid_url(url): @@ -47,26 +47,29 @@ async def validate_m3u8_url( url, allow_redirects=True, headers=headers, - timeout=aiohttp.ClientTimeout(total=30), + timeout=aiohttp.ClientTimeout(total=20), ) as response: - content_type = response.headers.get("Content-Type", "").lower() - - is_valid = content_type in const.M3U8_VALID_CONTENT_TYPES + content_type = response.headers.get("content-type", "").lower() + is_valid = content_type in const.IPTV_VALID_CONTENT_TYPES return is_valid except (ClientError, asyncio.TimeoutError) as err: logging.error(err) return False -async def validate_m3u8_url_with_cache(url: str, behaviour_hint: dict): - cache_key = f"m3u8_url:{parse.urlparse(url).netloc}" - cache_data = await REDIS_ASYNC_CLIENT.get(cache_key) - if cache_data: - return json.loads(cache_data) +async def validate_m3u8_or_mpd_url_with_cache(url: str, behaviour_hint: dict): + try: + cache_key = f"m3u8_url:{parse.urlparse(url).netloc}" + cache_data = await REDIS_ASYNC_CLIENT.get(cache_key) + if cache_data: + return json.loads(cache_data) - is_valid = await validate_m3u8_url(url, behaviour_hint) - await REDIS_ASYNC_CLIENT.set(cache_key, json.dumps(is_valid), ex=180) - return is_valid + is_valid = await validate_live_stream_url(url, behaviour_hint) + await REDIS_ASYNC_CLIENT.set(cache_key, json.dumps(is_valid), ex=180) + return is_valid + except Exception as e: + logging.exception(e) + return False class ValidationError(Exception): @@ -91,7 +94,7 @@ async def validate_tv_metadata(metadata: schemas.TVMetaData) -> list[schemas.TVS for stream in metadata.streams: if stream.url: stream_validation_tasks.append( - validate_m3u8_url( + validate_live_stream_url( stream.url, ( stream.behaviorHints.model_dump(exclude_none=True)