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.
@@ -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