pynetv 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. auth.py +370 -0
  2. cache.py +963 -0
  3. catchup.py +149 -0
  4. chromecast.py +389 -0
  5. epg.py +582 -0
  6. fast_start.py +354 -0
  7. ffmpeg_command.py +1782 -0
  8. ffmpeg_session.py +2646 -0
  9. gateway.py +703 -0
  10. gateway_catalog.py +354 -0
  11. gateway_epg.py +189 -0
  12. handoff.py +489 -0
  13. m3u.py +567 -0
  14. main.py +4580 -0
  15. playback_policy.py +181 -0
  16. playlists.py +533 -0
  17. pynetv-0.4.1.dist-info/METADATA +916 -0
  18. pynetv-0.4.1.dist-info/RECORD +59 -0
  19. pynetv-0.4.1.dist-info/WHEEL +4 -0
  20. pynetv-0.4.1.dist-info/entry_points.txt +6 -0
  21. pynetv-0.4.1.dist-info/licenses/LICENSE +190 -0
  22. static/icons/android-chrome-192x192.png +0 -0
  23. static/icons/android-chrome-512x512.png +0 -0
  24. static/icons/apple-touch-icon.png +0 -0
  25. static/icons/favicon-16x16.png +0 -0
  26. static/icons/favicon-32x32.png +0 -0
  27. static/icons/mstile-150x150.png +0 -0
  28. static/icons/site.webmanifest +20 -0
  29. static/js/app.js +245 -0
  30. static/js/cast.js +325 -0
  31. static/js/favorites-grid.js +225 -0
  32. static/js/handoff-card.js +54 -0
  33. static/js/live-playback.js +227 -0
  34. static/js/player-quality.js +41 -0
  35. static/js/player.js +1925 -0
  36. static/js/settings.js +944 -0
  37. static/js/virtual-guide.js +711 -0
  38. templates/base.html +87 -0
  39. templates/error.html +14 -0
  40. templates/guide.html +592 -0
  41. templates/icons.html +5 -0
  42. templates/login.html +35 -0
  43. templates/movie_detail.html +101 -0
  44. templates/player.html +286 -0
  45. templates/playlists.html +452 -0
  46. templates/search.html +207 -0
  47. templates/series.html +110 -0
  48. templates/series_detail.html +111 -0
  49. templates/settings.html +1007 -0
  50. templates/setup.html +46 -0
  51. templates/vod.html +111 -0
  52. tools/alignm3u.py +310 -0
  53. tools/debug_remap.py +79 -0
  54. tools/manual_remap.py +121 -0
  55. tools/remap_epg.py +915 -0
  56. tools/xtream2m3u.py +671 -0
  57. tools/zap2xml.py +733 -0
  58. util.py +232 -0
  59. xtream.py +161 -0
