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.

Files changed (48) hide show
  1. StreamingCommunity/Api/Site/altadefinizione/film.py +1 -1
  2. StreamingCommunity/Api/Site/altadefinizione/series.py +1 -1
  3. StreamingCommunity/Api/Site/animeunity/serie.py +2 -2
  4. StreamingCommunity/Api/Site/animeworld/film.py +1 -1
  5. StreamingCommunity/Api/Site/animeworld/serie.py +2 -2
  6. StreamingCommunity/Api/Site/crunchyroll/film.py +3 -2
  7. StreamingCommunity/Api/Site/crunchyroll/series.py +3 -2
  8. StreamingCommunity/Api/Site/crunchyroll/site.py +0 -8
  9. StreamingCommunity/Api/Site/crunchyroll/util/get_license.py +11 -105
  10. StreamingCommunity/Api/Site/guardaserie/series.py +1 -1
  11. StreamingCommunity/Api/Site/mediasetinfinity/film.py +1 -1
  12. StreamingCommunity/Api/Site/mediasetinfinity/series.py +7 -9
  13. StreamingCommunity/Api/Site/mediasetinfinity/site.py +29 -66
  14. StreamingCommunity/Api/Site/mediasetinfinity/util/ScrapeSerie.py +5 -1
  15. StreamingCommunity/Api/Site/mediasetinfinity/util/get_license.py +151 -233
  16. StreamingCommunity/Api/Site/raiplay/film.py +2 -10
  17. StreamingCommunity/Api/Site/raiplay/series.py +2 -10
  18. StreamingCommunity/Api/Site/raiplay/site.py +1 -0
  19. StreamingCommunity/Api/Site/raiplay/util/ScrapeSerie.py +7 -1
  20. StreamingCommunity/Api/Site/streamingcommunity/film.py +1 -1
  21. StreamingCommunity/Api/Site/streamingcommunity/series.py +1 -1
  22. StreamingCommunity/Api/Site/streamingwatch/film.py +1 -1
  23. StreamingCommunity/Api/Site/streamingwatch/series.py +1 -1
  24. StreamingCommunity/Api/Template/loader.py +158 -0
  25. StreamingCommunity/Lib/Downloader/DASH/downloader.py +267 -51
  26. StreamingCommunity/Lib/Downloader/DASH/segments.py +46 -15
  27. StreamingCommunity/Lib/Downloader/HLS/downloader.py +51 -36
  28. StreamingCommunity/Lib/Downloader/HLS/segments.py +105 -25
  29. StreamingCommunity/Lib/Downloader/MP4/downloader.py +12 -13
  30. StreamingCommunity/Lib/FFmpeg/command.py +18 -81
  31. StreamingCommunity/Lib/FFmpeg/util.py +14 -10
  32. StreamingCommunity/Lib/M3U8/estimator.py +13 -12
  33. StreamingCommunity/Lib/M3U8/parser.py +16 -16
  34. StreamingCommunity/Upload/update.py +2 -4
  35. StreamingCommunity/Upload/version.py +2 -2
  36. StreamingCommunity/Util/config_json.py +3 -132
  37. StreamingCommunity/Util/installer/bento4_install.py +21 -31
  38. StreamingCommunity/Util/installer/device_install.py +0 -1
  39. StreamingCommunity/Util/installer/ffmpeg_install.py +0 -1
  40. StreamingCommunity/Util/message.py +8 -9
  41. StreamingCommunity/Util/os.py +0 -8
  42. StreamingCommunity/run.py +4 -44
  43. {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/METADATA +1 -3
  44. {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/RECORD +48 -47
  45. {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/WHEEL +0 -0
  46. {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/entry_points.txt +0 -0
  47. {streamingcommunity-3.3.6.dist-info → streamingcommunity-3.3.8.dist-info}/licenses/LICENSE +0 -0
  48. {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 ...FFmpeg import print_duration_table, join_audios, join_video
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
- available_audio = ', '.join(list_available_audio_langs) if list_available_audio_langs else "Nothing"
87
- set_audio = str(filter_custom_audio) if filter_custom_audio else "Nothing"
88
- downloadable_audio_str = str(downloadable_audio) if downloadable_audio else "Nothing"
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
- data_rows.append(["Audio", available_audio, set_audio, downloadable_audio_str])
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. Sets self.error, self.stopped, self.output_file.
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
- for typ in ["video", "audio"]:
161
- rep = self.get_representation_by_type(typ)
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
- try:
174
- result = downloader.download_streams()
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
- # Check for interruption or failure
177
- if result.get("stopped"):
178
- self.stopped = True
179
- self.error = "Download interrupted"
180
- return False
181
-
182
- if result.get("nFailed", 0) > 0:
183
- self.error = f"Failed segments: {result['nFailed']}"
184
- return False
185
-
186
- except Exception as ex:
187
- self.error = str(ex)
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
- decrypted_path = os.path.join(self.decrypted_dir, f"{typ}.mp4")
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 = f"Decryption of {typ} failed"
365
+ self.error = "Decryption of audio failed"
197
366
  print(self.error)
198
367
  return False
199
368
 
200
- else:
201
- self.error = f"No {typ} found"
202
- print(self.error)
203
- return False
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
- # Definenition of decrypted files
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
- _, use_shortest = join_audios(video_file, audio_tracks, output_file)
226
-
402
+ merged_file, use_shortest = join_audios(video_file, audio_tracks, output_file)
403
+
227
404
  elif os.path.exists(video_file):
228
- _ = join_video(video_file, output_file, codec=None)
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.rename(output_file, new_filename)
238
- output_file = new_filename
239
- self.output_file = new_filename
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
+ }