This commit is contained in:
Nirvana
2026-01-23 15:52:35 +01:00
parent 4d4ee963cf
commit 91fb27e2fa
8 changed files with 239 additions and 133 deletions
@@ -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()
+3 -1
View File
@@ -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:
@@ -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[^>]*>([^<]+)</(?: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 []
@@ -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
return None
@@ -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:
@@ -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
return total_seconds
@@ -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.
+18 -8
View File
@@ -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)