diff --git a/lib/streaming_providers/base/utils/environment.py b/lib/streaming_providers/base/utils/environment.py index 610e603..c507c5a 100644 --- a/lib/streaming_providers/base/utils/environment.py +++ b/lib/streaming_providers/base/utils/environment.py @@ -83,16 +83,22 @@ class EnvironmentManager: # Get settings default_country = self._addon.getSetting("default_country") - self._config["default_country"] = str(default_country) if default_country else "DE" + self._config["default_country"] = ( + str(default_country) if default_country else "DE" + ) server_port = self._addon.getSetting("server_port") try: - self._config["server_port"] = int(str(server_port)) if server_port else 7777 + self._config["server_port"] = ( + int(str(server_port)) if server_port else 7777 + ) except ValueError: self._config["server_port"] = 7777 except Exception as init_error: # noqa: B902 - print(f"DEBUG: Exception type: {type(init_error).__name__}", file=sys.stderr) + print( + f"DEBUG: Exception type: {type(init_error).__name__}", file=sys.stderr + ) print(f"DEBUG: Exception message: {str(init_error)}", file=sys.stderr) # Log the error and fallback to standalone self._log_init_error("Kodi initialization failed", init_error) @@ -114,7 +120,9 @@ class EnvironmentManager: self._config["addon_path"] = os.path.dirname(os.path.abspath(__file__)) # Default configuration paths - config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join(str(Path.home()), ".config") + config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join( + str(Path.home()), ".config" + ) self._config["config_dir"] = os.path.join(config_home, "ultimate-backend") self._config["profile_path"] = self._config["config_dir"] @@ -240,7 +248,9 @@ class EnvironmentManager: else: raise ImportError("get_configured_manager is not callable") else: - raise ImportError("get_configured_manager not found in streaming_providers module") + raise ImportError( + "get_configured_manager not found in streaming_providers module" + ) except ImportError as manager_error: # Log error using the logger once we have it logger_instance = self.get_logger() diff --git a/lib/streaming_providers/base/utils/logger.py b/lib/streaming_providers/base/utils/logger.py index 04f3a8a..67e619c 100644 --- a/lib/streaming_providers/base/utils/logger.py +++ b/lib/streaming_providers/base/utils/logger.py @@ -50,7 +50,9 @@ class BaseLogger: log_message += f" - {details}" self.info(log_message) - def log_credential_event(self, provider: str, event: str, details: str = "") -> None: + def log_credential_event( + self, provider: str, event: str, details: str = "" + ) -> None: """Log credential event""" log_message = f"CRED [{provider}] {event}" if details: diff --git a/lib/streaming_providers/base/utils/manifest_parser.py b/lib/streaming_providers/base/utils/manifest_parser.py index d9c4fb3..81f34cb 100644 --- a/lib/streaming_providers/base/utils/manifest_parser.py +++ b/lib/streaming_providers/base/utils/manifest_parser.py @@ -53,7 +53,10 @@ class ManifestParser: system_id = match.group(1).lower() # Skip mp4protection scheme - if "mp4protection" in manifest_content[max(0, match.start() - 100) : match.start()]: + if ( + "mp4protection" + in manifest_content[max(0, match.start() - 100) : match.start()] + ): continue drm_system = DRMSystem.from_uuid(system_id) @@ -83,7 +86,9 @@ class ManifestParser: # Filter for expected DRM systems if provided if expected_system_ids: - filtered_pssh = [p for p in pssh_from_segment if p.system_id in expected_system_ids] + filtered_pssh = [ + p for p in pssh_from_segment if p.system_id in expected_system_ids + ] if filtered_pssh: logger.debug(f"Found {len(filtered_pssh)} PSSH boxes in segment") return filtered_pssh @@ -127,7 +132,9 @@ class ManifestParser: # Compile patterns once pssh_pattern = re.compile(r"<(?:cenc:)?pssh[^>]*>([^<]+)") - default_kid_pattern = re.compile(r'(?:cenc:)?default_KID="([^"]+)"', re.IGNORECASE) + default_kid_pattern = re.compile( + r'(?:cenc:)?default_KID="([^"]+)"', re.IGNORECASE + ) system_id_pattern = re.compile(r'schemeIdUri="urn:uuid:([^"]+)"', re.IGNORECASE) # Find ContentProtection blocks efficiently @@ -191,14 +198,18 @@ class ManifestParser: return list(pssh_dict.values()) @staticmethod - def extract_single_init_segment_url(manifest_content: str, manifest_url: str) -> Optional[str]: + def extract_single_init_segment_url( + manifest_content: str, manifest_url: str + ) -> Optional[str]: """ Extract ONE init segment URL from DASH manifest. Prioritizes video representations as they typically have the same DRM as audio. """ # Parse manifest base URL parsed = urlparse(manifest_url) - manifest_base = f"{parsed.scheme}://{parsed.netloc}{'/'.join(parsed.path.split('/')[:-1])}" + manifest_base = ( + f"{parsed.scheme}://{parsed.netloc}{'/'.join(parsed.path.split('/')[:-1])}" + ) if not manifest_base.endswith("/"): manifest_base += "/" @@ -288,17 +299,25 @@ class ManifestParser: DEPRECATED: Use extract_single_init_segment_url instead. This extracts ALL segments which is inefficient. """ - logger.warning("extract_segment_urls is deprecated, use extract_single_init_segment_url") - init_url = ManifestParser.extract_single_init_segment_url(manifest_content, manifest_url) + logger.warning( + "extract_segment_urls is deprecated, use extract_single_init_segment_url" + ) + init_url = ManifestParser.extract_single_init_segment_url( + manifest_content, manifest_url + ) return [init_url] if init_url else [] @staticmethod - def extract_init_segment_urls(manifest_content: str, manifest_url: str) -> List[str]: + def extract_init_segment_urls( + manifest_content: str, manifest_url: str + ) -> List[str]: """ DEPRECATED: Use extract_single_init_segment_url instead. """ logger.warning( "extract_init_segment_urls is deprecated, use extract_single_init_segment_url" ) - init_url = ManifestParser.extract_single_init_segment_url(manifest_content, manifest_url) + init_url = ManifestParser.extract_single_init_segment_url( + manifest_content, manifest_url + ) return [init_url] if init_url else [] diff --git a/lib/streaming_providers/base/utils/mp4_parser.py b/lib/streaming_providers/base/utils/mp4_parser.py index d22e3f3..e475fca 100644 --- a/lib/streaming_providers/base/utils/mp4_parser.py +++ b/lib/streaming_providers/base/utils/mp4_parser.py @@ -44,7 +44,7 @@ class MP4PSSHExtractor: if offset + 4 > len(data): break - box_size = struct.unpack(">I", data[offset: offset + 4])[0] + box_size = struct.unpack(">I", data[offset : offset + 4])[0] if box_size == 0: box_size = len(data) - offset # Box extends to end of file elif box_size == 1: @@ -55,11 +55,13 @@ class MP4PSSHExtractor: break # Read box type (4 bytes) - box_type = data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_type = data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "moov": # Look for PSSH in moov box - moov_data = data[offset: offset + box_size] + moov_data = data[offset : offset + box_size] pssh_in_moov = MP4PSSHExtractor._extract_from_moov(moov_data) # Enhance PSSH data with tenc KIDs if needed @@ -70,7 +72,9 @@ class MP4PSSHExtractor: elif box_type == "pssh": # Found standalone PSSH box - pssh_box = MP4PSSHExtractor._parse_pssh_box(data[offset: offset + box_size]) + pssh_box = MP4PSSHExtractor._parse_pssh_box( + data[offset : offset + box_size] + ) if pssh_box: # Add tenc KIDs if PSSH doesn't have its own if not pssh_box.key_ids and tenc_kids: @@ -96,28 +100,40 @@ class MP4PSSHExtractor: if offset + 8 > len(data): break - box_size = struct.unpack(">I", data[offset:offset + 4])[0] + box_size = struct.unpack(">I", data[offset : offset + 4])[0] if box_size < 8 or offset + box_size > len(data): offset += 1 continue - box_type = data[offset + 4:offset + 8] + box_type = data[offset + 4 : offset + 8] - if box_type == b'tenc': + if box_type == b"tenc": # Parse tenc box - logger.debug(f"Found tenc box at offset {offset} (0x{offset:x}), size {box_size}") - tenc_kid = MP4PSSHExtractor._parse_tenc_box(data[offset:offset + box_size]) + logger.debug( + f"Found tenc box at offset {offset} (0x{offset:x}), size {box_size}" + ) + tenc_kid = MP4PSSHExtractor._parse_tenc_box( + data[offset : offset + box_size] + ) if tenc_kid and tenc_kid not in kids: kids.append(tenc_kid) elif box_size > 8: # Recursively search inside container boxes container_boxes = { - b'moov', b'trak', b'mdia', b'minf', b'stbl', - b'stsd', b'encv', b'sinf', b'schi', b'udta' + b"moov", + b"trak", + b"mdia", + b"minf", + b"stbl", + b"stsd", + b"encv", + b"sinf", + b"schi", + b"udta", } if box_type in container_boxes: # Recursively search inside the container - inner_data = data[offset + 8:offset + box_size] + inner_data = data[offset + 8 : offset + box_size] inner_kids = MP4PSSHExtractor._extract_all_tenc_kids(inner_data) for kid in inner_kids: if kid not in kids: @@ -136,14 +152,16 @@ class MP4PSSHExtractor: try: # tenc box should be at least 32 bytes for version 0 with KID if len(tenc_bytes) < 32: - logger.debug(f"tenc box too small: {len(tenc_bytes)} bytes, need at least 32") + logger.debug( + f"tenc box too small: {len(tenc_bytes)} bytes, need at least 32" + ) return None # Parse box header box_size = struct.unpack(">I", tenc_bytes[0:4])[0] box_type = tenc_bytes[4:8] - if box_type != b'tenc': + if box_type != b"tenc": logger.debug(f"Not a tenc box, type: {box_type.hex()}") return None @@ -174,7 +192,8 @@ class MP4PSSHExtractor: kid_offset = 16 logger.debug( - f"tenc v0: is_encrypted={is_encrypted}, default_iv_size={default_iv_size}, kid_offset={kid_offset}") + f"tenc v0: is_encrypted={is_encrypted}, default_iv_size={default_iv_size}, kid_offset={kid_offset}" + ) elif version == 1: # Version 1 has different structure @@ -185,11 +204,13 @@ class MP4PSSHExtractor: return None if kid_offset + 16 > len(tenc_bytes): - logger.debug(f"tenc box too small for KID: {len(tenc_bytes)} bytes, need {kid_offset + 16}") + logger.debug( + f"tenc box too small for KID: {len(tenc_bytes)} bytes, need {kid_offset + 16}" + ) return None # Extract KID bytes - kid_bytes = tenc_bytes[kid_offset:kid_offset + 16] + kid_bytes = tenc_bytes[kid_offset : kid_offset + 16] hex_kid = kid_bytes.hex() logger.debug(f"KID bytes at offset {kid_offset}: {hex_kid}") @@ -219,19 +240,21 @@ class MP4PSSHExtractor: while offset < len(moov_data): try: - box_size = struct.unpack(">I", moov_data[offset: offset + 4])[0] - box_type = moov_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", moov_data[offset : offset + 4])[0] + box_type = moov_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "trak": # Parse track for PSSH - trak_data = moov_data[offset: offset + box_size] + trak_data = moov_data[offset : offset + box_size] pssh_in_trak = MP4PSSHExtractor._extract_from_trak(trak_data) pssh_list.extend(pssh_in_trak) elif box_type == "pssh": # PSSH directly in moov pssh_box = MP4PSSHExtractor._parse_pssh_box( - moov_data[offset: offset + box_size] + moov_data[offset : offset + box_size] ) if pssh_box: pssh_list.append(pssh_box) @@ -251,11 +274,13 @@ class MP4PSSHExtractor: while offset < len(trak_data): try: - box_size = struct.unpack(">I", trak_data[offset: offset + 4])[0] - box_type = trak_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", trak_data[offset : offset + 4])[0] + box_type = trak_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "mdia": - mdia_data = trak_data[offset: offset + box_size] + mdia_data = trak_data[offset : offset + box_size] pssh_in_mdia = MP4PSSHExtractor._extract_from_mdia(mdia_data) pssh_list.extend(pssh_in_mdia) @@ -274,11 +299,13 @@ class MP4PSSHExtractor: while offset < len(mdia_data): try: - box_size = struct.unpack(">I", mdia_data[offset: offset + 4])[0] - box_type = mdia_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", mdia_data[offset : offset + 4])[0] + box_type = mdia_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "minf": - minf_data = mdia_data[offset: offset + box_size] + minf_data = mdia_data[offset : offset + box_size] pssh_in_minf = MP4PSSHExtractor._extract_from_minf(minf_data) pssh_list.extend(pssh_in_minf) @@ -297,11 +324,13 @@ class MP4PSSHExtractor: while offset < len(minf_data): try: - box_size = struct.unpack(">I", minf_data[offset: offset + 4])[0] - box_type = minf_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", minf_data[offset : offset + 4])[0] + box_type = minf_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "stbl": - stbl_data = minf_data[offset: offset + box_size] + stbl_data = minf_data[offset : offset + box_size] pssh_in_stbl = MP4PSSHExtractor._extract_from_stbl(stbl_data) pssh_list.extend(pssh_in_stbl) @@ -320,11 +349,13 @@ class MP4PSSHExtractor: while offset < len(stbl_data): try: - box_size = struct.unpack(">I", stbl_data[offset: offset + 4])[0] - box_type = stbl_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", stbl_data[offset : offset + 4])[0] + box_type = stbl_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "sinf": - sinf_data = stbl_data[offset: offset + box_size] + sinf_data = stbl_data[offset : offset + box_size] pssh_in_sinf = MP4PSSHExtractor._extract_from_sinf(sinf_data) pssh_list.extend(pssh_in_sinf) @@ -343,11 +374,13 @@ class MP4PSSHExtractor: while offset < len(sinf_data): try: - box_size = struct.unpack(">I", sinf_data[offset: offset + 4])[0] - box_type = sinf_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", sinf_data[offset : offset + 4])[0] + box_type = sinf_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "schi": - schi_data = sinf_data[offset: offset + box_size] + schi_data = sinf_data[offset : offset + box_size] pssh_in_schi = MP4PSSHExtractor._extract_from_schi(schi_data) pssh_list.extend(pssh_in_schi) @@ -366,12 +399,14 @@ class MP4PSSHExtractor: while offset < len(schi_data): try: - box_size = struct.unpack(">I", schi_data[offset: offset + 4])[0] - box_type = schi_data[offset + 4: offset + 8].decode("ascii", errors="ignore") + box_size = struct.unpack(">I", schi_data[offset : offset + 4])[0] + box_type = schi_data[offset + 4 : offset + 8].decode( + "ascii", errors="ignore" + ) if box_type == "pssh": pssh_box = MP4PSSHExtractor._parse_pssh_box( - schi_data[offset: offset + box_size] + schi_data[offset : offset + box_size] ) if pssh_box: pssh_list.append(pssh_box) @@ -413,7 +448,9 @@ class MP4PSSHExtractor: if current_offset + 4 > len(pssh_bytes): return None - kid_count = struct.unpack(">I", pssh_bytes[current_offset: current_offset + 4])[0] + kid_count = struct.unpack( + ">I", pssh_bytes[current_offset : current_offset + 4] + )[0] current_offset += 4 # Read each KID @@ -421,12 +458,14 @@ class MP4PSSHExtractor: if current_offset + 16 > len(pssh_bytes): break - kid_bytes = pssh_bytes[current_offset: current_offset + 16] + kid_bytes = pssh_bytes[current_offset : current_offset + 16] kid_uuid = str(uuid.UUID(bytes=kid_bytes)) key_ids.append(kid_uuid.replace("-", "").lower()) current_offset += 16 else: - logger.debug(f"Version 0 PSSH for system {system_id}, will look for KIDs in tenc box") + logger.debug( + f"Version 0 PSSH for system {system_id}, will look for KIDs in tenc box" + ) # Encode entire PSSH box as base64 pssh_b64 = base64.b64encode(pssh_bytes[:box_size]).decode("ascii") @@ -440,4 +479,4 @@ class MP4PSSHExtractor: except Exception as e: logger.debug(f"Failed to parse PSSH box: {e}") - return None \ No newline at end of file + return None diff --git a/lib/streaming_providers/base/utils/mpd_cache.py b/lib/streaming_providers/base/utils/mpd_cache.py index 1e67ff6..f2b1d92 100644 --- a/lib/streaming_providers/base/utils/mpd_cache.py +++ b/lib/streaming_providers/base/utils/mpd_cache.py @@ -58,7 +58,9 @@ class MPDCacheManager: now = int(time.time()) if now >= expiry: - logger.debug(f"Cache expired for {cache_key} (expired {now - expiry}s ago)") + logger.debug( + f"Cache expired for {cache_key} (expired {now - expiry}s ago)" + ) # Clean up expired cache self.vfs.delete(manifest_file) self.vfs.delete(meta_file) @@ -70,7 +72,9 @@ class MPDCacheManager: logger.info(f"Cache hit for {cache_key} (expires in {expiry - now}s)") return manifest_content else: - logger.warning(f"Cache metadata exists but manifest file missing for {cache_key}") + logger.warning( + f"Cache metadata exists but manifest file missing for {cache_key}" + ) self.vfs.delete(meta_file) return None @@ -131,7 +135,9 @@ class MPDCacheManager: self.vfs.delete(manifest_file) return False - logger.info(f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})") + logger.info( + f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})" + ) return True except Exception as e: diff --git a/lib/streaming_providers/base/utils/mpd_rewriter.py b/lib/streaming_providers/base/utils/mpd_rewriter.py index a731592..76f4fcb 100644 --- a/lib/streaming_providers/base/utils/mpd_rewriter.py +++ b/lib/streaming_providers/base/utils/mpd_rewriter.py @@ -22,8 +22,12 @@ class MPDRewriter: # MPD namespace MPD_NAMESPACE = {"mpd": "urn:mpeg:dash:schema:mpd:2011"} - def __init__(self, media_proxy_url: str, provider_proxy_url: Optional[str] = None, - clearkey_keyids: Optional[dict] = None): + def __init__( + self, + media_proxy_url: str, + provider_proxy_url: Optional[str] = None, + clearkey_keyids: Optional[dict] = None, + ): """ Initialize MPD rewriter @@ -59,60 +63,50 @@ class MPDRewriter: encoded += "=" * padding return base64.urlsafe_b64decode(encoded.encode("utf-8")).decode("utf-8") - def build_proxy_url(self, original_url: str, template_pattern: Optional[str] = None, - segment_type: Optional[str] = None, is_encrypted: bool = True) -> str: + def build_proxy_url( + self, + original_url: str, + template_pattern: Optional[str] = None, + segment_type: Optional[str] = None, + is_encrypted: bool = True, + ) -> str: """ - Build media proxy URL for an original media URL + Build media proxy URL with structured parameter encoding - Args: - original_url: Original URL to be proxied (base path for templates) - template_pattern: Optional template pattern to append (e.g., "segment-$Number$.m4s") - segment_type: Optional segment type ('initialization' or 'media') for selective DRM params - is_encrypted: Whether the segment is encrypted (has ContentProtection) - - Returns: - Media proxy URL + Format: url={original}&key={key}&kid={kid}&proxy={proxy} + Then base64 encode the entire string """ - encoded = self.encode_url(original_url) + # Start with the original URL + param_string = f"url={original_url}" - # Choose endpoint based on whether we have clearkey data AND segment is encrypted + # Add clearkey parameters if needed + if self.clearkey_keyids and is_encrypted: + for kid, key in self.clearkey_keyids.items(): + if segment_type == "initialization": + param_string += f"&kid={kid}" + elif segment_type == "media": + param_string += f"&key={key}" + else: + param_string += f"&kid={kid}&key={key}" + + # Add provider proxy parameter + if self.provider_proxy_url: + param_string += f"&proxy={self.provider_proxy_url}" + + # Encode the complete parameter string + encoded = self.encode_url(param_string) + + # Choose endpoint if self.clearkey_keyids and is_encrypted: proxy_url = f"{self.media_proxy_url}/api/decrypt/{encoded}" else: proxy_url = f"{self.media_proxy_url}/api/proxy/{encoded}" - # Append template pattern if provided (keeps variables visible for client) + # Append template pattern if provided if template_pattern: - # URL encode the template pattern (same as current behavior) encoded_pattern = quote(template_pattern, safe=".-_$") proxy_url += f"/{encoded_pattern}" - # Build query parameters - query_params = [] - - # Add clearkey parameters ONLY if present AND segment is encrypted - if self.clearkey_keyids and is_encrypted: - for kid, key in self.clearkey_keyids.items(): - # Initialization segments: only add kid - # Media segments: only add key - # Unknown/unspecified: add both (backward compatible) - if segment_type == 'initialization': - query_params.append(f"kid={kid}") - elif segment_type == 'media': - query_params.append(f"key={key}") - else: - # Default behavior: add both - query_params.append(f"kid={kid}") - query_params.append(f"key={key}") - - # Add provider proxy parameter if configured (always, for all segments) - if self.provider_proxy_url: - query_params.append(f"proxy={self.provider_proxy_url}") - - # Append query string if we have parameters - if query_params: - proxy_url += "?" + "&".join(query_params) - return proxy_url @staticmethod @@ -142,7 +136,7 @@ class MPDRewriter: return "", url base_path = url[:last_slash_before_template] - template_pattern = url[last_slash_before_template + 1:] + template_pattern = url[last_slash_before_template + 1 :] return base_path, template_pattern @@ -182,11 +176,15 @@ class MPDRewriter: # If we're in decryption mode, identify encrypted AdaptationSets first if self.clearkey_keyids: self._identify_encrypted_adaptation_sets(root) - logger.debug(f"Identified {len(self.encrypted_adaptation_sets)} encrypted AdaptationSets") + logger.debug( + f"Identified {len(self.encrypted_adaptation_sets)} encrypted AdaptationSets" + ) # Then remove ContentProtection elements self._remove_content_protection(root) - logger.debug("Removed ContentProtection elements for decrypted playback") + logger.debug( + "Removed ContentProtection elements for decrypted playback" + ) # Rewrite all URLs in the MPD self._rewrite_urls_recursive(root, base_url) @@ -234,7 +232,9 @@ class MPDRewriter: parsed_manifest = urlparse(manifest_url) manifest_dir = f"{parsed_manifest.scheme}://{parsed_manifest.netloc}{parsed_manifest.path.rsplit('/', 1)[0]}/" resolved_base = urljoin(manifest_dir, base_url_text) - logger.debug(f"Resolved relative BaseURL '{base_url_text}' to: {resolved_base}") + logger.debug( + f"Resolved relative BaseURL '{base_url_text}' to: {resolved_base}" + ) return resolved_base else: # It's already an absolute URL @@ -256,7 +256,9 @@ class MPDRewriter: """ # Find all BaseURL elements at any level for parent in root.findall(".//*"): - for base_url_elem in list(parent.findall("mpd:BaseURL", self.MPD_NAMESPACE)): + for base_url_elem in list( + parent.findall("mpd:BaseURL", self.MPD_NAMESPACE) + ): parent.remove(base_url_elem) logger.debug("Removed BaseURL element") @@ -273,7 +275,9 @@ class MPDRewriter: # Check if this AdaptationSet has ContentProtection if adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE): # Get AdaptationSet ID for tracking - as_id = adaptation_set.get("id", id(adaptation_set)) # Use object id as fallback + as_id = adaptation_set.get( + "id", id(adaptation_set) + ) # Use object id as fallback self.encrypted_adaptation_sets.add(str(as_id)) logger.debug(f"AdaptationSet id={as_id} is encrypted") @@ -287,11 +291,15 @@ class MPDRewriter: """ # Find all ContentProtection elements at any level for parent in root.findall(".//*"): - for cp_elem in list(parent.findall("mpd:ContentProtection", self.MPD_NAMESPACE)): + for cp_elem in list( + parent.findall("mpd:ContentProtection", self.MPD_NAMESPACE) + ): parent.remove(cp_elem) logger.debug("Removed ContentProtection element") - def _is_element_in_encrypted_adaptation_set(self, element: ET.Element, root: ET.Element) -> bool: + def _is_element_in_encrypted_adaptation_set( + self, element: ET.Element, root: ET.Element + ) -> bool: """ Check if an element is within an encrypted AdaptationSet @@ -333,7 +341,9 @@ class MPDRewriter: return True return False - def _rewrite_urls_recursive(self, element: ET.Element, base_url: str, root: Optional[ET.Element] = None) -> None: + def _rewrite_urls_recursive( + self, element: ET.Element, base_url: str, root: Optional[ET.Element] = None + ) -> None: """ Recursively rewrite all URLs in MPD element tree @@ -353,13 +363,13 @@ class MPDRewriter: # Determine segment type based on attribute name segment_type_map = { - 'initialization': 'initialization', - 'media': 'media', - 'sourceURL': None, # Could be either, keep default + "initialization": "initialization", + "media": "media", + "sourceURL": None, # Could be either, keep default } # Rewrite URL attributes in current element - for attr in ['media', 'initialization', 'sourceURL']: + for attr in ["media", "initialization", "sourceURL"]: if attr in element.attrib: original_url = element.attrib[attr] @@ -387,7 +397,9 @@ class MPDRewriter: element.attrib[attr] = self.build_proxy_url( resolved, None, segment_type, is_encrypted ) - logger.debug(f"Rewrote URL ({attr}, encrypted={is_encrypted}): {original_url} -> media proxy") + logger.debug( + f"Rewrote URL ({attr}, encrypted={is_encrypted}): {original_url} -> media proxy" + ) # Handle SegmentURL elements (used in SegmentList) if element.tag.endswith("SegmentURL"): @@ -399,11 +411,11 @@ class MPDRewriter: if "$" in resolved: base_path, template_pattern = self.split_template_url(resolved) element.attrib["media"] = self.build_proxy_url( - base_path, template_pattern, 'media', is_encrypted + base_path, template_pattern, "media", is_encrypted ) else: element.attrib["media"] = self.build_proxy_url( - resolved, None, 'media', is_encrypted + resolved, None, "media", is_encrypted ) # Recurse to child elements @@ -527,4 +539,4 @@ class MPDRewriter: total_seconds = int(hours * 3600 + minutes * 60 + seconds) logger.debug(f"Parsed ISO duration '{duration}' to {total_seconds}s") - return total_seconds \ No newline at end of file + return total_seconds diff --git a/lib/streaming_providers/base/utils/timestamp_converter.py b/lib/streaming_providers/base/utils/timestamp_converter.py index 468500f..0af6fcb 100644 --- a/lib/streaming_providers/base/utils/timestamp_converter.py +++ b/lib/streaming_providers/base/utils/timestamp_converter.py @@ -56,7 +56,9 @@ class TimestampConverter: # Create timezone-aware datetime from epoch if as_utc and timezone is None: # Use UTC timezone - dt = datetime.datetime.fromtimestamp(epoch_seconds, tz=TimestampConverter.UTC) + dt = datetime.datetime.fromtimestamp( + epoch_seconds, tz=TimestampConverter.UTC + ) elif timezone is not None: # Use specified timezone dt = datetime.datetime.fromtimestamp(epoch_seconds, tz=timezone) @@ -126,7 +128,9 @@ class TimestampConverter: return dt.replace(tzinfo=TimestampConverter.UTC).timestamp() @staticmethod - def _parse_custom_iso(iso_string: str, format_type: Optional[str] = None) -> datetime.datetime: + def _parse_custom_iso( + iso_string: str, format_type: Optional[str] = None + ) -> datetime.datetime: """ Parse custom ISO formats not handled by fromisoformat. @@ -168,14 +172,18 @@ class TimestampConverter: except ValueError: # Try without microseconds if microseconds format fails if format_type == "microseconds": - dt = datetime.datetime.strptime(iso_string, TimestampConverter.ISO_EXTENDED) + dt = datetime.datetime.strptime( + iso_string, TimestampConverter.ISO_EXTENDED + ) else: raise return dt @staticmethod - def now_iso(format_type: str = "extended", timezone: Optional[datetime.tzinfo] = None) -> str: + def now_iso( + format_type: str = "extended", timezone: Optional[datetime.tzinfo] = None + ) -> str: """ Get current time as ISO 8601 string. diff --git a/lib/streaming_providers/base/utils/vfs.py b/lib/streaming_providers/base/utils/vfs.py index fd4e830..56835b0 100644 --- a/lib/streaming_providers/base/utils/vfs.py +++ b/lib/streaming_providers/base/utils/vfs.py @@ -36,7 +36,9 @@ class VFS: self._explicit_config_dir = config_dir self._env_manager = get_environment_manager() - logger.debug(f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}") + logger.debug( + f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}" + ) @property def base_path(self) -> str: @@ -52,9 +54,9 @@ class VFS: if self.addon_subdir: if is_kodi_environment(): # Kodi uses forward slashes - self._base_path = os.path.join(profile_path, self.addon_subdir).replace( - "\\", "/" - ) + self._base_path = os.path.join( + profile_path, self.addon_subdir + ).replace("\\", "/") else: # Standard filesystem self._base_path = os.path.join(profile_path, self.addon_subdir) @@ -207,7 +209,9 @@ class VFS: with xbmcvfs.File(filepath, "w") as f: bytes_written = f.write(content) - logger.debug(f"Kodi file write: {bytes_written} bytes to {filepath}") + logger.debug( + f"Kodi file write: {bytes_written} bytes to {filepath}" + ) return bytes_written > 0 else: import pathlib @@ -471,12 +475,16 @@ def get_global_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> # Convenience functions that use VFS cache -def exists(filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> bool: +def exists( + filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "" +) -> bool: """Check if file exists""" return get_vfs(config_dir, addon_subdir).exists(filepath) -def mkdirs(dirpath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> bool: +def mkdirs( + dirpath: str, config_dir: Optional[str] = None, addon_subdir: str = "" +) -> bool: """Create directories""" return get_vfs(config_dir, addon_subdir).mkdirs(dirpath) @@ -520,7 +528,9 @@ def write_json( return get_vfs(config_dir, addon_subdir).write_json(filepath, data, indent) -def delete(filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "") -> bool: +def delete( + filepath: str, config_dir: Optional[str] = None, addon_subdir: str = "" +) -> bool: """Delete file""" return get_vfs(config_dir, addon_subdir).delete(filepath)