stb-reader 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.
stb_reader/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from .client import STBClient
2
+ from .models import Genre, Channel, Category, Content, Season, Episode, EpisodeFile, PagedResult
3
+ from .exceptions import STBError, AuthError, StreamError, NotFoundError
4
+
5
+ __all__ = [
6
+ "STBClient",
7
+ "Genre", "Channel", "Category", "Content",
8
+ "Season", "Episode", "EpisodeFile", "PagedResult",
9
+ "STBError", "AuthError", "StreamError", "NotFoundError",
10
+ ]
stb_reader/_http.py ADDED
@@ -0,0 +1,105 @@
1
+ import logging
2
+ import threading
3
+ from collections.abc import Callable
4
+ from urllib.parse import urlparse
5
+ import requests
6
+ from .exceptions import AuthError, STBError, StreamError
7
+
8
+ _reauth_local = threading.local()
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _USER_AGENT = "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 2 rev: 250 Safari/533.3"
13
+ _X_USER_AGENT = "Model: MAG200; Link: WiFi"
14
+
15
+ _AUTH_FAILURE_PHRASES = {"Authorization failed", "Access denied"}
16
+ _REQUEST_TIMEOUT = 10
17
+
18
+
19
+ def _as_list(data) -> list:
20
+ """Normalise portal responses that return either a list or {"data": [...]}."""
21
+ return data if isinstance(data, list) else data.get("data", [])
22
+
23
+
24
+ def _is_auth_failure(text: str) -> bool:
25
+ return any(phrase in text for phrase in _AUTH_FAILURE_PHRASES)
26
+
27
+
28
+ class STBSession:
29
+ def __init__(self, base_url: str, mac: str, serial: str, lang: str, timezone: str, portal_path: str = "stalker_portal/c/portal.php") -> None:
30
+ self.base_url = base_url.rstrip("/")
31
+ self.mac = mac
32
+ self.serial = serial
33
+ self.lang = lang
34
+ self.timezone = timezone
35
+ self.portal_path = portal_path.strip("/")
36
+ self.token = ""
37
+ self.signature = ""
38
+ self.extra_headers: dict = {}
39
+ self.reauth_fn: Callable[[], None] | None = None
40
+ self._reauth_lock = threading.Lock()
41
+ self._cookies = {"stb_lang": lang, "mac": mac, "timezone": timezone}
42
+ parsed = urlparse(self.base_url)
43
+ self._base_headers = {
44
+ "User-Agent": _USER_AGENT,
45
+ "X-User-Agent": _X_USER_AGENT,
46
+ "Accept-Language": "en,*",
47
+ "Connection": "Keep-Alive",
48
+ "Host": parsed.hostname,
49
+ "Referer": self.base_url + "/",
50
+ }
51
+ self._session = requests.Session()
52
+
53
+ def get(self, type_: str, action: str, _retry: bool = False, **params) -> dict:
54
+ url = f"{self.base_url}/{self.portal_path}"
55
+ query = {"JsHttpRequest": "1-xml", "type": type_, "action": action, **params}
56
+ self._cookies["token"] = self.token
57
+ headers = {**self._base_headers, "Authorization": f"Bearer {self.token}", **self.extra_headers}
58
+ resp = self._session.get(url, params=query, headers=headers, cookies=self._cookies, timeout=_REQUEST_TIMEOUT)
59
+ logger.debug("Response [%s %s]: %s", resp.status_code, action, resp.text[:500])
60
+ if not resp.ok:
61
+ raise STBError(f"HTTP {resp.status_code}: {resp.text[:200]}")
62
+ if _is_auth_failure(resp.text):
63
+ if self.reauth_fn and not _retry and not getattr(_reauth_local, 'active', False):
64
+ with self._reauth_lock:
65
+ _reauth_local.active = True
66
+ try:
67
+ logger.debug("Auth failure on %s, re-authenticating", action)
68
+ self.reauth_fn()
69
+ finally:
70
+ _reauth_local.active = False
71
+ return self.get(type_, action, _retry=True, **params)
72
+ raise AuthError(f"Portal rejected request ({action}): {resp.text[:100]}")
73
+ try:
74
+ return resp.json()["js"]
75
+ except (KeyError, ValueError):
76
+ raise STBError(f"Invalid JSON response (status {resp.status_code}): {resp.text[:200]}")
77
+
78
+ def open_url(self, url: str) -> requests.Response:
79
+ """Fetch a full URL for streaming (no portal auth needed, e.g. CDN URLs)."""
80
+ resp = self._session.get(url, stream=True, timeout=_REQUEST_TIMEOUT)
81
+ logger.debug("Stream response [%s]: %s", resp.status_code, resp.headers.get("content-type"))
82
+ if not resp.ok:
83
+ raise StreamError(f"stream fetch failed ({resp.status_code})")
84
+ return resp
85
+
86
+ def open_stream(self, cmd: str) -> requests.Response:
87
+ """Open a streaming request for a portal-relative ?token= URL.
88
+
89
+ Uses only base MAG device headers (no Authorization/X-Random) so
90
+ load.php sees a genuine STB stream request rather than an API call.
91
+ Session token travels in Cookie: token= for session validation;
92
+ play token travels in the URL (?token=) for media lookup.
93
+ """
94
+ full_url = f"{self.base_url}/{self.portal_path}{cmd}"
95
+ self._cookies["token"] = self.token
96
+ resp = self._session.get(full_url, headers=self._base_headers, cookies=self._cookies, stream=True, timeout=_REQUEST_TIMEOUT)
97
+ ct = resp.headers.get("content-type", "")
98
+ logger.debug("Stream response [%s]: %s", resp.status_code, ct)
99
+ if not resp.ok:
100
+ raise StreamError(f"stream fetch failed ({resp.status_code})")
101
+ if "json" in ct:
102
+ body = resp.json()
103
+ logger.error("load.php stream JSON: %s", body)
104
+ raise StreamError(f"portal rejected stream: {body}")
105
+ return resp
stb_reader/auth.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+ import hashlib
3
+ import os
4
+ from typing import TYPE_CHECKING
5
+ from .exceptions import AuthError
6
+
7
+ if TYPE_CHECKING:
8
+ from ._http import STBSession
9
+
10
+
11
+ def handshake(session: "STBSession") -> None:
12
+ data = session.get("stb", "handshake")
13
+ token = data.get("token", "")
14
+ if not token:
15
+ raise AuthError("handshake returned no token")
16
+ session.token = token
17
+ random_token = data.get("random")
18
+ if random_token:
19
+ signature = hashlib.sha256(random_token.encode()).hexdigest().upper()
20
+ session.extra_headers["X-Random"] = random_token
21
+ session.extra_headers["Random"] = random_token
22
+ else:
23
+ signature = hashlib.sha256(os.urandom(32)).hexdigest().upper()
24
+ session.signature = signature
25
+
26
+
27
+ def get_profile(session: "STBSession") -> dict:
28
+ return session.get("stb", "get_profile")
stb_reader/client.py ADDED
@@ -0,0 +1,24 @@
1
+ from ._http import STBSession
2
+ from .auth import handshake, get_profile
3
+ from .live_tv import ITVService
4
+ from .vod import VODService
5
+
6
+
7
+ class STBClient:
8
+ def __init__(
9
+ self,
10
+ base_url: str,
11
+ mac: str,
12
+ serial: str = "000000000000",
13
+ lang: str = "en",
14
+ timezone: str = "Europe/London",
15
+ portal_path: str = "stalker_portal/c/portal.php",
16
+ ) -> None:
17
+ self._session = STBSession(base_url, mac, serial, lang, timezone, portal_path)
18
+ self._session.reauth_fn = self.authenticate
19
+ self.live_tv = ITVService(self._session)
20
+ self.vod = VODService(self._session)
21
+
22
+ def authenticate(self) -> None:
23
+ handshake(self._session)
24
+ get_profile(self._session)
@@ -0,0 +1,14 @@
1
+ class STBError(Exception):
2
+ pass
3
+
4
+
5
+ class AuthError(STBError):
6
+ pass
7
+
8
+
9
+ class StreamError(STBError):
10
+ pass
11
+
12
+
13
+ class NotFoundError(STBError):
14
+ pass
stb_reader/live_tv.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from .models import Genre, Channel, PagedResult
4
+ from .exceptions import NotFoundError, STBError, StreamError
5
+ from ._http import _as_list
6
+
7
+ if TYPE_CHECKING:
8
+ from ._http import STBSession
9
+
10
+
11
+ def _clean_url(url: str) -> str:
12
+ for prefix in ("ffmpeg ", "auto "):
13
+ if url.startswith(prefix):
14
+ url = url[len(prefix):]
15
+ return url
16
+
17
+
18
+ class ITVService:
19
+ def __init__(self, session: "STBSession") -> None:
20
+ self._s = session
21
+
22
+ def get_genres(self) -> list[Genre]:
23
+ data = self._s.get("itv", "get_genres")
24
+ return [
25
+ Genre(
26
+ id=str(g["id"]),
27
+ title=g.get("title", ""),
28
+ alias=g.get("alias", ""),
29
+ censored=bool(g.get("censored", False)),
30
+ )
31
+ for g in _as_list(data)
32
+ ]
33
+
34
+ def get_channels(
35
+ self,
36
+ genre_id: str = "*",
37
+ page: int = 1,
38
+ sort: str = "number",
39
+ hd: bool = False,
40
+ fav: bool = False,
41
+ ) -> PagedResult[Channel]:
42
+ raw = self._s.get(
43
+ "itv",
44
+ "get_ordered_list",
45
+ genre=genre_id,
46
+ p=page - 1,
47
+ sortby=sort,
48
+ hd=int(hd),
49
+ fav=int(fav),
50
+ )
51
+ items = [
52
+ Channel(
53
+ id=str(c["id"]),
54
+ number=str(c.get("number", "")),
55
+ name=c.get("name", ""),
56
+ cmd=c.get("cmd", ""),
57
+ logo=c.get("logo", ""),
58
+ genre_id=str(c.get("tv_genre_id", "")),
59
+ hd=bool(c.get("hd", False)),
60
+ censored=bool(c.get("censored", False)),
61
+ )
62
+ for c in raw.get("data", [])
63
+ ]
64
+ return PagedResult(
65
+ items=items,
66
+ total=int(raw.get("total_items", 0)),
67
+ page=page,
68
+ per_page=int(raw.get("max_page_items", len(items))),
69
+ )
70
+
71
+ def get_stream_url(self, cmd: str) -> str:
72
+ raw = self._s.get("itv", "create_link", cmd=cmd)
73
+ if raw.get("error"):
74
+ raise StreamError(raw["error"])
75
+ url = raw.get("cmd", "")
76
+ return _clean_url(url)
77
+
78
+ def get_stream_url_by_id(self, channel_id: str) -> str:
79
+ page = 1
80
+ while True:
81
+ result = self.get_channels(genre_id="*", page=page)
82
+ for ch in result.items:
83
+ if ch.id == str(channel_id):
84
+ return self.get_stream_url(ch.cmd)
85
+ if not result.items or page * result.per_page >= result.total:
86
+ raise NotFoundError("channel not found")
87
+ page += 1
stb_reader/models.py ADDED
@@ -0,0 +1,77 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Generic, TypeVar
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ @dataclass
8
+ class Genre:
9
+ id: str
10
+ title: str
11
+ alias: str
12
+ censored: bool
13
+
14
+
15
+ @dataclass
16
+ class Channel:
17
+ id: str
18
+ number: str
19
+ name: str
20
+ cmd: str
21
+ logo: str
22
+ genre_id: str
23
+ hd: bool
24
+ censored: bool
25
+
26
+
27
+ @dataclass
28
+ class Category:
29
+ id: str
30
+ title: str
31
+ alias: str
32
+ censored: bool
33
+
34
+
35
+ @dataclass
36
+ class Content:
37
+ id: str
38
+ name: str
39
+ cmd: str
40
+ screenshot_uri: str
41
+ genres: str
42
+ year: str
43
+ description: str
44
+ rating: str
45
+ duration: str
46
+ is_series: bool
47
+ fav: bool
48
+
49
+
50
+ @dataclass
51
+ class Season:
52
+ id: str
53
+ name: str
54
+ video_id: str
55
+
56
+
57
+ @dataclass
58
+ class Episode:
59
+ id: str
60
+ name: str
61
+ series_number: str
62
+ cmd: str
63
+
64
+
65
+ @dataclass
66
+ class EpisodeFile:
67
+ id: str
68
+ name: str
69
+ cmd: str
70
+
71
+
72
+ @dataclass
73
+ class PagedResult(Generic[T]):
74
+ items: list[T]
75
+ total: int
76
+ page: int
77
+ per_page: int
stb_reader/vod.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+ import re
3
+ from typing import TYPE_CHECKING
4
+ from .models import Category, Content, Season, Episode, EpisodeFile, PagedResult
5
+ from .exceptions import NotFoundError, STBError, StreamError
6
+ from ._http import _as_list
7
+ from .live_tv import _clean_url
8
+
9
+ if TYPE_CHECKING:
10
+ from ._http import STBSession
11
+
12
+
13
+ ADULT_TERMS = re.compile(r"adult|18\+", re.IGNORECASE)
14
+
15
+
16
+ class VODService:
17
+ def __init__(self, session: "STBSession") -> None:
18
+ self._s = session
19
+
20
+ def get_categories(self) -> list[Category]:
21
+ data = self._s.get("vod", "get_categories")
22
+ return [
23
+ Category(
24
+ id=str(c["id"]),
25
+ title=c.get("title", ""),
26
+ alias=c.get("alias", ""),
27
+ censored=bool(c.get("censored", False)),
28
+ )
29
+ for c in _as_list(data)
30
+ if not ADULT_TERMS.search(c.get("title", ""))
31
+ ]
32
+
33
+ def get_content(
34
+ self,
35
+ category_id: str = "*",
36
+ page: int = 1,
37
+ sort: str = "added",
38
+ fav: bool = False,
39
+ ) -> PagedResult[Content]:
40
+ raw = self._s.get(
41
+ "vod",
42
+ "get_ordered_list",
43
+ category=category_id,
44
+ p=page,
45
+ sortby=sort,
46
+ fav=int(fav),
47
+ not_ended=0,
48
+ )
49
+ items = [
50
+ Content(
51
+ id=str(c["id"]),
52
+ name=c.get("name", ""),
53
+ cmd=c.get("cmd", ""),
54
+ screenshot_uri=c.get("screenshot_uri", ""),
55
+ genres=c.get("genres_str", ""),
56
+ year=str(c.get("year", "")),
57
+ description=c.get("description", ""),
58
+ rating=str(c.get("rating_imdb", "")),
59
+ duration=str(c.get("time", "")),
60
+ is_series=bool(int(c.get("is_series") or 0)),
61
+ fav=bool(c.get("fav", False)),
62
+ )
63
+ for c in raw.get("data", [])
64
+ ]
65
+ return PagedResult(
66
+ items=items,
67
+ total=int(raw.get("total_items", 0)),
68
+ page=page,
69
+ per_page=int(raw.get("max_page_items", len(items))),
70
+ )
71
+
72
+ def get_seasons(self, series_id: str) -> list[Season]:
73
+ raw = self._s.get(
74
+ "vod",
75
+ "get_ordered_list",
76
+ movie_id=series_id,
77
+ season_id=0,
78
+ episode_id=0,
79
+ )
80
+ return [
81
+ Season(
82
+ id=str(s["id"]),
83
+ name=s.get("name", ""),
84
+ video_id=str(s.get("video_id", "")),
85
+ )
86
+ for s in raw.get("data", [])
87
+ ]
88
+
89
+ def get_episodes(self, series_id: str, season_id: str, delay_s: float = 0) -> list[Episode]:
90
+ import time
91
+ episodes: list[Episode] = []
92
+ page = 1
93
+ while True:
94
+ if delay_s > 0 and page > 1:
95
+ time.sleep(delay_s)
96
+ raw = self._s.get(
97
+ "vod",
98
+ "get_ordered_list",
99
+ movie_id=series_id,
100
+ season_id=season_id,
101
+ episode_id=0,
102
+ p=page,
103
+ )
104
+ episodes.extend(
105
+ Episode(
106
+ id=str(e["id"]),
107
+ name=e.get("name", ""),
108
+ series_number=str(e.get("series_number", "")),
109
+ cmd=e.get("cmd", ""),
110
+ )
111
+ for e in raw.get("data", [])
112
+ )
113
+ total = int(raw.get("total_items", 0))
114
+ per_page = int(raw.get("max_page_items", total)) or total
115
+ if per_page == 0 or page * per_page >= total:
116
+ break
117
+ page += 1
118
+ return episodes
119
+
120
+ def get_episode_files(self, series_id: str, season_id: str, episode_id: str) -> list[EpisodeFile]:
121
+ raw = self._s.get(
122
+ "vod",
123
+ "get_ordered_list",
124
+ movie_id=series_id,
125
+ season_id=season_id,
126
+ episode_id=episode_id,
127
+ )
128
+ return [
129
+ EpisodeFile(
130
+ id=str(f["id"]),
131
+ name=f.get("name", ""),
132
+ cmd=f.get("cmd", ""),
133
+ )
134
+ for f in raw.get("data", [])
135
+ ]
136
+
137
+ def get_stream_url_by_file_id(
138
+ self, series_id: str, season_id: str, episode_id: str, file_id: str
139
+ ) -> str:
140
+ files = self.get_episode_files(series_id, season_id, episode_id)
141
+ for f in files:
142
+ if f.id == str(file_id):
143
+ return self.get_stream_url(f.cmd)
144
+ raise NotFoundError("file not found")
145
+
146
+ def get_stream_url(self, cmd: str) -> str:
147
+ raw = self._s.get("vod", "create_link", cmd=cmd)
148
+ if raw.get("error"):
149
+ raise StreamError(raw["error"])
150
+ url = raw.get("cmd", raw.get("url", ""))
151
+ return _clean_url(url)
152
+
153
+ def get_stream_url_by_first_file(self, series_id: str, season_id: str, episode_id: str) -> str:
154
+ files = self.get_episode_files(series_id, season_id, episode_id)
155
+ if not files:
156
+ raise NotFoundError("no files for episode")
157
+ return self.get_stream_url(files[0].cmd)
158
+
159
+ def get_stream_url_by_content_id(self, content_id: str) -> str:
160
+ return self.get_stream_url(f"/media/{content_id}.mpg")
161
+
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: stb-reader
3
+ Version: 0.1.0
4
+ Summary: Python client library for Ministra/Stalker STB portals
5
+ Project-URL: Homepage, https://github.com/shubhsheth/stb-reader
6
+ Project-URL: Repository, https://github.com/shubhsheth/stb-reader
7
+ Author: shubhsheth
8
+ License: MIT License
9
+
10
+ Copyright (c) 2024 shubhsheth
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: iptv,ministra,portal,stalker,stb
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Topic :: Multimedia :: Video
38
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
39
+ Requires-Python: >=3.11
40
+ Requires-Dist: requests
41
+ Provides-Extra: test
42
+ Requires-Dist: httpx; extra == 'test'
43
+ Requires-Dist: pytest; extra == 'test'
44
+ Requires-Dist: pytest-cov; extra == 'test'
45
+ Requires-Dist: responses; extra == 'test'
46
+ Requires-Dist: twine; extra == 'test'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # stb-reader
50
+
51
+ Python client library for [Ministra/Stalker](https://ministra.com/) STB portals. Retrieve live-TV channels, VOD content, series, episodes, and stream URLs with simple method calls.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install stb-reader
57
+ ```
58
+
59
+ Requires Python 3.11+ and has a single runtime dependency: `requests`.
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from stb_reader import STBClient
65
+
66
+ client = STBClient(
67
+ base_url="http://your-portal.example.com",
68
+ mac="00:1A:79:XX:XX:XX",
69
+ )
70
+ client.authenticate()
71
+
72
+ # Live TV
73
+ genres = client.live_tv.get_genres()
74
+ channels = client.live_tv.get_channels(genre_id="*", page=1)
75
+ stream_url = client.live_tv.get_stream_url(channels.items[0].cmd)
76
+
77
+ # VOD
78
+ categories = client.vod.get_categories()
79
+ content = client.vod.get_content(category_id="*", page=1)
80
+
81
+ # Series
82
+ seasons = client.vod.get_seasons(series_id="123")
83
+ episodes = client.vod.get_episodes(series_id="123", season_id=seasons[0].id)
84
+ stream_url = client.vod.get_stream_url_by_first_file(
85
+ series_id="123",
86
+ season_id=seasons[0].id,
87
+ episode_id=episodes[0].id,
88
+ )
89
+ ```
90
+
91
+ ## API reference
92
+
93
+ ### `STBClient(base_url, mac, serial, lang, timezone, portal_path)`
94
+
95
+ | Parameter | Default | Description |
96
+ |-----------|---------|-------------|
97
+ | `base_url` | required | Portal base URL |
98
+ | `mac` | required | Device MAC address |
99
+ | `serial` | `"000000000000"` | Device serial |
100
+ | `lang` | `"en"` | Portal language |
101
+ | `timezone` | `"Europe/London"` | Portal timezone |
102
+ | `portal_path` | `"stalker_portal/c/portal.php"` | Path to portal PHP endpoint |
103
+
104
+ ### `client.live_tv`
105
+
106
+ | Method | Returns | Description |
107
+ |--------|---------|-------------|
108
+ | `get_genres()` | `list[Genre]` | All channel genres |
109
+ | `get_channels(genre_id, page, sort, hd, fav)` | `PagedResult[Channel]` | Paginated channel list |
110
+ | `get_stream_url(cmd)` | `str` | Resolved stream URL for a channel `cmd` |
111
+ | `get_stream_url_by_id(channel_id)` | `str` | Resolved stream URL by channel ID |
112
+
113
+ ### `client.vod`
114
+
115
+ | Method | Returns | Description |
116
+ |--------|---------|-------------|
117
+ | `get_categories()` | `list[Category]` | All VOD categories |
118
+ | `get_content(category_id, page, sort, fav)` | `PagedResult[Content]` | Paginated VOD content |
119
+ | `get_seasons(series_id)` | `list[Season]` | Seasons for a series |
120
+ | `get_episodes(series_id, season_id)` | `list[Episode]` | All episodes in a season |
121
+ | `get_episode_files(series_id, season_id, episode_id)` | `list[EpisodeFile]` | Quality variants for an episode |
122
+ | `get_stream_url(cmd)` | `str` | Resolved stream URL for a VOD `cmd` |
123
+ | `get_stream_url_by_content_id(content_id)` | `str` | Stream URL for a movie by ID |
124
+ | `get_stream_url_by_first_file(series_id, season_id, episode_id)` | `str` | Stream URL for first file of an episode |
125
+ | `get_stream_url_by_file_id(series_id, season_id, episode_id, file_id)` | `str` | Stream URL for a specific file |
126
+
127
+ ## Exceptions
128
+
129
+ All exceptions are importable from `stb_reader`:
130
+
131
+ | Exception | Raised when |
132
+ |-----------|-------------|
133
+ | `STBError` | Base class for all library errors |
134
+ | `AuthError` | Authentication / token failure |
135
+ | `StreamError` | Portal rejects a stream request |
136
+ | `NotFoundError` | Requested item not found |
137
+
138
+ ## Documentation
139
+
140
+ Full guides are in [`docs/guide/`](docs/guide/):
141
+
142
+ - [Getting started](docs/guide/getting-started.md) — installation, configuration, first call
143
+ - [Authentication](docs/guide/authentication.md) — token lifecycle, auto-reauth, error handling
144
+ - [Live TV](docs/guide/live-tv.md) — genres, channels, stream URLs
145
+ - [VOD — Movies](docs/guide/vod.md) — categories, content listing, movie streams
146
+ - [Series](docs/guide/series.md) — seasons, episodes, quality selection
147
+ - [Pagination](docs/guide/pagination.md) — `PagedResult`, fetch-all-pages pattern
148
+ - [Error handling](docs/guide/error-handling.md) — all exceptions, recovery patterns
149
+ - [API reference](docs/guide/api-reference.md) — complete method, model, and exception reference
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,12 @@
1
+ stb_reader/__init__.py,sha256=UckQyZYtvb-JxAt-3xFG01Y2ykk3Q444_AFmM7jliXg,394
2
+ stb_reader/_http.py,sha256=1di6ERDMKjQK9I9gd4s3MugRiwwfoVObXrpGgsAMunc,4819
3
+ stb_reader/auth.py,sha256=X98xjNWJN7Mm9vzuDLuOsyS6TPYCD-NtVnBIhpG688k,863
4
+ stb_reader/client.py,sha256=nBxJT1EjRH_G4lxAMHQrTmmbsBZPIQmNaxW1I8jl22I,739
5
+ stb_reader/exceptions.py,sha256=6x8PR4rDvpkCHyT7Haqh6fih-jqoNwyndk2EdN2kl7s,156
6
+ stb_reader/live_tv.py,sha256=A5zV4cEVwUQVodJV9N6RD-wIbbqzyYpHO5HNnh7EIgk,2655
7
+ stb_reader/models.py,sha256=3iLkVqyLw7mauDDVHtDCYwtayWz6Q6CgKdJEg9Ekudc,971
8
+ stb_reader/vod.py,sha256=CRVSosARKCAHCgjMVSi6kDIpd-llpR2bF03smbC0sT8,5239
9
+ stb_reader-0.1.0.dist-info/METADATA,sha256=lsQM3L-izjVlIBbvgWKpDmH8q_n_7-IpxsS96HDNPEQ,6229
10
+ stb_reader-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ stb_reader-0.1.0.dist-info/licenses/LICENSE,sha256=ugSjSVoNnMFluHM6mhnMGPiBefvxaqlVR3MzjL4oXzE,1067
12
+ stb_reader-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 shubhsheth
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.