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.
- auth.py +370 -0
- cache.py +963 -0
- catchup.py +149 -0
- chromecast.py +389 -0
- epg.py +582 -0
- fast_start.py +354 -0
- ffmpeg_command.py +1782 -0
- ffmpeg_session.py +2646 -0
- gateway.py +703 -0
- gateway_catalog.py +354 -0
- gateway_epg.py +189 -0
- handoff.py +489 -0
- m3u.py +567 -0
- main.py +4580 -0
- playback_policy.py +181 -0
- playlists.py +533 -0
- pynetv-0.4.1.dist-info/METADATA +916 -0
- pynetv-0.4.1.dist-info/RECORD +59 -0
- pynetv-0.4.1.dist-info/WHEEL +4 -0
- pynetv-0.4.1.dist-info/entry_points.txt +6 -0
- pynetv-0.4.1.dist-info/licenses/LICENSE +190 -0
- static/icons/android-chrome-192x192.png +0 -0
- static/icons/android-chrome-512x512.png +0 -0
- static/icons/apple-touch-icon.png +0 -0
- static/icons/favicon-16x16.png +0 -0
- static/icons/favicon-32x32.png +0 -0
- static/icons/mstile-150x150.png +0 -0
- static/icons/site.webmanifest +20 -0
- static/js/app.js +245 -0
- static/js/cast.js +325 -0
- static/js/favorites-grid.js +225 -0
- static/js/handoff-card.js +54 -0
- static/js/live-playback.js +227 -0
- static/js/player-quality.js +41 -0
- static/js/player.js +1925 -0
- static/js/settings.js +944 -0
- static/js/virtual-guide.js +711 -0
- templates/base.html +87 -0
- templates/error.html +14 -0
- templates/guide.html +592 -0
- templates/icons.html +5 -0
- templates/login.html +35 -0
- templates/movie_detail.html +101 -0
- templates/player.html +286 -0
- templates/playlists.html +452 -0
- templates/search.html +207 -0
- templates/series.html +110 -0
- templates/series_detail.html +111 -0
- templates/settings.html +1007 -0
- templates/setup.html +46 -0
- templates/vod.html +111 -0
- tools/alignm3u.py +310 -0
- tools/debug_remap.py +79 -0
- tools/manual_remap.py +121 -0
- tools/remap_epg.py +915 -0
- tools/xtream2m3u.py +671 -0
- tools/zap2xml.py +733 -0
- util.py +232 -0
- 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
|