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.
Files changed (59) hide show
  1. {stb_reader-0.2.0 → stb_reader-0.2.1}/AGENTS.md +5 -3
  2. {stb_reader-0.2.0 → stb_reader-0.2.1}/PKG-INFO +16 -56
  3. stb_reader-0.2.1/README.md +65 -0
  4. stb_reader-0.2.1/docs/guide/cli.md +206 -0
  5. {stb_reader-0.2.0 → stb_reader-0.2.1}/pyproject.toml +1 -1
  6. stb_reader-0.2.1/spec/016-persist-cli-token/016-persist-cli-token-requirements.md +150 -0
  7. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/_http.py +1 -1
  8. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/auth.py +1 -10
  9. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/config.py +32 -1
  10. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/vod.py +1 -1
  11. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/models.py +2 -0
  12. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/vod.py +2 -0
  13. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/conftest.py +6 -0
  14. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_auth.py +0 -14
  15. stb_reader-0.2.1/tests/test_cli_config.py +161 -0
  16. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_vod.py +25 -0
  17. {stb_reader-0.2.0 → stb_reader-0.2.1}/uv.lock +15 -1
  18. stb_reader-0.2.0/README.md +0 -105
  19. stb_reader-0.2.0/tests/test_cli_config.py +0 -72
  20. {stb_reader-0.2.0 → stb_reader-0.2.1}/.claude/skills/incremental-implementation/SKILL.md +0 -0
  21. {stb_reader-0.2.0 → stb_reader-0.2.1}/.claude/skills/planning-and-task-breakdown/SKILL.md +0 -0
  22. {stb_reader-0.2.0 → stb_reader-0.2.1}/.claude/skills/spec-driven-development/SKILL.md +0 -0
  23. {stb_reader-0.2.0 → stb_reader-0.2.1}/.github/workflows/ci.yml +0 -0
  24. {stb_reader-0.2.0 → stb_reader-0.2.1}/.github/workflows/publish.yml +0 -0
  25. {stb_reader-0.2.0 → stb_reader-0.2.1}/.gitignore +0 -0
  26. {stb_reader-0.2.0 → stb_reader-0.2.1}/CLAUDE.md +0 -0
  27. {stb_reader-0.2.0 → stb_reader-0.2.1}/LICENSE +0 -0
  28. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/api-reference.md +0 -0
  29. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/authentication.md +0 -0
  30. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/error-handling.md +0 -0
  31. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/getting-started.md +0 -0
  32. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/live-tv.md +0 -0
  33. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/pagination.md +0 -0
  34. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/series.md +0 -0
  35. {stb_reader-0.2.0 → stb_reader-0.2.1}/docs/guide/vod.md +0 -0
  36. {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/README.md +0 -0
  37. {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/authentication.md +0 -0
  38. {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/live-tv.md +0 -0
  39. {stb_reader-0.2.0/docs → stb_reader-0.2.1/docs/protocol}/vod-series.md +0 -0
  40. {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/012-library-only/012-library-only-plan.md +0 -0
  41. {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/012-library-only/012-library-only-requirements.md +0 -0
  42. {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/013-documentation/013-documentation-plan.md +0 -0
  43. {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/013-documentation/013-documentation-requirements.md +0 -0
  44. {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/014-library-api-improvements/014-library-api-improvements-requirements.md +0 -0
  45. {stb_reader-0.2.0 → stb_reader-0.2.1}/spec/015-stb-cli/015-stb-cli-requirements.md +0 -0
  46. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/__init__.py +0 -0
  47. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/__init__.py +0 -0
  48. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/formatting.py +0 -0
  49. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/live.py +0 -0
  50. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/cli/main.py +0 -0
  51. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/client.py +0 -0
  52. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/exceptions.py +0 -0
  53. {stb_reader-0.2.0 → stb_reader-0.2.1}/stb_reader/live_tv.py +0 -0
  54. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/__init__.py +0 -0
  55. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_cli_formatting.py +0 -0
  56. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_cli_live.py +0 -0
  57. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_cli_vod.py +0 -0
  58. {stb_reader-0.2.0 → stb_reader-0.2.1}/tests/test_http.py +0 -0
  59. {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/ STB protocol reference (authentication, live-tv, vod-series)
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
- - Update the relevant `docs/` file when changing protocol behavior
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.0
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. Retrieve live-TV channels, VOD content, series, episodes, and stream URLs with simple method calls.
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
- ## Installation
54
+ ## Install
55
55
 
56
56
  ```bash
57
57
  pip install stb-reader
58
58
  ```
59
59
 
60
- Requires Python 3.11+ and has a single runtime dependency: `requests`.
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
- Full guides are in [`docs/guide/`](docs/guide/):
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stb-reader"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Python client library for Ministra/Stalker STB portals"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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[:500])
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
- device_id = hashlib.sha256(session.serial.encode()).hexdigest()
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
- client.authenticate()
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
@@ -53,6 +53,8 @@ class Season:
53
53
  id: str
54
54
  name: str
55
55
  video_id: str
56
+ season_number: str
57
+ episode_count: int
56
58
 
57
59
 
58
60
  @dataclass
@@ -77,6 +77,8 @@ class VODService:
77
77
  id=str(s["id"]),
78
78
  name=s.get("name", ""),
79
79
  video_id=str(s.get("video_id", "")),
80
+ season_number=str(s.get("season_number", "")),
81
+ episode_count=int(s.get("season_series", 0)),
80
82
  )
81
83
  for s in raw.get("data", [])
82
84
  ]
@@ -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.1.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'" },
@@ -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