import asyncio import json import logging from urllib.parse import urlparse, urljoin import httpx from fastapi import Depends, HTTPException from fastapi.security import APIKeyHeader from db import schemas from db.config import settings from utils import const from utils.network import is_private_ip from db.redis_database import REDIS_ASYNC_CLIENT # API Key Header for authentication API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) async def api_password_dependency(api_key: str = Depends(API_KEY_HEADER)): """Validate the API password from header or throw exception""" if not settings.api_password: return "no_password_required" if api_key != settings.api_password: raise HTTPException( status_code=401, detail="Invalid API key", headers={"WWW-Authenticate": "APIKey"}, ) return api_key def is_valid_url(url: str) -> bool: parsed_url = urlparse(url) return all([parsed_url.scheme, parsed_url.netloc]) async def does_url_exist(url: str) -> bool: async with httpx.AsyncClient(proxy=settings.requests_proxy_url) as client: try: response = await client.head( url, timeout=10, headers=const.UA_HEADER, follow_redirects=True ) response.raise_for_status() return response.status_code == 200 except httpx.HTTPStatusError as err: logging.error("URL: %s, Status: %s", url, err.response.status_code) return False except (httpx.RequestError, httpx.TimeoutException) as err: logging.error("URL: %s, Status: %s", url, err) return False async def validate_image_url(url: str) -> bool: return is_valid_url(url) and await does_url_exist(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): return False headers = behaviour_hint.get("proxyHeaders", {}).get("request", {}) async with httpx.AsyncClient(proxy=settings.requests_proxy_url) as client: try: response = await client.head( url, timeout=10, headers=headers, follow_redirects=True ) response.raise_for_status() content_type = ( behaviour_hint.get("proxyHeaders", {}) .get("response", {}) .get("Content-Type", response.headers.get("content-type", "").lower()) ) is_valid = content_type in const.IPTV_VALID_CONTENT_TYPES return is_valid except ( httpx.RequestError, httpx.TimeoutException, httpx.HTTPStatusError, ) as err: logging.error(err) return False except Exception as e: logging.exception(e) return False async def validate_m3u8_or_mpd_url_with_cache(url: str, behaviour_hint: dict): try: cache_key = f"m3u8_url:{url}" cache_data = await REDIS_ASYNC_CLIENT.get(cache_key) if cache_data: return json.loads(cache_data) is_valid = await validate_live_stream_url(url, behaviour_hint) await REDIS_ASYNC_CLIENT.set(cache_key, json.dumps(is_valid), ex=300) return is_valid except Exception as e: logging.exception(e) return False class ValidationError(Exception): pass async def validate_yt_id(yt_id: str) -> bool: image_url = f"https://img.youtube.com/vi/{yt_id}/mqdefault.jpg" async with httpx.AsyncClient() as client: try: response = await client.head(image_url, timeout=10, follow_redirects=True) response.raise_for_status() return response.status_code == 200 except (httpx.HTTPStatusError, httpx.TimeoutException, Exception): return False async def validate_tv_metadata(metadata: schemas.TVMetaData) -> list[schemas.TVStreams]: # Prepare validation tasks for streams stream_validation_tasks = [] for stream in metadata.streams: if stream.url: stream_validation_tasks.append( validate_live_stream_url( stream.url, ( stream.behaviorHints.model_dump(exclude_none=True) if stream.behaviorHints else {} ), validate_url=True, ) ) elif stream.ytId: stream_validation_tasks.append(validate_yt_id(stream.ytId)) # Run all stream URL validations concurrently stream_validation_results = await asyncio.gather(*stream_validation_tasks) # Filter out valid streams based on the validation results valid_streams = [] for i, is_valid in enumerate(stream_validation_results): if is_valid: stream = metadata.streams[i] valid_streams.append(stream) if not valid_streams: raise ValidationError("Invalid stream URLs provided.") # Deduplicate streams based on URL or YT ID unique_streams = {stream.url or stream.ytId: stream for stream in valid_streams} return list(unique_streams.values()) def is_video_file(filename: str) -> bool: """ Fast check if a filename is a playable video format supported by modern media players (VLC, MPV, etc.) Covers all modern containers and formats including - Modern streaming formats (HLS, DASH) - High-efficiency containers (MKV, MP4, WebM) - Professional formats that are commonly playable - Network streaming formats Args: filename: URL or filename to check Returns: bool: True if the filename is a playable video format """ return filename.lower().endswith( ( # Modern Containers (most common first) ".mp4", # MPEG-4 Part 14 ".mkv", # Matroska ".webm", # WebM ".m4v", # MPEG-4 ".mov", # QuickTime # Streaming Formats ".m3u8", # HLS ".m3u", # Playlist ".mpd", # DASH # MPEG Transport Streams ".ts", # Transport Stream ".mts", # MPEG Transport Stream ".m2ts", # Blu-ray Transport Stream ".m2t", # MPEG-2 Transport Stream # MPEG Program Streams ".mpeg", # MPEG Program Stream ".mpg", # MPEG Program Stream ".mp2", # MPEG Program Stream ".m2v", # MPEG-2 Video ".m4p", # Protected MPEG-4 Part 14 # Common Legacy Formats (still widely supported) ".avi", # Audio Video Interleave ".wmv", # Windows Media Video ".flv", # Flash Video ".f4v", # Flash MP4 Video ".ogv", # Ogg Video ".ogm", # Ogg Media ".rm", # RealMedia ".rmvb", # RealMedia Variable Bitrate ".asf", # Advanced Systems Format ".divx", # DivX Video # Mobile Formats ".3gp", # 3GPP ".3g2", # 3GPP2 # DVD/Blu-ray Formats ".vob", # DVD Video Object ".ifo", # DVD Information ".bdmv", # Blu-ray Movie # Modern High-Efficiency Formats ".hevc", # High Efficiency Video Coding ".av1", # AOMedia Video 1 ".vp8", # WebM VP8 ".vp9", # WebM VP9 # Additional Modern Formats ".mxf", # Material eXchange Format (broadcast) ".dav", # DVR365 Format ".swf", # Shockwave Flash (contains video) # Network Streaming ".nsv", # Nullsoft Streaming Video ".strm", # Stream file # Additional Container Formats ".mvi", # Motion Video Interface ".vid", # Generic video file ".amv", # Anime Music Video ".m4s", # MPEG-DASH Segment ".mqv", # Sony Movie Format ".nuv", # NuppelVideo ".wtv", # Windows Recorded TV Show ".dvr-ms", # Microsoft Digital Video Recording # Playlist Formats ".pls", # Playlist File ".cue", # Cue Sheet # Modern Streaming Service Formats ".dash", # DASH ".hls", # HLS Alternative ".ismv", # Smooth Streaming ".m4f", # Protected MPEG-4 Fragment ".mp4v", # MPEG-4 Video # Animation Formats (playable in video players) ".gif", # Graphics Interchange Format ".gifv", # Imgur Video Alternative ".apng", # Animated PNG ) ) def get_filter_certification_values(user_data: schemas.UserData) -> list[str]: certification_values = [] for category in user_data.certification_filter: certification_values.extend(const.CERTIFICATION_MAPPING.get(category, [])) return certification_values def validate_parent_guide_nudity(metadata, user_data: schemas.UserData) -> bool: """ Validate if the metadata has adult content based on parent guide nudity status and certifications. Returns False if the content should be filtered out based on user preferences. """ # Strict policy for adult genres. if metadata.genres and "Adult" in metadata.genres: return False # Skip validation if filters are disabled if ( "Disable" in user_data.nudity_filter and "Disable" in user_data.certification_filter ): return True # Check nudity status filter if "Disable" not in user_data.nudity_filter: if metadata.parent_guide_nudity_status in user_data.nudity_filter: return False # Check certification filter if "Disable" not in user_data.certification_filter: filter_certification_values = get_filter_certification_values(user_data) if "Unknown" in user_data.certification_filter: # Filter out if certifications list is empty or doesn't exist if not metadata.parent_guide_certificates: return False if metadata.parent_guide_certificates and any( certificate in filter_certification_values for certificate in metadata.parent_guide_certificates ): return False return True async def validate_service( url: str, params: dict = None, success_message: str = None, invalid_creds_message: str = None, ) -> dict: async with httpx.AsyncClient() as client: try: response = await client.get(url, params=params, timeout=10) response.raise_for_status() return { "status": "success", "message": success_message or "Validation successful.", } except httpx.HTTPStatusError as err: if err.response.status_code == 403 and invalid_creds_message: return {"status": "error", "message": invalid_creds_message} return { "status": "error", "message": f"HTTPStatusError: Failed to validate service at {url}: {err}", } except httpx.RequestError as err: logging.error("Validation error for service at %s: %s", url, err) return { "status": "error", "message": f"RequestError: Failed to validate service at {url}: {err}", } except Exception as err: logging.error("Validation error for service at %s: %s", url, err) return { "status": "error", "message": f"Failed to validate service at {url}: {err}", } async def validate_mediaflow_proxy_credentials(user_data: schemas.UserData) -> dict: if not user_data.mediaflow_config: return {"status": "success", "message": "Mediaflow Proxy is not set."} validation_url = urljoin(user_data.mediaflow_config.proxy_url, "/proxy/ip") params = {"api_password": user_data.mediaflow_config.api_password} results = await validate_service( url=validation_url, params=params, invalid_creds_message="Invalid Mediaflow Proxy API Password. Please check your Mediaflow Proxy API Password.", ) if results["status"] == "success": return results if results["message"].startswith("RequestError"): parsed_url = urlparse(user_data.mediaflow_config.proxy_url) if is_private_ip(parsed_url.netloc): # MediaFlow proxy URL is a private IP address return { "status": "success", "message": "Mediaflow Proxy URL is a private IP address.", } return results async def validate_rpdb_token(user_data: schemas.UserData) -> dict: if not user_data.rpdb_config: return {"status": "success", "message": "RPDB is not enabled."} validation_url = ( f"https://api.ratingposterdb.com/{user_data.rpdb_config.api_key}/isValid" ) return await validate_service( url=validation_url, invalid_creds_message="Invalid RPDB API Key. Please check your RPDB API Key.", ) async def validate_mdblist_token(user_data: schemas.UserData) -> dict: if not user_data.mdblist_config: return {"status": "success", "message": "MDBList is not enabled."} validation_url = ( f"https://api.mdblist.com/user?apikey={user_data.mdblist_config.api_key}" ) return await validate_service( url=validation_url, invalid_creds_message="Invalid MDBList API Key. Please check your MDBList API Key.", )