stb-reader 0.2.0__tar.gz → 0.2.1__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.
- {stb_reader-0.2.0 → stb_reader-0.2.1}/AGENTS.md +5 -3
- {stb_reader-0.2.0 → stb_reader-0.2.1}/PKG-INFO +16 -56
- stb_reader-0.2.1/README.md +65 -0
- stb_reader-0.2.1/docs/guide/cli.md +206 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/pyproject.toml +1 -1
- stb_reader-0.2.1/spec/016-persist-cli-token/016-persist-cli-token-requirements.md +150 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/_http.py +1 -1
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/auth.py +1 -10
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/config.py +32 -1
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/vod.py +1 -1
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/models.py +2 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/vod.py +2 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/conftest.py +6 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_auth.py +0 -14
- stb_reader-0.2.1/tests/test_cli_config.py +161 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_vod.py +25 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/uv.lock +15 -1
- stb_reader-0.2.0/README.md +0 -105
- stb_reader-0.2.0/tests/test_cli_config.py +0 -72
- {stb_reader-0.2.0 → stb_reader-0.2.1}/.claude/skills/incremental-implementation/SKILL.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/.claude/skills/planning-and-task-breakdown/SKILL.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/.claude/skills/spec-driven-development/SKILL.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/.github/workflows/ci.yml +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/.github/workflows/publish.yml +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/.gitignore +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/CLAUDE.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/LICENSE +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/api-reference.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/authentication.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/error-handling.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/getting-started.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/live-tv.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/pagination.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/series.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/vod.md +0 -0
- {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/README.md +0 -0
- {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/authentication.md +0 -0
- {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/live-tv.md +0 -0
- {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/vod-series.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/012-library-only/012-library-only-plan.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/012-library-only/012-library-only-requirements.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/013-documentation/013-documentation-plan.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/013-documentation/013-documentation-requirements.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/014-library-api-improvements/014-library-api-improvements-requirements.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/015-stb-cli/015-stb-cli-requirements.md +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/__init__.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/__init__.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/formatting.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/live.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/main.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/client.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/exceptions.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/live_tv.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/__init__.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_cli_formatting.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_cli_live.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_cli_vod.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_http.py +0 -0
- {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_live_tv.py +0 -0
|
@@ -25,7 +25,8 @@ stb_reader/ Sole importable package
|
|
|
25
25
|
exceptions.py STBError, AuthError, StreamError, NotFoundError
|
|
26
26
|
|
|
27
27
|
tests/ pytest suite; all HTTP mocked via `responses` library
|
|
28
|
-
docs/
|
|
28
|
+
docs/protocol/ STB protocol reference (authentication, live-tv, vod-series)
|
|
29
|
+
docs/guide/ User-facing guides (cli.md)
|
|
29
30
|
spec/ Spec-driven feature specs (NNN-slug/{requirements,plan,implement}.md)
|
|
30
31
|
|
|
31
32
|
.github/
|
|
@@ -95,6 +96,7 @@ The `PYPI_TOKEN` secret must be configured in the GitHub repository settings.
|
|
|
95
96
|
|
|
96
97
|
## Documentation
|
|
97
98
|
|
|
98
|
-
- `docs/` contains protocol-level reference for the Ministra/Stalker STB API
|
|
99
|
-
-
|
|
99
|
+
- `docs/protocol/` contains protocol-level reference for the Ministra/Stalker STB API
|
|
100
|
+
- `docs/guide/` contains user-facing guides (CLI, library usage)
|
|
101
|
+
- Update the relevant `docs/protocol/` file when changing protocol behavior
|
|
100
102
|
- Update this file (`AGENTS.md`) when adding commands, models, or boundaries
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stb-reader
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python client library for Ministra/Stalker STB portals
|
|
5
5
|
Project-URL: Homepage, https://github.com/shubhsheth/stb-reader
|
|
6
6
|
Project-URL: Repository, https://github.com/shubhsheth/stb-reader
|
|
@@ -49,17 +49,27 @@ Description-Content-Type: text/markdown
|
|
|
49
49
|
|
|
50
50
|
# stb-reader
|
|
51
51
|
|
|
52
|
-
Python client library for [Ministra/Stalker](https://ministra.com/) STB portals.
|
|
52
|
+
Python client library and CLI for [Ministra/Stalker](https://ministra.com/) STB portals. Browse live TV channels, VOD content, and series, or resolve stream URLs — from Python code or straight from the terminal.
|
|
53
53
|
|
|
54
|
-
##
|
|
54
|
+
## Install
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
57
|
pip install stb-reader
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
Requires Python 3.11
|
|
60
|
+
Requires Python 3.11+.
|
|
61
61
|
|
|
62
|
-
## Quick start
|
|
62
|
+
## Quick start — CLI
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
stb init # save portal URL and MAC address once
|
|
66
|
+
stb live channels # browse channels
|
|
67
|
+
stb stream --type live <cmd> # get a stream URL
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
See the [CLI reference](docs/guide/cli.md) for all commands.
|
|
71
|
+
|
|
72
|
+
## Quick start — Python
|
|
63
73
|
|
|
64
74
|
```python
|
|
65
75
|
from stb_reader import STBClient
|
|
@@ -71,12 +81,10 @@ client = STBClient(
|
|
|
71
81
|
client.authenticate()
|
|
72
82
|
|
|
73
83
|
# Live TV
|
|
74
|
-
genres = client.live_tv.get_genres()
|
|
75
84
|
channels = client.live_tv.get_channels(genre_id="*", page=1)
|
|
76
85
|
stream_url = client.live_tv.get_stream_url(channels.items[0].cmd)
|
|
77
86
|
|
|
78
87
|
# VOD
|
|
79
|
-
categories = client.vod.get_categories()
|
|
80
88
|
content = client.vod.get_content(category_id="*", page=1)
|
|
81
89
|
|
|
82
90
|
# Series
|
|
@@ -89,57 +97,9 @@ stream_url = client.vod.get_stream_url_by_first_file(
|
|
|
89
97
|
)
|
|
90
98
|
```
|
|
91
99
|
|
|
92
|
-
## API reference
|
|
93
|
-
|
|
94
|
-
### `STBClient(base_url, mac, serial, lang, timezone, portal_path)`
|
|
95
|
-
|
|
96
|
-
| Parameter | Default | Description |
|
|
97
|
-
|-----------|---------|-------------|
|
|
98
|
-
| `base_url` | required | Portal base URL |
|
|
99
|
-
| `mac` | required | Device MAC address |
|
|
100
|
-
| `serial` | `"000000000000"` | Device serial |
|
|
101
|
-
| `lang` | `"en"` | Portal language |
|
|
102
|
-
| `timezone` | `"Europe/London"` | Portal timezone |
|
|
103
|
-
| `portal_path` | `"stalker_portal/c/portal.php"` | Path to portal PHP endpoint |
|
|
104
|
-
|
|
105
|
-
### `client.live_tv`
|
|
106
|
-
|
|
107
|
-
| Method | Returns | Description |
|
|
108
|
-
|--------|---------|-------------|
|
|
109
|
-
| `get_genres()` | `list[Genre]` | All channel genres |
|
|
110
|
-
| `get_channels(genre_id, page, sort, hd, fav)` | `PagedResult[Channel]` | Paginated channel list |
|
|
111
|
-
| `get_stream_url(cmd)` | `str` | Resolved stream URL for a channel `cmd` |
|
|
112
|
-
| `get_stream_url_by_id(channel_id)` | `str` | Resolved stream URL by channel ID |
|
|
113
|
-
|
|
114
|
-
### `client.vod`
|
|
115
|
-
|
|
116
|
-
| Method | Returns | Description |
|
|
117
|
-
|--------|---------|-------------|
|
|
118
|
-
| `get_categories()` | `list[Category]` | All VOD categories |
|
|
119
|
-
| `get_content(category_id, page, sort, fav)` | `PagedResult[Content]` | Paginated VOD content |
|
|
120
|
-
| `get_seasons(series_id)` | `list[Season]` | Seasons for a series |
|
|
121
|
-
| `get_episodes(series_id, season_id)` | `list[Episode]` | All episodes in a season |
|
|
122
|
-
| `get_episode_files(series_id, season_id, episode_id)` | `list[EpisodeFile]` | Quality variants for an episode |
|
|
123
|
-
| `get_stream_url(cmd)` | `str` | Resolved stream URL for a VOD `cmd` |
|
|
124
|
-
| `get_stream_url_by_content_id(content_id)` | `str` | Stream URL for a movie by ID |
|
|
125
|
-
| `get_stream_url_by_first_file(series_id, season_id, episode_id)` | `str` | Stream URL for first file of an episode |
|
|
126
|
-
| `get_stream_url_by_file_id(series_id, season_id, episode_id, file_id)` | `str` | Stream URL for a specific file |
|
|
127
|
-
|
|
128
|
-
## Exceptions
|
|
129
|
-
|
|
130
|
-
All exceptions are importable from `stb_reader`:
|
|
131
|
-
|
|
132
|
-
| Exception | Raised when |
|
|
133
|
-
|-----------|-------------|
|
|
134
|
-
| `STBError` | Base class for all library errors |
|
|
135
|
-
| `AuthError` | Authentication / token failure |
|
|
136
|
-
| `StreamError` | Portal rejects a stream request |
|
|
137
|
-
| `NotFoundError` | Requested item not found |
|
|
138
|
-
|
|
139
100
|
## Documentation
|
|
140
101
|
|
|
141
|
-
|
|
142
|
-
|
|
102
|
+
- [CLI reference](docs/guide/cli.md) — `stb` command-line tool
|
|
143
103
|
- [Getting started](docs/guide/getting-started.md) — installation, configuration, first call
|
|
144
104
|
- [Authentication](docs/guide/authentication.md) — token lifecycle, auto-reauth, error handling
|
|
145
105
|
- [Live TV](docs/guide/live-tv.md) — genres, channels, stream URLs
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# stb-reader
|
|
2
|
+
|
|
3
|
+
Python client library and CLI for [Ministra/Stalker](https://ministra.com/) STB portals. Browse live TV channels, VOD content, and series, or resolve stream URLs — from Python code or straight from the terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install stb-reader
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.11+.
|
|
12
|
+
|
|
13
|
+
## Quick start — CLI
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
stb init # save portal URL and MAC address once
|
|
17
|
+
stb live channels # browse channels
|
|
18
|
+
stb stream --type live <cmd> # get a stream URL
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
See the [CLI reference](docs/guide/cli.md) for all commands.
|
|
22
|
+
|
|
23
|
+
## Quick start — Python
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from stb_reader import STBClient
|
|
27
|
+
|
|
28
|
+
client = STBClient(
|
|
29
|
+
base_url="http://your-portal.example.com",
|
|
30
|
+
mac="00:1A:79:XX:XX:XX",
|
|
31
|
+
)
|
|
32
|
+
client.authenticate()
|
|
33
|
+
|
|
34
|
+
# Live TV
|
|
35
|
+
channels = client.live_tv.get_channels(genre_id="*", page=1)
|
|
36
|
+
stream_url = client.live_tv.get_stream_url(channels.items[0].cmd)
|
|
37
|
+
|
|
38
|
+
# VOD
|
|
39
|
+
content = client.vod.get_content(category_id="*", page=1)
|
|
40
|
+
|
|
41
|
+
# Series
|
|
42
|
+
seasons = client.vod.get_seasons(series_id="123")
|
|
43
|
+
episodes = client.vod.get_episodes(series_id="123", season_id=seasons[0].id)
|
|
44
|
+
stream_url = client.vod.get_stream_url_by_first_file(
|
|
45
|
+
series_id="123",
|
|
46
|
+
season_id=seasons[0].id,
|
|
47
|
+
episode_id=episodes[0].id,
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
- [CLI reference](docs/guide/cli.md) — `stb` command-line tool
|
|
54
|
+
- [Getting started](docs/guide/getting-started.md) — installation, configuration, first call
|
|
55
|
+
- [Authentication](docs/guide/authentication.md) — token lifecycle, auto-reauth, error handling
|
|
56
|
+
- [Live TV](docs/guide/live-tv.md) — genres, channels, stream URLs
|
|
57
|
+
- [VOD — Movies](docs/guide/vod.md) — categories, content listing, movie streams
|
|
58
|
+
- [Series](docs/guide/series.md) — seasons, episodes, quality selection
|
|
59
|
+
- [Pagination](docs/guide/pagination.md) — `PagedResult`, fetch-all-pages pattern
|
|
60
|
+
- [Error handling](docs/guide/error-handling.md) — all exceptions, recovery patterns
|
|
61
|
+
- [API reference](docs/guide/api-reference.md) — complete method, model, and exception reference
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# CLI Reference
|
|
2
|
+
|
|
3
|
+
The `stb` command-line tool lets you browse and stream STB portal content from a terminal.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install stb-reader
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Verify the tool is available:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
stb --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Run `stb init` once to save your portal credentials to `~/.stb/config`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
stb init
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You will be prompted for:
|
|
26
|
+
|
|
27
|
+
| Prompt | Default | Notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Portal URL (no port) | _(required)_ | e.g. `http://192.168.1.10` |
|
|
30
|
+
| Port | _(blank)_ | e.g. `8080`; leave blank if included in URL |
|
|
31
|
+
| MAC address | _(required)_ | e.g. `00:1A:79:XX:XX:XX` |
|
|
32
|
+
| Serial | `000000000000` | Device serial number |
|
|
33
|
+
| Language | `en` | Portal language code |
|
|
34
|
+
| Timezone | `Europe/London` | IANA timezone name |
|
|
35
|
+
| Portal path | `stalker_portal/c/portal.php` | Path to the portal endpoint |
|
|
36
|
+
|
|
37
|
+
The config is saved as plain JSON and can be edited by hand:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"url": "http://192.168.1.10",
|
|
42
|
+
"port": "8080",
|
|
43
|
+
"mac": "00:1A:79:XX:XX:XX",
|
|
44
|
+
"serial": "000000000000",
|
|
45
|
+
"lang": "en",
|
|
46
|
+
"timezone": "Europe/London",
|
|
47
|
+
"portal_path": "stalker_portal/c/portal.php"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
All commands except `stb init` read this file and exit with an error if it is missing.
|
|
52
|
+
|
|
53
|
+
## Global Flags
|
|
54
|
+
|
|
55
|
+
| Flag | Description |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `--debug` | Print raw portal responses to stderr. Useful for diagnosing auth or stream failures. |
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
stb --debug live genres
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Live TV
|
|
64
|
+
|
|
65
|
+
### `stb live genres`
|
|
66
|
+
|
|
67
|
+
List all live TV genres.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
ID Title
|
|
71
|
+
---- -----------
|
|
72
|
+
1 General
|
|
73
|
+
2 Sports
|
|
74
|
+
3 News
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `stb live channels`
|
|
78
|
+
|
|
79
|
+
List channels, optionally filtered by genre or HD status.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
stb live channels
|
|
83
|
+
stb live channels --genre 2
|
|
84
|
+
stb live channels --hd
|
|
85
|
+
stb live channels --page 2
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Flag | Default | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `--genre <id>` | `*` | Filter by genre ID (from `stb live genres`) |
|
|
91
|
+
| `--hd` | off | Show HD channels only |
|
|
92
|
+
| `--page <n>` | `1` | Page number |
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
# Name Genre ID HD CMD
|
|
96
|
+
---- -------------- -------- --- -------------------
|
|
97
|
+
101 BBC One 1 ffmpeg://...
|
|
98
|
+
102 Sky Sports HD 2 yes ffmpeg://...
|
|
99
|
+
|
|
100
|
+
Page 1 of 5 (48 total)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The `CMD` value is used with `stb stream` to resolve a playable URL.
|
|
104
|
+
|
|
105
|
+
## VOD
|
|
106
|
+
|
|
107
|
+
### `stb vod categories`
|
|
108
|
+
|
|
109
|
+
List all VOD categories.
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
ID Title
|
|
113
|
+
---- -----------
|
|
114
|
+
1 Action
|
|
115
|
+
2 Comedy
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `stb vod list`
|
|
119
|
+
|
|
120
|
+
List VOD content, optionally filtered by category.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
stb vod list
|
|
124
|
+
stb vod list --category 1
|
|
125
|
+
stb vod list --page 2
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Flag | Default | Description |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `--category <id>` | `*` | Filter by category ID (from `stb vod categories`) |
|
|
131
|
+
| `--page <n>` | `1` | Page number |
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
ID Name Year Genres Series CMD
|
|
135
|
+
---- -------------- ---- ------- ------ -----------
|
|
136
|
+
42 Inception 2010 Action ffmpeg://...
|
|
137
|
+
99 Breaking Bad 2008 Drama yes ffmpeg://...
|
|
138
|
+
|
|
139
|
+
Page 1 of 12 (115 total)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `stb vod seasons <series_id>`
|
|
143
|
+
|
|
144
|
+
List seasons for a series. The `series_id` is the ID from `stb vod list`.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
stb vod seasons 99
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
ID Name
|
|
152
|
+
---- --------
|
|
153
|
+
1 Season 1
|
|
154
|
+
2 Season 2
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `stb vod episodes <series_id> <season_id>`
|
|
158
|
+
|
|
159
|
+
List episodes for a season.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
stb vod episodes 99 1
|
|
163
|
+
stb vod episodes 99 1 --page 2
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
| Flag | Default | Description |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `--page <n>` | `1` | Page number |
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
ID Name # CMD
|
|
172
|
+
---- ------------ -- -----------
|
|
173
|
+
201 Pilot 1 ffmpeg://...
|
|
174
|
+
202 Cat's in the 2 ffmpeg://...
|
|
175
|
+
|
|
176
|
+
Page 1 of 7 (62 total)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `CMD` value is used with `stb stream` to resolve a playable URL.
|
|
180
|
+
|
|
181
|
+
## Stream URLs
|
|
182
|
+
|
|
183
|
+
### `stb stream --type <live|vod> <cmd>`
|
|
184
|
+
|
|
185
|
+
Resolve a stream URL and print it to stdout. The `--type` flag is required. The `<cmd>` value comes from the `CMD` column of a prior listing command.
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
stb stream --type live "ffmpeg://..."
|
|
189
|
+
stb stream --type vod "ffmpeg://..."
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Pipe directly to a media player:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
stb stream --type live "ffmpeg://..." | xargs mpv
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Error Handling
|
|
199
|
+
|
|
200
|
+
| Situation | Message | Exit code |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| Config file missing | `No config found. Run 'stb init' first.` | 1 |
|
|
203
|
+
| Authentication failure | Short description to stderr | 1 |
|
|
204
|
+
| Stream resolution failure | Short description to stderr | 1 |
|
|
205
|
+
|
|
206
|
+
No Python tracebacks are shown. Use `--debug` to see raw portal responses when diagnosing failures.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Spec: Persist CLI Auth Token (016)
|
|
2
|
+
|
|
3
|
+
## Objective
|
|
4
|
+
|
|
5
|
+
Eliminate the redundant handshake that occurs on every CLI invocation by persisting the session token to disk. On the next CLI call the cached token is loaded and used directly; if it has expired the existing in-process re-auth path fires automatically and the refreshed token is written back to disk.
|
|
6
|
+
|
|
7
|
+
Target user: anyone running `stb` CLI commands repeatedly (e.g. in scripts or quickly browsing channels). Today each command does a full handshake round-trip before doing its real work; after this change only the first invocation (or the one after a token expiry) pays that cost.
|
|
8
|
+
|
|
9
|
+
## User Stories
|
|
10
|
+
|
|
11
|
+
- As a CLI user, I want subsequent `stb` commands to skip the auth handshake so they respond faster.
|
|
12
|
+
- As a CLI user, I want the cache to refresh transparently when my token expires so I never have to manually re-authenticate.
|
|
13
|
+
|
|
14
|
+
## Functional Requirements
|
|
15
|
+
|
|
16
|
+
- FR-1: After a successful `authenticate()` call, the CLI writes the session token and any extra headers (`X-Random`, `Random`) to `~/.stb/token` as JSON.
|
|
17
|
+
- FR-2: On every CLI invocation, `get_client()` attempts to load `~/.stb/token`. If a valid token is found, it is applied to the session and `authenticate()` is **not** called.
|
|
18
|
+
- FR-3: When the loaded token causes an auth failure, the existing `_http.py` re-auth path calls `authenticate()` which refreshes the session. The CLI wrapper then saves the new token to `~/.stb/token`.
|
|
19
|
+
- FR-4: If `~/.stb/token` is missing or contains invalid JSON, `get_client()` falls back to a full `authenticate()` call and saves the resulting token.
|
|
20
|
+
- FR-5: The token file is written with the same directory guarantees as the config file (`~/.stb/` created if absent).
|
|
21
|
+
|
|
22
|
+
## Non-Functional Requirements
|
|
23
|
+
|
|
24
|
+
- NFR-1: No new runtime dependencies. Uses only `json` and `pathlib` (already in use in `config.py`).
|
|
25
|
+
- NFR-2: The token file is human-readable JSON so users can inspect or delete it manually.
|
|
26
|
+
- NFR-3: No changes to library code (`client.py`, `_http.py`, `auth.py`). All changes are CLI-only.
|
|
27
|
+
- NFR-4: Thread safety is not a concern for the CLI (single-process, sequential commands).
|
|
28
|
+
|
|
29
|
+
## Out of Scope
|
|
30
|
+
|
|
31
|
+
- `stb logout` command or explicit token invalidation command.
|
|
32
|
+
- Token expiry timestamps / TTL-based eviction (rely on re-auth-on-failure instead).
|
|
33
|
+
- Library-level token persistence (library callers manage their own sessions).
|
|
34
|
+
- Multi-profile or per-portal token caching.
|
|
35
|
+
- File permissions hardening (e.g. `chmod 600`) — left for a future security hardening pass.
|
|
36
|
+
|
|
37
|
+
## Assumptions
|
|
38
|
+
|
|
39
|
+
- The session token returned by the portal is stable enough to reuse across CLI invocations (i.e. not invalidated purely by time between calls in normal usage).
|
|
40
|
+
- `~/.stb/token` is an acceptable path (same parent directory as `~/.stb/config`).
|
|
41
|
+
- `extra_headers` on the session (e.g. `X-Random`) must also be persisted; some portals require them alongside the token on every request.
|
|
42
|
+
- Deleting `~/.stb/token` is the supported manual "logout" / cache-clear mechanism.
|
|
43
|
+
|
|
44
|
+
## Tech Stack
|
|
45
|
+
|
|
46
|
+
- Python ≥ 3.11
|
|
47
|
+
- stdlib only: `json`, `pathlib.Path`
|
|
48
|
+
- `pytest` + `responses` + Click's `CliRunner` for tests
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Test: pytest tests/
|
|
54
|
+
Install: pip install -e .
|
|
55
|
+
Run CLI: stb --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Project Structure
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
stb_reader/
|
|
62
|
+
cli/
|
|
63
|
+
config.py ← only file changed (add TOKEN_PATH, save_token, load_token; modify get_client)
|
|
64
|
+
tests/
|
|
65
|
+
test_cli_config.py ← new test cases added here
|
|
66
|
+
spec/
|
|
67
|
+
016-persist-cli-token/ ← this spec
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Code Style
|
|
71
|
+
|
|
72
|
+
Match the existing `config.py` style: plain functions, no classes, type annotations, no comments unless the why is non-obvious.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
TOKEN_PATH = Path.home() / ".stb" / "token"
|
|
76
|
+
|
|
77
|
+
def save_token(session) -> None:
|
|
78
|
+
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
TOKEN_PATH.write_text(json.dumps({
|
|
80
|
+
"token": session.token,
|
|
81
|
+
"extra_headers": session.extra_headers,
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
def load_token() -> dict | None:
|
|
85
|
+
if not TOKEN_PATH.exists():
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(TOKEN_PATH.read_text())
|
|
89
|
+
except (json.JSONDecodeError, KeyError):
|
|
90
|
+
return None
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`get_client()` wraps `client.authenticate` so every auth (initial or re-auth) persists the token:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
def get_client() -> STBClient:
|
|
97
|
+
cfg = load_config()
|
|
98
|
+
...
|
|
99
|
+
client = STBClient(**kwargs)
|
|
100
|
+
|
|
101
|
+
def _auth_and_save():
|
|
102
|
+
client.authenticate()
|
|
103
|
+
save_token(client._session)
|
|
104
|
+
|
|
105
|
+
client._session.reauth_fn = _auth_and_save
|
|
106
|
+
|
|
107
|
+
cached = load_token()
|
|
108
|
+
if cached:
|
|
109
|
+
client._session.token = cached["token"]
|
|
110
|
+
client._session.extra_headers.update(cached.get("extra_headers", {}))
|
|
111
|
+
else:
|
|
112
|
+
_auth_and_save()
|
|
113
|
+
|
|
114
|
+
return client
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Testing Strategy
|
|
118
|
+
|
|
119
|
+
- Framework: `pytest` with `monkeypatch` and Click's `CliRunner`.
|
|
120
|
+
- All new tests go in `tests/test_cli_config.py` alongside existing config tests.
|
|
121
|
+
- No new test files needed.
|
|
122
|
+
- Each new functional requirement has at least one happy-path and one error-path test.
|
|
123
|
+
|
|
124
|
+
Test cases:
|
|
125
|
+
|
|
126
|
+
| Test | Requirement |
|
|
127
|
+
|------|-------------|
|
|
128
|
+
| `test_save_and_load_token` — round-trip token + extra_headers | FR-1, FR-5 |
|
|
129
|
+
| `test_load_token_missing` — returns `None` when file absent | FR-4 |
|
|
130
|
+
| `test_load_token_corrupt` — returns `None` on bad JSON | FR-4 |
|
|
131
|
+
| `test_get_client_saves_token_on_fresh_auth` — no cache → authenticate called → token written | FR-1, FR-2 |
|
|
132
|
+
| `test_get_client_uses_cached_token` — cache present → authenticate not called → token applied | FR-2 |
|
|
133
|
+
| `test_get_client_reauth_updates_token` — reauth_fn fires → updated token written to disk | FR-3 |
|
|
134
|
+
|
|
135
|
+
## Boundaries
|
|
136
|
+
|
|
137
|
+
- **Always:** Run `pytest tests/` before marking done. Keep all changes inside `cli/config.py` and `tests/test_cli_config.py`.
|
|
138
|
+
- **Ask first:** Any change to library files (`client.py`, `_http.py`, `auth.py`). Adding a new CLI command (e.g. `stb logout`). Changing the token file path or format after the spec is approved.
|
|
139
|
+
- **Never:** Silently swallow `IOError` / `PermissionError` when writing the token file (let it surface). Touch files outside `cli/config.py` and its test.
|
|
140
|
+
|
|
141
|
+
## Success Criteria
|
|
142
|
+
|
|
143
|
+
- Running `stb live genres` twice in succession: the second invocation does not perform a network handshake (observable via `--debug` logging or by mocking).
|
|
144
|
+
- When the token file contains a stale token, the command still succeeds after a transparent re-auth, and the token file is updated with the new token.
|
|
145
|
+
- Deleting `~/.stb/token` and re-running any command causes a fresh handshake and recreates the file.
|
|
146
|
+
- `pytest tests/` passes with no failures and no regressions in existing tests.
|
|
147
|
+
|
|
148
|
+
## Open Questions
|
|
149
|
+
|
|
150
|
+
None — all requirements confirmed with the user.
|
|
@@ -62,7 +62,7 @@ class STBSession:
|
|
|
62
62
|
self._cookies["token"] = self.token
|
|
63
63
|
headers = {**self._base_headers, "Authorization": f"Bearer {self.token}", **self.extra_headers}
|
|
64
64
|
resp = self._session.get(url, params=query, headers=headers, cookies=self._cookies, timeout=_REQUEST_TIMEOUT)
|
|
65
|
-
logger.debug("Response [%s %s]: %s", resp.status_code, action, resp.text
|
|
65
|
+
logger.debug("Response [%s %s]: %s", resp.status_code, action, resp.text)
|
|
66
66
|
if not resp.ok:
|
|
67
67
|
raise STBError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
68
68
|
if _is_auth_failure(resp.text):
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
import hashlib
|
|
3
2
|
from typing import TYPE_CHECKING
|
|
4
3
|
from .exceptions import AuthError
|
|
5
4
|
|
|
@@ -20,15 +19,7 @@ def handshake(session: "STBSession") -> None:
|
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
def get_profile(session: "STBSession") -> None:
|
|
23
|
-
|
|
24
|
-
device_id2 = hashlib.sha256(session.mac.encode()).hexdigest()
|
|
25
|
-
signature = hashlib.sha256((session.serial + session.mac).encode()).hexdigest()
|
|
26
|
-
data = session.get(
|
|
27
|
-
"stb", "get_profile",
|
|
28
|
-
device_id=device_id,
|
|
29
|
-
device_id2=device_id2,
|
|
30
|
-
signature=signature,
|
|
31
|
-
)
|
|
22
|
+
data = session.get("stb", "get_profile")
|
|
32
23
|
token = data.get("token", "")
|
|
33
24
|
if token:
|
|
34
25
|
session.token = token
|
|
@@ -4,6 +4,7 @@ import click
|
|
|
4
4
|
from stb_reader import STBClient
|
|
5
5
|
|
|
6
6
|
CONFIG_PATH = Path.home() / ".stb" / "config"
|
|
7
|
+
TOKEN_PATH = Path.home() / ".stb" / "token"
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def save_config(data: dict) -> None:
|
|
@@ -17,6 +18,23 @@ def load_config() -> dict:
|
|
|
17
18
|
return json.loads(CONFIG_PATH.read_text())
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def save_token(session) -> None:
|
|
22
|
+
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
TOKEN_PATH.write_text(json.dumps({
|
|
24
|
+
"token": session.token,
|
|
25
|
+
"extra_headers": session.extra_headers,
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_token() -> dict | None:
|
|
30
|
+
if not TOKEN_PATH.exists():
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(TOKEN_PATH.read_text())
|
|
34
|
+
except (json.JSONDecodeError, KeyError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
20
38
|
def get_client() -> STBClient:
|
|
21
39
|
cfg = load_config()
|
|
22
40
|
base_url = cfg["url"]
|
|
@@ -27,5 +45,18 @@ def get_client() -> STBClient:
|
|
|
27
45
|
if key in cfg:
|
|
28
46
|
kwargs[key] = cfg[key]
|
|
29
47
|
client = STBClient(**kwargs)
|
|
30
|
-
|
|
48
|
+
|
|
49
|
+
def _auth_and_save() -> None:
|
|
50
|
+
client.authenticate()
|
|
51
|
+
save_token(client._session)
|
|
52
|
+
|
|
53
|
+
client._session.reauth_fn = _auth_and_save
|
|
54
|
+
|
|
55
|
+
cached = load_token()
|
|
56
|
+
if cached:
|
|
57
|
+
client._session.token = cached["token"]
|
|
58
|
+
client._session.extra_headers.update(cached.get("extra_headers", {}))
|
|
59
|
+
else:
|
|
60
|
+
_auth_and_save()
|
|
61
|
+
|
|
31
62
|
return client
|
|
@@ -29,7 +29,7 @@ def list_cmd(category_id: str, page: int) -> None:
|
|
|
29
29
|
client = get_client()
|
|
30
30
|
result = client.vod.get_content(category_id=category_id, page=page)
|
|
31
31
|
rows = [
|
|
32
|
-
[c.id, c.name, c.year, c.genres, "yes" if c.is_series else "", c.cmd]
|
|
32
|
+
[c.id, c.name, c.year, c.genres, "yes" if c.is_series else "", "" if c.is_series else c.cmd]
|
|
33
33
|
for c in result.items
|
|
34
34
|
]
|
|
35
35
|
total_pages = -(-result.total // result.per_page) if result.per_page else 1
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
import responses as responses_lib
|
|
3
3
|
from stb_reader._http import STBSession
|
|
4
|
+
from stb_reader.cli import config as cli_config
|
|
4
5
|
|
|
5
6
|
BASE_URL = "http://portal.test"
|
|
6
7
|
MAC = "00:1A:79:00:00:01"
|
|
@@ -11,6 +12,11 @@ PORTAL_PATH = "/stalker_portal/c/portal.php"
|
|
|
11
12
|
PORTAL_URL = f"{BASE_URL}{PORTAL_PATH}"
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def _isolate_token_path(tmp_path, monkeypatch):
|
|
17
|
+
monkeypatch.setattr(cli_config, "TOKEN_PATH", tmp_path / ".stb" / "token")
|
|
18
|
+
|
|
19
|
+
|
|
14
20
|
@pytest.fixture
|
|
15
21
|
def session():
|
|
16
22
|
return STBSession(BASE_URL, MAC, SERIAL, LANG, TIMEZONE)
|
|
@@ -54,20 +54,6 @@ def test_authenticate_token_propagated_to_second_request():
|
|
|
54
54
|
assert responses_lib.calls[1].request.headers["Authorization"] == "Bearer tok_abc"
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
@responses_lib.activate
|
|
58
|
-
def test_get_profile_sends_device_params():
|
|
59
|
-
import hashlib
|
|
60
|
-
from stb_reader._http import STBSession
|
|
61
|
-
serial = "TESTSERIAL"
|
|
62
|
-
session = STBSession(BASE_URL, MAC, serial, "en", "Europe/London")
|
|
63
|
-
session.token = "tok"
|
|
64
|
-
responses_lib.add(responses_lib.GET, _portal_url(), json={"js": {}})
|
|
65
|
-
get_profile(session)
|
|
66
|
-
qs = _qs(responses_lib.calls[0])
|
|
67
|
-
assert qs["device_id"][0] == hashlib.sha256(serial.encode()).hexdigest()
|
|
68
|
-
assert qs["device_id2"][0] == hashlib.sha256(MAC.encode()).hexdigest()
|
|
69
|
-
assert qs["signature"][0] == hashlib.sha256((serial + MAC).encode()).hexdigest()
|
|
70
|
-
|
|
71
57
|
|
|
72
58
|
@responses_lib.activate
|
|
73
59
|
def test_get_profile_applies_refreshed_token():
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
from stb_reader.cli.main import main
|
|
6
|
+
from stb_reader.cli import config as config_mod
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def tmp_config(tmp_path, monkeypatch):
|
|
11
|
+
cfg = tmp_path / ".stb" / "config"
|
|
12
|
+
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
|
13
|
+
return cfg
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_save_and_load_config(tmp_config):
|
|
17
|
+
config_mod.save_config({"url": "http://portal.test", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
18
|
+
data = config_mod.load_config()
|
|
19
|
+
assert data["url"] == "http://portal.test"
|
|
20
|
+
assert data["mac"] == "AA:BB:CC:DD:EE:FF"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_load_config_missing_raises(tmp_config):
|
|
24
|
+
from click import ClickException
|
|
25
|
+
with pytest.raises(ClickException, match="stb init"):
|
|
26
|
+
config_mod.load_config()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_INIT_DEFAULTS = "http://portal.test\n\nAA:BB:CC:DD:EE:FF\n\n\n\n\n"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_init_command_writes_config(tmp_config):
|
|
33
|
+
result = CliRunner().invoke(main, ["init"], input=_INIT_DEFAULTS)
|
|
34
|
+
assert result.exit_code == 0
|
|
35
|
+
data = json.loads(tmp_config.read_text())
|
|
36
|
+
assert data["url"] == "http://portal.test"
|
|
37
|
+
assert data["mac"] == "AA:BB:CC:DD:EE:FF"
|
|
38
|
+
assert data["serial"] == "000000000000"
|
|
39
|
+
assert data["lang"] == "en"
|
|
40
|
+
assert data["timezone"] == "Europe/London"
|
|
41
|
+
assert data["portal_path"] == "stalker_portal/c/portal.php"
|
|
42
|
+
assert "port" not in data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_init_with_port(tmp_config):
|
|
46
|
+
CliRunner().invoke(main, ["init"], input="http://portal.test\n8080\nAA:BB:CC:DD:EE:FF\n\n\n\n\n")
|
|
47
|
+
data = json.loads(tmp_config.read_text())
|
|
48
|
+
assert data["port"] == "8080"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_init_custom_portal_path(tmp_config):
|
|
52
|
+
CliRunner().invoke(main, ["init"], input="http://portal.test\n\nAA:BB:CC:DD:EE:FF\n\n\n\nstalker_portal/server/load.php\n")
|
|
53
|
+
data = json.loads(tmp_config.read_text())
|
|
54
|
+
assert data["portal_path"] == "stalker_portal/server/load.php"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_init_strips_trailing_slash(tmp_config):
|
|
58
|
+
CliRunner().invoke(main, ["init"], input="http://portal.test/\n\nAA:BB:CC:DD:EE:FF\n\n\n\n\n")
|
|
59
|
+
data = json.loads(tmp_config.read_text())
|
|
60
|
+
assert data["url"] == "http://portal.test"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_get_client_appends_port(tmp_config, monkeypatch):
|
|
64
|
+
config_mod.save_config({"url": "http://portal.test", "port": "8080", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
65
|
+
captured = {}
|
|
66
|
+
|
|
67
|
+
def fake_init(self, base_url, mac, **kwargs):
|
|
68
|
+
captured["base_url"] = base_url
|
|
69
|
+
self._session = MagicMock()
|
|
70
|
+
self._session.token = "tok"
|
|
71
|
+
self._session.extra_headers = {}
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr("stb_reader.client.STBClient.__init__", fake_init)
|
|
74
|
+
monkeypatch.setattr("stb_reader.client.STBClient.authenticate", lambda self: None)
|
|
75
|
+
config_mod.get_client()
|
|
76
|
+
assert captured["base_url"] == "http://portal.test:8080"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- token persistence tests ---
|
|
80
|
+
|
|
81
|
+
def test_save_and_load_token():
|
|
82
|
+
session = MagicMock()
|
|
83
|
+
session.token = "abc123"
|
|
84
|
+
session.extra_headers = {"X-Random": "xyz"}
|
|
85
|
+
config_mod.save_token(session)
|
|
86
|
+
result = config_mod.load_token()
|
|
87
|
+
assert result == {"token": "abc123", "extra_headers": {"X-Random": "xyz"}}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_load_token_missing():
|
|
91
|
+
assert config_mod.load_token() is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_load_token_corrupt():
|
|
95
|
+
config_mod.TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
config_mod.TOKEN_PATH.write_text("not json{{")
|
|
97
|
+
assert config_mod.load_token() is None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_get_client_saves_token_on_fresh_auth(tmp_config, monkeypatch):
|
|
101
|
+
config_mod.save_config({"url": "http://portal.test", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
102
|
+
|
|
103
|
+
def fake_init(self, base_url, mac, **kwargs):
|
|
104
|
+
self._session = MagicMock()
|
|
105
|
+
self._session.token = "fresh-token"
|
|
106
|
+
self._session.extra_headers = {}
|
|
107
|
+
|
|
108
|
+
monkeypatch.setattr("stb_reader.client.STBClient.__init__", fake_init)
|
|
109
|
+
monkeypatch.setattr("stb_reader.client.STBClient.authenticate", lambda self: None)
|
|
110
|
+
|
|
111
|
+
config_mod.get_client()
|
|
112
|
+
|
|
113
|
+
saved = config_mod.load_token()
|
|
114
|
+
assert saved["token"] == "fresh-token"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_get_client_uses_cached_token(tmp_config, monkeypatch):
|
|
118
|
+
config_mod.save_config({"url": "http://portal.test", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
119
|
+
config_mod.TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
config_mod.TOKEN_PATH.write_text(json.dumps({"token": "cached-token", "extra_headers": {"X-Random": "r1"}}))
|
|
121
|
+
|
|
122
|
+
auth_called = []
|
|
123
|
+
|
|
124
|
+
def fake_init(self, base_url, mac, **kwargs):
|
|
125
|
+
self._session = MagicMock()
|
|
126
|
+
self._session.token = ""
|
|
127
|
+
self._session.extra_headers = {}
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr("stb_reader.client.STBClient.__init__", fake_init)
|
|
130
|
+
monkeypatch.setattr("stb_reader.client.STBClient.authenticate", lambda self: auth_called.append(1))
|
|
131
|
+
|
|
132
|
+
client = config_mod.get_client()
|
|
133
|
+
|
|
134
|
+
assert not auth_called
|
|
135
|
+
assert client._session.token == "cached-token"
|
|
136
|
+
assert client._session.extra_headers["X-Random"] == "r1"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_get_client_reauth_updates_token(tmp_config, monkeypatch):
|
|
140
|
+
config_mod.save_config({"url": "http://portal.test", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
141
|
+
config_mod.TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
config_mod.TOKEN_PATH.write_text(json.dumps({"token": "old-token", "extra_headers": {}}))
|
|
143
|
+
|
|
144
|
+
def fake_init(self, base_url, mac, **kwargs):
|
|
145
|
+
self._session = MagicMock()
|
|
146
|
+
self._session.token = "old-token"
|
|
147
|
+
self._session.extra_headers = {}
|
|
148
|
+
|
|
149
|
+
def fake_authenticate(self):
|
|
150
|
+
self._session.token = "new-token"
|
|
151
|
+
|
|
152
|
+
monkeypatch.setattr("stb_reader.client.STBClient.__init__", fake_init)
|
|
153
|
+
monkeypatch.setattr("stb_reader.client.STBClient.authenticate", fake_authenticate)
|
|
154
|
+
|
|
155
|
+
client = config_mod.get_client()
|
|
156
|
+
|
|
157
|
+
# reauth_fn fires on token expiry; simulate it
|
|
158
|
+
client._session.reauth_fn()
|
|
159
|
+
|
|
160
|
+
saved = config_mod.load_token()
|
|
161
|
+
assert saved["token"] == "new-token"
|
|
@@ -108,6 +108,31 @@ def test_get_seasons_returns_list(session):
|
|
|
108
108
|
assert "episode_id=0" in url
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
@responses_lib.activate
|
|
112
|
+
def test_get_seasons_exposes_season_number_and_episode_count(session):
|
|
113
|
+
responses_lib.add(
|
|
114
|
+
responses_lib.GET, PORTAL_URL,
|
|
115
|
+
json={"js": {"data": [{"id": "25805", "name": "Season 3", "video_id": "70639",
|
|
116
|
+
"season_number": "3", "season_series": 51}]}},
|
|
117
|
+
)
|
|
118
|
+
svc = VODService(session)
|
|
119
|
+
seasons = svc.get_seasons("50")
|
|
120
|
+
assert seasons[0].season_number == "3"
|
|
121
|
+
assert seasons[0].episode_count == 51
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@responses_lib.activate
|
|
125
|
+
def test_get_seasons_defaults_when_fields_absent(session):
|
|
126
|
+
responses_lib.add(
|
|
127
|
+
responses_lib.GET, PORTAL_URL,
|
|
128
|
+
json={"js": {"data": [{"id": "1", "name": "Season 1", "video_id": "200"}]}},
|
|
129
|
+
)
|
|
130
|
+
svc = VODService(session)
|
|
131
|
+
seasons = svc.get_seasons("50")
|
|
132
|
+
assert seasons[0].season_number == ""
|
|
133
|
+
assert seasons[0].episode_count == 0
|
|
134
|
+
|
|
135
|
+
|
|
111
136
|
# --- get_episodes ---
|
|
112
137
|
|
|
113
138
|
@responses_lib.activate
|
|
@@ -167,6 +167,18 @@ wheels = [
|
|
|
167
167
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
|
168
168
|
]
|
|
169
169
|
|
|
170
|
+
[[package]]
|
|
171
|
+
name = "click"
|
|
172
|
+
version = "8.4.0"
|
|
173
|
+
source = { registry = "https://pypi.org/simple" }
|
|
174
|
+
dependencies = [
|
|
175
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
176
|
+
]
|
|
177
|
+
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
|
|
178
|
+
wheels = [
|
|
179
|
+
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
|
|
180
|
+
]
|
|
181
|
+
|
|
170
182
|
[[package]]
|
|
171
183
|
name = "colorama"
|
|
172
184
|
version = "0.4.6"
|
|
@@ -765,9 +777,10 @@ wheels = [
|
|
|
765
777
|
|
|
766
778
|
[[package]]
|
|
767
779
|
name = "stb-reader"
|
|
768
|
-
version = "0.
|
|
780
|
+
version = "0.2.0"
|
|
769
781
|
source = { editable = "." }
|
|
770
782
|
dependencies = [
|
|
783
|
+
{ name = "click" },
|
|
771
784
|
{ name = "requests" },
|
|
772
785
|
]
|
|
773
786
|
|
|
@@ -782,6 +795,7 @@ test = [
|
|
|
782
795
|
|
|
783
796
|
[package.metadata]
|
|
784
797
|
requires-dist = [
|
|
798
|
+
{ name = "click" },
|
|
785
799
|
{ name = "httpx", marker = "extra == 'test'" },
|
|
786
800
|
{ name = "pytest", marker = "extra == 'test'" },
|
|
787
801
|
{ name = "pytest-cov", marker = "extra == 'test'" },
|
stb_reader-0.2.0/README.md
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# stb-reader
|
|
2
|
-
|
|
3
|
-
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.
|
|
4
|
-
|
|
5
|
-
## Installation
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
pip install stb-reader
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Requires Python 3.11+ and has a single runtime dependency: `requests`.
|
|
12
|
-
|
|
13
|
-
## Quick start
|
|
14
|
-
|
|
15
|
-
```python
|
|
16
|
-
from stb_reader import STBClient
|
|
17
|
-
|
|
18
|
-
client = STBClient(
|
|
19
|
-
base_url="http://your-portal.example.com",
|
|
20
|
-
mac="00:1A:79:XX:XX:XX",
|
|
21
|
-
)
|
|
22
|
-
client.authenticate()
|
|
23
|
-
|
|
24
|
-
# Live TV
|
|
25
|
-
genres = client.live_tv.get_genres()
|
|
26
|
-
channels = client.live_tv.get_channels(genre_id="*", page=1)
|
|
27
|
-
stream_url = client.live_tv.get_stream_url(channels.items[0].cmd)
|
|
28
|
-
|
|
29
|
-
# VOD
|
|
30
|
-
categories = client.vod.get_categories()
|
|
31
|
-
content = client.vod.get_content(category_id="*", page=1)
|
|
32
|
-
|
|
33
|
-
# Series
|
|
34
|
-
seasons = client.vod.get_seasons(series_id="123")
|
|
35
|
-
episodes = client.vod.get_episodes(series_id="123", season_id=seasons[0].id)
|
|
36
|
-
stream_url = client.vod.get_stream_url_by_first_file(
|
|
37
|
-
series_id="123",
|
|
38
|
-
season_id=seasons[0].id,
|
|
39
|
-
episode_id=episodes[0].id,
|
|
40
|
-
)
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## API reference
|
|
44
|
-
|
|
45
|
-
### `STBClient(base_url, mac, serial, lang, timezone, portal_path)`
|
|
46
|
-
|
|
47
|
-
| Parameter | Default | Description |
|
|
48
|
-
|-----------|---------|-------------|
|
|
49
|
-
| `base_url` | required | Portal base URL |
|
|
50
|
-
| `mac` | required | Device MAC address |
|
|
51
|
-
| `serial` | `"000000000000"` | Device serial |
|
|
52
|
-
| `lang` | `"en"` | Portal language |
|
|
53
|
-
| `timezone` | `"Europe/London"` | Portal timezone |
|
|
54
|
-
| `portal_path` | `"stalker_portal/c/portal.php"` | Path to portal PHP endpoint |
|
|
55
|
-
|
|
56
|
-
### `client.live_tv`
|
|
57
|
-
|
|
58
|
-
| Method | Returns | Description |
|
|
59
|
-
|--------|---------|-------------|
|
|
60
|
-
| `get_genres()` | `list[Genre]` | All channel genres |
|
|
61
|
-
| `get_channels(genre_id, page, sort, hd, fav)` | `PagedResult[Channel]` | Paginated channel list |
|
|
62
|
-
| `get_stream_url(cmd)` | `str` | Resolved stream URL for a channel `cmd` |
|
|
63
|
-
| `get_stream_url_by_id(channel_id)` | `str` | Resolved stream URL by channel ID |
|
|
64
|
-
|
|
65
|
-
### `client.vod`
|
|
66
|
-
|
|
67
|
-
| Method | Returns | Description |
|
|
68
|
-
|--------|---------|-------------|
|
|
69
|
-
| `get_categories()` | `list[Category]` | All VOD categories |
|
|
70
|
-
| `get_content(category_id, page, sort, fav)` | `PagedResult[Content]` | Paginated VOD content |
|
|
71
|
-
| `get_seasons(series_id)` | `list[Season]` | Seasons for a series |
|
|
72
|
-
| `get_episodes(series_id, season_id)` | `list[Episode]` | All episodes in a season |
|
|
73
|
-
| `get_episode_files(series_id, season_id, episode_id)` | `list[EpisodeFile]` | Quality variants for an episode |
|
|
74
|
-
| `get_stream_url(cmd)` | `str` | Resolved stream URL for a VOD `cmd` |
|
|
75
|
-
| `get_stream_url_by_content_id(content_id)` | `str` | Stream URL for a movie by ID |
|
|
76
|
-
| `get_stream_url_by_first_file(series_id, season_id, episode_id)` | `str` | Stream URL for first file of an episode |
|
|
77
|
-
| `get_stream_url_by_file_id(series_id, season_id, episode_id, file_id)` | `str` | Stream URL for a specific file |
|
|
78
|
-
|
|
79
|
-
## Exceptions
|
|
80
|
-
|
|
81
|
-
All exceptions are importable from `stb_reader`:
|
|
82
|
-
|
|
83
|
-
| Exception | Raised when |
|
|
84
|
-
|-----------|-------------|
|
|
85
|
-
| `STBError` | Base class for all library errors |
|
|
86
|
-
| `AuthError` | Authentication / token failure |
|
|
87
|
-
| `StreamError` | Portal rejects a stream request |
|
|
88
|
-
| `NotFoundError` | Requested item not found |
|
|
89
|
-
|
|
90
|
-
## Documentation
|
|
91
|
-
|
|
92
|
-
Full guides are in [`docs/guide/`](docs/guide/):
|
|
93
|
-
|
|
94
|
-
- [Getting started](docs/guide/getting-started.md) — installation, configuration, first call
|
|
95
|
-
- [Authentication](docs/guide/authentication.md) — token lifecycle, auto-reauth, error handling
|
|
96
|
-
- [Live TV](docs/guide/live-tv.md) — genres, channels, stream URLs
|
|
97
|
-
- [VOD — Movies](docs/guide/vod.md) — categories, content listing, movie streams
|
|
98
|
-
- [Series](docs/guide/series.md) — seasons, episodes, quality selection
|
|
99
|
-
- [Pagination](docs/guide/pagination.md) — `PagedResult`, fetch-all-pages pattern
|
|
100
|
-
- [Error handling](docs/guide/error-handling.md) — all exceptions, recovery patterns
|
|
101
|
-
- [API reference](docs/guide/api-reference.md) — complete method, model, and exception reference
|
|
102
|
-
|
|
103
|
-
## License
|
|
104
|
-
|
|
105
|
-
MIT
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import pytest
|
|
3
|
-
from click.testing import CliRunner
|
|
4
|
-
from stb_reader.cli.main import main
|
|
5
|
-
from stb_reader.cli import config as config_mod
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@pytest.fixture(autouse=True)
|
|
9
|
-
def tmp_config(tmp_path, monkeypatch):
|
|
10
|
-
cfg = tmp_path / ".stb" / "config"
|
|
11
|
-
monkeypatch.setattr(config_mod, "CONFIG_PATH", cfg)
|
|
12
|
-
return cfg
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_save_and_load_config(tmp_config):
|
|
16
|
-
config_mod.save_config({"url": "http://portal.test", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
17
|
-
data = config_mod.load_config()
|
|
18
|
-
assert data["url"] == "http://portal.test"
|
|
19
|
-
assert data["mac"] == "AA:BB:CC:DD:EE:FF"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_load_config_missing_raises(tmp_config):
|
|
23
|
-
from click import ClickException
|
|
24
|
-
with pytest.raises(ClickException, match="stb init"):
|
|
25
|
-
config_mod.load_config()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
_INIT_DEFAULTS = "http://portal.test\n\nAA:BB:CC:DD:EE:FF\n\n\n\n\n"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_init_command_writes_config(tmp_config):
|
|
32
|
-
result = CliRunner().invoke(main, ["init"], input=_INIT_DEFAULTS)
|
|
33
|
-
assert result.exit_code == 0
|
|
34
|
-
data = json.loads(tmp_config.read_text())
|
|
35
|
-
assert data["url"] == "http://portal.test"
|
|
36
|
-
assert data["mac"] == "AA:BB:CC:DD:EE:FF"
|
|
37
|
-
assert data["serial"] == "000000000000"
|
|
38
|
-
assert data["lang"] == "en"
|
|
39
|
-
assert data["timezone"] == "Europe/London"
|
|
40
|
-
assert data["portal_path"] == "stalker_portal/c/portal.php"
|
|
41
|
-
assert "port" not in data
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_init_with_port(tmp_config):
|
|
45
|
-
CliRunner().invoke(main, ["init"], input="http://portal.test\n8080\nAA:BB:CC:DD:EE:FF\n\n\n\n\n")
|
|
46
|
-
data = json.loads(tmp_config.read_text())
|
|
47
|
-
assert data["port"] == "8080"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_init_custom_portal_path(tmp_config):
|
|
51
|
-
CliRunner().invoke(main, ["init"], input="http://portal.test\n\nAA:BB:CC:DD:EE:FF\n\n\n\nstalker_portal/server/load.php\n")
|
|
52
|
-
data = json.loads(tmp_config.read_text())
|
|
53
|
-
assert data["portal_path"] == "stalker_portal/server/load.php"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_init_strips_trailing_slash(tmp_config):
|
|
57
|
-
CliRunner().invoke(main, ["init"], input="http://portal.test/\n\nAA:BB:CC:DD:EE:FF\n\n\n\n\n")
|
|
58
|
-
data = json.loads(tmp_config.read_text())
|
|
59
|
-
assert data["url"] == "http://portal.test"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_get_client_appends_port(tmp_config, monkeypatch):
|
|
63
|
-
config_mod.save_config({"url": "http://portal.test", "port": "8080", "mac": "AA:BB:CC:DD:EE:FF"})
|
|
64
|
-
captured = {}
|
|
65
|
-
|
|
66
|
-
def fake_init(self, base_url, mac, **kwargs):
|
|
67
|
-
captured["base_url"] = base_url
|
|
68
|
-
|
|
69
|
-
monkeypatch.setattr("stb_reader.client.STBClient.__init__", fake_init)
|
|
70
|
-
monkeypatch.setattr("stb_reader.client.STBClient.authenticate", lambda self: None)
|
|
71
|
-
config_mod.get_client()
|
|
72
|
-
assert captured["base_url"] == "http://portal.test:8080"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stb_reader-0.2.0 → stb_reader-0.2.1}/spec/012-library-only/012-library-only-requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
{stb_reader-0.2.0 → stb_reader-0.2.1}/spec/013-documentation/013-documentation-requirements.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|