bdix-stremio-addon 2.0.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.
- bdix_stremio_addon-2.0.1/LICENSE +21 -0
- bdix_stremio_addon-2.0.1/PKG-INFO +84 -0
- bdix_stremio_addon-2.0.1/README.md +45 -0
- bdix_stremio_addon-2.0.1/bdix/__init__.py +10 -0
- bdix_stremio_addon-2.0.1/bdix/__main__.py +293 -0
- bdix_stremio_addon-2.0.1/bdix/common.py +117 -0
- bdix_stremio_addon-2.0.1/bdix/config.py +71 -0
- bdix_stremio_addon-2.0.1/bdix/server.py +289 -0
- bdix_stremio_addon-2.0.1/bdix/sources/__init__.py +48 -0
- bdix_stremio_addon-2.0.1/bdix/sources/circleftp.py +170 -0
- bdix_stremio_addon-2.0.1/bdix/sources/dflix.py +235 -0
- bdix_stremio_addon-2.0.1/bdix/webui.html +285 -0
- bdix_stremio_addon-2.0.1/bdix_stremio_addon.egg-info/PKG-INFO +84 -0
- bdix_stremio_addon-2.0.1/bdix_stremio_addon.egg-info/SOURCES.txt +17 -0
- bdix_stremio_addon-2.0.1/bdix_stremio_addon.egg-info/dependency_links.txt +1 -0
- bdix_stremio_addon-2.0.1/bdix_stremio_addon.egg-info/entry_points.txt +2 -0
- bdix_stremio_addon-2.0.1/bdix_stremio_addon.egg-info/top_level.txt +1 -0
- bdix_stremio_addon-2.0.1/pyproject.toml +31 -0
- bdix_stremio_addon-2.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 serra666
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bdix-stremio-addon
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: One combined Stremio stream addon for BDIX/FTP sources, with a web UI to choose which sources are included.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 serra666
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Keywords: stremio,stremio-addon,addon,streams,bdix,torrentio
|
|
28
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
29
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: OS Independent
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
34
|
+
Classifier: Topic :: Multimedia :: Video
|
|
35
|
+
Requires-Python: >=3.8
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# bdix-stremio-addon
|
|
41
|
+
|
|
42
|
+
A Stremio addon for BDIX stream sites, with a web UI to choose which sources
|
|
43
|
+
are included. Python 3.8+, standard library only.
|
|
44
|
+
|
|
45
|
+
## Supported sites
|
|
46
|
+
|
|
47
|
+
1. **Dflix**
|
|
48
|
+
2. **CircleFTP**
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Run it without installing anything:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uvx bdix-stremio-addon # or: pipx run bdix-stremio-addon
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or install it:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pipx install bdix-stremio-addon # then run: bdix
|
|
62
|
+
pip install bdix-stremio-addon # then run: python -m bdix
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then open <http://127.0.0.1:7000/>, pick your sources, and install the addon
|
|
66
|
+
into Stremio from that page.
|
|
67
|
+
|
|
68
|
+
## Use it from a TV or phone
|
|
69
|
+
|
|
70
|
+
The addon listens on `127.0.0.1`, which only reaches Stremio running on the
|
|
71
|
+
same machine. To use it elsewhere, expose the port with a tunnel:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ngrok http 7000
|
|
75
|
+
# or
|
|
76
|
+
cloudflared tunnel --url http://127.0.0.1:7000
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Either one prints a public `https://…` address. Add `/manifest.json` to that
|
|
80
|
+
address and install it in Stremio → Addons → **Install from URL**.
|
|
81
|
+
|
|
82
|
+
Note that CircleFTP's mirrors are ISP-locked, so its streams only play while
|
|
83
|
+
the device is on your ISP's network — a tunnel can't change that. Dflix works
|
|
84
|
+
from anywhere.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# bdix-stremio-addon
|
|
2
|
+
|
|
3
|
+
A Stremio addon for BDIX stream sites, with a web UI to choose which sources
|
|
4
|
+
are included. Python 3.8+, standard library only.
|
|
5
|
+
|
|
6
|
+
## Supported sites
|
|
7
|
+
|
|
8
|
+
1. **Dflix**
|
|
9
|
+
2. **CircleFTP**
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Run it without installing anything:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uvx bdix-stremio-addon # or: pipx run bdix-stremio-addon
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pipx install bdix-stremio-addon # then run: bdix
|
|
23
|
+
pip install bdix-stremio-addon # then run: python -m bdix
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then open <http://127.0.0.1:7000/>, pick your sources, and install the addon
|
|
27
|
+
into Stremio from that page.
|
|
28
|
+
|
|
29
|
+
## Use it from a TV or phone
|
|
30
|
+
|
|
31
|
+
The addon listens on `127.0.0.1`, which only reaches Stremio running on the
|
|
32
|
+
same machine. To use it elsewhere, expose the port with a tunnel:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
ngrok http 7000
|
|
36
|
+
# or
|
|
37
|
+
cloudflared tunnel --url http://127.0.0.1:7000
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Either one prints a public `https://…` address. Add `/manifest.json` to that
|
|
41
|
+
address and install it in Stremio → Addons → **Install from URL**.
|
|
42
|
+
|
|
43
|
+
Note that CircleFTP's mirrors are ISP-locked, so its streams only play while
|
|
44
|
+
the device is on your ISP's network — a tunnel can't change that. Dflix works
|
|
45
|
+
from anywhere.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""bdix - one combined Stremio stream addon for BDIX/FTP sources.
|
|
2
|
+
|
|
3
|
+
A single manifest and a single /stream endpoint that fans out to every enabled
|
|
4
|
+
source (see bdix/sources/) and merges the results, plus a small web UI for
|
|
5
|
+
choosing which sources are enabled.
|
|
6
|
+
|
|
7
|
+
Standard library only - no dependencies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "2.0.1"
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
python -m bdix serve the addon + web UI
|
|
4
|
+
python -m bdix --smoke headless self-check, exits 0/1
|
|
5
|
+
python -m bdix --port 8000 bind somewhere else
|
|
6
|
+
python -m bdix --idle-exit 60 quit after an hour with no requests
|
|
7
|
+
|
|
8
|
+
Serving is a single process and spawns nothing, so however this is stopped
|
|
9
|
+
(Ctrl+C, closing the terminal, killing the pid) nothing is left behind.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
import webbrowser
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .common import meta_by_id
|
|
25
|
+
from .config import config_path, load as load_config
|
|
26
|
+
from .server import BDIXServer, Handler, State, collect_streams, manifest
|
|
27
|
+
from .sources import discover
|
|
28
|
+
|
|
29
|
+
# What --smoke probes. A well-known film, so any working source should match it.
|
|
30
|
+
SMOKE_MOVIE = "tt0111161"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ======================================================================
|
|
34
|
+
# serving
|
|
35
|
+
# ======================================================================
|
|
36
|
+
|
|
37
|
+
def _sig_sources():
|
|
38
|
+
for name in ("SIGINT", "SIGTERM", "SIGBREAK"):
|
|
39
|
+
sig = getattr(signal, name, None)
|
|
40
|
+
if sig is not None:
|
|
41
|
+
yield sig
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _banner(state, selected):
|
|
45
|
+
on = [k for k in state.sources if k in selected]
|
|
46
|
+
off = [k for k in state.sources if k not in selected]
|
|
47
|
+
base = f"http://{state.host}:{state.port}"
|
|
48
|
+
lines = [
|
|
49
|
+
f"BDIX streams v{__version__}",
|
|
50
|
+
f" web UI {base}/",
|
|
51
|
+
f" manifest {base}/manifest.json",
|
|
52
|
+
" sources " + ", ".join(
|
|
53
|
+
[f"{k} (on)" for k in on] + [f"{k} (off)" for k in off]),
|
|
54
|
+
" Press Ctrl+C once to stop. One process, nothing left behind.",
|
|
55
|
+
]
|
|
56
|
+
print("\n".join(lines), flush=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def serve(args):
|
|
60
|
+
registry = discover()
|
|
61
|
+
if not registry:
|
|
62
|
+
print("[bdix] no sources found in bdix/sources/", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
selected, cfg_port = load_config(list(registry))
|
|
66
|
+
port = args.port if args.port is not None else cfg_port
|
|
67
|
+
state = State(registry, selected, port)
|
|
68
|
+
state.host = args.host
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
server = BDIXServer((args.host, port), Handler, state, log=args.log)
|
|
72
|
+
except OSError as e:
|
|
73
|
+
print(f"[bdix] cannot listen on {args.host}:{port} - {e}\n"
|
|
74
|
+
f" another copy may already be running; "
|
|
75
|
+
f"try --port {port + 1}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
state.port = server.server_address[1]
|
|
79
|
+
_banner(state, selected)
|
|
80
|
+
print(f" config {config_path()}", flush=True)
|
|
81
|
+
|
|
82
|
+
if not args.no_open:
|
|
83
|
+
threading.Thread(
|
|
84
|
+
target=lambda: webbrowser.open(f"http://{args.host}:{state.port}/"),
|
|
85
|
+
daemon=True).start()
|
|
86
|
+
|
|
87
|
+
stop = threading.Event()
|
|
88
|
+
hits = {"n": 0}
|
|
89
|
+
|
|
90
|
+
def on_signal(signum, frame):
|
|
91
|
+
hits["n"] += 1
|
|
92
|
+
if hits["n"] > 1: # impatient second Ctrl+C
|
|
93
|
+
print("\n[bdix] forced exit", flush=True)
|
|
94
|
+
os._exit(130)
|
|
95
|
+
print("\n[bdix] stopping (Ctrl+C again to force)", flush=True)
|
|
96
|
+
stop.set()
|
|
97
|
+
|
|
98
|
+
for sig in _sig_sources():
|
|
99
|
+
try:
|
|
100
|
+
signal.signal(sig, on_signal)
|
|
101
|
+
except (ValueError, OSError, RuntimeError):
|
|
102
|
+
pass # not in the main thread / unsupported
|
|
103
|
+
|
|
104
|
+
if args.idle_exit and args.idle_exit > 0:
|
|
105
|
+
threading.Thread(target=_idle_watch,
|
|
106
|
+
args=(state, stop, args.idle_exit), daemon=True).start()
|
|
107
|
+
|
|
108
|
+
threading.Thread(target=server.serve_forever, name="bdix-http",
|
|
109
|
+
daemon=True).start()
|
|
110
|
+
try:
|
|
111
|
+
while not stop.wait(0.5):
|
|
112
|
+
pass
|
|
113
|
+
except KeyboardInterrupt: # signal handler not installed
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
server.shutdown()
|
|
117
|
+
server.server_close()
|
|
118
|
+
print("[bdix] stopped", flush=True)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _idle_watch(state, stop, minutes):
|
|
123
|
+
limit = minutes * 60.0
|
|
124
|
+
while not stop.wait(5.0):
|
|
125
|
+
idle = time.time() - state.last_hit
|
|
126
|
+
if idle > limit:
|
|
127
|
+
print(f"[bdix] no requests for {minutes:g} min - exiting", flush=True)
|
|
128
|
+
stop.set()
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ======================================================================
|
|
133
|
+
# smoke test
|
|
134
|
+
# ======================================================================
|
|
135
|
+
|
|
136
|
+
def _http(url, payload=None, timeout=90):
|
|
137
|
+
"""Return (status, parsed-json-or-text)."""
|
|
138
|
+
data = None
|
|
139
|
+
headers = {}
|
|
140
|
+
if payload is not None:
|
|
141
|
+
data = json.dumps(payload).encode("utf-8")
|
|
142
|
+
headers["Content-Type"] = "application/json"
|
|
143
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
144
|
+
try:
|
|
145
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
146
|
+
raw = r.read().decode("utf-8", "replace")
|
|
147
|
+
status = r.status
|
|
148
|
+
except urllib.error.HTTPError as e:
|
|
149
|
+
raw, status = e.read().decode("utf-8", "replace"), e.code
|
|
150
|
+
try:
|
|
151
|
+
return status, json.loads(raw)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return status, raw
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def smoke():
|
|
157
|
+
"""Run the real server on an ephemeral port and exercise the real routes."""
|
|
158
|
+
# Isolate config writes from the user's ~/.bdix/config.json
|
|
159
|
+
os.environ["BDIX_HOME"] = tempfile.mkdtemp(prefix="bdix-smoke-")
|
|
160
|
+
|
|
161
|
+
registry = discover()
|
|
162
|
+
print(f"[1] sources discovered: "
|
|
163
|
+
f"{', '.join(f'{k} ({m.NAME})' for k, m in registry.items()) or 'NONE'}")
|
|
164
|
+
if not registry:
|
|
165
|
+
print("FAIL: no sources")
|
|
166
|
+
return 1
|
|
167
|
+
fails = []
|
|
168
|
+
|
|
169
|
+
state = State(registry, list(registry), 0)
|
|
170
|
+
try:
|
|
171
|
+
server = BDIXServer(("127.0.0.1", 0), Handler, state)
|
|
172
|
+
except OSError as e:
|
|
173
|
+
print(f"FAIL: cannot bind an ephemeral port: {e}")
|
|
174
|
+
return 1
|
|
175
|
+
state.port = server.server_address[1]
|
|
176
|
+
base = f"http://127.0.0.1:{state.port}"
|
|
177
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
# -- 2. web UI -----------------------------------------------------
|
|
181
|
+
status, body = _http(base + "/")
|
|
182
|
+
ok = status == 200 and "BDIX" in body
|
|
183
|
+
print(f"[2] GET / -> {status} "
|
|
184
|
+
f"{'ok' if ok else 'FAIL: expected the web UI'}")
|
|
185
|
+
if not ok:
|
|
186
|
+
fails.append("web UI")
|
|
187
|
+
|
|
188
|
+
# -- 3. manifest ---------------------------------------------------
|
|
189
|
+
status, m = _http(base + "/manifest.json")
|
|
190
|
+
ok = (status == 200 and m.get("id") == "org.bdix"
|
|
191
|
+
and m.get("resources") == ["stream"] and m.get("catalogs") == [])
|
|
192
|
+
print(f"[3] GET /manifest.json -> {status} "
|
|
193
|
+
f"{'ok' if ok else 'FAIL: ' + str(m)[:120]}")
|
|
194
|
+
if not ok:
|
|
195
|
+
fails.append("manifest")
|
|
196
|
+
|
|
197
|
+
# -- 4. reachability (decides whether to assert on streams) --------
|
|
198
|
+
status, chk = _http(base + "/api/check", payload={}, timeout=60)
|
|
199
|
+
results = chk.get("results", []) if isinstance(chk, dict) else []
|
|
200
|
+
live = [r for r in results if r.get("ok")]
|
|
201
|
+
for r in results:
|
|
202
|
+
print(f" {r['key']:<10} {'reachable' if r['ok'] else 'unreachable'}"
|
|
203
|
+
f" {r['ms']} ms {'' if r['ok'] else r['detail'][:70]}")
|
|
204
|
+
|
|
205
|
+
# -- 5. merged streams over a real HTTP request --------------------
|
|
206
|
+
status, got = _http(base + f"/stream/movie/{SMOKE_MOVIE}.json")
|
|
207
|
+
streams = got.get("streams", []) if isinstance(got, dict) else []
|
|
208
|
+
print(f"[4] GET /stream/movie/{SMOKE_MOVIE}.json -> {status}, "
|
|
209
|
+
f"{len(streams)} stream(s)")
|
|
210
|
+
for s in streams[:6]:
|
|
211
|
+
print(f" - {s.get('title', '?')}")
|
|
212
|
+
if status != 200:
|
|
213
|
+
fails.append("stream route status")
|
|
214
|
+
elif live and not streams:
|
|
215
|
+
fails.append("no streams despite reachable sources")
|
|
216
|
+
elif not live:
|
|
217
|
+
print(" (every source unreachable - skipping the stream assertion)")
|
|
218
|
+
|
|
219
|
+
# -- 6. per-source contribution, in process for timing -------------
|
|
220
|
+
meta = meta_by_id("movie", SMOKE_MOVIE)
|
|
221
|
+
print(f"[5] Cinemeta name: {meta.get('name')!r} "
|
|
222
|
+
f"year={meta.get('year')!r}")
|
|
223
|
+
for key, mod in registry.items():
|
|
224
|
+
t0 = time.time()
|
|
225
|
+
try:
|
|
226
|
+
n = len(mod.get_streams("movie", SMOKE_MOVIE, meta) or [])
|
|
227
|
+
note = f"{n} stream(s)"
|
|
228
|
+
except Exception as e:
|
|
229
|
+
note = f"error: {e}"
|
|
230
|
+
print(f" {key:<10} {note} ({time.time() - t0:.1f}s)")
|
|
231
|
+
|
|
232
|
+
# -- 7. selection actually gates the stream route ------------------
|
|
233
|
+
status, saved = _http(base + "/api/config",
|
|
234
|
+
payload={"sources": []})
|
|
235
|
+
status2, got2 = _http(base + f"/stream/movie/{SMOKE_MOVIE}.json")
|
|
236
|
+
empty = isinstance(got2, dict) and got2.get("streams") == []
|
|
237
|
+
print(f"[6] sources=[] saved -> {status} {saved}; "
|
|
238
|
+
f"stream route now returns {len(got2.get('streams', []))} "
|
|
239
|
+
f"{'ok' if empty else 'FAIL: still serving with nothing enabled'}")
|
|
240
|
+
if not empty or status != 200:
|
|
241
|
+
fails.append("config gating")
|
|
242
|
+
|
|
243
|
+
status, cfg = _http(base + "/api/config")
|
|
244
|
+
print(f"[7] GET /api/config -> {status}, selected={cfg.get('selected')}")
|
|
245
|
+
|
|
246
|
+
# -- 8. unknown route is a clean 404, not a crash ------------------
|
|
247
|
+
status, _ = _http(base + "/nope")
|
|
248
|
+
print(f"[8] GET /nope -> {status} "
|
|
249
|
+
f"{'ok' if status == 404 else 'FAIL'}")
|
|
250
|
+
if status != 404:
|
|
251
|
+
fails.append("404 handling")
|
|
252
|
+
finally:
|
|
253
|
+
server.shutdown()
|
|
254
|
+
server.server_close()
|
|
255
|
+
|
|
256
|
+
print()
|
|
257
|
+
if fails:
|
|
258
|
+
print("FAIL: " + ", ".join(fails))
|
|
259
|
+
return 1
|
|
260
|
+
print("PASS")
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ======================================================================
|
|
265
|
+
|
|
266
|
+
def main(argv=None):
|
|
267
|
+
parser = argparse.ArgumentParser(
|
|
268
|
+
prog="bdix",
|
|
269
|
+
description="One combined Stremio stream addon for BDIX/FTP sources, "
|
|
270
|
+
"with a web UI to pick which sources are included.")
|
|
271
|
+
parser.add_argument("--host", default="127.0.0.1",
|
|
272
|
+
help="interface to bind (default 127.0.0.1)")
|
|
273
|
+
parser.add_argument("--port", type=int, default=None,
|
|
274
|
+
help="port to bind (default: the saved config, else 7000)")
|
|
275
|
+
parser.add_argument("--log", action="store_true",
|
|
276
|
+
help="log every request")
|
|
277
|
+
parser.add_argument("--no-open", action="store_true",
|
|
278
|
+
help="don't open the web UI in a browser")
|
|
279
|
+
parser.add_argument("--idle-exit", type=float, default=0.0, metavar="MIN",
|
|
280
|
+
help="exit after MIN minutes with no requests (0 = never)")
|
|
281
|
+
parser.add_argument("--smoke", action="store_true",
|
|
282
|
+
help="run a headless self-check and exit")
|
|
283
|
+
parser.add_argument("--version", action="version",
|
|
284
|
+
version=f"bdix {__version__}")
|
|
285
|
+
args = parser.parse_args(argv)
|
|
286
|
+
|
|
287
|
+
if args.smoke:
|
|
288
|
+
return smoke()
|
|
289
|
+
return serve(args)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
sys.exit(main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Shared framework helpers - stdlib only, no site logic in here.
|
|
2
|
+
|
|
3
|
+
Sources import from this module; the server imports from this module. This is
|
|
4
|
+
where the code that used to be duplicated verbatim in both standalone addons
|
|
5
|
+
lives: the Cinemeta meta lookup (+ its cache), the title matcher, the Stremio id
|
|
6
|
+
parser and the quality ranking.
|
|
7
|
+
"""
|
|
8
|
+
import difflib
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
CINEMETA = "https://v3-cinemeta.strem.io/meta"
|
|
15
|
+
|
|
16
|
+
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) bdix/2.0"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ======================================================================
|
|
20
|
+
# HTTP
|
|
21
|
+
# ======================================================================
|
|
22
|
+
|
|
23
|
+
def fetch_text(url, timeout=60, headers=None):
|
|
24
|
+
req = urllib.request.Request(url, headers=dict(headers or UA))
|
|
25
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
26
|
+
return r.read().decode("utf-8", "replace")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_json(url, timeout=30, headers=None):
|
|
30
|
+
req = urllib.request.Request(url, headers=dict(headers or UA))
|
|
31
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
32
|
+
return json.load(r)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ======================================================================
|
|
36
|
+
# Cinemeta: turn a Stremio id back into name/year/kind
|
|
37
|
+
# ======================================================================
|
|
38
|
+
|
|
39
|
+
_META_CACHE = {} # (vtype, base_id) -> Cinemeta meta dict (or {})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def meta_by_id(vtype, base):
|
|
43
|
+
"""Cinemeta meta for a Stremio id, cached (misses cached as {} too)."""
|
|
44
|
+
key = (vtype, base)
|
|
45
|
+
if key not in _META_CACHE:
|
|
46
|
+
try:
|
|
47
|
+
url = (f"{CINEMETA}/{vtype}/"
|
|
48
|
+
f"{urllib.parse.quote(base, safe='')}.json")
|
|
49
|
+
meta = (fetch_json(url).get("meta") or {})
|
|
50
|
+
except Exception:
|
|
51
|
+
meta = {}
|
|
52
|
+
_META_CACHE[key] = meta
|
|
53
|
+
if len(_META_CACHE) > 2000:
|
|
54
|
+
_META_CACHE.pop(next(iter(_META_CACHE)))
|
|
55
|
+
return _META_CACHE[key]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def int_year(v):
|
|
59
|
+
if isinstance(v, int) and v > 1900:
|
|
60
|
+
return v
|
|
61
|
+
m = re.match(r"(\d{4})", str(v or ""))
|
|
62
|
+
return int(m.group(1)) if m else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def meta_year(meta):
|
|
66
|
+
return (int_year(meta.get("year"))
|
|
67
|
+
or int_year(meta.get("released"))
|
|
68
|
+
or int_year(meta.get("firstAired")))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ======================================================================
|
|
72
|
+
# Title matching
|
|
73
|
+
# ======================================================================
|
|
74
|
+
|
|
75
|
+
def norm(s):
|
|
76
|
+
return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def similar(a, b):
|
|
80
|
+
a, b = norm(a), norm(b)
|
|
81
|
+
if not a or not b:
|
|
82
|
+
return 0.0
|
|
83
|
+
if a == b:
|
|
84
|
+
return 1.0
|
|
85
|
+
if a in b or b in a:
|
|
86
|
+
return 0.9
|
|
87
|
+
return difflib.SequenceMatcher(None, a, b).ratio()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def year_bonus(gap):
|
|
91
|
+
return {0: 0.30, 1: 0.25, 2: 0.18, 3: 0.10,
|
|
92
|
+
4: 0.04}.get(gap, 0.0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ======================================================================
|
|
96
|
+
# Stremio ids + stream ranking
|
|
97
|
+
# ======================================================================
|
|
98
|
+
|
|
99
|
+
def split_vid(vid):
|
|
100
|
+
"""'tt0111161' -> (base, None, None); 'tt0903747:1:5' -> (base, 1, 5)."""
|
|
101
|
+
parts = (vid or "").split(":")
|
|
102
|
+
base = parts[0]
|
|
103
|
+
season = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
|
104
|
+
episode = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else None
|
|
105
|
+
return base, season, episode
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_QUALITY_RANK = {"2160p": 6, "4k": 6, "1080p": 5, "720p": 4,
|
|
109
|
+
"480p": 3, "360p": 2, "240p": 1}
|
|
110
|
+
|
|
111
|
+
_QUALITY_RE = re.compile(r"(2160p|4k|1080p|720p|480p|360p|240p)", re.IGNORECASE)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def quality_score(text):
|
|
115
|
+
"""Rank a stream by the quality tag in its title (0 when unknown)."""
|
|
116
|
+
m = _QUALITY_RE.search(text or "")
|
|
117
|
+
return _QUALITY_RANK.get(m.group(1).lower(), 0) if m else 0
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Config storage - ~/.bdix/config.json.
|
|
2
|
+
|
|
3
|
+
Holds the source selection (and optionally the port). `BDIX_HOME` overrides the
|
|
4
|
+
directory, which is what the smoke test uses to stay out of the real config.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_PORT = 7000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_dir():
|
|
14
|
+
override = os.environ.get("BDIX_HOME")
|
|
15
|
+
return Path(override) if override else (Path.home() / ".bdix")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def config_path():
|
|
19
|
+
return config_dir() / "config.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load(all_keys):
|
|
23
|
+
"""Return (selected_keys, port).
|
|
24
|
+
|
|
25
|
+
Missing or corrupt config means "everything enabled" - the addon is useful
|
|
26
|
+
out of the box. An explicitly saved empty list stays empty (the user turned
|
|
27
|
+
every source off on purpose). Unknown keys are dropped, so a config written
|
|
28
|
+
before a source was removed still loads.
|
|
29
|
+
"""
|
|
30
|
+
data = {}
|
|
31
|
+
path = config_path()
|
|
32
|
+
if path.exists():
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(path.read_text("utf-8"))
|
|
35
|
+
except Exception:
|
|
36
|
+
data = {}
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
data = {}
|
|
39
|
+
|
|
40
|
+
selected = data.get("sources")
|
|
41
|
+
if not isinstance(selected, list):
|
|
42
|
+
selected = list(all_keys)
|
|
43
|
+
selected = [k for k in selected if k in all_keys]
|
|
44
|
+
|
|
45
|
+
port = data.get("port")
|
|
46
|
+
if not isinstance(port, int) or isinstance(port, bool) \
|
|
47
|
+
or not (1 <= port <= 65535):
|
|
48
|
+
port = DEFAULT_PORT
|
|
49
|
+
return selected, port
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save(selected):
|
|
53
|
+
"""Persist the source selection, preserving any other stored keys."""
|
|
54
|
+
data = {}
|
|
55
|
+
path = config_path()
|
|
56
|
+
if path.exists():
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(path.read_text("utf-8"))
|
|
59
|
+
except Exception:
|
|
60
|
+
data = {}
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
data = {}
|
|
63
|
+
|
|
64
|
+
data["sources"] = list(selected)
|
|
65
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path.write_text(json.dumps(data, indent=2) + "\n", "utf-8")
|
|
67
|
+
try: # POSIX only; Windows ignores it
|
|
68
|
+
os.chmod(path, 0o600)
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
return path
|