StreamingCommunity 3.3.9__py3-none-any.whl → 3.4.2__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/hdplayer.py +0 -5
- StreamingCommunity/Api/Player/mediapolisvod.py +4 -13
- StreamingCommunity/Api/Player/supervideo.py +3 -8
- StreamingCommunity/Api/Player/sweetpixel.py +1 -9
- StreamingCommunity/Api/Player/vixcloud.py +5 -16
- StreamingCommunity/Api/Site/altadefinizione/film.py +4 -16
- StreamingCommunity/Api/Site/altadefinizione/series.py +3 -12
- StreamingCommunity/Api/Site/altadefinizione/site.py +2 -9
- StreamingCommunity/Api/Site/altadefinizione/util/ScrapeSerie.py +2 -7
- StreamingCommunity/Api/Site/animeunity/site.py +9 -24
- StreamingCommunity/Api/Site/animeunity/util/ScrapeSerie.py +11 -27
- StreamingCommunity/Api/Site/animeworld/film.py +4 -2
- StreamingCommunity/Api/Site/animeworld/site.py +3 -11
- StreamingCommunity/Api/Site/animeworld/util/ScrapeSerie.py +1 -4
- StreamingCommunity/Api/Site/crunchyroll/film.py +4 -5
- StreamingCommunity/Api/Site/crunchyroll/series.py +5 -17
- StreamingCommunity/Api/Site/crunchyroll/site.py +4 -13
- StreamingCommunity/Api/Site/crunchyroll/util/ScrapeSerie.py +5 -27
- StreamingCommunity/Api/Site/crunchyroll/util/get_license.py +11 -26
- StreamingCommunity/Api/Site/guardaserie/series.py +3 -14
- StreamingCommunity/Api/Site/guardaserie/site.py +4 -12
- StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py +3 -10
- StreamingCommunity/Api/Site/mediasetinfinity/film.py +11 -12
- StreamingCommunity/Api/Site/mediasetinfinity/series.py +4 -15
- StreamingCommunity/Api/Site/mediasetinfinity/site.py +16 -32
- StreamingCommunity/Api/Site/mediasetinfinity/util/ScrapeSerie.py +39 -50
- StreamingCommunity/Api/Site/mediasetinfinity/util/fix_mpd.py +3 -3
- StreamingCommunity/Api/Site/mediasetinfinity/util/get_license.py +7 -25
- StreamingCommunity/Api/Site/raiplay/film.py +6 -8
- StreamingCommunity/Api/Site/raiplay/series.py +5 -20
- StreamingCommunity/Api/Site/raiplay/site.py +45 -47
- StreamingCommunity/Api/Site/raiplay/util/ScrapeSerie.py +91 -55
- StreamingCommunity/Api/Site/raiplay/util/get_license.py +3 -12
- StreamingCommunity/Api/Site/streamingcommunity/film.py +5 -16
- StreamingCommunity/Api/Site/streamingcommunity/series.py +5 -10
- StreamingCommunity/Api/Site/streamingcommunity/site.py +3 -22
- StreamingCommunity/Api/Site/streamingcommunity/util/ScrapeSerie.py +11 -27
- StreamingCommunity/Api/Site/streamingwatch/__init__.py +1 -0
- StreamingCommunity/Api/Site/streamingwatch/film.py +4 -2
- StreamingCommunity/Api/Site/streamingwatch/series.py +4 -14
- StreamingCommunity/Api/Site/streamingwatch/site.py +4 -18
- StreamingCommunity/Api/Site/streamingwatch/util/ScrapeSerie.py +0 -3
- StreamingCommunity/Api/Template/Util/__init__.py +4 -2
- StreamingCommunity/Api/Template/Util/manage_ep.py +66 -0
- StreamingCommunity/Api/Template/config_loader.py +0 -7
- StreamingCommunity/Lib/Downloader/DASH/decrypt.py +54 -1
- StreamingCommunity/Lib/Downloader/DASH/downloader.py +186 -70
- StreamingCommunity/Lib/Downloader/DASH/parser.py +2 -3
- StreamingCommunity/Lib/Downloader/DASH/segments.py +109 -68
- StreamingCommunity/Lib/Downloader/HLS/downloader.py +100 -82
- StreamingCommunity/Lib/Downloader/HLS/segments.py +40 -28
- StreamingCommunity/Lib/Downloader/MP4/downloader.py +16 -4
- StreamingCommunity/Lib/FFmpeg/capture.py +37 -5
- StreamingCommunity/Lib/FFmpeg/command.py +32 -90
- StreamingCommunity/Lib/M3U8/estimator.py +47 -1
- StreamingCommunity/Lib/TMBD/tmdb.py +2 -4
- StreamingCommunity/TelegramHelp/config.json +0 -1
- StreamingCommunity/Upload/update.py +19 -6
- StreamingCommunity/Upload/version.py +1 -1
- StreamingCommunity/Util/config_json.py +28 -21
- StreamingCommunity/Util/http_client.py +28 -0
- StreamingCommunity/Util/os.py +16 -6
- StreamingCommunity/Util/table.py +50 -8
- {streamingcommunity-3.3.9.dist-info → streamingcommunity-3.4.2.dist-info}/METADATA +1 -3
- streamingcommunity-3.4.2.dist-info/RECORD +111 -0
- streamingcommunity-3.3.9.dist-info/RECORD +0 -111
- {streamingcommunity-3.3.9.dist-info → streamingcommunity-3.4.2.dist-info}/WHEEL +0 -0
- {streamingcommunity-3.3.9.dist-info → streamingcommunity-3.4.2.dist-info}/entry_points.txt +0 -0
- {streamingcommunity-3.3.9.dist-info → streamingcommunity-3.4.2.dist-info}/licenses/LICENSE +0 -0
- {streamingcommunity-3.3.9.dist-info → streamingcommunity-3.4.2.dist-info}/top_level.txt +0 -0
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
# 17.10.24
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import time
|
|
5
4
|
import logging
|
|
6
5
|
import shutil
|
|
7
|
-
from typing import Any, Dict, List, Optional
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
7
|
|
|
9
8
|
|
|
10
9
|
# External libraries
|
|
11
|
-
import httpx
|
|
12
10
|
from rich.console import Console
|
|
13
|
-
from rich.panel import Panel
|
|
14
11
|
from rich.table import Table
|
|
15
12
|
|
|
16
13
|
|
|
17
14
|
# Internal utilities
|
|
18
15
|
from StreamingCommunity.Util.config_json import config_manager
|
|
19
16
|
from StreamingCommunity.Util.headers import get_userAgent
|
|
20
|
-
from StreamingCommunity.Util.http_client import
|
|
17
|
+
from StreamingCommunity.Util.http_client import fetch
|
|
21
18
|
from StreamingCommunity.Util.os import os_manager, internet_manager
|
|
22
19
|
|
|
23
20
|
|
|
@@ -33,15 +30,13 @@ from .segments import M3U8_Segments
|
|
|
33
30
|
|
|
34
31
|
|
|
35
32
|
# Config
|
|
36
|
-
ENABLE_SUBTITLE = config_manager.get_bool('M3U8_DOWNLOAD', 'download_subtitle')
|
|
37
33
|
DOWNLOAD_SPECIFIC_AUDIO = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_audio')
|
|
38
34
|
DOWNLOAD_SPECIFIC_SUBTITLE = config_manager.get_list('M3U8_DOWNLOAD', 'specific_list_subtitles')
|
|
39
35
|
MERGE_SUBTITLE = config_manager.get_bool('M3U8_DOWNLOAD', 'merge_subs')
|
|
40
36
|
CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder')
|
|
41
37
|
GET_ONLY_LINK = config_manager.get_int('M3U8_DOWNLOAD', 'get_only_link')
|
|
42
38
|
FILTER_CUSTOM_RESOLUTION = str(config_manager.get('M3U8_CONVERSION', 'force_resolution')).strip().lower()
|
|
43
|
-
|
|
44
|
-
MAX_TIMEOUT = config_manager.get_int("REQUESTS", "timeout")
|
|
39
|
+
EXTENSION_OUTPUT = config_manager.get("M3U8_CONVERSION", "extension")
|
|
45
40
|
|
|
46
41
|
console = Console()
|
|
47
42
|
|
|
@@ -51,9 +46,9 @@ class HLSClient:
|
|
|
51
46
|
def __init__(self, custom_headers: Optional[Dict[str, str]] = None):
|
|
52
47
|
self.headers = custom_headers if custom_headers else {'User-Agent': get_userAgent()}
|
|
53
48
|
|
|
54
|
-
def request(self, url: str, return_content: bool = False) -> Optional[
|
|
49
|
+
def request(self, url: str, return_content: bool = False) -> Optional[Union[str, bytes]]:
|
|
55
50
|
"""
|
|
56
|
-
Makes HTTP GET requests with retry logic.
|
|
51
|
+
Makes HTTP GET requests with retry logic using http_client.
|
|
57
52
|
|
|
58
53
|
Args:
|
|
59
54
|
url: Target URL to request
|
|
@@ -67,21 +62,12 @@ class HLSClient:
|
|
|
67
62
|
logging.error("URL is None or empty, cannot make request")
|
|
68
63
|
return None
|
|
69
64
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return response.content if return_content else response.text
|
|
77
|
-
|
|
78
|
-
except Exception as e:
|
|
79
|
-
logging.error(f"Attempt {attempt+1} failed for URL {url}: {str(e)}")
|
|
80
|
-
if attempt < RETRY_LIMIT - 1: # Don't sleep on last attempt
|
|
81
|
-
time.sleep(1.5 ** attempt)
|
|
82
|
-
|
|
83
|
-
logging.error(f"All {RETRY_LIMIT} attempts failed for URL: {url}")
|
|
84
|
-
return None
|
|
65
|
+
return fetch(
|
|
66
|
+
url,
|
|
67
|
+
method="GET",
|
|
68
|
+
headers=self.headers,
|
|
69
|
+
return_content=return_content
|
|
70
|
+
)
|
|
85
71
|
|
|
86
72
|
|
|
87
73
|
class PathManager:
|
|
@@ -94,7 +80,7 @@ class PathManager:
|
|
|
94
80
|
"""
|
|
95
81
|
self.m3u8_url = m3u8_url
|
|
96
82
|
self.output_path = self._sanitize_output_path(output_path)
|
|
97
|
-
base_name = os.path.basename(self.output_path).replace(
|
|
83
|
+
base_name = os.path.basename(self.output_path).replace(EXTENSION_OUTPUT, "")
|
|
98
84
|
self.temp_dir = os.path.join(os.path.dirname(self.output_path), f"{base_name}_tmp")
|
|
99
85
|
|
|
100
86
|
def _sanitize_output_path(self, path: Optional[str]) -> str:
|
|
@@ -103,10 +89,10 @@ class PathManager:
|
|
|
103
89
|
Creates a hash-based filename if no path is provided.
|
|
104
90
|
"""
|
|
105
91
|
if not path:
|
|
106
|
-
path = "download
|
|
92
|
+
path = f"download{EXTENSION_OUTPUT}"
|
|
107
93
|
|
|
108
|
-
if not path.endswith(
|
|
109
|
-
path +=
|
|
94
|
+
if not path.endswith(EXTENSION_OUTPUT):
|
|
95
|
+
path += EXTENSION_OUTPUT
|
|
110
96
|
|
|
111
97
|
return os_manager.get_sanitize_path(path)
|
|
112
98
|
|
|
@@ -168,6 +154,7 @@ class M3U8Manager:
|
|
|
168
154
|
"""
|
|
169
155
|
Selects video, audio, and subtitle streams based on configuration.
|
|
170
156
|
If it's a master playlist, only selects video stream.
|
|
157
|
+
Auto-selects first audio if only one is available and none match filters.
|
|
171
158
|
"""
|
|
172
159
|
if not self.is_master:
|
|
173
160
|
self.video_url, self.video_res = self.m3u8_url, "undefined"
|
|
@@ -175,6 +162,7 @@ class M3U8Manager:
|
|
|
175
162
|
self.sub_streams = []
|
|
176
163
|
|
|
177
164
|
else:
|
|
165
|
+
# Video selection logic
|
|
178
166
|
if str(FILTER_CUSTOM_RESOLUTION) == "best":
|
|
179
167
|
self.video_url, self.video_res = self.parser._video.get_best_uri()
|
|
180
168
|
elif str(FILTER_CUSTOM_RESOLUTION) == "worst":
|
|
@@ -182,25 +170,41 @@ class M3U8Manager:
|
|
|
182
170
|
elif str(FILTER_CUSTOM_RESOLUTION).replace("p", "").replace("px", "").isdigit():
|
|
183
171
|
resolution_value = int(str(FILTER_CUSTOM_RESOLUTION).replace("p", "").replace("px", ""))
|
|
184
172
|
self.video_url, self.video_res = self.parser._video.get_custom_uri(resolution_value)
|
|
173
|
+
|
|
174
|
+
# Fallback to best if custom resolution not found
|
|
175
|
+
if self.video_url is None:
|
|
176
|
+
self.video_url, self.video_res = self.parser._video.get_best_uri()
|
|
185
177
|
else:
|
|
186
178
|
logging.error("Resolution not recognized.")
|
|
187
179
|
self.video_url, self.video_res = self.parser._video.get_best_uri()
|
|
188
180
|
|
|
189
|
-
# Audio
|
|
181
|
+
# Audio selection with auto-select fallback
|
|
182
|
+
all_audio = self.parser._audio.get_all_uris_and_names() or []
|
|
183
|
+
|
|
184
|
+
# Try to match with configured languages
|
|
190
185
|
self.audio_streams = [
|
|
191
|
-
s for s in
|
|
186
|
+
s for s in all_audio
|
|
192
187
|
if s.get('language') in DOWNLOAD_SPECIFIC_AUDIO
|
|
193
188
|
]
|
|
194
|
-
|
|
189
|
+
|
|
190
|
+
# Auto-select first audio if:
|
|
191
|
+
# 1. No audio matched the filters
|
|
192
|
+
# 2. At least one audio track is available
|
|
193
|
+
# 3. Filters are configured (not empty)
|
|
194
|
+
if not self.audio_streams and all_audio and DOWNLOAD_SPECIFIC_AUDIO:
|
|
195
|
+
first_audio_lang = all_audio[0].get('language', 'unknown')
|
|
196
|
+
console.print(f"\n[yellow]Auto-selecting first available audio track: {first_audio_lang}[/yellow]")
|
|
197
|
+
self.audio_streams = [all_audio[0]]
|
|
198
|
+
|
|
199
|
+
# Subtitle selection
|
|
195
200
|
self.sub_streams = []
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
self.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
]
|
|
201
|
+
if "*" in DOWNLOAD_SPECIFIC_SUBTITLE:
|
|
202
|
+
self.sub_streams = self.parser._subtitle.get_all_uris_and_names() or []
|
|
203
|
+
else:
|
|
204
|
+
self.sub_streams = [
|
|
205
|
+
s for s in (self.parser._subtitle.get_all_uris_and_names() or [])
|
|
206
|
+
if s.get('language') in DOWNLOAD_SPECIFIC_SUBTITLE
|
|
207
|
+
]
|
|
204
208
|
|
|
205
209
|
def log_selection(self):
|
|
206
210
|
"""Log the stream selection information in a formatted table."""
|
|
@@ -220,38 +224,22 @@ class M3U8Manager:
|
|
|
220
224
|
|
|
221
225
|
data_rows.append(["Video", available_video, str(FILTER_CUSTOM_RESOLUTION), downloadable_video])
|
|
222
226
|
|
|
223
|
-
# Codec information
|
|
224
|
-
if self.parser.codec is not None:
|
|
225
|
-
available_codec_info = (
|
|
226
|
-
f"v: {self.parser.codec.video_codec_name} "
|
|
227
|
-
f"(b: {self.parser.codec.video_bitrate // 1000}k), "
|
|
228
|
-
f"a: {self.parser.codec.audio_codec_name} "
|
|
229
|
-
f"(b: {self.parser.codec.audio_bitrate // 1000}k)"
|
|
230
|
-
)
|
|
231
|
-
set_codec_info = available_codec_info if config_manager.get_bool("M3U8_CONVERSION", "use_codec") else "copy"
|
|
232
|
-
|
|
233
|
-
data_rows.append(["Codec", available_codec_info, set_codec_info, set_codec_info])
|
|
234
|
-
|
|
235
227
|
# Subtitle information
|
|
236
228
|
available_subtitles = self.parser._subtitle.get_all_uris_and_names() or []
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if available_sub_languages:
|
|
229
|
+
if available_subtitles:
|
|
230
|
+
available_sub_languages = [sub.get('language') for sub in available_subtitles]
|
|
240
231
|
available_subs = ', '.join(available_sub_languages)
|
|
241
|
-
|
|
242
|
-
downloadable_sub_languages = available_sub_languages if "*" in DOWNLOAD_SPECIFIC_SUBTITLE else list(set(available_sub_languages) & set(DOWNLOAD_SPECIFIC_SUBTITLE))
|
|
232
|
+
downloadable_sub_languages = [sub.get('language') for sub in self.sub_streams]
|
|
243
233
|
downloadable_subs = ', '.join(downloadable_sub_languages) if downloadable_sub_languages else "Nothing"
|
|
244
234
|
|
|
245
235
|
data_rows.append(["Subtitle", available_subs, ', '.join(DOWNLOAD_SPECIFIC_SUBTITLE), downloadable_subs])
|
|
246
236
|
|
|
247
237
|
# Audio information
|
|
248
238
|
available_audio = self.parser._audio.get_all_uris_and_names() or []
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if available_audio_languages:
|
|
239
|
+
if available_audio:
|
|
240
|
+
available_audio_languages = [audio.get('language') for audio in available_audio]
|
|
252
241
|
available_audios = ', '.join(available_audio_languages)
|
|
253
|
-
|
|
254
|
-
downloadable_audio_languages = list(set(available_audio_languages) & set(DOWNLOAD_SPECIFIC_AUDIO))
|
|
242
|
+
downloadable_audio_languages = [audio.get('language') for audio in self.audio_streams]
|
|
255
243
|
downloadable_audios = ', '.join(downloadable_audio_languages) if downloadable_audio_languages else "Nothing"
|
|
256
244
|
|
|
257
245
|
data_rows.append(["Audio", available_audios, ', '.join(DOWNLOAD_SPECIFIC_AUDIO), downloadable_audios])
|
|
@@ -282,7 +270,8 @@ class M3U8Manager:
|
|
|
282
270
|
|
|
283
271
|
console.print(table)
|
|
284
272
|
print("")
|
|
285
|
-
|
|
273
|
+
|
|
274
|
+
|
|
286
275
|
class DownloadManager:
|
|
287
276
|
"""Manages downloading of video, audio, and subtitle streams."""
|
|
288
277
|
def __init__(self, temp_dir: str, client: HLSClient, url_fixer: M3U8_UrlFix, custom_headers: Optional[Dict[str, str]] = None):
|
|
@@ -301,6 +290,10 @@ class DownloadManager:
|
|
|
301
290
|
self.stopped = False
|
|
302
291
|
self.video_segments_count = 0
|
|
303
292
|
|
|
293
|
+
# For progress tracking
|
|
294
|
+
self.current_downloader: Optional[M3U8_Segments] = None
|
|
295
|
+
self.current_download_type: Optional[str] = None
|
|
296
|
+
|
|
304
297
|
def download_video(self, video_url: str) -> bool:
|
|
305
298
|
"""
|
|
306
299
|
Downloads video segments from the M3U8 playlist.
|
|
@@ -318,12 +311,20 @@ class DownloadManager:
|
|
|
318
311
|
tmp_folder=video_tmp_dir,
|
|
319
312
|
custom_headers=self.custom_headers
|
|
320
313
|
)
|
|
314
|
+
|
|
315
|
+
# Set current downloader for progress tracking
|
|
316
|
+
self.current_downloader = downloader
|
|
317
|
+
self.current_download_type = 'video'
|
|
321
318
|
|
|
322
319
|
# Download video and get segment count
|
|
323
320
|
result = downloader.download_streams("Video", "video")
|
|
324
321
|
self.video_segments_count = downloader.get_segments_count()
|
|
325
322
|
self.missing_segments.append(result)
|
|
326
323
|
|
|
324
|
+
# Reset current downloader after completion
|
|
325
|
+
self.current_downloader = None
|
|
326
|
+
self.current_download_type = None
|
|
327
|
+
|
|
327
328
|
if result.get('stopped', False):
|
|
328
329
|
self.stopped = True
|
|
329
330
|
return False
|
|
@@ -332,6 +333,8 @@ class DownloadManager:
|
|
|
332
333
|
|
|
333
334
|
except Exception as e:
|
|
334
335
|
logging.error(f"Error downloading video from {video_url}: {str(e)}")
|
|
336
|
+
self.current_downloader = None
|
|
337
|
+
self.current_download_type = None
|
|
335
338
|
return False
|
|
336
339
|
|
|
337
340
|
def download_audio(self, audio: Dict) -> bool:
|
|
@@ -353,10 +356,19 @@ class DownloadManager:
|
|
|
353
356
|
limit_segments=self.video_segments_count if self.video_segments_count > 0 else None,
|
|
354
357
|
custom_headers=self.custom_headers
|
|
355
358
|
)
|
|
356
|
-
|
|
359
|
+
|
|
360
|
+
# Set current downloader for progress tracking
|
|
361
|
+
self.current_downloader = downloader
|
|
362
|
+
self.current_download_type = f"audio_{audio['language']}"
|
|
363
|
+
|
|
364
|
+
# Download audio
|
|
357
365
|
result = downloader.download_streams(f"Audio {audio['language']}", "audio")
|
|
358
366
|
self.missing_segments.append(result)
|
|
359
367
|
|
|
368
|
+
# Reset current downloader after completion
|
|
369
|
+
self.current_downloader = None
|
|
370
|
+
self.current_download_type = None
|
|
371
|
+
|
|
360
372
|
if result.get('stopped', False):
|
|
361
373
|
self.stopped = True
|
|
362
374
|
return False
|
|
@@ -365,6 +377,8 @@ class DownloadManager:
|
|
|
365
377
|
|
|
366
378
|
except Exception as e:
|
|
367
379
|
logging.error(f"Error downloading audio {audio.get('language', 'unknown')}: {str(e)}")
|
|
380
|
+
self.current_downloader = None
|
|
381
|
+
self.current_download_type = None
|
|
368
382
|
return False
|
|
369
383
|
|
|
370
384
|
def download_subtitle(self, sub: Dict) -> bool:
|
|
@@ -664,6 +678,7 @@ class HLS_Downloader:
|
|
|
664
678
|
"""Prints download summary including file size, duration, and any missing segments."""
|
|
665
679
|
missing_ts = False
|
|
666
680
|
missing_info = ""
|
|
681
|
+
|
|
667
682
|
for item in self.download_manager.missing_segments:
|
|
668
683
|
if int(item['nFailed']) >= 1:
|
|
669
684
|
missing_ts = True
|
|
@@ -672,30 +687,33 @@ class HLS_Downloader:
|
|
|
672
687
|
file_size = internet_manager.format_file_size(os.path.getsize(self.path_manager.output_path))
|
|
673
688
|
duration = print_duration_table(self.path_manager.output_path, description=False, return_string=True)
|
|
674
689
|
|
|
675
|
-
|
|
676
|
-
f"[cyan]File size: [bold red]{file_size}[/bold red]\n"
|
|
677
|
-
f"[cyan]Duration: [bold]{duration}[/bold]\n"
|
|
678
|
-
f"[cyan]Output: [bold]{os.path.abspath(self.path_manager.output_path)}[/bold]"
|
|
679
|
-
)
|
|
680
|
-
|
|
681
|
-
if missing_ts:
|
|
682
|
-
panel_content += f"\n{missing_info}"
|
|
683
|
-
|
|
690
|
+
# Rename output file if there were missing segments or shortest used
|
|
684
691
|
new_filename = self.path_manager.output_path
|
|
685
692
|
if missing_ts and use_shortest:
|
|
686
|
-
new_filename = new_filename.replace(
|
|
693
|
+
new_filename = new_filename.replace(EXTENSION_OUTPUT, f"_failed_sync_ts{EXTENSION_OUTPUT}")
|
|
687
694
|
elif missing_ts:
|
|
688
|
-
new_filename = new_filename.replace(
|
|
695
|
+
new_filename = new_filename.replace(EXTENSION_OUTPUT, f"_failed_ts{EXTENSION_OUTPUT}")
|
|
689
696
|
elif use_shortest:
|
|
690
|
-
new_filename = new_filename.replace(
|
|
697
|
+
new_filename = new_filename.replace(EXTENSION_OUTPUT, f"_failed_sync{EXTENSION_OUTPUT}")
|
|
691
698
|
|
|
699
|
+
# Rename the file accordingly
|
|
692
700
|
if missing_ts or use_shortest:
|
|
693
701
|
os.rename(self.path_manager.output_path, new_filename)
|
|
694
702
|
self.path_manager.output_path = new_filename
|
|
695
703
|
|
|
696
|
-
print("")
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
704
|
+
console.print(f"[yellow]Output [red]{os.path.abspath(self.path_manager.output_path)} [cyan]with size [red]{file_size} [cyan]and duration [red]{duration}")
|
|
705
|
+
|
|
706
|
+
def get_progress_data(self) -> Optional[Dict]:
|
|
707
|
+
"""Get current download progress data."""
|
|
708
|
+
if not self.download_manager.current_downloader:
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
progress = self.download_manager.current_downloader.get_progress_data()
|
|
713
|
+
if progress:
|
|
714
|
+
progress['download_type'] = self.download_manager.current_download_type
|
|
715
|
+
return progress
|
|
716
|
+
|
|
717
|
+
except Exception as e:
|
|
718
|
+
logging.error(f"Error getting progress data: {e}")
|
|
719
|
+
return None
|
|
@@ -18,6 +18,7 @@ from rich.console import Console
|
|
|
18
18
|
# Internal utilities
|
|
19
19
|
from StreamingCommunity.Util.color import Colors
|
|
20
20
|
from StreamingCommunity.Util.headers import get_userAgent
|
|
21
|
+
from StreamingCommunity.Util.http_client import create_client_curl
|
|
21
22
|
from StreamingCommunity.Util.config_json import config_manager
|
|
22
23
|
|
|
23
24
|
|
|
@@ -38,6 +39,7 @@ DEFAULT_AUDIO_WORKERS = config_manager.get_int('M3U8_DOWNLOAD', 'default_audio_w
|
|
|
38
39
|
MAX_TIMEOUT = config_manager.get_int("REQUESTS", "timeout")
|
|
39
40
|
SEGMENT_MAX_TIMEOUT = config_manager.get_int("M3U8_DOWNLOAD", "segment_timeout")
|
|
40
41
|
LIMIT_SEGMENT = config_manager.get_int('M3U8_DOWNLOAD', 'limit_segment')
|
|
42
|
+
ENABLE_RETRY = config_manager.get_bool('M3U8_DOWNLOAD', 'enable_retry')
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
# Variable
|
|
@@ -68,6 +70,8 @@ class M3U8_Segments:
|
|
|
68
70
|
self.limit_segments = LIMIT_SEGMENT if LIMIT_SEGMENT > 0 else None
|
|
69
71
|
else:
|
|
70
72
|
self.limit_segments = limit_segments
|
|
73
|
+
|
|
74
|
+
self.enable_retry = ENABLE_RETRY
|
|
71
75
|
|
|
72
76
|
# Util class
|
|
73
77
|
self.decryption: M3U8_Decryption = None
|
|
@@ -94,12 +98,7 @@ class M3U8_Segments:
|
|
|
94
98
|
self.key_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
|
|
95
99
|
|
|
96
100
|
try:
|
|
97
|
-
|
|
98
|
-
'headers': self.custom_headers,
|
|
99
|
-
'timeout': MAX_TIMEOUT,
|
|
100
|
-
'verify': REQUEST_VERIFY
|
|
101
|
-
}
|
|
102
|
-
response = httpx.get(url=key_uri, **client_params)
|
|
101
|
+
response = create_client_curl(headers=self.custom_headers).get(key_uri)
|
|
103
102
|
response.raise_for_status()
|
|
104
103
|
|
|
105
104
|
hex_content = binascii.hexlify(response.content).decode('utf-8')
|
|
@@ -146,12 +145,7 @@ class M3U8_Segments:
|
|
|
146
145
|
"""
|
|
147
146
|
if self.is_index_url:
|
|
148
147
|
try:
|
|
149
|
-
|
|
150
|
-
'headers': self.custom_headers,
|
|
151
|
-
'timeout': MAX_TIMEOUT,
|
|
152
|
-
'verify': REQUEST_VERIFY
|
|
153
|
-
}
|
|
154
|
-
response = httpx.get(self.url, **client_params, follow_redirects=True)
|
|
148
|
+
response = create_client_curl(headers=self.custom_headers).get(self.url)
|
|
155
149
|
response.raise_for_status()
|
|
156
150
|
|
|
157
151
|
self.parse_data(response.text)
|
|
@@ -174,7 +168,7 @@ class M3U8_Segments:
|
|
|
174
168
|
"""
|
|
175
169
|
Get the file path for a temporary segment.
|
|
176
170
|
"""
|
|
177
|
-
return os.path.join(temp_dir, f"seg_{index:06d}.
|
|
171
|
+
return os.path.join(temp_dir, f"seg_{index:06d}.ts")
|
|
178
172
|
|
|
179
173
|
async def _download_init_segment(self, client: httpx.AsyncClient, output_path: str, progress_bar: tqdm) -> bool:
|
|
180
174
|
"""
|
|
@@ -220,7 +214,7 @@ class M3U8_Segments:
|
|
|
220
214
|
async def _download_single_segment(self, client: httpx.AsyncClient, ts_url: str, index: int, temp_dir: str,
|
|
221
215
|
semaphore: asyncio.Semaphore, max_retry: int) -> tuple:
|
|
222
216
|
"""
|
|
223
|
-
Downloads a single TS segment and saves to temp file.
|
|
217
|
+
Downloads a single TS segment and saves to temp file IMMEDIATELY.
|
|
224
218
|
|
|
225
219
|
Returns:
|
|
226
220
|
tuple: (index, success, retry_count, file_size)
|
|
@@ -248,11 +242,13 @@ class M3U8_Segments:
|
|
|
248
242
|
return index, False, attempt, 0
|
|
249
243
|
raise e
|
|
250
244
|
|
|
251
|
-
# Write to temp file
|
|
245
|
+
# Write segment to temp file IMMEDIATELY
|
|
252
246
|
with open(temp_file, 'wb') as f:
|
|
253
247
|
f.write(segment_content)
|
|
254
|
-
|
|
255
|
-
|
|
248
|
+
|
|
249
|
+
size = len(segment_content)
|
|
250
|
+
del segment_content
|
|
251
|
+
return index, True, attempt, size
|
|
256
252
|
|
|
257
253
|
except Exception:
|
|
258
254
|
if attempt + 1 == max_retry:
|
|
@@ -296,8 +292,8 @@ class M3U8_Segments:
|
|
|
296
292
|
console.print("\n[red]Download interrupted by user (Ctrl+C).")
|
|
297
293
|
break
|
|
298
294
|
|
|
299
|
-
# Retry failed segments
|
|
300
|
-
if not self.download_interrupted:
|
|
295
|
+
# Retry failed segments only if enabled
|
|
296
|
+
if self.enable_retry and not self.download_interrupted:
|
|
301
297
|
await self._retry_failed_segments(client, temp_dir, semaphore, progress_bar)
|
|
302
298
|
|
|
303
299
|
async def _retry_failed_segments(self, client: httpx.AsyncClient, temp_dir: str, semaphore: asyncio.Semaphore,
|
|
@@ -356,6 +352,7 @@ class M3U8_Segments:
|
|
|
356
352
|
if os.path.exists(temp_file):
|
|
357
353
|
with open(temp_file, 'rb') as infile:
|
|
358
354
|
outfile.write(infile.read())
|
|
355
|
+
os.remove(temp_file)
|
|
359
356
|
|
|
360
357
|
async def download_segments_async(self, description: str, type: str):
|
|
361
358
|
"""
|
|
@@ -473,13 +470,12 @@ class M3U8_Segments:
|
|
|
473
470
|
"""Ensure resource cleanup and final reporting."""
|
|
474
471
|
progress_bar.close()
|
|
475
472
|
|
|
476
|
-
# Delete temp
|
|
473
|
+
# Delete temp directory if exists
|
|
477
474
|
if temp_dir and os.path.exists(temp_dir):
|
|
478
475
|
try:
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
os.remove(temp_file)
|
|
476
|
+
# Remove any remaining files (in case of interruption)
|
|
477
|
+
for file in os.listdir(temp_dir):
|
|
478
|
+
os.remove(os.path.join(temp_dir, file))
|
|
483
479
|
os.rmdir(temp_dir)
|
|
484
480
|
except Exception as e:
|
|
485
481
|
console.print(f"[yellow]Warning: Could not clean temp directory: {e}")
|
|
@@ -489,7 +485,23 @@ class M3U8_Segments:
|
|
|
489
485
|
|
|
490
486
|
def _display_error_summary(self) -> None:
|
|
491
487
|
"""Generate final error report."""
|
|
492
|
-
console.print(f"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
488
|
+
console.print(f" [cyan]Max retries: [red]{self.info_maxRetry} [white] | "
|
|
489
|
+
f"[cyan]Total retries: [red]{self.info_nRetry} [white] | "
|
|
490
|
+
f"[cyan]Failed segments: [red]{self.info_nFailed}")
|
|
491
|
+
|
|
492
|
+
def get_progress_data(self) -> Dict:
|
|
493
|
+
"""Returns current download progress data for API consumption."""
|
|
494
|
+
total = self.get_segments_count()
|
|
495
|
+
downloaded = len(self.downloaded_segments)
|
|
496
|
+
percentage = (downloaded / total * 100) if total > 0 else 0
|
|
497
|
+
stats = self.class_ts_estimator.get_stats(downloaded, total)
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
'total_segments': total,
|
|
501
|
+
'downloaded_segments': downloaded,
|
|
502
|
+
'failed_segments': self.info_nFailed,
|
|
503
|
+
'current_speed': stats['download_speed'],
|
|
504
|
+
'estimated_size': stats['estimated_total_size'],
|
|
505
|
+
'percentage': round(percentage, 2),
|
|
506
|
+
'eta_seconds': stats['eta_seconds']
|
|
507
|
+
}
|
|
@@ -76,6 +76,7 @@ def MP4_downloader(url: str, path: str, referer: str = None, headers_: dict = No
|
|
|
76
76
|
- Single Ctrl+C: Completes download gracefully
|
|
77
77
|
- Triple Ctrl+C: Saves partial download and exits
|
|
78
78
|
"""
|
|
79
|
+
url = url.strip()
|
|
79
80
|
if TELEGRAM_BOT:
|
|
80
81
|
bot = get_bot_instance()
|
|
81
82
|
console.log("####")
|
|
@@ -134,20 +135,23 @@ def MP4_downloader(url: str, path: str, referer: str = None, headers_: dict = No
|
|
|
134
135
|
|
|
135
136
|
# Create progress bar with percentage instead of n_fmt/total_fmt
|
|
136
137
|
console.print("[cyan]You can safely stop the download with [bold]Ctrl+c[bold] [cyan]")
|
|
138
|
+
|
|
137
139
|
progress_bar = tqdm(
|
|
138
140
|
total=total,
|
|
139
141
|
ascii='░▒█',
|
|
140
142
|
bar_format=f"{Colors.YELLOW}MP4{Colors.CYAN} Downloading{Colors.WHITE}: "
|
|
141
|
-
f"{Colors.
|
|
142
|
-
f"{Colors.
|
|
143
|
-
f"{Colors.
|
|
143
|
+
f"{Colors.MAGENTA}{{bar:40}} "
|
|
144
|
+
f"{Colors.LIGHT_GREEN}{{n_fmt}}{Colors.WHITE}/{Colors.CYAN}{{total_fmt}}"
|
|
145
|
+
f" {Colors.DARK_GRAY}[{Colors.YELLOW}{{elapsed}}{Colors.WHITE} < {Colors.CYAN}{{remaining}}{Colors.DARK_GRAY}]"
|
|
146
|
+
f"{Colors.WHITE}{{postfix}} ",
|
|
144
147
|
unit='B',
|
|
145
148
|
unit_scale=True,
|
|
146
149
|
unit_divisor=1024,
|
|
147
150
|
mininterval=0.05,
|
|
148
151
|
file=sys.stdout
|
|
149
152
|
)
|
|
150
|
-
|
|
153
|
+
|
|
154
|
+
start_time = time.time()
|
|
151
155
|
downloaded = 0
|
|
152
156
|
with open(temp_path, 'wb') as file, progress_bar as bar:
|
|
153
157
|
try:
|
|
@@ -160,6 +164,14 @@ def MP4_downloader(url: str, path: str, referer: str = None, headers_: dict = No
|
|
|
160
164
|
size = file.write(chunk)
|
|
161
165
|
downloaded += size
|
|
162
166
|
bar.update(size)
|
|
167
|
+
|
|
168
|
+
# Update postfix with speed and final size
|
|
169
|
+
elapsed = time.time() - start_time
|
|
170
|
+
if elapsed > 0:
|
|
171
|
+
speed = downloaded / elapsed
|
|
172
|
+
speed_str = internet_manager.format_transfer_speed(speed)
|
|
173
|
+
postfix_str = f"{Colors.LIGHT_MAGENTA}@ {Colors.LIGHT_CYAN}{speed_str}"
|
|
174
|
+
bar.set_postfix_str(postfix_str)
|
|
163
175
|
|
|
164
176
|
except KeyboardInterrupt:
|
|
165
177
|
if not interrupt_handler.force_quit:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# 16.04.24
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
import time
|
|
4
5
|
import logging
|
|
5
6
|
import threading
|
|
6
7
|
import subprocess
|
|
@@ -29,6 +30,7 @@ def capture_output(process: subprocess.Popen, description: str) -> None:
|
|
|
29
30
|
"""
|
|
30
31
|
try:
|
|
31
32
|
max_length = 0
|
|
33
|
+
start_time = time.time()
|
|
32
34
|
|
|
33
35
|
for line in iter(process.stdout.readline, ''):
|
|
34
36
|
try:
|
|
@@ -44,8 +46,7 @@ def capture_output(process: subprocess.Popen, description: str) -> None:
|
|
|
44
46
|
|
|
45
47
|
if "size=" in line:
|
|
46
48
|
try:
|
|
47
|
-
|
|
48
|
-
# Parse the output line to extract relevant information
|
|
49
|
+
elapsed_time = time.time() - start_time
|
|
49
50
|
data = parse_output_line(line)
|
|
50
51
|
|
|
51
52
|
if 'q' in data:
|
|
@@ -55,11 +56,25 @@ def capture_output(process: subprocess.Popen, description: str) -> None:
|
|
|
55
56
|
else:
|
|
56
57
|
byte_size = int(re.findall(r'\d+', data.get('size', '0'))[0]) * 1000
|
|
57
58
|
|
|
59
|
+
# Extract additional information
|
|
60
|
+
fps = data.get('fps', 'N/A')
|
|
61
|
+
time_processed = data.get('time', 'N/A')
|
|
62
|
+
bitrate = data.get('bitrate', 'N/A')
|
|
63
|
+
speed = data.get('speed', 'N/A')
|
|
64
|
+
|
|
65
|
+
# Format elapsed time as HH:MM:SS
|
|
66
|
+
elapsed_formatted = format_time(elapsed_time)
|
|
58
67
|
|
|
59
68
|
# Construct the progress string with formatted output information
|
|
60
|
-
progress_string = (
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
progress_string = (
|
|
70
|
+
f"{description}[white]: "
|
|
71
|
+
f"([green]'fps': [yellow]{fps}[white], "
|
|
72
|
+
f"[green]'speed': [yellow]{speed}[white], "
|
|
73
|
+
f"[green]'size': [yellow]{internet_manager.format_file_size(byte_size)}[white], "
|
|
74
|
+
f"[green]'time': [yellow]{time_processed}[white], "
|
|
75
|
+
f"[green]'bitrate': [yellow]{bitrate}[white], "
|
|
76
|
+
f"[green]'elapsed': [yellow]{elapsed_formatted}[white])"
|
|
77
|
+
)
|
|
63
78
|
max_length = max(max_length, len(progress_string))
|
|
64
79
|
|
|
65
80
|
# Print the progress string to the console, overwriting the previous line
|
|
@@ -81,6 +96,19 @@ def capture_output(process: subprocess.Popen, description: str) -> None:
|
|
|
81
96
|
logging.error(f"Error terminating process: {e}")
|
|
82
97
|
|
|
83
98
|
|
|
99
|
+
def format_time(seconds: float) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Format seconds into HH:MM:SS format.
|
|
102
|
+
|
|
103
|
+
Parameters:
|
|
104
|
+
- seconds (float): Time in seconds.
|
|
105
|
+
"""
|
|
106
|
+
hours = int(seconds // 3600)
|
|
107
|
+
minutes = int((seconds % 3600) // 60)
|
|
108
|
+
secs = int(seconds % 60)
|
|
109
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
110
|
+
|
|
111
|
+
|
|
84
112
|
def parse_output_line(line: str) -> dict:
|
|
85
113
|
"""
|
|
86
114
|
Function to parse the output line and extract relevant information.
|
|
@@ -101,6 +129,10 @@ def parse_output_line(line: str) -> dict:
|
|
|
101
129
|
if len(key_value) == 2:
|
|
102
130
|
key = key_value[0]
|
|
103
131
|
value = key_value[1]
|
|
132
|
+
|
|
133
|
+
# Remove milliseconds from time value
|
|
134
|
+
if key == 'time' and isinstance(value, str) and '.' in value:
|
|
135
|
+
value = value.split('.')[0]
|
|
104
136
|
data[key] = value
|
|
105
137
|
|
|
106
138
|
return data
|