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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 4.0.1
3
+ Version: 4.1.1
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author: Johannes Habel
6
6
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
@@ -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
- threading.Event | None = None) -> tuple[str, bytes, bool]:
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
- tasks = [fetch_segment_with_semaphore(i, segments[i]) for i in target_indices]
2560
-
2561
- # Use asyncio.as_completed to process results as they come in, similar to wait(FIRST_COMPLETED)
2562
- for coro in asyncio.as_completed(tasks):
2563
- if stop_event is not None and stop_event.is_set():
2564
- cancelled = True
2565
- # The remaining tasks will see the event set and exit quickly
2566
- continue
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
- i, success, data = await coro
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 cancelled:
2571
- continue
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
- assert parts is not None
2588
- parts[i] = data # Keep in memory (I/O efficient)
2589
-
2590
- progressed += 1 # Fetched +1 segment, so we give back callback
2591
- if callback:
2592
- callback(progressed, n)
2593
- if progressed >= next_progress_log or progressed == n:
2594
- remaining = n - downloaded_count
2595
- self.logger.debug(
2596
- f"Segment progress: processed={progressed}/{n} "
2597
- f"downloaded={downloaded_count} remaining={remaining}"
2598
- )
2599
- next_progress_log += progress_log_step
2600
-
2601
- else:
2602
- # Handling failure (already retried in fetch_segment_with_semaphore)
2603
- progressed += 1
2604
- if callback:
2605
- callback(progressed, n)
2606
- if progressed >= next_progress_log or progressed == n:
2607
- remaining = n - downloaded_count
2608
- self.logger.debug(
2609
- f"Segment progress: processed={progressed}/{n} "
2610
- f"downloaded={downloaded_count} remaining={remaining}"
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
- await asyncio.to_thread(self._convert_ts_to_mp4, tmp_path, path, callback_remux, ios_support)
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(state, fp, ensure_ascii=True, indent=2, sort_keys=True)
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
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.0.1"
7
+ version = "4.1.1"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
File without changes
File without changes