| … | … | @@ -258,6 +258,68 @@ def _try_source(name: str, fn, *args, **kwargs) -> list[Stream]: |
| 258 | 258 | return out |
| 259 | 259 | |
| 260 | 260 | |
| 261 | # ------------------------------- source registry ---------------------------- | |
| 262 | # Sources used to be hardcoded: extract_streams named fmovies and vsembed | |
| 263 | # inline, and vidcore was bolted on beside them as a special case. There was no | |
| 264 | # shape into which a fourth could go, and nowhere to record what a source can | |
| 265 | # actually do. | |
| 266 | # | |
| 267 | # Three properties matter. The third is the one that cost a day to learn. | |
| 268 | @dataclass(frozen=True) | |
| 269 | class Source: | |
| 270 | id: str | |
| 271 | name: str # display label on the row | |
| 272 | container: str # "hls-fmp4" | "progressive-mp4" | |
| 273 | ingestable: bool # can a PLAIN HTTP CLIENT fetch the bytes? | |
| 274 | browser: bool # resolving needs xvfb+patchright (~11s) | |
| 275 | heights: tuple = () # qualities offered as rows | |
| 276 | resolve: Optional[object] = None | |
| 277 | enabled: bool = True | |
| 278 | note: str = "" | |
| 279 | ||
| 280 | ||
| 281 | SOURCES: dict[str, Source] = {} | |
| 282 | ||
| 283 | # A progressive MP4 seeks natively; HLS on this stack does not (that is the | |
| 284 | # whole subject of #4), so progressive sorts first. | |
| 285 | _CONTAINER_RANK = {"progressive-mp4": 0, "hls-fmp4": 1} | |
| 286 | ||
| 287 | ||
| 288 | def register_source(src: Source) -> None: | |
| 289 | if src.id in SOURCES: | |
| 290 | # Two sources sharing an id would make row order and breaker state | |
| 291 | # silently ambiguous — exactly the kind of thing that is discovered | |
| 292 | # months later as "sometimes the rows come out in a different order". | |
| 293 | raise ValueError(f"duplicate source id: {src.id}") | |
| 294 | SOURCES[src.id] = src | |
| 295 | ||
| 296 | ||
| 297 | def get_source(sid: str) -> Optional[Source]: | |
| 298 | return SOURCES.get(sid) | |
| 299 | ||
| 300 | ||
| 301 | def ordered_sources() -> list[Source]: | |
| 302 | """Enabled sources, best container first. `sorted` is stable, so sources | |
| 303 | within a container class keep registration order.""" | |
| 304 | return sorted((s for s in SOURCES.values() if s.enabled), | |
| 305 | key=lambda s: _CONTAINER_RANK.get(s.container, 9)) | |
| 306 | ||
| 307 | ||
| 308 | def ingest_source() -> Optional[Source]: | |
| 309 | """The source the library should be fed from, or None. | |
| 310 | ||
| 311 | `ingestable` is a veto that ranking cannot override, and that is the entire | |
| 312 | point of this function. vidlink sorts first on every visible merit — a | |
| 313 | progressive MP4, resolved in 3s, with exact byte sizes — and ffmpeg cannot | |
| 314 | fetch a single byte of it (measured 2026-08-11: 428/403/429 across six | |
| 315 | client/URL combinations, including byte-exact browser headers). The | |
| 316 | local-first design rests on ffmpeg pulling bytes independently of any | |
| 317 | player, so a browser-only source can never feed the library however good its | |
| 318 | API looks. | |
| 319 | """ | |
| 320 | return next((s for s in ordered_sources() if s.ingestable), None) | |
| 321 | ||
| 322 | ||
| 261 | 323 | def extract_streams(imdb_id: str, tv: bool = False, season: int = 0, episode: int = 0) -> list[Stream]: |
| 262 | 324 | """Gather streams from both fmovies (srv-1, netoda/voxzer direct) and vsembed |
| 263 | 325 | (srv-2, cloudnestra embed). Return both so Jacktook shows two source rows — |
| … | … | @@ -533,7 +595,7 @@ TV_GENRES = [ |
| 533 | 595 | |
| 534 | 596 | MANIFEST = { |
| 535 | 597 | "id": "org.tabertron.vsembed", |
| 536 | "version": "0.12.2", | |
| 598 | "version": "0.13.0", | |
| 537 | 599 | "name": "Tabertron VsEmbed", |
| 538 | 600 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 539 | 601 | "types": ["movie", "series"], |
| … | … | @@ -1380,6 +1442,12 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int, |
| 1380 | 1442 | otherwise start against a dead link. |
| 1381 | 1443 | """ |
| 1382 | 1444 | key = _mp4_key(kind, ident, season, episode, height) |
| 1445 | # The registry decides whether ANYTHING may feed the library. Resolution is | |
| 1446 | # still vidcore's directly, because vidcore is the only ingestable source — | |
| 1447 | # dispatching through src.resolve buys nothing until there are two, and | |
| 1448 | # speculative indirection is how the special-casing this replaces began. | |
| 1449 | if not ingest_source(): | |
| 1450 | return "failed: no ingestable source" | |
| 1383 | 1451 | meta = _library_meta(kind, ident, season, episode) |
| 1384 | 1452 | if not meta: |
| 1385 | 1453 | return f"failed: cannot identify {ident}" |
| … | … | @@ -1695,7 +1763,8 @@ def _append_vidcore_row(out: list, kind: str, ident: Optional[str], |
| 1695 | 1763 | # (request 50%, stay at 1.2%), while ISA seeks correctly. On this source the |
| 1696 | 1764 | # two are mutually exclusive, and audio wins. Seek needs a source that is not |
| 1697 | 1765 | # muxed fMP4 HLS -- a progressive MP4 (vidlink) seeks natively under ffmpeg. |
| 1698 | for height in VIDCORE_HEIGHTS: | |
| 1766 | src = get_source("vidcore") | |
| 1767 | for height in (src.heights if src else VIDCORE_HEIGHTS): | |
| 1699 | 1768 | out.append(_stream_dict( |
| 1700 | 1769 | _vidcore_row(request.host, kind, ident, season, episode, height=height))) |
| 1701 | 1770 | |
| … | … | @@ -1968,6 +2037,45 @@ def index(): |
| 1968 | 2037 | ) |
| 1969 | 2038 | |
| 1970 | 2039 | |
| 2040 | # The sources this addon knows about. Declared here, at the end, so each entry | |
| 2041 | # can point at the resolver it uses. Dead and unusable sources are RECORDED | |
| 2042 | # rather than deleted: the next person to notice that vidlink serves progressive | |
| 2043 | # MP4 should find the measurement, not repeat the day it took to make it. | |
| 2044 | SOURCES_DEFAULT: dict[str, Source] = {s.id: s for s in ( | |
| 2045 | Source( | |
| 2046 | id="vidcore", name="vidcore (flixlab)", | |
| 2047 | container="hls-fmp4", # muxed fMP4 — why every copy needs a remux | |
| 2048 | ingestable=True, browser=True, heights=VIDCORE_HEIGHTS, | |
| 2049 | resolve=_resolve_vidcore, | |
| 2050 | note="The only live source. Resolve ~11s via xvfb+patchright. Muxed " | |
| 2051 | "fMP4: ffmpeg plays it with audio but ignores seeks, ISA seeks but " | |
| 2052 | "starves the audio track — see labs#4.", | |
| 2053 | ), | |
| 2054 | Source( | |
| 2055 | id="vidlink", name="vidlink", container="progressive-mp4", | |
| 2056 | ingestable=False, browser=True, heights=(1080, 480, 360), enabled=False, | |
| 2057 | note="Measured 2026-08-11. The API is excellent: " | |
| 2058 | "vidlink.pro/api/b/tv/<id>/<s>/<e> returns every quality with " | |
| 2059 | "signed URLs, exact byte sizes and codec in ~3s. But the bytes sit " | |
| 2060 | "behind a proxy (requiresProxy:true) that rejects curl AND ffmpeg " | |
| 2061 | "with 428 even given byte-exact browser headers — TLS fingerprint " | |
| 2062 | "or session binding, not headers. Unusable for ingest without " | |
| 2063 | "curl-impersonate. Media is HEVC; Pi decode unverified.", | |
| 2064 | ), | |
| 2065 | Source( | |
| 2066 | id="fmovies", name="fmovies", container="hls-fmp4", | |
| 2067 | ingestable=True, browser=True, enabled=False, | |
| 2068 | note="Breaker-open since 2026-07-28 (Cloudflare Turnstile).", | |
| 2069 | ), | |
| 2070 | Source( | |
| 2071 | id="vsembed", name="CloudStream Pro", container="hls-fmp4", | |
| 2072 | ingestable=True, browser=False, enabled=False, | |
| 2073 | note="Breaker-open since 2026-07-28 (Cloudflare Turnstile).", | |
| 2074 | ), | |
| 2075 | )} | |
| 2076 | SOURCES.update(SOURCES_DEFAULT) | |
| 2077 | ||
| 2078 | ||
| 1971 | 2079 | if __name__ == "__main__": |
| 1972 | 2080 | import argparse |
| 1973 | 2081 |
| … | … | @@ -0,0 +1,159 @@ |
| 1 | """The source registry: what a source is, and which ones may feed the library. | |
| 2 | ||
| 3 | Sources were hardcoded -- extract_streams named fmovies and vsembed inline, and | |
| 4 | vidcore was bolted on beside them as a special case. There was no shape into | |
| 5 | which a fourth could be added, and no place to record what a source can and | |
| 6 | cannot do. | |
| 7 | ||
| 8 | Three properties matter, and the third is the one that cost a day to learn: | |
| 9 | ||
| 10 | container hls-fmp4 needs a remux to become seekable; progressive-mp4 is | |
| 11 | seekable as-is, so ingesting it is a plain fetch with no ffmpeg. | |
| 12 | browser resolving needs a real browser (~11s, xvfb+patchright) rather than | |
| 13 | plain HTTP, which decides whether it can sit on a hot path. | |
| 14 | ingestable whether a plain HTTP client can fetch the BYTES. | |
| 15 | ||
| 16 | `ingestable` is not a detail. vidlink hands over a signed progressive-MP4 URL in | |
| 17 | 3s with exact sizes -- strictly better than vidcore on every axis that shows up | |
| 18 | in an API response -- and is still useless to us, because its proxy rejects curl | |
| 19 | AND ffmpeg even with byte-exact browser headers (measured 2026-08-11: 428/403/429 | |
| 20 | across six combinations). The local-first design rests on ffmpeg pulling bytes | |
| 21 | independently of any player, so a browser-only source cannot feed the library at | |
| 22 | any quality. | |
| 23 | ||
| 24 | Encoding that here means the next candidate cannot be silently wired into ingest | |
| 25 | on the strength of a good-looking API. | |
| 26 | ||
| 27 | Run: .venv/bin/python3 -m pytest test_source_registry.py -q | |
| 28 | """ | |
| 29 | ||
| 30 | import pytest | |
| 31 | ||
| 32 | import addon | |
| 33 | ||
| 34 | ||
| 35 | @pytest.fixture(autouse=True) | |
| 36 | def clean_registry(monkeypatch): | |
| 37 | monkeypatch.setattr(addon, "SOURCES", {}) | |
| 38 | yield | |
| 39 | ||
| 40 | ||
| 41 | def _src(sid, **kw): | |
| 42 | defaults = dict(name=sid, container="hls-fmp4", ingestable=True, | |
| 43 | browser=False, heights=(1080,), resolve=None, enabled=True) | |
| 44 | defaults.update(kw) | |
| 45 | return addon.Source(id=sid, **defaults) | |
| 46 | ||
| 47 | ||
| 48 | # --- registration ----------------------------------------------------------- | |
| 49 | ||
| 50 | def test_a_source_can_be_registered_and_looked_up(): | |
| 51 | addon.register_source(_src("alpha")) | |
| 52 | assert addon.get_source("alpha").name == "alpha" | |
| 53 | ||
| 54 | ||
| 55 | def test_an_unknown_source_is_none_rather_than_a_crash(): | |
| 56 | assert addon.get_source("nope") is None | |
| 57 | ||
| 58 | ||
| 59 | def test_a_duplicate_id_is_refused(): | |
| 60 | """Two sources sharing an id would make row ordering and breaker state | |
| 61 | silently ambiguous.""" | |
| 62 | addon.register_source(_src("alpha")) | |
| 63 | with pytest.raises(ValueError): | |
| 64 | addon.register_source(_src("alpha")) | |
| 65 | ||
| 66 | ||
| 67 | # --- ordering --------------------------------------------------------------- | |
| 68 | ||
| 69 | def test_progressive_sources_are_offered_before_hls(): | |
| 70 | """A progressive MP4 seeks natively. HLS on this stack does not -- that is | |
| 71 | the whole subject of #4 -- so it goes second.""" | |
| 72 | addon.register_source(_src("hls-one", container="hls-fmp4")) | |
| 73 | addon.register_source(_src("prog-one", container="progressive-mp4")) | |
| 74 | assert [s.id for s in addon.ordered_sources()] == ["prog-one", "hls-one"] | |
| 75 | ||
| 76 | ||
| 77 | def test_a_disabled_source_is_not_offered(): | |
| 78 | addon.register_source(_src("off", enabled=False)) | |
| 79 | assert addon.ordered_sources() == [] | |
| 80 | ||
| 81 | ||
| 82 | def test_ordering_is_stable_within_a_container_class(): | |
| 83 | for sid in ("a", "b", "c"): | |
| 84 | addon.register_source(_src(sid)) | |
| 85 | assert [s.id for s in addon.ordered_sources()] == ["a", "b", "c"] | |
| 86 | ||
| 87 | ||
| 88 | # --- the ingest guarantee --------------------------------------------------- | |
| 89 | ||
| 90 | def test_a_browser_only_source_is_never_chosen_for_ingest(): | |
| 91 | """THE point of this registry. vidlink sorts first on every visible merit -- | |
| 92 | progressive MP4, 3s resolve, exact sizes -- and ffmpeg cannot fetch a byte | |
| 93 | of it. Ranking must not be able to override that.""" | |
| 94 | addon.register_source(_src("vidlink-like", container="progressive-mp4", | |
| 95 | ingestable=False)) | |
| 96 | addon.register_source(_src("vidcore-like", container="hls-fmp4", | |
| 97 | ingestable=True)) | |
| 98 | assert addon.ingest_source().id == "vidcore-like" | |
| 99 | ||
| 100 | ||
| 101 | def test_no_ingestable_source_yields_none_rather_than_a_bad_choice(): | |
| 102 | addon.register_source(_src("browser-only", ingestable=False)) | |
| 103 | assert addon.ingest_source() is None | |
| 104 | ||
| 105 | ||
| 106 | def test_a_non_ingestable_source_is_still_offered_for_playback(): | |
| 107 | """It cannot feed the library, but it can still be watched -- withholding it | |
| 108 | would remove a working way to play something.""" | |
| 109 | addon.register_source(_src("browser-only", ingestable=False)) | |
| 110 | assert [s.id for s in addon.ordered_sources()] == ["browser-only"] | |
| 111 | ||
| 112 | ||
| 113 | def test_a_disabled_source_is_never_chosen_for_ingest(): | |
| 114 | addon.register_source(_src("off", ingestable=True, enabled=False)) | |
| 115 | assert addon.ingest_source() is None | |
| 116 | ||
| 117 | ||
| 118 | # --- the real registry ------------------------------------------------------ | |
| 119 | # Not fixture-isolated: these assert what the addon actually ships with. | |
| 120 | ||
| 121 | def test_vidcore_is_registered_as_the_live_source(): | |
| 122 | src = addon.SOURCES_DEFAULT["vidcore"] | |
| 123 | assert src.enabled and src.ingestable | |
| 124 | assert src.container == "hls-fmp4" # why every copy needs a remux | |
| 125 | assert src.browser # ~11s, xvfb+patchright | |
| 126 | ||
| 127 | ||
| 128 | def test_vidlink_is_recorded_as_measured_and_disabled(): | |
| 129 | """Recorded rather than omitted: the next person to notice vidlink serves | |
| 130 | progressive MP4 should find the measurement, not repeat it.""" | |
| 131 | src = addon.SOURCES_DEFAULT["vidlink"] | |
| 132 | assert src.container == "progressive-mp4" | |
| 133 | assert src.ingestable is False | |
| 134 | assert not src.enabled | |
| 135 | assert "428" in src.note or "proxy" in src.note.lower() | |
| 136 | ||
| 137 | ||
| 138 | def test_the_dead_scrapers_are_recorded_as_disabled(): | |
| 139 | for sid in ("fmovies", "vsembed"): | |
| 140 | assert addon.SOURCES_DEFAULT[sid].enabled is False | |
| 141 | ||
| 142 | ||
| 143 | def test_the_shipped_registry_chooses_vidcore_for_ingest(monkeypatch): | |
| 144 | monkeypatch.setattr(addon, "SOURCES", dict(addon.SOURCES_DEFAULT)) | |
| 145 | assert addon.ingest_source().id == "vidcore" | |
| 146 | ||
| 147 | ||
| 148 | # --- the registry gates ingest ---------------------------------------------- | |
| 149 | ||
| 150 | def test_ingest_refuses_when_no_source_may_feed_the_library(monkeypatch, tmp_path): | |
| 151 | """If every ingestable source were disabled, ingest must decline outright | |
| 152 | rather than resolve, download and then discover it cannot.""" | |
| 153 | monkeypatch.setattr(addon, "SOURCES", {}) | |
| 154 | called = [] | |
| 155 | monkeypatch.setattr(addon, "_library_meta", | |
| 156 | lambda *a, **k: called.append(a) or None) | |
| 157 | state = addon._run_ingest("series", "tmdb:1", 1, 1, 720) | |
| 158 | assert state == "failed: no ingestable source" | |
| 159 | assert called == [], "it must not even look the title up" |