| … | … | @@ -4,6 +4,7 @@ with a vsembed.ru/CloudStream Pro fallback (currently broken — Cloudflare Turn |
| 4 | 4 | The fmovies path runs node + Playwright via subprocess to handle the JS-driven extraction. |
| 5 | 5 | """ |
| 6 | 6 | |
| 7 | import calendar |
| 7 | 8 | import html |
| 8 | 9 | import json |
| 9 | 10 | import logging |
| … | … | @@ -16,9 +17,10 @@ import tempfile |
| 16 | 17 | from concurrent.futures import ThreadPoolExecutor |
| 17 | 18 | import threading |
| 18 | 19 | import time |
| 20 | import unicodedata |
| 19 | 21 | from dataclasses import dataclass, field |
| 20 | 22 | from typing import Optional |
| 21 | | from urllib.parse import urlparse, unquote, urljoin |
| 23 | from urllib.parse import urlparse, unquote, urljoin, parse_qs |
| 22 | 24 | |
| 23 | 25 | import requests |
| 24 | 26 | from flask import Flask, jsonify, request, redirect, abort, Response, send_file |
| … | … | @@ -280,13 +282,17 @@ class Source: |
| 280 | 282 | resolve: Optional[object] = None |
| 281 | 283 | enabled: bool = True |
| 282 | 284 | note: str = "" |
| 285 | # How a source knows it has the RIGHT title. "tmdb": it is asked by id, so |
| 286 | # identity is asserted. "filename": it answers a search and we match the |
| 287 | # file's name, so identity is inferred. The library prefers the former. |
| 288 | identity: str = "tmdb" |
| 283 | 289 | |
| 284 | 290 | |
| 285 | 291 | SOURCES: dict[str, Source] = {} |
| 286 | 292 | |
| 287 | | # A progressive MP4 seeks natively; HLS on this stack does not (that is the |
| 293 | # A progressive file seeks natively; HLS on this stack does not (that is the |
| 288 | 294 | # whole subject of #4), so progressive sorts first. |
| 289 | | _CONTAINER_RANK = {"progressive-mp4": 0, "hls-fmp4": 1} |
| 295 | _CONTAINER_RANK = {"progressive-mp4": 0, "progressive-mkv": 0, "hls-fmp4": 1} |
| 290 | 296 | |
| 291 | 297 | |
| 292 | 298 | def register_source(src: Source) -> None: |
| … | … | @@ -321,7 +327,19 @@ def ingest_source() -> Optional[Source]: |
| 321 | 327 | player, so a browser-only source can never feed the library however good its |
| 322 | 328 | API looks. |
| 323 | 329 | """ |
| 324 | | return next((s for s in ordered_sources() if s.ingestable), None) |
| 330 | return next(iter(ingest_sources()), None) |
| 331 | |
| 332 | |
| 333 | def ingest_sources() -> list[Source]: |
| 334 | """Every source that may feed the library, in the order to try them. |
| 335 | |
| 336 | Not the playback order. For playback a progressive file wins because it |
| 337 | seeks; for the LIBRARY the question is whether the file is the right title, |
| 338 | because a wrongly-named copy is permanent. So a source asked by TMDB id goes |
| 339 | before one whose answer we matched by filename, whatever its container. |
| 340 | """ |
| 341 | return sorted((s for s in SOURCES.values() if s.enabled and s.ingestable), |
| 342 | key=lambda s: 0 if s.identity == "tmdb" else 1) |
| 325 | 343 | |
| 326 | 344 | |
| 327 | 345 | def extract_streams(imdb_id: str, tv: bool = False, season: int = 0, episode: int = 0) -> list[Stream]: |
| … | … | @@ -599,9 +617,9 @@ TV_GENRES = [ |
| 599 | 617 | |
| 600 | 618 | MANIFEST = { |
| 601 | 619 | "id": "org.tabertron.vsembed", |
| 602 | | "version": "0.18.1", |
| 620 | "version": "0.19.0", |
| 603 | 621 | "name": "Tabertron VsEmbed", |
| 604 | | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 622 | "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub", |
| 605 | 623 | "types": ["movie", "series"], |
| 606 | 624 | # stream ids: tt (cinemeta-driven), fmv (fmovies-native catalog), |
| 607 | 625 | # fxl (flixlab/vidcore-native catalog, TMDB-keyed). |
| … | … | @@ -2034,10 +2052,8 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int, |
| 2034 | 2052 | otherwise start against a dead link. |
| 2035 | 2053 | """ |
| 2036 | 2054 | key = _mp4_key(kind, ident, season, episode, height) |
| 2037 | | # The registry decides whether ANYTHING may feed the library. Resolution is |
| 2038 | | # still vidcore's directly, because vidcore is the only ingestable source — |
| 2039 | | # dispatching through src.resolve buys nothing until there are two, and |
| 2040 | | # speculative indirection is how the special-casing this replaces began. |
| 2055 | # The registry decides whether ANYTHING may feed the library; the order |
| 2056 | # below (vidcore, then HdHub) is ingest_sources()'s order, written out. |
| 2041 | 2057 | if not ingest_source(): |
| 2042 | 2058 | return "failed: no ingestable source" |
| 2043 | 2059 | # Checked before the resolve, so a full volume costs no browser at all. |
| … | … | @@ -2064,17 +2080,29 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int, |
| 2064 | 2080 | # things fetched after today. |
| 2065 | 2081 | _fetch_subtitles_for(dest, meta, season, episode) |
| 2066 | 2082 | return "done" |
| 2067 | | url = _resolve_vidcore(kind, ident, season, episode) |
| 2068 | | if not url: |
| 2069 | | return "failed: could not resolve title" |
| 2070 | | variant = _cap_variant(url, height) |
| 2071 | | log.info("ingest start: %s -> %s", key, dest) |
| 2083 | # vidcore first: it is asked by TMDB id, so what it returns IS this title. |
| 2084 | # HdHub is the fallback because its answer is matched by filename -- see |
| 2085 | # ingest_sources(). A failure of either kind (no resolve, or a resolve onto |
| 2086 | # a CDN that refuses the bytes, as on 2026-09-28) falls through. |
| 2072 | 2087 | started = time.time() |
| 2073 | | try: |
| 2074 | | _remux_to_mp4(variant, dest) |
| 2075 | | except Exception as exc: |
| 2076 | | log.warning("ingest failed for %s: %s", key, exc) |
| 2077 | | return f"failed: {exc}" |
| 2088 | failure = None |
| 2089 | url = _resolve_vidcore(kind, ident, season, episode) |
| 2090 | if url: |
| 2091 | variant = _cap_variant(url, height) |
| 2092 | log.info("ingest start: %s -> %s", key, dest) |
| 2093 | try: |
| 2094 | _remux_to_mp4(variant, dest) |
| 2095 | except Exception as exc: |
| 2096 | log.warning("ingest failed for %s: %s", key, exc) |
| 2097 | failure = f"failed: {exc}" |
| 2098 | else: |
| 2099 | failure = "failed: could not resolve title" |
| 2100 | if failure: |
| 2101 | started = time.time() |
| 2102 | why = _ingest_from_hdhub(kind, ident, season, episode, height, dest, meta) |
| 2103 | if why: |
| 2104 | log.info("ingest: hdhub fallback for %s declined: %s", key, why) |
| 2105 | return f"{failure}; hdhub: {why}" |
| 2078 | 2106 | log.info("ingest done: %s in %.0fs (%.2f GB)", key, |
| 2079 | 2107 | time.time() - started, os.path.getsize(dest) / 1e9) |
| 2080 | 2108 | _fetch_subtitles_for(dest, meta, season, episode) |
| … | … | @@ -2337,6 +2365,14 @@ def _serve_mp4(kind: str, ident: str, season: int, episode: int): |
| 2337 | 2365 | # surface as a playback failure while the copy silently proceeded. The same |
| 2338 | 2366 | # URL serves the local file once it lands, so the row upgrades itself. |
| 2339 | 2367 | _ensure_ingest(kind, ident, season, episode, height) |
| 2368 | # A progressive file first when HdHub has one: it seeks, and it is not on |
| 2369 | # vidcore's CDN -- which on 2026-09-28 was banned outright, turning every |
| 2370 | # vidcore redirect from here into a dead link. |
| 2371 | if _hdhub_enabled(): |
| 2372 | picked = _hdhub_pick(kind, ident, season, episode, height) |
| 2373 | if picked: |
| 2374 | log.info("mp4: no copy yet — serving hdhub %s", picked[0].filename) |
| 2375 | return redirect(picked[0].url, code=302) |
| 2340 | 2376 | url = _resolve_vidcore(kind, ident, season, episode, |
| 2341 | 2377 | wait=VIDCORE_PLAY_DEADLINE) |
| 2342 | 2378 | if not url: |
| … | … | @@ -2359,6 +2395,486 @@ def mp4_series(ident: str): |
| 2359 | 2395 | return _serve_mp4("series", m.group(1), int(m.group(2)), int(m.group(3))) |
| 2360 | 2396 | |
| 2361 | 2397 | |
| 2398 | # ------------------------------ HdHub (direct files) ------------------------ |
| 2399 | # The second live source, and the first that hands over FILES rather than a |
| 2400 | # player. Found 2026-09-28 in cinewave's bundle (watch.cinewave.qzz.io), where it |
| 2401 | # is one of 22 servers and the only one that is not an embed: a real Stremio |
| 2402 | # addon whose streams are plain mkv links on Cloudflare R2 and pixeldrain. |
| 2403 | # |
| 2404 | # It exists because one source is one outage. That evening Cloudflare blocked |
| 2405 | # vidcore's CDN host `moon.quietridge.top` outright ("restricted ... due to Terms |
| 2406 | # of Service violations", even on `/`), every master 403'd from every referer, |
| 2407 | # vidcore's own player sat on "LOADING THINGS UP...", and nothing not already in |
| 2408 | # the library could play. |
| 2409 | # |
| 2410 | # Measured from stortron before any of this was written: |
| 2411 | # * asked by IMDb id it answers in 0.2-0.4s; asked by `tmdb:` it says "No |
| 2412 | # streams found" (after up to 21s). So it is always asked by IMDb id. |
| 2413 | # * R2 and pixeldrain rows answer a plain Range GET with 206, ffprobe reads |
| 2414 | # them, and they download at ~10 MB/s -- ffmpeg can copy them, which is the |
| 2415 | # property vidlink never had. Everything else ("10Gbps Download Only" on |
| 2416 | # hubcloud, hubdrive) is HTML, a 400, or unmeasured, and is not used. |
| 2417 | # * matching is fuzzy: Tony (2026) came back with "NCIS: Tony & Ziva" rows. |
| 2418 | # * many rows are HEVC Main10 or 4K, which the Pi's H.264 decoder cannot play. |
| 2419 | # * dual-audio files flag HINDI as the default track. |
| 2420 | # * R2 links are pre-signed for 8h from when HdHub CACHED them, not from when |
| 2421 | # we ask; one handed over at 22:50 UTC expired at 23:25. |
| 2422 | HDHUB_BASE = ("https://hdhub.thevolecitor.qzz.io/" |
| 2423 | # base64 of {"torbox":"unset","qualities":"2160p,1080p,720p", |
| 2424 | # "sort":"desc"} -- the configuration cinewave itself sends. |
| 2425 | "eyJ0b3Jib3giOiJ1bnNldCIsInF1YWxpdGllcyI6IjIxNjBwLDEwODBwLDcyMHAi" |
| 2426 | "LCJzb3J0IjoiZGVzYyJ9") |
| 2427 | HDHUB_TIMEOUT = 10 |
| 2428 | HDHUB_TTL = 20 * 60 |
| 2429 | # Hosts measured to serve bytes to a plain HTTP client. An allowlist rather than |
| 2430 | # a blocklist: an unmeasured host is exactly how a dead row gets offered. |
| 2431 | HDHUB_HOSTS = (".r2.cloudflarestorage.com", ".r2.dev", "pixeldrain.dev", |
| 2432 | "pixeldrain.com") |
| 2433 | # What cinewave filters out too, matched against the row's own text. |
| 2434 | HDHUB_REJECT = ("10gbps", "hubcloud", "hubcdn", "castle", "file not found") |
| 2435 | # A link must outlive what it is used for. Pressing play needs minutes; a copy |
| 2436 | # of a 3.7 GB file at ~10 MB/s needs ~6 minutes plus reconnects, and a reconnect |
| 2437 | # after expiry is a 403 that leaves a partial behind. |
| 2438 | HDHUB_PLAY_MARGIN = 10 * 60 |
| 2439 | HDHUB_INGEST_MARGIN = 60 * 60 |
| 2440 | # A filename match is inferred identity. The last check before a file is named |
| 2441 | # into the library is its length against TMDB's runtime: a different film or |
| 2442 | # episode is almost never within 15% of this one. |
| 2443 | HDHUB_RUNTIME_WINDOW = (0.85, 1.15) |
| 2444 | # The play path has ~20s before Kodi gives up on an open. Probing is ~1-2s a |
| 2445 | # file, so a handful of tries fits; an unbounded walk down a dead list does not. |
| 2446 | HDHUB_PICK_BUDGET = 12 |
| 2447 | HDHUB_PICK_TRIES = 4 |
| 2448 | HDHUB_PROBE_TIMEOUT = 15 |
| 2449 | # Audio the MP4 container holds with a stream copy. DTS and TrueHD do not go in |
| 2450 | # without re-encoding, so a file whose only audio is one of them is refused. |
| 2451 | MP4_AUDIO = {"aac", "ac3", "eac3", "mp3"} |
| 2452 | _FOREIGN = ("hindi", "tamil", "telugu", "malayalam", "kannada", "bengali", |
| 2453 | "punjabi", "marathi", "dual", "multi") |
| 2454 | _UNDECODABLE = re.compile( |
| 2455 | r"x265|h\.?265|hevc|10.?bit|\bav1\b|\bdts\b|truehd|2160p|\b4k\b|\buhd\b", re.I) |
| 2456 | |
| 2457 | |
| 2458 | @dataclass(frozen=True) |
| 2459 | class HdHubFile: |
| 2460 | url: str |
| 2461 | filename: str |
| 2462 | height: int |
| 2463 | size: int |
| 2464 | host: str |
| 2465 | english_only: bool |
| 2466 | |
| 2467 | |
| 2468 | _hdhub_cache: dict[tuple, tuple[float, list]] = {} |
| 2469 | _hdhub_probe_cache: dict[str, tuple[float, Optional[dict]]] = {} |
| 2470 | _hdhub_pick_cache: dict[tuple, tuple[float, tuple]] = {} |
| 2471 | |
| 2472 | |
| 2473 | def _hdhub_query(kind: str, imdb: str, season: int = 0, episode: int = 0) -> list: |
| 2474 | """HdHub's raw stream rows for one title. Raises on any failure.""" |
| 2475 | key = (kind, imdb, season, episode) |
| 2476 | hit = _hdhub_cache.get(key) |
| 2477 | if hit and hit[0] > time.time(): |
| 2478 | return hit[1] |
| 2479 | path = (f"series/{imdb}:{season}:{episode}" if kind == "series" |
| 2480 | else f"movie/{imdb}") |
| 2481 | r = requests.get(f"{HDHUB_BASE}/stream/{path}.json", |
| 2482 | headers={"User-Agent": UA}, timeout=HDHUB_TIMEOUT) |
| 2483 | r.raise_for_status() |
| 2484 | rows = r.json().get("streams") or [] |
| 2485 | _hdhub_cache[key] = (time.time() + HDHUB_TTL, rows) |
| 2486 | return rows |
| 2487 | |
| 2488 | |
| 2489 | def _imdb_id_for(kind: str, ident: str) -> Optional[str]: |
| 2490 | """`tt<n>` verbatim, or a `tmdb:<n>` translated through TMDB.""" |
| 2491 | if ident.startswith("tt"): |
| 2492 | return ident |
| 2493 | tmdb_id = ident.split(":", 1)[1] |
| 2494 | data = _tmdb_get(f"{'tv' if kind == 'series' else 'movie'}/{tmdb_id}/external_ids") |
| 2495 | return data.get("imdb_id") or None |
| 2496 | |
| 2497 | |
| 2498 | def _norm(text: str) -> str: |
| 2499 | """Lowercase words only: diacritics folded, punctuation to spaces.""" |
| 2500 | text = unicodedata.normalize("NFKD", text or "") |
| 2501 | text = "".join(c for c in text if not unicodedata.combining(c)).lower() |
| 2502 | text = text.replace("&", " and ") |
| 2503 | return re.sub(r"[^a-z0-9]+", " ", text).strip() |
| 2504 | |
| 2505 | |
| 2506 | def _hdhub_filename(description: str) -> str: |
| 2507 | """The file name: first line of the description, minus its [tags].""" |
| 2508 | first = (description or "").split("\n", 1)[0] |
| 2509 | return re.sub(r"^(\s*\[[^\]]*\])+\s*", "", first).strip() |
| 2510 | |
| 2511 | |
| 2512 | def _hdhub_title_ok(filename: str, title: str, year: str, kind: str, |
| 2513 | season: int = 0, episode: int = 0) -> bool: |
| 2514 | """Is this file the title asked for? |
| 2515 | |
| 2516 | The title must LEAD the name -- which is what refuses "Ncis.Tony.&.Ziva" |
| 2517 | for Tony -- and what follows it must be the year (movies, +/-1) or the exact |
| 2518 | SxxEyy (series, optionally after a year). A season pack or a neighbouring |
| 2519 | episode is a different file, not a close match. |
| 2520 | """ |
| 2521 | f, t = _norm(filename), _norm(title) |
| 2522 | if not t or not f.startswith(t + " "): |
| 2523 | return False |
| 2524 | rest = f[len(t):].split() |
| 2525 | if kind == "series": |
| 2526 | tag = f"s{season:02d}e{episode:02d}" |
| 2527 | i = 1 if re.fullmatch(r"(19|20)\d\d", rest[0]) else 0 |
| 2528 | return len(rest) > i and rest[i] == tag |
| 2529 | if any(re.fullmatch(r"s\d{1,2}e\d{1,3}.*", tok) for tok in rest): |
| 2530 | return False |
| 2531 | if not (year and re.fullmatch(r"(19|20)\d\d", rest[0])): |
| 2532 | return False |
| 2533 | return abs(int(rest[0]) - int(year)) <= 1 |
| 2534 | |
| 2535 | |
| 2536 | def _hdhub_height(text: str) -> int: |
| 2537 | """Vertical resolution a row declares, or 0. `4KHDHub 1080p` is 1080: the |
| 2538 | site's name starts with 4K, the row does not.""" |
| 2539 | m = re.search(r"(\d{3,4})p\b", text or "", re.I) |
| 2540 | if m: |
| 2541 | return int(m.group(1)) |
| 2542 | return 2160 if re.search(r"\b4k\b", text or "", re.I) else 0 |
| 2543 | |
| 2544 | |
| 2545 | def _hdhub_decodable(name: str, filename: str) -> bool: |
| 2546 | """Could the Pi's H.264 decoder play this, judging by what it says?""" |
| 2547 | height = _hdhub_height(name) or _hdhub_height(filename) |
| 2548 | if not height or height > VIDCORE_MAX_HEIGHT: |
| 2549 | return False |
| 2550 | return not _UNDECODABLE.search(filename or "") |
| 2551 | |
| 2552 | |
| 2553 | def _hdhub_languages(filename: str) -> tuple[bool, bool]: |
| 2554 | """(has English audio, has ONLY English audio), from the name. |
| 2555 | |
| 2556 | A name that mentions no language at all is taken as English: that is how |
| 2557 | ordinary release names read. The probe checks the real tags later. |
| 2558 | """ |
| 2559 | words = _norm(filename).split() |
| 2560 | foreign = any(w in words for w in _FOREIGN) |
| 2561 | english = "english" in words or "eng" in words |
| 2562 | return (english or not foreign, not foreign) |
| 2563 | |
| 2564 | |
| 2565 | def _url_seconds_left(url: str, now: Optional[float] = None) -> Optional[float]: |
| 2566 | """Seconds until a pre-signed (AWS-style) URL expires; None if unsigned.""" |
| 2567 | q = parse_qs(urlparse(url).query) |
| 2568 | try: |
| 2569 | signed = calendar.timegm(time.strptime(q["X-Amz-Date"][0], "%Y%m%dT%H%M%SZ")) |
| 2570 | lifetime = int(q["X-Amz-Expires"][0]) |
| 2571 | except (KeyError, IndexError, ValueError): |
| 2572 | return None |
| 2573 | return signed + lifetime - (time.time() if now is None else now) |
| 2574 | |
| 2575 | |
| 2576 | def _hdhub_files(kind: str, ident: str, season: int = 0, episode: int = 0, |
| 2577 | max_height: int = VIDCORE_MAX_HEIGHT, |
| 2578 | margin: float = HDHUB_PLAY_MARGIN) -> list[HdHubFile]: |
| 2579 | """Files HdHub has for this title that we could use, best first. |
| 2580 | |
| 2581 | Never raises: HdHub is a third party, and its being down must cost a row, |
| 2582 | not the source list. |
| 2583 | """ |
| 2584 | try: |
| 2585 | meta = _library_meta(kind, ident, season, episode) |
| 2586 | if not meta: |
| 2587 | return [] |
| 2588 | imdb = _imdb_id_for(kind, ident) |
| 2589 | if not imdb: |
| 2590 | return [] |
| 2591 | rows = _hdhub_query(kind, imdb, season, episode) |
| 2592 | except Exception as exc: |
| 2593 | log.warning("hdhub: lookup failed for %s %s: %s", kind, ident, exc) |
| 2594 | return [] |
| 2595 | files = [] |
| 2596 | for r in rows: |
| 2597 | url = r.get("url") or "" |
| 2598 | name = r.get("name") or "" |
| 2599 | desc = r.get("description") or "" |
| 2600 | if not url or any(x in (name + " " + desc).lower() for x in HDHUB_REJECT): |
| 2601 | continue |
| 2602 | host = (urlparse(url).hostname or "").lower() |
| 2603 | if not any(host == h.lstrip(".") or host.endswith(h) for h in HDHUB_HOSTS): |
| 2604 | continue |
| 2605 | filename = _hdhub_filename(desc) |
| 2606 | if not _hdhub_title_ok(filename, meta["title"], meta["year"], kind, |
| 2607 | season, episode): |
| 2608 | continue |
| 2609 | if not _hdhub_decodable(name, filename): |
| 2610 | continue |
| 2611 | height = _hdhub_height(name) or _hdhub_height(filename) |
| 2612 | if height > max_height: |
| 2613 | continue |
| 2614 | has_english, english_only = _hdhub_languages(filename) |
| 2615 | if not has_english: |
| 2616 | continue |
| 2617 | left = _url_seconds_left(url) |
| 2618 | if left is not None and left < margin: |
| 2619 | continue |
| 2620 | size = int((r.get("behaviorHints") or {}).get("videoSize") or 0) |
| 2621 | files.append(HdHubFile(url=url, filename=filename, height=height, |
| 2622 | size=size, host=host, english_only=english_only)) |
| 2623 | # English-only first (no track juggling needed), then R2 (pixeldrain limits |
| 2624 | # hotlinked downloads), then resolution, then size as the quality tiebreak. |
| 2625 | files.sort(key=lambda x: (not x.english_only, ".r2." not in x.host + ".", |
| 2626 | -x.height, -x.size)) |
| 2627 | return files |
| 2628 | |
| 2629 | |
| 2630 | def _hdhub_probe(url: str, filename: Optional[str] = None) -> Optional[dict]: |
| 2631 | """ffprobe's view of a remote file, or None. Cached, including failures |
| 2632 | (briefly), so a dead link is not re-probed on every click.""" |
| 2633 | hit = _hdhub_probe_cache.get(url) |
| 2634 | if hit and hit[0] > time.time(): |
| 2635 | return hit[1] |
| 2636 | result = None |
| 2637 | try: |
| 2638 | out = subprocess.run( |
| 2639 | ["ffprobe", "-v", "error", "-rw_timeout", "10000000", |
| 2640 | "-show_entries", |
| 2641 | "stream=index,codec_type,codec_name,height:stream_tags=language" |
| 2642 | ":stream_disposition=attached_pic:format=duration", |
| 2643 | "-of", "json", url], |
| 2644 | capture_output=True, text=True, timeout=HDHUB_PROBE_TIMEOUT) |
| 2645 | if out.returncode == 0: |
| 2646 | result = json.loads(out.stdout) |
| 2647 | except Exception as exc: |
| 2648 | log.info("hdhub: probe failed for %s (%s)", filename or url[:60], exc) |
| 2649 | _hdhub_probe_cache[url] = (time.time() + (HDHUB_TTL if result else 300), result) |
| 2650 | return result |
| 2651 | |
| 2652 | |
| 2653 | def _probe_video(p: Optional[dict]) -> Optional[dict]: |
| 2654 | """The real video stream -- not the cover art some releases attach.""" |
| 2655 | for s in (p or {}).get("streams") or []: |
| 2656 | if s.get("codec_type") == "video" and not (s.get("disposition") or {}).get("attached_pic"): |
| 2657 | return s |
| 2658 | return None |
| 2659 | |
| 2660 | |
| 2661 | def _probe_audio(p: Optional[dict]) -> list[dict]: |
| 2662 | """Audio streams an MP4 can carry with a stream copy.""" |
| 2663 | return [s for s in (p or {}).get("streams") or [] |
| 2664 | if s.get("codec_type") == "audio" and s.get("codec_name") in MP4_AUDIO] |
| 2665 | |
| 2666 | |
| 2667 | def _audio_lang(s: dict) -> str: |
| 2668 | return ((s.get("tags") or {}).get("language") or "").lower() |
| 2669 | |
| 2670 | |
| 2671 | def _probe_usable(p: Optional[dict]) -> bool: |
| 2672 | """Can the Pi play this file, with English sound? The file decides, not |
| 2673 | its name: a name can omit HEVC, the stream cannot.""" |
| 2674 | v = _probe_video(p) |
| 2675 | if not v or v.get("codec_name") != "h264" or (v.get("height") or 0) > 1088: |
| 2676 | return False |
| 2677 | audio = _probe_audio(p) |
| 2678 | if not audio: |
| 2679 | return False |
| 2680 | tagged = [a for a in audio if _audio_lang(a) not in ("", "und")] |
| 2681 | return not tagged or any(_audio_lang(a) in ("eng", "en") for a in tagged) |
| 2682 | |
| 2683 | |
| 2684 | def _hdhub_pick(kind: str, ident: str, season: int = 0, episode: int = 0, |
| 2685 | height: int = VIDCORE_MAX_HEIGHT, |
| 2686 | margin: float = HDHUB_PLAY_MARGIN) -> Optional[tuple]: |
| 2687 | """(file, probe) for the best file that actually checks out, or None.""" |
| 2688 | key = (kind, ident, season, episode, height) |
| 2689 | hit = _hdhub_pick_cache.get(key) |
| 2690 | if hit and hit[0] > time.time(): |
| 2691 | left = _url_seconds_left(hit[1][0].url) |
| 2692 | if left is None or left >= margin: |
| 2693 | return hit[1] |
| 2694 | deadline = time.time() + HDHUB_PICK_BUDGET |
| 2695 | for f in _hdhub_files(kind, ident, season, episode, max_height=height, |
| 2696 | margin=margin)[:HDHUB_PICK_TRIES]: |
| 2697 | if time.time() > deadline: |
| 2698 | log.info("hdhub: pick budget spent for %s", ident) |
| 2699 | break |
| 2700 | p = _hdhub_probe(f.url, f.filename) |
| 2701 | if _probe_usable(p): |
| 2702 | _hdhub_pick_cache[key] = (time.time() + HDHUB_TTL, (f, p)) |
| 2703 | return f, p |
| 2704 | log.info("hdhub: %s not usable, trying the next", f.filename) |
| 2705 | return None |
| 2706 | |
| 2707 | |
| 2708 | _hdhub_prefetch_sem = threading.Semaphore(2) |
| 2709 | |
| 2710 | |
| 2711 | def _hdhub_prefetch(kind: str, ident: str, season: int = 0, |
| 2712 | episode: int = 0) -> None: |
| 2713 | """Probe the best file while the source list is being read, so pressing |
| 2714 | play is a cache hit. Skipped, not queued, when already busy.""" |
| 2715 | if not _hdhub_prefetch_sem.acquire(blocking=False): |
| 2716 | return |
| 2717 | |
| 2718 | def run() -> None: |
| 2719 | try: |
| 2720 | _hdhub_pick(kind, ident, season, episode) |
| 2721 | except Exception as exc: |
| 2722 | log.warning("hdhub prefetch failed for %s: %s", ident, exc) |
| 2723 | finally: |
| 2724 | _hdhub_prefetch_sem.release() |
| 2725 | |
| 2726 | threading.Thread(target=run, name=f"hdhub-{ident}", daemon=True).start() |
| 2727 | |
| 2728 | |
| 2729 | def _hdhub_enabled() -> bool: |
| 2730 | src = get_source("hdhub") |
| 2731 | return bool(src and src.enabled) |
| 2732 | |
| 2733 | |
| 2734 | def _append_hdhub_rows(out: list, kind: str, ident: Optional[str], |
| 2735 | season: int = 0, episode: int = 0) -> None: |
| 2736 | """One row per resolution HdHub has a usable-looking file at. |
| 2737 | |
| 2738 | Built from HdHub's listing (0.2-0.4s) without probing: probing is seconds |
| 2739 | per file and belongs off the source list. The play route probes and falls |
| 2740 | through to the next file, so a row whose top file is bad still plays. |
| 2741 | """ |
| 2742 | if not ident or not _hdhub_enabled(): |
| 2743 | return |
| 2744 | files = _hdhub_files(kind, ident, season, episode) |
| 2745 | if not files: |
| 2746 | return |
| 2747 | try: |
| 2748 | _hdhub_prefetch(kind, ident, season, episode) |
| 2749 | except Exception as exc: # never let a warm-up break /stream |
| 2750 | log.warning("hdhub prefetch could not start: %s", exc) |
| 2751 | if kind == "series": |
| 2752 | path = f"/hdhub/series/{ident}:{season}:{episode}.mkv" |
| 2753 | tag = f"S{season:02d}E{episode:02d} " # Jacktook drops TV rows without it |
| 2754 | else: |
| 2755 | path = f"/hdhub/movie/{ident}.mkv" |
| 2756 | tag = "" |
| 2757 | seen = set() |
| 2758 | for f in files: |
| 2759 | if f.height in seen: |
| 2760 | continue |
| 2761 | seen.add(f.height) |
| 2762 | size = f" · {f.size / 1e9:.1f} GB" if f.size else "" |
| 2763 | audio = "English" if f.english_only else "English + other audio" |
| 2764 | out.append(_stream_dict(Stream( |
| 2765 | url=f"http://{request.host}{path}?h={f.height}", |
| 2766 | quality=f"{f.height}p", name="HdHub", referer="", |
| 2767 | title_extra=f"{tag}{f.height}p{size}\nHdHub · {audio} · seekable"))) |
| 2768 | |
| 2769 | |
| 2770 | def _serve_hdhub(kind: str, ident: str, season: int, episode: int): |
| 2771 | height = _requested_height() |
| 2772 | local = _local_redirect(kind, ident, season, episode, height) |
| 2773 | if local: |
| 2774 | return local |
| 2775 | picked = _hdhub_pick(kind, ident, season, episode, height) |
| 2776 | if not picked: |
| 2777 | abort(404) |
| 2778 | _ensure_ingest(kind, ident, season, episode, height=height) |
| 2779 | if kind == "series": |
| 2780 | _queue_next_episode(ident, season, episode, height) |
| 2781 | log.info("hdhub: serving %s", picked[0].filename) |
| 2782 | return redirect(picked[0].url, code=302) |
| 2783 | |
| 2784 | |
| 2785 | @app.route("/hdhub/movie/<path:ident>.mkv") |
| 2786 | def hdhub_movie(ident: str): |
| 2787 | if not re.fullmatch(VIDCORE_IDENT, ident): |
| 2788 | abort(404) |
| 2789 | return _serve_hdhub("movie", ident, 0, 0) |
| 2790 | |
| 2791 | |
| 2792 | @app.route("/hdhub/series/<path:ident>.mkv") |
| 2793 | def hdhub_series(ident: str): |
| 2794 | m = re.fullmatch(rf"({VIDCORE_IDENT}):(\d+):(\d+)", ident) |
| 2795 | if not m: |
| 2796 | abort(404) |
| 2797 | return _serve_hdhub("series", m.group(1), int(m.group(2)), int(m.group(3))) |
| 2798 | |
| 2799 | |
| 2800 | def _copy_progressive(url: str, probe: dict, out_path: str) -> None: |
| 2801 | """Stream-copy a remote file into a progressive MP4, English audio first. |
| 2802 | |
| 2803 | English goes first AND is flagged default because Kodi plays the default |
| 2804 | track, and these dual-audio files flag Hindi. Subtitle streams are not |
| 2805 | copied: the library gets wyzie sidecars beside every copy, and SRT does not |
| 2806 | stream-copy into MP4. Measured against the source's own duration before it |
| 2807 | is published, for the reason _remux_to_mp4 is: ffmpeg exits 0 on an input |
| 2808 | that stops early. |
| 2809 | """ |
| 2810 | tmp = _part_path(out_path) |
| 2811 | os.makedirs(os.path.dirname(out_path), exist_ok=True) |
| 2812 | video = _probe_video(probe) |
| 2813 | audio = _probe_audio(probe) |
| 2814 | if not video or not audio: |
| 2815 | raise RuntimeError("no copyable video/audio in source") |
| 2816 | audio = ([a for a in audio if _audio_lang(a) in ("eng", "en")] |
| 2817 | + [a for a in audio if _audio_lang(a) not in ("eng", "en")]) |
| 2818 | cmd = ["ffmpeg", "-v", "error", "-y", |
| 2819 | "-rw_timeout", "30000000", |
| 2820 | "-reconnect", "1", |
| 2821 | "-reconnect_streamed", "1", |
| 2822 | "-reconnect_on_network_error", "1", |
| 2823 | "-reconnect_delay_max", "30", |
| 2824 | "-i", url, "-map", f"0:{video['index']}"] |
| 2825 | for a in audio: |
| 2826 | cmd += ["-map", f"0:{a['index']}"] |
| 2827 | cmd += ["-c", "copy"] |
| 2828 | for i in range(len(audio)): |
| 2829 | cmd += [f"-disposition:a:{i}", "default" if i == 0 else "0"] |
| 2830 | # -f mp4 is REQUIRED, as in _remux_to_mp4: the output is `<name>.part`. |
| 2831 | cmd += ["-movflags", "+faststart", "-f", "mp4", tmp] |
| 2832 | proc = _run_remux(cmd, tmp, stall_timeout=MP4_STALL_TIMEOUT, |
| 2833 | hard_timeout=MP4_REMUX_TIMEOUT) |
| 2834 | if proc.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0: |
| 2835 | raise RuntimeError(f"ffmpeg exit {proc.returncode}: {(proc.stderr or '')[-300:]}") |
| 2836 | try: |
| 2837 | expected = float(((probe or {}).get("format") or {}).get("duration") or 0) or None |
| 2838 | except (TypeError, ValueError): |
| 2839 | expected = None |
| 2840 | if expected: |
| 2841 | actual = _media_duration(tmp) |
| 2842 | if actual is None or actual < expected * REMUX_MIN_COMPLETENESS: |
| 2843 | raise RuntimeError( |
| 2844 | f"incomplete copy: got {actual if actual else 0:.0f}s of " |
| 2845 | f"{expected:.0f}s — not published") |
| 2846 | log.info("copy verified: %.0fs of %.0fs", actual, expected) |
| 2847 | _record_verified(out_path, actual, expected) |
| 2848 | os.replace(tmp, out_path) |
| 2849 | |
| 2850 | |
| 2851 | def _ingest_from_hdhub(kind: str, ident: str, season: int, episode: int, |
| 2852 | height: int, dest: str, meta: dict) -> Optional[str]: |
| 2853 | """Copy the title from HdHub into `dest`. None on success, else why not.""" |
| 2854 | if not _hdhub_enabled(): |
| 2855 | return "disabled" |
| 2856 | picked = _hdhub_pick(kind, ident, season, episode, height, |
| 2857 | margin=HDHUB_INGEST_MARGIN) |
| 2858 | if not picked: |
| 2859 | return "no usable file" |
| 2860 | f, p = picked |
| 2861 | try: |
| 2862 | seconds = float((p.get("format") or {}).get("duration") or 0) |
| 2863 | expected = float(meta.get("runtime") or 0) * 60 |
| 2864 | except (TypeError, ValueError): |
| 2865 | seconds = expected = 0 |
| 2866 | lo, hi = HDHUB_RUNTIME_WINDOW |
| 2867 | if seconds and expected and not (lo * expected <= seconds <= hi * expected): |
| 2868 | return (f"{f.filename} runs {seconds / 60:.0f} min but TMDB says " |
| 2869 | f"{expected / 60:.0f} — not taken as this title") |
| 2870 | log.info("ingest start (hdhub): %s -> %s", f.filename, dest) |
| 2871 | try: |
| 2872 | _copy_progressive(f.url, p, dest) |
| 2873 | except Exception as exc: |
| 2874 | return str(exc) |
| 2875 | return None |
| 2876 | |
| 2877 | |
| 2362 | 2878 | def _vidcore_ident_for_fmv(ctype: str, slug: str) -> Optional[str]: |
| 2363 | 2879 | """fmovies carries no imdb/tmdb id — bridge its slug to TMDB so the (lazy) |
| 2364 | 2880 | vidcore row can be offered on catalog entries too.""" |
| … | … | @@ -2566,6 +3082,7 @@ def stream_movie(ident: str): |
| 2566 | 3082 | if fxl: # flixlab-native catalog: vidcore only |
| 2567 | 3083 | out: list = [] |
| 2568 | 3084 | _append_mp4_row(out, "movie", f"tmdb:{fxl.group(1)}") |
| 3085 | _append_hdhub_rows(out, "movie", f"tmdb:{fxl.group(1)}") |
| 2569 | 3086 | _append_vidcore_row(out, "movie", f"tmdb:{fxl.group(1)}") |
| 2570 | 3087 | _append_action_rows(out, "movie", f"tmdb:{fxl.group(1)}") |
| 2571 | 3088 | return jsonify({"streams": out}) |
| … | … | @@ -2588,6 +3105,7 @@ def stream_movie(ident: str): |
| 2588 | 3105 | log.exception("extract failed for %s: %s", ident, exc) |
| 2589 | 3106 | streams = [] |
| 2590 | 3107 | out = [_stream_dict(s) for s in streams] |
| 3108 | _append_hdhub_rows(out, "movie", ident) |
| 2591 | 3109 | _append_vidcore_row(out, "movie", ident) # additive gap-filler |
| 2592 | 3110 | return jsonify({"streams": out}) |
| 2593 | 3111 | |
| … | … | @@ -2599,6 +3117,7 @@ def stream_series(ident: str): |
| 2599 | 3117 | tmdb, season, episode = fxl.group(1), int(fxl.group(2)), int(fxl.group(3)) |
| 2600 | 3118 | out: list = [] |
| 2601 | 3119 | _append_mp4_row(out, "series", f"tmdb:{tmdb}", season, episode) |
| 3120 | _append_hdhub_rows(out, "series", f"tmdb:{tmdb}", season, episode) |
| 2602 | 3121 | _append_vidcore_row(out, "series", f"tmdb:{tmdb}", season, episode) |
| 2603 | 3122 | _append_action_rows(out, "series", f"tmdb:{tmdb}", season, episode) |
| 2604 | 3123 | return jsonify({"streams": out}) |
| … | … | @@ -2635,6 +3154,7 @@ def stream_series(ident: str): |
| 2635 | 3154 | streams = [] |
| 2636 | 3155 | se_tag = f"S{season:02d}E{episode:02d}" |
| 2637 | 3156 | out = [_stream_dict(s, se_tag=se_tag) for s in streams] |
| 3157 | _append_hdhub_rows(out, "series", imdb_id, season, episode) |
| 2638 | 3158 | _append_vidcore_row(out, "series", imdb_id, season, episode) # additive |
| 2639 | 3159 | return jsonify({"streams": out}) |
| 2640 | 3160 | |
| … | … | @@ -2646,19 +3166,17 @@ def _stream_dict(s: Stream, se_tag: str = "") -> dict: |
| 2646 | 3166 | title = f"{se_tag} {s.quality}\n{s.name}" |
| 2647 | 3167 | else: |
| 2648 | 3168 | title = f"{s.quality}\n{s.name}" |
| 3169 | hints: dict = {"notWebReady": True} |
| 3170 | # No referer, no proxyHeaders: a plain file host needs none, and sending a |
| 3171 | # made-up one only gives it something to refuse. |
| 3172 | if s.referer: |
| 3173 | hints["proxyHeaders"] = {"request": {"User-Agent": UA, |
| 3174 | "Referer": s.referer}} |
| 2649 | 3175 | return { |
| 2650 | 3176 | "name": s.name, |
| 2651 | 3177 | "title": title, |
| 2652 | 3178 | "url": s.url, |
| 2653 | | "behaviorHints": { |
| 2654 | | "notWebReady": True, |
| 2655 | | "proxyHeaders": { |
| 2656 | | "request": { |
| 2657 | | "User-Agent": UA, |
| 2658 | | "Referer": s.referer, |
| 2659 | | } |
| 2660 | | }, |
| 2661 | | }, |
| 3179 | "behaviorHints": hints, |
| 2662 | 3180 | } |
| 2663 | 3181 | |
| 2664 | 3182 | |
| … | … | @@ -3195,6 +3713,16 @@ SOURCES_DEFAULT: dict[str, Source] = {s.id: s for s in ( |
| 3195 | 3713 | "or session binding, not headers. Unusable for ingest without " |
| 3196 | 3714 | "curl-impersonate. Media is HEVC; Pi decode unverified.", |
| 3197 | 3715 | ), |
| 3716 | Source( |
| 3717 | id="hdhub", name="HdHub", container="progressive-mkv", |
| 3718 | ingestable=True, browser=False, heights=(1080, 720, 480), |
| 3719 | resolve=_hdhub_pick, identity="filename", |
| 3720 | note="Added 2026-09-28 when Cloudflare banned vidcore's CDN host. A " |
| 3721 | "Stremio addon (found in cinewave's server list) returning direct " |
| 3722 | "mkv files on R2/pixeldrain: plain HTTP, ~10 MB/s, seekable. Asked " |
| 3723 | "by IMDb id (tmdb: finds nothing). Identity is a FILENAME match, " |
| 3724 | "so the library tries vidcore first and checks runtime.", |
| 3725 | ), |
| 3198 | 3726 | Source( |
| 3199 | 3727 | id="fmovies", name="fmovies", container="hls-fmp4", |
| 3200 | 3728 | ingestable=True, browser=True, enabled=False, |