split download and ffmpeg procces

This commit is contained in:
root
2026-09-27 12:52:05 +08:00
parent 993fd8f217
commit b6160430a0
13 changed files with 868 additions and 341 deletions
+9
View File
@@ -49,6 +49,15 @@ result = client.download_url(
DownloadRequest(output_dir=Path("downloads")),
)
# Download ordered segment files without merging tracks. The caller owns and
# removes result.temporary_dir after its own post-processing is complete.
segments = client.download_segments(
media,
DownloadRequest(output_dir=Path("downloads"), temporary_dir=Path("work")),
)
for track in segments.tracks:
print(track.track_id, track.initialization, track.segments)
video = next(track for track in media.tracks if track.resolution)
video_only = client.download(
media,
+4
View File
@@ -3,6 +3,7 @@
from .api import (
DownloadRequest,
DownloadResult,
DownloadedTrackSegments,
DownloadedFile,
MediaInfo,
MediaTrack,
@@ -11,6 +12,7 @@ from .api import (
N_m3u8DL,
RequestOptions,
TrackSelection,
SegmentDownloadResult,
)
from .errors import DownloadCancelledError, DownloadError, ManifestError, N_m3u8DLError, SelectionError
from .events import DownloadEvent, DownloadEventKind
@@ -25,6 +27,7 @@ __all__ = [
"DownloadEventKind",
"DownloadRequest",
"DownloadResult",
"DownloadedTrackSegments",
"DownloadedFile",
"EncryptMethod",
"ManifestError",
@@ -37,5 +40,6 @@ __all__ = [
"N_m3u8DLError",
"RequestOptions",
"SelectionError",
"SegmentDownloadResult",
"TrackSelection",
]
+120
View File
@@ -176,6 +176,32 @@ class DownloadResult:
temporary_dir: Path
@dataclass(frozen=True)
class DownloadedTrackSegments:
"""Downloaded segment files for one selected track, in playback order."""
track_id: str
media_type: MediaType
duration: float
directory: Path
initialization: Path | None
segments: tuple[Path, ...]
@dataclass(frozen=True)
class SegmentDownloadResult:
"""Result of :meth:`N_m3u8DL.download_segments`.
The caller owns ``temporary_dir`` and must remove it after consuming the
returned segment paths.
"""
media_info: MediaInfo
selected_track_ids: tuple[str, ...]
tracks: tuple[DownloadedTrackSegments, ...]
temporary_dir: Path
@dataclass(frozen=True)
class MuxResult:
"""Output generated by :meth:`N_m3u8DL.mux`."""
@@ -242,6 +268,100 @@ class N_m3u8DL:
media_info = self.inspect(input_value, options=options, on_event=on_event)
return self.download(media_info, request, on_event=on_event)
def download_segments_url(
self,
input_value: str | Path,
request: DownloadRequest | None = None,
*,
options: RequestOptions | None = None,
on_event: EventCallback | None = None,
) -> SegmentDownloadResult:
"""Inspect and download segment files without merging media tracks."""
media_info = self.inspect(input_value, options=options, on_event=on_event)
return self.download_segments(media_info, request, on_event=on_event)
def download_segments(
self,
media_info: MediaInfo,
request: DownloadRequest | None = None,
*,
on_event: EventCallback | None = None,
) -> SegmentDownloadResult:
"""Download selected tracks into ordered segment directories.
This method never invokes ffmpeg and deliberately leaves the temporary
directory in place for the caller to process and remove.
"""
request = request or DownloadRequest()
try:
self._emit(on_event, DownloadEventKind.DOWNLOAD_STARTED, message=f"Preparing {media_info.protocol} segment download")
selected_ids, selected_streams = self._select_streams(media_info, request.selection)
media_info._source.fetch_playlists(selected_streams)
self._emit(on_event, DownloadEventKind.PLAYLIST_LOADED, message=f"Loaded {len(selected_streams)} selected playlists")
apply_custom_range(selected_streams, request.custom_range)
clean_ads(selected_streams, list(request.ad_keywords))
if not all(stream.playlist and stream.playlist.segments for stream in selected_streams):
raise SelectionError("One or more selected tracks have no downloadable media segments")
output_dir = request.output_dir
save_name = valid_filename(request.file_name or inferred_name(media_info.input_value), 180)
temporary_root = (request.temporary_dir or output_dir / ".n_m3u8dl") / save_name
options = DownloadOptions(
tmp_dir=temporary_root,
save_dir=output_dir,
save_name=save_name,
save_pattern=request.save_pattern,
thread_count=request.thread_count,
retries=request.retry_count,
binary_merge=False,
skip_merge=True,
del_after_done=False,
check_segments_count=request.check_segments_count,
max_speed=request.max_speed,
subtitle_format=request.subtitle_format,
auto_subtitle_fix=request.auto_subtitle_fix,
ffmpeg=None,
use_ffmpeg_concat_demuxer=False,
decryption_engine=request.decryption_engine,
decryption_binary=None,
keys=[],
)
track_ids = {id(stream): track_id for track_id, stream in media_info._streams_by_id.items()}
manager = DownloadManager(
HttpClient(
dict(media_info._request_options.headers),
media_info._request_options.timeout,
media_info._request_options.proxy,
media_info._request_options.use_system_proxy,
),
options,
event_callback=on_event,
track_ids=track_ids,
)
downloaded = manager.download_segments(selected_streams, concurrent_tracks=False)
tracks = tuple(
DownloadedTrackSegments(
track_id=track_id,
media_type=self._media_type(stream),
duration=stream.playlist.total_duration if stream.playlist else 0.0,
directory=result.directory,
initialization=result.initialization,
segments=result.segments,
)
for track_id, stream, result in zip(selected_ids, selected_streams, downloaded, strict=True)
)
except DownloadCancelledError:
self._emit_cancelled(on_event)
raise
except N_m3u8DLError as error:
self._emit(on_event, DownloadEventKind.FAILED, message=str(error))
raise
except Exception as error:
self._emit(on_event, DownloadEventKind.FAILED, message=str(error))
raise DownloadError(str(error)) from error
result = SegmentDownloadResult(media_info, tuple(selected_ids), tracks, temporary_root)
self._emit(on_event, DownloadEventKind.DOWNLOAD_COMPLETED, message=f"Downloaded segments for {len(tracks)} track(s)")
return result
def download(self, media_info: MediaInfo, request: DownloadRequest | None = None, *, on_event: EventCallback | None = None) -> DownloadResult:
request = request or DownloadRequest()
try:
+58 -32
View File
@@ -39,6 +39,15 @@ class DownloadOptions:
keys: list[str]
@dataclass(frozen=True)
class DownloadedSegments:
"""Downloaded, ordered segment files for one media stream."""
directory: Path
initialization: Path | None
segments: tuple[Path, ...]
class DownloadManager:
def __init__(
self,
@@ -67,7 +76,55 @@ class DownloadManager:
return [future.result() for future in futures]
return [self.download_stream(stream, number) for number, stream in enumerate(streams, 1)]
def download_segments(self, streams: list[StreamSpec], concurrent_tracks: bool = False) -> list[DownloadedSegments]:
"""Download streams without merging them or deleting their work directories."""
self.options.tmp_dir.mkdir(parents=True, exist_ok=True)
self.options.save_dir.mkdir(parents=True, exist_ok=True)
if concurrent_tracks and len(streams) > 1:
workers = min(len(streams), max(1, self.options.thread_count))
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = [executor.submit(self.download_stream_segments, stream, number) for number, stream in enumerate(streams, 1)]
return [future.result() for future in futures]
return [self.download_stream_segments(stream, number) for number, stream in enumerate(streams, 1)]
def download_stream(self, stream: StreamSpec, number: int) -> Path:
downloaded = self.download_stream_segments(stream, number)
work_dir = downloaded.directory
init_path = downloaded.initialization
files = list(downloaded.segments)
if self.options.skip_merge:
self._emit(
DownloadEventKind.TRACK_COMPLETED,
stream,
f"Downloaded segments to {work_dir}",
completed_segments=len(files),
total_segments=len(files),
output_path=work_dir,
)
return work_dir
output = self._output_path(stream, number)
if stream.media_type is MediaType.SUBTITLES and self.options.auto_subtitle_fix:
self._merge_subtitles(files, output)
else:
merge_files = ([init_path] if init_path else []) + files
force_binary = init_path is not None or self.options.binary_merge
output = self._merge_media([item for item in merge_files if item], output, force_binary, stream)
if any(segment.encrypt_info.method is EncryptMethod.CENC for segment in stream.playlist.segments):
self._decrypt_cenc(output, stream)
self._report(f"Saved {output}")
self._emit(
DownloadEventKind.TRACK_COMPLETED,
stream,
f"Saved {output}",
completed_segments=len(files),
total_segments=len(files),
output_path=output,
)
if self.options.del_after_done:
shutil.rmtree(work_dir, ignore_errors=True)
return output
def download_stream_segments(self, stream: StreamSpec, number: int) -> DownloadedSegments:
self._check_cancelled()
if not stream.playlist or not stream.playlist.segments:
raise ValueError(f"No media segments for stream: {stream.describe()}")
@@ -79,7 +136,6 @@ class DownloadManager:
self._report(f"Downloading {stream.describe()}")
self._emit(DownloadEventKind.TRACK_STARTED, stream, f"Downloading {stream.describe()}", total_segments=len(segments))
self._check_cancelled()
downloaded: list[Path] = []
init_path: Path | None = None
if stream.playlist.media_init:
init_path = work_dir / "00000_init.bin"
@@ -112,37 +168,7 @@ class DownloadManager:
downloaded = [paths[index] for index in range(1, len(segments) + 1)]
if self.options.check_segments_count and len(downloaded) != len(segments):
raise RuntimeError(f"Segment count mismatch for {stream.short_name()}")
if self.options.skip_merge:
self._emit(
DownloadEventKind.TRACK_COMPLETED,
stream,
f"Downloaded segments to {work_dir}",
completed_segments=len(segments),
total_segments=len(segments),
output_path=work_dir,
)
return work_dir
output = self._output_path(stream, number)
if stream.media_type is MediaType.SUBTITLES and self.options.auto_subtitle_fix:
self._merge_subtitles(downloaded, output)
else:
files = ([init_path] if init_path else []) + downloaded
force_binary = init_path is not None or self.options.binary_merge
output = self._merge_media([item for item in files if item], output, force_binary, stream)
if any(segment.encrypt_info.method is EncryptMethod.CENC for segment in segments):
self._decrypt_cenc(output, stream)
self._report(f"Saved {output}")
self._emit(
DownloadEventKind.TRACK_COMPLETED,
stream,
f"Saved {output}",
completed_segments=len(segments),
total_segments=len(segments),
output_path=output,
)
if self.options.del_after_done:
shutil.rmtree(work_dir, ignore_errors=True)
return output
return DownloadedSegments(work_dir, init_path, tuple(downloaded))
def _download_segment(self, segment: MediaSegment, destination: Path) -> Path:
self._check_cancelled()
+25
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
@@ -76,6 +77,30 @@ def test_api_download_url_is_one_call_convenience(tmp_path: Path) -> None:
assert result.files[0].path.read_bytes() == b"one"
def test_api_downloads_segments_without_merging_or_ffmpeg(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"one")
(tmp_path / "two.ts").write_bytes(b"two")
manifest = tmp_path / "movie.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXTINF:1,\ntwo.ts\n#EXT-X-ENDLIST\n")
result = N_m3u8DL().download_segments_url(
manifest,
DownloadRequest(
output_dir=tmp_path / "output",
temporary_dir=tmp_path / "temporary",
file_name="movie",
ffmpeg_path="/not-used-by-download-segments",
),
)
track = result.tracks[0]
assert [path.read_bytes() for path in track.segments] == [b"one", b"two"]
assert track.directory.is_dir()
assert not list((tmp_path / "output").glob("movie.*"))
shutil.rmtree(result.temporary_dir.parent)
assert not result.temporary_dir.exists()
def test_api_downloads_only_explicit_track_id(tmp_path: Path) -> None:
(tmp_path / "video.ts").write_bytes(b"video")
(tmp_path / "audio.ts").write_bytes(b"audio")