auth.py ADDED
@@ -0,0 +1,370 @@
1
+ """Authentication: users, passwords, tokens, JWT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NotRequired, TypedDict
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import pathlib
11
+ import secrets
12
+ import time
13
+
14
+ from treekle import MutablePlainTree, from_plain, json_loads, mutable, to_plain
15
+
16
+ from util import atomic_write_json, data_dir, merge_unknown, read_json_object
17
+
18
+
19
+ class UserRecord(TypedDict):
20
+ """A stored account; ``password`` is a salted PBKDF2 hash."""
21
+
22
+ password: str
23
+ admin: bool
24
+ max_streams_per_source: NotRequired[dict[str, int]]
25
+ unavailable_groups: NotRequired[list[str]]
26
+
27
+
28
+ class UserLimits(TypedDict):
29
+ """A user's per-source stream limits and blocked groups."""
30
+
31
+ max_streams_per_source: dict[str, int]
32
+ unavailable_groups: list[str]
33
+
34
+
35
+ class UserSummary(UserLimits):
36
+ """Public account metadata, without the password hash."""
37
+
38
+ username: str
39
+ admin: bool
40
+
41
+
42
+ class TokenClaims(TypedDict):
43
+ """Claims signed into a session token."""
44
+
45
+ sub: str
46
+ exp: NotRequired[int]
47
+
48
+
49
+ APP_DIR = pathlib.Path(__file__).parent
50
+ CACHE_DIR = APP_DIR / ".cache"
51
+ SERVER_SETTINGS_FILE = CACHE_DIR / "server_settings.json"
52
+ USERS_DIR = CACHE_DIR / "users"
53
+
54
+
55
+ def get_all_usernames() -> list[str]:
56
+ """Get list of all usernames.
57
+
58
+ Returns:
59
+ usernames: Account names in stored order.
60
+ """
61
+ return list(
62
+ from_plain(
63
+ read_json_object(_get_settings_file()).get("users", {}),
64
+ dict[str, UserRecord],
65
+ ).keys(),
66
+ )
67
+
68
+
69
+ def is_setup_required() -> bool:
70
+ """Check if initial setup is required (no users configured).
71
+
72
+ Returns:
73
+ required: Whether no account exists yet.
74
+ """
75
+ return (
76
+ len(
77
+ from_plain(
78
+ read_json_object(_get_settings_file()).get("users", {}),
79
+ dict[str, UserRecord],
80
+ ),
81
+ )
82
+ == 0
83
+ )
84
+
85
+
86
+ def create_user(username: str, password: str, admin: bool = False) -> None:
87
+ """Create a new user with hashed password.
88
+
89
+ Args:
90
+ username: Account name.
91
+ password: Plaintext password to hash.
92
+ admin: Grant administrator access; the first account is always admin.
93
+ """
94
+ settings = read_json_object(_get_settings_file())
95
+ users = from_plain(settings.get("users", {}), dict[str, UserRecord])
96
+ # First user is always admin.
97
+ if len(users) == 0:
98
+ admin = True
99
+ users[username] = {"password": _hash_password(password), "admin": admin}
100
+ _save_users(settings, users)
101
+ # Create user directory for per-user settings.
102
+ user_dir = USERS_DIR / username
103
+ user_dir.mkdir(parents=True, exist_ok=True)
104
+
105
+
106
+ def delete_user(username: str) -> bool:
107
+ """Delete an account and its source assignments.
108
+
109
+ Args:
110
+ username: Account to remove.
111
+
112
+ Returns:
113
+ deleted: Whether the account existed.
114
+ """
115
+ settings_file = _get_settings_file()
116
+ if not settings_file.exists():
117
+ return False
118
+ settings = read_json_object(settings_file)
119
+ users = from_plain(settings.get("users", {}), dict[str, UserRecord])
120
+ if username not in users:
121
+ return False
122
+ del users[username]
123
+ _ensure_one_admin(users)
124
+ # Drop the user from source assignments so a future account with the same
125
+ # name does not inherit access.
126
+ sources = settings.get("sources", [])
127
+ if isinstance(sources, list):
128
+ for source in sources:
129
+ if isinstance(source, dict):
130
+ assigned = source.get("users")
131
+ if isinstance(assigned, list) and username in assigned:
132
+ source["users"] = [u for u in assigned if u != username]
133
+ _save_users(settings, users)
134
+ return True
135
+
136
+
137
+ def verify_password(username: str, password: str) -> bool:
138
+ """Verify username and password.
139
+
140
+ Args:
141
+ username: Account to check.
142
+ password: Plaintext password to verify.
143
+
144
+ Returns:
145
+ valid: Whether the account exists and the password matches.
146
+ """
147
+ users = from_plain(
148
+ read_json_object(_get_settings_file()).get("users", {}),
149
+ dict[str, UserRecord],
150
+ )
151
+ stored = users[username]["password"] if username in users else _hash_password("dummy")
152
+ valid = _verify_hashed_password(password, stored)
153
+ return valid and username in users
154
+
155
+
156
+ def change_password(username: str, new_password: str) -> bool:
157
+ """Change an existing user's password.
158
+
159
+ Args:
160
+ username: Account to update.
161
+ new_password: Plaintext replacement password.
162
+
163
+ Returns:
164
+ changed: Whether the account existed and was updated.
165
+ """
166
+ settings_file = _get_settings_file()
167
+ if not settings_file.exists():
168
+ return False
169
+ settings = read_json_object(settings_file)
170
+ users = from_plain(settings.get("users", {}), dict[str, UserRecord])
171
+ if username not in users:
172
+ return False
173
+ users[username]["password"] = _hash_password(new_password)
174
+ _save_users(settings, users)
175
+ return True
176
+
177
+
178
+ def is_admin(username: str) -> bool:
179
+ """Check if user is an admin."""
180
+ users = from_plain(
181
+ read_json_object(_get_settings_file()).get("users", {}),
182
+ dict[str, UserRecord],
183
+ )
184
+ return username in users and users[username]["admin"]
185
+
186
+
187
+ def set_admin(username: str, admin: bool) -> bool:
188
+ """Set administrator access while retaining at least one administrator.
189
+
190
+ Args:
191
+ username: Account to update.
192
+ admin: Requested administrator status.
193
+
194
+ Returns:
195
+ changed: Whether the account existed and was updated.
196
+ """
197
+ settings_file = _get_settings_file()
198
+ if not settings_file.exists():
199
+ return False
200
+ settings = read_json_object(settings_file)
201
+ users = from_plain(settings.get("users", {}), dict[str, UserRecord])
202
+ if username not in users:
203
+ return False
204
+ users[username]["admin"] = admin
205
+ _ensure_one_admin(users)
206
+ _save_users(settings, users)
207
+ return True
208
+
209
+
210
+ def get_users_with_admin() -> list[UserSummary]:
211
+ """Get users with their administrator status and limits.
212
+
213
+ Returns:
214
+ users: Public account metadata without password hashes.
215
+ """
216
+ return [
217
+ {
218
+ "username": u,
219
+ "admin": d["admin"],
220
+ "max_streams_per_source": d.get("max_streams_per_source", {}),
221
+ "unavailable_groups": d.get("unavailable_groups", []),
222
+ }
223
+ for u, d in from_plain(
224
+ read_json_object(_get_settings_file()).get("users", {}),
225
+ dict[str, UserRecord],
226
+ ).items()
227
+ ]
228
+
229
+
230
+ def get_user_limits(username: str) -> UserLimits:
231
+ """Get a user's stream limits and group restrictions.
232
+
233
+ Args:
234
+ username: Account to look up.
235
+
236
+ Returns:
237
+ limits: Per-source stream limits and unavailable groups.
238
+ """
239
+ user_data = from_plain(
240
+ read_json_object(_get_settings_file()).get("users", {}),
241
+ dict[str, UserRecord],
242
+ ).get(username)
243
+ if user_data is None:
244
+ return {"max_streams_per_source": {}, "unavailable_groups": []}
245
+ return {
246
+ "max_streams_per_source": user_data.get("max_streams_per_source", {}),
247
+ "unavailable_groups": user_data.get("unavailable_groups", []),
248
+ }
249
+
250
+
251
+ def set_user_limits(
252
+ username: str,
253
+ max_streams_per_source: dict[str, int] | None = None,
254
+ unavailable_groups: list[str] | None = None,
255
+ ) -> bool:
256
+ """Set a user's stream limits and group restrictions.
257
+
258
+ Args:
259
+ username: Account to update.
260
+ max_streams_per_source: Replacement limits; None preserves existing limits.
261
+ unavailable_groups: Replacement restrictions; None preserves existing groups.
262
+
263
+ Returns:
264
+ changed: Whether the account existed and was updated.
265
+ """
266
+ settings_file = _get_settings_file()
267
+ if not settings_file.exists():
268
+ return False
269
+ settings = read_json_object(settings_file)
270
+ users = from_plain(settings.get("users", {}), dict[str, UserRecord])
271
+ if username not in users:
272
+ return False
273
+ if max_streams_per_source is not None:
274
+ users[username]["max_streams_per_source"] = max_streams_per_source
275
+ if unavailable_groups is not None:
276
+ users[username]["unavailable_groups"] = unavailable_groups
277
+ _save_users(settings, users)
278
+ return True
279
+
280
+
281
+ def create_token(payload: TokenClaims, *, expiry_seconds: int = 86_400 * 7) -> str:
282
+ """Create a signed JWT-like token."""
283
+ claims: TokenClaims = {**payload, "exp": int(time.time()) + expiry_seconds}
284
+ data = json.dumps(claims, separators=(",", ":")).encode()
285
+ sig = hmac.new(_get_secret_key().encode(), data, hashlib.sha256).hexdigest()
286
+ return f"{data.hex()}.{sig}"
287
+
288
+
289
+ def verify_token(token: str) -> TokenClaims | None:
290
+ """Verify a token's signature and expiry.
291
+
292
+ Args:
293
+ token: Hex-encoded payload and signature.
294
+
295
+ Returns:
296
+ payload: Authenticated payload, or None for an invalid or expired token.
297
+ """
298
+ try:
299
+ data_hex, sig = token.split(".")
300
+ data = bytes.fromhex(data_hex)
301
+ expected = hmac.new(_get_secret_key().encode(), data, hashlib.sha256).hexdigest()
302
+ if not hmac.compare_digest(sig, expected):
303
+ return None
304
+ payload = json_loads(data.decode(), TokenClaims)
305
+ except (OSError, ValueError):
306
+ return None
307
+ if payload.get("exp", 0) < time.time():
308
+ return None
309
+ return payload
310
+
311
+
312
+ def _get_settings_file() -> pathlib.Path:
313
+ """Resolve legacy storage without replacing caller-supplied paths."""
314
+ global CACHE_DIR, SERVER_SETTINGS_FILE, USERS_DIR
315
+ default_dir = APP_DIR / ".cache"
316
+ if default_dir == CACHE_DIR:
317
+ CACHE_DIR = data_dir(APP_DIR)
318
+ if default_dir / "server_settings.json" == SERVER_SETTINGS_FILE:
319
+ SERVER_SETTINGS_FILE = CACHE_DIR / "server_settings.json"
320
+ if default_dir / "users" == USERS_DIR:
321
+ USERS_DIR = CACHE_DIR / "users"
322
+ return SERVER_SETTINGS_FILE
323
+
324
+
325
+ def _get_secret_key() -> str:
326
+ """Get or generate secret key (persisted in settings)."""
327
+ settings_file = _get_settings_file()
328
+ settings = read_json_object(settings_file)
329
+ key = settings.get("secret_key")
330
+ if isinstance(key, str):
331
+ return key
332
+ key = secrets.token_hex(32)
333
+ settings["secret_key"] = key
334
+ atomic_write_json(settings_file, settings)
335
+ return key
336
+
337
+
338
+ def _hash_password(password: str, salt: str | None = None) -> str:
339
+ """Hash password with salt using PBKDF2."""
340
+ if salt is None:
341
+ salt = secrets.token_hex(16)
342
+ key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 100_000)
343
+ return f"{salt}:{key.hex()}"
344
+
345
+
346
+ def _verify_hashed_password(password: str, hashed: str) -> bool:
347
+ """Verify password against hash."""
348
+ if ":" not in hashed:
349
+ return False # Invalid hash format.
350
+ salt, _ = hashed.split(":", 1)
351
+ return hmac.compare_digest(_hash_password(password, salt), hashed)
352
+
353
+
354
+ # Unknown keys survive both beside ``users`` and inside each user's record; a deleted
355
+ # user, or an owned key the record no longer has, is removed.
356
+ def _save_users(settings: dict[str, MutablePlainTree], users: dict[str, UserRecord]) -> None:
357
+ """Write ``users`` into the raw settings tree, keeping keys this release does not know."""
358
+ settings["users"] = merge_unknown(
359
+ settings.get("users"),
360
+ mutable(to_plain(users)),
361
+ dict[str, UserRecord],
362
+ )
363
+ atomic_write_json(_get_settings_file(), settings)
364
+
365
+
366
+ def _ensure_one_admin(users: dict[str, UserRecord]) -> None:
367
+ """Ensure at least one user is admin. Promotes first user if needed."""
368
+ if not users or any(u["admin"] for u in users.values()):
369
+ return
370
+ next(iter(users.values()))["admin"] = True