hlsfetch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hlsfetch-0.1.0/.github/workflows/publish.yml +37 -0
- hlsfetch-0.1.0/.gitignore +10 -0
- hlsfetch-0.1.0/LICENSE +21 -0
- hlsfetch-0.1.0/PKG-INFO +135 -0
- hlsfetch-0.1.0/README.md +110 -0
- hlsfetch-0.1.0/pyproject.toml +44 -0
- hlsfetch-0.1.0/src/hlsfetch/__init__.py +34 -0
- hlsfetch-0.1.0/src/hlsfetch/__main__.py +3 -0
- hlsfetch-0.1.0/src/hlsfetch/cli.py +131 -0
- hlsfetch-0.1.0/src/hlsfetch/downloader.py +288 -0
- hlsfetch-0.1.0/src/hlsfetch/errors.py +14 -0
- hlsfetch-0.1.0/src/hlsfetch/playlist.py +198 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
build:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: "3.12"
|
|
14
|
+
- name: Build sdist and wheel
|
|
15
|
+
run: |
|
|
16
|
+
python -m pip install --upgrade build twine
|
|
17
|
+
python -m build
|
|
18
|
+
python -m twine check --strict dist/*
|
|
19
|
+
- uses: actions/upload-artifact@v4
|
|
20
|
+
with:
|
|
21
|
+
name: dist
|
|
22
|
+
path: dist/
|
|
23
|
+
|
|
24
|
+
publish:
|
|
25
|
+
needs: build
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
environment:
|
|
28
|
+
name: pypi
|
|
29
|
+
url: https://pypi.org/project/hlsfetch/
|
|
30
|
+
permissions:
|
|
31
|
+
id-token: write
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/download-artifact@v4
|
|
34
|
+
with:
|
|
35
|
+
name: dist
|
|
36
|
+
path: dist/
|
|
37
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
hlsfetch-0.1.0/LICENSE
ADDED
|
@@ -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.
|
hlsfetch-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
hlsfetch-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# hlsfetch
|
|
2
|
+
|
|
3
|
+
Fast, async HLS (`.m3u8`) downloader for Python, usable as a library or from the command line.
|
|
4
|
+
|
|
5
|
+
- Parallel segment downloads on `asyncio` + `httpx`
|
|
6
|
+
- Resumes interrupted downloads: run the same call again and finished segments are skipped
|
|
7
|
+
- AES-128 decryption (key rotation and explicit or implicit IVs are handled)
|
|
8
|
+
- MPEG-TS and fMP4 (`#EXT-X-MAP`) streams, plus `#EXT-X-BYTERANGE`
|
|
9
|
+
- Quality selection: `best`, `worst`, max height, or your own function
|
|
10
|
+
- Custom headers, Referer, proxy (HTTP or SOCKS)
|
|
11
|
+
- Optional remux to `.mp4` through ffmpeg (stream copy, no re-encoding)
|
|
12
|
+
- Only two dependencies: `httpx` and `cryptography`
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install hlsfetch
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
## Command line
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
hlsfetch "https://example.com/video/master.m3u8" -o movie.mp4
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
hlsfetch URL [-o OUTPUT] [-q best|worst|720] [-H 'Name: value']... [-r REFERER]
|
|
30
|
+
[-j CONCURRENCY] [--retries N] [--timeout S] [--proxy URL]
|
|
31
|
+
[--no-remux] [--keep-parts] [-i/--info]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`hlsfetch URL --info` lists the variants, segment count, duration and encryption without downloading anything.
|
|
35
|
+
|
|
36
|
+
If a download is interrupted, run the same command again to resume it.
|
|
37
|
+
|
|
38
|
+
## Library
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import hlsfetch
|
|
42
|
+
|
|
43
|
+
path = hlsfetch.download(
|
|
44
|
+
"https://example.com/video/master.m3u8",
|
|
45
|
+
"movie.mp4",
|
|
46
|
+
quality=720, # "best", "worst", 720, or a callable
|
|
47
|
+
headers={"Referer": "https://example.com/"},
|
|
48
|
+
concurrency=16,
|
|
49
|
+
)
|
|
50
|
+
print(path)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Async, with a progress callback:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
import hlsfetch
|
|
58
|
+
|
|
59
|
+
def on_progress(p: hlsfetch.Progress):
|
|
60
|
+
print(f"{p.done}/{p.total} segments, {p.bytes / 1e6:.1f} MB, {p.speed / 1e6:.1f} MB/s")
|
|
61
|
+
|
|
62
|
+
async def main():
|
|
63
|
+
await hlsfetch.adownload(url, "movie.mp4", progress=on_progress)
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The callback may also be an `async def`. You can pass your own `httpx.AsyncClient` through `client=` to share cookies or connection pools.
|
|
69
|
+
|
|
70
|
+
Parse a playlist yourself:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
pl = hlsfetch.parse_playlist(text, url)
|
|
74
|
+
if isinstance(pl, hlsfetch.MasterPlaylist):
|
|
75
|
+
for v in pl.variants:
|
|
76
|
+
print(v.resolution, v.bandwidth, v.uri)
|
|
77
|
+
else:
|
|
78
|
+
print(len(pl.segments), "segments,", pl.duration, "seconds")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `download()` / `adownload()` options
|
|
82
|
+
|
|
83
|
+
| Option | Default | Meaning |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `quality` | `"best"` | `"best"`, `"worst"`, max height (`720`), or `callable(variants) -> Variant` |
|
|
86
|
+
| `headers` | `None` | Extra HTTP headers |
|
|
87
|
+
| `concurrency` | `8` | Segments downloaded at once |
|
|
88
|
+
| `retries` | `5` | Retries per request (network errors, 408/429/5xx) |
|
|
89
|
+
| `timeout` | `30.0` | Request timeout in seconds |
|
|
90
|
+
| `proxy` | `None` | Proxy URL |
|
|
91
|
+
| `remux` | `True` | Use ffmpeg when the output extension differs from the stream's container |
|
|
92
|
+
| `keep_parts` | `False` | Keep the `<output>.parts/` folder after merging |
|
|
93
|
+
| `progress` | `None` | Called with a `Progress` after each segment |
|
|
94
|
+
| `client` | `None` | Your own `httpx.AsyncClient` (it is not closed for you) |
|
|
95
|
+
|
|
96
|
+
Errors are raised as subclasses of `hlsfetch.HLSFetchError`: `PlaylistError`, `UnsupportedError` and `DownloadError`.
|
|
97
|
+
|
|
98
|
+
## Limitations
|
|
99
|
+
|
|
100
|
+
- VOD only. Live playlists (no `#EXT-X-ENDLIST`) raise `UnsupportedError`.
|
|
101
|
+
- No DRM (`SAMPLE-AES`, Widevine, FairPlay), and there are no plans to add it.
|
|
102
|
+
- Separate audio renditions (`#EXT-X-MEDIA`) are not merged yet. Only the selected variant is downloaded.
|
|
103
|
+
|
|
104
|
+
## Disclaimer
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hlsfetch"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Fast async HLS (m3u8) downloader for Python: resume, AES-128, fMP4, ffmpeg remux."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "doumamay" }]
|
|
14
|
+
keywords = ["hls", "m3u8", "downloader", "video", "streaming", "asyncio", "aes-128"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Multimedia :: Video",
|
|
23
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.26",
|
|
27
|
+
"cryptography>=41",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
socks = ["httpx[socks]>=0.26"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
hlsfetch = "hlsfetch.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/doumamay/hlsfetch"
|
|
38
|
+
Issues = "https://github.com/doumamay/hlsfetch/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "src/hlsfetch/__init__.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/hlsfetch"]
|
|
@@ -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
|
+
]
|
|
@@ -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())
|
|
@@ -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(...)`")
|
|
@@ -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."""
|
|
@@ -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
|