mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-31 11:12:45 +01:00
Add tvg-epgid
This commit is contained in:
@@ -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
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user