From 7befd9a43a5454cfdd9c0872801d330b4c23cd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?v=20=E2=82=BF?= Date: Sun, 1 Nov 2026 19:44:13 +0200 Subject: [PATCH 1/4] feat: build region-grouped Pluto TV M3Us and publish only Pluto files to existing Bunny zones --- tools/plutotv_bunny.py | 307 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 tools/plutotv_bunny.py diff --git a/tools/plutotv_bunny.py b/tools/plutotv_bunny.py new file mode 100644 index 0000000000..786797a42d --- /dev/null +++ b/tools/plutotv_bunny.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Mirror free Pluto TV channel listings into the EXISTING Bunny Storage Zone. + +Pluto streams point to third-party playlist transport (jmp2.uk) and may be +geo-blocked. This is not an official Pluto TV API or a playback guarantee. +Only M3U metadata is hosted on Bunny; no video content is downloaded/proxied. +""" +from __future__ import annotations + +import argparse +import getpass +import json +import re +import sys +import tempfile +import time +from collections import defaultdict +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +import auto_bunny_setup as bunny +import personal_bunny_playlists as m3u + +UPSTREAM = "https://raw.githubusercontent.com/BuddyChewChew/app-m3u-generator/main/playlists/" +ALL = UPSTREAM + "plutotv_all.m3u" +GERMANY = UPSTREAM + "plutotv_de.m3u" +FILES = ("plutotv.m3u", "plutotv-lite.m3u", + "plutotv-de.m3u", "plutotv-erotic.m3u") +EXTRAS = ("plutotv-us.m3u", "plutotv-gb.m3u") +COUNTRY_GROUPS = ("United States", "Germany", "United Kingdom", "France", + "Spain", "Italy", "Canada", "Brazil", "Argentina", "Mexico", + "Chile", "Denmark", "Sweden", "Norway") +# Reject unexpected URL types: never publish subscribed/private/tokenized URLs +# added to a source file at some future date. jmp2.uk is a third-party provider. +JMP = re.compile(r"^https://jmp2\.uk/plu-[0-9a-f]{24}\.m3u8$", re.IGNORECASE) +LOGO = re.compile(r'\btvg-logo="([^"]*)"') +GROUP = re.compile(r'\bgroup-title="([^"]*)"') +TITLE = re.compile(r"^#EXTINF:[^,]*,(.*)$") +URL_TVG = re.compile(r'\burl-tvg="([^"]*)"') + + +def download(url: str) -> str: + if url not in (ALL, GERMANY): + raise ValueError("unrecognized_upstream_playlist") + req = Request(url, headers={"User-Agent": "Lampa-Bunny-PlutoTV/1.0"}) + with urlopen(req, timeout=45) as resp: + raw = resp.read(2_000_001) + if len(raw) > 2_000_000: + raise ValueError("upstream_m3u_too_large") + return raw.decode("utf-8-sig") + + +def extract(text: str, min_count: int, max_count: int) -> tuple[str, list[tuple[str, str]]]: + lines = text.splitlines() + if not lines or not lines[0].startswith("#EXTM3U"): + raise ValueError("missing_m3u_header") + pending = None + entries = [] + for line in lines[1:]: + line = line.strip() + if line.startswith("#EXTINF:"): + if pending is not None: + raise ValueError("missing_stream_url") + match = TITLE.match(line) + if not match or not match.group(1).strip(): + raise ValueError("invalid_channel_title") + groups = GROUP.findall(line) + if len(groups) != 1 or not groups[0].strip() or ";" in groups[0]: + raise ValueError("invalid_composite_channel_group") + logos = LOGO.findall(line) + if len(logos) > 1 or any(not urlsplit(u).scheme == "https" for u in logos if u): + raise ValueError("invalid_logo") + pending = line + elif pending is not None and line.startswith(("https://", "http://")): + if not JMP.fullmatch(line): + raise ValueError("unexpected_stream_url_scheme_or_host") + entries.append((pending, line)) + pending = None + elif pending is not None and line and not line.startswith("#"): + raise ValueError("unexpected_stream_reference") + if pending is not None: + raise ValueError("unterminated_channel") + if not (min_count <= len(entries) <= max_count): + raise ValueError(f"upstream_count_out_of_bounds_{len(entries)}") + epg = URL_TVG.search(lines[0]) + epg_url = epg.group(1) if epg else "" + if epg_url and not epg_url.startswith("https://"): + raise ValueError("invalid_epg_url") + return epg_url, entries + + +def serialize(entries: list[tuple[str, str]], epg: str = "") -> str: + if not entries: + raise ValueError("empty_generated_playlist") + head = "#EXTM3U" + (' url-tvg="' + epg + '"' if epg else "") + "\n" + return head + "".join(meta + "\n" + url + "\n" for meta, url in entries) + + +def unique(items: list[tuple[str, str]]) -> list[tuple[str, str]]: + result = [] + seen = set() + for meta, url in items: + # Region-specific copies of the same channel can exist; dedupe only exact + # metadata+URL combinations so the original all-region catalog is retained. + key = (meta, url) + if key not in seen: + seen.add(key) + result.append((meta, url)) + return result + + +def round_robin(entries: list[tuple[str, str]], limit: int = 240 + ) -> list[tuple[str, str]]: + groups: dict[str, list[tuple[str, str]]] = defaultdict(list) + for item in entries: + groups[GROUP.search(item[0]).group(1)].append(item) + order = sorted(groups) + selected, channels = [], set() + index = 0 + while len(selected) < limit: + progressed = False + for country in order: + if index >= len(groups[country]): + continue + meta, url = groups[country][index] + # Pluto's 24-hex channel ID in the stream path + cid = url.split("/plu-", 1)[-1].split(".", 1)[0] + if cid in channels: + continue + selected.append((meta, url)) + channels.add(cid) + progressed = True + if len(selected) >= limit: + break + if not progressed and all(index >= len(groups[c]) - 1 for c in order): + break + index += 1 + return selected + + +def create(source_all: str, source_de: str, output: Path) -> dict: + all_epg, all_entries = extract(source_all, 500, 6000) + de_epg, de_entries = extract(source_de, 100, 400) + all_entries = unique(all_entries) + de_entries = unique(de_entries) + fast = [row for row in de_entries + if "Sinnliche Fantasien" in GROUP.search(row[0]).group(1)] + if not (1 <= len(fast) <= 30): + raise ValueError("German erotic FAST category changed or missing; refusing incomplete output") + lite = round_robin(all_entries, limit=240) + if len(lite) < 100: + raise ValueError("lightweight profile unexpectedly small") + output.mkdir(parents=True, exist_ok=True) + paths = { + "plutotv.m3u": serialize(all_entries, all_epg), + "plutotv-lite.m3u": serialize(lite), # No huge EPG requested by Tizen. + "plutotv-de.m3u": serialize(de_entries, de_epg), + "plutotv-erotic.m3u": serialize(fast, de_epg), + } + # Intentional aliases from official "all" metadata, avoid fetching more + # hosts and keep bounded count for Samsung. + for suffix, group in (("us", "United States"), ("gb", "United Kingdom")): + regional = [x for x in all_entries if GROUP.search(x[0]).group(1) == group] + if regional: + paths[f"plutotv-{suffix}.m3u"] = serialize(regional, all_epg) + for name, text in paths.items(): + (output / name).write_text(text, encoding="utf-8") + report = { + "upstream": "BuddyChewChew/app-m3u-generator", + "upstream_all_entries": len(all_entries), + "upstream_germany_entries": len(de_entries), + "erotic_fast_channels_germany": len(fast), + "files": {name: { + "entries": len(m3u.parse_m3u(text)), + "bytes": len(text.encode("utf-8")) + } for name, text in paths.items()}, + "all_country_groups": sorted({GROUP.search(x[0]).group(1) for x in all_entries}), + "direct_stream_host": "jmp2.uk (third-party, not official pluto.tv)", + "streams_playback_tested": False, + "geo_restrictions_removed": False, + "rights_to_rebroadcast_video_verified": False, + "adult_hardcore_claimed": False, + "note": "Only playlist metadata is uploaded; no video is hosted on Bunny." + } + return report + + +def ensure_existing_bunny(): + if not bunny.STATE.is_file(): + raise RuntimeError("Missing .bunny-iptv-state.json. Run inside the EXISTING ~/iptv-bunny directory") + state = bunny.load_state(bunny.STATE) + print("Existing resource group:", state["name"]) + key = getpass.getpass("Bunny Account API Key (hidden): ") + if not key: + raise ValueError("Missing Bunny Account API Key") + api = bunny.BunnyAPI(key) + key = "" + if not bunny.named(bunny.list_zones(api, "storagezone"), state["name"] + "-storage"): + raise RuntimeError("Storage Zone missing; refusing to create a new paid resource") + if not bunny.named(bunny.list_zones(api, "pullzone"), state["name"] + "-cdn"): + raise RuntimeError("Pull Zone missing; refusing to create a new paid resource") + storage = bunny.storage_zone(api, state) + pull = bunny.pull_zone(api, state, storage["id"]) + return api, state, storage, pull + + +def verify_cdn(hostname: str, filename: str) -> bool: + for attempt in range(3): + try: + req = Request("https://" + hostname + "/iptv/" + filename + "?v=" + + str(int(time.time())), headers={"User-Agent": "Lampa-PlutoTV-Verify/1.0"}) + with urlopen(req, timeout=12) as response: + if response.status == 200 and response.read(8).startswith(b"#EXTM3U"): + return True + except (HTTPError, URLError, TimeoutError, OSError): + pass + if attempt < 2: + time.sleep(2) + return False + + +def publish(local: Path) -> int: + needed = ["plutotv.m3u", "plutotv-lite.m3u", + "plutotv-de.m3u", "plutotv-erotic.m3u"] + if any(not (local / p).is_file() for p in needed): + raise RuntimeError("Local PlutoTV playlist build missing; run normally first") + api, state, storage, pull = ensure_existing_bunny() + with tempfile.TemporaryDirectory(prefix="lampa-pluto-upload-") as temp: + staged = Path(temp) + for path in sorted(local.glob("plutotv*.m3u")): + staged.joinpath(path.name).write_bytes(path.read_bytes()) + # Only staged Pluto M3U files are uploaded to the iptv/ prefix. + bunny.publish(api, state, storage, pull, staged) + base = "https://" + pull["hostname"] + "/iptv/" + print("\nPLUTOTV_UPLOAD_COMPLETE") + for name in sorted(x.name for x in local.glob("plutotv*.m3u")): + print(base + name) + for name in ("plutotv-lite.m3u", "plutotv-de.m3u", "plutotv-erotic.m3u"): + print("CDN_CHECK", name, "VERIFIED" if verify_cdn(pull["hostname"], name) else "NOT_VERIFIED") + return 0 + + +def test(): + def row(i, group="United States"): + cid = f"{i:024x}" + return (f'#EXTINF:-1 tvg-id="{cid}" tvg-logo="https://images.pluto.tv/logo.png" ' + f'group-title="{group}",Example {i}\nhttps://jmp2.uk/plu-{cid}.m3u8') + all_src = "#EXTM3U\n" + "\n".join(row(i, ["Germany", "United States", "United Kingdom"][i % 3]) for i in range(600)) + de_src = "#EXTM3U\n" + "\n".join(row(1000 + i, "Sinnliche Fantasien" if i < 8 else "News") for i in range(150)) + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) + report = create(all_src, de_src, path) + assert report["upstream_all_entries"] == 600 + assert report["erotic_fast_channels_germany"] == 8 + assert report["files"]["plutotv-lite.m3u"]["entries"] == 240 + assert report["files"]["plutotv-erotic.m3u"]["entries"] == 8 + for f in path.glob("*.m3u"): + text = f.read_text() + assert text.startswith("#EXTM3U") + assert all(";" not in GROUP.search(meta).group(1) for meta, _ in m3u.parse_m3u(text)) + # Rejection must fail closed. + for edited in ( + all_src.replace("https://jmp2.uk/", "https://example.com/", 1), + all_src.replace("https://jmp2.uk/", "http://jmp2.uk/", 1), + all_src.replace('group-title="Germany"', 'group-title="Germany;Other"', 1) + ): + try: + create(edited, de_src, path) + raise AssertionError("Unsafe/incorrect source entry accepted") + except ValueError: + pass + print("PLUTOTV_OFFLINE_TEST_PASS") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="dist/plutotv") + parser.add_argument("--dry-run", action="store_true", help="Prepare local files only") + parser.add_argument("--publish-only", action="store_true", help="Reuse saved Pluto M3Us and publish") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + test() + return 0 + if args.dry_run and args.publish_only: + parser.error("Do not combine --dry-run and --publish-only") + local = Path(args.output) + try: + if not args.publish_only: + print("Fetching Pluto TV all regions and Germany...") + report = create(download(ALL), download(GERMANY), local) + (local / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print("Generated:", json.dumps(report["files"], indent=2)) + print("Report:", local / "report.json") + if args.dry_run: + print("DRY RUN: nothing uploaded to Bunny") + return 0 + return publish(local) + except (RuntimeError, ValueError, HTTPError, URLError, OSError, EOFError) as e: + print("ERROR:", type(e).__name__, str(e), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 980e9e253fd4ca96841268a7bc522eadc68756ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?v=20=E2=82=BF?= Date: Sun, 1 Nov 2026 19:45:09 +0200 Subject: [PATCH 2/4] test: build Pluto M3U bundles from latest upstream and enforce TV lite count and safe streams --- .../workflows/personal-bunny-playlists.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/personal-bunny-playlists.yml b/.github/workflows/personal-bunny-playlists.yml index 21cc23e938..e68b930814 100644 --- a/.github/workflows/personal-bunny-playlists.yml +++ b/.github/workflows/personal-bunny-playlists.yml @@ -10,6 +10,7 @@ on: - 'tools/build_lampa_lite.py' - 'tools/lampa_category_fix.py' - 'tools/adulttest.py' + - 'tools/plutotv_bunny.py' - '.github/workflows/personal-bunny-playlists.yml' permissions: contents: read @@ -159,3 +160,27 @@ jobs: assert all(not adulttest.CAM_WORDS.search(candidate.name) for candidate in results) print('ADULTTEST_SOURCE_DISCOVERY_PASS') PY + + - name: Compile PlutoTV standalone installer + run: python3 -m py_compile tools/plutotv_bunny.py + - name: Offline PlutoTV playlist security and grouping tests + run: python3 tools/plutotv_bunny.py --self-test + - name: Build PlutoTV playlists from current upstream (no Bunny writes) + run: | + python3 tools/plutotv_bunny.py --dry-run --output "$RUNNER_TEMP/plutotv-source-audit" + python3 - <<'PY' + import os,sys,json,pathlib + root=pathlib.Path(os.environ["RUNNER_TEMP"])/"plutotv-source-audit" + report=json.loads((root/"report.json").read_text()) + assert report["upstream_all_entries"] >= 500 + assert report["upstream_germany_entries"] >= 100 + assert 1 <= report["erotic_fast_channels_germany"] <= 30 + assert report["files"]["plutotv-lite.m3u"]["entries"] <= 240 + for name, info in report["files"].items(): + assert (root/name).read_text().startswith("#EXTM3U") + assert info["entries"] > 0 + assert info["bytes"] <= 2_000_000 + print("PLUTOTV_SOURCE_BUILD_PASS", report["upstream_all_entries"], + "GERMANY", report["upstream_germany_entries"], + "EROTIC_FAST", report["erotic_fast_channels_germany"]) + PY From adc6227565ec7aa54f312bbdb35db0366ae501e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?v=20=E2=82=BF?= Date: Sun, 1 Nov 2026 19:46:23 +0200 Subject: [PATCH 3/4] docs: qist source review and Pluto isolated Bunny upload command --- docs/QIST_PLUTOTV.md | 93 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/QIST_PLUTOTV.md diff --git a/docs/QIST_PLUTOTV.md b/docs/QIST_PLUTOTV.md new file mode 100644 index 0000000000..a5c3bff765 --- /dev/null +++ b/docs/QIST_PLUTOTV.md @@ -0,0 +1,93 @@ +# qist IPTV research and Pluto TV Bunny uploader + +## qist repositories inspected (2026-11-01) + +GitHub public repository search returned 36 qist repositories. Most relevant: + +| Source | Evidence | Assessment | +|---|---|---| +| https://github.com/qist/tvbox/blob/master/list.m3u | 824 EXTINF records; CCTV, regional, Douyu, NewTV | IPTV candidate list, operator/region limitations | +| https://github.com/qist/tvbox/blob/master/tvlive.txt | ~1,453 URL occurrences | Chinese ISP/IPv6 access often required | +| https://github.com/qist/tvbox/blob/master/tvboxtv.txt | ~1,391 URL occurrences | Chinese operator streams, many IPv6 | +| https://github.com/qist/tvbox/blob/master/ITV.txt | ~637 URL occurrences | Candidate-only, not playback-verified | +| https://github.com/qist/tvbox/blob/master/zb.txt | ~2,908 URL occurrences, RTMP and alternatives | Requires format normalization | +| https://github.com/qist/tvbox/blob/master/list.txt | ~202 URLs | Smaller Chinese CCTV set | +| https://github.com/qist/tvbox/blob/master/livex.m3u | 14,651 EXTINF; https://vod.mycamtv.net/1.m3u8 up to /14872.m3u8 | EXCLUDE: numbered cam/VOD content, not channels | +| https://github.com/qist/tvbox/blob/master/cat/tjs/18_tv_config.json | 18+ site crawlers | Not stable linear broadcasts | +| https://github.com/qist/livetv | Uses YouTube Live as IPTV; requires runtime | Software, not content list | +| https://github.com/qist/iptv-static-scan | HTTP/HLS scanner | Use only on authorized targets | +| https://github.com/qist/tvgate | IPTV gateway/player | Software, not a channel source | +| https://github.com/qist/TVLogo | Logo library | No live video | + +Finding a public source link does not establish licensing, real playback, +video quality or geographic accessibility from a Ukrainian Samsung TV. + +## Additional public IPTV/FAST directories + +- https://github.com/iptv-org/iptv +- https://github.com/BuddyChewChew/app-m3u-generator +- https://github.com/BuddyChewChew/pluto +- https://github.com/matthuisman/i.mjh.nz +- https://github.com/OwnerPlugins/pluto-tv-m3u + +## Pluto TV: isolated M3U publication + +The uploader tools/plutotv_bunny.py reads Pluto TV ALL and Germany +M3U playlists from BuddyChewChew/app-m3u-generator. It preserves channel +names and tvg-logo links and emits six separate M3Us: + +- plutotv.m3u: all-region, approximately 3016 entries; too big for Samsung. +- plutotv-lite.m3u: at most 240 entries balanced across countries, no huge EPG. +- plutotv-de.m3u: approximately 200 German Pluto channels. +- plutotv-erotic.m3u: German Sinnliche Fantasien category, about 8 channels. +- plutotv-us.m3u: US regional subset. +- plutotv-gb.m3u: UK regional subset. + +All sources presently use the third-party jmp2.uk HLS transport, NOT +official Pluto direct URLs. Geo-blocks, interruptions and provider terms +may apply. These playlists are FREE/FAST, not an equivalent to premium +Brazzers, Penthouse, Hustler or Dorcel subscriptions. + +The script uploads ONLY these files under /iptv/ to the pre-existing +Bunny Storage + Pull Zone from local .bunny-iptv-state.json; no new +paid resources and no overwrites of adult.m3u, adulttest.m3u or indexes. + +### One Terminal command for macOS + +Run from the original $HOME/iptv-bunny directory: + + ( + set -euo pipefail + cd "$HOME/iptv-bunny" + for file in personal_bunny_playlists.py free_hls_filter.py \ + sync_bunny_countries.py build_lampa_lite.py lampa_category_fix.py \ + auto_bunny_setup.py plutotv_bunny.py + do + curl -fsSL --retry 3 \ + "https://raw.githubusercontent.com/vbrekher/iptv/master/tools/$file" \ + -o "$file" + done + python3 plutotv_bunny.py --self-test + python3 plutotv_bunny.py + ) + +The script prompts for Bunny Account API Key using hidden input. +Do not share the key. Successful publication prints +PLUTOTV_UPLOAD_COMPLETE plus CDN_CHECK. + +Expected CDN paths after successful upload: + + https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv.m3u + https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv-lite.m3u + https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv-de.m3u + https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv-erotic.m3u + https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv-us.m3u + https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv-gb.m3u + +Dry-run: python3 plutotv_bunny.py --dry-run +Upload resume: python3 plutotv_bunny.py --publish-only + +Only playlist TEXT is hosted on Bunny; video streams remain with their +third-party providers. The stream contents are not individually +playback-verified. Do not import the giant ALL playlist into Lampa/Tizen +unless your IPTV plugin can handle large catalogs. From 5465c0e3322b02f03b46cad43e72d45a9726d030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?v=20=E2=82=BF?= Date: Sun, 1 Nov 2026 19:47:43 +0200 Subject: [PATCH 4/4] fix: map Pluto DE upstream blank group-title to General without losing a channel --- tools/plutotv_bunny.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/plutotv_bunny.py b/tools/plutotv_bunny.py index 786797a42d..051c48d654 100644 --- a/tools/plutotv_bunny.py +++ b/tools/plutotv_bunny.py @@ -67,8 +67,12 @@ def extract(text: str, min_count: int, max_count: int) -> tuple[str, list[tuple[ if not match or not match.group(1).strip(): raise ValueError("invalid_channel_title") groups = GROUP.findall(line) - if len(groups) != 1 or not groups[0].strip() or ";" in groups[0]: + if len(groups) != 1 or ";" in groups[0]: raise ValueError("invalid_composite_channel_group") + if not groups[0].strip(): + # Pluto DE sometimes leaves the category empty for seasonal shows; + # keep the channel under one usable group instead of rejecting it. + line = line.replace('group-title=""', 'group-title="General"', 1) logos = LOGO.findall(line) if len(logos) > 1 or any(not urlsplit(u).scheme == "https" for u in logos if u): raise ValueError("invalid_logo") @@ -261,6 +265,9 @@ def row(i, group="United States"): assert text.startswith("#EXTM3U") assert all(";" not in GROUP.search(meta).group(1) for meta, _ in m3u.parse_m3u(text)) # Rejection must fail closed. + empty_group = de_src.replace('group-title="News"', 'group-title=""', 1) + _, rows = extract(empty_group, 100, 400) + assert any('group-title="General"' in meta for meta, _ in rows) for edited in ( all_src.replace("https://jmp2.uk/", "https://example.com/", 1), all_src.replace("https://jmp2.uk/", "http://jmp2.uk/", 1),