diff --git a/app/extractors/hosters/seekplayer_engine.py b/app/extractors/hosters/seekplayer_engine.py index d5def61..eeeed58 100644 --- a/app/extractors/hosters/seekplayer_engine.py +++ b/app/extractors/hosters/seekplayer_engine.py @@ -41,7 +41,7 @@ from __future__ import annotations import json import logging import re -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -92,6 +92,69 @@ def _decrypt(hex_str: str) -> str: return (unpadder.update(pt) + unpadder.finalize()).decode("utf-8", errors="replace") + +# Mapowanie z ICH bundla (`index-*.js`, funkcja skladajaca liste zrodel): +# {Cloudflare: cf, Tiktok: hlsVideoTiktok, Google: hlsVideoGoogle, "In-House": source} +# Kolejnosc prob i przekształcenia adresu przychodza w `streamingConfig`, ktore serwis +# ustawia per plik. Do 2026-08-26 czytalismy WYLACZNIE `source`, wiec sceny majace +# wylacznie `hlsVideoTiktok` (a takie sa) traktowalismy jak martwe, mimo ze graja. +_STREAM_FIELDS = { + "Cloudflare": "cf", + "Tiktok": "hlsVideoTiktok", + "Google": "hlsVideoGoogle", + "In-House": "source", +} +# Ile kandydatow realnie sprawdzamy. Sprawdzenie to osobne pobranie, a to sciezka +# resolve, na ktora czeka uzytkownik; przy tylu proba i tak trafiamy w to, co dziala. +_MAX_CANDIDATES = 3 +_VERIFY_TIMEOUT = 8.0 + + +def _build_candidate(host: str, raw_value: str | None, cfg: dict, key: str) -> str | None: + """Sklada adres dla jednej sciezki CDN wg regul z `streamingConfig.adjust`. + + Odwzorowanie ich funkcji: pomijamy wylaczone, zamieniamy `/hls/` na + `/hlsmod//` (tak kieruja ruch na CDN TikToka) i doklejamy `params`. + """ + adjust = (cfg.get("adjust") or {}).get(key) or {} + if adjust.get("disabled") or not raw_value: + return None + url = raw_value if raw_value.startswith("http") else host + raw_value + domain = adjust.get("domain") + if domain and "/hls/" in url: + url = url.replace("/hls/", "/hlsmod/" + domain + "/") + params = adjust.get("params") + if isinstance(params, dict) and params: + url += ("&" if "?" in url else "?") + urlencode(params) + return url + + +def _responds(url: str, referer: str) -> bool: + """Czy pod tym adresem NAPRAWDE cos jest. + + Konieczne, bo serwis podaje adresy do plikow, ktorych juz nie ma: ten sam wezel CDN + serwuje jeden plik poprawnie, a na inny oddaje 502 (sprawdzone 2026-08-26, wezly + 94.131.217.172/175/177 rotuja i wszystkie zwracaja 502 dla usunietego pliku). Bez tej + weryfikacji oddawalismy martwy adres, aplikacja pokazywala czarny ekran, a ratunek + z vikingfile nigdy sie nie uruchamial, bo lista zrodel nie byla pusta. + """ + try: + r = browser_get( + url, + headers={"User-Agent": _DEFAULT_UA, "Referer": referer}, + timeout=_VERIFY_TIMEOUT, + follow_redirects=True, + ) + except Exception as e: + log.debug("seekplayer-engine: weryfikacja %s padla (%s)", url, e) + return False + if r.status_code != 200: + return False + head = (r.text or "")[:200] + # Manifest HLS albo lista adresow (wariant `cf` oddaje plik tekstowy z linkami). + return "#EXTM3U" in head or head.strip().startswith("http") + + def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: parsed = urlparse(page_url) if not parsed.hostname or not _HOST_RE.match(parsed.hostname): @@ -138,60 +201,59 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non if isinstance(data, dict) and data.get("error"): raise HosterDead(f"seekplayer-engine {page_url}: {data['error']}") - source = (data.get("source") or "").strip() + # Adresy strumienia siedza w kilku polach naraz, a serwis sam mowi w + # `streamingConfig`, ktorej sciezki uzyc i w jakiej kolejnosci. Do 2026-08-26 + # czytalismy tylko `source`, przez co sceny majace wylacznie `hlsVideoTiktok` + # (zweryfikowane: oddaje manifest 1080p, dziala i z serwerowni, i z lacza domowego) + # traktowalismy jak martwe. + # + # `source` to m3u8 na wezle CDN pod GOLYM IP. Token ma ~2,8 h TTL i NIE jest zwiazany + # z IP (sprawdzone 2026-08-14), a wezly rotuja, wiec padniecie odtwarzania leczy + # ponowny resolve (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen), nie WebView. + # + # `proxy_no_verify` NIE JEST przez nic czytane; flaga jest informacyjna i zapisuje + # realne ryzyko: gdyby ktorys wezel zaczal serwowac cert niepasujacy do IP, trzeba + # bedzie dopisac galaz `verify=False` w `stream_proxy.py`. - # Source: m3u8 na CDN edge pod GOŁYM IP (np. `185.237.107.146/v4///…/master.m3u8`). - # Token ma ~2,8 h TTL i NIE jest związany z IP (sprawdzone 2026-08-14), a węzły - # rotują: dwa kolejne resolve tej samej sceny wracają z różnymi adresami. Dlatego - # padnięcie odtwarzania na tym źródle leczy się ponownym resolve, a nie WebView-em - # (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen). - # - # `proxy_no_verify` NIE JEST przez nic czytane. Wcześniejszy komentarz twierdził, że - # `stream_proxy.py` ma gałąź `verify=False` dla hostów-IP — nie ma jej, w tym pliku nie - # występuje słowo `verify` ani razu. W praktyce certyfikaty tych węzłów przechodzą - # weryfikację (fetch przez proxy oddaje 200), więc flaga jest informacyjna. Zostaje, - # bo zapisuje realne ryzyko: gdyby któryś nod zaczął serwować cert niepasujący do IP, - # trzeba będzie tę gałąź dopisać. - # - # `cf` (fallback za Cloudflare) CELOWO NIE JEST PRZEKAZYWANY DALEJ. Sprawdzone - # 2026-08-14, sześć wariantów, wszystkie 403: - # - # httpx + Referer hostera · httpx + Referer galaxyporn.net - # curl_cffi chrome120 z Refererem · curl_cffi bez Referera (oddaje stronę CF) - # znacznik czasu bieżący · znacznik z linku głównego - # - # I to samo 403 z łącza RESIDENTIAL, więc to nie jest blokada datacenter. Adres jest - # najpewniej związany z sesją tego samego wywołania `/api/v1/video`, a my sięgamy po - # niego osobno i po czasie (znacznik w nazwie pliku bywa 5 dni stary). Podpięcie go - # jako zapasowego źródła dokładałoby request, który ZAWSZE pada, na ścieżce błędu. - # - # Padnięcie pojedynczego noda leczy ponowny resolve: kolejne wywołania wracają z - # różnymi adresami CDN (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen, commit 47547fe). - if not source: - # Brak `source` to NIE jest usterka parsowania ani chwilowy problem sieci. - # W ich własnym bundlu (`index-*.js`, funkcja startowa playera) warunek brzmi - # dosłownie: gdy w odpowiedzi nie ma `source`, player pokazuje „Video is not - # ready yet" i kończy. Potwierdzone 2026-08-24 w przeglądarce z łącza domowego - # na `vip.easyvidplayer.com/#96kzl`: dokładnie ten komunikat, a plakat obok - # oddał 502. To samo z VPS i przez brytyjskie proxy, więc nie chodzi o IP. - # - # Rzucamy `HosterDead`, bo tylko wtedy `resolve_movie_playback` oznaczy źródło - # martwe i przestanie je podsuwać. Zwracanie None dawało WebView z tym samym - # komunikatem, w kółko, i tak wyglądały zgłoszenia z 2026-08-24. - raise HosterDead(f"seekplayer-engine {page_url}: brak pola source") + cfg = {} + raw_cfg = data.get("streamingConfig") + if isinstance(raw_cfg, str) and raw_cfg.strip(): + try: + cfg = json.loads(raw_cfg) + except Exception: + cfg = {} + order = cfg.get("order") or ["In-House"] - sources: list[StreamSource] = [] - if source: - sources.append( + # Budujemy kandydatow w KOLEJNOSCI, ktora serwis sam podaje, i zwracamy pierwszego, + # ktory naprawde odpowiada. Weryfikacja jest tu kluczowa: adres do usunietego pliku + # wyglada normalnie, a oddaje 502, i bez sprawdzenia oddawalismy go dalej jako dobry. + referer = f"{host}/" + sprawdzone = 0 + for key in order: + if sprawdzone >= _MAX_CANDIDATES: + break + candidate = _build_candidate(host, data.get(_STREAM_FIELDS.get(key, "")), cfg, key) + if not candidate: + continue + sprawdzone += 1 + if not _responds(candidate, referer): + log.info("seekplayer-engine: sciezka %s nie odpowiada dla %s", key, page_url) + continue + return [ StreamSource( - link=source, + link=candidate, quality=None, type="m3u8", - referer=f"{host}/", - raw={ - "proxy_no_verify": True, - "engine": "seekplayer", - }, + referer=referer, + raw={"proxy_no_verify": True, "engine": "seekplayer", "sciezka": key}, ) - ) - return sources or None + ] + + # Zadna sciezka nie odpowiada. To NIE jest usterka parsowania ani chwilowy problem + # sieci: w ich wlasnym bundlu brak zrodla konczy sie komunikatem "Video is not ready + # yet", a przy adresie do usunietego pliku CDN oddaje 502 ze WSZYSTKICH wezlow + # (sprawdzone 2026-08-26; ten sam wezel serwuje inny plik poprawnie). + # + # Rzucamy `HosterDead`, bo tylko wtedy `resolve_movie_playback` oznaczy zrodlo martwe. + # Dla scen galaxyporn ten wyjatek jest lapany i uruchamia ratunek z vikingfile. + raise HosterDead(f"seekplayer-engine {page_url}: zadna sciezka CDN nie odpowiada") diff --git a/app/extractors/tubes/galaxyporn.py b/app/extractors/tubes/galaxyporn.py index e9b7ba5..72e364a 100644 --- a/app/extractors/tubes/galaxyporn.py +++ b/app/extractors/tubes/galaxyporn.py @@ -16,7 +16,7 @@ import re from urllib.parse import urlparse from app.extractors._fetch import browser_get, fetch_tube_html -from app.extractors._models import StreamSource +from app.extractors._models import HosterDead, StreamSource from app.extractors.hosters import seekplayer_engine log = logging.getLogger(__name__) @@ -115,11 +115,22 @@ def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | Non try: sources = seekplayer_engine.extract(iframe_src, timeout=timeout) - except Exception as e: - # Martwy player potrafi RZUCIC (HosterDead przy 404 z /api/v1/video), a nie tylko - # oddac pustke. Bez tego wyjatek wychodzil na zewnatrz i ratunek z vikingfile - # nigdy sie nie wykonywal. + except HosterDead as e: + # Martwy player RZUCA (404 z /api/v1/video albo zadna sciezka CDN nie odpowiada), + # a nie tylko oddaje pustke. Bez tego wyjatek wychodzil na zewnatrz i ratunek + # z vikingfile nigdy sie nie wykonywal. log.info("galaxyporn: seekplayer rzucil dla %s (%s), probuje vikingfile", iframe_src, e) + ratunek = _vikingfile_source(html_text, page_url, timeout=timeout) + if ratunek: + return ratunek + # Player mowi wprost, ze pliku nie ma, a na stronie nie ma tez vikingfile. To jest + # PEWNA smierc, wiec przepuszczamy wyjatek dalej: `resolve_playback` oznaczy zrodlo + # martwym i scena przestanie sie pokazywac. Zwrocenie None dawalo 503 + # "sprobuj ponownie", wiec ta sama martwa scena wracala w kolko (zgloszenie + # a405b9d6, scena bez vikingfile i z wszystkimi wezlami CDN na 502). + raise + except Exception as e: + log.info("galaxyporn: seekplayer padl dla %s (%s), probuje vikingfile", iframe_src, e) return _vikingfile_source(html_text, page_url, timeout=timeout) if not sources: # Iframe bywa ZWIETRZALY. Serwis przeniosl pliki na vikingfile, ale w starszych