This commit is contained in:
Nirvana
2026-01-30 15:50:47 +01:00
parent 2a3f3f2ff5
commit 07745c9dce
2 changed files with 188 additions and 13 deletions
@@ -9,6 +9,7 @@ from email.utils import parsedate_to_datetime
from dataclasses import dataclass, field
from .logger import logger
from .vfs import get_vfs
# Pre-compile regex for ISO duration parsing at module level
ISO_8601_PERIOD_RE = re.compile(
@@ -51,6 +52,94 @@ class KeyConfiguration:
logger.debug(f"Multi-key mode: {len(self.keys)} keys available")
class RepresentationBlocklist:
"""
Manages blocklist of problematic Representation IDs that cause 500 errors.
Blocklist format (JSON):
{
"provider_name": {
"channel_name": ["rep_id_1", "rep_id_2"],
"another_channel": ["rep_id_3"]
}
}
"""
def __init__(self, blocklist_path: str = "representation_blocklist.json"):
"""
Initialize blocklist manager.
Args:
blocklist_path: Path to JSON file containing blocklist configuration
"""
self.blocklist_path = blocklist_path
self.blocklist: Dict[str, Dict[str, List[str]]] = {}
self._load_blocklist()
def _load_blocklist(self):
"""Load blocklist from JSON file using VFS."""
try:
vfs = get_vfs()
data = vfs.read_json(self.blocklist_path)
if data:
self.blocklist = data
total_blocked = sum(
len(rep_ids)
for provider in self.blocklist.values()
for rep_ids in provider.values()
)
logger.info(
f"Loaded representation blocklist: "
f"{len(self.blocklist)} providers, {total_blocked} total blocked representations"
)
else:
logger.info(f"No blocklist found at {self.blocklist_path}, starting with empty blocklist")
except Exception as e:
logger.warning(f"Failed to load representation blocklist from {self.blocklist_path}: {e}")
self.blocklist = {}
def is_blocked(self, provider: str, channel: str, representation_id: str) -> bool:
"""
Check if a representation ID is blocked for a given provider/channel.
Args:
provider: Provider name (e.g., "magenta_tv", "ht_iptv")
channel: Channel name/ID
representation_id: Representation ID to check
Returns:
True if blocked, False otherwise
"""
if not provider or not channel:
return False
provider_data = self.blocklist.get(provider, {})
channel_data = provider_data.get(channel, [])
return representation_id in channel_data
def get_blocked_ids(self, provider: str, channel: str) -> Set[str]:
"""
Get set of all blocked representation IDs for a provider/channel.
Args:
provider: Provider name
channel: Channel name/ID
Returns:
Set of blocked representation IDs
"""
if not provider or not channel:
return set()
provider_data = self.blocklist.get(provider, {})
channel_data = provider_data.get(channel, [])
return set(channel_data)
@dataclass
class VideoRepresentation:
"""Represents a video representation with its quality metrics."""
@@ -85,12 +174,20 @@ class MPDRewriter:
provider_proxy_url: Optional[str] = None,
clearkey_keyids: Optional[dict] = None,
highest_quality_video_only: bool = False, # NEW: Enable highest quality filtering
provider: Optional[str] = None, # NEW: Provider name for blocklist filtering
channel: Optional[str] = None, # NEW: Channel name for blocklist filtering
blocklist_path: str = "representation_blocklist.json", # NEW: Path to blocklist file
):
self.media_proxy_url = media_proxy_url.rstrip("/")
self.provider_proxy_url = provider_proxy_url
self.key_config = KeyConfiguration(clearkey_keyids or {})
self.highest_quality_video_only = highest_quality_video_only
# NEW: Blocklist configuration
self.provider = provider
self.channel = channel
self.blocklist = RepresentationBlocklist(blocklist_path)
# Pre-calculate query params that don't change to save cycles during rewrite
self._static_params = {}
if self.provider_proxy_url:
@@ -191,6 +288,10 @@ class MPDRewriter:
else:
self._remove_all_encrypted_adaptationsets(root)
# SECOND: Filter out blocked representations that cause 500 errors
if self.provider and self.channel:
self._remove_blocked_representations(root)
# THEN: Filter to highest quality video from remaining decryptable content
best_video_info = None
if self.highest_quality_video_only:
@@ -537,6 +638,64 @@ class MPDRewriter:
if removal_count > 0:
logger.info(f"Removed {removal_count} encrypted AdaptationSet(s) (no keys available)")
def _remove_blocked_representations(self, root: ET.Element):
"""
Remove Representation elements that are blocked for this provider/channel.
If an AdaptationSet has only one Representation and it's blocked, remove the entire AdaptationSet.
"""
if not self.provider or not self.channel:
return
blocked_ids = self.blocklist.get_blocked_ids(self.provider, self.channel)
if not blocked_ids:
return
total_reps_removed = 0
total_as_removed = 0
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
adaptationsets_to_remove = []
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
representations = adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE)
representations_to_remove = []
# Check each representation in this AdaptationSet
for representation in representations:
rep_id = representation.get("id", "")
if rep_id in blocked_ids:
logger.info(
f"Blocking representation '{rep_id}' for {self.provider}/{self.channel} "
f"(known to cause 500 errors)"
)
representations_to_remove.append(representation)
# If all representations are blocked, mark the entire AdaptationSet for removal
if representations_to_remove and len(representations_to_remove) == len(representations):
as_id = adaptation_set.get("id", "unknown")
logger.info(
f"Removing entire AdaptationSet '{as_id}' - "
f"all {len(representations)} representation(s) are blocked"
)
adaptationsets_to_remove.append(adaptation_set)
total_as_removed += 1
else:
# Remove only the blocked representations
for representation in representations_to_remove:
adaptation_set.remove(representation)
total_reps_removed += 1
# Remove marked AdaptationSets
for adaptation_set in adaptationsets_to_remove:
period.remove(adaptation_set)
if total_reps_removed > 0 or total_as_removed > 0:
logger.info(
f"Blocklist filtering complete: removed {total_reps_removed} representation(s) "
f"and {total_as_removed} AdaptationSet(s) for {self.provider}/{self.channel}"
)
def _rewrite_node(
self,
element: ET.Element,
+29 -13
View File
@@ -333,7 +333,7 @@ class UltimateService:
return self.env_manager.get_config(setting_id, default)
def _get_decrypted_manifest(
self, provider: str, channel_id: str, keyids: dict, highest_quality_only: bool = False
self, provider: str, channel_id: str, keyids: dict, highest_quality_only: bool = False
) -> str:
"""
Get rewritten MPD manifest for decrypted playback via media proxy.
@@ -351,7 +351,8 @@ class UltimateService:
country = request.query.get("country")
# Note: We don't cache decrypted manifests as they contain keys
logger.info(f"Generating decrypted manifest for {provider}/{channel_id} (highest_quality_only={highest_quality_only})")
logger.info(
f"Generating decrypted manifest for {provider}/{channel_id} (highest_quality_only={highest_quality_only})")
# Get original manifest URL
manifest_url = self.manager.get_channel_manifest(
@@ -392,11 +393,14 @@ class UltimateService:
logger.debug(f"Provider has proxy configured: {provider_proxy_url}")
# Rewrite MPD URLs to point to media proxy decrypt endpoint with keys
# CHANGED: Added provider and channel_id parameters for blocklist filtering
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
keyids,
highest_quality_only # ADDED
highest_quality_only,
provider=provider, # NEW: Enable blocklist filtering
channel=channel_id # NEW: Enable blocklist filtering
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
@@ -433,7 +437,8 @@ class UltimateService:
return cached_mpd
# Cache miss or highest_quality_only enabled - fetch and rewrite
logger.info(f"Cache miss for {provider}/{channel_id}, fetching manifest (highest_quality_only={highest_quality_only})")
logger.info(
f"Cache miss for {provider}/{channel_id}, fetching manifest (highest_quality_only={highest_quality_only})")
# Check if media proxy is configured
if not self.media_proxy_url:
@@ -492,11 +497,14 @@ class UltimateService:
logger.debug(f"Provider has proxy configured: {provider_proxy_url}")
# Rewrite MPD URLs to point to media proxy
# CHANGED: Added provider and channel_id parameters for blocklist filtering
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
None, # No keyids for proxied (unencrypted) streams
highest_quality_only # ADDED
highest_quality_only,
provider=provider, # NEW: Enable blocklist filtering
channel=channel_id # NEW: Enable blocklist filtering
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
@@ -1431,13 +1439,13 @@ class UltimateService:
return m3u_content
def _get_proxied_catchup_manifest(
self,
provider: str,
channel_id: str,
start_time: int,
end_time: int,
epg_id: str = None,
country: str = None,
self,
provider: str,
channel_id: str,
start_time: int,
end_time: int,
epg_id: str = None,
country: str = None,
) -> str:
"""
Get proxied and rewritten MPD manifest for catchup content using media proxy.
@@ -1510,7 +1518,15 @@ class UltimateService:
logger.debug(f"Provider has proxy configured: {provider_proxy_url}")
# Rewrite MPD URLs to point to media proxy
rewriter = MPDRewriter(self.media_proxy_url, provider_proxy_url)
# CHANGED: Added provider and channel_id parameters for blocklist filtering
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
None, # No keyids for catchup streams
False, # highest_quality_only - usually not needed for catchup
provider=provider, # NEW: Enable blocklist filtering
channel=channel_id # NEW: Enable blocklist filtering
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
# Cache the rewritten MPD with catchup-specific key