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.
- nstream/__init__.py +3 -0
- nstream/__main__.py +8 -0
- nstream/addons.py +338 -0
- nstream/api.py +841 -0
- nstream/application.py +91 -0
- nstream/availability.py +178 -0
- nstream/bridge.py +533 -0
- nstream/cast_control.py +194 -0
- nstream/cast_delivery.py +181 -0
- nstream/cast_flow.py +813 -0
- nstream/cast_vet.py +468 -0
- nstream/caster.py +694 -0
- nstream/cli.py +1231 -0
- nstream/cli_args.py +257 -0
- nstream/config.py +440 -0
- nstream/debrid.py +389 -0
- nstream/discovery.py +177 -0
- nstream/doctor.py +67 -0
- nstream/engine.py +461 -0
- nstream/explain.py +301 -0
- nstream/failures.py +110 -0
- nstream/headless.py +572 -0
- nstream/headless_play.py +457 -0
- nstream/labels.py +142 -0
- nstream/languages.py +132 -0
- nstream/live.py +609 -0
- nstream/log.py +171 -0
- nstream/menus.py +100 -0
- nstream/mirror.py +437 -0
- nstream/net.py +281 -0
- nstream/notices.py +59 -0
- nstream/nstream.lua +209 -0
- nstream/oshash.py +77 -0
- nstream/picker.py +280 -0
- nstream/playback.py +37 -0
- nstream/player.py +395 -0
- nstream/preview.py +259 -0
- nstream/providers.py +21 -0
- nstream/quality.py +1011 -0
- nstream/remux.py +1540 -0
- nstream/series.py +254 -0
- nstream/serve.py +792 -0
- nstream/settings.py +742 -0
- nstream/sources.py +150 -0
- nstream/srt.py +195 -0
- nstream/state/__init__.py +110 -0
- nstream/state/breaker.py +212 -0
- nstream/state/cast_session.py +137 -0
- nstream/state/dead.py +71 -0
- nstream/state/history.py +310 -0
- nstream/state/throughput.py +63 -0
- nstream/stream_select.py +988 -0
- nstream/subalign.py +563 -0
- nstream/subs.py +394 -0
- nstream/tracks.py +162 -0
- nstream/types.py +93 -0
- nstream/ui.py +463 -0
- nstream/urlproxy.py +185 -0
- nstream/util.py +406 -0
- nstream-1.43.0.dist-info/METADATA +183 -0
- nstream-1.43.0.dist-info/RECORD +64 -0
- nstream-1.43.0.dist-info/WHEEL +4 -0
- nstream-1.43.0.dist-info/entry_points.txt +2 -0
- nstream-1.43.0.dist-info/licenses/LICENSE +21 -0
nstream/__init__.py
ADDED
nstream/__main__.py
ADDED
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))
|