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 +10 -0
- stb_reader/_http.py +105 -0
- stb_reader/auth.py +28 -0
- stb_reader/client.py +24 -0
- stb_reader/exceptions.py +14 -0
- stb_reader/live_tv.py +87 -0
- stb_reader/models.py +77 -0
- stb_reader/vod.py +161 -0
- stb_reader-0.1.0.dist-info/METADATA +153 -0
- stb_reader-0.1.0.dist-info/RECORD +12 -0
- stb_reader-0.1.0.dist-info/WHEEL +4 -0
- stb_reader-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
stb_reader/exceptions.py
ADDED
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,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.
|