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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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