StreamingCommunity 3.2.1__py3-none-any.whl → 3.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of StreamingCommunity might be problematic. Click here for more details.

Files changed (53) hide show
  1. StreamingCommunity/Api/Player/Helper/Vixcloud/util.py +4 -0
  2. StreamingCommunity/Api/Player/hdplayer.py +2 -2
  3. StreamingCommunity/Api/Player/mixdrop.py +1 -1
  4. StreamingCommunity/Api/Player/vixcloud.py +4 -5
  5. StreamingCommunity/Api/Site/crunchyroll/__init__.py +103 -0
  6. StreamingCommunity/Api/Site/crunchyroll/film.py +83 -0
  7. StreamingCommunity/Api/Site/crunchyroll/series.py +182 -0
  8. StreamingCommunity/Api/Site/crunchyroll/site.py +113 -0
  9. StreamingCommunity/Api/Site/crunchyroll/util/ScrapeSerie.py +218 -0
  10. StreamingCommunity/Api/Site/crunchyroll/util/get_license.py +227 -0
  11. StreamingCommunity/Api/Site/guardaserie/site.py +1 -2
  12. StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py +9 -8
  13. StreamingCommunity/Api/Site/mediasetinfinity/__init__.py +96 -0
  14. StreamingCommunity/Api/Site/mediasetinfinity/film.py +76 -0
  15. StreamingCommunity/Api/Site/mediasetinfinity/series.py +177 -0
  16. StreamingCommunity/Api/Site/mediasetinfinity/site.py +112 -0
  17. StreamingCommunity/Api/Site/mediasetinfinity/util/ScrapeSerie.py +259 -0
  18. StreamingCommunity/Api/Site/mediasetinfinity/util/fix_mpd.py +64 -0
  19. StreamingCommunity/Api/Site/mediasetinfinity/util/get_license.py +217 -0
  20. StreamingCommunity/Api/Site/streamingcommunity/__init__.py +6 -17
  21. StreamingCommunity/Api/Site/streamingcommunity/film.py +2 -2
  22. StreamingCommunity/Api/Site/streamingcommunity/series.py +9 -9
  23. StreamingCommunity/Api/Site/streamingcommunity/site.py +2 -4
  24. StreamingCommunity/Api/Site/streamingcommunity/util/ScrapeSerie.py +3 -6
  25. StreamingCommunity/Api/Site/streamingwatch/__init__.py +6 -14
  26. StreamingCommunity/Api/Site/streamingwatch/film.py +2 -2
  27. StreamingCommunity/Api/Site/streamingwatch/series.py +9 -9
  28. StreamingCommunity/Api/Site/streamingwatch/site.py +5 -7
  29. StreamingCommunity/Api/Site/streamingwatch/util/ScrapeSerie.py +2 -2
  30. StreamingCommunity/Lib/Downloader/DASH/cdm_helpher.py +131 -0
  31. StreamingCommunity/Lib/Downloader/DASH/decrypt.py +79 -0
  32. StreamingCommunity/Lib/Downloader/DASH/downloader.py +220 -0
  33. StreamingCommunity/Lib/Downloader/DASH/parser.py +249 -0
  34. StreamingCommunity/Lib/Downloader/DASH/segments.py +332 -0
  35. StreamingCommunity/Lib/Downloader/HLS/downloader.py +1 -14
  36. StreamingCommunity/Lib/Downloader/HLS/segments.py +3 -3
  37. StreamingCommunity/Lib/Downloader/MP4/downloader.py +0 -5
  38. StreamingCommunity/Lib/FFmpeg/capture.py +3 -3
  39. StreamingCommunity/Lib/FFmpeg/command.py +1 -1
  40. StreamingCommunity/TelegramHelp/config.json +3 -5
  41. StreamingCommunity/Upload/version.py +1 -1
  42. StreamingCommunity/Util/os.py +21 -0
  43. StreamingCommunity/run.py +1 -1
  44. {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.5.dist-info}/METADATA +4 -2
  45. {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.5.dist-info}/RECORD +49 -35
  46. StreamingCommunity/Api/Site/1337xx/__init__.py +0 -72
  47. StreamingCommunity/Api/Site/1337xx/site.py +0 -82
  48. StreamingCommunity/Api/Site/1337xx/title.py +0 -61
  49. StreamingCommunity/Lib/Proxies/proxy.py +0 -72
  50. {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.5.dist-info}/WHEEL +0 -0
  51. {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.5.dist-info}/entry_points.txt +0 -0
  52. {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.5.dist-info}/licenses/LICENSE +0 -0
  53. {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,131 @@
1
+ # 25.07.25
2
+
3
+ import base64
4
+ import logging
5
+ import os
6
+
7
+
8
+ # External libraries
9
+ import httpx
10
+ from rich.console import Console
11
+ from pywidevine.cdm import Cdm
12
+ from pywidevine.device import Device
13
+ from pywidevine.pssh import PSSH
14
+
15
+
16
+ # Variable
17
+ console = Console()
18
+
19
+
20
+ def get_widevine_keys(pssh, license_url, cdm_device_path, headers=None, payload=None):
21
+ """
22
+ Extract Widevine CONTENT keys (KID/KEY) from a license using pywidevine.
23
+
24
+ Args:
25
+ pssh (str): PSSH base64.
26
+ license_url (str): Widevine license URL.
27
+ cdm_device_path (str): Path to CDM file (device.wvd).
28
+ headers (dict): Optional HTTP headers.
29
+
30
+ Returns:
31
+ list: List of dicts {'kid': ..., 'key': ...} (only CONTENT keys) or None if error.
32
+ """
33
+
34
+ # Check if CDM file exists
35
+ if not os.path.isfile(cdm_device_path):
36
+ console.print(f"[bold red] CDM file not found: {cdm_device_path}[/bold red]")
37
+ return None
38
+
39
+ # Check if PSSH is a valid base64 string
40
+ try:
41
+ base64.b64decode(pssh)
42
+ except Exception:
43
+ console.print(f"[bold red] Invalid PSSH base64 string.[/bold red]")
44
+ return None
45
+
46
+ try:
47
+ device = Device.load(cdm_device_path)
48
+ cdm = Cdm.from_device(device)
49
+ session_id = cdm.open()
50
+
51
+ # Display security level in a more readable format
52
+ security_levels = {1: "L1 (Hardware)", 2: "L2 (Software)", 3: "L3 (Software)"}
53
+ security_level_str = security_levels.get(device.security_level, 'Unknown')
54
+ logging.info(f"Security Level: {security_level_str}")
55
+
56
+ # Only allow L3, otherwise warn and exit
57
+ if device.security_level != 3:
58
+ console.print(f"[bold yellow]⚠️ Only L3 (Software) security level is supported. Current: {security_level_str}[/bold yellow]")
59
+ return None
60
+
61
+ try:
62
+ challenge = cdm.get_license_challenge(session_id, PSSH(pssh))
63
+ req_headers = headers or {}
64
+ req_headers['Content-Type'] = 'application/octet-stream'
65
+
66
+ # Send license request
67
+ response = httpx.post(license_url, data=challenge, headers=req_headers, content=payload)
68
+
69
+ if response.status_code != 200:
70
+ console.print(f"[bold red]License error:[/bold red] {response.status_code}")
71
+ return None
72
+
73
+ # Handle (JSON) or classic (binary) license response
74
+ license_data = response.content
75
+ content_type = response.headers.get("Content-Type", "")
76
+ logging.info(f"License data: {license_data}, Content-Type: {content_type}")
77
+
78
+ # Check if license_data is empty
79
+ if not license_data:
80
+ console.print(f"[bold red]License response is empty.[/bold red]")
81
+ return None
82
+
83
+ if "application/json" in content_type:
84
+ try:
85
+
86
+ # Try to decode as JSON only if plausible
87
+ text = response.text
88
+ data = None
89
+ try:
90
+ data = response.json()
91
+ except Exception as e:
92
+ data = None
93
+
94
+ if data and "license" in data:
95
+ license_data = base64.b64decode(data["license"])
96
+
97
+ elif data is not None:
98
+ console.print("[bold red]'license' field not found in JSON response.[/bold red]")
99
+ return None
100
+
101
+ except Exception as e:
102
+ console.print(f"[bold red]Error parsing JSON license:[/bold red] {e}")
103
+
104
+ cdm.parse_license(session_id, license_data)
105
+
106
+ # Extract only CONTENT keys from the license
107
+ content_keys = []
108
+ for key in cdm.get_keys(session_id):
109
+ if key.type == "CONTENT":
110
+ kid = key.kid.hex() if isinstance(key.kid, bytes) else str(key.kid)
111
+ key_val = key.key.hex() if isinstance(key.key, bytes) else str(key.key)
112
+
113
+ content_keys.append({
114
+ 'kid': kid.replace('-', '').strip(),
115
+ 'key': key_val.replace('-', '').strip()
116
+ })
117
+ logging.info(f"Use kid: {kid}, key: {key_val}")
118
+
119
+ # Check if content_keys list is empty
120
+ if not content_keys:
121
+ console.print(f"[bold yellow]⚠️ No CONTENT keys found in license.[/bold yellow]")
122
+ return None
123
+
124
+ return content_keys
125
+
126
+ finally:
127
+ cdm.close(session_id)
128
+
129
+ except Exception as e:
130
+ console.print(f"[bold red]CDM error:[/bold red] {e}")
131
+ return None
@@ -0,0 +1,79 @@
1
+ # 25.07.25
2
+
3
+ import os
4
+ import subprocess
5
+ import logging
6
+
7
+
8
+ # External libraries
9
+ from rich.console import Console
10
+
11
+
12
+ # Variable
13
+ console = Console()
14
+
15
+
16
+ def decrypt_with_mp4decrypt(encrypted_path, kid, key, output_path=None, cleanup=True):
17
+ """
18
+ Decrypt an mp4/m4s file using mp4decrypt.
19
+
20
+ Args:
21
+ encrypted_path (str): Path to encrypted file.
22
+ kid (str): Hexadecimal KID.
23
+ key (str): Hexadecimal key.
24
+ output_path (str): Output decrypted file path (optional).
25
+ cleanup (bool): If True, remove temporary files after decryption.
26
+
27
+ Returns:
28
+ str: Path to decrypted file, or None if error.
29
+ """
30
+
31
+ # Check if input file exists
32
+ if not os.path.isfile(encrypted_path):
33
+ console.print(f"[bold red] Encrypted file not found: {encrypted_path}[/bold red]")
34
+ return None
35
+
36
+ # Check if kid and key are valid hex
37
+ try:
38
+ bytes.fromhex(kid)
39
+ bytes.fromhex(key)
40
+ except Exception:
41
+ console.print(f"[bold red] Invalid KID or KEY (not hex).[/bold red]")
42
+ return None
43
+
44
+ if not output_path:
45
+ output_path = os.path.splitext(encrypted_path)[0] + "_decrypted.mp4"
46
+
47
+ key_format = f"{kid.lower()}:{key.lower()}"
48
+ cmd = ["mp4decrypt", "--key", key_format, encrypted_path, output_path]
49
+ logging.info(f"Running command: {' '.join(cmd)}")
50
+
51
+ try:
52
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
53
+ except Exception as e:
54
+ console.print(f"[bold red] mp4decrypt execution failed: {e}[/bold red]")
55
+ return None
56
+
57
+ if result.returncode == 0 and os.path.exists(output_path):
58
+
59
+ # Cleanup temporary files if requested
60
+ if cleanup:
61
+ if os.path.exists(encrypted_path):
62
+ os.remove(encrypted_path)
63
+
64
+ temp_dec = os.path.splitext(encrypted_path)[0] + "_decrypted.mp4"
65
+
66
+ # Do not delete the final output!
67
+ if temp_dec != output_path and os.path.exists(temp_dec):
68
+ os.remove(temp_dec)
69
+
70
+ # Check if output file is not empty
71
+ if os.path.getsize(output_path) == 0:
72
+ console.print(f"[bold red] Decrypted file is empty: {output_path}[/bold red]")
73
+ return None
74
+
75
+ return output_path
76
+
77
+ else:
78
+ console.print(f"[bold red] mp4decrypt failed:[/bold red] {result.stderr}")
79
+ return None
@@ -0,0 +1,220 @@
1
+ # 25.07.25
2
+
3
+ import os
4
+ import shutil
5
+ from rich.console import Console
6
+
7
+
8
+ # Internal utilities
9
+ from StreamingCommunity.Util.config_json import config_manager
10
+ from StreamingCommunity.Util.os import os_manager
11
+ from StreamingCommunity.Lib.FFmpeg.command import join_audios, join_video
12
+
13
+
14
+ # Logic class
15
+ from .parser import MPDParser
16
+ from .segments import MPD_Segments
17
+ from .decrypt import decrypt_with_mp4decrypt
18
+ from .cdm_helpher import get_widevine_keys
19
+
20
+
21
+ # Config
22
+ ENABLE_AUDIO = config_manager.get_bool('M3U8_DOWNLOAD', 'download_audio')
23
+ DOWNLOAD_SPECIFIC_AUDIO = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_audio')
24
+ CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder')
25
+ FILTER_CUSTOM_REOLUTION = str(config_manager.get('M3U8_PARSER', 'force_resolution')).strip().lower()
26
+
27
+
28
+ # Variable
29
+ console = Console()
30
+
31
+
32
+ class DASH_Download:
33
+ def __init__(self, cdm_device, license_url, mpd_url, output_path):
34
+ self.cdm_device = cdm_device
35
+ self.license_url = license_url
36
+ self.mpd_url = mpd_url
37
+ self.original_output_path = os.path.abspath(str(output_path))
38
+ self.out_path = os.path.splitext(self.original_output_path)[0]
39
+ self.parser = None
40
+ self._setup_temp_dirs()
41
+
42
+ self.error = None
43
+ self.stopped = False
44
+ self.output_file = None
45
+
46
+ def _setup_temp_dirs(self):
47
+ """
48
+ Create temporary folder structure under out_path\tmp
49
+ """
50
+ self.tmp_dir = os.path.join(self.out_path, "tmp")
51
+ self.encrypted_dir = os.path.join(self.tmp_dir, "encrypted")
52
+ self.decrypted_dir = os.path.join(self.tmp_dir, "decrypted")
53
+ self.optimize_dir = os.path.join(self.tmp_dir, "optimize")
54
+
55
+ os.makedirs(self.encrypted_dir, exist_ok=True)
56
+ os.makedirs(self.decrypted_dir, exist_ok=True)
57
+ os.makedirs(self.optimize_dir, exist_ok=True)
58
+
59
+ def parse_manifest(self, custom_headers):
60
+ self.parser = MPDParser(self.mpd_url)
61
+ self.parser.parse(custom_headers)
62
+
63
+ # Video info
64
+ selected_video, list_available_resolution, filter_custom_resolution, downloadable_video = self.parser.select_video(FILTER_CUSTOM_REOLUTION)
65
+ console.print(
66
+ f"[cyan bold]Video [/cyan bold] [green]Available:[/green] [purple]{', '.join(list_available_resolution)}[/purple] | "
67
+ f"[red]Set:[/red] [purple]{filter_custom_resolution}[/purple] | "
68
+ f"[yellow]Downloadable:[/yellow] [purple]{downloadable_video}[/purple]"
69
+ )
70
+ self.selected_video = selected_video
71
+
72
+ # Audio info (only if enabled)
73
+ if ENABLE_AUDIO:
74
+ selected_audio, list_available_audio_langs, filter_custom_audio, downloadable_audio = self.parser.select_audio(DOWNLOAD_SPECIFIC_AUDIO)
75
+ console.print(
76
+ f"[cyan bold]Audio [/cyan bold] [green]Available:[/green] [purple]{', '.join(list_available_audio_langs)}[/purple] | "
77
+ f"[red]Set:[/red] [purple]{filter_custom_audio}[/purple] | "
78
+ f"[yellow]Downloadable:[/yellow] [purple]{downloadable_audio}[/purple]"
79
+ )
80
+ self.selected_audio = selected_audio
81
+ else:
82
+ self.selected_audio = None
83
+
84
+ def get_representation_by_type(self, typ):
85
+ if typ == "video":
86
+ return getattr(self, "selected_video", None)
87
+ elif typ == "audio":
88
+ return getattr(self, "selected_audio", None)
89
+ return None
90
+
91
+ def download_and_decrypt(self, custom_headers=None, custom_payload=None):
92
+ """
93
+ Download and decrypt video/audio streams. Sets self.error, self.stopped, self.output_file.
94
+ Returns True if successful, False otherwise.
95
+ """
96
+ self.error = None
97
+ self.stopped = False
98
+
99
+ for typ in ["video", "audio"]:
100
+ rep = self.get_representation_by_type(typ)
101
+ if rep:
102
+ encrypted_path = os.path.join(self.encrypted_dir, f"{rep['id']}_encrypted.m4s")
103
+
104
+ downloader = MPD_Segments(
105
+ tmp_folder=self.encrypted_dir,
106
+ representation=rep,
107
+ pssh=self.parser.pssh
108
+ )
109
+
110
+ try:
111
+ result = downloader.download_streams()
112
+
113
+ # Check for interruption or failure
114
+ if result.get("stopped"):
115
+ self.stopped = True
116
+ self.error = "Download interrupted"
117
+ return False
118
+
119
+ if result.get("nFailed", 0) > 0:
120
+ self.error = f"Failed segments: {result['nFailed']}"
121
+ return False
122
+
123
+ except Exception as ex:
124
+ self.error = str(ex)
125
+ return False
126
+
127
+ if not self.parser.pssh:
128
+ print("No PSSH found: segments are not encrypted, skipping decryption.")
129
+ self.download_segments(clear=True)
130
+ return True
131
+
132
+ keys = get_widevine_keys(
133
+ pssh=self.parser.pssh,
134
+ license_url=self.license_url,
135
+ cdm_device_path=self.cdm_device,
136
+ headers=custom_headers,
137
+ payload=custom_payload
138
+ )
139
+
140
+ if not keys:
141
+ self.error = f"No key found, cannot decrypt {typ}"
142
+ print(self.error)
143
+ return False
144
+
145
+ key = keys[0]
146
+ KID = key['kid']
147
+ KEY = key['key']
148
+
149
+ decrypted_path = os.path.join(self.decrypted_dir, f"{typ}.mp4")
150
+ result_path = decrypt_with_mp4decrypt(
151
+ encrypted_path, KID, KEY, output_path=decrypted_path
152
+ )
153
+
154
+ if not result_path:
155
+ self.error = f"Decryption of {typ} failed"
156
+ print(self.error)
157
+ return False
158
+
159
+ else:
160
+ self.error = f"No {typ} found"
161
+ print(self.error)
162
+ return False
163
+
164
+ return True
165
+
166
+ def download_segments(self, clear=False):
167
+ # Download segments and concatenate them
168
+ # clear=True: no decryption needed
169
+ pass
170
+
171
+ def finalize_output(self):
172
+ video_file = os.path.join(self.decrypted_dir, "video.mp4")
173
+ audio_file = os.path.join(self.decrypted_dir, "audio.mp4")
174
+
175
+ # fallback: if one of the two is missing, look in encrypted
176
+ if not os.path.exists(video_file):
177
+ for f in os.listdir(self.encrypted_dir):
178
+ if f.endswith("_encrypted.m4s") and ("video" in f or f.startswith("1_")):
179
+ video_file = os.path.join(self.encrypted_dir, f)
180
+ break
181
+ if not os.path.exists(audio_file):
182
+ for f in os.listdir(self.encrypted_dir):
183
+ if f.endswith("_encrypted.m4s") and ("audio" in f or f.startswith("0_")):
184
+ audio_file = os.path.join(self.encrypted_dir, f)
185
+ break
186
+
187
+ # Usa il nome file originale per il file finale
188
+ output_file = self.original_output_path
189
+
190
+ if os.path.exists(video_file) and os.path.exists(audio_file):
191
+ audio_tracks = [{"path": audio_file}]
192
+ join_audios(video_file, audio_tracks, output_file)
193
+ elif os.path.exists(video_file):
194
+ join_video(video_file, output_file, codec=None)
195
+ else:
196
+ print("Video file missing, cannot export")
197
+
198
+ # Clean up: delete all tmp
199
+ if os.path.exists(self.tmp_dir):
200
+ shutil.rmtree(self.tmp_dir, ignore_errors=True)
201
+
202
+ # Rimuovi la cartella principale se è vuota
203
+ try:
204
+ if os.path.exists(self.out_path) and not os.listdir(self.out_path):
205
+ os.rmdir(self.out_path)
206
+ except Exception as e:
207
+ print(f"[WARN] Impossibile eliminare la cartella {self.out_path}: {e}")
208
+
209
+
210
+ return self.output_file
211
+
212
+ def get_status(self):
213
+ """
214
+ Returns a dict with 'path', 'error', and 'stopped' for external use.
215
+ """
216
+ return {
217
+ "path": self.output_file,
218
+ "error": self.error,
219
+ "stopped": self.stopped
220
+ }
@@ -0,0 +1,249 @@
1
+ # 25.07.25
2
+
3
+ from urllib.parse import urljoin
4
+ import xml.etree.ElementTree as ET
5
+
6
+
7
+ # External library
8
+ import httpx
9
+ from rich.console import Console
10
+
11
+
12
+ # Internal utilities
13
+ from StreamingCommunity.Util.config_json import config_manager
14
+
15
+
16
+ # Variable
17
+ console = Console()
18
+ max_timeout = config_manager.get_int('REQUESTS', 'timeout')
19
+
20
+
21
+ class MPDParser:
22
+ @staticmethod
23
+ def get_best(representations):
24
+ """
25
+ Returns the video representation with the highest resolution/bandwidth, or audio with highest bandwidth.
26
+ """
27
+ videos = [r for r in representations if r['type'] == 'video']
28
+ audios = [r for r in representations if r['type'] == 'audio']
29
+ if videos:
30
+ return max(videos, key=lambda r: (r['height'], r['width'], r['bandwidth']))
31
+ elif audios:
32
+ return max(audios, key=lambda r: r['bandwidth'])
33
+ return None
34
+
35
+ @staticmethod
36
+ def get_worst(representations):
37
+ """
38
+ Returns the video representation with the lowest resolution/bandwidth, or audio with lowest bandwidth.
39
+ """
40
+ videos = [r for r in representations if r['type'] == 'video']
41
+ audios = [r for r in representations if r['type'] == 'audio']
42
+ if videos:
43
+ return min(videos, key=lambda r: (r['height'], r['width'], r['bandwidth']))
44
+ elif audios:
45
+ return min(audios, key=lambda r: r['bandwidth'])
46
+ return None
47
+
48
+ @staticmethod
49
+ def get_list(representations, type_filter=None):
50
+ """
51
+ Returns the list of representations filtered by type ('video', 'audio', etc.).
52
+ """
53
+ if type_filter:
54
+ return [r for r in representations if r['type'] == type_filter]
55
+ return representations
56
+
57
+ def __init__(self, mpd_url):
58
+ self.mpd_url = mpd_url
59
+ self.pssh = None
60
+ self.representations = []
61
+ self.base_url = mpd_url.rsplit('/', 1)[0] + '/'
62
+
63
+ def parse(self, custom_headers):
64
+ response = httpx.get(self.mpd_url, headers=custom_headers, timeout=max_timeout, follow_redirects=True)
65
+ response.raise_for_status()
66
+
67
+ root = ET.fromstring(response.content)
68
+
69
+ # Properly handle default namespace
70
+ ns = {}
71
+ if root.tag.startswith('{'):
72
+ uri = root.tag[1:].split('}')[0]
73
+ ns['mpd'] = uri
74
+ ns['cenc'] = 'urn:mpeg:cenc:2013'
75
+
76
+ # Extract PSSH dynamically: take the first <cenc:pssh> found
77
+ for protection in root.findall('.//mpd:ContentProtection', ns):
78
+ pssh_element = protection.find('cenc:pssh', ns)
79
+ if pssh_element is not None and pssh_element.text:
80
+ self.pssh = pssh_element.text
81
+ break
82
+
83
+ if not self.pssh:
84
+ console.print("[bold red]PSSH not found in MPD![/bold red]")
85
+
86
+ # Extract representations
87
+ for adapt_set in root.findall('.//mpd:AdaptationSet', ns):
88
+ mime_type = adapt_set.get('mimeType', '')
89
+ lang = adapt_set.get('lang', '')
90
+
91
+ # Find SegmentTemplate at AdaptationSet level (DASH spec allows this)
92
+ seg_template = adapt_set.find('mpd:SegmentTemplate', ns)
93
+
94
+ for rep in adapt_set.findall('mpd:Representation', ns):
95
+ rep_id = rep.get('id')
96
+ bandwidth = rep.get('bandwidth')
97
+ codecs = rep.get('codecs')
98
+ width = rep.get('width')
99
+ height = rep.get('height')
100
+
101
+ # Try to find SegmentTemplate at Representation level (overrides AdaptationSet)
102
+ rep_seg_template = rep.find('mpd:SegmentTemplate', ns)
103
+ seg_tmpl = rep_seg_template if rep_seg_template is not None else seg_template
104
+ if seg_tmpl is None:
105
+ continue
106
+
107
+ init = seg_tmpl.get('initialization')
108
+ media = seg_tmpl.get('media')
109
+ start_number = int(seg_tmpl.get('startNumber', 1))
110
+
111
+ # Use BaseURL from Representation if present, else fallback to self.base_url
112
+ base_url_elem = rep.find('mpd:BaseURL', ns)
113
+ base_url = base_url_elem.text if base_url_elem is not None else self.base_url
114
+
115
+ # Replace $RepresentationID$ in init/media if present
116
+ if init and '$RepresentationID$' in init:
117
+ init = init.replace('$RepresentationID$', rep_id)
118
+ if media and '$RepresentationID$' in media:
119
+ media = media.replace('$RepresentationID$', rep_id)
120
+
121
+ init_url = urljoin(base_url, init) if init else None
122
+
123
+ # Calculate segments from timeline
124
+ segments = []
125
+ seg_timeline = seg_tmpl.find('mpd:SegmentTimeline', ns)
126
+ if seg_timeline is not None:
127
+ segment_number = start_number
128
+ for s in seg_timeline.findall('mpd:S', ns):
129
+ repeat = int(s.get('r', 0))
130
+
131
+ # Always append at least one segment
132
+ segments.append(segment_number)
133
+ segment_number += 1
134
+ for _ in range(repeat):
135
+ segments.append(segment_number)
136
+ segment_number += 1
137
+
138
+ if not segments:
139
+ segments = list(range(start_number, start_number + 100))
140
+
141
+ # Replace $Number$ and $RepresentationID$ in media URL
142
+ media_urls = []
143
+ for n in segments:
144
+ url = media
145
+ if '$Number$' in url:
146
+ url = url.replace('$Number$', str(n))
147
+ if '$RepresentationID$' in url:
148
+ url = url.replace('$RepresentationID$', rep_id)
149
+ media_urls.append(urljoin(base_url, url))
150
+
151
+ self.representations.append({
152
+ 'id': rep_id,
153
+ 'type': mime_type.split('/')[0] if mime_type else (rep.get('mimeType', '').split('/')[0] if rep.get('mimeType') else 'unknown'),
154
+ 'codec': codecs,
155
+ 'bandwidth': int(bandwidth) if bandwidth else 0,
156
+ 'width': int(width) if width else 0,
157
+ 'height': int(height) if height else 0,
158
+ 'language': lang,
159
+ 'init_url': init_url,
160
+ 'segment_urls': media_urls
161
+ })
162
+
163
+ def get_resolutions(self):
164
+ """Return list of video representations with their resolutions."""
165
+ return [
166
+ rep for rep in self.representations
167
+ if rep['type'] == 'video'
168
+ ]
169
+
170
+ def get_audios(self):
171
+ """Return list of audio representations."""
172
+ return [
173
+ rep for rep in self.representations
174
+ if rep['type'] == 'audio'
175
+ ]
176
+
177
+ def get_best_video(self):
178
+ """Return the best video representation (highest resolution, then bandwidth)."""
179
+ videos = self.get_resolutions()
180
+ if not videos:
181
+ return None
182
+
183
+ # Sort by (height, width, bandwidth)
184
+ return max(videos, key=lambda r: (r['height'], r['width'], r['bandwidth']))
185
+
186
+ def get_best_audio(self):
187
+ """Return the best audio representation (highest bandwidth)."""
188
+ audios = self.get_audios()
189
+ if not audios:
190
+ return None
191
+ return max(audios, key=lambda r: r['bandwidth'])
192
+
193
+ def select_video(self, force_resolution="Best"):
194
+ """
195
+ Select a video representation based on the requested resolution.
196
+ Returns: (selected_video, list_available_resolution, filter_custom_resolution, downloadable_video)
197
+ """
198
+ video_reps = self.get_resolutions()
199
+ list_available_resolution = [
200
+ f"{rep['width']}x{rep['height']}" for rep in video_reps
201
+ ]
202
+ force_resolution_l = (force_resolution or "Best").lower()
203
+
204
+ if force_resolution_l == "best":
205
+ selected_video = self.get_best_video()
206
+ filter_custom_resolution = "Best"
207
+
208
+ elif force_resolution_l == "worst":
209
+ selected_video = MPDParser.get_worst(video_reps)
210
+ filter_custom_resolution = "Worst"
211
+
212
+ else:
213
+ selected_video = self.get_best_video()
214
+ filter_custom_resolution = "Best"
215
+
216
+ downloadable_video = f"{selected_video['width']}x{selected_video['height']}" if selected_video else "N/A"
217
+ return selected_video, list_available_resolution, filter_custom_resolution, downloadable_video
218
+
219
+ def select_audio(self, preferred_audio_langs=None):
220
+ """
221
+ Select an audio representation based on preferred languages.
222
+ Returns: (selected_audio, list_available_audio_langs, filter_custom_audio, downloadable_audio)
223
+ """
224
+ audio_reps = self.get_audios()
225
+ list_available_audio_langs = [
226
+ rep['language'] or "None" for rep in audio_reps
227
+ ]
228
+
229
+ selected_audio = None
230
+ filter_custom_audio = "First"
231
+
232
+ if preferred_audio_langs:
233
+
234
+ # Search for the first available language in order of preference
235
+ for lang in preferred_audio_langs:
236
+ for rep in audio_reps:
237
+ if (rep['language'] or "None").lower() == lang.lower():
238
+ selected_audio = rep
239
+ filter_custom_audio = lang
240
+ break
241
+ if selected_audio:
242
+ break
243
+ if not selected_audio:
244
+ selected_audio = self.get_best_audio()
245
+ else:
246
+ selected_audio = self.get_best_audio()
247
+
248
+ downloadable_audio = selected_audio['language'] or "None" if selected_audio else "N/A"
249
+ return selected_audio, list_available_audio_langs, filter_custom_audio, downloadable_audio