eaf-base-api 2.4.0__tar.gz → 2.4.3__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.3}/PKG-INFO +1 -1
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/base_api/base.py +261 -77
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3/eaf_base_api.egg-info}/PKG-INFO +1 -1
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/eaf_base_api.egg-info/top_level.txt +1 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/pyproject.toml +1 -1
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/LICENSE +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/MANIFEST.in +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/README.md +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/base_api/__init__.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/base_api/modules/config.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/base_api/modules/errors.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/eaf_base_api.egg-info/SOURCES.txt +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/eaf_base_api.egg-info/dependency_links.txt +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/eaf_base_api.egg-info/requires.txt +0 -0
- {eaf_base_api-2.4.0 → eaf_base_api-2.4.3}/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
|
)
|
|
@@ -371,6 +477,8 @@ class BaseCore:
|
|
|
371
477
|
bypass_kill_switch: bool = False, # prevents infinite loop
|
|
372
478
|
data: dict = None,
|
|
373
479
|
method: str = "GET",
|
|
480
|
+
headers: dict = None,
|
|
481
|
+
json: dict = None,
|
|
374
482
|
) -> Union[bytes, str, httpx.Response, None]:
|
|
375
483
|
"""
|
|
376
484
|
Fetches content in UTF-8 Text, Bytes, or as a stream using multiple request attempts,
|
|
@@ -398,8 +506,11 @@ class BaseCore:
|
|
|
398
506
|
self.check_kill_switch()
|
|
399
507
|
|
|
400
508
|
# Perform the request with stream handling
|
|
509
|
+
if headers is None:
|
|
510
|
+
headers = self.session.headers.copy()
|
|
511
|
+
|
|
401
512
|
response = self.session.request(method=method, url=url, timeout=timeout, cookies=cookies,
|
|
402
|
-
follow_redirects=allow_redirects, data=data)
|
|
513
|
+
follow_redirects=allow_redirects, data=data, headers=headers, json=json)
|
|
403
514
|
self.total_requests += 1
|
|
404
515
|
|
|
405
516
|
# NEW: bot/challenge detection
|
|
@@ -527,7 +638,7 @@ class BaseCore:
|
|
|
527
638
|
|
|
528
639
|
self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
|
|
529
640
|
if last_error:
|
|
530
|
-
|
|
641
|
+
pass
|
|
531
642
|
return None # Return None if all attempts fail
|
|
532
643
|
|
|
533
644
|
@classmethod
|
|
@@ -561,67 +672,88 @@ class BaseCore:
|
|
|
561
672
|
return sanitized[:max_length]
|
|
562
673
|
|
|
563
674
|
@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")
|
|
675
|
+
def get_m3u8_by_quality(self, m3u8_url: str, quality: Union[str, int]) -> str:
|
|
676
|
+
"""
|
|
677
|
+
Return the media-playlist URL for the requested quality.
|
|
570
678
|
|
|
679
|
+
quality:
|
|
680
|
+
- 'best' | 'half' | 'worst'
|
|
681
|
+
- 1080 / '1080' / '1080p' (and similar)
|
|
682
|
+
"""
|
|
683
|
+
# Resolve master content
|
|
684
|
+
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
685
|
+
master = m3u8.loads(m3u8_url)
|
|
686
|
+
self.logger.debug("Resolved inline/custom m3u8 master content.")
|
|
687
|
+
base_for_join = "" # URIs should be absolute in inline cases; join will handle if relative
|
|
571
688
|
else:
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if not playlist.is_variant:
|
|
577
|
-
raise ValueError("Provided URL is not a master playlist.")
|
|
689
|
+
content = self.fetch(url=m3u8_url)
|
|
690
|
+
master = m3u8.loads(content)
|
|
691
|
+
base_for_join = m3u8_url
|
|
692
|
+
self.logger.debug(f"Resolved m3u8 master: {m3u8_url}")
|
|
578
693
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
(variant.stream_info.resolution or "unknown"): variant.uri
|
|
582
|
-
for variant in playlist.playlists
|
|
583
|
-
} # Fetches the available qualities
|
|
694
|
+
if not master.is_variant:
|
|
695
|
+
raise ValueError("Provided URL/content is not a master playlist.")
|
|
584
696
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
697
|
+
variants = _collect_variants(master)
|
|
698
|
+
if not variants:
|
|
699
|
+
raise ValueError("No usable video variants found in master playlist.")
|
|
590
700
|
|
|
591
|
-
|
|
592
|
-
if
|
|
593
|
-
|
|
701
|
+
q = _normalize_quality(quality)
|
|
702
|
+
if isinstance(q, str): # 'best'/'half'/'worst'
|
|
703
|
+
chosen = _pick_by_label(variants, q)
|
|
704
|
+
else: # numeric height like 1080, 720, etc.
|
|
705
|
+
chosen = _pick_by_height(variants, q)
|
|
594
706
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
elif quality == "half":
|
|
599
|
-
url_tmp = sorted_qualities[len(sorted_qualities) // 2][1] # Mid-quality URL
|
|
707
|
+
full_url = urljoin(base_for_join or m3u8_url, chosen["uri"])
|
|
708
|
+
return full_url
|
|
600
709
|
|
|
710
|
+
def list_available_qualities(self, m3u8_url: str) -> List[int]:
|
|
711
|
+
"""
|
|
712
|
+
Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
|
|
713
|
+
"""
|
|
714
|
+
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
715
|
+
master = m3u8.loads(m3u8_url)
|
|
601
716
|
else:
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
717
|
+
content = self.fetch(url=m3u8_url)
|
|
718
|
+
master = m3u8.loads(content)
|
|
719
|
+
|
|
720
|
+
if not master.is_variant:
|
|
721
|
+
return []
|
|
722
|
+
|
|
723
|
+
heights = {h for h in (_height_from_variant(v) for v in master.playlists) if h is not None}
|
|
724
|
+
if heights:
|
|
725
|
+
return sorted(heights)
|
|
726
|
+
# fallback: bandwidth-only (roughly infer tiers)
|
|
727
|
+
by_bw = sorted(
|
|
728
|
+
(getattr(v.stream_info, "bandwidth", 0) for v in master.playlists if _is_video_playlist(v)),
|
|
729
|
+
key=int
|
|
730
|
+
)
|
|
731
|
+
# Return rank numbers instead of heights if we truly can't infer—kept simple:
|
|
732
|
+
return [i for i, _ in enumerate(by_bw, start=1)]
|
|
606
733
|
|
|
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"""
|
|
734
|
+
def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
|
|
610
735
|
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
|
|
736
|
+
self.logger.debug(f"Trying to fetch segment from m3u8 -> {m3u8_url}")
|
|
612
737
|
content = self.fetch(url=m3u8_url)
|
|
613
738
|
|
|
739
|
+
segments = []
|
|
614
740
|
m3u8_processed = m3u8.loads(content)
|
|
615
741
|
|
|
616
742
|
if m3u8_processed.is_variant:
|
|
617
|
-
self.logger.warning("
|
|
743
|
+
self.logger.warning("Media playlist expected; got variant. Resolving to first sub-playlist...")
|
|
618
744
|
new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
|
|
619
745
|
self.logger.info(f"Resolved to new URL: {new_m3u8}")
|
|
620
746
|
content = self.fetch(url=new_m3u8)
|
|
621
747
|
m3u8_processed = m3u8.loads(content)
|
|
622
748
|
|
|
623
|
-
|
|
624
|
-
|
|
749
|
+
if getattr(m3u8_processed, "segment_map", None):
|
|
750
|
+
init_uri = m3u8_processed.segment_map[0].uri
|
|
751
|
+
init_url = urljoin(m3u8_url, init_uri)
|
|
752
|
+
segments.append(init_url)
|
|
753
|
+
self.logger.debug(f"Found init segment: {init_url}")
|
|
754
|
+
|
|
755
|
+
segments.extend(urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments)
|
|
756
|
+
self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL (including init if present)")
|
|
625
757
|
return segments
|
|
626
758
|
|
|
627
759
|
def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
|
|
@@ -728,13 +860,13 @@ class BaseCore:
|
|
|
728
860
|
|
|
729
861
|
def _convert_ts_to_mp4(self, input_path: str, output_path: str, callback=None):
|
|
730
862
|
try:
|
|
731
|
-
import
|
|
863
|
+
from av import open as av_open
|
|
732
864
|
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!")
|
|
865
|
+
except (ModuleNotFoundError, ImportError) as e:
|
|
866
|
+
raise ModuleNotFoundError(f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}")
|
|
735
867
|
|
|
736
|
-
input_ =
|
|
737
|
-
output =
|
|
868
|
+
input_ = av_open(input_path)
|
|
869
|
+
output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
|
|
738
870
|
|
|
739
871
|
# --- VIDEO: keep your exact remux approach ---
|
|
740
872
|
in_video = input_.streams.video[0]
|
|
@@ -848,39 +980,91 @@ class BaseCore:
|
|
|
848
980
|
|
|
849
981
|
return False
|
|
850
982
|
|
|
851
|
-
def legacy_download(self, path: str, url: str, callback=None
|
|
983
|
+
def legacy_download(self, path: str, url: str, callback=None,
|
|
984
|
+
chunk_size: int = 1 << 20, # 1 MiB
|
|
985
|
+
max_retries: int = 5,
|
|
986
|
+
read_timeout: float = 120.0) -> bool:
|
|
852
987
|
"""
|
|
853
|
-
Download a file using streaming
|
|
988
|
+
Download a file using streaming with stall tolerance and resume.
|
|
989
|
+
Assumes self.session is an httpx.Client.
|
|
854
990
|
"""
|
|
855
991
|
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)
|
|
992
|
+
# progress UI fallback
|
|
993
|
+
progress_bar = None
|
|
994
|
+
if callback is None:
|
|
995
|
+
progress_bar = Callback()
|
|
873
996
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=file_size)
|
|
997
|
+
# how many bytes we already have (for resume)
|
|
998
|
+
downloaded_so_far = os.path.getsize(path) if os.path.exists(path) else 0
|
|
999
|
+
etag = None
|
|
1000
|
+
attempt = 0
|
|
879
1001
|
|
|
880
|
-
|
|
881
|
-
|
|
1002
|
+
# prefer a longer read timeout to ride out throttling pauses
|
|
1003
|
+
timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=30.0, pool=30.0)
|
|
882
1004
|
|
|
883
|
-
|
|
1005
|
+
while True:
|
|
1006
|
+
headers = {
|
|
1007
|
+
}
|
|
1008
|
+
if downloaded_so_far:
|
|
1009
|
+
headers["Range"] = f"bytes={downloaded_so_far}-"
|
|
1010
|
+
|
|
1011
|
+
# stream the next segment (or whole file if starting fresh)
|
|
1012
|
+
with self.session.stream("GET", url, headers=headers,
|
|
1013
|
+
follow_redirects=True, timeout=timeout) as response:
|
|
1014
|
+
# If we asked for a Range and got 200, server ignored it: start over.
|
|
1015
|
+
if downloaded_so_far and response.status_code == 200:
|
|
1016
|
+
downloaded_so_far = 0 # restart from scratch
|
|
1017
|
+
response.raise_for_status()
|
|
1018
|
+
|
|
1019
|
+
# track ETag to detect mid-download content changes
|
|
1020
|
+
etag_cur = response.headers.get("ETag")
|
|
1021
|
+
if etag is None:
|
|
1022
|
+
etag = etag_cur
|
|
1023
|
+
elif etag_cur and etag_cur != etag:
|
|
1024
|
+
raise RuntimeError("Remote content changed during download")
|
|
1025
|
+
|
|
1026
|
+
total = None
|
|
1027
|
+
# Prefer Content-Range total when resuming; else fall back to Content-Length
|
|
1028
|
+
cr = response.headers.get("Content-Range") # e.g., "bytes start-end/total"
|
|
1029
|
+
if cr and "/" in cr:
|
|
1030
|
+
try:
|
|
1031
|
+
total = int(cr.rsplit("/", 1)[1])
|
|
1032
|
+
except ValueError:
|
|
1033
|
+
total = None
|
|
1034
|
+
if total is None:
|
|
1035
|
+
try:
|
|
1036
|
+
total = int(response.headers.get("Content-Length", "0")) or None
|
|
1037
|
+
except ValueError:
|
|
1038
|
+
total = None
|
|
1039
|
+
|
|
1040
|
+
mode = "ab" if downloaded_so_far else "wb"
|
|
1041
|
+
with open(path, mode) as file:
|
|
1042
|
+
try:
|
|
1043
|
+
for chunk in response.iter_bytes(chunk_size=chunk_size):
|
|
1044
|
+
if not chunk:
|
|
1045
|
+
continue
|
|
1046
|
+
file.write(chunk)
|
|
1047
|
+
downloaded_so_far += len(chunk)
|
|
1048
|
+
|
|
1049
|
+
# update progress
|
|
1050
|
+
if callback:
|
|
1051
|
+
callback(downloaded_so_far, total or 0)
|
|
1052
|
+
|
|
1053
|
+
else:
|
|
1054
|
+
progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total or 0)
|
|
1055
|
+
|
|
1056
|
+
# finished successfully
|
|
1057
|
+
if progress_bar:
|
|
1058
|
+
del progress_bar
|
|
1059
|
+
return True
|
|
1060
|
+
|
|
1061
|
+
except httpx.ReadTimeout:
|
|
1062
|
+
# stall: back off and retry from current offset
|
|
1063
|
+
attempt += 1
|
|
1064
|
+
if attempt > max_retries:
|
|
1065
|
+
raise
|
|
1066
|
+
time.sleep(min(2 ** attempt, 30))
|
|
1067
|
+
continue # loop retries with Range # let him coook!!!
|
|
884
1068
|
|
|
885
1069
|
except httpx.StreamClosed:
|
|
886
1070
|
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
|