hlsfetch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hlsfetch/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """hlsfetch: download HLS (m3u8) streams from Python."""
2
+
3
+ from .downloader import Progress, adownload, download, fetch_playlist, select_variant
4
+ from .errors import DownloadError, HLSFetchError, PlaylistError, UnsupportedError
5
+ from .playlist import (
6
+ InitSection,
7
+ Key,
8
+ MasterPlaylist,
9
+ MediaPlaylist,
10
+ Segment,
11
+ Variant,
12
+ parse_playlist,
13
+ )
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "download",
19
+ "adownload",
20
+ "fetch_playlist",
21
+ "select_variant",
22
+ "parse_playlist",
23
+ "Progress",
24
+ "MasterPlaylist",
25
+ "MediaPlaylist",
26
+ "Variant",
27
+ "Segment",
28
+ "Key",
29
+ "InitSection",
30
+ "HLSFetchError",
31
+ "PlaylistError",
32
+ "UnsupportedError",
33
+ "DownloadError",
34
+ ]
hlsfetch/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
hlsfetch/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """Command-line interface: ``hlsfetch URL -o out.mp4``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ import warnings
9
+ from pathlib import PurePosixPath
10
+ from urllib.parse import urlparse
11
+
12
+ from . import __version__
13
+ from .downloader import Progress, _make_client, adownload, fetch_playlist
14
+ from .errors import HLSFetchError
15
+ from .playlist import MasterPlaylist, parse_playlist
16
+
17
+ _GENERIC = {"index", "master", "playlist", "prog_index", "chunklist", "manifest", "video", "stream", "main"}
18
+
19
+
20
+ def _default_output(url: str) -> str:
21
+ parts = [p for p in PurePosixPath(urlparse(url).path).parts if p != "/"]
22
+ for p in reversed(parts):
23
+ stem = PurePosixPath(p).stem
24
+ if stem and stem.lower() not in _GENERIC and not stem.lower().startswith(("index", "chunklist")):
25
+ return stem + ".mp4"
26
+ return "video.mp4"
27
+
28
+
29
+ def _fmt_size(n: float) -> str:
30
+ for unit in ("B", "KB", "MB", "GB"):
31
+ if n < 1024 or unit == "GB":
32
+ return f"{n:.1f} {unit}"
33
+ n /= 1024
34
+ return f"{n:.1f} GB"
35
+
36
+
37
+ def _print_progress(p: Progress) -> None:
38
+ bar = int(p.fraction * 30)
39
+ sys.stderr.write(
40
+ f"\r[{'#' * bar}{'.' * (30 - bar)}] {p.done}/{p.total} "
41
+ f"{p.fraction * 100:5.1f}% {_fmt_size(p.bytes)} {_fmt_size(p.speed)}/s "
42
+ )
43
+ sys.stderr.flush()
44
+
45
+
46
+ def _parse_headers(items, referer):
47
+ headers = {}
48
+ for h in items or []:
49
+ if ":" not in h:
50
+ raise SystemExit(f"invalid header (expected 'Name: value'): {h}")
51
+ k, v = h.split(":", 1)
52
+ headers[k.strip()] = v.strip()
53
+ if referer:
54
+ headers["Referer"] = referer
55
+ headers.setdefault("Origin", "{0.scheme}://{0.netloc}".format(urlparse(referer)))
56
+ return headers
57
+
58
+
59
+ async def _info(url, headers, proxy, timeout) -> None:
60
+ async with _make_client(headers, proxy, timeout) as client:
61
+ r = await client.get(url)
62
+ r.raise_for_status()
63
+ pl = parse_playlist(r.text, str(r.url))
64
+ if isinstance(pl, MasterPlaylist):
65
+ print(f"Master playlist with {len(pl.variants)} variants:")
66
+ for v in sorted(pl.variants, key=lambda v: v.bandwidth, reverse=True):
67
+ print(f" {v} {v.codecs or ''}")
68
+ media, variant = await fetch_playlist(url, client)
69
+ if variant:
70
+ print(f"\nBest variant: {variant}")
71
+ kind = "fMP4" if media.is_fmp4 else "MPEG-TS"
72
+ enc = sorted({s.key.method for s in media.segments if s.key}) or ["none"]
73
+ m, s = divmod(int(media.duration), 60)
74
+ print(f"Segments: {len(media.segments)} Duration: {m // 60}:{m % 60:02d}:{s:02d} "
75
+ f"Container: {kind} Encryption: {', '.join(enc)} "
76
+ f"{'VOD' if media.endlist else 'LIVE'}")
77
+
78
+
79
+ def main(argv=None) -> int:
80
+ ap = argparse.ArgumentParser(prog="hlsfetch", description="Download HLS (m3u8) streams.")
81
+ ap.add_argument("url", help="URL of a master or media .m3u8 playlist")
82
+ ap.add_argument("-o", "--output", help="output file (default: derived from URL, .mp4)")
83
+ ap.add_argument("-q", "--quality", default="best",
84
+ help="best, worst, or max height like 720 (default: best)")
85
+ ap.add_argument("-H", "--header", action="append", metavar="'NAME: VALUE'",
86
+ help="extra HTTP header, repeatable")
87
+ ap.add_argument("-r", "--referer", help="set Referer (and Origin) header")
88
+ ap.add_argument("-j", "--concurrency", type=int, default=8, help="parallel segment downloads (default: 8)")
89
+ ap.add_argument("--retries", type=int, default=5, help="retries per request (default: 5)")
90
+ ap.add_argument("--timeout", type=float, default=30.0, help="request timeout in seconds (default: 30)")
91
+ ap.add_argument("--proxy", help="proxy URL, e.g. http://127.0.0.1:8080 or socks5://...")
92
+ ap.add_argument("--no-remux", action="store_true", help="never call ffmpeg; write the raw stream")
93
+ ap.add_argument("--keep-parts", action="store_true", help="keep the downloaded segment folder")
94
+ ap.add_argument("-i", "--info", action="store_true", help="show stream info and exit")
95
+ ap.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
96
+ args = ap.parse_args(argv)
97
+
98
+ headers = _parse_headers(args.header, args.referer)
99
+ quality = int(args.quality.rstrip("p")) if args.quality.rstrip("p").isdigit() else args.quality
100
+
101
+ try:
102
+ if args.info:
103
+ asyncio.run(_info(args.url, headers, args.proxy, args.timeout))
104
+ return 0
105
+ output = args.output or _default_output(args.url)
106
+ with warnings.catch_warnings(record=True) as caught:
107
+ warnings.simplefilter("always")
108
+ path = asyncio.run(adownload(
109
+ args.url, output,
110
+ quality=quality, headers=headers, concurrency=args.concurrency,
111
+ retries=args.retries, timeout=args.timeout, proxy=args.proxy,
112
+ remux=not args.no_remux, keep_parts=args.keep_parts, progress=_print_progress,
113
+ ))
114
+ sys.stderr.write("\n")
115
+ for w in caught:
116
+ sys.stderr.write(f"warning: {w.message}\n")
117
+ print(f"Saved to {path}")
118
+ return 0
119
+ except KeyboardInterrupt:
120
+ sys.stderr.write("\nInterrupted. Run the same command again to resume.\n")
121
+ return 130
122
+ except (HLSFetchError, ValueError) as e:
123
+ sys.stderr.write(f"\nerror: {e}\n")
124
+ return 1
125
+ except Exception as e: # httpx errors from --info, etc.
126
+ sys.stderr.write(f"\nerror: {type(e).__name__}: {e}\n")
127
+ return 1
128
+
129
+
130
+ if __name__ == "__main__":
131
+ raise SystemExit(main())
hlsfetch/downloader.py ADDED
@@ -0,0 +1,288 @@
1
+ """Async HLS downloader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import random
8
+ import shutil
9
+ import subprocess
10
+ import time
11
+ import warnings
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Awaitable, Callable, Mapping, Optional, Union
15
+
16
+ import httpx
17
+ from cryptography.hazmat.primitives import padding
18
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
19
+
20
+ from .errors import DownloadError, PlaylistError, UnsupportedError
21
+ from .playlist import InitSection, MasterPlaylist, MediaPlaylist, Segment, Variant, parse_playlist
22
+
23
+ Quality = Union[str, int, Callable[[list], Variant]]
24
+
25
+ DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
26
+ _RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
27
+
28
+
29
+ @dataclass
30
+ class Progress:
31
+ done: int
32
+ total: int
33
+ bytes: int
34
+ elapsed: float
35
+
36
+ @property
37
+ def fraction(self) -> float:
38
+ return self.done / self.total if self.total else 1.0
39
+
40
+ @property
41
+ def speed(self) -> float:
42
+ """Average bytes per second since the download started."""
43
+ return self.bytes / self.elapsed if self.elapsed > 0 else 0.0
44
+
45
+
46
+ ProgressCallback = Callable[[Progress], Union[None, Awaitable[None]]]
47
+
48
+
49
+ def select_variant(variants: list, quality: Quality = "best") -> Variant:
50
+ """Pick a variant: ``"best"``, ``"worst"``, a max height (``720``) or a callable."""
51
+ if callable(quality):
52
+ return quality(list(variants))
53
+ by_bw = sorted(variants, key=lambda v: v.bandwidth)
54
+ if quality == "best":
55
+ return by_bw[-1]
56
+ if quality == "worst":
57
+ return by_bw[0]
58
+ try:
59
+ max_h = int(str(quality).rstrip("p"))
60
+ except ValueError:
61
+ raise ValueError(f"invalid quality: {quality!r}") from None
62
+ fitting = [v for v in by_bw if v.height is not None and v.height <= max_h]
63
+ return fitting[-1] if fitting else by_bw[0]
64
+
65
+
66
+ def _decrypt(data: bytes, key: bytes, iv: bytes) -> bytes:
67
+ dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
68
+ out = dec.update(data) + dec.finalize()
69
+ try:
70
+ unpadder = padding.PKCS7(128).unpadder()
71
+ return unpadder.update(out) + unpadder.finalize()
72
+ except ValueError:
73
+ return out # some servers don't pad; keep the raw plaintext
74
+
75
+
76
+ class _Fetcher:
77
+ def __init__(self, client: httpx.AsyncClient, retries: int):
78
+ self.client = client
79
+ self.retries = retries
80
+ self.keys: dict = {}
81
+
82
+ async def get(self, url: str, byterange=None) -> bytes:
83
+ headers = {}
84
+ if byterange:
85
+ off, length = byterange
86
+ headers["Range"] = f"bytes={off}-{off + length - 1}"
87
+ last_exc: Exception = DownloadError(url)
88
+ for attempt in range(self.retries + 1):
89
+ try:
90
+ r = await self.client.get(url, headers=headers)
91
+ if r.status_code in _RETRY_STATUS:
92
+ last_exc = DownloadError(f"HTTP {r.status_code} for {url}")
93
+ elif r.status_code >= 400:
94
+ raise DownloadError(f"HTTP {r.status_code} for {url}")
95
+ else:
96
+ data = r.content
97
+ if byterange and r.status_code == 200: # server ignored Range
98
+ data = data[byterange[0]:byterange[0] + byterange[1]]
99
+ return data
100
+ except httpx.HTTPError as e:
101
+ last_exc = DownloadError(f"{type(e).__name__} for {url}: {e}")
102
+ if attempt < self.retries:
103
+ await asyncio.sleep(min(2 ** attempt, 30) + random.random())
104
+ raise last_exc
105
+
106
+ async def key(self, uri: str) -> bytes:
107
+ if uri not in self.keys:
108
+ data = await self.get(uri)
109
+ if len(data) != 16:
110
+ raise DownloadError(f"AES-128 key at {uri} is {len(data)} bytes, expected 16")
111
+ self.keys[uri] = data
112
+ return self.keys[uri]
113
+
114
+ async def payload(self, uri, byterange, key, iv) -> bytes:
115
+ data = await self.get(uri, byterange)
116
+ if key is not None:
117
+ data = _decrypt(data, await self.key(key.uri), iv)
118
+ return data
119
+
120
+
121
+ def _check_supported(pl: MediaPlaylist) -> None:
122
+ if not pl.endlist:
123
+ raise UnsupportedError("live streams are not supported (playlist has no #EXT-X-ENDLIST)")
124
+ if not pl.segments:
125
+ raise PlaylistError("media playlist has no segments")
126
+ for seg in pl.segments:
127
+ for k in (seg.key, seg.init.key if seg.init else None):
128
+ if k and k.method != "AES-128":
129
+ raise UnsupportedError(f"encryption method {k.method} is not supported")
130
+ if k and not k.uri:
131
+ raise PlaylistError("EXT-X-KEY without URI")
132
+
133
+
134
+ async def fetch_playlist(url: str, client: httpx.AsyncClient, quality: Quality = "best"):
135
+ """Fetch ``url`` and resolve it to a media playlist. Returns (media, variant_or_None)."""
136
+ fetcher = _Fetcher(client, retries=3)
137
+ pl = parse_playlist((await fetcher.get(url)).decode("utf-8", "replace"), url)
138
+ if isinstance(pl, MasterPlaylist):
139
+ variant = select_variant(pl.variants, quality)
140
+ media = parse_playlist((await fetcher.get(variant.uri)).decode("utf-8", "replace"), variant.uri)
141
+ if isinstance(media, MasterPlaylist):
142
+ raise PlaylistError("variant URI points to another master playlist")
143
+ return media, variant
144
+ return pl, None
145
+
146
+
147
+ def _make_client(headers, proxy, timeout) -> httpx.AsyncClient:
148
+ h = {"User-Agent": DEFAULT_UA}
149
+ h.update(headers or {})
150
+ return httpx.AsyncClient(headers=h, proxy=proxy, timeout=timeout, follow_redirects=True,
151
+ limits=httpx.Limits(max_connections=64))
152
+
153
+
154
+ async def adownload(
155
+ url: str,
156
+ output: Union[str, os.PathLike],
157
+ *,
158
+ quality: Quality = "best",
159
+ headers: Optional[Mapping[str, str]] = None,
160
+ concurrency: int = 8,
161
+ retries: int = 5,
162
+ timeout: float = 30.0,
163
+ proxy: Optional[str] = None,
164
+ remux: bool = True,
165
+ keep_parts: bool = False,
166
+ progress: Optional[ProgressCallback] = None,
167
+ client: Optional[httpx.AsyncClient] = None,
168
+ ) -> Path:
169
+ """Download the HLS stream at ``url`` into ``output`` and return the final path.
170
+
171
+ Segments are stored in ``<output>.parts/`` while downloading, so an interrupted
172
+ run picks up where it left off. If ``output`` ends in a different container
173
+ than the stream (e.g. ``.mp4`` for an MPEG-TS stream) and ``remux`` is true,
174
+ ffmpeg is used to remux without re-encoding; without ffmpeg the raw stream is
175
+ saved next to ``output`` with its native extension.
176
+ """
177
+ output = Path(output)
178
+ own_client = client is None
179
+ client = client or _make_client(headers, proxy, timeout)
180
+ try:
181
+ media, _ = await fetch_playlist(url, client, quality)
182
+ _check_supported(media)
183
+ fetcher = _Fetcher(client, retries)
184
+
185
+ parts = output.with_name(output.name + ".parts")
186
+ parts.mkdir(parents=True, exist_ok=True)
187
+
188
+ inits: dict = {} # InitSection -> path
189
+ for seg in media.segments:
190
+ if seg.init and seg.init not in inits:
191
+ inits[seg.init] = parts / f"init{len(inits)}.mp4"
192
+ for init, path in inits.items():
193
+ if not path.exists():
194
+ iv = (init.key.iv or b"\0" * 16) if init.key else None
195
+ path.write_bytes(await fetcher.payload(init.uri, init.byterange, init.key, iv))
196
+
197
+ ext = ".m4s" if media.is_fmp4 else ".ts"
198
+ seg_path = lambda s: parts / f"{s.index:06d}{ext}" # noqa: E731
199
+
200
+ total = len(media.segments)
201
+ state = {"done": 0, "bytes": 0}
202
+ start = time.monotonic()
203
+
204
+ async def report():
205
+ if progress:
206
+ r = progress(Progress(state["done"], total, state["bytes"], time.monotonic() - start))
207
+ if asyncio.iscoroutine(r):
208
+ await r
209
+
210
+ for seg in media.segments:
211
+ p = seg_path(seg)
212
+ if p.exists():
213
+ state["done"] += 1
214
+ state["bytes"] += p.stat().st_size
215
+ await report()
216
+
217
+ sem = asyncio.Semaphore(max(1, concurrency))
218
+
219
+ async def worker(seg: Segment):
220
+ p = seg_path(seg)
221
+ if p.exists():
222
+ return
223
+ async with sem:
224
+ data = await fetcher.payload(seg.uri, seg.byterange, seg.key, seg.iv)
225
+ tmp = p.with_suffix(p.suffix + ".tmp")
226
+ tmp.write_bytes(data)
227
+ os.replace(tmp, p)
228
+ state["done"] += 1
229
+ state["bytes"] += len(data)
230
+ await report()
231
+
232
+ tasks = [asyncio.ensure_future(worker(s)) for s in media.segments]
233
+ try:
234
+ await asyncio.gather(*tasks)
235
+ except BaseException:
236
+ for t in tasks:
237
+ t.cancel()
238
+ await asyncio.gather(*tasks, return_exceptions=True)
239
+ raise
240
+
241
+ final = _merge(media, inits, seg_path, output, remux)
242
+ if not keep_parts:
243
+ shutil.rmtree(parts, ignore_errors=True)
244
+ return final
245
+ finally:
246
+ if own_client:
247
+ await client.aclose()
248
+
249
+
250
+ def _merge(media: MediaPlaylist, inits: dict, seg_path, output: Path, remux: bool) -> Path:
251
+ native = ".mp4" if media.is_fmp4 else ".ts"
252
+ need_remux = remux and output.suffix.lower() != native
253
+ ffmpeg = shutil.which("ffmpeg") if need_remux else None
254
+ if need_remux and not ffmpeg:
255
+ warnings.warn(f"ffmpeg not found; saving raw stream as {native} instead of remuxing",
256
+ RuntimeWarning, stacklevel=3)
257
+ output = output.with_suffix(native)
258
+ need_remux = False
259
+
260
+ raw = output.with_name(output.stem + ".raw" + native) if need_remux else output
261
+ current_init: Optional[InitSection] = None
262
+ with open(raw, "wb") as out:
263
+ for seg in media.segments:
264
+ if seg.init is not None and seg.init != current_init:
265
+ out.write(inits[seg.init].read_bytes())
266
+ current_init = seg.init
267
+ with open(seg_path(seg), "rb") as f:
268
+ shutil.copyfileobj(f, out)
269
+
270
+ if need_remux:
271
+ cmd = [ffmpeg, "-y", "-loglevel", "error", "-i", str(raw), "-c", "copy"]
272
+ if native == ".ts" and output.suffix.lower() in (".mp4", ".m4v", ".mov"):
273
+ cmd += ["-bsf:a", "aac_adtstoasc"]
274
+ cmd.append(str(output))
275
+ res = subprocess.run(cmd, capture_output=True, text=True)
276
+ if res.returncode != 0:
277
+ raise DownloadError(f"ffmpeg remux failed, raw stream kept at {raw}: {res.stderr.strip()}")
278
+ raw.unlink()
279
+ return output
280
+
281
+
282
+ def download(url: str, output: Union[str, os.PathLike], **kwargs) -> Path:
283
+ """Blocking wrapper around :func:`adownload`. Takes the same keyword arguments."""
284
+ try:
285
+ asyncio.get_running_loop()
286
+ except RuntimeError:
287
+ return asyncio.run(adownload(url, output, **kwargs))
288
+ raise RuntimeError("download() can't run inside an event loop; use `await adownload(...)`")
hlsfetch/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ class HLSFetchError(Exception):
2
+ """Base class for all hlsfetch errors."""
3
+
4
+
5
+ class PlaylistError(HLSFetchError):
6
+ """The playlist could not be parsed or is not usable."""
7
+
8
+
9
+ class UnsupportedError(HLSFetchError):
10
+ """The stream uses a feature hlsfetch does not support (live, SAMPLE-AES, ...)."""
11
+
12
+
13
+ class DownloadError(HLSFetchError):
14
+ """A segment, key or playlist could not be downloaded."""
hlsfetch/playlist.py ADDED
@@ -0,0 +1,198 @@
1
+ """Minimal M3U8 parser covering what a VOD downloader needs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, Tuple, Union
8
+ from urllib.parse import urljoin
9
+
10
+ from .errors import PlaylistError
11
+
12
+ _ATTR_RE = re.compile(r'([A-Z0-9-]+)=("[^"]*"|[^,]*)')
13
+
14
+
15
+ def _attrs(s: str) -> dict:
16
+ return {k: v.strip('"') for k, v in _ATTR_RE.findall(s)}
17
+
18
+
19
+ def _byterange(value: str, prev_end: Optional[int]) -> Tuple[int, int]:
20
+ """Return (offset, length) for a BYTERANGE value like ``1000@200``."""
21
+ length, _, offset = value.partition("@")
22
+ if offset:
23
+ return int(offset), int(length)
24
+ if prev_end is None:
25
+ raise PlaylistError(f"BYTERANGE without offset and no previous range: {value}")
26
+ return prev_end, int(length)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Key:
31
+ method: str
32
+ uri: Optional[str]
33
+ iv: Optional[bytes]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class InitSection:
38
+ uri: str
39
+ byterange: Optional[Tuple[int, int]] = None
40
+ key: Optional[Key] = None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Segment:
45
+ index: int
46
+ uri: str
47
+ duration: float
48
+ sequence: int
49
+ key: Optional[Key] = None
50
+ byterange: Optional[Tuple[int, int]] = None
51
+ init: Optional[InitSection] = None
52
+
53
+ @property
54
+ def iv(self) -> Optional[bytes]:
55
+ if self.key is None:
56
+ return None
57
+ return self.key.iv or self.sequence.to_bytes(16, "big")
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Variant:
62
+ uri: str
63
+ bandwidth: int
64
+ resolution: Optional[Tuple[int, int]] = None
65
+ codecs: Optional[str] = None
66
+ frame_rate: Optional[float] = None
67
+
68
+ @property
69
+ def height(self) -> Optional[int]:
70
+ return self.resolution[1] if self.resolution else None
71
+
72
+ def __str__(self) -> str:
73
+ res = f"{self.resolution[0]}x{self.resolution[1]}" if self.resolution else "audio/unknown"
74
+ return f"{res} @ {self.bandwidth / 1000:.0f} kbps"
75
+
76
+
77
+ @dataclass
78
+ class MasterPlaylist:
79
+ url: str
80
+ variants: list = field(default_factory=list)
81
+
82
+
83
+ @dataclass
84
+ class MediaPlaylist:
85
+ url: str
86
+ segments: list = field(default_factory=list)
87
+ target_duration: float = 0.0
88
+ endlist: bool = False
89
+
90
+ @property
91
+ def duration(self) -> float:
92
+ return sum(s.duration for s in self.segments)
93
+
94
+ @property
95
+ def is_fmp4(self) -> bool:
96
+ return any(s.init is not None for s in self.segments)
97
+
98
+
99
+ Playlist = Union[MasterPlaylist, MediaPlaylist]
100
+
101
+
102
+ def parse_playlist(text: str, url: str) -> Playlist:
103
+ """Parse M3U8 text fetched from ``url`` (used to resolve relative URIs)."""
104
+ lines = [ln.strip() for ln in text.lstrip("").splitlines()]
105
+ lines = [ln for ln in lines if ln]
106
+ if not lines or not lines[0].startswith("#EXTM3U"):
107
+ raise PlaylistError("not an M3U8 playlist (missing #EXTM3U)")
108
+
109
+ if any(ln.startswith("#EXT-X-STREAM-INF") for ln in lines):
110
+ return _parse_master(lines, url)
111
+ return _parse_media(lines, url)
112
+
113
+
114
+ def _parse_master(lines: list, url: str) -> MasterPlaylist:
115
+ master = MasterPlaylist(url=url)
116
+ pending = None
117
+ for ln in lines:
118
+ if ln.startswith("#EXT-X-STREAM-INF:"):
119
+ pending = _attrs(ln.split(":", 1)[1])
120
+ elif ln.startswith("#"):
121
+ continue
122
+ elif pending is not None:
123
+ res = None
124
+ if "RESOLUTION" in pending:
125
+ w, _, h = pending["RESOLUTION"].lower().partition("x")
126
+ res = (int(w), int(h))
127
+ master.variants.append(Variant(
128
+ uri=urljoin(url, ln),
129
+ bandwidth=int(pending.get("BANDWIDTH", 0)),
130
+ resolution=res,
131
+ codecs=pending.get("CODECS"),
132
+ frame_rate=float(pending["FRAME-RATE"]) if "FRAME-RATE" in pending else None,
133
+ ))
134
+ pending = None
135
+ if not master.variants:
136
+ raise PlaylistError("master playlist has no variants")
137
+ return master
138
+
139
+
140
+ def _parse_key(value: str, url: str) -> Optional[Key]:
141
+ a = _attrs(value)
142
+ method = a.get("METHOD", "NONE").upper()
143
+ if method == "NONE":
144
+ return None
145
+ iv = None
146
+ if "IV" in a:
147
+ hexiv = a["IV"][2:] if a["IV"].lower().startswith("0x") else a["IV"]
148
+ iv = bytes.fromhex(hexiv.rjust(32, "0"))
149
+ return Key(method=method, uri=urljoin(url, a["URI"]) if "URI" in a else None, iv=iv)
150
+
151
+
152
+ def _parse_media(lines: list, url: str) -> MediaPlaylist:
153
+ pl = MediaPlaylist(url=url)
154
+ seq = 0
155
+ key: Optional[Key] = None
156
+ init: Optional[InitSection] = None
157
+ duration: Optional[float] = None
158
+ byterange: Optional[Tuple[int, int]] = None
159
+ range_end: dict = {} # uri -> end offset of the last byterange, for implicit offsets
160
+
161
+ for ln in lines:
162
+ if ln.startswith("#EXT-X-MEDIA-SEQUENCE:"):
163
+ seq = int(ln.split(":", 1)[1])
164
+ elif ln.startswith("#EXT-X-TARGETDURATION:"):
165
+ pl.target_duration = float(ln.split(":", 1)[1])
166
+ elif ln.startswith("#EXT-X-ENDLIST"):
167
+ pl.endlist = True
168
+ elif ln.startswith("#EXT-X-KEY:"):
169
+ key = _parse_key(ln.split(":", 1)[1], url)
170
+ elif ln.startswith("#EXT-X-MAP:"):
171
+ a = _attrs(ln.split(":", 1)[1])
172
+ br = _byterange(a["BYTERANGE"], 0) if "BYTERANGE" in a else None
173
+ init = InitSection(uri=urljoin(url, a["URI"]), byterange=br, key=key)
174
+ elif ln.startswith("#EXTINF:"):
175
+ duration = float(ln.split(":", 1)[1].split(",", 1)[0] or 0)
176
+ elif ln.startswith("#EXT-X-BYTERANGE:"):
177
+ byterange = ln.split(":", 1)[1]
178
+ elif ln.startswith("#"):
179
+ continue
180
+ else:
181
+ uri = urljoin(url, ln)
182
+ br = None
183
+ if byterange is not None:
184
+ br = _byterange(byterange, range_end.get(uri))
185
+ range_end[uri] = br[0] + br[1]
186
+ pl.segments.append(Segment(
187
+ index=len(pl.segments),
188
+ uri=uri,
189
+ duration=duration or 0.0,
190
+ sequence=seq,
191
+ key=key,
192
+ byterange=br,
193
+ init=init,
194
+ ))
195
+ seq += 1
196
+ duration = None
197
+ byterange = None
198
+ return pl
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: hlsfetch
3
+ Version: 0.1.0
4
+ Summary: Fast async HLS (m3u8) downloader for Python: resume, AES-128, fMP4, ffmpeg remux.
5
+ Project-URL: Homepage, https://github.com/doumamay/hlsfetch
6
+ Project-URL: Issues, https://github.com/doumamay/hlsfetch/issues
7
+ Author: doumamay
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: aes-128,asyncio,downloader,hls,m3u8,streaming,video
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: Multimedia :: Video
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: cryptography>=41
21
+ Requires-Dist: httpx>=0.26
22
+ Provides-Extra: socks
23
+ Requires-Dist: httpx[socks]>=0.26; extra == 'socks'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # hlsfetch
27
+
28
+ Fast, async HLS (`.m3u8`) downloader for Python, usable as a library or from the command line.
29
+
30
+ - Parallel segment downloads on `asyncio` + `httpx`
31
+ - Resumes interrupted downloads: run the same call again and finished segments are skipped
32
+ - AES-128 decryption (key rotation and explicit or implicit IVs are handled)
33
+ - MPEG-TS and fMP4 (`#EXT-X-MAP`) streams, plus `#EXT-X-BYTERANGE`
34
+ - Quality selection: `best`, `worst`, max height, or your own function
35
+ - Custom headers, Referer, proxy (HTTP or SOCKS)
36
+ - Optional remux to `.mp4` through ffmpeg (stream copy, no re-encoding)
37
+ - Only two dependencies: `httpx` and `cryptography`
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install hlsfetch
43
+ ```
44
+
45
+ For SOCKS proxies: `pip install "hlsfetch[socks]"`. To get `.mp4` output from MPEG-TS streams, install [ffmpeg](https://ffmpeg.org/) and put it on your `PATH`. Without ffmpeg the raw `.ts` file is saved.
46
+
47
+ ## Command line
48
+
49
+ ```bash
50
+ hlsfetch "https://example.com/video/master.m3u8" -o movie.mp4
51
+ ```
52
+
53
+ ```text
54
+ hlsfetch URL [-o OUTPUT] [-q best|worst|720] [-H 'Name: value']... [-r REFERER]
55
+ [-j CONCURRENCY] [--retries N] [--timeout S] [--proxy URL]
56
+ [--no-remux] [--keep-parts] [-i/--info]
57
+ ```
58
+
59
+ `hlsfetch URL --info` lists the variants, segment count, duration and encryption without downloading anything.
60
+
61
+ If a download is interrupted, run the same command again to resume it.
62
+
63
+ ## Library
64
+
65
+ ```python
66
+ import hlsfetch
67
+
68
+ path = hlsfetch.download(
69
+ "https://example.com/video/master.m3u8",
70
+ "movie.mp4",
71
+ quality=720, # "best", "worst", 720, or a callable
72
+ headers={"Referer": "https://example.com/"},
73
+ concurrency=16,
74
+ )
75
+ print(path)
76
+ ```
77
+
78
+ Async, with a progress callback:
79
+
80
+ ```python
81
+ import asyncio
82
+ import hlsfetch
83
+
84
+ def on_progress(p: hlsfetch.Progress):
85
+ print(f"{p.done}/{p.total} segments, {p.bytes / 1e6:.1f} MB, {p.speed / 1e6:.1f} MB/s")
86
+
87
+ async def main():
88
+ await hlsfetch.adownload(url, "movie.mp4", progress=on_progress)
89
+
90
+ asyncio.run(main())
91
+ ```
92
+
93
+ The callback may also be an `async def`. You can pass your own `httpx.AsyncClient` through `client=` to share cookies or connection pools.
94
+
95
+ Parse a playlist yourself:
96
+
97
+ ```python
98
+ pl = hlsfetch.parse_playlist(text, url)
99
+ if isinstance(pl, hlsfetch.MasterPlaylist):
100
+ for v in pl.variants:
101
+ print(v.resolution, v.bandwidth, v.uri)
102
+ else:
103
+ print(len(pl.segments), "segments,", pl.duration, "seconds")
104
+ ```
105
+
106
+ ### `download()` / `adownload()` options
107
+
108
+ | Option | Default | Meaning |
109
+ |---|---|---|
110
+ | `quality` | `"best"` | `"best"`, `"worst"`, max height (`720`), or `callable(variants) -> Variant` |
111
+ | `headers` | `None` | Extra HTTP headers |
112
+ | `concurrency` | `8` | Segments downloaded at once |
113
+ | `retries` | `5` | Retries per request (network errors, 408/429/5xx) |
114
+ | `timeout` | `30.0` | Request timeout in seconds |
115
+ | `proxy` | `None` | Proxy URL |
116
+ | `remux` | `True` | Use ffmpeg when the output extension differs from the stream's container |
117
+ | `keep_parts` | `False` | Keep the `<output>.parts/` folder after merging |
118
+ | `progress` | `None` | Called with a `Progress` after each segment |
119
+ | `client` | `None` | Your own `httpx.AsyncClient` (it is not closed for you) |
120
+
121
+ Errors are raised as subclasses of `hlsfetch.HLSFetchError`: `PlaylistError`, `UnsupportedError` and `DownloadError`.
122
+
123
+ ## Limitations
124
+
125
+ - VOD only. Live playlists (no `#EXT-X-ENDLIST`) raise `UnsupportedError`.
126
+ - No DRM (`SAMPLE-AES`, Widevine, FairPlay), and there are no plans to add it.
127
+ - Separate audio renditions (`#EXT-X-MEDIA`) are not merged yet. Only the selected variant is downloaded.
128
+
129
+ ## Disclaimer
130
+
131
+ Only download content you have the right to download. You are responsible for following the terms of service of the sites you use and the copyright law where you live.
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,11 @@
1
+ hlsfetch/__init__.py,sha256=WFMw-SrbhVB5TfhOJ_RFDrDi9hrDrhAPLZyxxJLswzg,706
2
+ hlsfetch/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ hlsfetch/cli.py,sha256=FIqNTeXE0MhnGo-B-IZqi6zpAQMNRzV3jXMx4zIwcnY,5617
4
+ hlsfetch/downloader.py,sha256=mHVhlBxiAwA7d9z20PXHm1snV6xwcM0tF0sYWosjLDU,11000
5
+ hlsfetch/errors.py,sha256=yqhdZrOSsrbFfYrlzPBJVCmdMk3d6He3GTR-8zysHJI,405
6
+ hlsfetch/playlist.py,sha256=oOx3OL9_UmcD4Rv4a16UhuB50R04qKn-y0ZlJ76kSxE,6176
7
+ hlsfetch-0.1.0.dist-info/METADATA,sha256=F5_hC0yhX7gRyD7IK78d7nj9luQD85JdUBhghuLaJT0,4698
8
+ hlsfetch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ hlsfetch-0.1.0.dist-info/entry_points.txt,sha256=NSnp7JFmaLm3l4fMhRKzDq6tUuTVy3xG5qmMgySMZ3c,47
10
+ hlsfetch-0.1.0.dist-info/licenses/LICENSE,sha256=6l4fTi_PbWMWDRt6qUg0rspzPXnmiSLl4git-0VRE4s,1065
11
+ hlsfetch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hlsfetch = hlsfetch.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 doumamay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.