StreamingCommunity 3.2.0__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.
- StreamingCommunity/Api/Player/Helper/Vixcloud/util.py +4 -0
- StreamingCommunity/Api/Player/hdplayer.py +2 -2
- StreamingCommunity/Api/Player/mixdrop.py +1 -1
- StreamingCommunity/Api/Player/vixcloud.py +4 -5
- StreamingCommunity/Api/Site/animeunity/__init__.py +2 -2
- StreamingCommunity/Api/Site/crunchyroll/__init__.py +103 -0
- StreamingCommunity/Api/Site/crunchyroll/film.py +83 -0
- StreamingCommunity/Api/Site/crunchyroll/series.py +182 -0
- StreamingCommunity/Api/Site/crunchyroll/site.py +113 -0
- StreamingCommunity/Api/Site/crunchyroll/util/ScrapeSerie.py +218 -0
- StreamingCommunity/Api/Site/crunchyroll/util/get_license.py +227 -0
- StreamingCommunity/Api/Site/guardaserie/site.py +1 -2
- StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py +9 -8
- StreamingCommunity/Api/Site/mediasetinfinity/__init__.py +96 -0
- StreamingCommunity/Api/Site/mediasetinfinity/film.py +76 -0
- StreamingCommunity/Api/Site/mediasetinfinity/series.py +177 -0
- StreamingCommunity/Api/Site/mediasetinfinity/site.py +112 -0
- StreamingCommunity/Api/Site/mediasetinfinity/util/ScrapeSerie.py +259 -0
- StreamingCommunity/Api/Site/mediasetinfinity/util/fix_mpd.py +64 -0
- StreamingCommunity/Api/Site/mediasetinfinity/util/get_license.py +217 -0
- StreamingCommunity/Api/Site/streamingcommunity/__init__.py +6 -17
- StreamingCommunity/Api/Site/streamingcommunity/film.py +2 -2
- StreamingCommunity/Api/Site/streamingcommunity/series.py +9 -9
- StreamingCommunity/Api/Site/streamingcommunity/site.py +3 -4
- StreamingCommunity/Api/Site/streamingcommunity/util/ScrapeSerie.py +3 -6
- StreamingCommunity/Api/Site/streamingwatch/__init__.py +6 -14
- StreamingCommunity/Api/Site/streamingwatch/film.py +2 -2
- StreamingCommunity/Api/Site/streamingwatch/series.py +9 -9
- StreamingCommunity/Api/Site/streamingwatch/site.py +5 -7
- StreamingCommunity/Api/Site/streamingwatch/util/ScrapeSerie.py +2 -2
- StreamingCommunity/Lib/Downloader/DASH/cdm_helpher.py +131 -0
- StreamingCommunity/Lib/Downloader/DASH/decrypt.py +79 -0
- StreamingCommunity/Lib/Downloader/DASH/downloader.py +220 -0
- StreamingCommunity/Lib/Downloader/DASH/parser.py +249 -0
- StreamingCommunity/Lib/Downloader/DASH/segments.py +332 -0
- StreamingCommunity/Lib/Downloader/HLS/downloader.py +1 -14
- StreamingCommunity/Lib/Downloader/HLS/segments.py +3 -3
- StreamingCommunity/Lib/Downloader/MP4/downloader.py +0 -5
- StreamingCommunity/Lib/FFmpeg/capture.py +3 -3
- StreamingCommunity/Lib/FFmpeg/command.py +1 -1
- StreamingCommunity/TelegramHelp/config.json +3 -5
- StreamingCommunity/Upload/version.py +2 -2
- StreamingCommunity/Util/os.py +21 -0
- StreamingCommunity/run.py +1 -1
- {streamingcommunity-3.2.0.dist-info → streamingcommunity-3.2.5.dist-info}/METADATA +4 -2
- {streamingcommunity-3.2.0.dist-info → streamingcommunity-3.2.5.dist-info}/RECORD +50 -36
- StreamingCommunity/Api/Site/1337xx/__init__.py +0 -72
- StreamingCommunity/Api/Site/1337xx/site.py +0 -82
- StreamingCommunity/Api/Site/1337xx/title.py +0 -61
- StreamingCommunity/Lib/Proxies/proxy.py +0 -72
- {streamingcommunity-3.2.0.dist-info → streamingcommunity-3.2.5.dist-info}/WHEEL +0 -0
- {streamingcommunity-3.2.0.dist-info → streamingcommunity-3.2.5.dist-info}/entry_points.txt +0 -0
- {streamingcommunity-3.2.0.dist-info → streamingcommunity-3.2.5.dist-info}/licenses/LICENSE +0 -0
- {streamingcommunity-3.2.0.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
|