nstream 1.43.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. nstream/__init__.py +3 -0
  2. nstream/__main__.py +8 -0
  3. nstream/addons.py +338 -0
  4. nstream/api.py +841 -0
  5. nstream/application.py +91 -0
  6. nstream/availability.py +178 -0
  7. nstream/bridge.py +533 -0
  8. nstream/cast_control.py +194 -0
  9. nstream/cast_delivery.py +181 -0
  10. nstream/cast_flow.py +813 -0
  11. nstream/cast_vet.py +468 -0
  12. nstream/caster.py +694 -0
  13. nstream/cli.py +1231 -0
  14. nstream/cli_args.py +257 -0
  15. nstream/config.py +440 -0
  16. nstream/debrid.py +389 -0
  17. nstream/discovery.py +177 -0
  18. nstream/doctor.py +67 -0
  19. nstream/engine.py +461 -0
  20. nstream/explain.py +301 -0
  21. nstream/failures.py +110 -0
  22. nstream/headless.py +572 -0
  23. nstream/headless_play.py +457 -0
  24. nstream/labels.py +142 -0
  25. nstream/languages.py +132 -0
  26. nstream/live.py +609 -0
  27. nstream/log.py +171 -0
  28. nstream/menus.py +100 -0
  29. nstream/mirror.py +437 -0
  30. nstream/net.py +281 -0
  31. nstream/notices.py +59 -0
  32. nstream/nstream.lua +209 -0
  33. nstream/oshash.py +77 -0
  34. nstream/picker.py +280 -0
  35. nstream/playback.py +37 -0
  36. nstream/player.py +395 -0
  37. nstream/preview.py +259 -0
  38. nstream/providers.py +21 -0
  39. nstream/quality.py +1011 -0
  40. nstream/remux.py +1540 -0
  41. nstream/series.py +254 -0
  42. nstream/serve.py +792 -0
  43. nstream/settings.py +742 -0
  44. nstream/sources.py +150 -0
  45. nstream/srt.py +195 -0
  46. nstream/state/__init__.py +110 -0
  47. nstream/state/breaker.py +212 -0
  48. nstream/state/cast_session.py +137 -0
  49. nstream/state/dead.py +71 -0
  50. nstream/state/history.py +310 -0
  51. nstream/state/throughput.py +63 -0
  52. nstream/stream_select.py +988 -0
  53. nstream/subalign.py +563 -0
  54. nstream/subs.py +394 -0
  55. nstream/tracks.py +162 -0
  56. nstream/types.py +93 -0
  57. nstream/ui.py +463 -0
  58. nstream/urlproxy.py +185 -0
  59. nstream/util.py +406 -0
  60. nstream-1.43.0.dist-info/METADATA +183 -0
  61. nstream-1.43.0.dist-info/RECORD +64 -0
  62. nstream-1.43.0.dist-info/WHEEL +4 -0
  63. nstream-1.43.0.dist-info/entry_points.txt +2 -0
  64. nstream-1.43.0.dist-info/licenses/LICENSE +21 -0
