diff --git a/.github/workflows/personal-bunny-playlists.yml b/.github/workflows/personal-bunny-playlists.yml index 8f5d5bc475..342164449e 100644 --- a/.github/workflows/personal-bunny-playlists.yml +++ b/.github/workflows/personal-bunny-playlists.yml @@ -9,7 +9,6 @@ on: - 'tools/sync_bunny_countries.py' - 'tools/build_lampa_lite.py' - 'tools/lampa_category_fix.py' - - 'tools/adulttest.py' - 'tools/plutotv_bunny.py' - 'tools/publish_public_bunny.py' - 'tools/verify_public_hub.py' @@ -134,40 +133,6 @@ jobs: print("FULL_LAMPA_GROUPS_PASS", len(full), len(counts)) PY - - name: Compile Adulttest multi-source tester - run: python3 -m py_compile tools/adulttest.py - - name: Adulttest M3U normalization and credential safety tests - run: python3 tools/adulttest.py --self-test - - name: Check Adulttest source inventory against research - run: | - python3 - <<'PY' - import sys - sys.path.insert(0, 'tools') - import adulttest - assert len(adulttest.SOURCES) == 16 - assert len({spec[0] for spec in adulttest.SOURCES}) == 16 - assert len([s for s in adulttest.SOURCES if s[-1] == 'metadata']) == 1 - for s in adulttest.SOURCES: - assert adulttest.source_url(s).startswith('https://raw.githubusercontent.com/') - print('ADULTTEST_SOURCE_INVENTORY_PASS', len(adulttest.SOURCES)) - PY - - - name: Collect Adulttest source metadata (no stream probes, no upload) - run: | - python3 - <<'PY' - import collections,sys - sys.path.insert(0,'tools') - import adulttest - results, rejected, sources = adulttest.collect() - print('ADULTTEST_PUBLIC_CANDIDATES_FOUND', len(results), - 'SOURCES', len(sources), 'EARLY_REJECTIONS', sum(rejected.values())) - assert len(sources) == 16 - assert len(results) >= 30 - assert all(adulttest.safe_stream_url(candidate.url)[0] for candidate in results) - 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 @@ -194,7 +159,7 @@ jobs: - name: Compile public catalog tools run: python3 -m py_compile tools/publish_public_bunny.py tools/verify_public_hub.py - - name: Validate 238 public M3U links and evidence status + - name: Validate 237 public M3U links and evidence status run: python3 tools/verify_public_hub.py - name: Offline safe country fallback test (no Bunny, no network) run: python3 tools/publish_public_bunny.py --self-test diff --git a/README.md b/README.md index 289595ae34..3868293f4c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![GitHub CI](https://github.com/vbrekher/iptv/actions/workflows/personal-bunny-playlists.yml/badge.svg)](https://github.com/vbrekher/iptv/actions/workflows/personal-bunny-playlists.yml) [![License: CC0](https://img.shields.io/badge/License-CC0-5c6bc0.svg)](LICENSE) [![Fork: IPTV-org](https://img.shields.io/badge/Based%20on-IPTV--org-2962ff.svg)](https://github.com/iptv-org/iptv) -[![Public M3U Directory](https://img.shields.io/badge/Catalog-238%20M3U%20links-2da8a6.svg)](docs/BUNNY_PLAYLISTS.md) +[![Public M3U Directory](https://img.shields.io/badge/Catalog-237%20M3U%20links-2da8a6.svg)](docs/BUNNY_PLAYLISTS.md) [**📚 ПОЛНЫЙ КАТАЛОГ**](docs/BUNNY_PLAYLISTS.md) · [**🚀 БЫСТРЫЙ СТАРТ**](#-начните-отсюда) · [**🌍 СТРАНЫ**](#-188-стран-с-флагами) · [**📖 ИЗМЕНЕНИЯ**](docs/FORK_CHANGELOG.md) · [**🛠 ПУБЛИКАЦИЯ**](docs/PUBLIC_RELEASE.md) @@ -21,12 +21,12 @@ ## ✨ Что здесь особенного -| 🌍 Каталог | 📺 Samsung / Lampa | 🔞 Adulttest | 🪐 Pluto TV | +| 🌍 Каталог | 📺 Samsung / Lampa | 🔞 Adult / XXX | 🪐 Pluto TV | |---|---|---|---| -| 188 стран с флагами, 34 категории | Лёгкие варианты на 240/600 записей | Отдельный тестированный HLS-каталог | Общее, региональное и Erotic FAST | -| Прямые ссылки на Bunny CDN | Исправленные группы без случайных комбинаций | Отдельный список, не смешанный с Pluto | Источники и логотипы в M3U | +| 188 стран с флагами, 34 категории | Лёгкие варианты на 240/600 записей | Отдельные 18+ разделы | Общее, региональное и Erotic FAST | +| Прямые ссылки на Bunny CDN | Исправленные группы без случайных комбинаций | С предупреждениями о доступности и рекламе | Источники и логотипы в M3U | -**238 описанных URL** = 188 стран + 34 категории + 16 основных/специальных списков. Это количество ссылок в каталоге, **не количество проверенных доступных CDN-объектов или телеканалов**. +**237 описанных URL** = 188 стран + 34 категории + 15 основных/специальных списков. Это количество ссылок в каталоге, **не количество проверенных доступных CDN-объектов или телеканалов**. ## 🚀 Начните отсюда @@ -39,7 +39,6 @@ | 🇺🇦 **Украина** | [countries/ua.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/countries/ua.m3u) | Украинский телекаталог | ☑️ Был загружен | | 🎭 **Все категории** | [index.category.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/index.category.m3u) | Большой исторический список | ☑️ Был загружен | | 🌐 **Общий IPTV** | [index.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/index.m3u) | Все каналы (большой файл) | ✅ CDN ранее VERIFIED | -| 🔞 **Adulttest** | [adulttest.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adulttest.m3u) | HLS-кандидаты 18+ | ✅ CDN по отчёту пользователя | **Исправленный полный каталог:** [index.clean.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/index.clean.m3u) · **Общий по странам:** [index.country.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/index.country.m3u) · **Lampa Clean 600:** [lampa-clean.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/lampa-clean.m3u) @@ -108,7 +107,6 @@ | Источник | M3U | Статус | |---|---|---| -| Adulttest · 141 технически открытый HLS по пользовательскому отчёту | [adulttest.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adulttest.m3u) | ✅ CDN подтверждён отчётом | | Исторический список · 46 анонимных HLS | [adult.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adult.m3u) | ☑️ Был загружен | | Старый категорийный каталог | [categories/xxx.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/categories/xxx.m3u) | ☑️ Был загружен | | Немецкие Pluto Erotic FAST (не премиальное хардкор-ТВ) | [plutotv-erotic.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/plutotv-erotic.m3u) | ○ Публикация не подтверждена | @@ -135,7 +133,7 @@ python3 publish_public_bunny.py # публикация отсутс Изменения с PR [#1](https://github.com/vbrekher/iptv/pull/1) по [#9](https://github.com/vbrekher/iptv/pull/9) оформлены в [полной истории разработки](docs/FORK_CHANGELOG.md), включая скрипты, исправления ошибок, релизные границы и реальные результаты проверок. -**Документация:** [Каталог M3U](docs/BUNNY_PLAYLISTS.md) · [Развёртывание Bunny](docs/BUNNY_ONE_CLICK.md) · [Adulttest](docs/ADULTTEST.md) · [Pluto и qist](docs/QIST_PLUTOTV.md) · [Обновление публикаций](docs/PUBLIC_RELEASE.md) · [Данные каталога JSON](catalog/bunny-playlists.json) +**Документация:** [Каталог M3U](docs/BUNNY_PLAYLISTS.md) · [Развёртывание Bunny](docs/BUNNY_ONE_CLICK.md) · [Pluto и qist](docs/QIST_PLUTOTV.md) · [Обновление публикаций](docs/PUBLIC_RELEASE.md) · [Данные каталога JSON](catalog/bunny-playlists.json) --- diff --git a/catalog/bunny-playlists.json b/catalog/bunny-playlists.json index 83c24ab1b0..77f0badddb 100644 --- a/catalog/bunny-playlists.json +++ b/catalog/bunny-playlists.json @@ -11,8 +11,8 @@ "totals": { "countries": 188, "categories": 34, - "featured": 16, - "links": 238 + "featured": 15, + "links": 237 }, "items": [ { @@ -60,15 +60,6 @@ "title": "Adult / XXX (исходный)", "description": "46 ранее технически открытых HLS; много рекламных/платных заглушек" }, - { - "id": "adulttest-m3u", - "path": "adulttest.m3u", - "url": "https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adulttest.m3u", - "status": "user_report_cdn_verified", - "group": "adult", - "title": "Adulttest / 18+ (тестовый)", - "description": "141 анонимный HLS прошёл тест; качество не подтверждено" - }, { "id": "lampa-lite-m3u", "path": "lampa-lite.m3u", diff --git a/docs/ADULTTEST.md b/docs/ADULTTEST.md deleted file mode 100644 index ccdb0bbb6d..0000000000 --- a/docs/ADULTTEST.md +++ /dev/null @@ -1,90 +0,0 @@ -# Adulttest — isolated adult IPTV audit and Bunny M3U publication - -Separate pipeline. Never overwrites the existing adult playlist, categories, country playlists or combined index. Publishes only /iptv/adulttest.m3u. - -## One-paste command for existing Mac and Bunny zones - -Run inside the existing $HOME/iptv-bunny directory so the local nonsecret state identifies the right Storage/Pull zones. - - ( - 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 \ - adulttest.py - do - curl -fsSL --retry 3 \ - "https://raw.githubusercontent.com/vbrekher/iptv/master/tools/$file" \ - -o "$file" - done - python3 adulttest.py --self-test - python3 adulttest.py - ) - -If uploading to Bunny fails after the tests succeeded, **do not rescan** -all sources and channels. Resume the saved local playlist with: - - cd "$HOME/iptv-bunny" - python3 adulttest.py --publish-only - -This mode verifies the local M3U entry count against the saved report, -reuses existing Storage/Pull zones, and only retries the upload. - -For local test only, replace the last line with: - - python3 adulttest.py --dry-run - -The code inventories the 16 research source identities (15 public files plus -one IPTV NSFW blocklist). Some are exact mirrors or archives; 16 does not mean -16 unique sources of current working video. - -## What it produces - -- dist/adulttest/adulttest-candidates.m3u: all unique safe URL candidates, LOCAL ONLY. -- dist/adulttest/adulttest-results.csv: every probe result, source(s), advertised - quality and HLS master resolution when available, LOCAL ONLY. -- dist/adulttest/adulttest-report.json: counts and rejection reasons, LOCAL ONLY. -- dist/adulttest/adulttest.m3u: only successful anonymous HLS candidate links, - the one file uploaded to /iptv/adulttest.m3u. - -The playlist includes a human-readable name, source-provided tvg-logo -or an official IPTV-org logos.json match for a known tvg-id (never invented), -tvg-id where provided and a SINGLE group-title, either Adult TV or Erotic FAST. Broken, private, obvious subscription-token, cam -advertising, embedded user/password, teen-labelled and VOD-only candidates -are excluded. No paid-service bypass, DRM circumvention, custom stream -headers, or reverse proxy is provided. - -Every accepted URL must return an anonymous HLS manifest and a tiny sample -of a media segment. HTTP status by itself does not establish quality, -rights or Tizen compatibility. Labels HD/FHD/4K in source titles -are NOT proof of resolution, and even an actual HLS master resolution -does not show whether on-screen webcam advertising is present. - -Only after the tests, when one or more streams passed, the CLI prompts -for Bunny Account API Key with hidden input. It retrieves the Storage Zone -write password from the Bunny management API and reuses existing zones, -never creates new billable zones, and uploads ONLY adulttest.m3u. - -After successful publication the expected endpoint is: - - https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adulttest.m3u - -Remove any previous Adulttest entry from Lampa and reimport the URL through -the direct IPTV importer, not via a CUB cloud upload feature. - -### Limitations - -- Anonymous HTTP readability does not prove content is licensed or legally - redistributable. Verify provenance/permission before public promotion. -- No account key is saved; original candidate and rejected URL lists stay - on the Mac. Do not upload these diagnostics to GitHub. -- Original user-provided personal IPTV provider credentials are not copied - into this script or published. -- Some of the 16 lists may be inaccessible and many URLs may be stale. -- The last step is not yet executed in the user's Bunny account; the owner - performs it from Terminal. GitHub CI only covers offline source checks. diff --git a/docs/BUNNY_PLAYLISTS.md b/docs/BUNNY_PLAYLISTS.md index 6de8701c6b..b77314c076 100644 --- a/docs/BUNNY_PLAYLISTS.md +++ b/docs/BUNNY_PLAYLISTS.md @@ -29,7 +29,6 @@ | Плейлист | Примечание | URL | Статус | |---|---|---|---| | Adult / XXX (исходный) | 46 ранее технически открытых HLS; много рекламных/платных заглушек | [adult.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adult.m3u) | ☑️ Загружен по логу | -| Adulttest / 18+ (тестовый) | 141 анонимный HLS прошёл тест; качество не подтверждено | [adulttest.m3u](https://lampa-iptv-875323ab-cdn.b-cdn.net/iptv/adulttest.m3u) | ✅ CDN подтверждён отчётом | ## 🪐 Pluto TV · отдельные списки diff --git a/docs/FORK_CHANGELOG.md b/docs/FORK_CHANGELOG.md index 13a3965bc1..6a292e3b5c 100644 --- a/docs/FORK_CHANGELOG.md +++ b/docs/FORK_CHANGELOG.md @@ -15,19 +15,19 @@ | [#5 · All Countries](https://github.com/vbrekher/iptv/pull/5) | Страновые M3U из официального списка IPTV-org, grouped index, countries-only | | [#6 · Samsung Tizen Lite](https://github.com/vbrekher/iptv/pull/6) | Облегчённые M3U на 240 и 600 записей | | [#7 · Clean Groups](https://github.com/vbrekher/iptv/pull/7) | Одна нормальная категория на телеканал, устранение комбинаций Family;Series и др. | -| [#8 · Adulttest](https://github.com/vbrekher/iptv/pull/8) | Исследование 16 источников, фильтрация, логотипы, отдельный тестовый adulttest.m3u | +| [#8 · Архивный эксперимент](https://github.com/vbrekher/iptv/pull/8) | Экспериментальный тестер потоков позднее исключён из публичной версии | | [#9 · Pluto TV & qist](https://github.com/vbrekher/iptv/pull/9) | Pluto all/DE/US/GB/lite/erotic FAST, изучение qist/tvbox | ## Public Playlist Hub -- [238 прямых ссылок M3U](BUNNY_PLAYLISTS.md): 16 основных и специальных плейлистов, 34 категории, 188 стран с флагами. +- [237 прямых ссылок M3U](BUNNY_PLAYLISTS.md): 15 основных и специальных плейлистов, 34 категории, 188 стран с флагами. - [Машиночитаемый JSON-каталог](../catalog/bunny-playlists.json): URL, описание, тип и статус каждой ссылки. -- [Один запуск публикации в существующий Bunny](PUBLIC_RELEASE.md): один скрытый ввод ключа, без пересоздания зон, без повторной проверки adulttest. +- [Один запуск публикации в существующий Bunny](PUBLIC_RELEASE.md): один скрытый ввод ключа, без пересоздания зон, без повторной проверки потоков. - Новая главная страница GitHub и проверка каталога в CI. ## Что реально подтверждено -В предыдущем терминальном отчёте пользователя опубликованы 39 M3U/каталожных файлов: большой index.m3u, adult.m3u, countries/ua.m3u, отдельные категории. CDN index отмечен VERIFIED. В отдельном пользовательском отчёте Adulttest — 682 URL-кандидата, 141 технически доступный HLS, 541 отклонён, adulttest_uploaded=true и cdn_verified=true. +В предыдущем терминальном отчёте пользователя опубликованы 39 M3U/каталожных файлов: большой index.m3u, adult.m3u, countries/ua.m3u, отдельные категории. CDN index отмечен VERIFIED. GitHub CI построил Pluto ALL 3016 записей, Germany 200, Erotic FAST 8, Lite 240, US 433 и GB 183. **Это сборка данных в CI, не доказательство загрузки Pluto на Bunny или воспроизведения каналов на Samsung.** @@ -37,8 +37,12 @@ GitHub CI построил Pluto ALL 3016 записей, Germany 200, Erotic FA ## Техническая архитектура -Оригинальный IPTV-org остаётся базой. Наши скрипты расположены в папке [tools/](../tools/): personal_bunny_playlists.py, free_hls_filter.py, auto_bunny_setup.py, sync_bunny_countries.py, build_lampa_lite.py, lampa_category_fix.py, adulttest.py, plutotv_bunny.py, publish_public_bunny.py. +Оригинальный IPTV-org остаётся базой. Наши скрипты расположены в папке [tools/](../tools/): personal_bunny_playlists.py, free_hls_filter.py, auto_bunny_setup.py, sync_bunny_countries.py, build_lampa_lite.py, lampa_category_fix.py, plutotv_bunny.py, publish_public_bunny.py. Bunny Storage и CDN хранят/выдают **только M3U и текстовые отчёты**. HLS-видео идёт от третьих сторон. Наличие ссылки не снимает лицензионные, DRM или географические ограничения. Исходная лицензия [CC0](../LICENSE) сохраняется. На логотипы, товарные знаки и вещание сторонних каналов могут распространяться отдельные права. + +## Удаление тестового 18+ каталога + +Тестовый список и его генератор исключены из текущего дерева форка по запросу владельца. Исторические PR GitHub сохраняются как история разработки. Удаление ранее загруженного объекта из Bunny Storage и очистка CDN выполняются отдельно в аккаунте владельца. diff --git a/docs/PUBLIC_RELEASE.md b/docs/PUBLIC_RELEASE.md index 13d0f4027b..15a4c9ea6b 100644 --- a/docs/PUBLIC_RELEASE.md +++ b/docs/PUBLIC_RELEASE.md @@ -43,7 +43,7 @@ GitHub README содержит открытые M3U-адреса, но **сам 2. Генерация шести Pluto TV M3U из публичных каталогов BuddyChewChew. 3. Если на Mac есть ранее сохранённые index.m3u и adult.m3u, создание исправленных Tizen/Category-файлов. 4. Проверка **существующих** Storage/Pull Zone и скрытый ввод одного Account API Key. -5. Загрузка только недостающих типов файлов: countries, Pluto, новые clean-плейлисты. **Не трогает adulttest.m3u, adult.m3u, прежние категории и исходный index.m3u.** +5. Загрузка только недостающих типов файлов: countries, Pluto, новые clean-плейлисты. **Не трогает adult.m3u, прежние категории и исходный index.m3u.** 6. Отчёт на Mac: dist/public-hub/publication-report.json с опубликованными именами и результатом CDN_CHECK. ## Другие режимы diff --git a/docs/QIST_PLUTOTV.md b/docs/QIST_PLUTOTV.md index a5c3bff765..0dcc494cce 100644 --- a/docs/QIST_PLUTOTV.md +++ b/docs/QIST_PLUTOTV.md @@ -50,7 +50,7 @@ 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. +paid resources and no overwrites of adult.m3u or indexes. ### One Terminal command for macOS diff --git a/tools/adulttest.py b/tools/adulttest.py deleted file mode 100644 index e8da425db9..0000000000 --- a/tools/adulttest.py +++ /dev/null @@ -1,553 +0,0 @@ -#!/usr/bin/env python3 -"""Build, test and publish a separate adulttest.m3u on an existing Bunny account. - -Python 3.9+, stdlib. Never downloads or stores video files. Does not use -subscription credentials, custom headers, DRM, proxying, or provider tokens. -The legal right to redistribute a stream is NOT established by HTTP 200. -""" -from __future__ import annotations - -import argparse -import csv -import getpass -import io -import json -import re -import sys -import tempfile -from collections import Counter -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass, field -from pathlib import Path -from urllib.error import HTTPError, URLError -from urllib.parse import quote, unquote, urlsplit -from urllib.request import Request, urlopen - -import auto_bunny_setup as bunny -import free_hls_filter as hls -import personal_bunny_playlists as iptv - -# Based on the 16-source November research. Some entries in that research -# referred to a mirror or metadata, not an additional source of streams. -# Inputs are public GitHub texts, not personal subscription playlists. -SOURCES = ( - ("NiceZoneCoder", "NiceZoneCoder/NiceZoneCoder.github.io", "main", "live/m3u/xxx/xxx_4.m3u", "m3u"), - ("karlovitch", "karlovitch/iptv", "main", "NSFW", "m3u"), - ("Keglr", "Keglr/IPTV_V6", "main", "Adult.m3u", "m3u"), - ("SICOPOLY", "SICOPOLY/json2m3u", "main", "tv.json", "json"), - ("ChinaIPTV", "hujingguang/ChinaIPTV", "main", "xxx.m3u8", "m3u"), - ("wuvip", "wuvip/tv", "main", "xxx.m3u", "m3u"), - ("cylim76", "cylim76/install", "main", "iptv/xxx.m3u", "m3u"), - ("CuanDaily", "CuanDaily/Zabiya-Id", "main", "xxx.m3u", "m3u"), - ("THEMMMINAT", "THEMMMINAT/xxx-IPTV", "main", "XXX.m3u", "m3u"), - ("ql-fa NL", "ql-fa/A", "master", "streams/nl.m3u", "m3u"), - ("IPTV 2024 XXX", "iptv-org/iptv", "a1f14e961505010b0ef13bcd2a535f658ed51ac1", "categories/xxx.m3u", "m3u"), - ("IPTV 2024 NSFW", "iptv-org/iptv", "a1f14e961505010b0ef13bcd2a535f658ed51ac1", "index.nsfw.m3u", "m3u"), - ("Pluto TV Germany", "BuddyChewChew/app-m3u-generator", "main", "playlists/plutotv_de.m3u", "m3u"), - ("Asia 18+ TXT", "lizhaohai369/fork-iptv-m3u8", "main", "18+.txt", "txt"), - ("hipy Adult", "woshishiq1/hipy-drpy2", "main", "aa999ok/CK/18015/lib/Adult.m3u", "m3u"), - ("IPTV NSFW blocklist (names only)", "iptv-org/database", "master", "data/blocklist.csv", "metadata"), -) - -ADULT_WORDS = re.compile( - r"(?i)brazzers|penthouse|playboy|hustler|dorcel|redlight|private.?tv|" - r"vivid|vixen|xxl|erox|eroxxx|exxxotica|dusk|evil angel|reality kings|" - r"digital playground|digital desire|naughty america|blue hustler|" - r"extasy|eromania|venus|sexy hot|erotic|beate uhse|hot tv|" - r"barely legal tv|pornstar|fap tv|french lover|xxx|adult|sex|" - r"red shoe diaries|gigolos|liebe sünde|heisse|heiße|emmanuelle|" - r"sanfte berührungen|stimulierende dokus|sinnliche fantasien|" - r"lust|s66cams|spice|sct|colmax|hotxxl|cineman|blue movie|" - r"passie|jacquie michel|dancing bear|pink o tv|leotv|babes tv" -) -CAM_WORDS = re.compile(r"(?i)adultiptv|mycamtv|live.?cams|web.?cam|cam4|camsoda|chaturbate|stripchat") -VOD_WORDS = re.compile(r"(?i)\.(?:mp4|mkv|avi|wmv)(?:[?#]|$)|/movie/|/series/|/vod/") -TEEN_WORDS = re.compile(r"(?i)\bteen\b|\bteens\b") -# Plain credentials, embedded tokens and expiration signatures are intentionally -# excluded rather than tested. Token names are only checked, never logged. -BLOCKED_PARAMETERS = hls.BLOCKED_PARAMS | { - "username", "pwd", "passw", "secret", "user", "client_id", - "client_secret", "st", "e", "hash", "verify", "play_token" -} -META_RE = re.compile(r'([\w-]+)="([^"]*)"') -MAX_SOURCE_BYTES = 3_000_000 -MAX_CANDIDATES = 2500 - - -@dataclass -class Candidate: - name: str - url: str - logo: str = "" - tvg_id: str = "" - category: str = "Adult TV" - sources: set[str] = field(default_factory=set) - quality_hint: str = "unknown" - - -def source_url(spec) -> str: - _, repo, ref, path, _ = spec - owner, name = repo.split("/") - return "https://raw.githubusercontent.com/" + owner + "/" + name + "/" + ref + "/" + quote(path, safe="/") - - -def fetch_text(spec) -> str: - url = source_url(spec) - req = Request(url, headers={"User-Agent": "Lampa-adulttest-research/1.0"}) - with urlopen(req, timeout=30) as response: - data = response.read(MAX_SOURCE_BYTES + 1) - if len(data) > MAX_SOURCE_BYTES: - raise ValueError("source_exceeds_size_limit") - return data.decode("utf-8-sig", errors="replace") - - -def source_rows(text: str, kind: str) -> list[tuple[str, str, str, str, str]]: - """Return (title, stream_url, logo, tvg_id, group).""" - output = [] - if kind == "m3u": - pending = None - for raw in text.splitlines(): - line = raw.strip() - if line.startswith("#EXTINF:"): - attrs = dict(META_RE.findall(line.split(",", 1)[0])) - title = line.split(",", 1)[-1].strip() - pending = ( - title[:150], "", attrs.get("tvg-logo", ""), - attrs.get("tvg-id", ""), attrs.get("group-title", ""), - ) - elif pending is not None and line.startswith(("https://", "http://")): - output.append((pending[0], line, pending[2], pending[3], pending[4])) - pending = None - elif pending is not None and line and not line.startswith("#"): - pending = None - elif kind == "json": - try: - doc = json.loads(text) - except (ValueError, TypeError): - return [] - def walk(obj, depth=0): - if depth > 12: - return - if isinstance(obj, list): - for item in obj: - walk(item, depth + 1) - elif isinstance(obj, dict): - title = obj.get("name") or obj.get("title") - link = obj.get("url") or obj.get("stream") - if isinstance(title, str) and isinstance(link, str) and link.startswith(("http://", "https://")): - logo = obj.get("logo") or obj.get("image") or "" - output.append((title[:150], link, logo if isinstance(logo, str) else "", str(obj.get("id") or ""), "")) - for child in obj.values(): - if isinstance(child, (dict, list)): - walk(child, depth + 1) - walk(doc) - elif kind == "txt": - for line in text.splitlines(): - if "," not in line or line.startswith(("#", "http")): - continue - title, link = line.split(",", 1) - if link.strip().startswith(("http://", "https://")): - output.append((title.strip()[:150], link.strip(), "", "", "")) - return output - - -def relevant(name: str, tvg_id: str, group: str, source_name: str, nsfw_ids: set[str]) -> bool: - if source_name == "Pluto TV Germany": - return "Sinnliche Fantasien" in group - if source_name in ("IPTV 2024 NSFW", "ql-fa NL", "SICOPOLY"): - return tvg_id in nsfw_ids or bool(ADULT_WORDS.search(name + " " + group)) - if source_name == "karlovitch": - # Large file has hundreds of VOD episode/movie titles after its live - # channel section. Only source-labelled (18+) entries are candidates. - return "(18+)" in name - # Remaining inputs are dedicated adult M3U files, but cam names, unsafe - # URLs and VOD media are still rejected by independent rules. - return True - - -def safe_stream_url(url: str) -> tuple[bool, str]: - try: - part = urlsplit(url) - if part.scheme not in ("http", "https") or not part.hostname: - return False, "invalid_url" - if part.username or part.password: - return False, "embedded_credentials" - if part.fragment: - return False, "fragment" - if part.hostname in ("localhost", "0.0.0.0"): - return False, "private_url" - if part.hostname.endswith((".local", ".internal", ".localhost")): - return False, "private_url" - for key, value in __import__("urllib.parse", fromlist=["parse_qsl"]).parse_qsl(part.query, keep_blank_values=True): - if key.lower() in BLOCKED_PARAMETERS: - return False, "signed_or_subscription_query" - if re.search(r"/(?:live|iptv|get)\b/[^/?]{5,}/[^/?]{5,}/", part.path, re.IGNORECASE): - return False, "subscription_path" - if re.search(r"(?i)/(?:playlist|player)_api\.php|/get\.php|/xmltv\.php", part.path): - return False, "subscription_api" - if VOD_WORDS.search(url): - return False, "vod_not_linear_tv" - return True, "candidate" - except ValueError: - return False, "malformed_url" - - -def choose_category(source: str, name: str) -> str: - if source == "Pluto TV Germany": - return "Erotic FAST" - return "Adult TV" - - -def safe_logo(url: str) -> str: - try: - part = urlsplit(url) - if part.scheme != "https" or not part.hostname or part.username or part.password: - return "" - if any(k.lower() in BLOCKED_PARAMETERS for k, _ in - __import__("urllib.parse", fromlist=["parse_qsl"]).parse_qsl(part.query)): - return "" - return url[:800] - except (ValueError, TypeError): - return "" - - -def hint(name: str) -> str: - m = re.search(r"(?i)\b(?:2160|1080|720|576|480)p\b|\b(?:4K|UHD|FHD|HD)\b", name) - return m.group(0) if m else "unknown" - - -def sanitize(text: str, limit: int = 140) -> str: - return text.replace('"', "'").replace("\r", " ").replace("\n", " ").replace("\\", "").strip()[:limit] - - -def collect() -> tuple[list[Candidate], dict, list[dict]]: - nsfw_ids: set[str] = set() - results: list[dict] = [] - seen: dict[str, Candidate] = {} - skipped = Counter() - block = SOURCES[-1] - try: - text = fetch_text(block) - nsfw_ids = {row["channel"] for row in csv.DictReader(io.StringIO(text)) - if row.get("reason") == "nsfw" and row.get("channel")} - results.append({"source": block[0], "kind": "metadata", "ids": len(nsfw_ids), "status": "ok"}) - except Exception as exc: - results.append({"source": block[0], "kind": "metadata", "status": "error_" + type(exc).__name__}) - if not nsfw_ids: - raise RuntimeError("NSFW blocklist unavailable; refusing poorly classified mixed sources") - for spec in SOURCES[:-1]: - label, _, _, _, kind = spec - try: - raw = fetch_text(spec) - rows = source_rows(raw, kind) - before = len(seen) - for name, url, logo, tvg_id, group in rows: - name = sanitize(name) - if not relevant(name, tvg_id, group, label, nsfw_ids): - skipped["not_adult"] += 1 - continue - if CAM_WORDS.search(name + " " + group): - skipped["cam_source"] += 1 - continue - if TEEN_WORDS.search(name): - skipped["teen_label_excluded"] += 1 - continue - valid, reason = safe_stream_url(url) - if not valid: - skipped[reason] += 1 - continue - if not name: - skipped["missing_title"] += 1 - continue - if url in seen: - seen[url].sources.add(label) - if not seen[url].logo: - seen[url].logo = safe_logo(logo) - continue - if len(seen) >= MAX_CANDIDATES: - raise ValueError("candidate_limit_exceeded") - seen[url] = Candidate( - name=name, url=url, logo=safe_logo(logo), - tvg_id=sanitize(tvg_id, 90), - category=choose_category(label, name), - sources={label}, quality_hint=hint(name) - ) - results.append({"source": label, "kind": kind, "rows": len(rows), - "new_unique_candidates": len(seen) - before, "status": "ok"}) - print(f"Source {label}: {len(rows)} entries, +{len(seen)-before} unique candidates") - except (HTTPError, URLError, OSError, UnicodeError, ValueError) as exc: - results.append({"source": label, "kind": kind, "status": "error_" + type(exc).__name__}) - print(f"Source {label}: {type(exc).__name__} (skipped)") - return list(seen.values()), dict(skipped), results - - - -def official_logo_fallback(candidates: list[Candidate]) -> int: - """Enrich tvg-id matches from the upstream logo registry, no guesswork. - - This is best-effort: a missing/unavailable logo never affects the stream - eligibility or creates a fictitious image reference. - """ - needed = {c.tvg_id for c in candidates if c.tvg_id and not c.logo} - if not needed: - return 0 - request = Request("https://iptv-org.github.io/api/logos.json", - headers={"User-Agent": "Lampa-adulttest-research/1.0"}) - try: - with urlopen(request, timeout=35) as response: - raw = response.read(10_000_001) - if len(raw) > 10_000_000: - return 0 - data = json.loads(raw.decode("utf-8")) - if not isinstance(data, list): - return 0 - except (HTTPError, URLError, OSError, TimeoutError, ValueError, UnicodeError): - return 0 - lookup: dict[str, tuple[int, str]] = {} - for record in data: - if not isinstance(record, dict) or record.get("channel") not in needed: - continue - url = record.get("url") - if not isinstance(url, str): - continue - clean = safe_logo(url) - if not clean: - continue - # Prefer in-use, stable raster images for legacy Samsung. - fmt = str(record.get("format") or "").upper() - score = (2 if record.get("in_use") else 0) + (1 if fmt in ("PNG", "JPEG", "WEBP") else 0) - key = record["channel"] - if key not in lookup or score > lookup[key][0]: - lookup[key] = (score, clean) - total = 0 - for candidate in candidates: - match = lookup.get(candidate.tvg_id) - if not candidate.logo and match: - candidate.logo = match[1] - total += 1 - return total - - -def write_m3u(items: list[Candidate], target: Path): - lines = ["#EXTM3U"] - for item in items: - logo = ' tvg-logo="' + sanitize(item.logo, 800) + '"' if item.logo else "" - tvg_id = ' tvg-id="' + sanitize(item.tvg_id, 90) + '"' if item.tvg_id else "" - lines.append('#EXTINF:-1' + tvg_id + logo + - ' group-title="' + item.category + '",' + sanitize(item.name)) - lines.append(item.url) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def probe(item: Candidate, timeout: float) -> tuple[bool, str, str]: - # Limit checks to unauthenticated HLS with readable non-DRM media segments. - # Stream quality and copyright are *not* established by an HTTP response. - if not hls.public_url(item.url): - return False, "unsafe_or_nonpublic_host", "" - ok, reason = hls.check_stream(item.url, timeout) - resolution = "unverified" - if ok: - try: - body, _, _ = hls.get_data(item.url, timeout, 128_000, - "application/vnd.apple.mpegurl, */*") - match = re.search(rb"\bRESOLUTION=(\d{3,4})x(\d{3,4})\b", body) - if match: - resolution = match.group(1).decode() + "x" + match.group(2).decode() - except (ValueError, HTTPError, URLError, OSError): - pass - return ok, reason, resolution - - -def run(args) -> int: - # The cached local state prevents accidental creation of new Bunny zones. - if not bunny.STATE.is_file(): - raise RuntimeError("Existing .bunny-iptv-state.json required: cd $HOME/iptv-bunny") - root = Path(args.output) - root.mkdir(parents=True, exist_ok=True) - if args.publish_only: - return publish_saved(root) - candidates, rejected, sources = collect() - if not candidates: - raise RuntimeError("No safe stream candidates found; no upload") - candidates.sort(key=lambda c: (c.category, c.name.lower(), c.url)) - enriched_logos = official_logo_fallback(candidates) - print("IPTV-org official logo fallback:", enriched_logos) - write_m3u(candidates, root / "adulttest-candidates.m3u") - print(f"UNTESTED CANDIDATES: {len(candidates)}; saved locally (not uploaded)") - good: list[Candidate] = [] - result_rows = [] - tally = Counter() - with ThreadPoolExecutor(max_workers=args.workers) as pool: - future_map = {pool.submit(probe, c, args.timeout): c for c in candidates} - for i, future in enumerate(as_completed(future_map), 1): - c = future_map[future] - try: - success, reason, resolution = future.result() - except Exception as exc: - success, reason, resolution = False, "probe_" + type(exc).__name__, "" - tally[reason] += 1 - if success: - good.append(c) - result_rows.append({ - "name": c.name, "category": c.category, "logo": c.logo, - "url": c.url, "sources": "|".join(sorted(c.sources)), - "status": "open_hls" if success else "rejected", - "reason": reason, "resolution": resolution, - "advertised_quality": c.quality_hint, - }) - print(f"[{i}/{len(candidates)}] {'OPEN' if success else 'FAIL'} " - f"{sanitize(c.name, 72)} ({reason}, {resolution})") - good.sort(key=lambda c: (c.category, c.name.lower(), c.url)) - write_m3u(good, root / "adulttest.m3u") - result_rows.sort(key=lambda r: (r["status"], r["name"].lower(), r["url"])) - with (root / "adulttest-results.csv").open("w", newline="", encoding="utf-8") as fh: - columns = ("name", "category", "logo", "url", "sources", "status", "reason", - "resolution", "advertised_quality") - writer = csv.DictWriter(fh, fieldnames=columns) - writer.writeheader() - writer.writerows(result_rows) - report = { - "source_count_research": len(SOURCES), - "official_logos_enriched": enriched_logos, - "candidates_with_logos": sum(bool(c.logo) for c in candidates), - "sources": sources, - "candidate_urls_deduplicated": len(candidates), - "open_anonymous_hls": len(good), - "rejected": len(candidates)-len(good), - "excluded_before_probe": rejected, - "reasons": dict(tally), - "adulttest_uploaded": False, - "rights_verified": False, - "hd_quality_verified": False, - "samsung_playback_verified": False, - "notes": [ - "Only HLS playlists with a readable small media sample pass.", - "A candidate filename containing HD/4K does not prove actual resolution.", - "Anonymous accessibility does not prove broadcasting rights.", - "Excludes apparent subscription credentials, cam-source playlists, VOD and teen-labelled names.", - "Private source audit and rejected URLs stay on this Mac; never upload them to Bunny.", - ] - } - (root / "adulttest-report.json").write_text( - json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) - if not good: - print("No anonymous HLS candidates passed. No Bunny upload performed.") - return 2 - if args.dry_run: - print("DRY RUN: no Bunny upload. Local:", root.resolve()) - return 0 - - return publish_saved(root) - - -def publish_saved(root: Path) -> int: - """Resume a completed successful probe without repeating source scans.""" - playlist = root / "adulttest.m3u" - report_file = root / "adulttest-report.json" - if not playlist.is_file() or not report_file.is_file(): - raise RuntimeError("Missing completed local Adulttest files. Run the normal test first.") - report = json.loads(report_file.read_text(encoding="utf-8")) - if report.get("open_anonymous_hls", 0) <= 0: - raise RuntimeError("No previously tested accessible streams; refusing to publish an empty M3U") - local_data = playlist.read_bytes() - if not local_data.startswith(b"#EXTM3U") or len(iptv.parse_m3u(local_data.decode("utf-8-sig"))) != report["open_anonymous_hls"]: - raise RuntimeError("Local Adulttest playlist does not match validated report; rerun probe") - if not bunny.STATE.is_file(): - raise RuntimeError("Missing existing .bunny-iptv-state.json; do not recreate Bunny zones") - state = bunny.load_state(bunny.STATE) - key = getpass.getpass("Bunny Account API Key (hidden): ") - api = bunny.BunnyAPI(key) - key = "" - stor_name = state["name"] + "-storage" - pull_name = state["name"] + "-cdn" - if not bunny.named(bunny.list_zones(api, "storagezone"), stor_name): - raise RuntimeError("Existing Bunny Storage Zone not found; refusing to create new resources") - if not bunny.named(bunny.list_zones(api, "pullzone"), pull_name): - raise RuntimeError("Existing Bunny Pull Zone not found; refusing to create new resources") - storage = bunny.storage_zone(api, state) - pull = bunny.pull_zone(api, state, storage["id"]) - with tempfile.TemporaryDirectory(prefix="lampa-adulttest-upload-") as temp: - upload = Path(temp) - (upload / "adulttest.m3u").write_bytes(local_data) - # The only public Bunny object modified is /iptv/adulttest.m3u. - bunny.publish(api, state, storage, pull, upload) - report["adulttest_uploaded"] = True - report["cdn_url"] = "https://" + pull["hostname"] + "/iptv/adulttest.m3u" - # A reachable CDN is different from a successful Storage PUT. - report["cdn_verified"] = False - try: - request = Request(report["cdn_url"] + "?v=" + str(__import__("time").time_ns()), - headers={"User-Agent": "Lampa-adulttest-check/1.0"}) - with urlopen(request, timeout=15) as response: - report["cdn_verified"] = (response.status == 200 and response.read(7) == b"#EXTM3U") - except (HTTPError, URLError, OSError, TimeoutError): - pass - report_file.write_text( - json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) - print("\nADULTTEST_UPLOAD_COMPLETE:", report["cdn_url"]) - print("CDN_CHECK:", "VERIFIED" if report["cdn_verified"] else "NOT_VERIFIED (check propagation)") - print("OPEN:", report["open_anonymous_hls"], "/", report["candidate_urls_deduplicated"]) - print("LOCAL REPORT:", report_file.resolve()) - return 0 - - -def self_test(): - m3u = ('#EXTM3U\n' - '#EXTINF:-1 tvg-id="Channel.us" tvg-logo="https://images.example/logo.png" ' - 'group-title="XXX",Brazzers TV HD\nhttps://example.com/live.m3u8\n' - '#EXTINF:-1 group-title="XXX",MyCamTV Anal\nhttps://example.com/cam.m3u8\n') - rows = source_rows(m3u, "m3u") - assert len(rows) == 2 and rows[0][2] == "https://images.example/logo.png" - assert CAM_WORDS.search(rows[1][0]) - assert relevant("Brazzers TV", "Channel.us", "XXX", "NiceZoneCoder", {"Channel.us"}) - assert not relevant("News", "News.us", "News", "ql-fa NL", set()) - assert not relevant("TeleMadrid", "", "", "SICOPOLY", set()) - assert relevant("Dorcel", "", "", "SICOPOLY", set()) - assert not relevant("M. Dorcel - Episode 1", "", "", "karlovitch", set()) - assert relevant("Dorcel TV FHD (18+) NL", "", "", "karlovitch", set()) - assert relevant("Pluto TV Emmanuelle Filme", "", "Sinnliche Fantasien", "Pluto TV Germany", set()) - assert safe_stream_url("https://example.org/live.m3u8")[0] - assert not safe_stream_url("https://example.org/live?token=secret")[0] - assert not safe_stream_url("https://user:secret@example.org/live.m3u8")[0] - assert not safe_stream_url("http://example.org/live/user1234/pass1234/1.m3u8")[0] - assert not safe_stream_url("https://example.org/movie/32.mp4")[0] - example = Candidate(name="Brazzers TV HD", url="https://example.org/live.m3u8", - logo="https://example.org/logo.png", category="Adult TV") - with tempfile.TemporaryDirectory() as temp: - out = Path(temp) / "adulttest.m3u" - write_m3u([example], out) - assert 'group-title="Adult TV"' in out.read_text() - assert 'tvg-logo="https://example.org/logo.png"' in out.read_text() - assert len(iptv.parse_m3u(out.read_text())) == 1 - assert len(SOURCES) == 16 - print("ADULTTEST_OFFLINE_SELF_TEST_PASS") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", default="dist/adulttest") - parser.add_argument("--workers", type=int, default=6) - parser.add_argument("--timeout", type=float, default=7) - parser.add_argument("--dry-run", action="store_true", help="Test all streams but do not upload to Bunny") - parser.add_argument("--publish-only", action="store_true", help="Upload previous verified local M3U without repeating probes") - parser.add_argument("--self-test", action="store_true") - args = parser.parse_args() - if args.self_test: - self_test() - return 0 - if args.dry_run and args.publish_only: - parser.error("--dry-run and --publish-only cannot be combined") - if not (1 <= args.workers <= 12 and 1 <= args.timeout <= 25): - parser.error("workers must be 1..12 and timeout 1..25 seconds") - try: - return run(args) - except (ValueError, RuntimeError, OSError, HTTPError, URLError) as exc: - print("ERROR:", str(exc), file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/verify_public_hub.py b/tools/verify_public_hub.py index 8a8b9b894b..13f9ab21dc 100644 --- a/tools/verify_public_hub.py +++ b/tools/verify_public_hub.py @@ -18,20 +18,22 @@ def validate() -> dict: catalog = json.loads(MANIFEST.read_text(encoding="utf-8")) assert catalog["schema_version"] == 1 - expected = {"countries": 188, "categories": 34, "featured": 16, "links": 238} + expected = {"countries": 188, "categories": 34, "featured": 15, "links": 237} assert catalog["totals"] == expected, (catalog["totals"], expected) items = catalog["items"] assert len(items) == expected["links"] assert len({item["id"] for item in items}) == len(items) assert len({item["path"] for item in items}) == len(items) assert len({item["url"] for item in items}) == len(items) - assert {x["status"] for x in items} == ALLOWED + assert {x["status"] for x in items} <= ALLOWED + assert {"user_log_uploaded", "publication_unconfirmed"} <= {x["status"] for x in items} directory = DIRECTORY.read_text(encoding="utf-8") readme = README.read_text(encoding="utf-8") assert len(readme) >= 5000 and len(directory) >= 25000 + assert "adulttest" not in readme.lower() and "adulttest" not in directory.lower() assert "iptv-org/iptv" in readme and "iptv-org/iptv" in directory assert "PUBLIC_RELEASE.md" in readme and "FORK_CHANGELOG.md" in readme - assert "adulttest.m3u" in readme and "plutotv-lite.m3u" in readme + assert "plutotv-lite.m3u" in readme counts = {"country": 0, "category": 0, "main": 0, "adult": 0, "tizen": 0, "pluto": 0} for x in items: assert x["group"] in counts, x @@ -56,8 +58,7 @@ def validate() -> dict: assert sum(counts[k] for k in ("main", "adult", "tizen", "pluto")) == expected["featured"] ua = next(x for x in items if x["path"] == "countries/ua.m3u") assert ua["status"] == "user_log_uploaded" - adulttest = next(x for x in items if x["path"] == "adulttest.m3u") - assert adulttest["status"] == "user_report_cdn_verified" + assert all("adulttest" not in x["path"].lower() for x in items) for path in ("plutotv-lite.m3u", "plutotv.m3u", "index.country.m3u", "lampa-clean-lite.m3u", "countries/us.m3u"): assert next(x for x in items if x["path"] == path)["status"] == "publication_unconfirmed"