diff --git a/app/api/scenes.py b/app/api/scenes.py index 333ab81..60a562e 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -650,6 +650,20 @@ def _sxyprn_thumb_url(page_url: str | None) -> str | None: return f"/proxy/sxyprn-thumb/{m.group(1)}" if m else None +_YOUPORN_WATCH_RE = re.compile(r"youporn\.com/watch/(\d+)", re.IGNORECASE) + + +def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None: + """Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny + on-demand endpoint `/proxy/youporn-thumb/` — zapisany podpis wygasa i CDN + oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg` + NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy).""" + if not page_url or not thumb or "hash=" not in thumb: + return None + m = _YOUPORN_WATCH_RE.search(page_url) + return f"/proxy/youporn-thumb/{m.group(1)}" if m else None + + def _is_rotting_thumb(url: str) -> bool: """sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po ~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze @@ -782,10 +796,15 @@ def _build_scenes_out_batch( anim_by_scene: dict = {} for sid, thumb, anim, page_url in pb_light: sxy = _sxyprn_thumb_url(page_url) + yp = _youporn_thumb_url(page_url, thumb) if sxy: # sxyprn → żywy on-demand resolver (martwy stored URL ignorujemy), # tier fallback: użyty tylko gdy scena nie ma stabilniejszej miniatury. thumb_fallback.setdefault(sid, (sxy, page_url)) + elif yp: + # youporn z podpisanym (wygasającym) posterem → on-demand resolver. + # Też tier fallback: statyczna miniatura z innego źródła jest tańsza. + thumb_fallback.setdefault(sid, (yp, page_url)) elif thumb: if _is_rotting_thumb(thumb): thumb_fallback.setdefault(sid, (thumb, page_url)) @@ -833,8 +852,11 @@ def _build_scenes_out_batch( # "na liście jest miniaturka, w scenie nie"). Podmieniamy na żywy resolver; # inne martwe rotting-thumby zerujemy → mobile bierze kolejne źródło / placeholder. sxy = _sxyprn_thumb_url(p.page_url) + yp = _youporn_thumb_url(p.page_url, out.thumbnail_url) if sxy: out.thumbnail_url = sxy + elif yp: + out.thumbnail_url = yp elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url): out.thumbnail_url = None if out.thumbnail_url and _needs_proxy(out.thumbnail_url): @@ -985,8 +1007,11 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_ # detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh # thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22. sxy = _sxyprn_thumb_url(p.page_url) + yp = _youporn_thumb_url(p.page_url, out.thumbnail_url) if sxy: out.thumbnail_url = sxy + elif yp: + out.thumbnail_url = yp elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url): out.thumbnail_url = None # Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera diff --git a/app/api/stream_proxy.py b/app/api/stream_proxy.py index 5bdd7da..884e0d1 100644 --- a/app/api/stream_proxy.py +++ b/app/api/stream_proxy.py @@ -368,6 +368,73 @@ _VID_POSTER_RE = re.compile(r"]*poster=[\"']([^\"']+)", re.IGNORECASE) _SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$") +# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn +# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em +# (`.../original_.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=`) — taki URL +# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930 +# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają +# bezterminowo — tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy +# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/`. +_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {} +_YOUPORN_POSTER_TTL = 1800 +_YOUPORN_VID_RE = re.compile(r"^\d{4,12}$") +_YP_JSONLD_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*"([^"]+)"', re.IGNORECASE) + + +@router.get("/youporn-thumb/{video_id}") +async def youporn_thumb(video_id: str) -> Response: + """On-demand poster youporn (podpisany URL imgproxy wygasa, więc nie da się go + trzymać w bazie). Stabilny URL per video_id → klient cache'uje bajty.""" + import html as _htmllib + + vid = video_id.split(".")[0] + if not _YOUPORN_VID_RE.match(vid): + raise HTTPException(status_code=400, detail="bad video_id") + now = time.time() + cached = _YOUPORN_POSTER_CACHE.get(vid) + poster = cached[0] if (cached and cached[1] > now) else None + timeout = httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=5.0) + page = f"https://www.youporn.com/watch/{vid}/" + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + if poster is None: + try: + r = await client.get(page, headers=_build_headers(None)) + except Exception as e: + log.info("youporn-thumb page fetch failed %s: %s", vid, e) + return Response(content=b"", status_code=502, media_type="image/jpeg") + html_txt = r.text + m = ( + _YP_JSONLD_THUMB_RE.search(html_txt) + or _OG_IMG_RE.search(html_txt) + or _OG_IMG_RE2.search(html_txt) + or _VID_POSTER_RE.search(html_txt) + ) + if not m: + return Response(content=b"", status_code=404, media_type="image/jpeg") + # JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`) — bez unescape + # podpis w query jest zepsuty i CDN oddaje 403. + poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/")) + if poster.startswith("//"): + poster = "https:" + poster + _YOUPORN_POSTER_CACHE[vid] = (poster, now + _YOUPORN_POSTER_TTL) + if len(_YOUPORN_POSTER_CACHE) > 8000: + for k in [k for k, v in list(_YOUPORN_POSTER_CACHE.items()) if v[1] < now]: + _YOUPORN_POSTER_CACHE.pop(k, None) + try: + pr = await client.get(poster, headers=_build_headers(page)) + except Exception as e: + log.info("youporn-thumb poster fetch failed %s: %s", vid, e) + return Response(content=b"", status_code=502, media_type="image/jpeg") + if pr.status_code >= 400: + _YOUPORN_POSTER_CACHE.pop(vid, None) # wygasły podpis → re-resolve następnym razem + return Response(content=b"", status_code=502, media_type="image/jpeg") + return Response( + content=pr.content, + media_type=pr.headers.get("content-type", "image/jpeg"), + headers={"Cache-Control": "public, max-age=604800"}, + ) + + @router.get("/sxyprn-thumb/{post_id}") async def sxyprn_thumb(post_id: str) -> Response: """On-demand poster sxyprn. URL stabilny per post_id (klient cache'uje bajty); diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index b63fd19..5ece6b5 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,14 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-26b', + date: 'July 2026', + items: [ + 'The screen no longer turns off while a video is playing. It still dims normally when you pause.', + 'youporn thumbnails that had gone blank now load again.', + ], + }, { id: '2026-07-26', date: 'July 2026', diff --git a/mobile/src/lib/keepAwake.ts b/mobile/src/lib/keepAwake.ts new file mode 100644 index 0000000..3ea3a6d --- /dev/null +++ b/mobile/src/lib/keepAwake.ts @@ -0,0 +1,54 @@ +/** + * Keep-screen-on podczas odtwarzania (bug-reports 2026-07-18 `fcbaa933` i 2026-07-23 + * `c40fb79f`: "ekran gaśnie w trakcie oglądania" — Android wygasza ekran po timeoucie, + * bo dotyk nie pada, a samo granie wideo nie trzyma wake-locka). + * + * Dlaczego nie `expo-keep-awake` z importu: pakiet NIE jest w top-level `node_modules` + * (siedzi w `node_modules/expo/node_modules/`), więc metro nie rozwiąże go z naszego + * kodu, a dokładanie zależności do package.json nie przeszłoby przez OTA gdyby wersja + * rozjechała się z APK. Natywny moduł `ExpoKeepAwake` (activate/deactivate po tagu) JEST + * jednak w buildzie, bo `expo` bundluje go i autolinkuje — więc sięgamy po niego wprost + * przez `requireNativeModule` z `expo-modules-core` (ten jest top-level). Dzięki temu + * całość leci OTA, bez nowego APK. + * + * Wszystko best-effort: gdyby modułu zabrakło (inny build), no-op zamiast wywalenia + * playera. + */ +import { requireNativeModule } from 'expo-modules-core'; + +const TAG = 'goon-player'; + +type KeepAwakeNative = { + activate?: (tag: string) => Promise; + deactivate?: (tag: string) => Promise; +}; + +let _mod: KeepAwakeNative | null | undefined; + +function nativeModule(): KeepAwakeNative | null { + if (_mod !== undefined) return _mod; + try { + _mod = requireNativeModule('ExpoKeepAwake'); + } catch { + _mod = null; // brak modułu w tym buildzie → no-op + } + return _mod; +} + +/** Trzymaj ekran włączony (idempotentne — natywka trzyma set tagów). */ +export function keepScreenOn(): void { + try { + void nativeModule()?.activate?.(TAG)?.catch?.(() => {}); + } catch { + // ignore + } +} + +/** Pozwól ekranowi gasnąć normalnie. */ +export function allowScreenOff(): void { + try { + void nativeModule()?.deactivate?.(TAG)?.catch?.(() => {}); + } catch { + // ignore + } +} diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 08793b9..bc6bca0 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -8,6 +8,7 @@ import * as ScreenOrientation from 'expo-screen-orientation'; import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video'; import * as Clipboard from 'expo-clipboard'; import { setLastPlayback } from '../lib/lastPlayback'; +import { allowScreenOff, keepScreenOn } from '../lib/keepAwake'; import React from 'react'; import { ActivityIndicator, @@ -211,6 +212,15 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { const playingEvent = useEvent(player, 'playingChange', { isPlaying: player.playing }); const isPlaying = playingEvent?.isPlaying ?? player.playing; + // Ekran nie gaśnie w trakcie odtwarzania (bug-reports fcbaa933 / c40fb79f). Wake-lock + // TYLKO gdy realnie gra — przy pauzie ekran ma gasnąć normalnie, żeby nie trzymać + // wybudzenia gdy user odłożył telefon. + React.useEffect(() => { + if (isPlaying) keepScreenOn(); + else allowScreenOff(); + }, [isPlaying]); + React.useEffect(() => allowScreenOff, []); // unmount → zawsze zwolnij + // Zapis kontekstu odtwarzania → BugReportFAB dokleja go do zgłoszeń (który serwer/host, // pozycja, ostatni błąd) bez pytania usera. Aktualizowany na zmianę źródła i statusu. React.useEffect(() => { @@ -1432,6 +1442,13 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { const client = useClient(); const nav = useNavigation>(); const { url, sceneId, entityKind, durationSec, refererHost } = params; + + // Keep-screen-on dla ścieżki WebView: nie mamy tu sygnału play/pause (wideo gra + // wewnątrz strony hostera), więc trzymamy wake-lock przez cały czas życia ekranu. + React.useEffect(() => { + keepScreenOn(); + return allowScreenOff; + }, []); // Dispatch dla movie vs scene progress endpoint (jak w NativeVideoPlayer). const upsertWatchProgress = React.useCallback( (body: { position_sec: number; duration_sec?: number; finished?: boolean }) =>