StreamingCommunity 3.2.1__py3-none-any.whl → 3.2.7__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/altadefinizione/film.py +2 -2
- StreamingCommunity/Api/Site/altadefinizione/series.py +1 -1
- StreamingCommunity/Api/Site/animeunity/serie.py +1 -1
- StreamingCommunity/Api/Site/animeworld/film.py +1 -1
- StreamingCommunity/Api/Site/animeworld/serie.py +1 -2
- StreamingCommunity/Api/Site/cb01new/film.py +1 -1
- StreamingCommunity/Api/Site/crunchyroll/__init__.py +103 -0
- StreamingCommunity/Api/Site/crunchyroll/film.py +82 -0
- StreamingCommunity/Api/Site/crunchyroll/series.py +186 -0
- StreamingCommunity/Api/Site/crunchyroll/site.py +113 -0
- StreamingCommunity/Api/Site/crunchyroll/util/ScrapeSerie.py +238 -0
- StreamingCommunity/Api/Site/crunchyroll/util/get_license.py +227 -0
- StreamingCommunity/Api/Site/guardaserie/series.py +1 -2
- 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 +85 -0
- StreamingCommunity/Api/Site/mediasetinfinity/series.py +185 -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 +214 -0
- StreamingCommunity/Api/Site/raiplay/film.py +2 -2
- StreamingCommunity/Api/Site/raiplay/series.py +2 -1
- StreamingCommunity/Api/Site/streamingcommunity/__init__.py +6 -17
- StreamingCommunity/Api/Site/streamingcommunity/film.py +3 -3
- StreamingCommunity/Api/Site/streamingcommunity/series.py +11 -11
- StreamingCommunity/Api/Site/streamingcommunity/site.py +2 -4
- StreamingCommunity/Api/Site/streamingcommunity/util/ScrapeSerie.py +3 -6
- StreamingCommunity/Api/Site/streamingwatch/__init__.py +6 -14
- StreamingCommunity/Api/Site/streamingwatch/film.py +3 -3
- 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 +218 -0
- StreamingCommunity/Lib/Downloader/DASH/parser.py +249 -0
- StreamingCommunity/Lib/Downloader/DASH/segments.py +332 -0
- StreamingCommunity/Lib/Downloader/HLS/downloader.py +10 -30
- StreamingCommunity/Lib/Downloader/HLS/segments.py +146 -263
- 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 -7
- StreamingCommunity/Upload/version.py +1 -1
- StreamingCommunity/Util/bento4_installer.py +191 -0
- StreamingCommunity/Util/config_json.py +1 -1
- StreamingCommunity/Util/headers.py +0 -3
- StreamingCommunity/Util/os.py +36 -46
- StreamingCommunity/__init__.py +2 -1
- StreamingCommunity/run.py +11 -10
- {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.7.dist-info}/METADATA +7 -9
- streamingcommunity-3.2.7.dist-info/RECORD +111 -0
- 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.1.dist-info/RECORD +0 -96
- {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.7.dist-info}/WHEEL +0 -0
- {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.7.dist-info}/entry_points.txt +0 -0
- {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.7.dist-info}/licenses/LICENSE +0 -0
- {streamingcommunity-3.2.1.dist-info → streamingcommunity-3.2.7.dist-info}/top_level.txt +0 -0
|
@@ -36,7 +36,7 @@ msg = Prompt()
|
|
|
36
36
|
console = Console()
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def download_video(index_season_selected: int, index_episode_selected: int, scrape_serie: GetSerieInfo
|
|
39
|
+
def download_video(index_season_selected: int, index_episode_selected: int, scrape_serie: GetSerieInfo) -> Tuple[str,bool]:
|
|
40
40
|
"""
|
|
41
41
|
Downloads a specific episode from a specified season.
|
|
42
42
|
|
|
@@ -60,7 +60,7 @@ def download_video(index_season_selected: int, index_episode_selected: int, scra
|
|
|
60
60
|
mp4_path = os.path.join(site_constant.SERIES_FOLDER, scrape_serie.series_name, f"S{index_season_selected}")
|
|
61
61
|
|
|
62
62
|
# Retrieve scws and if available master playlist
|
|
63
|
-
video_source = VideoSource(
|
|
63
|
+
video_source = VideoSource()
|
|
64
64
|
master_playlist = video_source.get_m3u8_url(obj_episode.url)
|
|
65
65
|
|
|
66
66
|
# Download the episode
|
|
@@ -76,7 +76,7 @@ def download_video(index_season_selected: int, index_episode_selected: int, scra
|
|
|
76
76
|
return r_proc['path'], r_proc['stopped']
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, download_all: bool = False, episode_selection: str = None
|
|
79
|
+
def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, download_all: bool = False, episode_selection: str = None) -> None:
|
|
80
80
|
"""
|
|
81
81
|
Handle downloading episodes for a specific season.
|
|
82
82
|
|
|
@@ -92,7 +92,7 @@ def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, dow
|
|
|
92
92
|
|
|
93
93
|
if download_all:
|
|
94
94
|
for i_episode in range(1, episodes_count + 1):
|
|
95
|
-
path, stopped = download_video(index_season_selected, i_episode, scrape_serie
|
|
95
|
+
path, stopped = download_video(index_season_selected, i_episode, scrape_serie)
|
|
96
96
|
|
|
97
97
|
if stopped:
|
|
98
98
|
break
|
|
@@ -113,12 +113,12 @@ def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, dow
|
|
|
113
113
|
|
|
114
114
|
# Download selected episodes if not stopped
|
|
115
115
|
for i_episode in list_episode_select:
|
|
116
|
-
path, stopped = download_video(index_season_selected, i_episode, scrape_serie
|
|
116
|
+
path, stopped = download_video(index_season_selected, i_episode, scrape_serie)
|
|
117
117
|
|
|
118
118
|
if stopped:
|
|
119
119
|
break
|
|
120
120
|
|
|
121
|
-
def download_series(select_season: MediaItem, season_selection: str = None, episode_selection: str = None
|
|
121
|
+
def download_series(select_season: MediaItem, season_selection: str = None, episode_selection: str = None) -> None:
|
|
122
122
|
"""
|
|
123
123
|
Handle downloading a complete series.
|
|
124
124
|
|
|
@@ -127,7 +127,7 @@ def download_series(select_season: MediaItem, season_selection: str = None, epis
|
|
|
127
127
|
- season_selection (str, optional): Pre-defined season selection that bypasses manual input
|
|
128
128
|
- episode_selection (str, optional): Pre-defined episode selection that bypasses manual input
|
|
129
129
|
"""
|
|
130
|
-
scrape_serie = GetSerieInfo(select_season.url
|
|
130
|
+
scrape_serie = GetSerieInfo(select_season.url)
|
|
131
131
|
|
|
132
132
|
# Get total number of seasons
|
|
133
133
|
seasons_count = scrape_serie.getNumberSeason()
|
|
@@ -154,7 +154,7 @@ def download_series(select_season: MediaItem, season_selection: str = None, epis
|
|
|
154
154
|
for i_season in list_season_select:
|
|
155
155
|
if len(list_season_select) > 1 or index_season_selected == "*":
|
|
156
156
|
# Download all episodes if multiple seasons are selected or if '*' is used
|
|
157
|
-
download_episode(i_season, scrape_serie, download_all=True
|
|
157
|
+
download_episode(i_season, scrape_serie, download_all=True)
|
|
158
158
|
else:
|
|
159
159
|
# Otherwise, let the user select specific episodes for the single season
|
|
160
|
-
download_episode(i_season, scrape_serie, download_all=False, episode_selection=episode_selection
|
|
160
|
+
download_episode(i_season, scrape_serie, download_all=False, episode_selection=episode_selection)
|
|
@@ -27,13 +27,12 @@ table_show_manager = TVShowManager()
|
|
|
27
27
|
max_timeout = config_manager.get_int("REQUESTS", "timeout")
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def extract_nonce(
|
|
30
|
+
def extract_nonce() -> str:
|
|
31
31
|
"""Extract nonce value from the page script"""
|
|
32
32
|
response = httpx.get(
|
|
33
33
|
site_constant.FULL_URL,
|
|
34
34
|
headers={'user-agent': get_userAgent()},
|
|
35
|
-
timeout=max_timeout
|
|
36
|
-
proxy=proxy
|
|
35
|
+
timeout=max_timeout
|
|
37
36
|
)
|
|
38
37
|
|
|
39
38
|
soup = BeautifulSoup(response.content, 'html.parser')
|
|
@@ -45,7 +44,7 @@ def extract_nonce(proxy) -> str:
|
|
|
45
44
|
return ""
|
|
46
45
|
|
|
47
46
|
|
|
48
|
-
def title_search(query: str
|
|
47
|
+
def title_search(query: str) -> int:
|
|
49
48
|
"""
|
|
50
49
|
Search for titles based on a search query.
|
|
51
50
|
|
|
@@ -62,7 +61,7 @@ def title_search(query: str, proxy: str) -> int:
|
|
|
62
61
|
console.print(f"[cyan]Search url: [yellow]{search_url}")
|
|
63
62
|
|
|
64
63
|
try:
|
|
65
|
-
_wpnonce = extract_nonce(
|
|
64
|
+
_wpnonce = extract_nonce()
|
|
66
65
|
|
|
67
66
|
if not _wpnonce:
|
|
68
67
|
console.print("[red]Error: Failed to extract nonce")
|
|
@@ -81,8 +80,7 @@ def title_search(query: str, proxy: str) -> int:
|
|
|
81
80
|
'user-agent': get_userAgent()
|
|
82
81
|
},
|
|
83
82
|
data=data,
|
|
84
|
-
timeout=max_timeout
|
|
85
|
-
proxy=proxy
|
|
83
|
+
timeout=max_timeout
|
|
86
84
|
)
|
|
87
85
|
response.raise_for_status()
|
|
88
86
|
soup = BeautifulSoup(response.text, 'html.parser')
|
|
@@ -19,13 +19,13 @@ max_timeout = config_manager.get_int("REQUESTS", "timeout")
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class GetSerieInfo:
|
|
22
|
-
def __init__(self, url
|
|
22
|
+
def __init__(self, url):
|
|
23
23
|
self.headers = {'user-agent': get_userAgent()}
|
|
24
24
|
self.url = url
|
|
25
25
|
self.seasons_manager = SeasonManager()
|
|
26
26
|
self.series_name = None
|
|
27
27
|
|
|
28
|
-
self.client = httpx.Client(headers=self.headers,
|
|
28
|
+
self.client = httpx.Client(headers=self.headers, timeout=max_timeout)
|
|
29
29
|
|
|
30
30
|
def collect_info_season(self) -> None:
|
|
31
31
|
"""
|
|
@@ -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,218 @@
|
|
|
1
|
+
# 25.07.25
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# External libraries
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Internal utilities
|
|
12
|
+
from StreamingCommunity.Util.config_json import config_manager
|
|
13
|
+
from StreamingCommunity.Lib.FFmpeg.command import join_audios, join_video
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Logic class
|
|
17
|
+
from .parser import MPDParser
|
|
18
|
+
from .segments import MPD_Segments
|
|
19
|
+
from .decrypt import decrypt_with_mp4decrypt
|
|
20
|
+
from .cdm_helpher import get_widevine_keys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Config
|
|
24
|
+
DOWNLOAD_SPECIFIC_AUDIO = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_audio')
|
|
25
|
+
FILTER_CUSTOM_REOLUTION = str(config_manager.get('M3U8_PARSER', 'force_resolution')).strip().lower()
|
|
26
|
+
CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Variable
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DASH_Downloader:
|
|
34
|
+
def __init__(self, cdm_device, license_url, mpd_url, output_path):
|
|
35
|
+
self.cdm_device = cdm_device
|
|
36
|
+
self.license_url = license_url
|
|
37
|
+
self.mpd_url = mpd_url
|
|
38
|
+
self.original_output_path = os.path.abspath(str(output_path))
|
|
39
|
+
self.out_path = os.path.splitext(self.original_output_path)[0]
|
|
40
|
+
self.parser = None
|
|
41
|
+
self._setup_temp_dirs()
|
|
42
|
+
|
|
43
|
+
self.error = None
|
|
44
|
+
self.stopped = False
|
|
45
|
+
self.output_file = None
|
|
46
|
+
|
|
47
|
+
def _setup_temp_dirs(self):
|
|
48
|
+
"""
|
|
49
|
+
Create temporary folder structure under out_path\tmp
|
|
50
|
+
"""
|
|
51
|
+
self.tmp_dir = os.path.join(self.out_path, "tmp")
|
|
52
|
+
self.encrypted_dir = os.path.join(self.tmp_dir, "encrypted")
|
|
53
|
+
self.decrypted_dir = os.path.join(self.tmp_dir, "decrypted")
|
|
54
|
+
self.optimize_dir = os.path.join(self.tmp_dir, "optimize")
|
|
55
|
+
|
|
56
|
+
os.makedirs(self.encrypted_dir, exist_ok=True)
|
|
57
|
+
os.makedirs(self.decrypted_dir, exist_ok=True)
|
|
58
|
+
os.makedirs(self.optimize_dir, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
def parse_manifest(self, custom_headers):
|
|
61
|
+
self.parser = MPDParser(self.mpd_url)
|
|
62
|
+
self.parser.parse(custom_headers)
|
|
63
|
+
|
|
64
|
+
# Video info
|
|
65
|
+
selected_video, list_available_resolution, filter_custom_resolution, downloadable_video = self.parser.select_video(FILTER_CUSTOM_REOLUTION)
|
|
66
|
+
console.print(
|
|
67
|
+
f"[cyan bold]Video [/cyan bold] [green]Available:[/green] [purple]{', '.join(list_available_resolution)}[/purple] | "
|
|
68
|
+
f"[red]Set:[/red] [purple]{filter_custom_resolution}[/purple] | "
|
|
69
|
+
f"[yellow]Downloadable:[/yellow] [purple]{downloadable_video}[/purple]"
|
|
70
|
+
)
|
|
71
|
+
self.selected_video = selected_video
|
|
72
|
+
|
|
73
|
+
# Audio info
|
|
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
|
+
|
|
82
|
+
def get_representation_by_type(self, typ):
|
|
83
|
+
if typ == "video":
|
|
84
|
+
return getattr(self, "selected_video", None)
|
|
85
|
+
elif typ == "audio":
|
|
86
|
+
return getattr(self, "selected_audio", None)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def download_and_decrypt(self, custom_headers=None, custom_payload=None):
|
|
90
|
+
"""
|
|
91
|
+
Download and decrypt video/audio streams. Sets self.error, self.stopped, self.output_file.
|
|
92
|
+
Returns True if successful, False otherwise.
|
|
93
|
+
"""
|
|
94
|
+
self.error = None
|
|
95
|
+
self.stopped = False
|
|
96
|
+
|
|
97
|
+
for typ in ["video", "audio"]:
|
|
98
|
+
rep = self.get_representation_by_type(typ)
|
|
99
|
+
if rep:
|
|
100
|
+
encrypted_path = os.path.join(self.encrypted_dir, f"{rep['id']}_encrypted.m4s")
|
|
101
|
+
|
|
102
|
+
downloader = MPD_Segments(
|
|
103
|
+
tmp_folder=self.encrypted_dir,
|
|
104
|
+
representation=rep,
|
|
105
|
+
pssh=self.parser.pssh
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
result = downloader.download_streams()
|
|
110
|
+
|
|
111
|
+
# Check for interruption or failure
|
|
112
|
+
if result.get("stopped"):
|
|
113
|
+
self.stopped = True
|
|
114
|
+
self.error = "Download interrupted"
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
if result.get("nFailed", 0) > 0:
|
|
118
|
+
self.error = f"Failed segments: {result['nFailed']}"
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
except Exception as ex:
|
|
122
|
+
self.error = str(ex)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
if not self.parser.pssh:
|
|
126
|
+
print("No PSSH found: segments are not encrypted, skipping decryption.")
|
|
127
|
+
self.download_segments(clear=True)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
keys = get_widevine_keys(
|
|
131
|
+
pssh=self.parser.pssh,
|
|
132
|
+
license_url=self.license_url,
|
|
133
|
+
cdm_device_path=self.cdm_device,
|
|
134
|
+
headers=custom_headers,
|
|
135
|
+
payload=custom_payload
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not keys:
|
|
139
|
+
self.error = f"No key found, cannot decrypt {typ}"
|
|
140
|
+
print(self.error)
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
key = keys[0]
|
|
144
|
+
KID = key['kid']
|
|
145
|
+
KEY = key['key']
|
|
146
|
+
|
|
147
|
+
decrypted_path = os.path.join(self.decrypted_dir, f"{typ}.mp4")
|
|
148
|
+
result_path = decrypt_with_mp4decrypt(
|
|
149
|
+
encrypted_path, KID, KEY, output_path=decrypted_path
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if not result_path:
|
|
153
|
+
self.error = f"Decryption of {typ} failed"
|
|
154
|
+
print(self.error)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
else:
|
|
158
|
+
self.error = f"No {typ} found"
|
|
159
|
+
print(self.error)
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
def download_segments(self, clear=False):
|
|
165
|
+
# Download segments and concatenate them
|
|
166
|
+
# clear=True: no decryption needed
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def finalize_output(self):
|
|
170
|
+
video_file = os.path.join(self.decrypted_dir, "video.mp4")
|
|
171
|
+
audio_file = os.path.join(self.decrypted_dir, "audio.mp4")
|
|
172
|
+
|
|
173
|
+
# fallback: if one of the two is missing, look in encrypted
|
|
174
|
+
if not os.path.exists(video_file):
|
|
175
|
+
for f in os.listdir(self.encrypted_dir):
|
|
176
|
+
if f.endswith("_encrypted.m4s") and ("video" in f or f.startswith("1_")):
|
|
177
|
+
video_file = os.path.join(self.encrypted_dir, f)
|
|
178
|
+
break
|
|
179
|
+
if not os.path.exists(audio_file):
|
|
180
|
+
for f in os.listdir(self.encrypted_dir):
|
|
181
|
+
if f.endswith("_encrypted.m4s") and ("audio" in f or f.startswith("0_")):
|
|
182
|
+
audio_file = os.path.join(self.encrypted_dir, f)
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
# Usa il nome file originale per il file finale
|
|
186
|
+
output_file = self.original_output_path
|
|
187
|
+
|
|
188
|
+
if os.path.exists(video_file) and os.path.exists(audio_file):
|
|
189
|
+
audio_tracks = [{"path": audio_file}]
|
|
190
|
+
join_audios(video_file, audio_tracks, output_file)
|
|
191
|
+
elif os.path.exists(video_file):
|
|
192
|
+
join_video(video_file, output_file, codec=None)
|
|
193
|
+
else:
|
|
194
|
+
print("Video file missing, cannot export")
|
|
195
|
+
|
|
196
|
+
# Clean up: delete all tmp
|
|
197
|
+
if os.path.exists(self.tmp_dir):
|
|
198
|
+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
|
199
|
+
|
|
200
|
+
# Rimuovi la cartella principale se è vuota
|
|
201
|
+
try:
|
|
202
|
+
if os.path.exists(self.out_path) and not os.listdir(self.out_path):
|
|
203
|
+
os.rmdir(self.out_path)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
print(f"[WARN] Impossibile eliminare la cartella {self.out_path}: {e}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
return self.output_file
|
|
209
|
+
|
|
210
|
+
def get_status(self):
|
|
211
|
+
"""
|
|
212
|
+
Returns a dict with 'path', 'error', and 'stopped' for external use.
|
|
213
|
+
"""
|
|
214
|
+
return {
|
|
215
|
+
"path": self.output_file,
|
|
216
|
+
"error": self.error,
|
|
217
|
+
"stopped": self.stopped
|
|
218
|
+
}
|