StreamingCommunity 3.3.6__py3-none-any.whl → 3.3.8__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/Site/altadefinizione/film.py +1 -1
- StreamingCommunity/Api/Site/altadefinizione/series.py +1 -1
- StreamingCommunity/Api/Site/animeunity/serie.py +2 -2
- StreamingCommunity/Api/Site/animeworld/film.py +1 -1
- StreamingCommunity/Api/Site/animeworld/serie.py +2 -2
- StreamingCommunity/Api/Site/crunchyroll/film.py +3 -2
- StreamingCommunity/Api/Site/crunchyroll/series.py +3 -2
- StreamingCommunity/Api/Site/crunchyroll/site.py +0 -8
- StreamingCommunity/Api/Site/crunchyroll/util/get_license.py +11 -105
- StreamingCommunity/Api/Site/guardaserie/series.py +1 -1
- StreamingCommunity/Api/Site/mediasetinfinity/film.py +1 -1
- StreamingCommunity/Api/Site/mediasetinfinity/series.py +7 -9
- StreamingCommunity/Api/Site/mediasetinfinity/site.py +29 -66
- StreamingCommunity/Api/Site/mediasetinfinity/util/ScrapeSerie.py +5 -1
- StreamingCommunity/Api/Site/mediasetinfinity/util/get_license.py +151 -233
- StreamingCommunity/Api/Site/raiplay/film.py +2 -10
- StreamingCommunity/Api/Site/raiplay/series.py +2 -10
- StreamingCommunity/Api/Site/raiplay/site.py +1 -0
- StreamingCommunity/Api/Site/raiplay/util/ScrapeSerie.py +7 -1
- StreamingCommunity/Api/Site/streamingcommunity/film.py +1 -1
- StreamingCommunity/Api/Site/streamingcommunity/series.py +1 -1
- StreamingCommunity/Api/Site/streamingwatch/film.py +1 -1
- StreamingCommunity/Api/Site/streamingwatch/series.py +1 -1
- StreamingCommunity/Api/Template/loader.py +158 -0
- StreamingCommunity/Lib/Downloader/DASH/downloader.py +267 -51
- StreamingCommunity/Lib/Downloader/DASH/segments.py +46 -15
- StreamingCommunity/Lib/Downloader/HLS/downloader.py +51 -36
- StreamingCommunity/Lib/Downloader/HLS/segments.py +105 -25
- StreamingCommunity/Lib/Downloader/MP4/downloader.py +12 -13
- StreamingCommunity/Lib/FFmpeg/command.py +18 -81
- StreamingCommunity/Lib/FFmpeg/util.py +14 -10
- StreamingCommunity/Lib/M3U8/estimator.py +13 -12
- StreamingCommunity/Lib/M3U8/parser.py +16 -16
- StreamingCommunity/Upload/update.py +2 -4
- StreamingCommunity/Upload/version.py +2 -2
- StreamingCommunity/Util/config_json.py +3 -132
- StreamingCommunity/Util/installer/bento4_install.py +21 -31
- StreamingCommunity/Util/installer/device_install.py +0 -1
- StreamingCommunity/Util/installer/ffmpeg_install.py +0 -1
- StreamingCommunity/Util/message.py +8 -9
- StreamingCommunity/Util/os.py +0 -8
- StreamingCommunity/run.py +4 -44
- {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/METADATA +1 -3
- {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/RECORD +48 -47
- {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/WHEEL +0 -0
- {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/entry_points.txt +0 -0
- {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/licenses/LICENSE +0 -0
- {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# 01.10.25
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import glob
|
|
6
|
+
import logging
|
|
7
|
+
import importlib
|
|
8
|
+
from typing import Dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# External import
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Variable
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LazySearchModule:
|
|
20
|
+
def __init__(self, module_name: str, indice: int):
|
|
21
|
+
"""
|
|
22
|
+
Lazy loader for a search module.
|
|
23
|
+
Args:
|
|
24
|
+
module_name: Name of the site module (e.g., 'streamingcommunity')
|
|
25
|
+
indice: Sort index for the module
|
|
26
|
+
"""
|
|
27
|
+
self.module_name = module_name
|
|
28
|
+
self.indice = indice
|
|
29
|
+
self._module = None
|
|
30
|
+
self._search_func = None
|
|
31
|
+
self._use_for = None
|
|
32
|
+
|
|
33
|
+
def _load_module(self):
|
|
34
|
+
"""Load the module on first access."""
|
|
35
|
+
if self._module is None:
|
|
36
|
+
try:
|
|
37
|
+
self._module = importlib.import_module(
|
|
38
|
+
f'StreamingCommunity.Api.Site.{self.module_name}'
|
|
39
|
+
)
|
|
40
|
+
self._search_func = getattr(self._module, 'search')
|
|
41
|
+
self._use_for = getattr(self._module, '_useFor')
|
|
42
|
+
logging.info(f"Loaded module: {self.module_name}")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
console.print(f"[red]Failed to load module {self.module_name}: {str(e)}")
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
def __call__(self, *args, **kwargs):
|
|
48
|
+
"""Execute search function when called.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
*args: Positional arguments to pass to search function
|
|
52
|
+
**kwargs: Keyword arguments to pass to search function
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Result from the search function
|
|
56
|
+
"""
|
|
57
|
+
self._load_module()
|
|
58
|
+
return self._search_func(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def use_for(self):
|
|
62
|
+
"""Get _useFor attribute (loads module if needed).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of content types this module supports
|
|
66
|
+
"""
|
|
67
|
+
if self._use_for is None:
|
|
68
|
+
self._load_module()
|
|
69
|
+
|
|
70
|
+
return self._use_for
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, index: int):
|
|
73
|
+
"""Support tuple unpacking: func, use_for = loaded_functions['name'].
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
index: Index to access (0 for function, 1 for use_for)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Self (as callable) for index 0, use_for for index 1
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
IndexError: If index is not 0 or 1
|
|
83
|
+
"""
|
|
84
|
+
if index == 0:
|
|
85
|
+
return self
|
|
86
|
+
elif index == 1:
|
|
87
|
+
return self.use_for
|
|
88
|
+
|
|
89
|
+
raise IndexError("LazySearchModule only supports indices 0 and 1")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_search_functions() -> Dict[str, LazySearchModule]:
|
|
93
|
+
"""Load and return all available search functions from site modules.
|
|
94
|
+
|
|
95
|
+
This function uses lazy loading - modules are only imported when first used.
|
|
96
|
+
Returns instantly (~0.001s) instead of ~0.2s with full imports.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dictionary mapping '{module_name}_search' to LazySearchModule instances
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
>>> search_funcs = load_search_functions() # Instant!
|
|
103
|
+
>>> results = search_funcs['streamingcommunity_search']("breaking bad") # Import happens here
|
|
104
|
+
"""
|
|
105
|
+
loaded_functions = {}
|
|
106
|
+
|
|
107
|
+
# Determine base path (calculated once)
|
|
108
|
+
if getattr(sys, 'frozen', False):
|
|
109
|
+
|
|
110
|
+
# When frozen (exe), sys._MEIPASS points to temporary extraction directory
|
|
111
|
+
base_path = os.path.join(sys._MEIPASS, "StreamingCommunity")
|
|
112
|
+
api_dir = os.path.join(base_path, 'Api', 'Site')
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
# When not frozen, __file__ is in StreamingCommunity/Api/Template/loader.py
|
|
116
|
+
# Go up two levels to get to StreamingCommunity/Api
|
|
117
|
+
base_path = os.path.dirname(os.path.dirname(__file__))
|
|
118
|
+
api_dir = os.path.join(base_path, 'Site')
|
|
119
|
+
|
|
120
|
+
# Quick scan: just read directory structure and module metadata
|
|
121
|
+
modules_metadata = []
|
|
122
|
+
|
|
123
|
+
for init_file in glob.glob(os.path.join(api_dir, '*', '__init__.py')):
|
|
124
|
+
module_name = os.path.basename(os.path.dirname(init_file))
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Read only the __init__.py file to extract metadata (no import)
|
|
128
|
+
with open(init_file, 'r', encoding='utf-8') as f:
|
|
129
|
+
content = f.read()
|
|
130
|
+
|
|
131
|
+
# Quick check for deprecation without importing
|
|
132
|
+
if '_deprecate = True' in content or '_deprecate=True' in content:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Extract indice using simple string search (faster than regex)
|
|
136
|
+
indice = None
|
|
137
|
+
for line in content.split('\n'):
|
|
138
|
+
line = line.strip()
|
|
139
|
+
if line.startswith('indice =') or line.startswith('indice='):
|
|
140
|
+
try:
|
|
141
|
+
indice = int(line.split('=')[1].strip())
|
|
142
|
+
break
|
|
143
|
+
except (ValueError, IndexError):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if indice is not None:
|
|
147
|
+
modules_metadata.append((module_name, indice))
|
|
148
|
+
logging.info(f"Found module: {module_name} (index: {indice})")
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
console.print(f"[yellow]Warning: Could not read metadata from {module_name}: {str(e)}")
|
|
152
|
+
|
|
153
|
+
# Sort by index and create lazy loaders
|
|
154
|
+
for module_name, indice in sorted(modules_metadata, key=lambda x: x[1]):
|
|
155
|
+
loaded_functions[f'{module_name}_search'] = LazySearchModule(module_name, indice)
|
|
156
|
+
|
|
157
|
+
logging.info(f"Loaded {len(loaded_functions)} search modules")
|
|
158
|
+
return loaded_functions
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# 25.07.25
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import time
|
|
4
5
|
import shutil
|
|
5
6
|
|
|
6
7
|
|
|
@@ -13,7 +14,8 @@ from rich.table import Table
|
|
|
13
14
|
# Internal utilities
|
|
14
15
|
from StreamingCommunity.Util.config_json import config_manager
|
|
15
16
|
from StreamingCommunity.Util.os import internet_manager
|
|
16
|
-
from
|
|
17
|
+
from StreamingCommunity.Util.http_client import create_client
|
|
18
|
+
from StreamingCommunity.Util.headers import get_userAgent
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
# Logic class
|
|
@@ -23,11 +25,18 @@ from .decrypt import decrypt_with_mp4decrypt
|
|
|
23
25
|
from .cdm_helpher import get_widevine_keys
|
|
24
26
|
|
|
25
27
|
|
|
28
|
+
# FFmpeg functions
|
|
29
|
+
from ...FFmpeg import print_duration_table, join_audios, join_video, join_subtitle
|
|
30
|
+
|
|
26
31
|
|
|
27
32
|
# Config
|
|
28
33
|
DOWNLOAD_SPECIFIC_AUDIO = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_audio')
|
|
34
|
+
DOWNLOAD_SPECIFIC_SUBTITLE = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_subtitles')
|
|
35
|
+
ENABLE_SUBTITLE = config_manager.get_bool('M3U8_DOWNLOAD', 'download_subtitle')
|
|
36
|
+
MERGE_SUBTITLE = config_manager.get_bool('M3U8_DOWNLOAD', 'merge_subs')
|
|
29
37
|
FILTER_CUSTOM_REOLUTION = str(config_manager.get('M3U8_CONVERSION', 'force_resolution')).strip().lower()
|
|
30
38
|
CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder')
|
|
39
|
+
RETRY_LIMIT = config_manager.get_int('REQUESTS', 'max_retry')
|
|
31
40
|
|
|
32
41
|
|
|
33
42
|
# Variable
|
|
@@ -35,12 +44,24 @@ console = Console()
|
|
|
35
44
|
|
|
36
45
|
|
|
37
46
|
class DASH_Downloader:
|
|
38
|
-
def __init__(self, cdm_device, license_url, mpd_url, output_path):
|
|
47
|
+
def __init__(self, cdm_device, license_url, mpd_url, mpd_sub_list: list = None, output_path: str = None):
|
|
48
|
+
"""
|
|
49
|
+
Initialize the DASH Downloader with necessary parameters.
|
|
50
|
+
|
|
51
|
+
Parameters:
|
|
52
|
+
- cdm_device (str): Path to the CDM device for decryption.
|
|
53
|
+
- license_url (str): URL to obtain the license for decryption.
|
|
54
|
+
- mpd_url (str): URL of the MPD manifest file.
|
|
55
|
+
- mpd_sub_list (list): List of subtitle dicts with keys: 'language', 'url', 'format'.
|
|
56
|
+
- output_path (str): Path to save the final output file.
|
|
57
|
+
"""
|
|
39
58
|
self.cdm_device = cdm_device
|
|
40
59
|
self.license_url = license_url
|
|
41
60
|
self.mpd_url = mpd_url
|
|
61
|
+
self.mpd_sub_list = mpd_sub_list or []
|
|
42
62
|
self.out_path = os.path.splitext(os.path.abspath(str(output_path)))[0]
|
|
43
63
|
self.original_output_path = output_path
|
|
64
|
+
self.file_already_exists = os.path.exists(self.original_output_path)
|
|
44
65
|
self.parser = None
|
|
45
66
|
self._setup_temp_dirs()
|
|
46
67
|
|
|
@@ -52,16 +73,27 @@ class DASH_Downloader:
|
|
|
52
73
|
"""
|
|
53
74
|
Create temporary folder structure under out_path\tmp
|
|
54
75
|
"""
|
|
76
|
+
if self.file_already_exists:
|
|
77
|
+
return
|
|
78
|
+
|
|
55
79
|
self.tmp_dir = os.path.join(self.out_path, "tmp")
|
|
56
80
|
self.encrypted_dir = os.path.join(self.tmp_dir, "encrypted")
|
|
57
81
|
self.decrypted_dir = os.path.join(self.tmp_dir, "decrypted")
|
|
58
82
|
self.optimize_dir = os.path.join(self.tmp_dir, "optimize")
|
|
83
|
+
self.subs_dir = os.path.join(self.tmp_dir, "subs")
|
|
59
84
|
|
|
60
85
|
os.makedirs(self.encrypted_dir, exist_ok=True)
|
|
61
86
|
os.makedirs(self.decrypted_dir, exist_ok=True)
|
|
62
87
|
os.makedirs(self.optimize_dir, exist_ok=True)
|
|
88
|
+
os.makedirs(self.subs_dir, exist_ok=True)
|
|
63
89
|
|
|
64
90
|
def parse_manifest(self, custom_headers):
|
|
91
|
+
"""
|
|
92
|
+
Parse the MPD manifest file and extract relevant information.
|
|
93
|
+
"""
|
|
94
|
+
if self.file_already_exists:
|
|
95
|
+
return
|
|
96
|
+
|
|
65
97
|
self.parser = MPDParser(self.mpd_url)
|
|
66
98
|
self.parser.parse(custom_headers)
|
|
67
99
|
|
|
@@ -79,15 +111,38 @@ class DASH_Downloader:
|
|
|
79
111
|
|
|
80
112
|
data_rows.append(["Video", available_video, set_video, downloadable_video_str])
|
|
81
113
|
|
|
82
|
-
# Audio info
|
|
114
|
+
# Audio info
|
|
83
115
|
selected_audio, list_available_audio_langs, filter_custom_audio, downloadable_audio = self.parser.select_audio(DOWNLOAD_SPECIFIC_AUDIO)
|
|
84
116
|
self.selected_audio = selected_audio
|
|
85
117
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
118
|
+
if list_available_audio_langs:
|
|
119
|
+
available_audio = ', '.join(list_available_audio_langs)
|
|
120
|
+
set_audio = str(filter_custom_audio) if filter_custom_audio else "Nothing"
|
|
121
|
+
downloadable_audio_str = str(downloadable_audio) if downloadable_audio else "Nothing"
|
|
122
|
+
|
|
123
|
+
data_rows.append(["Audio", available_audio, set_audio, downloadable_audio_str])
|
|
124
|
+
|
|
125
|
+
# Subtitle info
|
|
126
|
+
available_sub_languages = [sub.get('language') for sub in self.mpd_sub_list]
|
|
89
127
|
|
|
90
|
-
|
|
128
|
+
if available_sub_languages:
|
|
129
|
+
available_subs = ', '.join(available_sub_languages)
|
|
130
|
+
|
|
131
|
+
# Filter subtitles based on configuration
|
|
132
|
+
if "*" in DOWNLOAD_SPECIFIC_SUBTITLE:
|
|
133
|
+
self.selected_subs = self.mpd_sub_list
|
|
134
|
+
downloadable_sub_languages = available_sub_languages
|
|
135
|
+
else:
|
|
136
|
+
self.selected_subs = [
|
|
137
|
+
sub for sub in self.mpd_sub_list
|
|
138
|
+
if sub.get('language') in DOWNLOAD_SPECIFIC_SUBTITLE
|
|
139
|
+
]
|
|
140
|
+
downloadable_sub_languages = [sub.get('language') for sub in self.selected_subs]
|
|
141
|
+
|
|
142
|
+
downloadable_subs = ', '.join(downloadable_sub_languages) if downloadable_sub_languages else "Nothing"
|
|
143
|
+
set_subs = ', '.join(DOWNLOAD_SPECIFIC_SUBTITLE) if DOWNLOAD_SPECIFIC_SUBTITLE else "Nothing"
|
|
144
|
+
|
|
145
|
+
data_rows.append(["Subtitle", available_subs, set_subs, downloadable_subs])
|
|
91
146
|
|
|
92
147
|
# Calculate max width for each column
|
|
93
148
|
headers = ["Type", "Available", "Set", "Downloadable"]
|
|
@@ -120,19 +175,77 @@ class DASH_Downloader:
|
|
|
120
175
|
console.print("")
|
|
121
176
|
|
|
122
177
|
def get_representation_by_type(self, typ):
|
|
178
|
+
"""
|
|
179
|
+
Get the representation of the selected stream by type.
|
|
180
|
+
"""
|
|
123
181
|
if typ == "video":
|
|
124
182
|
return getattr(self, "selected_video", None)
|
|
125
183
|
elif typ == "audio":
|
|
126
184
|
return getattr(self, "selected_audio", None)
|
|
127
185
|
return None
|
|
128
186
|
|
|
187
|
+
def download_subtitles(self) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
Download subtitle files based on configuration with retry mechanism.
|
|
190
|
+
Returns True if successful or if no subtitles to download, False on critical error.
|
|
191
|
+
"""
|
|
192
|
+
if not ENABLE_SUBTITLE or not self.selected_subs:
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
headers = {'User-Agent': get_userAgent()}
|
|
196
|
+
client = create_client(headers=headers)
|
|
197
|
+
|
|
198
|
+
for sub in self.selected_subs:
|
|
199
|
+
language = sub.get('language', 'unknown')
|
|
200
|
+
url = sub.get('url')
|
|
201
|
+
fmt = sub.get('format', 'vtt')
|
|
202
|
+
|
|
203
|
+
if not url:
|
|
204
|
+
console.print(f"[yellow]Warning: No URL for subtitle {language}[/yellow]")
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Retry mechanism for downloading subtitles
|
|
208
|
+
success = False
|
|
209
|
+
for attempt in range(RETRY_LIMIT):
|
|
210
|
+
try:
|
|
211
|
+
# Download subtitle
|
|
212
|
+
response = client.get(url)
|
|
213
|
+
response.raise_for_status()
|
|
214
|
+
|
|
215
|
+
# Save subtitle file
|
|
216
|
+
sub_filename = f"{language}.{fmt}"
|
|
217
|
+
sub_path = os.path.join(self.subs_dir, sub_filename)
|
|
218
|
+
|
|
219
|
+
with open(sub_path, 'wb') as f:
|
|
220
|
+
f.write(response.content)
|
|
221
|
+
|
|
222
|
+
success = True
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
if attempt < RETRY_LIMIT - 1:
|
|
227
|
+
console.print(f"[yellow]Attempt {attempt + 1}/{RETRY_LIMIT} failed for subtitle {language}: {e}. Retrying...[/yellow]")
|
|
228
|
+
time.sleep(1.5 ** attempt)
|
|
229
|
+
else:
|
|
230
|
+
console.print(f"[yellow]Warning: Failed to download subtitle {language} after {RETRY_LIMIT} attempts: {e}[/yellow]")
|
|
231
|
+
|
|
232
|
+
if not success:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
return True
|
|
236
|
+
|
|
129
237
|
def download_and_decrypt(self, custom_headers=None, custom_payload=None):
|
|
130
238
|
"""
|
|
131
|
-
Download and decrypt video/audio streams.
|
|
132
|
-
Returns True if successful, False otherwise.
|
|
239
|
+
Download and decrypt video/audio streams. Skips download if file already exists.
|
|
133
240
|
"""
|
|
241
|
+
if self.file_already_exists:
|
|
242
|
+
console.print(f"[red]File already exists: {self.original_output_path}[/red]")
|
|
243
|
+
self.output_file = self.original_output_path
|
|
244
|
+
return True
|
|
245
|
+
|
|
134
246
|
self.error = None
|
|
135
247
|
self.stopped = False
|
|
248
|
+
video_segments_count = 0
|
|
136
249
|
|
|
137
250
|
# Fetch keys immediately after obtaining PSSH
|
|
138
251
|
if not self.parser.pssh:
|
|
@@ -157,50 +270,106 @@ class DASH_Downloader:
|
|
|
157
270
|
KID = key['kid']
|
|
158
271
|
KEY = key['key']
|
|
159
272
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if rep:
|
|
163
|
-
encrypted_path = os.path.join(self.encrypted_dir, f"{rep['id']}_encrypted.m4s")
|
|
164
|
-
|
|
165
|
-
# If m4s file doesn't exist, start downloading
|
|
166
|
-
if not os.path.exists(encrypted_path):
|
|
167
|
-
downloader = MPD_Segments(
|
|
168
|
-
tmp_folder=self.encrypted_dir,
|
|
169
|
-
representation=rep,
|
|
170
|
-
pssh=self.parser.pssh
|
|
171
|
-
)
|
|
273
|
+
# Download subtitles
|
|
274
|
+
self.download_subtitles()
|
|
172
275
|
|
|
173
|
-
|
|
174
|
-
|
|
276
|
+
# Download the video to get segment count
|
|
277
|
+
video_rep = self.get_representation_by_type("video")
|
|
278
|
+
if video_rep:
|
|
279
|
+
encrypted_path = os.path.join(self.encrypted_dir, f"{video_rep['id']}_encrypted.m4s")
|
|
175
280
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
281
|
+
# If m4s file doesn't exist, start downloading
|
|
282
|
+
if not os.path.exists(encrypted_path):
|
|
283
|
+
video_downloader = MPD_Segments(
|
|
284
|
+
tmp_folder=self.encrypted_dir,
|
|
285
|
+
representation=video_rep,
|
|
286
|
+
pssh=self.parser.pssh
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
result = video_downloader.download_streams(description="Video")
|
|
291
|
+
|
|
292
|
+
# Store the video segment count for limiting audio
|
|
293
|
+
video_segments_count = video_downloader.get_segments_count()
|
|
294
|
+
|
|
295
|
+
# Check for interruption or failure
|
|
296
|
+
if result.get("stopped"):
|
|
297
|
+
self.stopped = True
|
|
298
|
+
self.error = "Download interrupted"
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
if result.get("nFailed", 0) > 0:
|
|
302
|
+
self.error = f"Failed segments: {result['nFailed']}"
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
except Exception as ex:
|
|
306
|
+
self.error = str(ex)
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
# Decrypt video
|
|
310
|
+
decrypted_path = os.path.join(self.decrypted_dir, "video.mp4")
|
|
311
|
+
result_path = decrypt_with_mp4decrypt(
|
|
312
|
+
encrypted_path, KID, KEY, output_path=decrypted_path
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if not result_path:
|
|
316
|
+
self.error = "Decryption of video failed"
|
|
317
|
+
print(self.error)
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
else:
|
|
321
|
+
self.error = "No video found"
|
|
322
|
+
print(self.error)
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
# Now download audio with segment limiting
|
|
326
|
+
audio_rep = self.get_representation_by_type("audio")
|
|
327
|
+
if audio_rep:
|
|
328
|
+
encrypted_path = os.path.join(self.encrypted_dir, f"{audio_rep['id']}_encrypted.m4s")
|
|
329
|
+
|
|
330
|
+
# If m4s file doesn't exist, start downloading
|
|
331
|
+
if not os.path.exists(encrypted_path):
|
|
332
|
+
audio_language = audio_rep.get('language', 'Unknown')
|
|
333
|
+
|
|
334
|
+
audio_downloader = MPD_Segments(
|
|
335
|
+
tmp_folder=self.encrypted_dir,
|
|
336
|
+
representation=audio_rep,
|
|
337
|
+
pssh=self.parser.pssh,
|
|
338
|
+
limit_segments=video_segments_count if video_segments_count > 0 else None
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
result = audio_downloader.download_streams(description=f"Audio {audio_language}")
|
|
343
|
+
|
|
344
|
+
# Check for interruption or failure
|
|
345
|
+
if result.get("stopped"):
|
|
346
|
+
self.stopped = True
|
|
347
|
+
self.error = "Download interrupted"
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
if result.get("nFailed", 0) > 0:
|
|
351
|
+
self.error = f"Failed segments: {result['nFailed']}"
|
|
188
352
|
return False
|
|
353
|
+
|
|
354
|
+
except Exception as ex:
|
|
355
|
+
self.error = str(ex)
|
|
356
|
+
return False
|
|
189
357
|
|
|
190
|
-
|
|
358
|
+
# Decrypt audio
|
|
359
|
+
decrypted_path = os.path.join(self.decrypted_dir, "audio.mp4")
|
|
191
360
|
result_path = decrypt_with_mp4decrypt(
|
|
192
361
|
encrypted_path, KID, KEY, output_path=decrypted_path
|
|
193
362
|
)
|
|
194
363
|
|
|
195
364
|
if not result_path:
|
|
196
|
-
self.error =
|
|
365
|
+
self.error = "Decryption of audio failed"
|
|
197
366
|
print(self.error)
|
|
198
367
|
return False
|
|
199
368
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
369
|
+
else:
|
|
370
|
+
self.error = "No audio found"
|
|
371
|
+
print(self.error)
|
|
372
|
+
return False
|
|
204
373
|
|
|
205
374
|
return True
|
|
206
375
|
|
|
@@ -210,8 +379,15 @@ class DASH_Downloader:
|
|
|
210
379
|
pass
|
|
211
380
|
|
|
212
381
|
def finalize_output(self):
|
|
213
|
-
|
|
214
|
-
|
|
382
|
+
"""
|
|
383
|
+
Merge video, audio, and optionally subtitles into final output file.
|
|
384
|
+
"""
|
|
385
|
+
if self.file_already_exists:
|
|
386
|
+
output_file = self.original_output_path
|
|
387
|
+
self.output_file = output_file
|
|
388
|
+
return output_file
|
|
389
|
+
|
|
390
|
+
# Definition of decrypted files
|
|
215
391
|
video_file = os.path.join(self.decrypted_dir, "video.mp4")
|
|
216
392
|
audio_file = os.path.join(self.decrypted_dir, "audio.mp4")
|
|
217
393
|
output_file = self.original_output_path
|
|
@@ -220,23 +396,63 @@ class DASH_Downloader:
|
|
|
220
396
|
self.output_file = output_file
|
|
221
397
|
use_shortest = False
|
|
222
398
|
|
|
399
|
+
# Merge video and audio
|
|
223
400
|
if os.path.exists(video_file) and os.path.exists(audio_file):
|
|
224
401
|
audio_tracks = [{"path": audio_file}]
|
|
225
|
-
|
|
226
|
-
|
|
402
|
+
merged_file, use_shortest = join_audios(video_file, audio_tracks, output_file)
|
|
403
|
+
|
|
227
404
|
elif os.path.exists(video_file):
|
|
228
|
-
|
|
229
|
-
|
|
405
|
+
merged_file = join_video(video_file, output_file, codec=None)
|
|
406
|
+
|
|
230
407
|
else:
|
|
231
408
|
console.print("[red]Video file missing, cannot export[/red]")
|
|
232
409
|
return None
|
|
233
410
|
|
|
411
|
+
# Merge subtitles if enabled and available
|
|
412
|
+
if MERGE_SUBTITLE and ENABLE_SUBTITLE and self.selected_subs:
|
|
413
|
+
|
|
414
|
+
# Check which subtitle files actually exist
|
|
415
|
+
existing_sub_tracks = []
|
|
416
|
+
for sub in self.selected_subs:
|
|
417
|
+
language = sub.get('language', 'unknown')
|
|
418
|
+
fmt = sub.get('format', 'vtt')
|
|
419
|
+
sub_path = os.path.join(self.subs_dir, f"{language}.{fmt}")
|
|
420
|
+
|
|
421
|
+
if os.path.exists(sub_path):
|
|
422
|
+
existing_sub_tracks.append({
|
|
423
|
+
'path': sub_path,
|
|
424
|
+
'language': language
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
if existing_sub_tracks:
|
|
428
|
+
|
|
429
|
+
# Create temporary file for subtitle merge
|
|
430
|
+
temp_output = output_file.replace('.mp4', '_temp.mp4')
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
final_file = join_subtitle(
|
|
434
|
+
video_path=merged_file,
|
|
435
|
+
subtitles_list=existing_sub_tracks,
|
|
436
|
+
out_path=temp_output
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Replace original with subtitled version
|
|
440
|
+
if os.path.exists(final_file):
|
|
441
|
+
if os.path.exists(output_file):
|
|
442
|
+
os.remove(output_file)
|
|
443
|
+
os.rename(final_file, output_file)
|
|
444
|
+
merged_file = output_file
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
console.print(f"[yellow]Warning: Failed to merge subtitles: {e}[/yellow]")
|
|
448
|
+
|
|
234
449
|
# Handle failed sync case
|
|
235
450
|
if use_shortest:
|
|
236
451
|
new_filename = output_file.replace(".mp4", "_failed_sync.mp4")
|
|
237
|
-
os.
|
|
238
|
-
|
|
239
|
-
|
|
452
|
+
if os.path.exists(output_file):
|
|
453
|
+
os.rename(output_file, new_filename)
|
|
454
|
+
output_file = new_filename
|
|
455
|
+
self.output_file = new_filename
|
|
240
456
|
|
|
241
457
|
# Display file information
|
|
242
458
|
if os.path.exists(output_file):
|
|
@@ -291,4 +507,4 @@ class DASH_Downloader:
|
|
|
291
507
|
"path": self.output_file,
|
|
292
508
|
"error": self.error,
|
|
293
509
|
"stopped": self.stopped
|
|
294
|
-
}
|
|
510
|
+
}
|