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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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