nstream/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """nstream — native terminal-first Stremio-like client: Cinemeta + Torrentio/debrid or P2P + mpv."""
2
+
3
+ __version__ = "1.43.0"
nstream/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable `python -m nstream` (used as the preview-command fallback when the installed
2
+ console script isn't on PATH, e.g. editable/dev checkouts and tests)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from .cli import _entry
7
+
8
+ _entry()
nstream/addons.py ADDED
@@ -0,0 +1,338 @@
1
+ """Stremio addon-protocol client: built-in providers, user addons, resource dispatch.
2
+
3
+ A Stremio addon is identified by a ``…/manifest.json`` URL; its *base* is that URL minus
4
+ ``/manifest.json`` and a resource is fetched at ``{base}/{resource}/{type}/{id}.json``.
5
+
6
+ The built-in providers (Cinemeta, Torrentio, OpenSubtitles) are described inline, so no
7
+ manifest fetch is needed for them; only user-added addons are fetched and cached on disk.
8
+ The cache is keyed by a hash of the manifest URL — a Torrentio URL embeds the Real-Debrid
9
+ token, so it must never be written to the cache file in clear.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import threading
19
+ import time
20
+ import urllib.parse
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from . import net, util
25
+ from .config import Config
26
+ from .providers import DEBRID_PROVIDERS
27
+
28
+ CACHE_TTL = 86400 # re-fetch a user addon's manifest at most once a day
29
+ # After a failed manifest fetch, don't retry it in this process for a while: every API call
30
+ # goes through `effective_addons`, so a down addon used to cost one timed-out fetch per call.
31
+ FAIL_TTL = 600.0
32
+ _failed_at: dict[str, float] = {}
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Addon:
37
+ base: str
38
+ name: str
39
+ resources: dict[str, dict] # resource -> {"types": [...], "idPrefixes": [...]}
40
+ # (type, id, display name) — name falls back to id when the manifest omits it.
41
+ catalogs: tuple[tuple[str, str, str], ...] = ()
42
+ builtin: bool = False
43
+ # (type, id) of the catalogs that declare the `search` extra. Only these may be
44
+ # queried with `search=`: an addon ignores the extra on any other catalog and answers
45
+ # with unrelated rows (anime-kitsu returned "Hong Gil Dong 2084" for "Il grande Gatsby").
46
+ search_catalogs: tuple[tuple[str, str], ...] = ()
47
+ manifest_url: str = ""
48
+
49
+
50
+ # Cinemeta-shaped catalog ids already exposed as fixed TUI rows (Popolari / Novità / Top).
51
+ # User addons that re-declare these still contribute to the fetch fan-out; they just don't
52
+ # get a duplicate menu entry.
53
+ _BUILTIN_CATALOG_IDS = frozenset({"top", "year", "imdbRating"})
54
+
55
+
56
+ # --- manifest cache (user addons only) -----------------------------------
57
+
58
+
59
+ def _cache_path() -> Path:
60
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
61
+ return Path(base) / "nstream" / "manifests.json"
62
+
63
+
64
+ def _load_cache() -> dict:
65
+ return util.load_json(_cache_path(), {})
66
+
67
+
68
+ def _store_cache(cache: dict) -> None:
69
+ # The cache is best-effort; never block playback on a write failure.
70
+ with contextlib.suppress(OSError):
71
+ util.atomic_write(
72
+ _cache_path(),
73
+ lambda f: json.dump(cache, f, ensure_ascii=False),
74
+ prefix=".manifests-",
75
+ )
76
+
77
+
78
+ # --- manifest parsing ----------------------------------------------------
79
+
80
+
81
+ def _base_of(manifest_url: str) -> str:
82
+ suffix = "/manifest.json"
83
+ if manifest_url.endswith(suffix):
84
+ return manifest_url[: -len(suffix)]
85
+ return manifest_url.rstrip("/")
86
+
87
+
88
+ def _declares_search(catalog: dict) -> bool:
89
+ """True if a manifest catalog accepts the `search` extra (current `extra` list or the
90
+ legacy `extraSupported` form)."""
91
+ extra = catalog.get("extra")
92
+ if isinstance(extra, list) and any(
93
+ isinstance(e, dict) and e.get("name") == "search" for e in extra
94
+ ):
95
+ return True
96
+ legacy = catalog.get("extraSupported")
97
+ return isinstance(legacy, list) and "search" in legacy
98
+
99
+
100
+ def _parse_manifest(manifest_url: str, data: dict) -> Addon:
101
+ if not isinstance(data, dict): # guard against a corrupt/partial cache entry
102
+ data = {}
103
+ m_types = data.get("types", [])
104
+ m_idp = data.get("idPrefixes", [])
105
+ resources: dict[str, dict] = {}
106
+ for r in data.get("resources", []):
107
+ if isinstance(r, str):
108
+ resources[r] = {"types": list(m_types), "idPrefixes": list(m_idp)}
109
+ elif isinstance(r, dict) and r.get("name"):
110
+ resources[r["name"]] = {
111
+ "types": list(r.get("types", m_types)),
112
+ "idPrefixes": list(r.get("idPrefixes", m_idp)),
113
+ }
114
+ catalogs: list[tuple[str, str, str]] = []
115
+ search_catalogs: list[tuple[str, str]] = []
116
+ for c in data.get("catalogs", []):
117
+ if not isinstance(c, dict):
118
+ continue
119
+ cat_id = str(c.get("id") or "")
120
+ if not cat_id:
121
+ continue
122
+ typ = str(c.get("type") or "")
123
+ name = str(c.get("name") or cat_id).strip() or cat_id
124
+ catalogs.append((typ, cat_id, name))
125
+ if _declares_search(c):
126
+ search_catalogs.append((typ, cat_id))
127
+ base = _base_of(manifest_url)
128
+ return Addon(
129
+ base=base,
130
+ name=data.get("name") or base,
131
+ resources=resources,
132
+ catalogs=tuple(catalogs),
133
+ manifest_url=manifest_url,
134
+ search_catalogs=tuple(search_catalogs),
135
+ )
136
+
137
+
138
+ def load_addon(manifest_url: str, *, use_cache: bool = True) -> Addon | None:
139
+ """Fetch+parse a user addon manifest (cached). Returns None if unreachable.
140
+
141
+ A stale cached manifest is served immediately and refreshed on a background thread:
142
+ a synchronous refresh sat on every cold run's critical path, outside any gather budget
143
+ (~24s once a down addon's manifest passed its TTL). A failed refresh is remembered in
144
+ the cache (`fail_ts`) so later processes don't retry it for FAIL_TTL."""
145
+ key = hashlib.sha256(manifest_url.encode()).hexdigest()
146
+ if not use_cache:
147
+ return _fetch_manifest(manifest_url, key, {})
148
+ cache = _load_cache()
149
+ entry = cache.get(key)
150
+ if entry and "manifest" in entry:
151
+ fresh = (time.time() - entry.get("ts", 0)) < CACHE_TTL
152
+ failed_recently = (time.time() - entry.get("fail_ts", 0)) < FAIL_TTL
153
+ if not fresh and not failed_recently:
154
+ _refresh_in_background(manifest_url, key)
155
+ return _parse_manifest(manifest_url, entry["manifest"])
156
+ if time.monotonic() - _failed_at.get(key, -FAIL_TTL) < FAIL_TTL:
157
+ return None
158
+ return _fetch_manifest(manifest_url, key, cache)
159
+
160
+
161
+ def _fetch_manifest(manifest_url: str, key: str, cache: dict) -> Addon | None:
162
+ try:
163
+ # One try only: a dead user addon must not stall the flow for ~60s.
164
+ data = net.http_get_json(manifest_url, what="manifest addon", retries=1)
165
+ except net.NetworkError:
166
+ _failed_at[key] = time.monotonic()
167
+ return None
168
+ _failed_at.pop(key, None)
169
+ cache[key] = {"ts": int(time.time()), "manifest": data}
170
+ _store_cache(cache)
171
+ return _parse_manifest(manifest_url, data)
172
+
173
+
174
+ _refreshing: set[str] = set()
175
+ _refresh_lock = threading.Lock()
176
+
177
+
178
+ def _refresh_in_background(manifest_url: str, key: str) -> None:
179
+ """Best-effort refresh of a stale manifest; the process may exit before it lands."""
180
+ with _refresh_lock:
181
+ if key in _refreshing:
182
+ return
183
+ _refreshing.add(key)
184
+
185
+ def run() -> None:
186
+ try:
187
+ data = net.http_get_json(manifest_url, what="manifest addon", retries=1)
188
+ except net.NetworkError:
189
+ data = None
190
+ cache = _load_cache()
191
+ entry = dict(cache.get(key) or {})
192
+ if data is None:
193
+ entry["fail_ts"] = int(time.time())
194
+ else:
195
+ entry = {"ts": int(time.time()), "manifest": data}
196
+ if "manifest" in entry:
197
+ cache[key] = entry
198
+ _store_cache(cache)
199
+ with _refresh_lock:
200
+ _refreshing.discard(key)
201
+
202
+ threading.Thread(target=run, name="manifest-refresh", daemon=True).start()
203
+
204
+
205
+ # --- built-ins + dispatch ------------------------------------------------
206
+
207
+
208
+ def _strip_debrid(base: str) -> str:
209
+ """Drop any debrid segment from a Torrentio config string, so Torrentio returns
210
+ pure-torrent results (infoHash) instead of debrid urls."""
211
+ segs = [s for s in base.split("|") if s and s.split("=", 1)[0] not in DEBRID_PROVIDERS]
212
+ return "|".join(segs) or "sort=qualitysize"
213
+
214
+
215
+ def _torrentio_url(base: str) -> str:
216
+ return "https://torrentio.strem.fun/" + urllib.parse.quote(base, safe="=|")
217
+
218
+
219
+ def torrentio_base(cfg: Config) -> str:
220
+ base = cfg.torrentio_base
221
+ # "local" streams torrents itself and "native" resolves the chosen one through the
222
+ # provider's own API, so both query Torrentio token-less (pure-torrent infoHash). The
223
+ # hybrid "auto" backend keeps the token here (debrid urls / cached) and fetches the
224
+ # pure-torrent variant separately (see torrentio_token_less / api hybrid merge).
225
+ if cfg.playback_backend in ("local", "native"):
226
+ base = _strip_debrid(base)
227
+ return _torrentio_url(base)
228
+
229
+
230
+ def torrentio_token_less(cfg: Config) -> str:
231
+ """Torrentio base URL with the debrid segment stripped — pure-torrent results
232
+ (infoHash). Used by the hybrid 'auto' backend's P2P-fallback query."""
233
+ return _torrentio_url(_strip_debrid(cfg.torrentio_base))
234
+
235
+
236
+ def _builtins(cfg: Config) -> list[Addon]:
237
+ out = [
238
+ Addon(
239
+ base=cfg.cinemeta.rstrip("/"),
240
+ name="Cinemeta",
241
+ builtin=True,
242
+ resources={
243
+ "catalog": {"types": ["movie", "series"], "idPrefixes": ["tt"]},
244
+ "meta": {"types": ["movie", "series"], "idPrefixes": ["tt"]},
245
+ },
246
+ catalogs=tuple(
247
+ (t, c, c) for c in ("top", "year", "imdbRating") for t in ("movie", "series")
248
+ ),
249
+ search_catalogs=(("movie", "top"), ("series", "top")),
250
+ ),
251
+ ]
252
+ # Torrentio is optional: when disabled, stream discovery is only from cfg.addons
253
+ # (Comet / MediaFusion / AIOStreams / custom). Meta + subs builtins stay.
254
+ if cfg.torrentio_enabled:
255
+ out.append(
256
+ Addon(
257
+ base=torrentio_base(cfg),
258
+ name="Torrentio",
259
+ builtin=True,
260
+ resources={"stream": {"types": ["movie", "series"], "idPrefixes": ["tt", "kitsu"]}},
261
+ )
262
+ )
263
+ out.append(
264
+ Addon(
265
+ base=cfg.opensubtitles.rstrip("/"),
266
+ name="OpenSubtitles",
267
+ builtin=True,
268
+ resources={"subtitles": {"types": ["movie", "series"], "idPrefixes": []}},
269
+ )
270
+ )
271
+ return out
272
+
273
+
274
+ def effective_addons(cfg: Config) -> list[Addon]:
275
+ """Built-in providers plus the user's extra addons, deduped by base."""
276
+ result = _builtins(cfg)
277
+ for url in cfg.addons:
278
+ addon = load_addon(url)
279
+ if addon is not None:
280
+ result.append(addon)
281
+ seen: set[str] = set()
282
+ out: list[Addon] = []
283
+ for addon in result:
284
+ if addon.base not in seen:
285
+ seen.add(addon.base)
286
+ out.append(addon)
287
+ return out
288
+
289
+
290
+ def search_catalog(addon: Addon, typ: str) -> str | None:
291
+ """Id of the first catalog of `typ` that `addon` declares searchable, else None."""
292
+ return next((c for t, c in addon.search_catalogs if t == typ), None)
293
+
294
+
295
+ def has_catalog(addon: Addon, typ: str, cat: str) -> bool:
296
+ """True if `addon` declares a catalog with this type and id."""
297
+ return any(t == typ and c == cat for t, c, *_ in addon.catalogs)
298
+
299
+
300
+ def extra_catalogs(cfg: Config, typ: str) -> list[tuple[str, str]]:
301
+ """User-addon catalogs for `typ` as `(catalog_id, display_label)`, in addon order.
302
+
303
+ Skips built-ins and the Cinemeta-shaped ids already pinned in the TUI section menu.
304
+ Dedupes by catalog id (first declaration wins the label). Labels prefer the manifest
305
+ name and fall back to ``Addon · id`` when the name is just the bare id.
306
+ """
307
+ out: list[tuple[str, str]] = []
308
+ seen: set[str] = set()
309
+ for addon in effective_addons(cfg):
310
+ if addon.builtin or not serves(addon, "catalog", typ):
311
+ continue
312
+ for t, cat_id, name in addon.catalogs:
313
+ if t != typ or not cat_id or cat_id in _BUILTIN_CATALOG_IDS or cat_id in seen:
314
+ continue
315
+ seen.add(cat_id)
316
+ label = name if name and name != cat_id else f"{addon.name} · {cat_id}"
317
+ out.append((cat_id, label))
318
+ return out
319
+
320
+
321
+ def serves(addon: Addon, resource: str, typ: str, video_id: str | None = None) -> bool:
322
+ """True if `addon` provides `resource` for this type (and id prefix, if given)."""
323
+ spec = addon.resources.get(resource)
324
+ if not spec:
325
+ return False
326
+ if spec["types"] and typ not in spec["types"]:
327
+ return False
328
+ prefixes = spec["idPrefixes"]
329
+ return not (video_id and prefixes and not any(video_id.startswith(p) for p in prefixes))
330
+
331
+
332
+ def has_stream_source(cfg: Config) -> bool:
333
+ """True when at least one effective addon can serve the `stream` resource.
334
+
335
+ False means Torrentio is disabled and no stream-capable extra is configured —
336
+ search/meta still work, but every title will return zero streams.
337
+ """
338
+ return any("stream" in a.resources for a in effective_addons(cfg))