mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-24 01:52:29 +02:00
Add m3u playlists
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# streaming_providers/providers/m3u/__init__.py
|
||||
"""
|
||||
M3U Playlist Provider
|
||||
|
||||
A universal provider that reads local M3U/M3U8 playlist files
|
||||
to extract channel information, manifests, and DRM configuration.
|
||||
|
||||
Features:
|
||||
- Extended M3U attribute parsing (TVG, custom, DRM)
|
||||
- Support for DASH (.mpd) and HLS (.m3u8) formats
|
||||
- Kodi/VLC properties support
|
||||
- Auto-detection of content type, quality, radio channels
|
||||
- Caching with TTL and file modification tracking
|
||||
"""
|
||||
|
||||
from .provider import M3UProvider
|
||||
|
||||
__all__ = ["M3UProvider"]
|
||||
|
||||
# Provider metadata for registration
|
||||
PROVIDER_METADATA = {
|
||||
"name": "m3u",
|
||||
"label": "M3U Playlist",
|
||||
"description": "Universal M3U/M3U8 playlist parser with DRM support",
|
||||
"supports_countries": ["*"], # All countries
|
||||
"requires_auth": False,
|
||||
"supports_live": True,
|
||||
"supports_vod": True,
|
||||
"supports_radio": True,
|
||||
"supports_drm": True,
|
||||
"supports_catchup": False,
|
||||
"supports_epg": False,
|
||||
"formats": ["dash", "hls"],
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
# streaming_providers/providers/m3u/constants.py
|
||||
# ============================================================================
|
||||
# M3U Provider Configuration
|
||||
# ============================================================================
|
||||
|
||||
# M3U provider logo (generic playlist icon)
|
||||
M3U_LOGO = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Playlist_icon.svg/240px-Playlist_icon.svg.png"
|
||||
|
||||
# ============================================================================
|
||||
# Environment Variables
|
||||
# ============================================================================
|
||||
|
||||
# Environment variable for M3U playlists directory
|
||||
# Similar to DRM_PLUGINS_PATH, this can be set to specify where M3U files are located
|
||||
# Example: M3U_PLAYLISTS_PATH=/playlists
|
||||
ENV_M3U_PLAYLISTS_PATH = "M3U_PLAYLISTS_PATH"
|
||||
|
||||
# ============================================================================
|
||||
# File Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Supported file extensions
|
||||
SUPPORTED_EXTENSIONS = [".m3u", ".m3u8"]
|
||||
|
||||
# Default encoding for M3U files
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
|
||||
# Fallback encodings to try if default fails
|
||||
FALLBACK_ENCODINGS = ["latin-1", "iso-8859-1", "windows-1252"]
|
||||
|
||||
# ============================================================================
|
||||
# M3U Parsing Configuration
|
||||
# ============================================================================
|
||||
|
||||
# M3U header identifier
|
||||
M3U_HEADER = "#EXTM3U"
|
||||
|
||||
# Extended M3U info line identifier
|
||||
EXTINF_PREFIX = "#EXTINF:"
|
||||
|
||||
# Additional directive prefixes
|
||||
DIRECTIVE_PREFIXES = {
|
||||
"PLAYLIST": "#PLAYLIST:",
|
||||
"EXT_X_VERSION": "#EXT-X-VERSION:",
|
||||
"KODIPROP": "#KODIPROP:",
|
||||
"EXTVLCOPT": "#EXTVLCOPT:",
|
||||
"EXTGRP": "#EXTGRP:",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Attribute Mapping Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Standard TVG attributes in M3U files
|
||||
TVG_ATTRIBUTES = {
|
||||
"tvg-id": "channel_id",
|
||||
"tvg-name": "name",
|
||||
"tvg-logo": "logo_url",
|
||||
"tvg-language": "language",
|
||||
"tvg-country": "country",
|
||||
"tvg-url": "epg_url",
|
||||
"tvg-shift": "epg_shift",
|
||||
"group-title": "genre",
|
||||
"tvg-chno": "channel_number",
|
||||
}
|
||||
|
||||
# Additional custom attributes
|
||||
CUSTOM_ATTRIBUTES = {
|
||||
"channel-id": "channel_id",
|
||||
"logo": "logo_url",
|
||||
"country": "country",
|
||||
"language": "language",
|
||||
"group": "genre",
|
||||
"chno": "channel_number",
|
||||
}
|
||||
|
||||
# DRM-related attributes
|
||||
DRM_ATTRIBUTES = {
|
||||
"drm": "drm_system",
|
||||
"license-url": "license_url",
|
||||
"license-key": "license_key",
|
||||
"certificate-url": "certificate_url",
|
||||
"clearkey": "clearkey",
|
||||
}
|
||||
|
||||
# Kodi/VLC property mappings
|
||||
KODI_VLC_PROPERTIES = {
|
||||
"inputstream": "streaming_format",
|
||||
"inputstream.adaptive.license_type": "drm_system",
|
||||
"inputstream.adaptive.license_key": "license_url",
|
||||
"inputstream.adaptive.manifest_type": "streaming_format",
|
||||
"http-user-agent": "user_agent",
|
||||
"http-referrer": "referer",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Streaming Format Detection
|
||||
# ============================================================================
|
||||
|
||||
# Manifest format detection patterns
|
||||
MANIFEST_PATTERNS = {
|
||||
"dash": [".mpd", "dash"],
|
||||
"hls": [".m3u8", "hls", "m3u"],
|
||||
"smooth": [".ism", "smooth"],
|
||||
}
|
||||
|
||||
# Default streaming format
|
||||
DEFAULT_STREAMING_FORMAT = "dash"
|
||||
|
||||
# ============================================================================
|
||||
# Content Type Detection
|
||||
# ============================================================================
|
||||
|
||||
# Content type keywords for auto-detection
|
||||
CONTENT_TYPE_KEYWORDS = {
|
||||
"LIVE": ["live", "tv", "channel"],
|
||||
"VOD": ["vod", "movie", "film", "video"],
|
||||
"RADIO": ["radio", "audio", "musik", "music"],
|
||||
"SERIES": ["series", "show", "episode"],
|
||||
}
|
||||
|
||||
# Default content type
|
||||
DEFAULT_CONTENT_TYPE = "LIVE"
|
||||
|
||||
# ============================================================================
|
||||
# Quality Detection
|
||||
# ============================================================================
|
||||
|
||||
# Quality detection patterns in channel names
|
||||
QUALITY_PATTERNS = {
|
||||
"4K": ["4k", "uhd", "2160p"],
|
||||
"UHD": ["uhd", "ultra hd"],
|
||||
"HD": ["hd", "1080p", "720p", "high definition"],
|
||||
"SD": ["sd", "480p", "360p", "standard"],
|
||||
"AUDIO": ["radio", "audio only", "musik"],
|
||||
}
|
||||
|
||||
# Default quality
|
||||
DEFAULT_QUALITY = "HD"
|
||||
|
||||
# ============================================================================
|
||||
# Channel Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Default channel settings
|
||||
DEFAULT_CHANNEL_CONFIG = {
|
||||
"video": "best",
|
||||
"on_demand": True,
|
||||
"speed_up": True,
|
||||
"use_cdm": False, # Will be set to True if DRM detected
|
||||
"cdm_mode": "external",
|
||||
"session_manifest": False,
|
||||
"mode": "live",
|
||||
"content_type": "LIVE",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Caching Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Cache TTL in seconds (30 minutes default)
|
||||
DEFAULT_CACHE_TTL = 1800
|
||||
|
||||
# Enable auto-refresh when cache expires
|
||||
AUTO_REFRESH_ON_EXPIRE = True
|
||||
|
||||
# ============================================================================
|
||||
# Validation Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Minimum required fields for a valid channel entry
|
||||
REQUIRED_FIELDS = ["name", "manifest"]
|
||||
|
||||
# Skip channels without manifest URLs
|
||||
SKIP_INVALID_CHANNELS = True
|
||||
|
||||
# Log warnings for channels with missing optional fields
|
||||
WARN_MISSING_OPTIONAL_FIELDS = True
|
||||
|
||||
# ============================================================================
|
||||
# Default Values
|
||||
# ============================================================================
|
||||
|
||||
# Default country code
|
||||
DEFAULT_COUNTRY = "XX" # Unknown/International
|
||||
|
||||
# Default language code
|
||||
DEFAULT_LANGUAGE = "und" # Undetermined
|
||||
|
||||
# Default provider name for M3U channels
|
||||
DEFAULT_PROVIDER_NAME = "m3u"
|
||||
|
||||
# ============================================================================
|
||||
# User Agent Configuration
|
||||
# ============================================================================
|
||||
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/132.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Request Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Default timeout for HTTP requests (seconds)
|
||||
DEFAULT_REQUEST_TIMEOUT = 30
|
||||
|
||||
# Default maximum retries for failed requests
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
@@ -0,0 +1,992 @@
|
||||
# streaming_providers/providers/m3u/provider.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
M3U Playlist Provider Implementation
|
||||
|
||||
Provides access to channels from local M3U/M3U8 playlist files
|
||||
with support for extended attributes, DRM, and both DASH and HLS formats.
|
||||
|
||||
Environment Variable:
|
||||
M3U_PLAYLISTS_PATH: Directory containing M3U playlist files (auto-discovers *.m3u files)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import ClassVar, Dict, List, Optional
|
||||
|
||||
from ...base.models.drm_models import DRMConfig, DRMSystem, LicenseConfig
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.models.streaming_channel import StreamingChannel
|
||||
from ...base.provider import StreamingProvider
|
||||
from ...base.utils.logger import logger
|
||||
from ...base.utils.vfs import get_vfs
|
||||
from .constants import (
|
||||
AUTO_REFRESH_ON_EXPIRE,
|
||||
CONTENT_TYPE_KEYWORDS,
|
||||
CUSTOM_ATTRIBUTES,
|
||||
DEFAULT_CACHE_TTL,
|
||||
DEFAULT_CHANNEL_CONFIG,
|
||||
DEFAULT_CONTENT_TYPE,
|
||||
DEFAULT_COUNTRY,
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
DEFAULT_PROVIDER_NAME,
|
||||
DEFAULT_QUALITY,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
DEFAULT_STREAMING_FORMAT,
|
||||
DEFAULT_USER_AGENT,
|
||||
DRM_ATTRIBUTES,
|
||||
ENV_M3U_PLAYLISTS_PATH,
|
||||
EXTINF_PREFIX,
|
||||
FALLBACK_ENCODINGS,
|
||||
KODI_VLC_PROPERTIES,
|
||||
M3U_HEADER,
|
||||
M3U_LOGO,
|
||||
MANIFEST_PATTERNS,
|
||||
QUALITY_PATTERNS,
|
||||
REQUIRED_FIELDS,
|
||||
SKIP_INVALID_CHANNELS,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
TVG_ATTRIBUTES,
|
||||
WARN_MISSING_OPTIONAL_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
class M3UProvider(StreamingProvider):
|
||||
"""
|
||||
M3U Playlist Provider
|
||||
|
||||
Parses local M3U/M3U8 files to extract channel information including:
|
||||
- Channel metadata (name, logo, group, language, country)
|
||||
- Manifest URLs (DASH .mpd, HLS .m3u8)
|
||||
- DRM configuration (license URLs, keys)
|
||||
- Custom properties (Kodi/VLC options)
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# STATIC METADATA
|
||||
# ============================================================================
|
||||
PROVIDER_LABEL: ClassVar[str] = "M3U Playlist"
|
||||
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["anonymous"]
|
||||
PROVIDER_LOGO: ClassVar[str] = M3U_LOGO
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["*"] # All countries
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
country: str = DEFAULT_COUNTRY,
|
||||
config_dir: Optional[str] = None,
|
||||
m3u_filename: Optional[str] = None,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
cache_ttl: int = DEFAULT_CACHE_TTL,
|
||||
auto_refresh: bool = AUTO_REFRESH_ON_EXPIRE,
|
||||
encoding: str = DEFAULT_ENCODING,
|
||||
):
|
||||
"""
|
||||
Initialize M3U provider
|
||||
|
||||
Args:
|
||||
country: Country code (default: XX for international)
|
||||
config_dir: Configuration directory path
|
||||
m3u_filename: Specific M3U filename (if None, auto-discovers all *.m3u files)
|
||||
proxy_config: Proxy configuration object
|
||||
proxy_url: Proxy URL string
|
||||
cache_ttl: Cache time-to-live in seconds
|
||||
auto_refresh: Auto-refresh playlist when cache expires
|
||||
encoding: File encoding (default: utf-8)
|
||||
|
||||
Environment Variables:
|
||||
M3U_PLAYLISTS_PATH: Directory containing M3U files (overrides config_dir)
|
||||
"""
|
||||
super().__init__(country=country)
|
||||
|
||||
# Determine playlist directory from environment or config_dir
|
||||
playlists_path = os.environ.get(ENV_M3U_PLAYLISTS_PATH)
|
||||
if playlists_path:
|
||||
logger.info(f"M3U: Using {ENV_M3U_PLAYLISTS_PATH} from environment: {playlists_path}")
|
||||
self.playlists_dir = playlists_path
|
||||
elif config_dir:
|
||||
self.playlists_dir = config_dir
|
||||
else:
|
||||
# Use current directory as fallback
|
||||
self.playlists_dir = "."
|
||||
logger.warning(
|
||||
"M3U: No M3U_PLAYLISTS_PATH or config_dir specified, using current directory"
|
||||
)
|
||||
|
||||
# Initialize VFS for file operations
|
||||
self.vfs = get_vfs(config_dir=self.playlists_dir)
|
||||
|
||||
# Store configuration
|
||||
self.m3u_filename = m3u_filename
|
||||
self.encoding = encoding
|
||||
self.cache_ttl = cache_ttl
|
||||
self.auto_refresh = auto_refresh
|
||||
|
||||
# Setup HTTP manager for potential future use
|
||||
self.http_manager = self._setup_http_manager(
|
||||
provider_name="m3u",
|
||||
proxy_config=proxy_config,
|
||||
proxy_url=proxy_url,
|
||||
config_dir=config_dir,
|
||||
user_agent=DEFAULT_USER_AGENT,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
max_retries=DEFAULT_MAX_RETRIES,
|
||||
)
|
||||
|
||||
# Internal state and caching
|
||||
self._channels_cache: List[StreamingChannel] = []
|
||||
self._cache_timestamp: float = 0
|
||||
self._file_mtimes: Dict[str, float] = {} # Track mtime per file
|
||||
self._playlist_metadata: Dict = {}
|
||||
|
||||
# Statistics (aggregated across all files)
|
||||
self._stats = {
|
||||
"total_lines": 0,
|
||||
"channels_parsed": 0,
|
||||
"channels_skipped": 0,
|
||||
"drm_channels": 0,
|
||||
"errors": 0,
|
||||
"files_parsed": 0,
|
||||
}
|
||||
|
||||
# Discover M3U files on initialization
|
||||
self._discovered_files = self._discover_m3u_files()
|
||||
if self._discovered_files:
|
||||
logger.info(
|
||||
f"M3U: Discovered {len(self._discovered_files)} playlist file(s): "
|
||||
f"{', '.join(self._discovered_files)}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"M3U: No playlist files found in {self.playlists_dir}. "
|
||||
f"Provider initialized but get_channels() will return empty list."
|
||||
)
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "m3u"
|
||||
|
||||
@property
|
||||
def provider_label(self) -> str:
|
||||
return self.PROVIDER_LABEL
|
||||
|
||||
@property
|
||||
def provider_logo(self) -> str:
|
||||
return self.PROVIDER_LOGO
|
||||
|
||||
@property
|
||||
def supported_auth_types(self) -> List[str]:
|
||||
return self.SUPPORTED_AUTH_TYPES
|
||||
|
||||
@property
|
||||
def uses_dynamic_manifests(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def implements_epg(self) -> bool:
|
||||
return False
|
||||
|
||||
def authenticate(self, **kwargs) -> str:
|
||||
"""M3U provider doesn't require authentication"""
|
||||
logger.debug("M3U: No authentication required")
|
||||
return ""
|
||||
|
||||
def refresh_authentication(self) -> str:
|
||||
"""M3U provider doesn't require authentication"""
|
||||
return ""
|
||||
|
||||
# ============================================================================
|
||||
# M3U FILE DISCOVERY
|
||||
# ============================================================================
|
||||
|
||||
def _discover_m3u_files(self) -> List[str]:
|
||||
"""
|
||||
Discover M3U files in the playlists directory
|
||||
|
||||
Returns:
|
||||
List of M3U filenames found
|
||||
"""
|
||||
if self.m3u_filename:
|
||||
# Specific file requested
|
||||
if self.vfs.exists(self.m3u_filename):
|
||||
return [self.m3u_filename]
|
||||
else:
|
||||
logger.warning(
|
||||
f"M3U: Specified file not found: {self.m3u_filename}"
|
||||
)
|
||||
return []
|
||||
|
||||
# Auto-discover all *.m3u and *.m3u8 files
|
||||
discovered = []
|
||||
for ext in ["*.m3u", "*.m3u8"]:
|
||||
files = self.vfs.list_files("", pattern=ext)
|
||||
discovered.extend(files)
|
||||
|
||||
# Remove duplicates and sort
|
||||
discovered = sorted(set(discovered))
|
||||
|
||||
if not discovered:
|
||||
logger.debug(
|
||||
f"M3U: No playlist files found in {self.playlists_dir} "
|
||||
f"(searched for *.m3u and *.m3u8)"
|
||||
)
|
||||
|
||||
return discovered
|
||||
|
||||
# ============================================================================
|
||||
# M3U PARSING
|
||||
# ============================================================================
|
||||
|
||||
def _read_m3u_file(self, filename: str) -> List[str]:
|
||||
"""
|
||||
Read M3U file with encoding fallback support using VFS
|
||||
|
||||
Args:
|
||||
filename: M3U filename
|
||||
|
||||
Returns:
|
||||
List of lines from the M3U file
|
||||
"""
|
||||
encodings_to_try = [self.encoding] + FALLBACK_ENCODINGS
|
||||
|
||||
for enc in encodings_to_try:
|
||||
try:
|
||||
content = self.vfs.read_text(filename, encoding=enc)
|
||||
if content is None:
|
||||
raise FileNotFoundError(
|
||||
f"M3U file not found: {filename}. "
|
||||
f"Please ensure the file exists in {self.playlists_dir}"
|
||||
)
|
||||
lines = content.splitlines()
|
||||
logger.debug(
|
||||
f"M3U: Successfully read {filename} with encoding: {enc}"
|
||||
)
|
||||
return lines
|
||||
except UnicodeDecodeError:
|
||||
logger.debug(f"M3U: Failed to read {filename} with encoding: {enc}")
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"M3U: Error reading {filename}: {e}")
|
||||
raise
|
||||
|
||||
raise ValueError(
|
||||
f"Could not read M3U file {filename} with any encoding: {encodings_to_try}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_attributes(line: str) -> Dict[str, str]:
|
||||
"""
|
||||
Parse attributes from M3U line (TVG attributes, custom attributes)
|
||||
|
||||
Example:
|
||||
tvg-id="channel1" tvg-name="Channel One" tvg-logo="logo.png" group-title="News"
|
||||
|
||||
Returns:
|
||||
Dictionary of parsed attributes
|
||||
"""
|
||||
attributes = {}
|
||||
|
||||
# Match key="value" or key=value patterns
|
||||
pattern = r'([a-zA-Z0-9\-_]+)=["\'](.*?)["\']|([a-zA-Z0-9\-_]+)=(\S+)'
|
||||
matches = re.findall(pattern, line)
|
||||
|
||||
for match in matches:
|
||||
if match[0] and match[1]: # Quoted value
|
||||
key, value = match[0], match[1]
|
||||
elif match[2] and match[3]: # Unquoted value
|
||||
key, value = match[2], match[3]
|
||||
else:
|
||||
continue
|
||||
|
||||
attributes[key.lower()] = value.strip()
|
||||
|
||||
return attributes
|
||||
|
||||
@staticmethod
|
||||
def _extract_extinf_name(line: str) -> str:
|
||||
"""
|
||||
Extract channel name from EXTINF line
|
||||
|
||||
Example:
|
||||
#EXTINF:-1 tvg-id="1" tvg-name="Channel",Channel Name
|
||||
Returns: "Channel Name"
|
||||
"""
|
||||
# Split on comma - the part after last comma is the name
|
||||
parts = line.split(",", 1)
|
||||
if len(parts) > 1:
|
||||
return parts[1].strip()
|
||||
return "Unknown Channel"
|
||||
|
||||
@staticmethod
|
||||
def _detect_streaming_format(url: str) -> str:
|
||||
"""
|
||||
Detect streaming format from manifest URL
|
||||
|
||||
Returns:
|
||||
Format string: "dash", "hls", "smooth", or default
|
||||
"""
|
||||
url_lower = url.lower()
|
||||
|
||||
for format_type, patterns in MANIFEST_PATTERNS.items():
|
||||
if any(pattern in url_lower for pattern in patterns):
|
||||
return format_type
|
||||
|
||||
return DEFAULT_STREAMING_FORMAT
|
||||
|
||||
@staticmethod
|
||||
def _detect_content_type(name: str, group: Optional[str] = None) -> str:
|
||||
"""
|
||||
Auto-detect content type from channel name and group
|
||||
|
||||
Returns:
|
||||
Content type string: "LIVE", "VOD", "RADIO", "SERIES"
|
||||
"""
|
||||
text = f"{name} {group or ''}".lower()
|
||||
|
||||
for content_type, keywords in CONTENT_TYPE_KEYWORDS.items():
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return content_type
|
||||
|
||||
return DEFAULT_CONTENT_TYPE
|
||||
|
||||
@staticmethod
|
||||
def _detect_quality(name: str) -> Optional[str]:
|
||||
"""
|
||||
Detect quality from channel name
|
||||
|
||||
Returns:
|
||||
Quality string or None
|
||||
"""
|
||||
name_lower = name.lower()
|
||||
|
||||
for quality, patterns in QUALITY_PATTERNS.items():
|
||||
if any(pattern in name_lower for pattern in patterns):
|
||||
return quality
|
||||
|
||||
return DEFAULT_QUALITY
|
||||
|
||||
def _parse_drm_config(
|
||||
self, attributes: Dict[str, str], properties: Dict[str, str]
|
||||
) -> Optional[DRMConfig]:
|
||||
"""
|
||||
Parse DRM configuration from attributes and properties
|
||||
|
||||
Returns:
|
||||
DRMConfig object if DRM detected, None otherwise
|
||||
"""
|
||||
server_url = None
|
||||
drm_system_str = None
|
||||
clearkey = None
|
||||
|
||||
# Check DRM attributes
|
||||
for attr_key, attr_value in attributes.items():
|
||||
if attr_key in DRM_ATTRIBUTES:
|
||||
mapped_key = DRM_ATTRIBUTES[attr_key]
|
||||
if mapped_key == "license_url":
|
||||
server_url = attr_value
|
||||
elif mapped_key == "drm_system":
|
||||
drm_system_str = attr_value.lower()
|
||||
elif mapped_key == "clearkey":
|
||||
clearkey = attr_value
|
||||
|
||||
# Check Kodi/VLC properties
|
||||
for prop_key, prop_value in properties.items():
|
||||
if "license_key" in prop_key or "license_url" in prop_key:
|
||||
# Kodi format: URL|Headers|R{SSM}|
|
||||
server_url = prop_value.split("|")[0] if "|" in prop_value else prop_value
|
||||
elif "license_type" in prop_key:
|
||||
drm_system_str = prop_value.lower()
|
||||
|
||||
# Create DRM config if license URL found
|
||||
if server_url:
|
||||
# Determine DRM system
|
||||
if not drm_system_str:
|
||||
if "clearkey" in server_url.lower():
|
||||
drm_system_str = "clearkey"
|
||||
elif "widevine" in server_url.lower():
|
||||
drm_system_str = "widevine"
|
||||
else:
|
||||
drm_system_str = "widevine" # Default assumption
|
||||
|
||||
# Map to DRMSystem enum
|
||||
drm_system_mapping = {
|
||||
"widevine": DRMSystem.WIDEVINE,
|
||||
"com.widevine.alpha": DRMSystem.WIDEVINE,
|
||||
"playready": DRMSystem.PLAYREADY,
|
||||
"com.microsoft.playready": DRMSystem.PLAYREADY,
|
||||
"clearkey": DRMSystem.CLEARKEY,
|
||||
"org.w3.clearkey": DRMSystem.CLEARKEY,
|
||||
"fairplay": DRMSystem.FAIRPLAY,
|
||||
"com.apple.fps": DRMSystem.FAIRPLAY,
|
||||
"wiseplay": DRMSystem.WISEPLAY,
|
||||
"com.huawei.wiseplay": DRMSystem.WISEPLAY,
|
||||
}
|
||||
|
||||
drm_system = drm_system_mapping.get(
|
||||
drm_system_str, DRMSystem.WIDEVINE
|
||||
)
|
||||
|
||||
license_config = LicenseConfig(
|
||||
server_url=server_url,
|
||||
)
|
||||
|
||||
return DRMConfig(
|
||||
system=drm_system,
|
||||
license=license_config,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _parse_channel_entry(
|
||||
self,
|
||||
extinf_line: str,
|
||||
manifest_url: str,
|
||||
properties: Dict[str, str],
|
||||
channel_number: int,
|
||||
source_file: str,
|
||||
) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
Parse a single channel entry from EXTINF line and manifest URL
|
||||
|
||||
Args:
|
||||
extinf_line: The #EXTINF line
|
||||
manifest_url: The manifest/stream URL
|
||||
properties: Accumulated properties (KODIPROP, EXTVLCOPT)
|
||||
channel_number: Sequential channel number
|
||||
source_file: Source M3U filename
|
||||
|
||||
Returns:
|
||||
StreamingChannel object or None if parsing fails
|
||||
"""
|
||||
try:
|
||||
# Parse attributes from EXTINF line
|
||||
attributes = self._parse_attributes(extinf_line)
|
||||
|
||||
# Extract channel name
|
||||
channel_name = self._extract_extinf_name(extinf_line)
|
||||
|
||||
# Build channel data
|
||||
channel_data = {
|
||||
"name": channel_name,
|
||||
"manifest": manifest_url.strip(),
|
||||
"provider": self.provider_name,
|
||||
"country": self.country,
|
||||
"language": DEFAULT_LANGUAGE,
|
||||
"description": f"Source: {source_file}", # Track source file
|
||||
}
|
||||
|
||||
# Map TVG attributes
|
||||
for tvg_key, mapped_key in TVG_ATTRIBUTES.items():
|
||||
if tvg_key in attributes:
|
||||
value = attributes[tvg_key]
|
||||
|
||||
# Special handling for channel_number
|
||||
if mapped_key == "channel_number":
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid channel number: {value}")
|
||||
continue
|
||||
|
||||
channel_data[mapped_key] = value
|
||||
|
||||
# Map custom attributes
|
||||
for custom_key, mapped_key in CUSTOM_ATTRIBUTES.items():
|
||||
if custom_key in attributes and mapped_key not in channel_data:
|
||||
channel_data[mapped_key] = attributes[custom_key]
|
||||
|
||||
# Generate channel_id if not present
|
||||
if "channel_id" not in channel_data:
|
||||
# Use tvg-id if available, otherwise generate from name
|
||||
channel_id = attributes.get("tvg-id") or self._generate_channel_id(
|
||||
channel_name, channel_number, source_file
|
||||
)
|
||||
channel_data["channel_id"] = channel_id
|
||||
|
||||
# Set channel number if not already set
|
||||
if "channel_number" not in channel_data:
|
||||
channel_data["channel_number"] = channel_number
|
||||
|
||||
# Detect streaming format
|
||||
streaming_format = self._detect_streaming_format(manifest_url)
|
||||
channel_data["streaming_format"] = streaming_format
|
||||
|
||||
# Detect content type
|
||||
content_type = self._detect_content_type(
|
||||
channel_name, channel_data.get("genre")
|
||||
)
|
||||
channel_data["content_type"] = content_type
|
||||
|
||||
# Detect quality
|
||||
quality = self._detect_quality(channel_name)
|
||||
if quality:
|
||||
channel_data["quality"] = quality
|
||||
|
||||
# Parse DRM configuration
|
||||
drm_config = self._parse_drm_config(attributes, properties)
|
||||
if drm_config:
|
||||
channel_data["drm_config"] = drm_config
|
||||
channel_data["use_cdm"] = True
|
||||
|
||||
# Apply default configuration
|
||||
for key, value in DEFAULT_CHANNEL_CONFIG.items():
|
||||
if key not in channel_data:
|
||||
channel_data[key] = value
|
||||
|
||||
# Create StreamingChannel
|
||||
channel = StreamingChannel(**channel_data)
|
||||
|
||||
# Auto-detect radio if applicable
|
||||
channel.detect_and_set_radio()
|
||||
|
||||
# Validate required fields
|
||||
if SKIP_INVALID_CHANNELS:
|
||||
for field in REQUIRED_FIELDS:
|
||||
if not getattr(channel, field, None):
|
||||
logger.warning(
|
||||
f"Skipping channel from {source_file} (missing {field}): {channel_name}"
|
||||
)
|
||||
return None
|
||||
|
||||
return channel
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing channel entry from {source_file}: {e}")
|
||||
self._stats["errors"] += 1
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _generate_channel_id(name: str, number: int, source_file: str) -> str:
|
||||
"""
|
||||
Generate a unique channel ID from name, number, and source file
|
||||
|
||||
Args:
|
||||
name: Channel name
|
||||
number: Channel number
|
||||
source_file: Source M3U filename
|
||||
|
||||
Returns:
|
||||
Channel ID string
|
||||
"""
|
||||
# Clean name: lowercase, remove special chars, replace spaces with underscores
|
||||
clean_name = re.sub(r"[^a-z0-9\s]", "", name.lower())
|
||||
clean_name = re.sub(r"\s+", "_", clean_name.strip())
|
||||
|
||||
# Clean source file (remove extension and special chars)
|
||||
clean_source = re.sub(r"[^a-z0-9]", "_", source_file.lower().rsplit(".", 1)[0])
|
||||
|
||||
return f"{clean_source}_{clean_name}_{number}"
|
||||
|
||||
def _parse_m3u(self, filename: str) -> List[StreamingChannel]:
|
||||
"""
|
||||
Parse a single M3U file and extract all channels
|
||||
|
||||
Args:
|
||||
filename: M3U filename to parse
|
||||
|
||||
Returns:
|
||||
List of StreamingChannel objects
|
||||
"""
|
||||
logger.info(f"M3U: Parsing file: {filename}")
|
||||
|
||||
lines = self._read_m3u_file(filename)
|
||||
file_stats = {
|
||||
"total_lines": len(lines),
|
||||
"channels_parsed": 0,
|
||||
"channels_skipped": 0,
|
||||
"drm_channels": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
# Validate M3U header
|
||||
if not lines or not lines[0].strip().startswith(M3U_HEADER):
|
||||
logger.warning(f"M3U: File {filename} does not start with #EXTM3U header")
|
||||
|
||||
channels = []
|
||||
current_extinf = None
|
||||
current_properties = {}
|
||||
channel_number = 1
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments (except directives)
|
||||
if not line or (line.startswith("#") and not line.startswith("#EXT")):
|
||||
continue
|
||||
|
||||
# Parse EXTINF line
|
||||
if line.startswith(EXTINF_PREFIX):
|
||||
current_extinf = line
|
||||
current_properties = {} # Reset properties for new channel
|
||||
|
||||
# Parse KODIPROP
|
||||
elif line.startswith("#KODIPROP:"):
|
||||
prop_line = line[10:].strip() # Remove #KODIPROP:
|
||||
if "=" in prop_line:
|
||||
key, value = prop_line.split("=", 1)
|
||||
current_properties[key.strip().lower()] = value.strip()
|
||||
|
||||
# Parse EXTVLCOPT
|
||||
elif line.startswith("#EXTVLCOPT:"):
|
||||
prop_line = line[11:].strip() # Remove #EXTVLCOPT:
|
||||
if "=" in prop_line:
|
||||
key, value = prop_line.split("=", 1)
|
||||
current_properties[key.strip().lower()] = value.strip()
|
||||
|
||||
# Parse manifest URL (non-comment line after EXTINF)
|
||||
elif current_extinf and not line.startswith("#"):
|
||||
manifest_url = line
|
||||
|
||||
# Parse the channel entry
|
||||
channel = self._parse_channel_entry(
|
||||
current_extinf,
|
||||
manifest_url,
|
||||
current_properties,
|
||||
channel_number,
|
||||
filename, # Pass source filename
|
||||
)
|
||||
|
||||
if channel:
|
||||
channels.append(channel)
|
||||
channel_number += 1
|
||||
file_stats["channels_parsed"] += 1
|
||||
if channel.drm_config:
|
||||
file_stats["drm_channels"] += 1
|
||||
else:
|
||||
file_stats["channels_skipped"] += 1
|
||||
|
||||
# Reset for next channel
|
||||
current_extinf = None
|
||||
current_properties = {}
|
||||
|
||||
logger.info(
|
||||
f"M3U: Parsed {filename}: "
|
||||
f"{file_stats['channels_parsed']} channels, "
|
||||
f"{file_stats['channels_skipped']} skipped, "
|
||||
f"{file_stats['drm_channels']} with DRM, "
|
||||
f"{file_stats['errors']} errors"
|
||||
)
|
||||
|
||||
# Update aggregate stats
|
||||
self._stats["total_lines"] += file_stats["total_lines"]
|
||||
self._stats["channels_parsed"] += file_stats["channels_parsed"]
|
||||
self._stats["channels_skipped"] += file_stats["channels_skipped"]
|
||||
self._stats["drm_channels"] += file_stats["drm_channels"]
|
||||
self._stats["errors"] += file_stats["errors"]
|
||||
|
||||
return channels
|
||||
|
||||
# ============================================================================
|
||||
# CHANNEL MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
def _should_refresh_cache(self) -> bool:
|
||||
"""
|
||||
Determine if cache should be refreshed
|
||||
|
||||
Checks:
|
||||
- Cache TTL expiration
|
||||
- File modification time for any discovered file
|
||||
- New files discovered
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Check cache TTL
|
||||
if current_time - self._cache_timestamp >= self.cache_ttl:
|
||||
logger.debug("M3U: Cache TTL expired")
|
||||
return True
|
||||
|
||||
# Re-discover files to check for new files
|
||||
current_files = self._discover_m3u_files()
|
||||
if set(current_files) != set(self._discovered_files):
|
||||
logger.debug("M3U: File list changed (new files added or removed)")
|
||||
self._discovered_files = current_files
|
||||
return True
|
||||
|
||||
# Check if any file has been modified
|
||||
for filename in self._discovered_files:
|
||||
filepath = self.vfs.join_path(filename)
|
||||
# Get file modification time via VFS/filesystem
|
||||
try:
|
||||
if os.path.isabs(filepath):
|
||||
current_mtime = os.path.getmtime(filepath)
|
||||
else:
|
||||
# VFS doesn't have direct mtime, so we read file size as proxy
|
||||
# In production, this could be enhanced
|
||||
current_mtime = time.time()
|
||||
|
||||
previous_mtime = self._file_mtimes.get(filename, 0)
|
||||
if current_mtime > previous_mtime:
|
||||
logger.debug(f"M3U: File {filename} has been modified")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"M3U: Could not check mtime for {filename}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def get_channels(
|
||||
self,
|
||||
fetch_manifests: bool = False,
|
||||
populate_streaming_data: bool = True,
|
||||
force_refresh: bool = False,
|
||||
**kwargs,
|
||||
) -> List[StreamingChannel]:
|
||||
"""
|
||||
Get channels from all M3U files with caching support
|
||||
|
||||
Args:
|
||||
fetch_manifests: Not used (manifests are in M3U)
|
||||
populate_streaming_data: Not used (data is in M3U)
|
||||
force_refresh: Force re-parsing of M3U files
|
||||
|
||||
Returns:
|
||||
List of StreamingChannel objects from all M3U files
|
||||
"""
|
||||
# Check if refresh needed
|
||||
should_refresh = force_refresh or self._should_refresh_cache()
|
||||
|
||||
# Return cached channels if valid
|
||||
if not should_refresh and self._channels_cache:
|
||||
logger.debug("M3U: Returning channels from cache")
|
||||
return self._channels_cache
|
||||
|
||||
logger.info("M3U: Cache expired or force refresh requested. Parsing M3U files...")
|
||||
|
||||
try:
|
||||
# Reset aggregate statistics
|
||||
self._stats = {
|
||||
"total_lines": 0,
|
||||
"channels_parsed": 0,
|
||||
"channels_skipped": 0,
|
||||
"drm_channels": 0,
|
||||
"errors": 0,
|
||||
"files_parsed": 0,
|
||||
}
|
||||
|
||||
# Parse all discovered M3U files
|
||||
all_channels = []
|
||||
for filename in self._discovered_files:
|
||||
try:
|
||||
channels = self._parse_m3u(filename)
|
||||
all_channels.extend(channels)
|
||||
self._stats["files_parsed"] += 1
|
||||
|
||||
# Update file mtime
|
||||
filepath = self.vfs.join_path(filename)
|
||||
if os.path.isabs(filepath):
|
||||
self._file_mtimes[filename] = os.path.getmtime(filepath)
|
||||
else:
|
||||
self._file_mtimes[filename] = time.time()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"M3U: Error parsing {filename}: {e}")
|
||||
self._stats["errors"] += 1
|
||||
|
||||
# Update cache
|
||||
self._channels_cache = all_channels
|
||||
self._cache_timestamp = time.time()
|
||||
|
||||
logger.info(
|
||||
f"M3U: Parsed {self._stats['files_parsed']} file(s), "
|
||||
f"loaded {len(all_channels)} total channels"
|
||||
)
|
||||
|
||||
return all_channels
|
||||
|
||||
except Exception as e:
|
||||
# If parsing fails and we have cached data, return it
|
||||
if self._channels_cache:
|
||||
logger.error(f"M3U: Failed to parse files: {e}. Serving stale cache.")
|
||||
return self._channels_cache
|
||||
raise
|
||||
|
||||
def populate_streaming_data(
|
||||
self,
|
||||
channels: List[StreamingChannel],
|
||||
**kwargs,
|
||||
) -> List[StreamingChannel]:
|
||||
"""
|
||||
M3U channels already have streaming data from the file
|
||||
|
||||
Returns:
|
||||
Same list of channels
|
||||
"""
|
||||
return channels
|
||||
|
||||
def enrich_channel_data(
|
||||
self,
|
||||
channel: StreamingChannel,
|
||||
**kwargs,
|
||||
) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
M3U channels already have complete data from the file
|
||||
|
||||
Returns:
|
||||
Same channel object
|
||||
"""
|
||||
return channel
|
||||
|
||||
def get_manifest(
|
||||
self,
|
||||
channel_id: str,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get manifest URL for a specific channel
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
|
||||
Returns:
|
||||
Manifest URL string or None
|
||||
"""
|
||||
channels = self.get_channels()
|
||||
for channel in channels:
|
||||
if channel.channel_id == channel_id:
|
||||
return channel.manifest
|
||||
return None
|
||||
|
||||
def get_drm(
|
||||
self,
|
||||
channel_id: str,
|
||||
**kwargs,
|
||||
) -> List[DRMConfig]:
|
||||
"""
|
||||
Get DRM configuration for a specific channel
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
|
||||
Returns:
|
||||
List of DRMConfig objects
|
||||
"""
|
||||
channels = self.get_channels()
|
||||
for channel in channels:
|
||||
if channel.channel_id == channel_id:
|
||||
if channel.drm_config:
|
||||
return [channel.drm_config]
|
||||
return []
|
||||
|
||||
def get_dynamic_manifest_params(
|
||||
self,
|
||||
channel: StreamingChannel,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
M3U provider doesn't use dynamic manifests
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_epg(
|
||||
self,
|
||||
channel_id: str,
|
||||
**kwargs,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
M3U provider doesn't implement EPG
|
||||
|
||||
Returns:
|
||||
Empty list
|
||||
"""
|
||||
return []
|
||||
|
||||
# ============================================================================
|
||||
# UTILITY METHODS
|
||||
# ============================================================================
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""
|
||||
Get parsing statistics
|
||||
|
||||
Returns:
|
||||
Dictionary with parsing stats
|
||||
"""
|
||||
return {
|
||||
**self._stats,
|
||||
"cache_timestamp": self._cache_timestamp,
|
||||
"cached_channels": len(self._channels_cache),
|
||||
"discovered_files": self._discovered_files,
|
||||
"file_mtimes": self._file_mtimes,
|
||||
"playlists_dir": self.playlists_dir,
|
||||
}
|
||||
|
||||
def test_connection(self) -> Dict:
|
||||
"""
|
||||
Test M3U files accessibility and parsing
|
||||
|
||||
Returns:
|
||||
Test result dictionary
|
||||
"""
|
||||
result = {
|
||||
"provider": self.provider_name,
|
||||
"playlists_dir": self.playlists_dir,
|
||||
"success": False,
|
||||
"directory_exists": False,
|
||||
"files_discovered": 0,
|
||||
"files_readable": 0,
|
||||
"channels_parsed": 0,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
# Check directory exists
|
||||
result["directory_exists"] = self.vfs.exists("")
|
||||
if not result["directory_exists"]:
|
||||
result["error"] = f"Playlists directory does not exist: {self.playlists_dir}"
|
||||
return result
|
||||
|
||||
# Discover files
|
||||
files = self._discover_m3u_files()
|
||||
result["files_discovered"] = len(files)
|
||||
result["discovered_files"] = files
|
||||
|
||||
if not files:
|
||||
result["error"] = "No M3U files found in playlists directory"
|
||||
return result
|
||||
|
||||
# Try to read each file
|
||||
readable_count = 0
|
||||
for filename in files:
|
||||
try:
|
||||
content = self.vfs.read_text(filename)
|
||||
if content:
|
||||
readable_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"M3U: Could not read {filename}: {e}")
|
||||
|
||||
result["files_readable"] = readable_count
|
||||
|
||||
if readable_count == 0:
|
||||
result["error"] = "No files are readable"
|
||||
return result
|
||||
|
||||
# Parse channels
|
||||
channels = self.get_channels(force_refresh=True)
|
||||
result["channels_parsed"] = len(channels)
|
||||
result["statistics"] = self.get_statistics()
|
||||
result["success"] = True
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close HTTP manager and cleanup resources"""
|
||||
if hasattr(self, "http_manager") and self.http_manager:
|
||||
self.http_manager.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
Reference in New Issue
Block a user