Add tvg-epgid

This commit is contained in:
Nirvana
2026-04-08 15:39:56 +02:00
parent f6061b731d
commit 0417aaa969
5 changed files with 338 additions and 215 deletions
@@ -403,6 +403,9 @@ class MoveTVAuthenticator(BaseAuthenticator):
def get_auth_token(self, force_refresh: bool = False) -> str:
return self.authenticate(force_refresh).access_token
def get_current_token(self) -> MoveTVAuthToken:
return cast(MoveTVAuthToken, self._current_token)
def get_session_info(self) -> Optional[Dict[str, Any]]:
"""
Returns info required for playback.
@@ -10,7 +10,7 @@ class MoveTVConfig:
provider.py, auth.py, and vod_manager.py never contain raw strings
or magic numbers.
"""
PROVIDER_LOGO: str = "https://m.media-amazon.com/images/I/41NLX+Ej5+L.png"
# -------------------------------------------------------------------------
# Base URLs
# -------------------------------------------------------------------------
@@ -2,22 +2,19 @@
import re
import time
import requests
from typing import ClassVar, Dict, List, Optional, Any, cast
from typing import ClassVar, Dict, List, Optional, Any
from ...base.models import DRMConfig, StreamingChannel
from ...base.models.proxy_models import ProxyConfig
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from .auth import MoveTVAuthenticator, MoveTVAuthToken
from .auth import MoveTVAuthenticator
from .constants import MoveTVConfig
from .vod_manager import (
MoveTvVodManager,
VodFilters,
VodPage,
VodItem,
PageComponent,
ComponentItem,
)
from .epg_manager import MoveTvEpgManager
@@ -170,7 +167,7 @@ class MoveTVProvider(StreamingProvider):
@property
def provider_logo(self) -> str:
return "" # No hosted logo URL known at this time
return MoveTVConfig.PROVIDER_LOGO
@property
def uses_dynamic_manifests(self) -> bool:
@@ -216,7 +213,7 @@ class MoveTVProvider(StreamingProvider):
if exc.response is not None and exc.response.status_code == 401:
logger.info("move.tv: 401 on channel fetch — attempting token refresh …")
try:
token = cast(MoveTVAuthToken, self.authenticator._current_token)
token = self.authenticator.get_current_token()
if token and self.authenticator.refresh_token(token):
channels = self._fetch_channels()
self.channels = channels # type: ignore[assignment]
@@ -436,7 +433,7 @@ class MoveTVProvider(StreamingProvider):
"— attempting token refresh …"
)
try:
token = cast(MoveTVAuthToken, self.authenticator._current_token)
token = self.authenticator.get_current_token()
refreshed = self.authenticator.refresh_token(token) if token else None
if not refreshed:
logger.info(
@@ -537,7 +534,7 @@ class MoveTVProvider(StreamingProvider):
# On 401, attempt a token refresh and retry once before giving up.
if is_401:
logger.info(f"move.tv: 401 on manifest fetch for liveId={live_id} — attempting token refresh …")
token = cast(MoveTVAuthToken, self.authenticator._current_token)
token = self.authenticator.get_current_token()
refreshed = self.authenticator.refresh_token(token) if token else None
if not refreshed:
logger.info("move.tv: Refresh failed, falling back to full login for manifest fetch")
+45 -35
View File
@@ -36,7 +36,7 @@ def setup_m3u_routes(app, manager, service):
logger.info(
"No valid cache found, generating M3U playlist for all providers"
)
return service._generate_m3u_all(save_to_cache=True)
return service.generate_m3u_all(save_to_cache=True)
except Exception as api_err:
logger.error(f"API Error in /api/m3u: {str(api_err)}")
@@ -52,7 +52,7 @@ def setup_m3u_routes(app, manager, service):
"""
try:
logger.info("Force generating M3U playlist for all providers")
return service._generate_m3u_all(save_to_cache=True)
return service.generate_m3u_all(save_to_cache=True)
except Exception as api_err:
logger.error(f"API Error in /api/m3u/generate: {str(api_err)}")
@@ -85,7 +85,7 @@ def setup_m3u_routes(app, manager, service):
logger.info(
f"No valid cache found, generating M3U playlist for provider '{provider}'"
)
return service._generate_m3u_provider(provider, save_to_cache=True)
return service.generate_m3u_provider(provider, save_to_cache=True)
except ValueError as val_err:
logger.error(f"API Error in /api/providers/{provider}/m3u: {str(val_err)}")
@@ -105,7 +105,7 @@ def setup_m3u_routes(app, manager, service):
"""
try:
logger.info(f"Force generating M3U playlist for provider '{provider}'")
return service._generate_m3u_provider(provider, save_to_cache=True)
return service.generate_m3u_provider(provider, save_to_cache=True)
except ValueError as val_err:
logger.error(
@@ -131,7 +131,7 @@ def setup_m3u_routes(app, manager, service):
"""
try:
logger.info("Generating fast decrypted M3U playlist for all providers")
return service._generate_m3u_decrypted_fast(providers=None)
return service.generate_m3u_decrypted_fast(providers=None)
except Exception as api_err:
logger.error(f"API Error in /api/m3u/decrypted: {str(api_err)}")
@@ -165,7 +165,7 @@ def setup_m3u_routes(app, manager, service):
logger.info(
"No valid cache found, generating filtered decrypted M3U playlist for all providers"
)
return service._generate_m3u_decrypted_filtered_all(save_to_cache=True)
return service.generate_m3u_decrypted_filtered_all(save_to_cache=True)
except Exception as api_err:
logger.error(f"API Error in /api/m3u/decrypted/filtered: {str(api_err)}")
@@ -181,7 +181,7 @@ def setup_m3u_routes(app, manager, service):
"""
try:
logger.info("Force generating filtered decrypted M3U playlist for all providers")
return service._generate_m3u_decrypted_filtered_all(save_to_cache=True)
return service.generate_m3u_decrypted_filtered_all(save_to_cache=True)
except Exception as api_err:
logger.error(f"API Error in /api/m3u/decrypted/filtered/generate: {str(api_err)}")
@@ -199,7 +199,7 @@ def setup_m3u_routes(app, manager, service):
"""
try:
logger.info(f"Generating fast decrypted M3U playlist for provider '{provider}'")
return service._generate_m3u_decrypted_fast(providers=provider)
return service.generate_m3u_decrypted_fast(providers=provider)
except ValueError as val_err:
logger.error(
@@ -226,7 +226,7 @@ def setup_m3u_routes(app, manager, service):
"""
try:
logger.info(f"Generating fast decrypted ffmpeg M3U playlist for provider '{provider}'")
return service._generate_m3u_decrypted_ffmpeg_fast(providers=provider)
return service.generate_m3u_decrypted_ffmpeg_fast(providers=provider)
except ValueError as val_err:
logger.error(
@@ -270,7 +270,7 @@ def setup_m3u_routes(app, manager, service):
logger.info(
f"No valid cache found, generating filtered decrypted M3U playlist for provider '{provider}'"
)
return service._generate_m3u_decrypted_filtered_provider(
return service.generate_m3u_decrypted_filtered_provider(
provider, save_to_cache=True
)
@@ -298,7 +298,7 @@ def setup_m3u_routes(app, manager, service):
logger.info(
f"Force generating filtered decrypted M3U playlist for provider '{provider}'"
)
return service._generate_m3u_decrypted_filtered_provider(
return service.generate_m3u_decrypted_filtered_provider(
provider, save_to_cache=True
)
@@ -353,7 +353,12 @@ def setup_m3u_routes(app, manager, service):
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel, "channel_number", None) is not None else ""
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(
channel, "channel_number", None) is not None else ""
# Get EPG ID if available
epg_id = service.get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
# Get provider label
try:
@@ -361,14 +366,14 @@ def setup_m3u_routes(app, manager, service):
provider_label = getattr(
provider_instance, "provider_label", provider_name
)
except:
except Exception:
provider_label = provider_name
# Build stream URL with /index.mpd
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd"
# Add M3U entry
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
# Add DRM directives if available
try:
@@ -376,7 +381,7 @@ def setup_m3u_routes(app, manager, service):
provider_name, channel_id
)
if drm_configs:
drm_directives = service._generate_drm_directives(
drm_directives = service.generate_drm_directives(
drm_configs
)
m3u_content += drm_directives
@@ -408,22 +413,6 @@ def setup_m3u_routes(app, manager, service):
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
@app.route("/api/m3u/subscribed/generate")
def generate_m3u_subscribed():
"""Force regenerate subscribed M3U playlist"""
try:
# Clear cache and regenerate
cache_file = "playlist_subscribed.m3u"
service.vfs.delete(cache_file) # Delete if exists
# Call the subscribed M3U endpoint which will regenerate
return get_m3u_subscribed()
except Exception as api_err:
logger.error(f"API Error in /api/m3u/subscribed/generate: {str(api_err)}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
@app.route("/api/m3u/subscribed/decrypted")
def get_m3u_subscribed_decrypted():
"""
@@ -455,20 +444,25 @@ def setup_m3u_routes(app, manager, service):
provider_label = getattr(
provider_instance, "provider_label", provider_name
)
except:
except Exception:
provider_label = provider_name
for channel in channels:
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel, "channel_number", None) is not None else ""
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(
channel, "channel_number", None) is not None else ""
# Get EPG ID if available
epg_id = service.get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
# Build decrypted stream URL
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/index.mpd"
# Add M3U entry
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
m3u_content += f"{stream_url}\n"
@@ -487,4 +481,20 @@ def setup_m3u_routes(app, manager, service):
except Exception as api_err:
logger.error(f"API Error in /api/m3u/subscribed/decrypted: {str(api_err)}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
return {"error": f"Internal server error: {str(api_err)}"}
@app.route("/api/m3u/subscribed/generate")
def generate_m3u_subscribed():
"""Force regenerate subscribed M3U playlist"""
try:
# Clear cache and regenerate
cache_file = "playlist_subscribed.m3u"
service.vfs.delete(cache_file) # Delete if exists
# Call the subscribed M3U endpoint which will regenerate
return get_m3u_subscribed()
except Exception as api_err:
logger.error(f"API Error in /api/m3u/subscribed/generate: {str(api_err)}")
response.status = 500
return {"error": f"Internal server error: {str(api_err)}"}
+283 -170
View File
@@ -5,6 +5,8 @@ import sys
import threading
import time
from urllib.parse import parse_qsl, urlencode
from typing import Optional
import requests
from bottle import Bottle, redirect, request, response, run
@@ -90,6 +92,10 @@ class UltimateService:
logger.warning(f"Could not initialize EPG Manager: {e}")
self.epg_manager = None
# 3. Fetch EPG aliases for tvg-epgid mapping (initialize first)
self.epg_alias_map = {} # Initialize to empty dict as fallback
self._fetch_epg_aliases() # This will update self.epg_alias_map
self.setup_routes()
self.config_html = self._load_config_html()
@@ -594,65 +600,189 @@ class UltimateService:
key=lambda ch: (ch.channel_number is None, ch.channel_number or 0)
)
def _generate_m3u_content(
self, providers=None, save_to_cache=True, cache_filename=None
):
def _fetch_epg_aliases(self) -> None:
"""
Internal method to generate M3U content for specified providers.
Fetch EPG aliases from ultimate-epg service for tvg-epgid mapping.
Updates self.epg_alias_map with mapping of channel_id -> epg_id.
"""
api_base_url = os.environ.get("ULTIMATE_EPG_API_URL", "").strip()
if not api_base_url:
logger.info("ULTIMATE_EPG_API_URL not set - tvg-epgid will not be added to M3U playlists")
self.epg_alias_map = {}
return
aliases_url = f"{api_base_url.rstrip('/')}/api/v1/aliases"
try:
resp = requests.get(aliases_url, timeout=10)
resp.raise_for_status()
data = resp.json()
aliases = data.get("aliases", [])
# Build mapping: alias -> channel_name
alias_map = {}
for alias_entry in aliases:
alias = alias_entry.get("alias")
channel_name = alias_entry.get("channel_name")
if alias and channel_name:
alias_map[alias] = channel_name
self.epg_alias_map = alias_map
logger.info(f"Fetched {len(alias_map)} EPG aliases from {aliases_url}")
except requests.exceptions.RequestException as e:
logger.warning(f"Failed to fetch EPG aliases from {aliases_url}: {e}")
self.epg_alias_map = {}
except Exception as e:
logger.warning(f"Unexpected error fetching EPG aliases: {e}")
self.epg_alias_map = {}
def _get_epg_id(self, channel_id: str) -> Optional[str]:
"""
Get EPG ID (channel_name) for a given channel_id from alias mapping.
Args:
providers: List of provider names, or None for all providers
save_to_cache: Whether to save to cache
cache_filename: Cache filename to use
channel_id: The channel's ID (alias from ultimate-epg)
Returns:
M3U content as string
EPG ID string if found, None otherwise
"""
# Get base URL for absolute stream URLs
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
if not self.epg_alias_map:
return None
return self.epg_alias_map.get(channel_id)
# Start M3U content
m3u_content = "#EXTM3U\n"
def _generate_m3u_channel_entry(self, base_url, provider_name, channel):
"""
Generate M3U entry for a single channel.
# Determine which providers to process
if providers is None:
# All providers
providers_to_process = self.manager.list_providers()
cache_filename = cache_filename or "playlist.m3u"
Args:
base_url: Base URL for stream endpoints
provider_name: Name of the provider
channel: StreamingChannel object
Returns:
M3U entry as string
"""
entry_content = ""
# Access StreamingChannel attributes directly
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = self._chno_attr(channel)
# Get EPG ID if available
epg_id = self._get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
# Build stream URL with /index.mpd
stream_url = (
f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd"
)
# Get provider instance to access provider_label
try:
provider_instance = self.manager.get_provider(provider_name)
provider_label = provider_instance.provider_label
except (AttributeError, KeyError, ValueError):
# Fallback to provider_name if provider_label is not available
provider_label = provider_name
# Get catchup information from channel
catchup_window = getattr(channel, 'catchup_window', 0) # in hours
catchup_source = getattr(channel, 'catchup_source', 'default')
# Build EXTINF line with catchup tags if available
import math
if catchup_window > 0:
# Convert hours to days (ceil to ensure full coverage)
catchup_days = math.ceil(catchup_window / 24)
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n'
else:
# Specific provider(s)
providers_to_process = (
[providers] if isinstance(providers, str) else providers
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
# Get DRM configs and add KODIPROP directives
try:
drm_configs = self.manager.get_channel_drm_configs(
provider_name=provider_name, channel_id=channel_id
)
cache_filename = cache_filename or f"{providers_to_process[0]}.m3u"
for provider_name in providers_to_process:
try:
# Get channels for this provider
channels = self._sort_channels(
self.manager.get_channels(provider_name=provider_name, fetch_manifests=False)
)
if drm_configs:
drm_directives = self._generate_drm_directives(drm_configs)
entry_content += drm_directives
# Add each channel to M3U
for channel in channels:
m3u_content += self._generate_m3u_channel_entry(
base_url, provider_name, channel
)
except Exception as drm_err:
logger.debug(
f"Could not get DRM for {provider_name}/{channel_id}: {str(drm_err)}"
)
except Exception as provider_err:
logger.warning(
f"Failed to get channels for provider '{provider_name}': {str(provider_err)}"
)
continue
# Add stream URL
entry_content += f"{stream_url}\n"
# Save to cache if requested
if save_to_cache and cache_filename:
if self.vfs.write_text(cache_filename, m3u_content):
logger.info(f"M3U playlist cached to {cache_filename}")
else:
logger.warning(f"Failed to cache M3U playlist to {cache_filename}")
return entry_content
return m3u_content
def _generate_m3u_decrypted_channel_entry(
self, # Changed from @staticmethod to instance method
base_url,
provider_name,
provider_label,
channel,
clearkey_data,
provider_proxy_url,
):
"""
Generate M3U entry for a ClearKey encrypted channel with decryption URL.
Args:
base_url: Base URL for stream endpoints
provider_name: Name of the provider
provider_label: Display label for the provider
channel: StreamingChannel object
clearkey_data: ClearKey DRM data
provider_proxy_url: Optional provider proxy URL
Returns:
M3U entry as string
"""
entry_content = ""
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel,
"channel_number",
None) is not None else ""
# Get EPG ID if available
epg_id = self._get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
# Build decrypted stream URL via media proxy with /index.mpd
# Format: /api/providers/{provider}/channels/{channel_id}/stream/decrypted/index.mpd
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/index.mpd"
# Get catchup information from channel
catchup_window = getattr(channel, 'catchup_window', 0) # in hours
catchup_source = getattr(channel, 'catchup_source', 'default')
import math
# Build EXTINF line with catchup tags if available
if catchup_window > 0:
# Convert hours to days (ceil to ensure full coverage)
catchup_days = math.ceil(catchup_window / 24)
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n'
else:
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
# Add KODIPROP for inputstream.adaptive (still needed for DASH playback)
entry_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
# Add stream URL
entry_content += f"{stream_url}\n"
return entry_content
def _generate_m3u_decrypted_fast(self, providers=None):
"""
@@ -712,6 +842,10 @@ class UltimateService:
channel_logo = channel.logo_url or ""
chno = self._chno_attr(channel)
# Get EPG ID if available
epg_id = self._get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
# Build decrypted stream URL
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/index.mpd"
@@ -723,9 +857,9 @@ class UltimateService:
# Add M3U entry with catchup tags if available
if catchup_window > 0:
catchup_days = math.ceil(catchup_window / 24)
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n'
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n'
else:
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
m3u_content += f"{stream_url}\n"
@@ -814,6 +948,10 @@ class UltimateService:
channel_logo = channel.logo_url or ""
chno = self._chno_attr(channel)
# Get EPG ID if available
epg_id = self._get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
# Build decrypted stream URL (ffmpeg variant with highest quality)
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/ffmpeg/index.mpd"
@@ -839,7 +977,7 @@ class UltimateService:
)
# Add M3U entry with metadata
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
m3u_content += f"{ffmpeg_cmd}\n"
@@ -940,6 +1078,7 @@ class UltimateService:
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = self._chno_attr(channel)
# Try to get DRM configs
try:
@@ -984,9 +1123,10 @@ class UltimateService:
channels_included += 1
elif is_unencrypted:
# Explicitly unencrypted channel - include with direct stream URL
epg_id = self._get_epg_id(channel_id)
epg_id_attr = f' tvg-epgid="{epg_id}"' if epg_id else ""
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd"
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel, "channel_number", None) is not None else ""
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += f'#EXTINF:-1 tvg-id="{channel_id}"{epg_id_attr}{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
m3u_content += f"{stream_url}\n"
channels_included += 1
else:
@@ -1025,6 +1165,66 @@ class UltimateService:
return m3u_content
def _generate_m3u_content(
self, providers=None, save_to_cache=True, cache_filename=None
):
"""
Internal method to generate M3U content for specified providers.
Args:
providers: List of provider names, or None for all providers
save_to_cache: Whether to save to cache
cache_filename: Cache filename to use
Returns:
M3U content as string
"""
# Get base URL for absolute stream URLs
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
# Start M3U content
m3u_content = "#EXTM3U\n"
# Determine which providers to process
if providers is None:
# All providers
providers_to_process = self.manager.list_providers()
cache_filename = cache_filename or "playlist.m3u"
else:
# Specific provider(s)
providers_to_process = (
[providers] if isinstance(providers, str) else providers
)
cache_filename = cache_filename or f"{providers_to_process[0]}.m3u"
for provider_name in providers_to_process:
try:
# Get channels for this provider
channels = self._sort_channels(
self.manager.get_channels(provider_name=provider_name, fetch_manifests=False)
)
# Add each channel to M3U
for channel in channels:
m3u_content += self._generate_m3u_channel_entry(
base_url, provider_name, channel
)
except Exception as provider_err:
logger.warning(
f"Failed to get channels for provider '{provider_name}': {str(provider_err)}"
)
continue
# Save to cache if requested
if save_to_cache and cache_filename:
if self.vfs.write_text(cache_filename, m3u_content):
logger.info(f"M3U playlist cached to {cache_filename}")
else:
logger.warning(f"Failed to cache M3U playlist to {cache_filename}")
return m3u_content
def _generate_m3u_decrypted_filtered_all(self, save_to_cache: bool = False) -> str:
"""Internal method to generate filtered decrypted M3U for all providers."""
logger.info("Generating filtered decrypted M3U playlist for all providers")
@@ -1077,127 +1277,6 @@ class UltimateService:
chno = getattr(channel, "channel_number", None)
return f' tvg-chno="{chno}" ch-number="{chno}"' if chno is not None else ""
def _generate_m3u_channel_entry(self, base_url, provider_name, channel):
"""
Generate M3U entry for a single channel.
Args:
base_url: Base URL for stream endpoints
provider_name: Name of the provider
channel: StreamingChannel object
Returns:
M3U entry as string
"""
entry_content = ""
# Access StreamingChannel attributes directly
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = self._chno_attr(channel)
# Build stream URL with /index.mpd
stream_url = (
f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/index.mpd"
)
# Get provider instance to access provider_label
try:
provider_instance = self.manager.get_provider(provider_name)
provider_label = provider_instance.provider_label
except (AttributeError, KeyError, ValueError):
# Fallback to provider_name if provider_label is not available
provider_label = provider_name
# Get catchup information from channel
catchup_window = getattr(channel, 'catchup_window', 0) # in hours
catchup_source = getattr(channel, 'catchup_source', 'default')
# Build EXTINF line with catchup tags if available
import math
if catchup_window > 0:
# Convert hours to days (ceil to ensure full coverage)
catchup_days = math.ceil(catchup_window / 24)
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n'
else:
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
# Get DRM configs and add KODIPROP directives
try:
drm_configs = self.manager.get_channel_drm_configs(
provider_name=provider_name, channel_id=channel_id
)
if drm_configs:
drm_directives = self._generate_drm_directives(drm_configs)
entry_content += drm_directives
except Exception as drm_err:
logger.debug(
f"Could not get DRM for {provider_name}/{channel_id}: {str(drm_err)}"
)
# Add stream URL
entry_content += f"{stream_url}\n"
return entry_content
@staticmethod
def _generate_m3u_decrypted_channel_entry(
base_url,
provider_name,
provider_label,
channel,
clearkey_data,
provider_proxy_url,
):
"""
Generate M3U entry for a ClearKey encrypted channel with decryption URL.
Args:
base_url: Base URL for stream endpoints
provider_name: Name of the provider
provider_label: Display label for the provider
channel: StreamingChannel object
clearkey_data: ClearKey DRM data
provider_proxy_url: Optional provider proxy URL
Returns:
M3U entry as string
"""
entry_content = ""
channel_id = channel.channel_id
channel_name = channel.name
channel_logo = channel.logo_url or ""
chno = f' tvg-chno="{channel.channel_number}" ch-number="{channel.channel_number}"' if getattr(channel, "channel_number", None) is not None else ""
# Build decrypted stream URL via media proxy with /index.mpd
# Format: /api/providers/{provider}/channels/{channel_id}/stream/decrypted/index.mpd
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream/decrypted/index.mpd"
# Get catchup information from channel
catchup_window = getattr(channel, 'catchup_window', 0) # in hours
catchup_source = getattr(channel, 'catchup_source', 'default')
import math
# Build EXTINF line with catchup tags if available
if catchup_window > 0:
# Convert hours to days (ceil to ensure full coverage)
catchup_days = math.ceil(catchup_window / 24)
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}" catchup="{catchup_source}" catchup-days="{catchup_days}",{channel_name}\n'
else:
entry_content += f'#EXTINF:-1 tvg-id="{channel_id}"{chno} tvg-logo="{channel_logo}" group-title="{provider_label}",{channel_name}\n'
# Add KODIPROP for inputstream.adaptive (still needed for DASH playback)
entry_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
# Add stream URL
entry_content += f"{stream_url}\n"
return entry_content
def _generate_m3u_decrypted_content(
self, providers=None, save_to_cache=True, cache_filename=None
):
@@ -1476,6 +1555,40 @@ class UltimateService:
return directives
# ── Public API for route modules ─────────────────────────────────────────────
def get_epg_id(self, channel_id: str) -> Optional[str]:
"""Public wrapper for EPG ID lookup."""
return self._get_epg_id(channel_id)
def generate_m3u_all(self, save_to_cache: bool = False) -> str:
"""Public wrapper for full M3U generation."""
return self._generate_m3u_all(save_to_cache=save_to_cache)
def generate_m3u_provider(self, provider: str, save_to_cache: bool = False) -> str:
"""Public wrapper for per-provider M3U generation."""
return self._generate_m3u_provider(provider, save_to_cache=save_to_cache)
def generate_m3u_decrypted_fast(self, providers=None) -> str:
"""Public wrapper for fast decrypted M3U generation."""
return self._generate_m3u_decrypted_fast(providers)
def generate_m3u_decrypted_ffmpeg_fast(self, providers=None) -> str:
"""Public wrapper for fast ffmpeg-decrypted M3U generation."""
return self._generate_m3u_decrypted_ffmpeg_fast(providers)
def generate_m3u_decrypted_filtered_all(self, save_to_cache: bool = False) -> str:
"""Public wrapper for filtered decrypted M3U (all providers)."""
return self._generate_m3u_decrypted_filtered_all(save_to_cache=save_to_cache)
def generate_m3u_decrypted_filtered_provider(self, provider: str, save_to_cache: bool = False) -> str:
"""Public wrapper for filtered decrypted M3U (single provider)."""
return self._generate_m3u_decrypted_filtered_provider(provider, save_to_cache=save_to_cache)
def generate_drm_directives(self, drm_configs) -> str:
"""Public wrapper for DRM directive generation."""
return self._generate_drm_directives(drm_configs)
@staticmethod
def _process_license_headers(req_headers):
"""