split download and ffmpeg procces
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user