iptv-stream-validator 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.
- iptv_stream_validator/Playlist.py +194 -0
- iptv_stream_validator/Validator.py +244 -0
- iptv_stream_validator/__init__.py +49 -0
- iptv_stream_validator-0.1.0.dist-info/METADATA +206 -0
- iptv_stream_validator-0.1.0.dist-info/RECORD +8 -0
- iptv_stream_validator-0.1.0.dist-info/WHEEL +5 -0
- iptv_stream_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- iptv_stream_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""HTTP(S) validation for IPTV stream endpoints.
|
|
2
|
+
|
|
3
|
+
`StreamValidator` sends one lightweight request per URL, records the
|
|
4
|
+
HTTP status code and the time until the response status line arrives,
|
|
5
|
+
and closes the connection without reading the media body. Every check
|
|
6
|
+
returns a `ValidationResult`, so network failures are reported as data
|
|
7
|
+
instead of exceptions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
|
|
19
|
+
__all__ = ["StreamValidator", "ValidationResult", "summarize_results"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_USER_AGENT = "iptv-stream-validator/0.1.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ValidationResult:
|
|
26
|
+
"""Outcome of a single stream endpoint check.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
url: The URL that was checked.
|
|
30
|
+
ok: True when the endpoint answered with a successful HTTP
|
|
31
|
+
response (urllib treats 2xx, and followed 3xx redirects,
|
|
32
|
+
as success).
|
|
33
|
+
status: HTTP status code, when one was received.
|
|
34
|
+
response_time: Seconds until the response status line arrived
|
|
35
|
+
(or until the failure was detected), when measurable.
|
|
36
|
+
error: Short description of why the check failed, else None.
|
|
37
|
+
final_url: The URL after redirects, when it differs from `url`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
url: str
|
|
41
|
+
ok: bool
|
|
42
|
+
status: int | None = None
|
|
43
|
+
response_time: float | None = None
|
|
44
|
+
error: str | None = None
|
|
45
|
+
final_url: str | None = None
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
"""Return the result as a plain dictionary (JSON-compatible)."""
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
def to_json(self) -> str:
|
|
52
|
+
"""Return the result as an indented JSON string."""
|
|
53
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StreamValidator:
|
|
57
|
+
"""Check HTTP(S) stream endpoints with a configurable timeout.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
timeout: Seconds to wait for each connection/response. Values
|
|
61
|
+
below 0.01 are clamped to 0.01 to stay within the
|
|
62
|
+
resolution of `socket` timeouts.
|
|
63
|
+
method: HTTP method to use, ``"GET"`` (default) or ``"HEAD"``.
|
|
64
|
+
GET is more compatible with streaming servers that reject
|
|
65
|
+
HEAD; the body is never read either way, so the cost is one
|
|
66
|
+
round trip plus whatever the server starts sending before
|
|
67
|
+
the socket is closed.
|
|
68
|
+
user_agent: User-Agent header to send. Defaults to
|
|
69
|
+
``iptv-stream-validator/<version>``.
|
|
70
|
+
|
|
71
|
+
`validate` never raises for network-level problems; every failure
|
|
72
|
+
is described in the returned `ValidationResult`.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
timeout: float = 10.0,
|
|
78
|
+
method: str = "GET",
|
|
79
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
80
|
+
) -> None:
|
|
81
|
+
method = method.upper()
|
|
82
|
+
if method not in ("GET", "HEAD"):
|
|
83
|
+
raise ValueError(f"method must be 'GET' or 'HEAD', got {method!r}")
|
|
84
|
+
if not isinstance(timeout, (int, float)) or isinstance(timeout, bool):
|
|
85
|
+
raise TypeError("timeout must be a number")
|
|
86
|
+
if not isinstance(user_agent, str) or not user_agent:
|
|
87
|
+
raise ValueError("user_agent must be a non-empty string")
|
|
88
|
+
self.timeout = max(0.01, float(timeout))
|
|
89
|
+
self.method = method
|
|
90
|
+
self.user_agent = user_agent
|
|
91
|
+
|
|
92
|
+
def _open(self, url: str) -> urllib.request.OpenerDirector:
|
|
93
|
+
"""Build an opener that does not read response bodies."""
|
|
94
|
+
request = urllib.request.Request(
|
|
95
|
+
url, method=self.method, headers={"User-Agent": self.user_agent}
|
|
96
|
+
)
|
|
97
|
+
return _NoBodyOpener().open(request, timeout=self.timeout)
|
|
98
|
+
|
|
99
|
+
def validate(self, url: str) -> ValidationResult:
|
|
100
|
+
"""Check one stream endpoint.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
url: An HTTP or HTTPS URL.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A `ValidationResult`. ``ok`` is True for 2xx responses
|
|
107
|
+
(and 3xx redirects that urllib follows to a 2xx); anything
|
|
108
|
+
else -- DNS failure, refused connection, timeout, HTTP
|
|
109
|
+
error -- yields ``ok=False`` with the status or error set.
|
|
110
|
+
"""
|
|
111
|
+
start = time.perf_counter()
|
|
112
|
+
try:
|
|
113
|
+
with self._open(url) as response:
|
|
114
|
+
status = response.status
|
|
115
|
+
final_url = response.geturl()
|
|
116
|
+
except urllib.error.HTTPError as exc:
|
|
117
|
+
# HTTPError is also a response; it still proves the
|
|
118
|
+
# endpoint is alive, but a stream URL returning 4xx/5xx
|
|
119
|
+
# should not be reported as working.
|
|
120
|
+
elapsed = time.perf_counter() - start
|
|
121
|
+
return ValidationResult(
|
|
122
|
+
url,
|
|
123
|
+
ok=False,
|
|
124
|
+
status=exc.code,
|
|
125
|
+
response_time=round(elapsed, 6),
|
|
126
|
+
error=f"HTTP {exc.code} {exc.reason}",
|
|
127
|
+
)
|
|
128
|
+
except urllib.error.URLError as exc:
|
|
129
|
+
elapsed = time.perf_counter() - start
|
|
130
|
+
return ValidationResult(
|
|
131
|
+
url,
|
|
132
|
+
ok=False,
|
|
133
|
+
response_time=round(elapsed, 6),
|
|
134
|
+
error=_describe_url_error(exc),
|
|
135
|
+
)
|
|
136
|
+
except (TimeoutError, socket.timeout) as exc:
|
|
137
|
+
elapsed = time.perf_counter() - start
|
|
138
|
+
return ValidationResult(
|
|
139
|
+
url,
|
|
140
|
+
ok=False,
|
|
141
|
+
response_time=round(elapsed, 6),
|
|
142
|
+
error=f"timed out after {self.timeout:g}s"
|
|
143
|
+
+ (f" ({exc})" if str(exc) and str(exc) != "timed out" else ""),
|
|
144
|
+
)
|
|
145
|
+
elapsed = time.perf_counter() - start
|
|
146
|
+
return ValidationResult(
|
|
147
|
+
url,
|
|
148
|
+
ok=True,
|
|
149
|
+
status=status,
|
|
150
|
+
response_time=round(elapsed, 6),
|
|
151
|
+
final_url=(final_url if final_url != url else None),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def validate_many(self, urls) -> list[ValidationResult]:
|
|
155
|
+
"""Validate several endpoints, preserving the input order.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
urls: An iterable of HTTP(S) URLs.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
One `ValidationResult` per input URL.
|
|
162
|
+
"""
|
|
163
|
+
return [self.validate(url) for url in urls]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _NoBodyOpener(urllib.request.OpenerDirector):
|
|
167
|
+
"""URL opener that returns responses without ever reading bodies.
|
|
168
|
+
|
|
169
|
+
Streaming endpoints serve live or on-demand media; a validating
|
|
170
|
+
client only needs the status line and headers. Wrapping every
|
|
171
|
+
response in `_CloseOnlyResponse` guarantees the underlying socket
|
|
172
|
+
is closed immediately instead of downloading the stream.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def open(self, fullurl, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
|
|
176
|
+
response = super().open(fullurl, data, timeout)
|
|
177
|
+
return _CloseOnlyResponse(response)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _CloseOnlyResponse:
|
|
181
|
+
"""Minimal forwarder around an ``http.client.HTTPResponse``.
|
|
182
|
+
|
|
183
|
+
It implements just the surface `StreamValidator` uses, and closes
|
|
184
|
+
the wrapped response on exit or garbage collection without reading
|
|
185
|
+
its body.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, response) -> None:
|
|
189
|
+
self._response = response
|
|
190
|
+
|
|
191
|
+
def __enter__(self):
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
195
|
+
self.close()
|
|
196
|
+
|
|
197
|
+
def __getattr__(self, name):
|
|
198
|
+
return getattr(self._response, name)
|
|
199
|
+
|
|
200
|
+
def close(self) -> None:
|
|
201
|
+
self._response.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _describe_url_error(exc: urllib.error.URLError) -> str:
|
|
205
|
+
"""Distill a URLError into a short, stable description."""
|
|
206
|
+
reason = exc.reason
|
|
207
|
+
if isinstance(reason, socket.timeout):
|
|
208
|
+
return "timed out"
|
|
209
|
+
if isinstance(reason, OSError) and reason.errno is not None:
|
|
210
|
+
return f"{reason.__class__.__name__}: {reason}"
|
|
211
|
+
return str(reason) or reason.__class__.__name__
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def summarize_results(results) -> dict:
|
|
215
|
+
"""Summarize validation results for reports.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
results: An iterable of `ValidationResult`.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
A dictionary with the URL count, how many checks succeeded,
|
|
222
|
+
and the URLs that failed. Median and average response times
|
|
223
|
+
are computed over successful checks only, and are None when no
|
|
224
|
+
check succeeded.
|
|
225
|
+
"""
|
|
226
|
+
results = list(results)
|
|
227
|
+
failures = [r.url for r in results if not r.ok]
|
|
228
|
+
times = sorted(
|
|
229
|
+
r.response_time for r in results if r.ok and r.response_time is not None
|
|
230
|
+
)
|
|
231
|
+
median = None
|
|
232
|
+
average = None
|
|
233
|
+
if times:
|
|
234
|
+
mid = len(times) // 2
|
|
235
|
+
median = times[mid] if len(times) % 2 else (times[mid - 1] + times[mid]) / 2
|
|
236
|
+
average = round(sum(times) / len(times), 6)
|
|
237
|
+
return {
|
|
238
|
+
"total": len(results),
|
|
239
|
+
"ok": len(results) - len(failures),
|
|
240
|
+
"failed": len(failures),
|
|
241
|
+
"failed_urls": failures,
|
|
242
|
+
"median_response_time": median,
|
|
243
|
+
"average_response_time": average,
|
|
244
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Validate IPTV stream endpoints and parse M3U playlists.
|
|
2
|
+
|
|
3
|
+
This package provides two small modules:
|
|
4
|
+
|
|
5
|
+
* :mod:`iptv_stream_validator.Validator` — check whether HTTP(S)
|
|
6
|
+
stream endpoints respond, and record status, response time, and
|
|
7
|
+
errors.
|
|
8
|
+
* :mod:`iptv_stream_validator.Playlist` — parse M3U/M3U8 playlists
|
|
9
|
+
and inspect the URLs they contain.
|
|
10
|
+
|
|
11
|
+
Typical use::
|
|
12
|
+
|
|
13
|
+
from iptv_stream_validator import StreamValidator, parse_m3u
|
|
14
|
+
|
|
15
|
+
entries = parse_m3u(playlist_text)
|
|
16
|
+
validator = StreamValidator(timeout=5)
|
|
17
|
+
results = validator.validate_many(entry.url for entry in entries)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .Playlist import (
|
|
21
|
+
PlaylistEntry,
|
|
22
|
+
PlaylistError,
|
|
23
|
+
extract_urls,
|
|
24
|
+
is_valid_stream_url,
|
|
25
|
+
parse_m3u,
|
|
26
|
+
parse_m3u_file,
|
|
27
|
+
)
|
|
28
|
+
from .Validator import (
|
|
29
|
+
DEFAULT_USER_AGENT,
|
|
30
|
+
StreamValidator,
|
|
31
|
+
ValidationResult,
|
|
32
|
+
summarize_results,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DEFAULT_USER_AGENT",
|
|
39
|
+
"PlaylistEntry",
|
|
40
|
+
"PlaylistError",
|
|
41
|
+
"StreamValidator",
|
|
42
|
+
"ValidationResult",
|
|
43
|
+
"extract_urls",
|
|
44
|
+
"is_valid_stream_url",
|
|
45
|
+
"parse_m3u",
|
|
46
|
+
"parse_m3u_file",
|
|
47
|
+
"summarize_results",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
@@ -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,8 @@
|
|
|
1
|
+
iptv_stream_validator/Playlist.py,sha256=qI0502LADpMj_4CASXXealkOoQFxZMHSyOPpj8qyHkM,6336
|
|
2
|
+
iptv_stream_validator/Validator.py,sha256=jo0yPrNO-fQcLQK9fekLUoaNd7mbDXGgUuI_dW5OK9k,8627
|
|
3
|
+
iptv_stream_validator/__init__.py,sha256=8jaqRjJAIay8zkad1eWeOEHa24Z_iP3w8OmC_jsrVz8,1132
|
|
4
|
+
iptv_stream_validator-0.1.0.dist-info/licenses/LICENSE,sha256=uabJvcJm0LbjcybVymVZUh9o1WLPbfFfBooWFX3jhpk,1066
|
|
5
|
+
iptv_stream_validator-0.1.0.dist-info/METADATA,sha256=nvXHOQ5fXyIIP5MCffkyA4jpYCId0TPXaMqggMGJRHw,8521
|
|
6
|
+
iptv_stream_validator-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
iptv_stream_validator-0.1.0.dist-info/top_level.txt,sha256=MUcrVfvy66OYsQuq4GdgUxIdPJ775Nov9GA1CavW2Ko,22
|
|
8
|
+
iptv_stream_validator-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
iptv_stream_validator
|