eaf_base_api 4.0.1__tar.gz → 4.1.1__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-4.0.1 → eaf_base_api-4.1.1}/PKG-INFO +1 -1
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/base.py +161 -65
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/static_functions.py +6 -1
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/pyproject.toml +1 -1
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/LICENSE +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/README.md +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/__init__.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/config.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/errors.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/logger.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/modules/type_hints.py +0 -0
- {eaf_base_api-4.0.1 → eaf_base_api-4.1.1}/base_api/py.typed +0 -0
|
@@ -102,6 +102,47 @@ def _contains_http_status(error: BaseException, status_code: int) -> bool:
|
|
|
102
102
|
return False
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def _normalize_packet_timestamps(
|
|
106
|
+
packet: Any,
|
|
107
|
+
offsets: dict[int, int],
|
|
108
|
+
last_dts: dict[int, int],
|
|
109
|
+
last_durations: dict[int, int],
|
|
110
|
+
) -> int:
|
|
111
|
+
"""Keep remuxed packet timestamps continuous across HLS discontinuities."""
|
|
112
|
+
if packet.dts is None:
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
stream_index = packet.stream.index
|
|
116
|
+
raw_dts = packet.dts
|
|
117
|
+
offset = offsets.get(stream_index, 0)
|
|
118
|
+
normalized_dts = raw_dts + offset
|
|
119
|
+
previous_dts = last_dts.get(stream_index)
|
|
120
|
+
correction = 0
|
|
121
|
+
|
|
122
|
+
if previous_dts is not None:
|
|
123
|
+
step = max(1, last_durations.get(stream_index, packet.duration or 1))
|
|
124
|
+
expected_dts = previous_dts + step
|
|
125
|
+
try:
|
|
126
|
+
ten_seconds = max(1, int(10 / float(packet.time_base)))
|
|
127
|
+
except (TypeError, ValueError, ZeroDivisionError):
|
|
128
|
+
ten_seconds = step * 240
|
|
129
|
+
|
|
130
|
+
discontinuity_threshold = max(step * 10, ten_seconds)
|
|
131
|
+
delta = normalized_dts - expected_dts
|
|
132
|
+
if normalized_dts <= previous_dts or abs(delta) > discontinuity_threshold:
|
|
133
|
+
correction = -delta
|
|
134
|
+
offset += correction
|
|
135
|
+
offsets[stream_index] = offset
|
|
136
|
+
normalized_dts = raw_dts + offset
|
|
137
|
+
|
|
138
|
+
packet.dts = normalized_dts
|
|
139
|
+
if packet.pts is not None:
|
|
140
|
+
packet.pts += offset
|
|
141
|
+
last_dts[stream_index] = normalized_dts
|
|
142
|
+
last_durations[stream_index] = max(1, packet.duration or 1)
|
|
143
|
+
return correction
|
|
144
|
+
|
|
145
|
+
|
|
105
146
|
@dataclass(frozen=True, slots=True)
|
|
106
147
|
class RequestCacheKey:
|
|
107
148
|
"""Identity of a cacheable HTTP request without retaining credentials."""
|
|
@@ -2284,7 +2325,7 @@ class BaseCore:
|
|
|
2284
2325
|
self.logger.debug("Failed to remove directory %s: %s", path, e)
|
|
2285
2326
|
|
|
2286
2327
|
async def download_segment(self, url: str, timeout: int, stop_event:
|
|
2287
|
-
|
|
2328
|
+
asyncio.Event | None = None) -> tuple[str, bytes, bool]:
|
|
2288
2329
|
"""
|
|
2289
2330
|
Attempt to download a single segment.
|
|
2290
2331
|
Returns (url, content, success).
|
|
@@ -2522,6 +2563,8 @@ class BaseCore:
|
|
|
2522
2563
|
else:
|
|
2523
2564
|
self.logger.debug(f"Writing segments to disk. segment_dir={segment_dir} tmp_path={tmp_path}")
|
|
2524
2565
|
|
|
2566
|
+
segment_tasks: set[asyncio.Task[Tuple[int, bool, bytes]]] = set()
|
|
2567
|
+
stop_waiter: asyncio.Task[bool] | None = None
|
|
2525
2568
|
try:
|
|
2526
2569
|
# Use asyncio.gather to fetch segments concurrently instead of ThreadPoolExecutor
|
|
2527
2570
|
|
|
@@ -2556,78 +2599,110 @@ class BaseCore:
|
|
|
2556
2599
|
)
|
|
2557
2600
|
return idx, False, b""
|
|
2558
2601
|
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2602
|
+
segment_tasks = {
|
|
2603
|
+
asyncio.create_task(
|
|
2604
|
+
fetch_segment_with_semaphore(i, segments[i]),
|
|
2605
|
+
name=f"hls-segment-{i}",
|
|
2606
|
+
)
|
|
2607
|
+
for i in target_indices
|
|
2608
|
+
}
|
|
2609
|
+
stop_waiter = (
|
|
2610
|
+
asyncio.create_task(stop_event.wait(), name="hls-stop-waiter")
|
|
2611
|
+
if stop_event is not None
|
|
2612
|
+
else None
|
|
2613
|
+
)
|
|
2567
2614
|
|
|
2568
|
-
|
|
2615
|
+
while segment_tasks:
|
|
2616
|
+
waiters = set(segment_tasks)
|
|
2617
|
+
if stop_waiter is not None:
|
|
2618
|
+
waiters.add(stop_waiter)
|
|
2619
|
+
done, _ = await asyncio.wait(
|
|
2620
|
+
waiters,
|
|
2621
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
2622
|
+
)
|
|
2569
2623
|
|
|
2570
|
-
if
|
|
2571
|
-
|
|
2624
|
+
if stop_waiter is not None and stop_waiter in done:
|
|
2625
|
+
cancelled = True
|
|
2626
|
+
for task in segment_tasks:
|
|
2627
|
+
task.cancel()
|
|
2628
|
+
await asyncio.gather(*segment_tasks, return_exceptions=True)
|
|
2629
|
+
segment_tasks.clear()
|
|
2630
|
+
self.logger.info("Cancelled all in-flight HLS segment requests.")
|
|
2631
|
+
break
|
|
2632
|
+
|
|
2633
|
+
completed_tasks = done.intersection(segment_tasks)
|
|
2634
|
+
for task in completed_tasks:
|
|
2635
|
+
segment_tasks.remove(task)
|
|
2636
|
+
i, success, data = task.result()
|
|
2637
|
+
|
|
2638
|
+
if success and data:
|
|
2639
|
+
downloaded[i] = True # Successfully got segment, mark it as done
|
|
2640
|
+
downloaded_count += 1
|
|
2641
|
+
if segment_dir:
|
|
2642
|
+
# Write to a temp path (good for resuming, but not I/O efficient)
|
|
2643
|
+
seg_path = segment_file_path(segment_dir, i, width)
|
|
2644
|
+
tmp_seg = f"{seg_path}.part"
|
|
2645
|
+
# Offload segment file writing to a thread
|
|
2646
|
+
def write_part(ts_path: str, t_data: bytes) -> None:
|
|
2647
|
+
with open(ts_path, "wb") as f:
|
|
2648
|
+
f.write(t_data)
|
|
2649
|
+
await asyncio.to_thread(write_part, tmp_seg, data)
|
|
2650
|
+
os.replace(tmp_seg, seg_path)
|
|
2651
|
+
else:
|
|
2652
|
+
assert parts is not None
|
|
2653
|
+
parts[i] = data # Keep in memory (I/O efficient)
|
|
2654
|
+
|
|
2655
|
+
progressed += 1 # Fetched +1 segment, so we give back callback
|
|
2656
|
+
if callback:
|
|
2657
|
+
callback(progressed, n)
|
|
2658
|
+
if progressed >= next_progress_log or progressed == n:
|
|
2659
|
+
remaining = n - downloaded_count
|
|
2660
|
+
self.logger.debug(
|
|
2661
|
+
f"Segment progress: processed={progressed}/{n} "
|
|
2662
|
+
f"downloaded={downloaded_count} remaining={remaining}"
|
|
2663
|
+
)
|
|
2664
|
+
next_progress_log += progress_log_step
|
|
2572
2665
|
|
|
2573
|
-
if success and data:
|
|
2574
|
-
downloaded[i] = True # Successfully got segment, mark it as done
|
|
2575
|
-
downloaded_count += 1
|
|
2576
|
-
if segment_dir:
|
|
2577
|
-
# Write to a temp path (good for resuming, but not I/O efficient)
|
|
2578
|
-
seg_path = segment_file_path(segment_dir, i, width)
|
|
2579
|
-
tmp_seg = f"{seg_path}.part"
|
|
2580
|
-
# Offload segment file writing to a thread
|
|
2581
|
-
def write_part(ts_path: str, t_data: bytes) -> None:
|
|
2582
|
-
with open(ts_path, "wb") as f:
|
|
2583
|
-
f.write(t_data)
|
|
2584
|
-
await asyncio.to_thread(write_part, tmp_seg, data)
|
|
2585
|
-
os.replace(tmp_seg, seg_path)
|
|
2586
2666
|
else:
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
)
|
|
2612
|
-
next_progress_log += progress_log_step
|
|
2613
|
-
|
|
2614
|
-
if not segment_dir and parts is not None:
|
|
2615
|
-
chunks_to_write = []
|
|
2616
|
-
while next_to_write < n and parts[next_to_write] is not None:
|
|
2617
|
-
if parts[next_to_write]:
|
|
2618
|
-
chunks_to_write.append(parts[next_to_write])
|
|
2619
|
-
next_to_write += 1
|
|
2620
|
-
if chunks_to_write:
|
|
2621
|
-
# Write memory chunks to thread to prevent IO block
|
|
2622
|
-
def write_chunks(fp: Any, list_of_data: List[bytes]) -> None:
|
|
2623
|
-
for c_data in list_of_data:
|
|
2624
|
-
fp.write(c_data)
|
|
2625
|
-
await asyncio.to_thread(write_chunks, cast(Any, out_fp), chunks_to_write)
|
|
2667
|
+
# Handling failure (already retried in fetch_segment_with_semaphore)
|
|
2668
|
+
progressed += 1
|
|
2669
|
+
if callback:
|
|
2670
|
+
callback(progressed, n)
|
|
2671
|
+
if progressed >= next_progress_log or progressed == n:
|
|
2672
|
+
remaining = n - downloaded_count
|
|
2673
|
+
self.logger.debug(
|
|
2674
|
+
f"Segment progress: processed={progressed}/{n} "
|
|
2675
|
+
f"downloaded={downloaded_count} remaining={remaining}"
|
|
2676
|
+
)
|
|
2677
|
+
next_progress_log += progress_log_step
|
|
2678
|
+
|
|
2679
|
+
if not segment_dir and parts is not None:
|
|
2680
|
+
chunks_to_write = []
|
|
2681
|
+
while next_to_write < n and parts[next_to_write] is not None:
|
|
2682
|
+
if parts[next_to_write]:
|
|
2683
|
+
chunks_to_write.append(parts[next_to_write])
|
|
2684
|
+
next_to_write += 1
|
|
2685
|
+
if chunks_to_write:
|
|
2686
|
+
# Write memory chunks to thread to prevent IO block
|
|
2687
|
+
def write_chunks(fp: Any, list_of_data: List[bytes]) -> None:
|
|
2688
|
+
for c_data in list_of_data:
|
|
2689
|
+
fp.write(c_data)
|
|
2690
|
+
await asyncio.to_thread(write_chunks, cast(Any, out_fp), chunks_to_write)
|
|
2626
2691
|
|
|
2627
2692
|
finally:
|
|
2693
|
+
if stop_waiter is not None:
|
|
2694
|
+
stop_waiter.cancel()
|
|
2695
|
+
await asyncio.gather(stop_waiter, return_exceptions=True)
|
|
2696
|
+
if segment_tasks:
|
|
2697
|
+
for task in segment_tasks:
|
|
2698
|
+
task.cancel()
|
|
2699
|
+
await asyncio.gather(*segment_tasks, return_exceptions=True)
|
|
2628
2700
|
if out_fp is not None:
|
|
2629
2701
|
out_fp.close()
|
|
2630
2702
|
|
|
2703
|
+
if stop_event is not None and stop_event.is_set():
|
|
2704
|
+
cancelled = True
|
|
2705
|
+
|
|
2631
2706
|
missing = [i for i, ok in enumerate(downloaded) if not ok] # Missing segments
|
|
2632
2707
|
missing_urls = [segments[i] for i in missing] # Missing URLs of segments
|
|
2633
2708
|
self.logger.info(
|
|
@@ -2764,7 +2839,11 @@ class BaseCore:
|
|
|
2764
2839
|
if remux:
|
|
2765
2840
|
self.logger.info(f"Remuxing TS to MP4: input={tmp_path} output={path}")
|
|
2766
2841
|
# Offload heavy CPU/IO bound task
|
|
2767
|
-
|
|
2842
|
+
try:
|
|
2843
|
+
await asyncio.to_thread(self._convert_ts_to_mp4, tmp_path, path, callback_remux, ios_support)
|
|
2844
|
+
except Exception:
|
|
2845
|
+
self._safe_remove(path)
|
|
2846
|
+
raise
|
|
2768
2847
|
# This is important, because not all players can play MPEG-TS AND I want to write
|
|
2769
2848
|
# metadata to the files, and this doesn't work without a container.
|
|
2770
2849
|
self._safe_remove(tmp_path)
|
|
@@ -2894,6 +2973,9 @@ class BaseCore:
|
|
|
2894
2973
|
progress_step = max(1, total // 10) if total else 0
|
|
2895
2974
|
next_progress_log = progress_step if progress_step else 0
|
|
2896
2975
|
current_progress = 0
|
|
2976
|
+
timestamp_offsets: dict[int, int] = {}
|
|
2977
|
+
last_dts: dict[int, int] = {}
|
|
2978
|
+
last_durations: dict[int, int] = {}
|
|
2897
2979
|
|
|
2898
2980
|
for idx, packet in enumerate(packets):
|
|
2899
2981
|
pkt_size = getattr(packet, "size", 0) or 0
|
|
@@ -2904,6 +2986,20 @@ class BaseCore:
|
|
|
2904
2986
|
callback(current_progress, total)
|
|
2905
2987
|
continue
|
|
2906
2988
|
|
|
2989
|
+
timestamp_correction = _normalize_packet_timestamps(
|
|
2990
|
+
packet,
|
|
2991
|
+
timestamp_offsets,
|
|
2992
|
+
last_dts,
|
|
2993
|
+
last_durations,
|
|
2994
|
+
)
|
|
2995
|
+
if timestamp_correction:
|
|
2996
|
+
self.logger.info(
|
|
2997
|
+
"Normalized HLS timestamp discontinuity: stream=%s correction=%s time_base=%s",
|
|
2998
|
+
packet.stream.index,
|
|
2999
|
+
timestamp_correction,
|
|
3000
|
+
packet.time_base,
|
|
3001
|
+
)
|
|
3002
|
+
|
|
2907
3003
|
if packet.stream == in_video:
|
|
2908
3004
|
packet.stream = out_video
|
|
2909
3005
|
output.mux(packet)
|
|
@@ -4,6 +4,7 @@ import math
|
|
|
4
4
|
import json
|
|
5
5
|
import unicodedata
|
|
6
6
|
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import asdict
|
|
7
8
|
from pathlib import PurePath
|
|
8
9
|
from .type_hints import DownloadState
|
|
9
10
|
from datetime import timezone, datetime
|
|
@@ -361,8 +362,12 @@ def segment_file_path(segment_dir, index: int, width: int) -> str:
|
|
|
361
362
|
|
|
362
363
|
def write_segment_state(state_path: str, state: DownloadState) -> None:
|
|
363
364
|
tmp_path = f"{state_path}.tmp"
|
|
365
|
+
payload = asdict(state)
|
|
366
|
+
for path_key in ("output_path", "segment_dir"):
|
|
367
|
+
if isinstance(payload[path_key], PurePath):
|
|
368
|
+
payload[path_key] = str(payload[path_key])
|
|
364
369
|
with open(tmp_path, "w", encoding="utf-8") as fp:
|
|
365
|
-
json.dump(
|
|
370
|
+
json.dump(payload, fp, ensure_ascii=True, indent=2, sort_keys=True)
|
|
366
371
|
os.replace(tmp_path, state_path)
|
|
367
372
|
|
|
368
373
|
|
|
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
|