iptv-stream-validator 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.
- iptv_stream_validator-0.1.0/LICENSE +21 -0
- iptv_stream_validator-0.1.0/PKG-INFO +206 -0
- iptv_stream_validator-0.1.0/README.md +180 -0
- iptv_stream_validator-0.1.0/pyproject.toml +39 -0
- iptv_stream_validator-0.1.0/setup.cfg +4 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator/Playlist.py +194 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator/Validator.py +244 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator/__init__.py +49 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator.egg-info/PKG-INFO +206 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator.egg-info/SOURCES.txt +12 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator.egg-info/dependency_links.txt +1 -0
- iptv_stream_validator-0.1.0/src/iptv_stream_validator.egg-info/top_level.txt +1 -0
- iptv_stream_validator-0.1.0/tests/Test_playlist.py +187 -0
- iptv_stream_validator-0.1.0/tests/Test_validator.py +210 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iptv2live
|
|
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.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iptv-stream-validator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check whether IPTV/M3U stream endpoints respond, and parse M3U playlists.
|
|
5
|
+
Author: iptv2live
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://iptv2live.com
|
|
8
|
+
Project-URL: Documentation, https://iptv2live.com
|
|
9
|
+
Keywords: iptv,m3u,m3u8,playlist,stream,validator
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# iptv-stream-validator
|
|
28
|
+
|
|
29
|
+
A small Python package for validating IPTV stream endpoints and parsing M3U / M3U8 playlists. It uses only the Python standard library, has no runtime dependencies, and supports Python 3.9+.
|
|
30
|
+
|
|
31
|
+
## What the package does
|
|
32
|
+
|
|
33
|
+
- **Parses M3U / M3U8 playlists** — extended playlists with `#EXTINF` metadata (title, duration, `tvg-id`, `group-title`, and other `key="value"` attributes) as well as plain URL-only lists.
|
|
34
|
+
- **Checks stream endpoints over HTTP(S)** — one lightweight request per URL that stops after the response status line and headers, without downloading the media body.
|
|
35
|
+
- **Records HTTP status and response time** for every check.
|
|
36
|
+
- **Applies configurable timeouts** so dead endpoints cannot stall a validation run.
|
|
37
|
+
- **Reports failures as data** — every check returns a `ValidationResult`, so DNS errors, refused connections, timeouts, and HTTP errors are results, not exceptions.
|
|
38
|
+
- **Produces JSON-ready output** — results and playlist entries serialize to plain dictionaries, and `summarize_results()` aggregates a whole run into counts, failed URLs, and median/average response times.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
The distribution name is `iptv-stream-validator`. Once published on PyPI:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install iptv-stream-validator
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To install from a checkout of this repository (the package is pure Python, standard library only):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
There are no runtime dependencies.
|
|
55
|
+
|
|
56
|
+
## Basic Python usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from iptv_stream_validator import StreamValidator, summarize_results
|
|
60
|
+
|
|
61
|
+
validator = StreamValidator(timeout=5)
|
|
62
|
+
|
|
63
|
+
results = validator.validate_many(
|
|
64
|
+
[
|
|
65
|
+
"https://example.com/streams/news.m3u8",
|
|
66
|
+
"http://example.com:8080/live/sports.ts",
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
for result in results:
|
|
71
|
+
print(result.to_json())
|
|
72
|
+
|
|
73
|
+
print(summarize_results(results))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`StreamValidator` sends a `GET` request by default (many streaming servers reject `HEAD`) but never reads the response body — the connection is closed as soon as the status line and headers arrive. Pass `method="HEAD"` if you prefer HEAD requests.
|
|
77
|
+
|
|
78
|
+
## M3U playlist validation example
|
|
79
|
+
|
|
80
|
+
Parse a playlist and check the syntactic validity of its URLs before doing any network I/O:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from iptv_stream_validator import parse_m3u, is_valid_stream_url
|
|
84
|
+
|
|
85
|
+
playlist_text = """#EXTM3U
|
|
86
|
+
#EXTINF:-1 tvg-id="cnn.us" group-title="News",CNN
|
|
87
|
+
https://example.com/streams/cnn.m3u8
|
|
88
|
+
#EXTINF:-1 tvg-id="bbc.uk",BBC One
|
|
89
|
+
https://example.com/streams/bbc.m3u8
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
entries = parse_m3u(playlist_text)
|
|
93
|
+
|
|
94
|
+
for entry in entries:
|
|
95
|
+
print(entry.title, entry.attributes, entry.url)
|
|
96
|
+
print(" URL looks valid:", is_valid_stream_url(entry.url))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Output:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
CNN {'tvg-id': 'cnn.us', 'group-title': 'News'} https://example.com/streams/cnn.m3u8
|
|
103
|
+
URL looks valid: True
|
|
104
|
+
BBC One {'tvg-id': 'bbc.uk'} https://example.com/streams/bbc.m3u8
|
|
105
|
+
URL looks valid: True
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Playlists can also be read straight from a file with `parse_m3u_file(path)`, and `extract_urls(text)` returns just the URL list. A playlist whose text contains no URL lines raises `PlaylistError`.
|
|
109
|
+
|
|
110
|
+
## Stream endpoint validation example
|
|
111
|
+
|
|
112
|
+
Combining the two modules — parse a playlist, then validate every endpoint it contains:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from iptv_stream_validator import StreamValidator, parse_m3u, summarize_results
|
|
116
|
+
|
|
117
|
+
with open("playlist.m3u", "r", encoding="utf-8") as handle:
|
|
118
|
+
entries = parse_m3u(handle.read())
|
|
119
|
+
|
|
120
|
+
validator = StreamValidator(timeout=5)
|
|
121
|
+
results = validator.validate_many(entry.url for entry in entries)
|
|
122
|
+
|
|
123
|
+
for entry, result in zip(entries, results):
|
|
124
|
+
state = "OK" if result.ok else "FAIL"
|
|
125
|
+
print(f"{state} {entry.title or entry.url} {result.error or result.status}")
|
|
126
|
+
|
|
127
|
+
summary = summarize_results(results)
|
|
128
|
+
print(f"{summary['ok']}/{summary['total']} endpoints responded")
|
|
129
|
+
print(f"median response time: {summary['median_response_time']}s")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Timeout and error handling
|
|
133
|
+
|
|
134
|
+
- The timeout (default 10 seconds, configurable via `StreamValidator(timeout=...)`) covers DNS resolution, connection setup, and waiting for the response status line.
|
|
135
|
+
- `validate()` catches network-level problems (`URLError`, DNS failures, refused connections, socket timeouts) and HTTP error statuses, and returns them inside the `ValidationResult` — it does not raise for a broken endpoint.
|
|
136
|
+
- HTTP `4xx`/`5xx` responses are reported as failures with their status code, since the endpoint answered but the URL is not usable as a stream.
|
|
137
|
+
- Redirects are followed by default; if the final URL differs from the requested one it is recorded in `result.final_url`.
|
|
138
|
+
- Invalid constructor arguments (unknown method, non-numeric timeout, empty user agent) raise `ValueError`/`TypeError` immediately.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = validator.validate("https://example.com/streams/down.m3u8")
|
|
142
|
+
if not result.ok:
|
|
143
|
+
print(result.error) # e.g. "timed out after 5s" or "HTTP 404 Not Found"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Example output
|
|
147
|
+
|
|
148
|
+
A single result (`result.to_json()`):
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"url": "https://example.com/streams/news.m3u8",
|
|
153
|
+
"ok": true,
|
|
154
|
+
"status": 200,
|
|
155
|
+
"response_time": 0.184371,
|
|
156
|
+
"error": null,
|
|
157
|
+
"final_url": null
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A failed check:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"url": "https://example.com/streams/down.m3u8",
|
|
166
|
+
"ok": false,
|
|
167
|
+
"status": null,
|
|
168
|
+
"response_time": 5.001293,
|
|
169
|
+
"error": "timed out after 5s",
|
|
170
|
+
"final_url": null
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A run summary (`summarize_results(results)`):
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
{
|
|
178
|
+
"total": 3,
|
|
179
|
+
"ok": 2,
|
|
180
|
+
"failed": 1,
|
|
181
|
+
"failed_urls": ["https://example.com/streams/down.m3u8"],
|
|
182
|
+
"median_response_time": 0.211054,
|
|
183
|
+
"average_response_time": 0.197713,
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Limitations
|
|
188
|
+
|
|
189
|
+
- Checks are sequential; validating thousands of URLs takes correspondingly long. Run multiple processes or threads yourself if you need concurrency.
|
|
190
|
+
- A successful HTTP response does not prove the payload is playable media — the content is not inspected or decoded. Some servers accept any request path with a 200 response; treat results as a first-pass filter, not a guarantee.
|
|
191
|
+
- Endpoints that require specific headers (cookies, referers, unusual user agents) can be configured via `user_agent`, but anything more elaborate needs custom code on top of this package.
|
|
192
|
+
- `is_valid_stream_url()` is a syntactic check only (HTTP/HTTPS scheme, non-empty host); it performs no network requests.
|
|
193
|
+
- The parser covers the common `#EXTINF`-based IPTV playlist layout. Other directives (`#EXTVLCOPT`, `#EXTGRP`, `#KODIPROP`, ...) are ignored rather than interpreted.
|
|
194
|
+
- No M3U variant-specific handling beyond `#EXTINF` metadata: no XMLTV merging, no EPG lookup, no deduplication of streams.
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT — see [LICENSE](LICENSE).
|
|
199
|
+
|
|
200
|
+
## Additional IPTV playlist / player resources
|
|
201
|
+
|
|
202
|
+
- [IPTV2Live](https://iptv2live.com) — IPTV playlist and player reference site; this project's homepage and documentation are hosted there.
|
|
203
|
+
- [HLS (HTTP Live Streaming), RFC 8216](https://datatracker.ietf.org/doc/html/rfc8216) — the specification behind `.m3u8` media playlists.
|
|
204
|
+
- [VideoLAN VLC](https://www.videolan.org/vlc/) — a widely used player for M3U playlists and network streams.
|
|
205
|
+
- [FFmpeg](https://ffmpeg.org/) — the standard toolkit for inspecting and re-streaming media (e.g. `ffprobe` for checking whether a URL yields playable media).
|
|
206
|
+
- [iptv-org/iptv](https://github.com/iptv-org/iptv) — a large, publicly maintained collection of M3U playlists, useful as realistic test input.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# iptv-stream-validator
|
|
2
|
+
|
|
3
|
+
A small Python package for validating IPTV stream endpoints and parsing M3U / M3U8 playlists. It uses only the Python standard library, has no runtime dependencies, and supports Python 3.9+.
|
|
4
|
+
|
|
5
|
+
## What the package does
|
|
6
|
+
|
|
7
|
+
- **Parses M3U / M3U8 playlists** — extended playlists with `#EXTINF` metadata (title, duration, `tvg-id`, `group-title`, and other `key="value"` attributes) as well as plain URL-only lists.
|
|
8
|
+
- **Checks stream endpoints over HTTP(S)** — one lightweight request per URL that stops after the response status line and headers, without downloading the media body.
|
|
9
|
+
- **Records HTTP status and response time** for every check.
|
|
10
|
+
- **Applies configurable timeouts** so dead endpoints cannot stall a validation run.
|
|
11
|
+
- **Reports failures as data** — every check returns a `ValidationResult`, so DNS errors, refused connections, timeouts, and HTTP errors are results, not exceptions.
|
|
12
|
+
- **Produces JSON-ready output** — results and playlist entries serialize to plain dictionaries, and `summarize_results()` aggregates a whole run into counts, failed URLs, and median/average response times.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
The distribution name is `iptv-stream-validator`. Once published on PyPI:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install iptv-stream-validator
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
To install from a checkout of this repository (the package is pure Python, standard library only):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
There are no runtime dependencies.
|
|
29
|
+
|
|
30
|
+
## Basic Python usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from iptv_stream_validator import StreamValidator, summarize_results
|
|
34
|
+
|
|
35
|
+
validator = StreamValidator(timeout=5)
|
|
36
|
+
|
|
37
|
+
results = validator.validate_many(
|
|
38
|
+
[
|
|
39
|
+
"https://example.com/streams/news.m3u8",
|
|
40
|
+
"http://example.com:8080/live/sports.ts",
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
for result in results:
|
|
45
|
+
print(result.to_json())
|
|
46
|
+
|
|
47
|
+
print(summarize_results(results))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`StreamValidator` sends a `GET` request by default (many streaming servers reject `HEAD`) but never reads the response body — the connection is closed as soon as the status line and headers arrive. Pass `method="HEAD"` if you prefer HEAD requests.
|
|
51
|
+
|
|
52
|
+
## M3U playlist validation example
|
|
53
|
+
|
|
54
|
+
Parse a playlist and check the syntactic validity of its URLs before doing any network I/O:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from iptv_stream_validator import parse_m3u, is_valid_stream_url
|
|
58
|
+
|
|
59
|
+
playlist_text = """#EXTM3U
|
|
60
|
+
#EXTINF:-1 tvg-id="cnn.us" group-title="News",CNN
|
|
61
|
+
https://example.com/streams/cnn.m3u8
|
|
62
|
+
#EXTINF:-1 tvg-id="bbc.uk",BBC One
|
|
63
|
+
https://example.com/streams/bbc.m3u8
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
entries = parse_m3u(playlist_text)
|
|
67
|
+
|
|
68
|
+
for entry in entries:
|
|
69
|
+
print(entry.title, entry.attributes, entry.url)
|
|
70
|
+
print(" URL looks valid:", is_valid_stream_url(entry.url))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Output:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
CNN {'tvg-id': 'cnn.us', 'group-title': 'News'} https://example.com/streams/cnn.m3u8
|
|
77
|
+
URL looks valid: True
|
|
78
|
+
BBC One {'tvg-id': 'bbc.uk'} https://example.com/streams/bbc.m3u8
|
|
79
|
+
URL looks valid: True
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Playlists can also be read straight from a file with `parse_m3u_file(path)`, and `extract_urls(text)` returns just the URL list. A playlist whose text contains no URL lines raises `PlaylistError`.
|
|
83
|
+
|
|
84
|
+
## Stream endpoint validation example
|
|
85
|
+
|
|
86
|
+
Combining the two modules — parse a playlist, then validate every endpoint it contains:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from iptv_stream_validator import StreamValidator, parse_m3u, summarize_results
|
|
90
|
+
|
|
91
|
+
with open("playlist.m3u", "r", encoding="utf-8") as handle:
|
|
92
|
+
entries = parse_m3u(handle.read())
|
|
93
|
+
|
|
94
|
+
validator = StreamValidator(timeout=5)
|
|
95
|
+
results = validator.validate_many(entry.url for entry in entries)
|
|
96
|
+
|
|
97
|
+
for entry, result in zip(entries, results):
|
|
98
|
+
state = "OK" if result.ok else "FAIL"
|
|
99
|
+
print(f"{state} {entry.title or entry.url} {result.error or result.status}")
|
|
100
|
+
|
|
101
|
+
summary = summarize_results(results)
|
|
102
|
+
print(f"{summary['ok']}/{summary['total']} endpoints responded")
|
|
103
|
+
print(f"median response time: {summary['median_response_time']}s")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Timeout and error handling
|
|
107
|
+
|
|
108
|
+
- The timeout (default 10 seconds, configurable via `StreamValidator(timeout=...)`) covers DNS resolution, connection setup, and waiting for the response status line.
|
|
109
|
+
- `validate()` catches network-level problems (`URLError`, DNS failures, refused connections, socket timeouts) and HTTP error statuses, and returns them inside the `ValidationResult` — it does not raise for a broken endpoint.
|
|
110
|
+
- HTTP `4xx`/`5xx` responses are reported as failures with their status code, since the endpoint answered but the URL is not usable as a stream.
|
|
111
|
+
- Redirects are followed by default; if the final URL differs from the requested one it is recorded in `result.final_url`.
|
|
112
|
+
- Invalid constructor arguments (unknown method, non-numeric timeout, empty user agent) raise `ValueError`/`TypeError` immediately.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
result = validator.validate("https://example.com/streams/down.m3u8")
|
|
116
|
+
if not result.ok:
|
|
117
|
+
print(result.error) # e.g. "timed out after 5s" or "HTTP 404 Not Found"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Example output
|
|
121
|
+
|
|
122
|
+
A single result (`result.to_json()`):
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"url": "https://example.com/streams/news.m3u8",
|
|
127
|
+
"ok": true,
|
|
128
|
+
"status": 200,
|
|
129
|
+
"response_time": 0.184371,
|
|
130
|
+
"error": null,
|
|
131
|
+
"final_url": null
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A failed check:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"url": "https://example.com/streams/down.m3u8",
|
|
140
|
+
"ok": false,
|
|
141
|
+
"status": null,
|
|
142
|
+
"response_time": 5.001293,
|
|
143
|
+
"error": "timed out after 5s",
|
|
144
|
+
"final_url": null
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
A run summary (`summarize_results(results)`):
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
{
|
|
152
|
+
"total": 3,
|
|
153
|
+
"ok": 2,
|
|
154
|
+
"failed": 1,
|
|
155
|
+
"failed_urls": ["https://example.com/streams/down.m3u8"],
|
|
156
|
+
"median_response_time": 0.211054,
|
|
157
|
+
"average_response_time": 0.197713,
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Limitations
|
|
162
|
+
|
|
163
|
+
- Checks are sequential; validating thousands of URLs takes correspondingly long. Run multiple processes or threads yourself if you need concurrency.
|
|
164
|
+
- A successful HTTP response does not prove the payload is playable media — the content is not inspected or decoded. Some servers accept any request path with a 200 response; treat results as a first-pass filter, not a guarantee.
|
|
165
|
+
- Endpoints that require specific headers (cookies, referers, unusual user agents) can be configured via `user_agent`, but anything more elaborate needs custom code on top of this package.
|
|
166
|
+
- `is_valid_stream_url()` is a syntactic check only (HTTP/HTTPS scheme, non-empty host); it performs no network requests.
|
|
167
|
+
- The parser covers the common `#EXTINF`-based IPTV playlist layout. Other directives (`#EXTVLCOPT`, `#EXTGRP`, `#KODIPROP`, ...) are ignored rather than interpreted.
|
|
168
|
+
- No M3U variant-specific handling beyond `#EXTINF` metadata: no XMLTV merging, no EPG lookup, no deduplication of streams.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT — see [LICENSE](LICENSE).
|
|
173
|
+
|
|
174
|
+
## Additional IPTV playlist / player resources
|
|
175
|
+
|
|
176
|
+
- [IPTV2Live](https://iptv2live.com) — IPTV playlist and player reference site; this project's homepage and documentation are hosted there.
|
|
177
|
+
- [HLS (HTTP Live Streaming), RFC 8216](https://datatracker.ietf.org/doc/html/rfc8216) — the specification behind `.m3u8` media playlists.
|
|
178
|
+
- [VideoLAN VLC](https://www.videolan.org/vlc/) — a widely used player for M3U playlists and network streams.
|
|
179
|
+
- [FFmpeg](https://ffmpeg.org/) — the standard toolkit for inspecting and re-streaming media (e.g. `ffprobe` for checking whether a URL yields playable media).
|
|
180
|
+
- [iptv-org/iptv](https://github.com/iptv-org/iptv) — a large, publicly maintained collection of M3U playlists, useful as realistic test input.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "iptv-stream-validator"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Check whether IPTV/M3U stream endpoints respond, and parse M3U playlists."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "iptv2live" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
keywords = ["iptv", "m3u", "m3u8", "playlist", "stream", "validator"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Internet",
|
|
26
|
+
"Topic :: Multimedia :: Video",
|
|
27
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://iptv2live.com"
|
|
33
|
+
Documentation = "https://iptv2live.com"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
package-dir = { "" = "src" }
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Parsing and validation of M3U / M3U8 playlists.
|
|
2
|
+
|
|
3
|
+
An *extended* M3U playlist starts with ``#EXTM3U`` and describes each
|
|
4
|
+
stream with an ``#EXTINF`` line followed by the media URL. Plain M3U
|
|
5
|
+
files contain only URLs. This module parses both forms and offers a
|
|
6
|
+
cheap syntactic URL check suitable for filtering obviously broken
|
|
7
|
+
entries before any network request is made.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PlaylistEntry",
|
|
18
|
+
"PlaylistError",
|
|
19
|
+
"extract_urls",
|
|
20
|
+
"is_valid_stream_url",
|
|
21
|
+
"parse_m3u",
|
|
22
|
+
"parse_m3u_file",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_EXTINF_ATTRIBUTES = r'([A-Za-z0-9_-]+)="([^"]*)"'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlaylistError(ValueError):
|
|
29
|
+
"""Raised when a playlist's structure is invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PlaylistEntry:
|
|
34
|
+
"""One stream entry from a playlist.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
url: The media URL (M3U line following an ``#EXTINF`` line, or
|
|
38
|
+
a standalone non-comment line).
|
|
39
|
+
title: Display name from the ``#EXTINF`` line, if present.
|
|
40
|
+
duration: Declared duration in seconds; live streams usually
|
|
41
|
+
declare ``-1``. None when the playlist is plain M3U.
|
|
42
|
+
attributes: ``key="value"`` pairs from the ``#EXTINF`` line,
|
|
43
|
+
such as ``tvg-id`` or ``group-title``. Values keep the
|
|
44
|
+
original spelling of their keys.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
url: str
|
|
48
|
+
title: str | None = None
|
|
49
|
+
duration: float | None = None
|
|
50
|
+
attributes: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
"""Return the entry as a plain dictionary (JSON-compatible)."""
|
|
54
|
+
return {
|
|
55
|
+
"url": self.url,
|
|
56
|
+
"title": self.title,
|
|
57
|
+
"duration": self.duration,
|
|
58
|
+
"attributes": dict(self.attributes),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_m3u(text: str) -> list[PlaylistEntry]:
|
|
63
|
+
"""Parse M3U / M3U8 playlist text.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
text: The full playlist content, UTF-8 decoded.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Entries in playlist order.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TypeError: If `text` is not a string.
|
|
73
|
+
PlaylistError: If the text is empty or has no URL lines.
|
|
74
|
+
|
|
75
|
+
A leading ``#EXTM3U`` header is recognized but optional, so plain
|
|
76
|
+
URL lists are accepted as well. ``#EXTINF`` metadata is attached
|
|
77
|
+
to the URL line that follows it; other directives (``#EXTVLCOPT``,
|
|
78
|
+
``#EXTGRP``, comments) are ignored.
|
|
79
|
+
"""
|
|
80
|
+
if not isinstance(text, str):
|
|
81
|
+
raise TypeError(f"playlist text must be str, got {type(text).__name__}")
|
|
82
|
+
entries: list[PlaylistEntry] = []
|
|
83
|
+
pending_title = None
|
|
84
|
+
pending_duration: float | None = None
|
|
85
|
+
pending_attributes: dict[str, str] = {}
|
|
86
|
+
for raw_line in text.splitlines():
|
|
87
|
+
line = raw_line.strip()
|
|
88
|
+
if not line:
|
|
89
|
+
continue
|
|
90
|
+
if line.startswith("#EXTINF:"):
|
|
91
|
+
pending_duration, pending_attributes, pending_title = _parse_extinf(line)
|
|
92
|
+
elif not line.startswith("#"):
|
|
93
|
+
entry = PlaylistEntry(
|
|
94
|
+
url=line,
|
|
95
|
+
title=pending_title,
|
|
96
|
+
duration=pending_duration,
|
|
97
|
+
attributes=pending_attributes,
|
|
98
|
+
)
|
|
99
|
+
entries.append(entry)
|
|
100
|
+
pending_title = None
|
|
101
|
+
pending_duration = None
|
|
102
|
+
pending_attributes = {}
|
|
103
|
+
if not entries:
|
|
104
|
+
raise PlaylistError("no stream URLs found in playlist")
|
|
105
|
+
return entries
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_m3u_file(path, encoding: str = "utf-8") -> list[PlaylistEntry]:
|
|
109
|
+
"""Parse an M3U / M3U8 playlist from a file path.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
path: Path to the playlist file.
|
|
113
|
+
encoding: Encoding of the file; many playlists in the wild are
|
|
114
|
+
UTF-8, which is the default.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Entries in playlist order.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
OSError: If the file cannot be read.
|
|
121
|
+
PlaylistError: If the content is not a parseable playlist.
|
|
122
|
+
"""
|
|
123
|
+
with open(path, "r", encoding=encoding) as handle:
|
|
124
|
+
return parse_m3u(handle.read())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_urls(text: str) -> list[str]:
|
|
128
|
+
"""Return the URL of every entry in playlist text, in order.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
text: The full playlist content.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
PlaylistError: If the text is not a parseable playlist.
|
|
135
|
+
|
|
136
|
+
This is a convenience wrapper around `parse_m3u` for callers that
|
|
137
|
+
only need the URLs.
|
|
138
|
+
"""
|
|
139
|
+
return [entry.url for entry in parse_m3u(text)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_valid_stream_url(url: str) -> bool:
|
|
143
|
+
"""Return True if `url` is a syntactically valid HTTP(S) stream URL.
|
|
144
|
+
|
|
145
|
+
This is a structural check only -- it verifies scheme, host, and
|
|
146
|
+
the absence of spaces or control characters. It performs no
|
|
147
|
+
network I/O and says nothing about whether the endpoint works; use
|
|
148
|
+
``StreamValidator`` for that.
|
|
149
|
+
|
|
150
|
+
Accepted: ``http`` and ``https`` URLs with a non-empty host, e.g.
|
|
151
|
+
``http://example.com:8080/live/stream.m3u8?token=x``.
|
|
152
|
+
"""
|
|
153
|
+
if not isinstance(url, str) or not url:
|
|
154
|
+
return False
|
|
155
|
+
if any(char.isspace() for char in url):
|
|
156
|
+
return False
|
|
157
|
+
try:
|
|
158
|
+
parts = urlparse(url)
|
|
159
|
+
except ValueError:
|
|
160
|
+
return False
|
|
161
|
+
return (
|
|
162
|
+
parts.scheme in ("http", "https")
|
|
163
|
+
and parts.netloc != ""
|
|
164
|
+
and "." in parts.netloc.rsplit("@", 1)[-1].split(":", 1)[0]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_extinf(line: str) -> tuple[float | None, dict[str, str], str | None]:
|
|
169
|
+
"""Split an ``#EXTINF:<duration> [attrs] [,title]`` line.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A ``(duration, attributes, title)`` tuple; duration and title
|
|
173
|
+
are None when absent. Unparseable durations are treated as
|
|
174
|
+
None rather than raising, because malformed metadata should not
|
|
175
|
+
hide a possibly working URL.
|
|
176
|
+
"""
|
|
177
|
+
body = line[len("#EXTINF:") :].strip()
|
|
178
|
+
# duration: digits up to the first attribute, comma, or whitespace
|
|
179
|
+
duration: float | None = None
|
|
180
|
+
first_break = re.search(r'[,\s]', body)
|
|
181
|
+
token = body[: first_break.start()] if first_break else body
|
|
182
|
+
try:
|
|
183
|
+
duration = float(token)
|
|
184
|
+
except ValueError:
|
|
185
|
+
duration = None
|
|
186
|
+
metadata = body
|
|
187
|
+
else:
|
|
188
|
+
metadata = body[first_break.start() :] if first_break else ""
|
|
189
|
+
metadata = metadata.strip()
|
|
190
|
+
attributes: dict[str, str] = dict(re.findall(_EXTINF_ATTRIBUTES, metadata))
|
|
191
|
+
title: str | None = None
|
|
192
|
+
if "," in metadata:
|
|
193
|
+
title = metadata.rsplit(",", 1)[1].strip() or None
|
|
194
|
+
return duration, attributes, title
|