This commit is contained in:
root
2026-09-26 13:55:43 +08:00
commit 9dfc684869
40 changed files with 5632 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
.venv_network_failed/
__pycache__/
.pytest_cache/
*.pyc
*.egg-info/
+90
View File
@@ -0,0 +1,90 @@
# N_m3u8DL-PY
`N_m3u8DL-PY` is a Python reimplementation of the downloader architecture in
the parent `N_m3u8DL-RE` project. It accepts HLS, DASH and Smooth Streaming
manifests from HTTP URLs or local files and downloads selected media tracks.
```bash
python -m n_m3u8dl_py <input> --auto-select
```
The command line intentionally follows the original project where practical.
Implemented workflows include:
- HLS master/media playlists, `EXT-X-MAP`, byte ranges, discontinuities and
AES-128/AES-128-ECB decryption through the system `openssl` command.
- DASH `SegmentTemplate`, `SegmentTimeline`, `SegmentList`, `SegmentBase`,
BaseURL inheritance and common CENC metadata detection.
- Smooth Streaming manifest parsing for supported video/audio/text tracks.
- Parallel segment download, retries, HTTP headers/proxy support, range and
ad filtering, binary concat, ffmpeg merge and post-download muxing.
DRM/CENC media cannot be decrypted in Python. The CLI passes supplied keys to
the configured external decryption tool (`mp4decrypt`, `shaka-packager`, or
`ffmpeg`) after download where possible.
## Python API
The package exports a stable synchronous API for integration in another Python
application. It never opens an interactive track picker.
```python
from pathlib import Path
from n_m3u8dl_py import DownloadRequest, N_m3u8DL, TrackSelection
client = N_m3u8DL()
media = client.inspect("https://example.com/master.m3u8")
# The default request chooses the best video, the best audio per language,
# and all subtitle tracks. Track IDs can be selected explicitly when needed.
result = client.download(
media,
DownloadRequest(output_dir=Path("downloads"), file_name="episode"),
)
# Or use the one-call convenience method when track inspection is unnecessary.
result = client.download_url(
"https://example.com/master.m3u8",
DownloadRequest(output_dir=Path("downloads")),
)
video = next(track for track in media.tracks if track.resolution)
video_only = client.download(
media,
DownloadRequest(
output_dir=Path("downloads"),
selection=TrackSelection(track_ids=(video.track_id,)),
),
)
```
Pass `on_event` to `inspect()` or `download()` for structured progress. A
callback may return `False` to cancel the current task; cancellation raises
`DownloadCancelledError`. Successful calls return `MediaInfo` and
`DownloadResult`; manifest, selection, and download failures raise the
corresponding public exception classes.
```python
from n_m3u8dl_py import DownloadEventKind
def show_progress(event):
if event.kind is DownloadEventKind.SEGMENT_COMPLETED:
print(event.track_id, event.completed_segments, event.total_segments)
media = client.inspect("playlist.m3u8", on_event=show_progress)
result = client.download(media, on_event=show_progress)
```
Create an editable local installation:
```bash
python -m venv --system-site-packages .venv
.venv/bin/python -m pip install --no-build-isolation -e .
```
Run the test suite when `pytest` is available:
```bash
.venv/bin/python -m pytest tests
```
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "n-m3u8dl-py"
version = "0.2.0"
description = "Python implementation of the N_m3u8DL-RE DASH/HLS/MSS downloader"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
N_m3u8DL-PY = "n_m3u8dl_py.cli:main"
n-m3u8dl-py = "n_m3u8dl_py.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
+41
View File
@@ -0,0 +1,41 @@
"""N_m3u8DL-PY public package API."""
from .api import (
DownloadRequest,
DownloadResult,
DownloadedFile,
MediaInfo,
MediaTrack,
MuxRequest,
MuxResult,
N_m3u8DL,
RequestOptions,
TrackSelection,
)
from .errors import DownloadCancelledError, DownloadError, ManifestError, N_m3u8DLError, SelectionError
from .events import DownloadEvent, DownloadEventKind
from .models import EncryptMethod, MediaType
__version__ = "0.2.0"
__all__ = [
"DownloadCancelledError",
"DownloadError",
"DownloadEvent",
"DownloadEventKind",
"DownloadRequest",
"DownloadResult",
"DownloadedFile",
"EncryptMethod",
"ManifestError",
"MediaInfo",
"MediaTrack",
"MediaType",
"MuxRequest",
"MuxResult",
"N_m3u8DL",
"N_m3u8DLError",
"RequestOptions",
"SelectionError",
"TrackSelection",
]
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+392
View File
@@ -0,0 +1,392 @@
"""Stable, synchronous API for integrating N_m3u8DL-PY in Python programs."""
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import Mapping
from .downloader import DownloadManager, DownloadOptions, mux_outputs
from .errors import DownloadCancelledError, DownloadError, ManifestError, N_m3u8DLError, SelectionError
from .events import DownloadEvent, DownloadEventKind, EventCallback
from .extractor import ExtractedSource, load_source
from .http import HttpClient
from .models import EncryptMethod, MediaType, StreamSpec
from .selection import apply_custom_range, automatic_selection, clean_ads
from .utils import inferred_name, valid_filename
@dataclass(frozen=True)
class RequestOptions:
"""Network and manifest parsing options used by :meth:`N_m3u8DL.inspect`."""
headers: Mapping[str, str] = field(default_factory=dict)
timeout: float = 100.0
proxy: str | None = None
use_system_proxy: bool = True
base_url: str | None = None
append_url_params: bool = False
custom_hls_method: EncryptMethod | None = None
custom_hls_key: bytes | None = None
custom_hls_iv: bytes | None = None
allow_hls_multi_ext_map: bool = False
def __post_init__(self) -> None:
if self.timeout <= 0:
raise ValueError("timeout must be greater than zero")
object.__setattr__(self, "headers", MappingProxyType(dict(self.headers)))
@dataclass(frozen=True)
class TrackSelection:
"""Select explicit tracks or use automatic best-track selection."""
track_ids: tuple[str, ...] | None = None
auto_select: bool = True
sub_only: bool = False
def __post_init__(self) -> None:
if self.track_ids is not None:
normalized = tuple(dict.fromkeys(self.track_ids))
if not normalized:
raise ValueError("track_ids cannot be empty")
object.__setattr__(self, "track_ids", normalized)
object.__setattr__(self, "auto_select", False)
@dataclass(frozen=True)
class DownloadRequest:
"""Output, selection, and download behavior for :meth:`N_m3u8DL.download`."""
output_dir: Path | str = field(default_factory=Path.cwd)
temporary_dir: Path | str | None = None
file_name: str | None = None
save_pattern: str | None = None
selection: TrackSelection = field(default_factory=TrackSelection)
thread_count: int = field(default_factory=lambda: max(1, os.cpu_count() or 1))
retry_count: int = 3
merge: bool = True
binary_merge: bool = False
delete_temporary_files: bool = True
check_segments_count: bool = True
max_speed: int | None = None
subtitle_format: str = "SRT"
auto_subtitle_fix: bool = True
custom_range: str | None = None
ad_keywords: tuple[str, ...] = ()
ffmpeg_path: str | None = None
decryption_engine: str = "MP4DECRYPT"
decryption_binary_path: str | None = None
keys: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.thread_count < 1:
raise ValueError("thread_count must be at least one")
if self.retry_count < 0:
raise ValueError("retry_count cannot be negative")
if self.max_speed is not None and self.max_speed <= 0:
raise ValueError("max_speed must be greater than zero")
format_name = self.subtitle_format.upper()
if format_name not in {"SRT", "VTT"}:
raise ValueError("subtitle_format must be SRT or VTT")
engine = self.decryption_engine.upper()
if engine not in {"FFMPEG", "MP4DECRYPT", "SHAKA_PACKAGER"}:
raise ValueError("unsupported decryption_engine")
object.__setattr__(self, "output_dir", Path(self.output_dir))
object.__setattr__(self, "temporary_dir", Path(self.temporary_dir) if self.temporary_dir is not None else None)
object.__setattr__(self, "subtitle_format", format_name)
object.__setattr__(self, "decryption_engine", engine)
object.__setattr__(self, "ad_keywords", tuple(self.ad_keywords))
object.__setattr__(self, "keys", tuple(self.keys))
@dataclass(frozen=True)
class MuxRequest:
"""Optional ffmpeg mux configuration for completed download files."""
format: str = "mkv"
output_path: Path | str | None = None
ffmpeg_path: str | None = None
keep_inputs: bool = False
def __post_init__(self) -> None:
format_name = self.format.lower()
if format_name not in {"mkv", "mp4", "ts"}:
raise ValueError("format must be mkv, mp4, or ts")
object.__setattr__(self, "format", format_name)
object.__setattr__(self, "output_path", Path(self.output_path) if self.output_path is not None else None)
@dataclass(frozen=True)
class MediaTrack:
"""Immutable public description of one downloadable media track."""
track_id: str
media_type: MediaType
group_id: str | None
language: str | None
name: str | None
bandwidth: int | None
codecs: str | None
resolution: str | None
frame_rate: float | None
channels: str | None
extension: str | None
role: str | None
period_id: str | None
segments_count: int
duration: float
is_live: bool
is_encrypted: bool
@dataclass(frozen=True)
class MediaInfo:
"""Result of manifest inspection. Pass this object to :meth:`N_m3u8DL.download`."""
input_value: str
protocol: str
source_url: str
tracks: tuple[MediaTrack, ...]
_source: ExtractedSource = field(repr=False, compare=False)
_streams_by_id: Mapping[str, StreamSpec] = field(repr=False, compare=False)
_request_options: RequestOptions = field(repr=False, compare=False)
@dataclass(frozen=True)
class DownloadedFile:
"""One output generated by a successful download."""
track_id: str
media_type: MediaType
path: Path
size: int
@dataclass(frozen=True)
class DownloadResult:
"""Successful download result. Failures are raised as exceptions instead."""
media_info: MediaInfo
selected_track_ids: tuple[str, ...]
files: tuple[DownloadedFile, ...]
temporary_dir: Path
@dataclass(frozen=True)
class MuxResult:
"""Output generated by :meth:`N_m3u8DL.mux`."""
path: Path
inputs: tuple[DownloadedFile, ...]
class N_m3u8DL:
"""Programmatic facade for manifest inspection, downloading, and muxing."""
def __init__(self, request_options: RequestOptions | None = None) -> None:
self.request_options = request_options or RequestOptions()
def inspect(self, input_value: str | Path, *, options: RequestOptions | None = None, on_event: EventCallback | None = None) -> MediaInfo:
value = str(input_value)
request_options = options or self.request_options
try:
self._emit(on_event, DownloadEventKind.INSPECT_STARTED, message=f"Inspecting {value}")
client = HttpClient(
dict(request_options.headers),
request_options.timeout,
request_options.proxy,
request_options.use_system_proxy,
)
source = load_source(
value,
client,
base_url=request_options.base_url,
append_url_params=request_options.append_url_params,
custom_hls_method=request_options.custom_hls_method,
custom_hls_key=request_options.custom_hls_key,
custom_hls_iv=request_options.custom_hls_iv,
allow_hls_multi_ext_map=request_options.allow_hls_multi_ext_map,
)
except DownloadCancelledError:
self._emit_cancelled(on_event)
raise
except Exception as error:
self._emit(on_event, DownloadEventKind.FAILED, message=str(error))
raise ManifestError(f"Unable to inspect {value}: {error}") from error
streams_by_id = {self._track_id(source.kind, index): stream for index, stream in enumerate(source.streams)}
info = MediaInfo(
input_value=value,
protocol=source.kind,
source_url=source.source_url,
tracks=tuple(self._to_track(track_id, stream) for track_id, stream in streams_by_id.items()),
_source=source,
_streams_by_id=MappingProxyType(streams_by_id),
_request_options=request_options,
)
self._emit(on_event, DownloadEventKind.INSPECT_COMPLETED, message=f"Found {len(info.tracks)} tracks")
return info
def download_url(
self,
input_value: str | Path,
request: DownloadRequest | None = None,
*,
options: RequestOptions | None = None,
on_event: EventCallback | None = None,
) -> DownloadResult:
"""Inspect and download one manifest without exposing the intermediate ``MediaInfo``."""
media_info = self.inspect(input_value, options=options, on_event=on_event)
return self.download(media_info, request, on_event=on_event)
def download(self, media_info: MediaInfo, request: DownloadRequest | None = None, *, on_event: EventCallback | None = None) -> DownloadResult:
request = request or DownloadRequest()
try:
self._emit(on_event, DownloadEventKind.DOWNLOAD_STARTED, message=f"Preparing {media_info.protocol} 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
ffmpeg = request.ffmpeg_path or shutil.which("ffmpeg")
decryption_binary = request.decryption_binary_path or self._find_decryption_binary(request.decryption_engine, ffmpeg)
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=request.binary_merge,
skip_merge=not request.merge,
del_after_done=request.delete_temporary_files,
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=ffmpeg,
use_ffmpeg_concat_demuxer=True,
decryption_engine=request.decryption_engine,
decryption_binary=decryption_binary,
keys=list(request.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,
)
outputs = manager.download(selected_streams, concurrent_tracks=False)
files = tuple(
DownloadedFile(
track_id=track_id,
media_type=self._media_type(stream),
path=path,
size=path.stat().st_size if path.is_file() else 0,
)
for track_id, stream, path in zip(selected_ids, selected_streams, outputs, 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 = DownloadResult(media_info, tuple(selected_ids), files, temporary_root)
self._emit(on_event, DownloadEventKind.DOWNLOAD_COMPLETED, message=f"Created {len(files)} output file(s)")
return result
def mux(self, result: DownloadResult, request: MuxRequest | None = None) -> MuxResult:
request = request or MuxRequest()
ffmpeg = request.ffmpeg_path or shutil.which("ffmpeg")
if not ffmpeg:
raise DownloadError("ffmpeg is required for muxing")
inputs = [item.path for item in result.files if item.path.is_file()]
if not inputs:
raise DownloadError("No downloaded files are available for muxing")
output_stem = request.output_path or result.files[0].path.with_name(valid_filename(inferred_name(result.media_info.input_value), 180))
try:
output = mux_outputs(ffmpeg, inputs, output_stem, request.format, request.keep_inputs)
except Exception as error:
raise DownloadError(f"Mux failed: {error}") from error
return MuxResult(output, result.files)
@staticmethod
def _track_id(protocol: str, index: int) -> str:
return f"{protocol.lower()}:{index}"
@staticmethod
def _media_type(stream: StreamSpec) -> MediaType:
return stream.media_type or MediaType.VIDEO
def _to_track(self, track_id: str, stream: StreamSpec) -> MediaTrack:
playlist = stream.playlist
segments = playlist.segments if playlist else []
return MediaTrack(
track_id=track_id,
media_type=self._media_type(stream),
group_id=stream.group_id,
language=stream.language,
name=stream.name,
bandwidth=stream.bandwidth,
codecs=stream.codecs,
resolution=stream.resolution,
frame_rate=stream.frame_rate,
channels=stream.channels,
extension=stream.extension,
role=stream.role,
period_id=stream.period_id,
segments_count=len(segments),
duration=playlist.total_duration if playlist else 0.0,
is_live=playlist.is_live if playlist else False,
is_encrypted=any(segment.is_encrypted for segment in segments),
)
def _select_streams(self, media_info: MediaInfo, selection: TrackSelection) -> tuple[list[str], list[StreamSpec]]:
if selection.track_ids is not None:
unknown = [track_id for track_id in selection.track_ids if track_id not in media_info._streams_by_id]
if unknown:
raise SelectionError(f"Unknown track ID(s): {', '.join(unknown)}")
return list(selection.track_ids), [media_info._streams_by_id[track_id] for track_id in selection.track_ids]
if not selection.auto_select:
raise SelectionError("Specify track_ids or enable auto_select")
selected = automatic_selection(list(media_info._streams_by_id.values()), selection.sub_only)
if not selected:
raise SelectionError("Automatic selection did not find any matching tracks")
ids_by_stream = {id(stream): track_id for track_id, stream in media_info._streams_by_id.items()}
return [ids_by_stream[id(stream)] for stream in selected], selected
@staticmethod
def _find_decryption_binary(engine: str, ffmpeg: str | None) -> str | None:
if engine == "FFMPEG":
return ffmpeg
if engine == "SHAKA_PACKAGER":
return shutil.which("shaka-packager")
return shutil.which("mp4decrypt")
@staticmethod
def _emit(on_event: EventCallback | None, kind: DownloadEventKind, **kwargs: object) -> None:
if on_event is not None and on_event(DownloadEvent(kind, **kwargs)) is False:
raise DownloadCancelledError("Download cancelled by event callback")
@staticmethod
def _emit_cancelled(on_event: EventCallback | None) -> None:
if on_event is not None:
on_event(DownloadEvent(DownloadEventKind.CANCELLED, message="Download cancelled"))
+334
View File
@@ -0,0 +1,334 @@
from __future__ import annotations
import argparse
import json
import shutil
import sys
import time
from datetime import datetime
from pathlib import Path
from . import __version__
from .downloader import DownloadManager, DownloadOptions, mux_outputs
from .extractor import ExtractedSource, load_source
from .http import HttpClient
from .models import EncryptMethod, MediaPart, MediaType, StreamSpec
from .selection import StreamFilter, apply_custom_range, apply_drop, automatic_selection, clean_ads
from .utils import inferred_name, parse_data, to_jsonable, valid_filename
VERSION_INFO = f"N_m3u8DL-PY (Python reimplementation) {__version__}"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="N_m3u8DL-PY", description="DASH/HLS/MSS download tool")
parser.add_argument("input", nargs="?", help="Input URL or manifest file")
general = parser.add_argument_group("general")
general.add_argument("--tmp-dir")
general.add_argument("--save-dir")
general.add_argument("--save-name")
general.add_argument("--save-pattern")
general.add_argument("--log-file-path")
general.add_argument("--base-url")
general.add_argument("--urlprocessor-args")
general.add_argument("--thread-count", type=int, default=max(1, __import__("os").cpu_count() or 1))
general.add_argument("--download-retry-count", type=int, default=3)
general.add_argument("--http-request-timeout", type=float, default=100)
general.add_argument("-H", "--header", action="append", default=[], help='Custom HTTP header, e.g. -H "Cookie: value"')
general.add_argument("--custom-proxy")
general.add_argument("--use-system-proxy", action=argparse.BooleanOptionalAction, default=True)
general.add_argument("--append-url-params", action="store_true")
general.add_argument("--write-meta-json", action=argparse.BooleanOptionalAction, default=True)
general.add_argument("--no-log", action="store_true")
general.add_argument("--log-level", choices=["DEBUG", "INFO", "WARN", "ERROR", "OFF"], default="INFO")
general.add_argument("--ui-language", choices=["en-US", "zh-CN", "zh-TW"])
general.add_argument("--force-ansi-console", action="store_true")
general.add_argument("--no-ansi-color", action="store_true")
general.add_argument("--disable-update-check", action="store_true")
general.add_argument("--task-start-at", help="yyyyMMddHHmmss")
download = parser.add_argument_group("download")
download.add_argument("--auto-select", action="store_true")
download.add_argument("--sub-only", action="store_true")
download.add_argument("-mt", "--concurrent-download", action="store_true")
download.add_argument("--skip-download", action="store_true")
download.add_argument("--skip-merge", action="store_true")
download.add_argument("--no-date-info", action="store_true")
download.add_argument("--binary-merge", action="store_true")
download.add_argument("--use-ffmpeg-concat-demuxer", action="store_true")
download.add_argument("--del-after-done", action=argparse.BooleanOptionalAction, default=True)
download.add_argument("--check-segments-count", action=argparse.BooleanOptionalAction, default=True)
download.add_argument("-R", "--max-speed", type=_parse_speed)
download.add_argument("--custom-range")
download.add_argument("--ad-keyword", action="append", default=[])
media = parser.add_argument_group("media")
media.add_argument("--sub-format", choices=["SRT", "VTT"], default="SRT")
media.add_argument("--auto-subtitle-fix", action=argparse.BooleanOptionalAction, default=True)
media.add_argument("--ffmpeg-binary-path")
media.add_argument("--key", action="append", default=[])
media.add_argument("--key-text-file")
media.add_argument("--decryption-engine", choices=["FFMPEG", "MP4DECRYPT", "SHAKA_PACKAGER"], default="MP4DECRYPT")
media.add_argument("--decryption-binary-path")
media.add_argument("--mp4-real-time-decryption", action="store_true")
media.add_argument("--custom-hls-method", choices=[item.value for item in EncryptMethod])
media.add_argument("--custom-hls-key")
media.add_argument("--custom-hls-iv")
media.add_argument("--allow-hls-multi-ext-map", action="store_true")
media.add_argument("-M", "--mux-after-done", metavar="OPTIONS")
media.add_argument("--mux-import", action="append", default=[])
filters = parser.add_argument_group("track selection")
filters.add_argument("-sv", "--select-video")
filters.add_argument("-sa", "--select-audio")
filters.add_argument("-ss", "--select-subtitle")
filters.add_argument("-dv", "--drop-video")
filters.add_argument("-da", "--drop-audio")
filters.add_argument("-ds", "--drop-subtitle")
live = parser.add_argument_group("live")
live.add_argument("--live-perform-as-vod", action="store_true")
live.add_argument("--live-record-limit")
live.add_argument("--live-wait-time", type=float)
live.add_argument("--live-take-count", type=int, default=16)
live.add_argument("--live-real-time-merge", action="store_true")
live.add_argument("--live-keep-segments", action=argparse.BooleanOptionalAction, default=True)
live.add_argument("--live-pipe-mux", action="store_true")
live.add_argument("--live-fix-vtt-by-audio", action="store_true")
parser.add_argument("--morehelp", metavar="OPTION")
parser.add_argument("--version", action="version", version=VERSION_INFO)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.morehelp:
print(_more_help(args.morehelp))
return 0
if not args.input:
parser.print_help()
return 2
try:
return _run(args)
except KeyboardInterrupt:
print("Interrupted.", file=sys.stderr)
return 130
except Exception as error:
print(f"Failed: {error}", file=sys.stderr)
if args.log_level == "DEBUG":
raise
return 1
def _run(args: argparse.Namespace) -> int:
if args.task_start_at:
start_at = datetime.strptime(args.task_start_at, "%Y%m%d%H%M%S")
while datetime.now() < start_at:
time.sleep(1)
headers = _headers(args.header)
headers.setdefault("User-Agent", "Mozilla/5.0 (N_m3u8DL-PY)")
if args.urlprocessor_args:
print("Warning: --urlprocessor-args was accepted, but no site-specific URL processors are bundled in the Python version.")
if args.mp4_real_time_decryption:
print("Warning: --mp4-real-time-decryption is not available; CENC decryption is attempted after merge.")
if args.live_real_time_merge or args.live_pipe_mux:
print("Warning: real-time live merging is not available; completed live recordings are merged after capture.")
if args.live_fix_vtt_by_audio:
print("Warning: --live-fix-vtt-by-audio is not available in the Python version.")
client = HttpClient(headers, args.http_request_timeout, args.custom_proxy, args.use_system_proxy)
source = load_source(
args.input,
client,
base_url=args.base_url,
append_url_params=args.append_url_params,
custom_hls_method=EncryptMethod(args.custom_hls_method) if args.custom_hls_method else None,
custom_hls_key=parse_data(args.custom_hls_key),
custom_hls_iv=parse_data(args.custom_hls_iv),
allow_hls_multi_ext_map=args.allow_hls_multi_ext_map,
)
save_name = valid_filename(args.save_name or inferred_name(args.input), 180)
tmp_root = Path(args.tmp_dir or Path.cwd()) / save_name
if args.write_meta_json:
tmp_root.mkdir(parents=True, exist_ok=True)
(tmp_root / f"raw.{source.kind.lower()}").write_text(source.content, encoding="utf-8")
_write_json(tmp_root / "meta.json", source.streams)
print(f"Matched {source.kind}; found {len(source.streams)} stream(s).")
for number, stream in enumerate(source.streams, 1):
print(f"[{number}] {stream.describe()}")
selected = _select(source, args)
if not selected:
raise ValueError("No streams selected")
source.fetch_playlists(selected)
if any(stream.playlist and stream.playlist.is_live for stream in selected) and not args.live_perform_as_vod:
_collect_live_snapshot(source, selected, args)
apply_custom_range(selected, args.custom_range)
clean_ads(selected, args.ad_keyword)
if args.write_meta_json:
_write_json(tmp_root / "meta_selected.json", selected)
print("Selected streams:")
for stream in selected:
print(f" {stream.describe()}")
if args.skip_download:
return 0
ffmpeg = args.ffmpeg_binary_path or shutil.which("ffmpeg")
keys = _keys(args.key, args.key_text_file)
decryption_binary = args.decryption_binary_path or (shutil.which("mp4decrypt") if args.decryption_engine == "MP4DECRYPT" else ffmpeg if args.decryption_engine == "FFMPEG" else shutil.which("shaka-packager"))
options = DownloadOptions(
tmp_dir=tmp_root,
save_dir=Path(args.save_dir or Path.cwd()),
save_name=save_name,
save_pattern=args.save_pattern,
thread_count=max(1, args.thread_count),
retries=max(0, args.download_retry_count),
binary_merge=args.binary_merge,
skip_merge=args.skip_merge,
del_after_done=args.del_after_done,
check_segments_count=args.check_segments_count,
max_speed=args.max_speed,
subtitle_format=args.sub_format,
auto_subtitle_fix=args.auto_subtitle_fix,
ffmpeg=ffmpeg,
use_ffmpeg_concat_demuxer=args.use_ffmpeg_concat_demuxer,
decryption_engine=args.decryption_engine,
decryption_binary=decryption_binary,
keys=keys,
)
manager = DownloadManager(client, options, print)
outputs = manager.download(selected, args.concurrent_download)
if args.mux_after_done:
if not ffmpeg:
raise FileNotFoundError("ffmpeg is required for --mux-after-done")
mux = _option_values(args.mux_after_done)
imported = [_option_values(value).get("path") for value in args.mux_import]
inputs = [item for item in outputs + [Path(item) for item in imported if item] if item.is_file()]
if not inputs:
raise ValueError("No completed files are available for muxing")
output = mux_outputs(ffmpeg, inputs, options.save_dir / save_name, mux.get("format", "mkv"), mux.get("keep", "false").lower() == "true")
print(f"Muxed {output}")
print("Done")
return 0
def _select(source: ExtractedSource, args: argparse.Namespace) -> list[StreamSpec]:
streams = source.streams
videos = [stream for stream in streams if stream.media_type is None]
audios = [stream for stream in streams if stream.media_type is MediaType.AUDIO]
subtitles = [stream for stream in streams if stream.media_type is MediaType.SUBTITLES]
videos = apply_drop(videos, StreamFilter.parse(args.drop_video))
audios = apply_drop(audios, StreamFilter.parse(args.drop_audio))
subtitles = apply_drop(subtitles, StreamFilter.parse(args.drop_subtitle))
has_keep_filter = any([args.select_video, args.select_audio, args.select_subtitle])
if args.auto_select or args.sub_only:
return automatic_selection(videos + audios + subtitles, args.sub_only)
if has_keep_filter:
chosen: list[StreamSpec] = []
for candidates, raw in ((videos, args.select_video), (audios, args.select_audio), (subtitles, args.select_subtitle)):
stream_filter = StreamFilter.parse(raw)
if stream_filter:
chosen.extend(stream_filter.select(candidates))
return chosen
available = videos + audios + subtitles
if not sys.stdin.isatty() or len(available) == 1:
return automatic_selection(available)
return _interactive_selection(available)
def _interactive_selection(streams: list[StreamSpec]) -> list[StreamSpec]:
raw = input("Select comma-separated stream numbers (Enter = automatic): ").strip()
if not raw:
return automatic_selection(streams)
selections = {int(item.strip()) for item in raw.split(",") if item.strip()}
return [stream for index, stream in enumerate(streams, 1) if index in selections]
def _collect_live_snapshot(source: ExtractedSource, selected: list[StreamSpec], args: argparse.Namespace) -> None:
if source.kind != "HLS":
print("Live DASH/MSS refresh is not available; downloading the current manifest snapshot.")
return
if not args.live_record_limit:
print("Live playlist detected. Set --live-record-limit to finish automatically; downloading the current snapshot.")
return
from .utils import parse_duration
limit = parse_duration(args.live_record_limit)
collected: dict[int, list] = {id(stream): list(stream.playlist.segments[-args.live_take_count :]) for stream in selected if stream.playlist}
seen = {id(stream): {(segment.url, segment.start_range, segment.expect_length) for segment in segments} for stream, segments in ((stream, collected[id(stream)]) for stream in selected if stream.playlist)}
started = time.monotonic()
while time.monotonic() - started < limit:
refresh = min((stream.playlist.refresh_interval_ms for stream in selected if stream.playlist), default=5000) / 1000
time.sleep(args.live_wait_time if args.live_wait_time is not None else refresh)
source.fetch_playlists(selected)
for stream in selected:
if not stream.playlist:
continue
for segment in stream.playlist.segments:
key = (segment.url, segment.start_range, segment.expect_length)
if key not in seen[id(stream)]:
seen[id(stream)].add(key)
collected[id(stream)].append(segment)
print(f"Live recording: {int(time.monotonic() - started)}/{int(limit)} seconds")
for stream in selected:
if stream.playlist:
stream.playlist.media_parts = [MediaPart(collected[id(stream)])]
stream.playlist.is_live = False
def _headers(values: list[str]) -> dict[str, str]:
headers: dict[str, str] = {}
for value in values:
key, separator, contents = value.partition(":")
if not separator or not key.strip():
raise ValueError(f"Invalid header: {value!r}; expected 'Name: value'")
headers[key.strip()] = contents.strip()
return headers
def _parse_speed(value: str) -> int:
text = value.strip().upper()
multiplier = 1024 * 1024 if text.endswith("M") else 1024 if text.endswith("K") else None
if not multiplier:
raise argparse.ArgumentTypeError("speed must have M or K suffix, for example 15M or 100K")
return int(float(text[:-1]) * multiplier)
def _keys(values: list[str], key_file: str | None) -> list[str]:
keys = list(values)
if key_file:
for line in Path(key_file).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
keys.append(line)
return keys
def _write_json(path: Path, value: object) -> None:
path.write_text(json.dumps(to_jsonable(value), ensure_ascii=False, indent=2), encoding="utf-8")
def _option_values(value: str) -> dict[str, str]:
result: dict[str, str] = {}
current: list[str] = []
quote = ""
pieces: list[str] = []
for char in value:
if char in "\"'":
quote = "" if quote == char else char if not quote else quote
if char == ":" and not quote:
pieces.append("".join(current))
current = []
else:
current.append(char)
pieces.append("".join(current))
for piece in pieces:
key, separator, item = piece.partition("=")
if separator:
result[key.strip().lower()] = item.strip().strip("\"'")
return result
def _more_help(option: str) -> str:
help_text = {
"mux-after-done": "-M format=mkv|mp4|ts:keep=true|false\nMuxes completed tracks with ffmpeg.",
"custom-range": "--custom-range 0-10, 10-, -99, or 05:00-20:00",
"select-video": "-sv id=REGEX:res=REGEX:codecs=REGEX:for=best|worst|all",
"select-audio": "-sa lang=REGEX:for=best|worst|all",
"select-subtitle": "-ss lang=REGEX:for=all",
}
return help_text.get(option.lstrip("-"), f"No extra help for {option}")
+343
View File
@@ -0,0 +1,343 @@
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from collections.abc import Iterable
from datetime import datetime, timezone
from .models import EncryptInfo, EncryptMethod, MediaPart, MediaSegment, MediaType, Playlist, StreamSpec
from .utils import append_query, combine_url, parse_iso8601_duration, parse_range, replace_vars
def _local(element: ET.Element) -> str:
return element.tag.rsplit("}", 1)[-1]
def _children(element: ET.Element, name: str) -> list[ET.Element]:
return [child for child in element if _local(child) == name]
def _child(element: ET.Element, name: str) -> ET.Element | None:
return next(iter(_children(element, name)), None)
def _attr(element: ET.Element | None, name: str, default: str | None = None) -> str | None:
return element.attrib.get(name, default) if element is not None else default
def _find_attr(element: ET.Element, name: str) -> str | None:
for key, value in element.attrib.items():
if key.rsplit("}", 1)[-1] == name:
return value
return None
class DASHExtractor:
def __init__(self, source_url: str, base_url: str | None = None, append_url_params: bool = False) -> None:
self.source_url = source_url
self.base_url = base_url or source_url
self.append_url_params = append_url_params
def extract(self, content: str) -> list[StreamSpec]:
root = ET.fromstring(content.strip())
if _local(root) != "MPD":
raise ValueError("Bad MPD: expected MPD root element")
is_live = root.attrib.get("type") == "dynamic"
mpd_duration = parse_iso8601_duration(root.attrib.get("mediaPresentationDuration"))
root_base = self._extend_base(self.base_url, root)
streams: list[StreamSpec] = []
periods = _children(root, "Period")
for period_number, period in enumerate(periods):
period_base = self._extend_base(root_base, period)
period_id = period.attrib.get("id", str(period_number))
period_duration = parse_iso8601_duration(period.attrib.get("duration")) or mpd_duration
for adaptation in _children(period, "AdaptationSet"):
adaptation_base = self._extend_base(period_base, adaptation)
representations = _children(adaptation, "Representation")
for representation in representations:
stream = self._make_stream(
root,
period,
adaptation,
representation,
self._extend_base(adaptation_base, representation),
period_id,
period_duration,
is_live,
)
self._merge_period_stream(streams, stream, is_live)
audios = [stream for stream in streams if stream.media_type is MediaType.AUDIO]
subtitles = [stream for stream in streams if stream.media_type is MediaType.SUBTITLES]
for stream in streams:
if stream.media_type is None and stream.resolution:
stream.audio_id = max(audios, key=lambda item: item.bandwidth or 0).group_id if audios else None
stream.subtitle_id = max(subtitles, key=lambda item: item.bandwidth or 0).group_id if subtitles else None
if self.append_url_params:
for stream in streams:
if stream.playlist and stream.playlist.media_init:
stream.playlist.media_init.url = append_query(self.source_url, stream.playlist.media_init.url)
if stream.playlist:
for segment in stream.playlist.segments:
segment.url = append_query(self.source_url, segment.url)
return streams
def _make_stream(
self,
root: ET.Element,
period: ET.Element,
adaptation: ET.Element,
representation: ET.Element,
segment_base_url: str,
period_id: str,
period_duration: float,
is_live: bool,
) -> StreamSpec:
mime_type = _attr(representation, "mimeType") or _attr(adaptation, "mimeType") or _attr(representation, "contentType") or _attr(adaptation, "contentType") or ""
content_type = (_attr(representation, "contentType") or _attr(adaptation, "contentType") or mime_type.split("/", 1)[0]).lower()
media_type = {"audio": MediaType.AUDIO, "text": MediaType.SUBTITLES, "subtitle": MediaType.SUBTITLES}.get(content_type)
codecs = _attr(representation, "codecs") or _attr(adaptation, "codecs")
if codecs in {"stpp", "wvtt"}:
media_type = MediaType.SUBTITLES
role = self._role(representation) or self._role(adaptation)
if role and role.lower().replace("-", "") in {"subtitle", "forcedsubtitle"}:
media_type = MediaType.SUBTITLES
extension = mime_type.split("/", 1)[1] if "/" in mime_type else None
if media_type is MediaType.SUBTITLES and "ttml" in mime_type:
extension = "ttml"
frame_rate = self._frame_rate(representation) or self._frame_rate(adaptation)
width = _attr(representation, "width") or _attr(adaptation, "width")
height = _attr(representation, "height") or _attr(adaptation, "height")
stream = StreamSpec(
url=self.source_url,
media_type=media_type,
group_id=_attr(representation, "id"),
bandwidth=int(_attr(representation, "bandwidth", "0") or 0),
codecs=codecs,
language=self._language(_attr(representation, "lang") or _attr(adaptation, "lang")),
resolution=f"{width}x{height}" if width and height else None,
frame_rate=frame_rate,
role=role,
period_id=period_id,
original_url=self.source_url,
extension=extension,
playlist=Playlist(is_live=is_live),
)
stream.playlist.media_parts.append(MediaPart())
channel_config = _child(representation, "AudioChannelConfiguration")
if channel_config is None:
channel_config = _child(adaptation, "AudioChannelConfiguration")
stream.channels = _attr(channel_config, "value")
publish_time = _attr(root, "publishTime")
if publish_time:
try:
stream.publish_time = datetime.fromisoformat(publish_time.replace("Z", "+00:00"))
except ValueError:
pass
buffer_depth = parse_iso8601_duration(_attr(root, "timeShiftBufferDepth") or "PT1M")
if is_live:
stream.playlist.refresh_interval_ms = max(1000, buffer_depth * 500)
self._parse_segment_base(adaptation, representation, segment_base_url, stream, period_duration)
self._parse_segment_list(adaptation, representation, segment_base_url, stream)
self._parse_segment_template(root, period, adaptation, representation, segment_base_url, stream, period_duration, is_live)
if not stream.playlist.segments:
stream.playlist.media_parts[0].media_segments.append(MediaSegment(0, segment_base_url, period_duration))
self._dedupe(stream.playlist.media_parts[0].media_segments)
self._apply_protection(adaptation, representation, stream)
if media_type is MediaType.SUBTITLES and extension == "mp4":
stream.extension = "m4s"
elif media_type is not MediaType.SUBTITLES and (not stream.extension or stream.segments_count > 1):
stream.extension = "m4s"
return stream
def _parse_segment_base(self, adaptation: ET.Element, representation: ET.Element, base: str, stream: StreamSpec, duration: float) -> None:
segment_base = _child(representation, "SegmentBase")
if segment_base is None:
segment_base = _child(adaptation, "SegmentBase")
if segment_base is None:
return
initialization = _child(segment_base, "Initialization")
if initialization is None:
return
source = _attr(initialization, "sourceURL")
if not source:
stream.playlist.media_parts[0].media_segments.append(MediaSegment(0, base, duration))
return
start_range, length = self._range(_attr(initialization, "range"))
stream.playlist.media_init = MediaSegment(-1, combine_url(base, source), start_range=start_range, expect_length=length)
def _parse_segment_list(self, adaptation: ET.Element, representation: ET.Element, base: str, stream: StreamSpec) -> None:
segment_list = _child(representation, "SegmentList")
if segment_list is None:
segment_list = _child(adaptation, "SegmentList")
if segment_list is None:
return
timescale = int(_attr(segment_list, "timescale", "1") or 1)
duration = int(_attr(segment_list, "duration", "0") or 0)
initialization = _child(segment_list, "Initialization")
if initialization is not None:
start, length = self._range(_attr(initialization, "range"))
stream.playlist.media_init = MediaSegment(-1, combine_url(base, _attr(initialization, "sourceURL", "") or ""), start_range=start, expect_length=length)
for index, item in enumerate(_children(segment_list, "SegmentURL")):
start, length = self._range(_attr(item, "mediaRange"))
stream.playlist.media_parts[0].media_segments.append(
MediaSegment(index, combine_url(base, _attr(item, "media", "") or ""), duration / timescale, start_range=start, expect_length=length)
)
def _parse_segment_template(
self,
root: ET.Element,
period: ET.Element,
adaptation: ET.Element,
representation: ET.Element,
base: str,
stream: StreamSpec,
period_duration: float,
is_live: bool,
) -> None:
templates = [_child(node, "SegmentTemplate") for node in (root, period, adaptation, representation)]
templates = [template for template in templates if template is not None]
if not templates:
return
attributes: dict[str, str] = {}
timeline: ET.Element | None = None
for template in templates:
attributes.update(template.attrib)
template_timeline = _child(template, "SegmentTimeline")
if template_timeline is not None:
timeline = template_timeline
values: dict[str, object | None] = {
"RepresentationID": stream.group_id,
"Bandwidth": stream.bandwidth,
}
timescale = int(attributes.get("timescale", "1"))
start_number = int(attributes.get("startNumber", "1"))
initialization = attributes.get("initialization")
media = attributes.get("media")
if initialization:
stream.playlist.media_init = MediaSegment(-1, combine_url(base, replace_vars(initialization, values)))
if not media:
return
if timeline is not None:
segment_values = self._timeline_segments(timeline, media, values, start_number, timescale, period_duration)
else:
duration = int(attributes.get("duration", "0"))
if not duration:
return
count = int((period_duration * timescale + duration - 1) // duration) if period_duration else (1 if is_live else 0)
segment_values = [(start_number + offset, offset * duration, duration) for offset in range(count)]
for index, (number, timestamp, duration) in enumerate(segment_values):
values["Number"] = number
values["Time"] = timestamp
stream.playlist.media_parts[0].media_segments.append(
MediaSegment(index, combine_url(base, replace_vars(media, values)), duration / timescale, name_from_var=str(timestamp) if "$Time" in media else str(number) if "$Number" in media else None)
)
def _timeline_segments(
self,
timeline: ET.Element,
media: str,
values: dict[str, object | None],
start_number: int,
timescale: int,
period_duration: float,
) -> list[tuple[int, int, int]]:
items = _children(timeline, "S")
output: list[tuple[int, int, int]] = []
current = 0
number = start_number
for item_index, item in enumerate(items):
current = int(_attr(item, "t")) if _attr(item, "t") is not None else current
duration = int(_attr(item, "d", "0") or 0)
repeat = int(_attr(item, "r", "0") or 0)
if duration <= 0:
continue
if repeat < 0:
next_time = next((_attr(next_item, "t") for next_item in items[item_index + 1 :] if _attr(next_item, "t") is not None), None)
until = int(next_time) if next_time is not None else int(period_duration * timescale)
repeat = max(0, (until - current) // duration - 1)
for _ in range(repeat + 1):
output.append((number, current, duration))
number += 1
current += duration
return output
@staticmethod
def _range(value: str | None) -> tuple[int | None, int | None]:
if not value:
return None, None
return parse_range(value)
@staticmethod
def _extend_base(base: str, element: ET.Element) -> str:
base_url = _child(element, "BaseURL")
return combine_url(base, base_url.text or "") if base_url is not None else base
@staticmethod
def _frame_rate(element: ET.Element) -> float | None:
value = _attr(element, "frameRate")
if not value:
return None
try:
numerator, separator, denominator = value.partition("/")
return float(numerator) / float(denominator) if separator else float(numerator)
except ValueError:
return None
@staticmethod
def _language(value: str | None) -> str | None:
return value if value and re.fullmatch(r"[\w-]+", value) else "und" if value else None
@staticmethod
def _role(element: ET.Element) -> str | None:
role = _child(element, "Role")
return _attr(role, "value")
@staticmethod
def _dedupe(segments: list[MediaSegment]) -> None:
unique: list[MediaSegment] = []
seen: set[tuple[str, int | None, int | None]] = set()
for segment in segments:
key = (segment.url, segment.start_range, segment.expect_length)
if key not in seen:
seen.add(key)
unique.append(segment)
segments[:] = unique
def _apply_protection(self, adaptation: ET.Element, representation: ET.Element, stream: StreamSpec) -> None:
protection = _children(adaptation, "ContentProtection") + _children(representation, "ContentProtection")
if not protection:
return
kid: str | None = None
for element in protection:
value = _find_attr(element, "default_KID")
if value:
kid = value.replace("-", "").lower()
break
encrypted = EncryptInfo(EncryptMethod.CENC, kid=kid)
if stream.playlist.media_init:
stream.playlist.media_init.encrypt_info = encrypted.copy()
for segment in stream.playlist.segments:
segment.encrypt_info = encrypted.copy()
@staticmethod
def _merge_period_stream(streams: list[StreamSpec], candidate: StreamSpec, is_live: bool) -> None:
existing = next(
(
stream
for stream in streams
if stream.period_id != candidate.period_id
and stream.group_id == candidate.group_id
and stream.resolution == candidate.resolution
and stream.media_type == candidate.media_type
),
None,
)
if existing is None or is_live:
streams.append(candidate)
return
if existing.playlist and candidate.playlist:
offset = existing.segments_count
for segment in candidate.playlist.segments:
segment.index += offset
existing.playlist.media_parts.extend(candidate.playlist.media_parts)
+376
View File
@@ -0,0 +1,376 @@
from __future__ import annotations
import concurrent.futures
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from .http import HttpClient
from .errors import DownloadCancelledError
from .events import DownloadEvent, DownloadEventKind, EventCallback
from .models import EncryptMethod, MediaSegment, MediaType, StreamSpec
from .subtitles import merge_webvtt
from .utils import valid_filename
@dataclass
class DownloadOptions:
tmp_dir: Path
save_dir: Path
save_name: str
save_pattern: str | None
thread_count: int
retries: int
binary_merge: bool
skip_merge: bool
del_after_done: bool
check_segments_count: bool
max_speed: int | None
subtitle_format: str
auto_subtitle_fix: bool
ffmpeg: str | None
use_ffmpeg_concat_demuxer: bool
decryption_engine: str
decryption_binary: str | None
keys: list[str]
class DownloadManager:
def __init__(
self,
client: HttpClient,
options: DownloadOptions,
report: Callable[[str], None] | None = None,
event_callback: EventCallback | None = None,
track_ids: dict[int, str] | None = None,
) -> None:
self.client = client
self.options = options
self.report = report
self.event_callback = event_callback
self.track_ids = track_ids or {}
self.cancelled = threading.Event()
self._speed_started = time.monotonic()
self._speed_bytes = 0
def download(self, streams: list[StreamSpec], concurrent_tracks: bool = False) -> list[Path]:
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, stream, number) for number, stream in enumerate(streams, 1)]
return [future.result() for future in futures]
return [self.download_stream(stream, number) for number, stream in enumerate(streams, 1)]
def download_stream(self, stream: StreamSpec, number: int) -> Path:
self._check_cancelled()
if not stream.playlist or not stream.playlist.segments:
raise ValueError(f"No media segments for stream: {stream.describe()}")
label = valid_filename(f"{number}_{stream.group_id or 'stream'}_{stream.bandwidth or 0}_{stream.language or 'und'}", 160)
work_dir = self.options.tmp_dir / label
work_dir.mkdir(parents=True, exist_ok=True)
segments = stream.playlist.segments
width = max(1, len(str(len(segments))))
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"
self._download_segment(stream.playlist.media_init, init_path)
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, self.options.thread_count)) as executor:
future_map = {
executor.submit(self._download_segment, segment, work_dir / f"{index:0{width}d}.bin"): index
for index, segment in enumerate(segments, 1)
}
paths: dict[int, Path] = {}
try:
for future in concurrent.futures.as_completed(future_map):
self._check_cancelled()
index = future_map[future]
paths[index] = future.result()
progress = len(paths)
self._report(f" {stream.type_name}: {progress}/{len(segments)} segments")
self._emit(
DownloadEventKind.SEGMENT_COMPLETED,
stream,
f"Downloaded segment {progress}/{len(segments)}",
completed_segments=progress,
total_segments=len(segments),
)
self._check_cancelled()
except BaseException:
for future in future_map:
future.cancel()
raise
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
def _download_segment(self, segment: MediaSegment, destination: Path) -> Path:
self._check_cancelled()
if destination.exists() and destination.stat().st_size:
return destination
payload, _, content_length = self.client.get_bytes(
segment.url,
start=segment.start_range,
end=segment.stop_range,
retries=self.options.retries,
)
if segment.expect_length is not None and len(payload) != segment.expect_length:
raise IOError(f"Unexpected range size from {segment.url}: expected {segment.expect_length}, got {len(payload)}")
payload = self._decrypt_hls(payload, segment)
self._check_cancelled()
self._limit_speed(len(payload))
temporary = destination.with_suffix(".part")
temporary.write_bytes(payload)
temporary.replace(destination)
return destination
def _report(self, message: str) -> None:
if self.report is not None:
self.report(message)
def _emit(
self,
kind: DownloadEventKind,
stream: StreamSpec,
message: str,
*,
completed_segments: int | None = None,
total_segments: int | None = None,
output_path: Path | None = None,
) -> None:
if self.event_callback is None:
return
event = DownloadEvent(
kind,
message=message,
track_id=self.track_ids.get(id(stream)),
completed_segments=completed_segments,
total_segments=total_segments,
output_path=output_path,
)
if self.event_callback(event) is False:
self.cancelled.set()
def _check_cancelled(self) -> None:
if self.cancelled.is_set():
raise DownloadCancelledError("Download cancelled by event callback")
@staticmethod
def _decrypt_hls(payload: bytes, segment: MediaSegment) -> bytes:
encryption = segment.encrypt_info
if encryption.method not in {EncryptMethod.AES_128, EncryptMethod.AES_128_ECB}:
return payload
if not encryption.key:
raise ValueError(f"Missing key for encrypted segment {segment.url}")
openssl = shutil.which("openssl")
if not openssl:
raise RuntimeError("AES-128 HLS decryption requires the system 'openssl' command")
cipher_name = "aes-128-ecb" if encryption.method is EncryptMethod.AES_128_ECB else "aes-128-cbc"
command = [openssl, "enc", "-d", f"-{cipher_name}", "-K", encryption.key.hex(), "-nopad"]
if encryption.method is EncryptMethod.AES_128:
command.extend(["-iv", (encryption.iv or bytes(16)).hex()])
result = subprocess.run(command, input=payload, capture_output=True)
if result.returncode:
raise RuntimeError(f"openssl AES decryption failed: {result.stderr.decode(errors='replace').strip()}")
plaintext = result.stdout
if plaintext:
padding = plaintext[-1]
if 0 < padding <= 16 and plaintext[-padding:] == bytes([padding]) * padding:
return plaintext[:-padding]
return plaintext
def _limit_speed(self, byte_count: int) -> None:
if not self.options.max_speed:
return
self._speed_bytes += byte_count
target_elapsed = self._speed_bytes / self.options.max_speed
wait = target_elapsed - (time.monotonic() - self._speed_started)
if wait > 0:
time.sleep(wait)
def _output_path(self, stream: StreamSpec, number: int) -> Path:
suffix = self._suffix(stream)
name = self._format_save_name(stream, number, suffix)
if not self.options.save_pattern and stream.language:
name += f".{valid_filename(stream.language, 30)}"
if not self.options.save_pattern and number > 1 and not stream.language:
name += f".{stream.type_name.lower()}"
filename = valid_filename(name, 180)
candidate = self.options.save_dir / (filename if filename.lower().endswith(suffix.lower()) else f"{filename}{suffix}")
if not candidate.exists():
return candidate
sequence = 1
while True:
alternate = candidate.with_name(f"{candidate.stem}_{sequence}{candidate.suffix}")
if not alternate.exists():
return alternate
sequence += 1
def _format_save_name(self, stream: StreamSpec, number: int, suffix: str) -> str:
if not self.options.save_pattern:
return self.options.save_name
values = {
"SaveName": self.options.save_name,
"Id": stream.group_id or str(number),
"Codecs": stream.codecs or "",
"Language": stream.language or "und",
"Resolution": stream.resolution or "",
"Bandwidth": str(stream.bandwidth or ""),
"MediaType": stream.type_name.lower(),
"Channels": stream.channels or "",
"FrameRate": str(stream.frame_rate or ""),
"VideoRange": stream.video_range or "",
"GroupId": stream.group_id or "",
"Ext": suffix.lstrip("."),
}
result = self.options.save_pattern
for key, value in values.items():
result = result.replace(f"<{key}>", value)
return result
def _suffix(self, stream: StreamSpec) -> str:
if stream.media_type is MediaType.SUBTITLES and self.options.auto_subtitle_fix:
return ".srt" if self.options.subtitle_format.upper() == "SRT" else ".vtt"
if stream.media_type is MediaType.AUDIO and stream.extension in {"m4s", "mp4"}:
return ".m4a"
if stream.extension in {"m4s", "mp4"} and stream.media_type is not MediaType.SUBTITLES:
return ".mp4"
return f".{stream.extension or 'ts'}"
def _merge_subtitles(self, files: list[Path], output: Path) -> None:
try:
output.write_text(merge_webvtt([item.read_bytes() for item in files], output.suffix == ".srt"), encoding="utf-8")
except Exception:
self._merge_binary(files, output)
def _merge_media(self, files: list[Path], output: Path, force_binary: bool, stream: StreamSpec) -> Path:
if force_binary:
self._merge_binary(files, output)
return output
ffmpeg = self.options.ffmpeg
if not ffmpeg:
self._merge_binary(files, output)
return output
ffmpeg_output = self._ffmpeg_output_path(output, stream)
manifest = ffmpeg_output.with_suffix(".concat.txt")
manifest.write_text("".join(f"file '{item.as_posix().replace("'", "'\\\\''")}'\n" for item in files), encoding="utf-8")
command = [ffmpeg, "-hide_banner", "-loglevel", "warning", "-nostdin", "-f", "concat", "-safe", "0", "-i", str(manifest), "-c", "copy", "-y", str(ffmpeg_output)]
result = subprocess.run(command, capture_output=True, text=True)
manifest.unlink(missing_ok=True)
if result.returncode != 0:
ffmpeg_output.unlink(missing_ok=True)
self._report(f"ffmpeg concat failed, using binary merge: {result.stderr.strip().splitlines()[-1:]}")
self._merge_binary(files, output)
return output
return ffmpeg_output
def _ffmpeg_output_path(self, output: Path, stream: StreamSpec) -> Path:
suffix = ".m4a" if stream.media_type is MediaType.AUDIO else ".mp4"
candidate = output.with_suffix(suffix)
if not candidate.exists():
return candidate
sequence = 1
while True:
alternate = candidate.with_name(f"{candidate.stem}_{sequence}{candidate.suffix}")
if not alternate.exists():
return alternate
sequence += 1
@staticmethod
def _merge_binary(files: list[Path], output: Path) -> None:
with output.open("wb") as target:
for source in files:
with source.open("rb") as incoming:
shutil.copyfileobj(incoming, target, 1024 * 1024)
def _decrypt_cenc(self, output: Path, stream: StreamSpec) -> None:
if not self.options.keys:
self._report(f"Warning: CENC detected for {output}; downloaded media remains encrypted (no --key supplied).")
return
binary = self.options.decryption_binary
engine = self.options.decryption_engine.upper()
if not binary:
self._report(f"Warning: CENC detected for {output}; no decryption binary configured.")
return
decrypted = output.with_name(f"{output.stem}_dec{output.suffix}")
if engine == "MP4DECRYPT":
command = [binary]
for key in self.options.keys:
command.extend(["--key", key])
command.extend([str(output), str(decrypted)])
elif engine == "SHAKA_PACKAGER":
command = [binary, f"input={output},stream=0,output={decrypted}"]
for key in self.options.keys:
command.append(f"--keys=key_id={key.split(':', 1)[0]},key={key.split(':', 1)[-1]}")
else:
command = [binary, "-hide_banner", "-loglevel", "warning", "-y", "-decryption_key", self.options.keys[0].split(":")[-1], "-i", str(output), "-c", "copy", str(decrypted)]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
self._report(f"Warning: external CENC decryption failed: {result.stderr.strip()[-500:]}")
return
decrypted.replace(output)
def mux_outputs(ffmpeg: str, inputs: list[Path], output_stem: Path, format_name: str = "mkv", keep: bool = False) -> Path:
format_name = format_name.lower()
if format_name not in {"mkv", "mp4", "ts"}:
raise ValueError("--mux-after-done format must be mkv, mp4 or ts")
output = output_stem.with_suffix(f".{format_name}")
command = [ffmpeg, "-hide_banner", "-loglevel", "warning", "-nostdin", "-y"]
for item in inputs:
command.extend(["-i", str(item)])
command.extend(["-map", "0"])
for index in range(1, len(inputs)):
command.extend(["-map", str(index)])
command.extend(["-c", "copy"])
if format_name == "mp4" and any(item.suffix.lower() == ".srt" for item in inputs):
command.extend(["-c:s", "mov_text"])
command.append(str(output))
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise RuntimeError(f"ffmpeg mux failed: {result.stderr[-1000:]}")
if not keep:
for item in inputs:
item.unlink(missing_ok=True)
return output
+21
View File
@@ -0,0 +1,21 @@
"""Exceptions raised by the supported Python API."""
class N_m3u8DLError(Exception):
"""Base class for errors raised by the N_m3u8DL-PY API."""
class ManifestError(N_m3u8DLError):
"""The input could not be loaded or parsed as a supported manifest."""
class SelectionError(N_m3u8DLError):
"""The requested media tracks do not exist or cannot be downloaded."""
class DownloadError(N_m3u8DLError):
"""A selected media track could not be downloaded or merged."""
class DownloadCancelledError(N_m3u8DLError):
"""The caller cancelled a download by returning ``False`` from ``on_event``."""
+38
View File
@@ -0,0 +1,38 @@
"""Progress events for programmatic downloads."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Callable
class DownloadEventKind(str, Enum):
INSPECT_STARTED = "inspect_started"
INSPECT_COMPLETED = "inspect_completed"
PLAYLIST_LOADED = "playlist_loaded"
DOWNLOAD_STARTED = "download_started"
TRACK_STARTED = "track_started"
SEGMENT_COMPLETED = "segment_completed"
TRACK_COMPLETED = "track_completed"
DOWNLOAD_COMPLETED = "download_completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class DownloadEvent:
"""A synchronous event emitted while inspecting or downloading media."""
kind: DownloadEventKind
message: str = ""
track_id: str | None = None
completed_segments: int | None = None
total_segments: int | None = None
output_path: Path | None = None
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
EventCallback = Callable[[DownloadEvent], bool | None]
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .dash import DASHExtractor
from .hls import HLSExtractor
from .http import HttpClient
from .models import EncryptMethod, StreamSpec
from .mss import MSSExtractor
@dataclass
class ExtractedSource:
kind: str
source_url: str
content: str
streams: list[StreamSpec]
extractor: HLSExtractor | DASHExtractor | MSSExtractor
def fetch_playlists(self, streams: list[StreamSpec]) -> None:
if isinstance(self.extractor, HLSExtractor):
self.extractor.fetch_playlists(streams)
def load_source(
value: str,
client: HttpClient,
*,
base_url: str | None = None,
append_url_params: bool = False,
custom_hls_method: EncryptMethod | None = None,
custom_hls_key: bytes | None = None,
custom_hls_iv: bytes | None = None,
allow_hls_multi_ext_map: bool = False,
) -> ExtractedSource:
if value.startswith(("http://", "https://", "file:")):
content, source_url = client.get_text(value)
else:
path = Path(value).expanduser().resolve()
content = path.read_text(encoding="utf-8-sig")
source_url = path.as_uri()
text = content.lstrip("\ufeff").strip()
if text.startswith("#EXTM3U"):
extractor = HLSExtractor(
source_url,
client,
base_url,
append_url_params,
custom_hls_method,
custom_hls_key,
custom_hls_iv,
allow_hls_multi_ext_map,
)
return ExtractedSource("HLS", source_url, text, extractor.extract(text), extractor)
if "<MPD" in text and "</MPD>" in text:
extractor = DASHExtractor(source_url, base_url, append_url_params)
return ExtractedSource("DASH", source_url, text, extractor.extract(text), extractor)
if "<SmoothStreamingMedia" in text and "</SmoothStreamingMedia>" in text:
extractor = MSSExtractor(source_url, base_url, append_url_params)
return ExtractedSource("MSS", source_url, text, extractor.extract(text), extractor)
raise ValueError("Input is not a supported HLS, DASH, or Smooth Streaming manifest")
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
from datetime import datetime
from urllib.parse import urlparse
from .http import HttpClient
from .models import EncryptInfo, EncryptMethod, MediaPart, MediaSegment, MediaType, Playlist, StreamSpec
from .utils import append_query, combine_url, get_attribute, parse_hls_byterange
class HLSExtractor:
def __init__(
self,
source_url: str,
client: HttpClient,
base_url: str | None = None,
append_url_params: bool = False,
custom_method: EncryptMethod | None = None,
custom_key: bytes | None = None,
custom_iv: bytes | None = None,
allow_multi_ext_map: bool = False,
) -> None:
self.source_url = source_url
self.client = client
self.base_url = base_url or source_url
self.append_url_params = append_url_params
self.custom_method = custom_method
self.custom_key = custom_key
self.custom_iv = custom_iv
self.allow_multi_ext_map = allow_multi_ext_map
self.is_master = False
def extract(self, content: str) -> list[StreamSpec]:
content = self._preprocess(content)
if "#EXT-X-STREAM-INF" in content:
self.is_master = True
return self._parse_master(content)
playlist = self._parse_playlist(content, self.source_url)
return [StreamSpec(url=self.source_url, playlist=playlist, extension="mp4" if playlist.media_init else "ts")]
def fetch_playlists(self, streams: list[StreamSpec]) -> None:
for stream in streams:
content, resolved_url = self.client.get_text(stream.url)
playlist = self._parse_playlist(self._preprocess(content), resolved_url)
if stream.playlist and stream.playlist.media_init:
playlist.media_init = stream.playlist.media_init
stream.playlist = playlist
if stream.media_type is MediaType.SUBTITLES:
urls = [segment.url.lower() for segment in playlist.segments]
stream.extension = "ttml" if any(".ttml" in url for url in urls) else "vtt" if any(".vtt" in url or ".webvtt" in url for url in urls) else "vtt"
else:
stream.extension = "m4s" if playlist.media_init else "ts"
def _preprocess(self, content: str) -> str:
content = content.lstrip("\ufeff").strip()
if not content.startswith("#EXTM3U"):
raise ValueError("Bad m3u8: missing #EXTM3U")
return content
def _url(self, base: str, value: str) -> str:
target = combine_url(base, value)
return append_query(self.source_url, target) if self.append_url_params else target
def _parse_master(self, content: str) -> list[StreamSpec]:
streams: list[StreamSpec] = []
pending: StreamSpec | None = None
for raw_line in content.splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("#EXT-X-STREAM-INF"):
codecs = get_attribute(line, "CODECS") or None
audio_id = get_attribute(line, "AUDIO") or None
if codecs and audio_id:
codecs = codecs.split(",", 1)[0]
frame_rate = get_attribute(line, "FRAME-RATE")
pending = StreamSpec(
url="",
bandwidth=int(get_attribute(line, "AVERAGE-BANDWIDTH") or get_attribute(line, "BANDWIDTH") or 0),
codecs=codecs,
resolution=get_attribute(line, "RESOLUTION") or None,
frame_rate=float(frame_rate) if frame_rate else None,
audio_id=audio_id,
video_id=get_attribute(line, "VIDEO") or None,
subtitle_id=get_attribute(line, "SUBTITLES") or None,
video_range=get_attribute(line, "VIDEO-RANGE") or None,
original_url=self.source_url,
)
elif line.startswith("#EXT-X-MEDIA"):
type_name = get_attribute(line, "TYPE").replace("-", "_").upper()
if type_name == "CLOSED_CAPTIONS":
continue
try:
media_type = MediaType(type_name)
except ValueError:
continue
uri = get_attribute(line, "URI")
if not uri:
continue
default_text = get_attribute(line, "DEFAULT").upper()
streams.append(
StreamSpec(
url=self._url(self.base_url, uri),
media_type=media_type,
group_id=get_attribute(line, "GROUP-ID") or None,
language=get_attribute(line, "LANGUAGE") or None,
name=get_attribute(line, "NAME") or None,
default=default_text == "YES",
channels=get_attribute(line, "CHANNELS") or None,
characteristics=(get_attribute(line, "CHARACTERISTICS").split(",")[-1].split(".")[-1] or None),
original_url=self.source_url,
)
)
elif not line.startswith("#") and pending is not None:
pending.url = self._url(self.base_url, line)
streams.append(pending)
pending = None
unique: dict[str, StreamSpec] = {}
for stream in streams:
unique.setdefault(stream.url, stream)
return list(unique.values())
def _parse_playlist(self, content: str, playlist_url: str) -> Playlist:
base = self.base_url if self.base_url != self.source_url else playlist_url
playlist = Playlist(url=playlist_url)
parts: list[MediaPart] = []
segments: list[MediaSegment] = []
current_encryption = EncryptInfo(
self.custom_method or EncryptMethod.NONE,
self.custom_key,
self.custom_iv,
)
pending_duration: float | None = None
pending_range: tuple[int, int] | None = None
previous_range_end: int | None = None
sequence = 0
endlist = False
next_date: datetime | None = None
skip_ad = False
for raw_line in content.splitlines():
line = raw_line.strip()
if not line:
continue
if line.startswith("#EXT-X-TARGETDURATION"):
playlist.target_duration = float(get_attribute(line))
elif line.startswith("#EXT-X-MEDIA-SEQUENCE"):
sequence = int(get_attribute(line))
elif line.startswith("#EXT-X-PLAYLIST-TYPE"):
endlist = get_attribute(line).upper() == "VOD"
elif line.startswith("#EXT-X-PROGRAM-DATE-TIME"):
raw_date = get_attribute(line).replace("Z", "+00:00")
try:
next_date = datetime.fromisoformat(raw_date)
except ValueError:
next_date = None
elif line.startswith("#EXT-X-BYTERANGE"):
pending_range = parse_hls_byterange(get_attribute(line), previous_range_end)
previous_range_end = pending_range[0] + pending_range[1]
elif line.startswith("#EXT-X-DISCONTINUITY"):
if segments:
parts.append(MediaPart(segments))
segments = []
elif line.startswith("#EXT-X-KEY"):
current_encryption = self._parse_key(line, playlist_url, sequence, current_encryption)
elif line.startswith("#EXT-X-MAP"):
if playlist.media_init is not None and not self.allow_multi_ext_map:
break
init_url = self._url(base, get_attribute(line, "URI"))
init_range: tuple[int, int] | None = None
if get_attribute(line, "BYTERANGE"):
init_range = parse_hls_byterange(get_attribute(line, "BYTERANGE"), None)
playlist.media_init = MediaSegment(
index=-1,
url=init_url,
start_range=init_range[0] if init_range else None,
expect_length=init_range[1] if init_range else None,
encrypt_info=current_encryption.copy(),
)
elif line.startswith("#EXTINF"):
value = get_attribute(line)
duration, _, _title = value.partition(",")
pending_duration = float(duration)
elif line.startswith("#UPLYNK-SEGMENT"):
skip_ad = ",ad" in line
elif line.startswith("#EXT-X-ENDLIST"):
endlist = True
elif not line.startswith("#") and pending_duration is not None:
url = self._url(base, line)
if not skip_ad and not ("/ad/" in url and "duration=" in url):
segments.append(
MediaSegment(
index=sequence,
url=url,
duration=pending_duration,
date_time=next_date,
start_range=pending_range[0] if pending_range else None,
expect_length=pending_range[1] if pending_range else None,
encrypt_info=current_encryption.copy(),
)
)
sequence += 1
pending_duration = None
pending_range = None
next_date = None
if segments:
parts.append(MediaPart(segments))
playlist.media_parts = parts
playlist.is_live = not endlist
if playlist.is_live:
playlist.refresh_interval_ms = (playlist.target_duration or 5) * 2000
return playlist
def _parse_key(self, line: str, playlist_url: str, sequence: int, current: EncryptInfo) -> EncryptInfo:
method = EncryptMethod.from_manifest(get_attribute(line, "METHOD"))
if method is EncryptMethod.NONE:
return EncryptInfo(EncryptMethod.NONE)
if self.custom_method:
method = self.custom_method
key = self.custom_key
key_uri = get_attribute(line, "URI")
if key is None and key_uri and method in {EncryptMethod.AES_128, EncryptMethod.AES_128_ECB, EncryptMethod.CHACHA20}:
key, _, _ = self.client.get_bytes(self._url(playlist_url, key_uri), retries=2)
iv_value = get_attribute(line, "IV")
iv = self.custom_iv or (bytes.fromhex(iv_value.removeprefix("0x")) if iv_value else sequence.to_bytes(16, "big"))
if method in {EncryptMethod.AES_128, EncryptMethod.AES_128_ECB} and not key:
raise ValueError("HLS key URI did not yield a decryption key")
return EncryptInfo(method, key, iv, current.kid)
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
import base64
import gzip
import ipaddress
import io
import socket
import time
from http.client import HTTPConnection, HTTPSConnection
from pathlib import Path
from typing import Mapping
from urllib.error import HTTPError, URLError
from urllib.parse import unquote, urlparse
from urllib.request import HTTPHandler, HTTPSHandler, Request, ProxyHandler, build_opener, url2pathname
class HttpClient:
"""Small synchronous HTTP client shared by extractors and download workers."""
def __init__(
self,
headers: Mapping[str, str] | None = None,
timeout: float = 100,
proxy: str | None = None,
use_system_proxy: bool = True,
) -> None:
self.headers = dict(headers or {})
self.timeout = timeout
self.proxy = proxy
self.use_system_proxy = use_system_proxy
self.opener = build_proxy_opener(proxy, use_system_proxy)
def get_text(self, url: str) -> tuple[str, str]:
data, resolved, _ = self.get_bytes(url)
return data.decode("utf-8-sig", errors="replace"), resolved
def get_bytes(
self,
url: str,
headers: Mapping[str, str] | None = None,
start: int | None = None,
end: int | None = None,
retries: int = 3,
) -> tuple[bytes, str, int | None]:
if url.startswith("file:"):
path = Path(url2pathname(unquote(urlparse(url).path)))
data = path.read_bytes()
return self._slice(data, start, end), url, len(data)
if url.startswith("base64://"):
data = base64.b64decode(url[len("base64://") :])
return self._slice(data, start, end), url, len(data)
if url.startswith("hex://"):
data = bytes.fromhex(url[len("hex://") :])
return self._slice(data, start, end), url, len(data)
request_headers = dict(self.headers)
request_headers.update(headers or {})
if start is not None or end is not None:
request_headers["Range"] = f"bytes={start or 0}-{'' if end is None else end}"
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
request = Request(url, headers=request_headers, method="GET")
with self.opener.open(request, timeout=self.timeout) as response:
data = response.read()
if response.headers.get("Content-Encoding", "").lower() == "gzip":
data = gzip.GzipFile(fileobj=io.BytesIO(data)).read()
length = response.headers.get("Content-Length")
return data, response.geturl(), int(length) if length else None
except (HTTPError, URLError, TimeoutError, OSError) as error:
last_error = error
if attempt < retries:
time.sleep(min(1.0 * (attempt + 1), 3.0))
assert last_error is not None
raise last_error
@staticmethod
def _slice(data: bytes, start: int | None, end: int | None) -> bytes:
if start is None and end is None:
return data
return data[start or 0 : None if end is None else end + 1]
def build_proxy_opener(proxy: str | None, use_system_proxy: bool = True):
"""Build an opener for direct, HTTP(S), or no-auth SOCKS5 proxy requests.
SOCKS target hostnames are resolved by the proxy so downloads do not depend
on the downloader host's DNS configuration.
"""
if not proxy:
return build_opener(ProxyHandler({}) if not use_system_proxy else ProxyHandler())
parsed = urlparse(proxy)
if parsed.scheme in {"socks5", "socks5h"}:
if parsed.username is not None or parsed.password is not None:
raise ValueError("Authenticated SOCKS5 proxies are not supported")
if not parsed.hostname:
raise ValueError("SOCKS5 proxy host is required")
port = parsed.port or 1080
return build_opener(
ProxyHandler({}),
_Socks5HTTPHandler(parsed.hostname, port),
_Socks5HTTPSHandler(parsed.hostname, port),
)
if parsed.scheme not in {"http", "https"}:
raise ValueError("Unsupported proxy scheme")
return build_opener(ProxyHandler({"http": proxy, "https": proxy}))
class _Socks5HTTPConnection(HTTPConnection):
def __init__(self, host: str, port: int | None = None, *, proxy_host: str, proxy_port: int, **kwargs: object) -> None:
super().__init__(host, port, **kwargs)
self._proxy_host = proxy_host
self._proxy_port = proxy_port
def connect(self) -> None:
self.sock = _socks5_connect(
self._proxy_host,
self._proxy_port,
self.host,
self.port,
self.timeout,
self.source_address,
)
class _Socks5HTTPSConnection(HTTPSConnection):
def __init__(self, host: str, port: int | None = None, *, proxy_host: str, proxy_port: int, **kwargs: object) -> None:
super().__init__(host, port, **kwargs)
self._proxy_host = proxy_host
self._proxy_port = proxy_port
def connect(self) -> None:
socket_connection = _socks5_connect(
self._proxy_host,
self._proxy_port,
self.host,
self.port,
self.timeout,
self.source_address,
)
self.sock = self._context.wrap_socket(socket_connection, server_hostname=self.host)
class _Socks5HTTPHandler(HTTPHandler):
def __init__(self, proxy_host: str, proxy_port: int) -> None:
super().__init__()
self._proxy_host = proxy_host
self._proxy_port = proxy_port
def http_open(self, request: Request):
return self.do_open(
lambda host, **kwargs: _Socks5HTTPConnection(
host,
proxy_host=self._proxy_host,
proxy_port=self._proxy_port,
**kwargs,
),
request,
)
class _Socks5HTTPSHandler(HTTPSHandler):
def __init__(self, proxy_host: str, proxy_port: int) -> None:
super().__init__()
self._proxy_host = proxy_host
self._proxy_port = proxy_port
def https_open(self, request: Request):
return self.do_open(
lambda host, **kwargs: _Socks5HTTPSConnection(
host,
proxy_host=self._proxy_host,
proxy_port=self._proxy_port,
**kwargs,
),
request,
context=self._context,
)
def _socks5_connect(
proxy_host: str,
proxy_port: int,
host: str,
port: int,
timeout: float | object,
source_address: tuple[str, int] | None,
) -> socket.socket:
connection = socket.create_connection((proxy_host, proxy_port), timeout=timeout, source_address=source_address)
try:
connection.sendall(b"\x05\x01\x00")
if _receive_exact(connection, 2) != b"\x05\x00":
raise OSError("SOCKS5 proxy does not support no-authentication")
address_type, address = _socks5_target(host)
connection.sendall(b"\x05\x01\x00" + bytes([address_type]) + address + port.to_bytes(2, "big"))
reply = _receive_exact(connection, 4)
if reply[0] != 5 or reply[1] != 0:
raise OSError(f"SOCKS5 CONNECT failed with code {reply[1]}")
if reply[3] == 1:
_receive_exact(connection, 4)
elif reply[3] == 4:
_receive_exact(connection, 16)
elif reply[3] == 3:
_receive_exact(connection, _receive_exact(connection, 1)[0])
else:
raise OSError("SOCKS5 proxy returned an invalid bind address")
_receive_exact(connection, 2)
return connection
except BaseException:
connection.close()
raise
def _socks5_target(host: str) -> tuple[int, bytes]:
try:
address = ipaddress.ip_address(host)
except ValueError:
encoded = host.encode("idna")
if not encoded or len(encoded) > 255:
raise ValueError("SOCKS5 target hostname is invalid")
return 3, bytes([len(encoded)]) + encoded
return (1 if address.version == 4 else 4), address.packed
def _receive_exact(connection: socket.socket, count: int) -> bytes:
chunks: list[bytes] = []
remaining = count
while remaining:
chunk = connection.recv(remaining)
if not chunk:
raise OSError("SOCKS5 proxy closed the connection")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
class MediaType(str, Enum):
VIDEO = "VIDEO"
AUDIO = "AUDIO"
SUBTITLES = "SUBTITLES"
CLOSED_CAPTIONS = "CLOSED_CAPTIONS"
class EncryptMethod(str, Enum):
NONE = "NONE"
AES_128 = "AES_128"
AES_128_ECB = "AES_128_ECB"
CENC = "CENC"
CHACHA20 = "CHACHA20"
SAMPLE_AES = "SAMPLE_AES"
SAMPLE_AES_CTR = "SAMPLE_AES_CTR"
UNKNOWN = "UNKNOWN"
@classmethod
def from_manifest(cls, value: str | None) -> "EncryptMethod":
if not value:
return cls.UNKNOWN
normalized = value.upper().replace("-", "_")
return cls._value2member_map_.get(normalized, cls.UNKNOWN)
@dataclass
class EncryptInfo:
method: EncryptMethod = EncryptMethod.NONE
key: bytes | None = None
iv: bytes | None = None
kid: str | None = None
def copy(self) -> "EncryptInfo":
return EncryptInfo(self.method, self.key, self.iv, self.kid)
@dataclass
class MediaSegment:
index: int
url: str
duration: float = 0.0
title: str | None = None
date_time: datetime | None = None
start_range: int | None = None
expect_length: int | None = None
encrypt_info: EncryptInfo = field(default_factory=EncryptInfo)
name_from_var: str | None = None
@property
def stop_range(self) -> int | None:
if self.start_range is None or self.expect_length is None:
return None
return self.start_range + self.expect_length - 1
@property
def is_encrypted(self) -> bool:
return self.encrypt_info.method is not EncryptMethod.NONE
@dataclass
class MediaPart:
media_segments: list[MediaSegment] = field(default_factory=list)
@dataclass
class Playlist:
url: str = ""
is_live: bool = False
refresh_interval_ms: float = 15000
target_duration: float | None = None
media_init: MediaSegment | None = None
media_parts: list[MediaPart] = field(default_factory=list)
@property
def segments(self) -> list[MediaSegment]:
return [segment for part in self.media_parts for segment in part.media_segments]
@property
def total_duration(self) -> float:
return sum(segment.duration for segment in self.segments)
@dataclass
class StreamSpec:
url: str
media_type: MediaType | None = None
group_id: str | None = None
language: str | None = None
name: str | None = None
default: bool | None = None
bandwidth: int | None = None
codecs: str | None = None
resolution: str | None = None
frame_rate: float | None = None
channels: str | None = None
extension: str | None = None
role: str | None = None
video_range: str | None = None
characteristics: str | None = None
publish_time: datetime | None = None
audio_id: str | None = None
video_id: str | None = None
subtitle_id: str | None = None
period_id: str | None = None
original_url: str = ""
playlist: Playlist | None = None
skipped_duration: float = 0.0
extra: dict[str, Any] = field(default_factory=dict)
@property
def segments_count(self) -> int:
return len(self.playlist.segments) if self.playlist else 0
@property
def type_name(self) -> str:
return self.media_type.value.title() if self.media_type else "VIDEO"
def short_name(self) -> str:
if self.media_type is MediaType.AUDIO:
fields = ["Aud", self.group_id, _kbps(self.bandwidth), self.name, self.codecs, self.language, self.channels]
elif self.media_type is MediaType.SUBTITLES:
fields = ["Sub", self.group_id, self.language, self.name, self.codecs]
else:
fields = ["Vid", self.resolution, _kbps(self.bandwidth), self.group_id, self.frame_rate, self.codecs]
return " | ".join(str(item) for item in fields if item not in (None, ""))
def describe(self) -> str:
duration = _format_duration(self.playlist.total_duration) if self.playlist else "0s"
segments = f"{self.segments_count} segments" if self.segments_count else ""
return " | ".join(part for part in [self.short_name(), segments, f"~{duration}"] if part)
def _kbps(value: int | None) -> str | None:
return f"{value // 1000} Kbps" if value is not None else None
def _format_duration(total: float) -> str:
seconds = max(0, int(total))
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
if hours:
return f"{hours}h{minutes}m{seconds}s"
if minutes:
return f"{minutes}m{seconds}s"
return f"{seconds}s"
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from .dash import _attr, _child, _children, _local
from .models import EncryptInfo, EncryptMethod, MediaPart, MediaSegment, MediaType, Playlist, StreamSpec
from .utils import append_query, combine_url, replace_vars
class MSSExtractor:
"""Parse Smooth Streaming manifests into the common playlist model.
Unlike the .NET edition, this implementation does not synthesize a `moov`
init segment from CodecPrivateData. Existing fragment streams remain useful
with `--binary-merge`, and an external ffmpeg may handle site-provided init
metadata where available.
"""
def __init__(self, source_url: str, base_url: str | None = None, append_url_params: bool = False) -> None:
self.source_url = source_url
self.base_url = base_url or source_url
self.append_url_params = append_url_params
def extract(self, content: str) -> list[StreamSpec]:
root = ET.fromstring(content.strip())
if _local(root) != "SmoothStreamingMedia":
raise ValueError("Bad Smooth Streaming manifest")
timescale = int(_attr(root, "TimeScale", "10000000") or 10000000)
duration = int(_attr(root, "Duration", "0") or 0)
is_live = (_attr(root, "IsLive", "FALSE") or "FALSE").upper() == "TRUE"
protected = _child(root, "Protection") is not None
streams: list[StreamSpec] = []
for stream_index in _children(root, "StreamIndex"):
stream_type = (_attr(stream_index, "Type") or "video").lower()
media_type = {"audio": MediaType.AUDIO, "text": MediaType.SUBTITLES}.get(stream_type)
url_pattern = (_attr(stream_index, "Url") or "").replace("{bitrate}", "$Bandwidth$").replace("{Bitrate}", "$Bandwidth$").replace("{start time}", "$Time$").replace("{start_time}", "$Time$")
language = _attr(stream_index, "Language")
language = language if language and len(language) == 3 else None
chunks = _children(stream_index, "c")
for quality in _children(stream_index, "QualityLevel"):
bitrate = int(_attr(quality, "Bitrate", "0") or 0)
codec_private_data = _attr(quality, "CodecPrivateData", "") or ""
fourcc = (_attr(quality, "FourCC", "") or "").upper()
stream = StreamSpec(
url=self.source_url,
media_type=media_type,
group_id=_attr(stream_index, "Name") or _attr(quality, "Index"),
bandwidth=bitrate,
codecs=self._codecs(fourcc, codec_private_data),
language=language,
resolution=self._resolution(quality),
channels=_attr(quality, "Channels"),
period_id=_attr(quality, "Index"),
original_url=self.source_url,
extension="m4s",
playlist=Playlist(is_live=is_live, media_parts=[MediaPart()]),
extra={"mss_codec_private_data": codec_private_data, "mss_fourcc": fourcc},
)
current = 0
index = 0
for chunk_index, chunk in enumerate(chunks):
current = int(_attr(chunk, "t")) if _attr(chunk, "t") is not None else current
chunk_duration = int(_attr(chunk, "d", "0") or 0)
repeat = int(_attr(chunk, "r", "0") or 0)
if repeat > 0:
repeat -= 1
if repeat < 0 and chunk_duration:
repeat = max(0, duration // chunk_duration - 1)
for _ in range(repeat + 1):
values = {"Bandwidth": bitrate, "Time": current}
url = combine_url(self.base_url, replace_vars(url_pattern, values))
encryption = EncryptInfo(EncryptMethod.CENC) if protected and stream_type != "text" else EncryptInfo()
stream.playlist.media_parts[0].media_segments.append(MediaSegment(index, url, chunk_duration / timescale, encrypt_info=encryption, name_from_var=str(current)))
current += chunk_duration
index += 1
streams.append(stream)
audios = [stream for stream in streams if stream.media_type is MediaType.AUDIO]
subtitles = [stream for stream in streams if stream.media_type is MediaType.SUBTITLES]
for stream in streams:
if stream.media_type is None:
stream.audio_id = audios[0].group_id if audios else None
stream.subtitle_id = subtitles[0].group_id if subtitles else None
if self.append_url_params:
for stream in streams:
for segment in stream.playlist.segments:
segment.url = append_query(self.source_url, segment.url)
return streams
@staticmethod
def _resolution(quality: ET.Element) -> str | None:
width = _attr(quality, "MaxWidth")
height = _attr(quality, "MaxHeight")
return f"{width}x{height}" if width and height and width != "0" else None
@staticmethod
def _codecs(fourcc: str, private_data: str) -> str | None:
if fourcc == "TTML":
return "stpp"
if fourcc in {"H264", "X264", "DAVC", "AVC1"}:
match = re.search(r"00000001d7([0-9a-fA-F]{6})", private_data)
return f"avc1.{match.group(1)}" if match else "avc1.4D401E"
if fourcc in {"AAC", "AACL", "AACH", "AACP"}:
profile = 5 if fourcc == "AACH" else int(private_data[:2], 16) >> 3 if private_data else 2
return f"mp4a.40.{profile}"
return fourcc.lower() or None
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from .models import MediaSegment, MediaType, StreamSpec
from .utils import parse_duration
@dataclass
class StreamFilter:
values: dict[str, str]
selection: str = "best"
@classmethod
def parse(cls, raw: str | None) -> "StreamFilter | None":
if raw is None:
return None
if raw in {"all", "best", "worst"} or re.fullmatch(r"(?:best|worst)\d+", raw):
return cls({}, raw)
values: dict[str, str] = {}
for item in _split_options(raw):
key, separator, value = item.partition("=")
if not separator:
continue
values[key.strip().lower()] = value.strip().strip("\"'")
return cls(values, values.pop("for", "best"))
def select(self, streams: list[StreamSpec]) -> list[StreamSpec]:
selected = [stream for stream in streams if self._matches(stream)]
mode = self.selection.lower()
if mode == "all":
return selected
descending = not mode.startswith("worst")
count_text = re.sub(r"^(?:best|worst)", "", mode)
count = int(count_text) if count_text else 1
groups: dict[str, list[StreamSpec]] = {}
for stream in selected:
language = (stream.language or "").replace("_", "-").split("-", 1)[0].lower()
groups.setdefault(language, []).append(stream)
return [stream for group in groups.values() for stream in sorted(group, key=lambda item: item.bandwidth or 0, reverse=descending)[:count]]
def _matches(self, stream: StreamSpec) -> bool:
mapping = {
"id": stream.group_id,
"lang": stream.language,
"name": stream.name,
"codecs": stream.codecs,
"res": stream.resolution,
"frame": str(stream.frame_rate) if stream.frame_rate is not None else None,
"ch": stream.channels,
"range": stream.video_range,
"url": stream.url,
"period": stream.period_id,
"role": stream.role,
}
for key, value in self.values.items():
if key in mapping and (mapping[key] is None or not re.search(value, mapping[key] or "")):
return False
segments = stream.segments_count
if "segsmin" in self.values and segments <= int(self.values["segsmin"]):
return False
if "segsmax" in self.values and segments >= int(self.values["segsmax"]):
return False
if "bwmin" in self.values and (stream.bandwidth or 0) < int(self.values["bwmin"]) * 1000:
return False
if "bwmax" in self.values and (stream.bandwidth or 0) > int(self.values["bwmax"]) * 1000:
return False
duration = stream.playlist.total_duration if stream.playlist else 0
if "plistdurmin" in self.values and duration < parse_duration(self.values["plistdurmin"]):
return False
if "plistdurmax" in self.values and duration > parse_duration(self.values["plistdurmax"]):
return False
return True
def apply_drop(streams: list[StreamSpec], stream_filter: StreamFilter | None) -> list[StreamSpec]:
if not stream_filter:
return streams
drops = {id(stream) for stream in stream_filter.select(streams)}
return [stream for stream in streams if id(stream) not in drops]
def apply_custom_range(streams: list[StreamSpec], value: str | None) -> None:
if not value:
return
left, separator, right = value.partition("-")
if not separator:
raise ValueError("--custom-range must use START-END syntax")
by_time = ":" in value
start = parse_duration(left) if by_time and left else float(left or 0)
end = parse_duration(right) if by_time and right else float("inf")
for stream in streams:
if not stream.playlist:
continue
offset = 0.0
skipped = 0.0
for part in stream.playlist.media_parts:
original = part.media_segments
keep: list[MediaSegment] = []
for segment in original:
match = start <= offset <= end if by_time else start <= segment.index <= end
if match:
keep.append(segment)
elif not keep:
skipped += segment.duration
offset += segment.duration
part.media_segments = keep
stream.playlist.media_parts = [part for part in stream.playlist.media_parts if part.media_segments]
stream.skipped_duration = skipped
def clean_ads(streams: list[StreamSpec], patterns: list[str]) -> None:
regexes = [re.compile(pattern) for pattern in patterns]
if not regexes:
return
for stream in streams:
if not stream.playlist:
continue
stream.playlist.media_parts = [
type(part)([segment for segment in part.media_segments if not any(regex.search(segment.url) for regex in regexes)])
for part in stream.playlist.media_parts
]
stream.playlist.media_parts = [part for part in stream.playlist.media_parts if part.media_segments]
if stream.playlist.media_init and any(regex.search(stream.playlist.media_init.url) for regex in regexes):
stream.playlist.media_init = None
def automatic_selection(streams: list[StreamSpec], sub_only: bool = False) -> list[StreamSpec]:
video = [stream for stream in streams if stream.media_type is None]
audio = [stream for stream in streams if stream.media_type is MediaType.AUDIO]
subtitles = [stream for stream in streams if stream.media_type is MediaType.SUBTITLES]
if sub_only:
return subtitles
chosen: list[StreamSpec] = [max(video, key=lambda stream: stream.bandwidth or 0)] if video else []
audio_languages = {stream.language or "" for stream in audio}
for language in audio_languages:
candidates = [stream for stream in audio if (stream.language or "") == language]
chosen.append(max(candidates, key=lambda stream: stream.bandwidth or 0))
return chosen + subtitles
def _split_options(value: str) -> list[str]:
items: list[str] = []
current: list[str] = []
quote = ""
escaped = False
for character in value:
if escaped:
current.append(character)
escaped = False
elif character == "\\":
escaped = True
elif character in "\"'":
quote = "" if quote == character else character if not quote else quote
current.append(character)
elif character == ":" and not quote:
items.append("".join(current))
current = []
else:
current.append(character)
items.append("".join(current))
return items
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import re
from dataclasses import dataclass
TIMING = re.compile(r"(?P<start>[^\s]+)\s+-->\s+(?P<end>[^\s]+)(?P<settings>.*)")
@dataclass
class Cue:
start: float
end: float
text: str
settings: str = ""
def merge_webvtt(files: list[bytes], output_srt: bool) -> str:
cues: list[Cue] = []
seen: set[tuple[float, float, str]] = set()
for payload in files:
for cue in parse_webvtt(payload.decode("utf-8-sig", errors="replace")):
key = (cue.start, cue.end, cue.text)
if key not in seen:
seen.add(key)
cues.append(cue)
cues.sort(key=lambda cue: (cue.start, cue.end))
return to_srt(cues) if output_srt else to_vtt(cues)
def parse_webvtt(content: str) -> list[Cue]:
lines = content.replace("\r\n", "\n").split("\n")
cues: list[Cue] = []
index = 0
while index < len(lines):
line = lines[index].strip()
if " --> " not in line:
index += 1
continue
match = TIMING.match(line)
if not match:
index += 1
continue
payload: list[str] = []
index += 1
while index < len(lines) and lines[index].strip():
payload.append(lines[index].strip())
index += 1
if payload:
cues.append(Cue(_seconds(match.group("start")), _seconds(match.group("end")), "\n".join(payload), match.group("settings").strip()))
return cues
def to_vtt(cues: list[Cue]) -> str:
content = ["WEBVTT", ""]
for cue in cues:
content.extend([f"{_vtt_time(cue.start)} --> {_vtt_time(cue.end)} {cue.settings}".rstrip(), cue.text, ""])
return "\n".join(content) + "\n"
def to_srt(cues: list[Cue]) -> str:
if not cues:
return "1\n00:00:00,000 --> 00:00:01,000\n\n"
content: list[str] = []
for number, cue in enumerate(cues, 1):
content.extend([str(number), f"{_srt_time(cue.start)} --> {_srt_time(cue.end)}", cue.text, ""])
return "\n".join(content) + "\n"
def _seconds(value: str) -> float:
value = value.replace(",", ".")
if value.endswith("s"):
return float(value[:-1])
parts = [float(part) for part in value.split(":")]
return parts[-1] + (parts[-2] * 60 if len(parts) >= 2 else 0) + (parts[-3] * 3600 if len(parts) >= 3 else 0)
def _vtt_time(value: float) -> str:
hours, remainder = divmod(int(value * 1000), 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
seconds, milliseconds = divmod(remainder, 1000)
return f"{hours:02}:{minutes:02}:{seconds:02}.{milliseconds:03}"
def _srt_time(value: float) -> str:
return _vtt_time(value).replace(".", ",")
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import base64
import hashlib
import os
import re
from datetime import timedelta
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
INVALID_FILENAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
VARIABLE_PATTERN = re.compile(r"\$(RepresentationID|Bandwidth|Number|Time)(?:%0?(\d+)([dDxX]))?\$")
def combine_url(base_url: str, value: str) -> str:
return urljoin(base_url, value.strip())
def get_attribute(line: str, name: str | None = None) -> str:
payload = line.split(":", 1)[1] if ":" in line else line
if name is None:
return payload.strip()
match = re.search(rf"(?:^|,)\s*{re.escape(name)}=(?:\"([^\"]*)\"|([^,]*))", payload, re.I)
return (match.group(1) if match and match.group(1) is not None else match.group(2) if match else "").strip()
def parse_range(value: str) -> tuple[int, int]:
start, end = value.strip().split("-", 1)
first = int(start)
last = int(end)
return first, last - first + 1
def parse_hls_byterange(value: str, previous_end: int | None) -> tuple[int, int]:
length_text, _, offset_text = value.strip().partition("@")
length = int(length_text)
start = int(offset_text) if offset_text else (previous_end or 0)
return start, length
def replace_vars(template: str, values: dict[str, object | None]) -> str:
def replacement(match: re.Match[str]) -> str:
name, width, kind = match.groups()
value = values.get(name)
if value is None:
return match.group(0)
if not kind:
return str(value)
integer = int(value)
width_number = int(width or 0)
if kind == "d":
return f"{integer:0{width_number}d}" if width_number else str(integer)
rendered = format(integer, kind.lower())
rendered = rendered.rjust(width_number, "0") if width_number else rendered
return rendered.upper() if kind == "X" else rendered
return VARIABLE_PATTERN.sub(replacement, template).replace("$$", "$")
def parse_iso8601_duration(value: str | None) -> float:
if not value:
return 0.0
match = re.fullmatch(
r"P(?:(?P<days>[\d.]+)D)?(?:T(?:(?P<hours>[\d.]+)H)?(?:(?P<minutes>[\d.]+)M)?(?:(?P<seconds>[\d.]+)S)?)?",
value,
)
if not match:
raise ValueError(f"Unsupported ISO-8601 duration: {value}")
values = {key: float(number or 0) for key, number in match.groupdict().items()}
return timedelta(days=values["days"], hours=values["hours"], minutes=values["minutes"], seconds=values["seconds"]).total_seconds()
def parse_duration(value: str) -> float:
value = value.strip()
if ":" in value:
values = [float(part) for part in value.split(":")]
if len(values) == 2:
return values[0] * 60 + values[1]
if len(values) == 3:
return values[0] * 3600 + values[1] * 60 + values[2]
raise ValueError(f"Invalid duration: {value}")
matches = re.findall(r"([\d.]+)\s*([hms])", value, flags=re.I)
if matches:
factors = {"h": 3600, "m": 60, "s": 1}
return sum(float(amount) * factors[unit.lower()] for amount, unit in matches)
return float(value)
def parse_data(value: str | None) -> bytes | None:
if not value:
return None
path = Path(value)
if path.is_file():
return path.read_bytes()
compact = re.sub(r"\s+", "", value)
if re.fullmatch(r"[0-9a-fA-F]+", compact) and len(compact) % 2 == 0:
return bytes.fromhex(compact)
return base64.b64decode(compact)
def valid_filename(value: str, max_bytes: int | None = None) -> str:
value = INVALID_FILENAME.sub("_", value).strip(" .")
return truncate_filename(value or "download", max_bytes) if max_bytes else (value or "download")
def truncate_filename(value: str, max_bytes: int = 200) -> str:
encoded = value.encode("utf-8")
if len(encoded) <= max_bytes:
return value
suffix = "_" + hashlib.sha256(encoded).hexdigest()[:12]
budget = max_bytes - len(suffix.encode("utf-8"))
prefix: list[str] = []
used = 0
for character in value:
size = len(character.encode("utf-8"))
if used + size > budget:
break
prefix.append(character)
used += size
return "".join(prefix) + suffix
def inferred_name(input_value: str) -> str:
parsed = urlparse(input_value)
candidate = Path(parsed.path or input_value).stem or "download"
return valid_filename(candidate, 180)
def append_query(source_url: str, target_url: str) -> str:
source_query = parse_qsl(urlparse(source_url).query, keep_blank_values=True)
if not source_query or not target_url.startswith(("http://", "https://")):
return target_url
parsed = urlparse(target_url)
target_query = parse_qsl(parsed.query, keep_blank_values=True)
keys = {key for key, _ in target_query}
merged = target_query + [(key, value) for key, value in source_query if key not in keys]
return urlunparse(parsed._replace(query=urlencode(merged)))
def to_jsonable(value: object) -> object:
from dataclasses import asdict, is_dataclass
from enum import Enum
from datetime import datetime
if is_dataclass(value):
return {key: to_jsonable(item) for key, item in asdict(value).items()}
if isinstance(value, Enum):
return value.value
if isinstance(value, bytes):
return value.hex()
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, list):
return [to_jsonable(item) for item in value]
if isinstance(value, dict):
return {str(key): to_jsonable(item) for key, item in value.items()}
return value
def remove_file(path: Path) -> None:
try:
path.unlink()
except FileNotFoundError:
pass
def files_equal_path(left: str, right: str) -> bool:
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from pathlib import Path
import pytest
from n_m3u8dl_py import (
DownloadCancelledError,
DownloadEventKind,
DownloadRequest,
N_m3u8DL,
SelectionError,
TrackSelection,
)
def test_public_api_inspects_dash_with_stable_track_ids() -> None:
root = Path(__file__).parents[2]
manifest = root / "src/N_m3u8DL-RE.Tests/Resources/Dash/Manifest_DuplicateSegments.mpd"
info = N_m3u8DL().inspect(manifest)
assert info.protocol == "DASH"
assert info.tracks[0].track_id == "dash:0"
assert info.tracks[0].segments_count == 3
assert info.tracks[0].duration == 6
def test_api_downloads_hls_with_default_auto_selection_and_events(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")
events = []
client = N_m3u8DL()
info = client.inspect(manifest, on_event=events.append)
result = client.download(
info,
DownloadRequest(
output_dir=tmp_path / "output",
temporary_dir=tmp_path / "temp",
file_name="movie",
binary_merge=True,
),
on_event=events.append,
)
assert result.selected_track_ids == ("hls:0",)
assert result.files[0].path.read_bytes() == b"onetwo"
assert result.files[0].size == 6
assert [event.kind for event in events] == [
DownloadEventKind.INSPECT_STARTED,
DownloadEventKind.INSPECT_COMPLETED,
DownloadEventKind.DOWNLOAD_STARTED,
DownloadEventKind.PLAYLIST_LOADED,
DownloadEventKind.TRACK_STARTED,
DownloadEventKind.SEGMENT_COMPLETED,
DownloadEventKind.SEGMENT_COMPLETED,
DownloadEventKind.TRACK_COMPLETED,
DownloadEventKind.DOWNLOAD_COMPLETED,
]
segment_events = [event for event in events if event.kind is DownloadEventKind.SEGMENT_COMPLETED]
assert [(event.completed_segments, event.total_segments) for event in segment_events] == [(1, 2), (2, 2)]
def test_api_download_url_is_one_call_convenience(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"one")
manifest = tmp_path / "movie.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
result = N_m3u8DL().download_url(
manifest,
DownloadRequest(output_dir=tmp_path / "output", temporary_dir=tmp_path / "temp", file_name="one-call", binary_merge=True),
)
assert result.files[0].path.read_bytes() == b"one"
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")
(tmp_path / "video.m3u8").write_text("#EXTM3U\n#EXTINF:1,\nvideo.ts\n#EXT-X-ENDLIST\n")
(tmp_path / "audio.m3u8").write_text("#EXTM3U\n#EXTINF:1,\naudio.ts\n#EXT-X-ENDLIST\n")
manifest = tmp_path / "master.m3u8"
manifest.write_text(
"#EXTM3U\n"
'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",LANGUAGE="en",URI="audio.m3u8"\n'
'#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360,AUDIO="aud"\n'
"video.m3u8\n"
)
client = N_m3u8DL()
info = client.inspect(manifest)
video = next(track for track in info.tracks if track.resolution == "640x360")
result = client.download(
info,
DownloadRequest(
output_dir=tmp_path / "output",
temporary_dir=tmp_path / "temp",
file_name="video-only",
binary_merge=True,
selection=TrackSelection(track_ids=(video.track_id,)),
),
)
assert result.selected_track_ids == (video.track_id,)
assert len(result.files) == 1
assert result.files[0].path.read_bytes() == b"video"
def test_api_rejects_unknown_track_id(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"one")
manifest = tmp_path / "movie.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
client = N_m3u8DL()
info = client.inspect(manifest)
with pytest.raises(SelectionError, match="Unknown track ID"):
client.download(info, DownloadRequest(selection=TrackSelection(track_ids=("hls:404",))))
def test_api_event_callback_can_cancel_download(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"one")
manifest = tmp_path / "movie.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
events = []
def on_event(event):
events.append(event)
return False if event.kind is DownloadEventKind.TRACK_STARTED else None
client = N_m3u8DL()
info = client.inspect(manifest, on_event=on_event)
with pytest.raises(DownloadCancelledError):
client.download(
info,
DownloadRequest(output_dir=tmp_path / "output", temporary_dir=tmp_path / "temp", file_name="cancelled"),
on_event=on_event,
)
assert events[-1].kind is DownloadEventKind.CANCELLED
assert not (tmp_path / "output" / "cancelled.ts").exists()
+18
View File
@@ -0,0 +1,18 @@
from n_m3u8dl_py.cli import build_parser
def test_cli_accepts_compatibility_options() -> None:
args = build_parser().parse_args(
[
"input.m3u8",
"--urlprocessor-args",
"token=value",
"--no-date-info",
"--mp4-real-time-decryption",
"--live-fix-vtt-by-audio",
]
)
assert args.urlprocessor_args == "token=value"
assert args.no_date_info
assert args.mp4_real_time_decryption
assert args.live_fix_vtt_by_audio
+26
View File
@@ -0,0 +1,26 @@
from pathlib import Path
from n_m3u8dl_py.dash import DASHExtractor
ROOT = Path(__file__).parents[2]
def test_dash_resource_parses_expected_stream_shape() -> None:
path = ROOT / "src/N_m3u8DL-RE.Tests/Resources/Dash/Manifest_1080p.mpd"
streams = DASHExtractor(path.resolve().as_uri()).extract(path.read_text())
assert len(streams) == 23
first = streams[0]
assert first.bandwidth == 386437
assert first.extension == "m4s"
assert first.playlist is not None
assert first.playlist.total_duration == 736
assert first.playlist.media_init is not None
assert first.playlist.media_init.url.endswith("/1/init.mp4")
def test_dash_removes_duplicate_segment_urls() -> None:
path = ROOT / "src/N_m3u8DL-RE.Tests/Resources/Dash/Manifest_DuplicateSegments.mpd"
stream = DASHExtractor(path.resolve().as_uri()).extract(path.read_text())[0]
assert stream.playlist is not None
assert [segment.url.rsplit("/", 1)[-1] for segment in stream.playlist.segments] == ["seg-1.m4s", "seg-2.m4s", "seg-3.m4s"]
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import contextlib
import functools
import http.server
from pathlib import Path
import shutil
import subprocess
import threading
from n_m3u8dl_py.cli import main
from n_m3u8dl_py.hls import HLSExtractor
from n_m3u8dl_py.http import HttpClient
def test_hls_master_and_media_playlist(tmp_path: Path) -> None:
(tmp_path / "video.m3u8").write_text("#EXTM3U\n#EXTINF:2,\na.ts\n#EXT-X-ENDLIST\n")
master = """#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",LANGUAGE="en",URI="audio.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=1000,RESOLUTION=640x360,AUDIO="aud"
video.m3u8
"""
extractor = HLSExtractor((tmp_path / "master.m3u8").as_uri(), HttpClient())
streams = extractor.extract(master)
assert len(streams) == 2
video = next(stream for stream in streams if stream.resolution == "640x360")
extractor.fetch_playlists([video])
assert video.playlist is not None
assert video.playlist.segments[0].url.endswith("/a.ts")
def test_local_hls_download_and_binary_merge(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"first")
(tmp_path / "two.ts").write_bytes(b"second")
manifest = tmp_path / "test.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXTINF:1,\ntwo.ts\n#EXT-X-ENDLIST\n")
output = tmp_path / "out"
assert main([str(manifest), "--auto-select", "--binary-merge", "--save-name", "joined", "--save-dir", str(output), "--tmp-dir", str(tmp_path / "tmp")]) == 0
assert (output / "joined.ts").read_bytes() == b"firstsecond"
def test_local_hls_ffmpeg_merge_remuxes_ts_to_mp4(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"first")
manifest = tmp_path / "test.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
ffmpeg = tmp_path / "fake-ffmpeg"
ffmpeg.write_text("#!/bin/sh\nfor argument do output=$argument; done\nprintf remuxed > \"$output\"\n", encoding="utf-8")
ffmpeg.chmod(0o755)
output = tmp_path / "out"
assert main([
str(manifest),
"--auto-select",
"--ffmpeg-binary-path",
str(ffmpeg),
"--save-name",
"episode",
"--save-dir",
str(output),
"--tmp-dir",
str(tmp_path / "tmp"),
]) == 0
assert (output / "episode.mp4").read_bytes() == b"remuxed"
assert not (output / "episode.ts").exists()
def test_local_hls_save_pattern(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"first")
manifest = tmp_path / "test.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
output = tmp_path / "out"
assert main([str(manifest), "--auto-select", "--binary-merge", "--save-name", "episode", "--save-pattern", "<SaveName>_<MediaType>.<Ext>", "--save-dir", str(output), "--tmp-dir", str(tmp_path / "tmp")]) == 0
assert (output / "episode_video.ts").read_bytes() == b"first"
def test_local_hls_file_uri_decodes_spaces(tmp_path: Path) -> None:
source = tmp_path / "directory with spaces"
source.mkdir()
(source / "one.ts").write_bytes(b"space-safe")
manifest = source / "test.m3u8"
manifest.write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
output = tmp_path / "out"
assert main([str(manifest), "--auto-select", "--binary-merge", "--save-name", "spaced", "--save-dir", str(output), "--tmp-dir", str(tmp_path / "tmp")]) == 0
assert (output / "spaced.ts").read_bytes() == b"space-safe"
def test_http_hls_download(tmp_path: Path) -> None:
(tmp_path / "one.ts").write_bytes(b"served-over-http")
(tmp_path / "test.m3u8").write_text("#EXTM3U\n#EXTINF:1,\none.ts\n#EXT-X-ENDLIST\n")
output = tmp_path / "out"
with _http_server(tmp_path) as base_url:
assert main([f"{base_url}/test.m3u8", "--no-use-system-proxy", "--auto-select", "--binary-merge", "--save-name", "http", "--save-dir", str(output), "--tmp-dir", str(tmp_path / "tmp")]) == 0
assert (output / "http.ts").read_bytes() == b"served-over-http"
def test_local_hls_aes128_download(tmp_path: Path) -> None:
key = bytes(range(16))
iv = bytes(16)
plaintext = b"encrypted payload"
padding = 16 - len(plaintext) % 16
ciphertext = subprocess.run(
[shutil.which("openssl") or "openssl", "enc", "-aes-128-cbc", "-K", key.hex(), "-iv", iv.hex(), "-nopad"],
input=plaintext + bytes([padding]) * padding,
capture_output=True,
check=True,
).stdout
(tmp_path / "key.bin").write_bytes(key)
(tmp_path / "encrypted.ts").write_bytes(ciphertext)
manifest = tmp_path / "encrypted.m3u8"
manifest.write_text("#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\",IV=0x00000000000000000000000000000000\n#EXTINF:1,\nencrypted.ts\n#EXT-X-ENDLIST\n")
output = tmp_path / "out"
assert main([str(manifest), "--auto-select", "--binary-merge", "--save-name", "plain", "--save-dir", str(output), "--tmp-dir", str(tmp_path / "tmp")]) == 0
assert (output / "plain.ts").read_bytes() == plaintext
@contextlib.contextmanager
def _http_server(directory: Path):
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(directory))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
thread.join()
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import contextlib
import functools
import http.server
import socket
import threading
from pathlib import Path
from urllib.request import Request
from n_m3u8dl_py.http import HttpClient, _Socks5HTTPSHandler
def test_http_client_routes_requests_through_no_auth_socks5_proxy(tmp_path: Path) -> None:
(tmp_path / "payload.txt").write_text("proxied", encoding="utf-8")
with _http_server(tmp_path) as target_url, _socks5_proxy() as (proxy_url, address_types):
target_url = target_url.replace("127.0.0.1", "localhost")
data, resolved, _ = HttpClient(proxy=proxy_url, use_system_proxy=False).get_bytes(f"{target_url}/payload.txt")
assert data == b"proxied"
assert resolved.endswith("/payload.txt")
assert address_types == [3]
def test_socks5_https_handler_uses_the_standard_library_context_contract() -> None:
handler = _Socks5HTTPSHandler("127.0.0.1", 1080)
captured: dict[str, object] = {}
def do_open(http_class, request, **kwargs):
captured.update(kwargs)
return "opened"
handler.do_open = do_open # type: ignore[method-assign]
assert handler.https_open(Request("https://example.test/video.m3u8")) == "opened"
assert captured == {"context": handler._context}
@contextlib.contextmanager
def _http_server(directory: Path):
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(directory))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
server.server_close()
thread.join()
@contextlib.contextmanager
def _socks5_proxy():
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen()
listener.settimeout(0.2)
stop = threading.Event()
workers: list[threading.Thread] = []
address_types: list[int] = []
def serve() -> None:
while not stop.is_set():
try:
client, _ = listener.accept()
except TimeoutError:
continue
except OSError:
if stop.is_set():
return
raise
worker = threading.Thread(target=_handle_client, args=(client, address_types), daemon=True)
workers.append(worker)
worker.start()
thread = threading.Thread(target=serve, daemon=True)
thread.start()
try:
yield f"socks5://127.0.0.1:{listener.getsockname()[1]}", address_types
finally:
stop.set()
listener.close()
thread.join()
for worker in workers:
worker.join()
def _handle_client(client: socket.socket, address_types: list[int]) -> None:
with client:
assert _receive_exact(client, 2) == b"\x05\x01"
assert _receive_exact(client, 1) == b"\x00"
client.sendall(b"\x05\x00")
header = _receive_exact(client, 4)
assert header[:3] == b"\x05\x01\x00"
address_types.append(header[3])
if header[3] == 1:
host = socket.inet_ntoa(_receive_exact(client, 4))
elif header[3] == 3:
host = _receive_exact(client, _receive_exact(client, 1)[0]).decode("idna")
else:
raise AssertionError("Unexpected SOCKS address type")
port = int.from_bytes(_receive_exact(client, 2), "big")
with socket.create_connection((host, port)) as upstream:
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
left = threading.Thread(target=_copy, args=(client, upstream), daemon=True)
right = threading.Thread(target=_copy, args=(upstream, client), daemon=True)
left.start()
right.start()
left.join()
right.join()
def _copy(source: socket.socket, target: socket.socket) -> None:
try:
while data := source.recv(64 * 1024):
target.sendall(data)
except OSError:
pass
finally:
try:
target.shutdown(socket.SHUT_WR)
except OSError:
pass
def _receive_exact(connection: socket.socket, count: int) -> bytes:
chunks: list[bytes] = []
while count:
chunk = connection.recv(count)
if not chunk:
raise OSError("Socket closed")
chunks.append(chunk)
count -= len(chunk)
return b"".join(chunks)
+22
View File
@@ -0,0 +1,22 @@
from n_m3u8dl_py.utils import replace_vars, truncate_filename, valid_filename
def test_replace_vars_supports_dash_number_formats() -> None:
values = {"RepresentationID": "audio", "Number": 28324}
assert replace_vars("$RepresentationID$/$Number%08x$/$Number%05d$/$Number%08X$", values) == "audio/00006ea4/28324/00006EA4"
def test_replace_vars_keeps_unknown_values() -> None:
assert replace_vars("$Number%08x$", {}) == "$Number%08x$"
def test_filename_truncation_is_stable_and_unicode_safe() -> None:
value = "中" * 100
result = truncate_filename(value, 50)
assert len(result.encode("utf-8")) <= 50
assert "\ufffd" not in result
assert valid_filename("a:b*c") == "a_b_c"
def test_replace_vars_supports_upper_and_lower_hex() -> None:
assert replace_vars("$Number%08x$/$Number%08X$", {"Number": 22999}) == "000059d7/000059D7"