eaf-base-api 2.4.0__tar.gz → 2.4.2__tar.gz
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.
- {eaf_base_api-2.4.0/eaf_base_api.egg-info → eaf_base_api-2.4.2}/PKG-INFO +1 -1
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/base_api/base.py +255 -76
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2/eaf_base_api.egg-info}/PKG-INFO +1 -1
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/top_level.txt +1 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/pyproject.toml +1 -1
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/LICENSE +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/MANIFEST.in +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/README.md +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/base_api/__init__.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/base_api/modules/config.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/base_api/modules/errors.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/SOURCES.txt +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/dependency_links.txt +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/requires.txt +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.2}/setup.cfg +0 -0
|
@@ -9,7 +9,7 @@ import logging
|
|
|
9
9
|
import traceback
|
|
10
10
|
import threading
|
|
11
11
|
|
|
12
|
-
from typing import Union
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
13
|
from functools import lru_cache
|
|
14
14
|
from urllib.parse import urljoin
|
|
15
15
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
@@ -33,6 +33,111 @@ user_agents = [
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
loggers = {}
|
|
36
|
+
HEIGHT_FROM_URI = re.compile(r'(?<!\d)(\d{3,4})[pP](?!\d)') # e.g., 1080p, 720P
|
|
37
|
+
|
|
38
|
+
def _height_from_variant(variant) -> Optional[int]:
|
|
39
|
+
"""Extract height from a variant:
|
|
40
|
+
1) stream_info.resolution (w, h)
|
|
41
|
+
2) URI pattern like .../720p/...
|
|
42
|
+
"""
|
|
43
|
+
if getattr(variant, "stream_info", None) and variant.stream_info.resolution:
|
|
44
|
+
_, h = variant.stream_info.resolution # (w, h)
|
|
45
|
+
return int(h)
|
|
46
|
+
|
|
47
|
+
if variant.uri:
|
|
48
|
+
m = HEIGHT_FROM_URI.search(variant.uri)
|
|
49
|
+
if m:
|
|
50
|
+
return int(m.group(1))
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _is_video_playlist(variant) -> bool:
|
|
55
|
+
"""Filter out I-frames/audio-only playlists."""
|
|
56
|
+
# m3u8 lib sometimes sets is_iframe if EXT-X-I-FRAME-STREAM-INF is present.
|
|
57
|
+
if getattr(variant, "is_iframe", False):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# If codecs known and contain only audio (mp4a, ac-3, ec-3, etc.)
|
|
61
|
+
codecs = getattr(variant.stream_info, "codecs", None) if getattr(variant, "stream_info", None) else None
|
|
62
|
+
if codecs:
|
|
63
|
+
# very light heuristic: if no video codec substring, probably audio-only.
|
|
64
|
+
# video: avc1, hvc1, hev1, vp9, av01, dvh
|
|
65
|
+
if not any(v in codecs.lower() for v in ("avc1", "hvc1", "hev1", "av01", "vp9", "dvh")):
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
def _collect_variants(master: m3u8.M3U8) -> List[Dict[str, Any]]:
|
|
71
|
+
"""Normalize playlist variants to a comparable list."""
|
|
72
|
+
items: List[Dict[str, Any]] = []
|
|
73
|
+
for v in master.playlists:
|
|
74
|
+
if not _is_video_playlist(v):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
h = _height_from_variant(v)
|
|
78
|
+
bw = getattr(v.stream_info, "bandwidth", 0) if getattr(v, "stream_info", None) else 0
|
|
79
|
+
fr = getattr(v.stream_info, "frame_rate", 0.0) if getattr(v, "stream_info", None) else 0.0
|
|
80
|
+
items.append({
|
|
81
|
+
"uri": v.uri,
|
|
82
|
+
"height": h, # may be None
|
|
83
|
+
"bandwidth": int(bw or 0),
|
|
84
|
+
"frame_rate": float(fr or 0.0),
|
|
85
|
+
"resolution": getattr(v.stream_info, "resolution", None) if getattr(v, "stream_info", None) else None,
|
|
86
|
+
"raw": v
|
|
87
|
+
})
|
|
88
|
+
return items
|
|
89
|
+
|
|
90
|
+
def _pick_by_label(variants: List[Dict[str, Any]], label: str) -> Dict[str, Any]:
|
|
91
|
+
"""best / worst / half based on a combined rank by (height, bandwidth)."""
|
|
92
|
+
# rank by height first, then bandwidth as tiebreaker
|
|
93
|
+
def key_fn(v):
|
|
94
|
+
return (v["height"] or 0, v["bandwidth"])
|
|
95
|
+
ordered = sorted(variants, key=key_fn)
|
|
96
|
+
|
|
97
|
+
if not ordered:
|
|
98
|
+
raise ValueError("No video variants available in master playlist.")
|
|
99
|
+
|
|
100
|
+
if label == "worst":
|
|
101
|
+
return ordered[0]
|
|
102
|
+
elif label == "half":
|
|
103
|
+
return ordered[len(ordered)//2]
|
|
104
|
+
elif label == "best":
|
|
105
|
+
return ordered[-1]
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("Invalid quality label.")
|
|
108
|
+
|
|
109
|
+
def _normalize_quality(quality: Union[str, int]) -> Union[str, int]:
|
|
110
|
+
"""Convert '1080p'->1080, '720'->720, keep labels as-is."""
|
|
111
|
+
if isinstance(quality, int):
|
|
112
|
+
return quality
|
|
113
|
+
q = str(quality).strip().lower()
|
|
114
|
+
if q in {"best", "worst", "half"}:
|
|
115
|
+
return q
|
|
116
|
+
m = re.search(r'(\d{3,4})', q)
|
|
117
|
+
if m:
|
|
118
|
+
return int(m.group(1))
|
|
119
|
+
raise ValueError(f"Invalid quality value: {quality!r}")
|
|
120
|
+
|
|
121
|
+
def _pick_by_height(variants: List[Dict[str, Any]], target: int) -> Dict[str, Any]:
|
|
122
|
+
"""Choose the highest height ≤ target; else closest by absolute diff (ties -> higher)."""
|
|
123
|
+
with_height = [v for v in variants if v["height"] is not None]
|
|
124
|
+
if with_height:
|
|
125
|
+
# Prefer height <= target
|
|
126
|
+
below_eq = [v for v in with_height if v["height"] <= target]
|
|
127
|
+
if below_eq:
|
|
128
|
+
# Among same height, prefer higher bandwidth then higher fps
|
|
129
|
+
best = sorted(below_eq, key=lambda v: (v["height"], v["bandwidth"], v["frame_rate"]))[-1]
|
|
130
|
+
return best
|
|
131
|
+
|
|
132
|
+
# Fallback: closest by absolute diff; ties -> higher height
|
|
133
|
+
def diff_key(v):
|
|
134
|
+
return (abs(v["height"] - target), -v["height"], v["bandwidth"], v["frame_rate"])
|
|
135
|
+
return sorted(with_height, key=diff_key)[0]
|
|
136
|
+
|
|
137
|
+
# If we have no heights at all, fall back to bandwidth ranking
|
|
138
|
+
return sorted(variants, key=lambda v: v["bandwidth"])[-1]
|
|
139
|
+
|
|
140
|
+
|
|
36
141
|
|
|
37
142
|
# Default formatter
|
|
38
143
|
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
@@ -150,6 +255,7 @@ LOG_COLORS = {
|
|
|
150
255
|
}
|
|
151
256
|
RESET_COLOR = "\033[0m"
|
|
152
257
|
|
|
258
|
+
|
|
153
259
|
class ColoredFormatter(logging.Formatter):
|
|
154
260
|
def format(self, record):
|
|
155
261
|
log_message = super().format(record)
|
|
@@ -299,7 +405,7 @@ class BaseCore:
|
|
|
299
405
|
proxy_ip = self.config.proxy
|
|
300
406
|
pattern = re.compile(
|
|
301
407
|
r'^(?P<scheme>http|socks5)://'
|
|
302
|
-
r'(
|
|
408
|
+
r'(?:\w+:\w+@)?' # optional user:pass@
|
|
303
409
|
r'(?P<host>[a-zA-Z0-9.-]+)'
|
|
304
410
|
r':(?P<port>\d{1,5})$'
|
|
305
411
|
)
|
|
@@ -527,7 +633,7 @@ class BaseCore:
|
|
|
527
633
|
|
|
528
634
|
self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
|
|
529
635
|
if last_error:
|
|
530
|
-
|
|
636
|
+
pass
|
|
531
637
|
return None # Return None if all attempts fail
|
|
532
638
|
|
|
533
639
|
@classmethod
|
|
@@ -561,67 +667,88 @@ class BaseCore:
|
|
|
561
667
|
return sanitized[:max_length]
|
|
562
668
|
|
|
563
669
|
@lru_cache(maxsize=250)
|
|
564
|
-
def get_m3u8_by_quality(self, m3u8_url: str, quality: str) -> str:
|
|
565
|
-
"""
|
|
566
|
-
|
|
567
|
-
if "#" in m3u8_url:
|
|
568
|
-
playlist = m3u8.loads(m3u8_url)
|
|
569
|
-
self.logger.debug("Resolved custom m3u8")
|
|
670
|
+
def get_m3u8_by_quality(self, m3u8_url: str, quality: Union[str, int]) -> str:
|
|
671
|
+
"""
|
|
672
|
+
Return the media-playlist URL for the requested quality.
|
|
570
673
|
|
|
674
|
+
quality:
|
|
675
|
+
- 'best' | 'half' | 'worst'
|
|
676
|
+
- 1080 / '1080' / '1080p' (and similar)
|
|
677
|
+
"""
|
|
678
|
+
# Resolve master content
|
|
679
|
+
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
680
|
+
master = m3u8.loads(m3u8_url)
|
|
681
|
+
self.logger.debug("Resolved inline/custom m3u8 master content.")
|
|
682
|
+
base_for_join = "" # URIs should be absolute in inline cases; join will handle if relative
|
|
571
683
|
else:
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if not playlist.is_variant:
|
|
577
|
-
raise ValueError("Provided URL is not a master playlist.")
|
|
578
|
-
|
|
579
|
-
# Extract available qualities and URLs
|
|
580
|
-
qualities = {
|
|
581
|
-
(variant.stream_info.resolution or "unknown"): variant.uri
|
|
582
|
-
for variant in playlist.playlists
|
|
583
|
-
} # Fetches the available qualities
|
|
684
|
+
content = self.fetch(url=m3u8_url)
|
|
685
|
+
master = m3u8.loads(content)
|
|
686
|
+
base_for_join = m3u8_url
|
|
687
|
+
self.logger.debug(f"Resolved m3u8 master: {m3u8_url}")
|
|
584
688
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
qualities.items(),
|
|
588
|
-
key=lambda x: (x[0][0] * x[0][1]) if x[0] != "unknown" else 0
|
|
589
|
-
) # Sorts qualities to support different HLS naming schemes
|
|
689
|
+
if not master.is_variant:
|
|
690
|
+
raise ValueError("Provided URL/content is not a master playlist.")
|
|
590
691
|
|
|
591
|
-
|
|
592
|
-
if
|
|
593
|
-
|
|
692
|
+
variants = _collect_variants(master)
|
|
693
|
+
if not variants:
|
|
694
|
+
raise ValueError("No usable video variants found in master playlist.")
|
|
594
695
|
|
|
595
|
-
|
|
596
|
-
|
|
696
|
+
q = _normalize_quality(quality)
|
|
697
|
+
if isinstance(q, str): # 'best'/'half'/'worst'
|
|
698
|
+
chosen = _pick_by_label(variants, q)
|
|
699
|
+
else: # numeric height like 1080, 720, etc.
|
|
700
|
+
chosen = _pick_by_height(variants, q)
|
|
597
701
|
|
|
598
|
-
|
|
599
|
-
|
|
702
|
+
full_url = urljoin(base_for_join or m3u8_url, chosen["uri"])
|
|
703
|
+
return full_url
|
|
600
704
|
|
|
705
|
+
def list_available_qualities(self, m3u8_url: str) -> List[int]:
|
|
706
|
+
"""
|
|
707
|
+
Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
|
|
708
|
+
"""
|
|
709
|
+
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
710
|
+
master = m3u8.loads(m3u8_url)
|
|
601
711
|
else:
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
712
|
+
content = self.fetch(url=m3u8_url)
|
|
713
|
+
master = m3u8.loads(content)
|
|
714
|
+
|
|
715
|
+
if not master.is_variant:
|
|
716
|
+
return []
|
|
717
|
+
|
|
718
|
+
heights = {h for h in (_height_from_variant(v) for v in master.playlists) if h is not None}
|
|
719
|
+
if heights:
|
|
720
|
+
return sorted(heights)
|
|
721
|
+
# fallback: bandwidth-only (roughly infer tiers)
|
|
722
|
+
by_bw = sorted(
|
|
723
|
+
(getattr(v.stream_info, "bandwidth", 0) for v in master.playlists if _is_video_playlist(v)),
|
|
724
|
+
key=int
|
|
725
|
+
)
|
|
726
|
+
# Return rank numbers instead of heights if we truly can't infer—kept simple:
|
|
727
|
+
return [i for i, _ in enumerate(by_bw, start=1)]
|
|
606
728
|
|
|
607
|
-
|
|
608
|
-
def get_segments(self, m3u8_url_master: str, quality: str) -> list:
|
|
609
|
-
"""Gets all video segments from a given quality and the primary m3u8 URL"""
|
|
729
|
+
def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
|
|
610
730
|
m3u8_url = self.get_m3u8_by_quality(m3u8_url=m3u8_url_master, quality=quality)
|
|
611
|
-
self.logger.debug(f"Trying to fetch segment from m3u8
|
|
731
|
+
self.logger.debug(f"Trying to fetch segment from m3u8 -> {m3u8_url}")
|
|
612
732
|
content = self.fetch(url=m3u8_url)
|
|
613
733
|
|
|
734
|
+
segments = []
|
|
614
735
|
m3u8_processed = m3u8.loads(content)
|
|
615
736
|
|
|
616
737
|
if m3u8_processed.is_variant:
|
|
617
|
-
self.logger.warning("
|
|
738
|
+
self.logger.warning("Media playlist expected; got variant. Resolving to first sub-playlist...")
|
|
618
739
|
new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
|
|
619
740
|
self.logger.info(f"Resolved to new URL: {new_m3u8}")
|
|
620
741
|
content = self.fetch(url=new_m3u8)
|
|
621
742
|
m3u8_processed = m3u8.loads(content)
|
|
622
743
|
|
|
623
|
-
|
|
624
|
-
|
|
744
|
+
if getattr(m3u8_processed, "segment_map", None):
|
|
745
|
+
init_uri = m3u8_processed.segment_map[0].uri
|
|
746
|
+
init_url = urljoin(m3u8_url, init_uri)
|
|
747
|
+
segments.append(init_url)
|
|
748
|
+
self.logger.debug(f"Found init segment: {init_url}")
|
|
749
|
+
|
|
750
|
+
segments.extend(urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments)
|
|
751
|
+
self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL (including init if present)")
|
|
625
752
|
return segments
|
|
626
753
|
|
|
627
754
|
def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
|
|
@@ -728,13 +855,13 @@ class BaseCore:
|
|
|
728
855
|
|
|
729
856
|
def _convert_ts_to_mp4(self, input_path: str, output_path: str, callback=None):
|
|
730
857
|
try:
|
|
731
|
-
import
|
|
858
|
+
from av import open as av_open
|
|
732
859
|
from av.audio.resampler import AudioResampler
|
|
733
|
-
except (ModuleNotFoundError, ImportError):
|
|
734
|
-
raise ModuleNotFoundError("PyAV is required for remuxing. Install with pip install av. Not supported on Termux!")
|
|
860
|
+
except (ModuleNotFoundError, ImportError) as e:
|
|
861
|
+
raise ModuleNotFoundError(f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}")
|
|
735
862
|
|
|
736
|
-
input_ =
|
|
737
|
-
output =
|
|
863
|
+
input_ = av_open(input_path)
|
|
864
|
+
output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
|
|
738
865
|
|
|
739
866
|
# --- VIDEO: keep your exact remux approach ---
|
|
740
867
|
in_video = input_.streams.video[0]
|
|
@@ -848,39 +975,91 @@ class BaseCore:
|
|
|
848
975
|
|
|
849
976
|
return False
|
|
850
977
|
|
|
851
|
-
def legacy_download(self, path: str, url: str, callback=None
|
|
978
|
+
def legacy_download(self, path: str, url: str, callback=None,
|
|
979
|
+
chunk_size: int = 1 << 20, # 1 MiB
|
|
980
|
+
max_retries: int = 5,
|
|
981
|
+
read_timeout: float = 120.0) -> bool:
|
|
852
982
|
"""
|
|
853
|
-
Download a file using streaming
|
|
983
|
+
Download a file using streaming with stall tolerance and resume.
|
|
984
|
+
Assumes self.session is an httpx.Client.
|
|
854
985
|
"""
|
|
855
986
|
try:
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if callback is None:
|
|
861
|
-
progress_bar = Callback()
|
|
862
|
-
|
|
863
|
-
downloaded_so_far = 0
|
|
864
|
-
|
|
865
|
-
# Open file for writing
|
|
866
|
-
with open(path, 'wb') as file:
|
|
867
|
-
for chunk in response.iter_bytes(chunk_size=1024):
|
|
868
|
-
if not chunk:
|
|
869
|
-
break # End of stream
|
|
870
|
-
|
|
871
|
-
file.write(chunk)
|
|
872
|
-
downloaded_so_far += len(chunk)
|
|
987
|
+
# progress UI fallback
|
|
988
|
+
progress_bar = None
|
|
989
|
+
if callback is None:
|
|
990
|
+
progress_bar = Callback()
|
|
873
991
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=file_size)
|
|
992
|
+
# how many bytes we already have (for resume)
|
|
993
|
+
downloaded_so_far = os.path.getsize(path) if os.path.exists(path) else 0
|
|
994
|
+
etag = None
|
|
995
|
+
attempt = 0
|
|
879
996
|
|
|
880
|
-
|
|
881
|
-
|
|
997
|
+
# prefer a longer read timeout to ride out throttling pauses
|
|
998
|
+
timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=30.0, pool=30.0)
|
|
882
999
|
|
|
883
|
-
|
|
1000
|
+
while True:
|
|
1001
|
+
headers = {
|
|
1002
|
+
}
|
|
1003
|
+
if downloaded_so_far:
|
|
1004
|
+
headers["Range"] = f"bytes={downloaded_so_far}-"
|
|
1005
|
+
|
|
1006
|
+
# stream the next segment (or whole file if starting fresh)
|
|
1007
|
+
with self.session.stream("GET", url, headers=headers,
|
|
1008
|
+
follow_redirects=True, timeout=timeout) as response:
|
|
1009
|
+
# If we asked for a Range and got 200, server ignored it: start over.
|
|
1010
|
+
if downloaded_so_far and response.status_code == 200:
|
|
1011
|
+
downloaded_so_far = 0 # restart from scratch
|
|
1012
|
+
response.raise_for_status()
|
|
1013
|
+
|
|
1014
|
+
# track ETag to detect mid-download content changes
|
|
1015
|
+
etag_cur = response.headers.get("ETag")
|
|
1016
|
+
if etag is None:
|
|
1017
|
+
etag = etag_cur
|
|
1018
|
+
elif etag_cur and etag_cur != etag:
|
|
1019
|
+
raise RuntimeError("Remote content changed during download")
|
|
1020
|
+
|
|
1021
|
+
total = None
|
|
1022
|
+
# Prefer Content-Range total when resuming; else fall back to Content-Length
|
|
1023
|
+
cr = response.headers.get("Content-Range") # e.g., "bytes start-end/total"
|
|
1024
|
+
if cr and "/" in cr:
|
|
1025
|
+
try:
|
|
1026
|
+
total = int(cr.rsplit("/", 1)[1])
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
total = None
|
|
1029
|
+
if total is None:
|
|
1030
|
+
try:
|
|
1031
|
+
total = int(response.headers.get("Content-Length", "0")) or None
|
|
1032
|
+
except ValueError:
|
|
1033
|
+
total = None
|
|
1034
|
+
|
|
1035
|
+
mode = "ab" if downloaded_so_far else "wb"
|
|
1036
|
+
with open(path, mode) as file:
|
|
1037
|
+
try:
|
|
1038
|
+
for chunk in response.iter_bytes(chunk_size=chunk_size):
|
|
1039
|
+
if not chunk:
|
|
1040
|
+
continue
|
|
1041
|
+
file.write(chunk)
|
|
1042
|
+
downloaded_so_far += len(chunk)
|
|
1043
|
+
|
|
1044
|
+
# update progress
|
|
1045
|
+
if callback:
|
|
1046
|
+
callback(downloaded_so_far, total or 0)
|
|
1047
|
+
|
|
1048
|
+
else:
|
|
1049
|
+
progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total or 0)
|
|
1050
|
+
|
|
1051
|
+
# finished successfully
|
|
1052
|
+
if progress_bar:
|
|
1053
|
+
del progress_bar
|
|
1054
|
+
return True
|
|
1055
|
+
|
|
1056
|
+
except httpx.ReadTimeout:
|
|
1057
|
+
# stall: back off and retry from current offset
|
|
1058
|
+
attempt += 1
|
|
1059
|
+
if attempt > max_retries:
|
|
1060
|
+
raise
|
|
1061
|
+
time.sleep(min(2 ** attempt, 30))
|
|
1062
|
+
continue # loop retries with Range # let him coook!!!
|
|
884
1063
|
|
|
885
1064
|
except httpx.StreamClosed:
|
|
886
1065
|
self.logger.error(f"Stream for: {url} was closed. This should not happen...")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|