Compare commits

..

No commits in common. "ce174b671e232ab6e2566fd97b7fe2eacd520ecd" and "426b5e99a23d04dab52b60d0fd019abc87d6010f" have entirely different histories.

24 changed files with 68 additions and 690 deletions

View file

@ -1,32 +0,0 @@
"""playback_events.via: skad przyszedl adres, ktory gral (albo padl) w odtwarzaczu.
Po przejsciu porndoe na resolver po stronie telefonu (2026-10-02) 35 urzadzen dalej
dostawalo wylacznie 403 i nie dalo sie odroznic, czy pada nowa droga (link pobrany na
telefonie), zapas przez WebView, czy stara paczka. Apka wysyla teraz `via`
('phone', 'backend', 'hoster', 'webview', 'proxy', z koncowka '-re' po ponownym
rozwiazaniu); NULL = paczka sprzed tej zmiany.
Kolumna bez DEFAULT, nullable: ADD COLUMN to sama zmiana metadanych.
Revision ID: 0053_playback_events_via
Revises: 0052_clips_feed_counters
Create Date: 2026-10-06
"""
from __future__ import annotations
from alembic import op
revision = "0053_playback_events_via"
down_revision: str | None = "0052_clips_feed_counters"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE playback_events ADD COLUMN IF NOT EXISTS via varchar(16)")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE playback_events DROP COLUMN IF EXISTS via")

View file

@ -1,31 +0,0 @@
"""hidden_sites: strony ukryte przez uzytkownika (per urzadzenie).
Zgloszenie 3910b19b: brak przycisku ukrywania stron. Lustro favorite_sites (0034).
Revision ID: 0054_hidden_sites
Revises: 0053_playback_events_via
Create Date: 2026-10-06
"""
from __future__ import annotations
from alembic import op
revision = "0054_hidden_sites"
down_revision: str | None = "0053_playback_events_via"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS hidden_sites (
device_id varchar(64) NOT NULL,
origin varchar(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, origin)
)
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS hidden_sites")

View file

@ -9,23 +9,18 @@ Endpointy:
DELETE /blacklist/{kind}/{entity_id} usuń DELETE /blacklist/{kind}/{entity_id} usuń
`kind` ∈ {performer, studio, tag}. `kind` ∈ {performer, studio, tag}.
Ukryte strony (2026-10-06, zgloszenie 3910b19b): origin to napis, nie UUID, wiec osobne
trasy `/blacklist/sites/{origin}`, zadeklarowane PRZED `/{kind}/{entity_id}`.
""" """
from __future__ import annotations from __future__ import annotations
import re
import uuid import uuid
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import exists, select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.device import get_device_id from app.api.device import get_device_id
from app.api.scenes import invalidate_blacklist_cache
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.blacklist import ( from app.models.blacklist import (
@ -33,9 +28,7 @@ from app.models.blacklist import (
BlacklistedStudio, BlacklistedStudio,
BlacklistedTag, BlacklistedTag,
) )
from app.models.hidden_site import HiddenSite
from app.models.performer import Performer from app.models.performer import Performer
from app.models.playback_source import PlaybackSource
from app.models.studio import Studio from app.models.studio import Studio
from app.models.tag import Tag from app.models.tag import Tag
@ -56,8 +49,6 @@ class BlacklistOut(BaseModel):
performers: list[BlacklistEntry] performers: list[BlacklistEntry]
studios: list[BlacklistEntry] studios: list[BlacklistEntry]
tags: list[BlacklistEntry] tags: list[BlacklistEntry]
# Ukryte strony: same originy ('tube:porndoecom'), nazwy apka bierze z /sources.
sites: list[str] = []
@router.get("", response_model=BlacklistOut) @router.get("", response_model=BlacklistOut)
@ -83,54 +74,13 @@ def list_blacklist(
.where(BlacklistedTag.device_id == device_id) .where(BlacklistedTag.device_id == device_id)
.order_by(Tag.name) .order_by(Tag.name)
).all() ).all()
sites = session.execute(
select(HiddenSite.origin).where(HiddenSite.device_id == device_id).order_by(HiddenSite.origin)
).scalars().all()
return BlacklistOut( return BlacklistOut(
performers=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in perfs], performers=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in perfs],
studios=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in studios], studios=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in studios],
tags=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in tags], tags=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in tags],
sites=list(sites),
) )
_ORIGIN_RE = re.compile(r"^[a-z0-9][a-z0-9:._-]{1,63}$")
@router.post("/sites/{origin}", status_code=status.HTTP_200_OK)
def hide_site(
origin: str,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> dict:
if not _ORIGIN_RE.match(origin):
raise HTTPException(status_code=400, detail="bad origin")
if session.get(HiddenSite, (device_id, origin)) is not None:
return {"origin": origin, "created": False}
known = session.execute(
select(exists(select(1).where(PlaybackSource.origin == origin)))
).scalar_one()
if not known:
raise HTTPException(status_code=404, detail="site not found")
session.add(HiddenSite(device_id=device_id, origin=origin))
session.commit()
invalidate_blacklist_cache(device_id)
return {"origin": origin, "created": True}
@router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT)
def unhide_site(
origin: str,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
row = session.get(HiddenSite, (device_id, origin))
if row is not None:
session.delete(row)
session.commit()
invalidate_blacklist_cache(device_id)
def _kind_to_entity(kind: Kind): def _kind_to_entity(kind: Kind):
if kind == "performer": if kind == "performer":
return BlacklistedPerformer, Performer, "performer_id" return BlacklistedPerformer, Performer, "performer_id"
@ -155,7 +105,6 @@ def add_blacklist(
return {"kind": kind, "id": str(entity_id), "created": False} return {"kind": kind, "id": str(entity_id), "created": False}
session.add(bl_model(**{"device_id": device_id, fk: entity_id})) session.add(bl_model(**{"device_id": device_id, fk: entity_id}))
session.commit() session.commit()
invalidate_blacklist_cache(device_id)
return {"kind": kind, "id": str(entity_id), "created": True} return {"kind": kind, "id": str(entity_id), "created": True}
@ -172,4 +121,3 @@ def remove_blacklist(
return # idempotent return # idempotent
session.delete(row) session.delete(row)
session.commit() session.commit()
invalidate_blacklist_cache(device_id)

View file

@ -214,20 +214,8 @@ def _load_affinity(session: Session, device_id: str) -> tuple[ClipAffinity | Non
def _save_affinity(session: Session, device_id: str, row: ClipAffinity | None, aff: cf.Affinity, events: int) -> None: def _save_affinity(session: Session, device_id: str, row: ClipAffinity | None, aff: cf.Affinity, events: int) -> None:
if row is None: if row is None:
# Pierwszy zapis dla urzadzenia. Onboarding i zdarzenia z pierwszych klipow row = ClipAffinity(device_id=device_id)
# przychodza naraz, oba widzialy brak wiersza i oba robily INSERT, drugi konczyl session.add(row)
# sie UniqueViolation i bledem 500 (GOON-7X). Wstawiamy pusty wiersz z ON CONFLICT
# i bierzemy go z blokada. Nasze wagi liczylismy od zera, wiec to same przyrosty:
# dodajemy je do tego, co zdazylo zapisac rownolegle zadanie.
session.execute(
pg_insert(ClipAffinity)
.values(device_id=device_id, tag_weights={}, niche_weights={}, creator_weights={}, events_seen=0)
.on_conflict_do_nothing(index_elements=["device_id"])
)
row = session.get(ClipAffinity, device_id, with_for_update=True, populate_existing=True)
for mine, theirs in ((aff.tags, row.tag_weights), (aff.niches, row.niche_weights), (aff.creators, row.creator_weights)):
for k, v in (theirs or {}).items():
mine[k] = round(mine.get(k, 0.0) + v, 3)
row.tag_weights = aff.tags row.tag_weights = aff.tags
row.niche_weights = aff.niches row.niche_weights = aff.niches
row.creator_weights = aff.creators row.creator_weights = aff.creators

View file

@ -56,10 +56,6 @@ class ImportOut(BaseModel):
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [ _COPY_TABLES: list[tuple[str, str]] = _TABLES + [
("saved_searches", "query"), ("saved_searches", "query"),
("clip_blacklist", "value"), ("clip_blacklist", "value"),
# Przypiete i ukryte strony (bez FK, kluczem jest napis origin). favorite_sites
# brakowalo tu od poczatku, wiec przypiecia nie przechodzily kodem synchronizacji.
("favorite_sites", "origin"),
("hidden_sites", "origin"),
] ]

View file

@ -32,9 +32,6 @@ class PlaybackEventIn(BaseModel):
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error). # padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512) error_detail: str | None = Field(default=None, max_length=512)
ttff_ms: int | None = Field(default=None, ge=0, le=600_000) ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
# Skad przyszedl adres w odtwarzaczu (patrz PlaybackEvent.via). Opcjonalne:
# starsze paczki go nie wysylaja.
via: str | None = Field(default=None, max_length=16)
@router.post("", status_code=204) @router.post("", status_code=204)
@ -52,7 +49,6 @@ def post_playback_event(
error_detail=body.error_detail if body.status == "error" else None, error_detail=body.error_detail if body.status == "error" else None,
ttff_ms=body.ttff_ms if body.status == "success" else None, ttff_ms=body.ttff_ms if body.status == "success" else None,
device_id=device_id, device_id=device_id,
via=body.via,
) )
) )
session.commit() session.commit()

View file

@ -187,47 +187,31 @@ def _jav_source_exists():
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule # Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
# wracają. Patrz reference_scenes_list_perf / task #22. # wracają. Patrz reference_scenes_list_perf / task #22.
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08). # Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
# Trzymamy, KTORE listy sa niepuste, nie tylko "czy cokolwiek": ukryte strony (2026-10-06) _BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
# nie powinny doklejac trzech pustych NOT EXISTS po performerach/studiach/tagach.
# Dodanie i usuniecie wpisu czysci cache (invalidate_blacklist_cache), wczesniej pierwszy
# wpis dzialal dopiero po 5 minutach.
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, frozenset[str]]] = {}
_BLACKLIST_EMPTY_TTL = 300.0 _BLACKLIST_EMPTY_TTL = 300.0
def invalidate_blacklist_cache(device_id: str) -> None: def _blacklists_empty(session: Session, device_id: str) -> bool:
_BLACKLIST_EMPTY_CACHE.pop(device_id, None) """True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
def _blacklist_kinds(session: Session, device_id: str) -> frozenset[str]:
"""Niepuste listy TEGO urzadzenia: podzbior {'performer', 'studio', 'tag', 'site'}."""
import time as _time import time as _time
from app.models.blacklist import ( from app.models.blacklist import (
BlacklistedPerformer, BlacklistedPerformer,
BlacklistedStudio, BlacklistedStudio,
BlacklistedTag, BlacklistedTag,
) )
from app.models.hidden_site import HiddenSite
now = _time.monotonic() now = _time.monotonic()
cached = _BLACKLIST_EMPTY_CACHE.get(device_id) cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL: if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
return cached[1] return cached[1]
row = session.execute( has_any = session.execute(
select( select(
exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id)), exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id))
exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id)), | exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id))
exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id)), | exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id))
exists(select(1).select_from(HiddenSite).where(HiddenSite.device_id == device_id)),
) )
).one() ).scalar_one()
kinds = frozenset(k for k, present in zip(("performer", "studio", "tag", "site"), row) if present) _BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
_BLACKLIST_EMPTY_CACHE[device_id] = (now, kinds) return not has_any
return kinds
def _blacklists_empty(session: Session, device_id: str) -> bool:
"""True gdy WSZYSTKIE listy TEGO device puste → pomiń NOT EXISTS klauzule."""
return not _blacklist_kinds(session, device_id)
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie # Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
@ -433,17 +417,9 @@ def live_playback_exists():
) )
def hidden_origins_subquery(device_id: str):
"""SELECT origin z ukrytych stron urzadzenia (do IN / NOT IN)."""
from app.models.hidden_site import HiddenSite
return select(HiddenSite.origin).where(HiddenSite.device_id == device_id)
def blacklist_clauses(session: Session, device_id: str) -> list: def blacklist_clauses(session: Session, device_id: str) -> list:
"""Klauzule blacklist device (performer/studio/tag) i ukrytych stron. [] gdy wszystko puste.""" """NOT-EXISTS klauzule blacklist device (performer/studio/tag). [] gdy wszystkie puste."""
kinds = _blacklist_kinds(session, device_id) if _blacklists_empty(session, device_id):
if not kinds:
return [] return []
from app.models.blacklist import ( from app.models.blacklist import (
BlacklistedPerformer, BlacklistedPerformer,
@ -451,63 +427,31 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
BlacklistedTag, BlacklistedTag,
) )
out: list = [] return [
if "performer" in kinds: ~exists(
out.append( select(1)
~exists( .select_from(ScenePerformer)
select(1) .join(
.select_from(ScenePerformer) BlacklistedPerformer,
.join( (BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
BlacklistedPerformer, & (BlacklistedPerformer.device_id == device_id),
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
& (BlacklistedPerformer.device_id == device_id),
)
.where(ScenePerformer.scene_id == Scene.id)
) )
) .where(ScenePerformer.scene_id == Scene.id)
if "studio" in kinds: ),
out.append( ~Scene.studio_id.in_(
~Scene.studio_id.in_( select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id) ),
~exists(
select(1)
.select_from(SceneTag)
.join(
BlacklistedTag,
(BlacklistedTag.tag_id == SceneTag.tag_id)
& (BlacklistedTag.device_id == device_id),
) )
) .where(SceneTag.scene_id == Scene.id)
if "tag" in kinds: ),
out.append( ]
~exists(
select(1)
.select_from(SceneTag)
.join(
BlacklistedTag,
(BlacklistedTag.tag_id == SceneTag.tag_id)
& (BlacklistedTag.device_id == device_id),
)
.where(SceneTag.scene_id == Scene.id)
)
)
if "site" in kinds:
# Ukryta strona chowa scene tylko wtedy, gdy wszystkie jej zywe zrodla sa z ukrytych
# stron: ta sama scena z innego serwisu dalej jest do obejrzenia. Sceny bez zywego
# zrodla przepuszczamy, o nich decyduje filtr has_playback.
hidden = hidden_origins_subquery(device_id)
out.append(
~(
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(hidden),
)
)
& ~exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.not_in(hidden),
)
)
)
)
return out
def _studio_sorted_select(base, inner_limit: int, studio_ids=None): def _studio_sorted_select(base, inner_limit: int, studio_ids=None):
@ -1203,15 +1147,7 @@ def get_scene(
scene = session.get(Scene, scene_id) scene = session.get(Scene, scene_id)
if scene is None: if scene is None:
raise HTTPException(status_code=404, detail="scene not found") raise HTTPException(status_code=404, detail="scene not found")
out = _build_scene_out(session, scene, device_id=device_id) return _build_scene_out(session, scene, device_id=device_id)
# Zrodla z ukrytych stron chowamy, o ile zostaje jakies inne. Scena tylko z ukrytych
# stron (np. z historii albo ulubionych) zostaje grywalna, zamiast pustej sekcji Watch.
if "site" in _blacklist_kinds(session, device_id):
hidden = set(session.execute(hidden_origins_subquery(device_id)).scalars())
visible = [p for p in out.playback_sources if p.origin not in hidden]
if visible and len(visible) < len(out.playback_sources):
out.playback_sources = visible
return out
@router.get("/{scene_id}/similar", response_model=SceneListOut) @router.get("/{scene_id}/similar", response_model=SceneListOut)

View file

@ -8,7 +8,6 @@ from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_scene import FavoriteScene from app.models.favorite_scene import FavoriteScene
from app.models.favorite_site import FavoriteSite from app.models.favorite_site import FavoriteSite
from app.models.hidden_site import HiddenSite
from app.models.favorite_studio import FavoriteStudio from app.models.favorite_studio import FavoriteStudio
from app.models.ingest_run import IngestRun from app.models.ingest_run import IngestRun
from app.models.merge_candidate import MergeCandidate from app.models.merge_candidate import MergeCandidate
@ -59,7 +58,6 @@ __all__ = [
"FavoritePerformer", "FavoritePerformer",
"FavoriteScene", "FavoriteScene",
"FavoriteSite", "FavoriteSite",
"HiddenSite",
"FavoriteStudio", "FavoriteStudio",
"IngestRun", "IngestRun",
"MergeCandidate", "MergeCandidate",

View file

@ -1,25 +0,0 @@
"""Ukryte strony (origins) per urzadzenie. Prosba ze zgloszenia 3910b19b (2026-10-05):
"nie ma przycisku ukrywania stron". Lustro FavoriteSite.
Scena z listy znika tylko wtedy, gdy WSZYSTKIE jej zywe zrodla sa z ukrytych stron
(app/api/scenes.py blacklist_clauses); w szczegolach sceny przyciski ukrytych stron
znikaja, o ile zostaje inne zrodlo. Przechodzi kodem synchronizacji (app/api/me.py).
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class HiddenSite(Base):
__tablename__ = "hidden_sites"
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
origin: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View file

@ -42,9 +42,6 @@ class PlaybackEvent(Base):
error_detail: Mapped[str | None] = mapped_column(String(512), nullable=True) error_detail: Mapped[str | None] = mapped_column(String(512), nullable=True)
# time-to-first-frame [ms], "szybkość" (tylko przy success; None gdy nie zmierzono). # time-to-first-frame [ms], "szybkość" (tylko przy success; None gdy nie zmierzono).
ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) ttff_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Skad przyszedl adres w odtwarzaczu: 'phone', 'backend', 'hoster', 'webview',
# 'proxy' (z '-re' po ponownym rozwiazaniu). NULL = paczka sprzed 2026-10-06.
via: Mapped[str | None] = mapped_column(String(16), nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False

View file

@ -200,7 +200,7 @@ def check_clips_now(clip_ids: list) -> int:
rows = session.execute( rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url).where(Clip.id.in_(clip_ids), Clip.dead_at.is_(None)) select(Clip.id, Clip.hd_url, Clip.sd_url).where(Clip.id.in_(clip_ids), Clip.dead_at.is_(None))
).all() ).all()
marked_ids: list = [] marked = 0
with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http: with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http:
for cid, hd, sd in rows: for cid, hd, sd in rows:
url = hd or sd url = hd or sd
@ -212,82 +212,12 @@ def check_clips_now(clip_ids: list) -> int:
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"), text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": f"{reason} (apka)", "id": cid}, {"r": f"{reason} (apka)", "id": cid},
) )
marked_ids.append(cid) marked += 1
marked = len(marked_ids)
if marked: if marked:
log.info("clips: %d zgloszonych z apki oznaczone jako martwe", marked) log.info("clips: %d zgloszonych z apki oznaczone jako martwe", marked)
sweep_creators(marked_ids)
return marked return marked
#: Ile klipow jednego przebiegu zamiatania i jak dawno sprawdzony klip bierzemy ponownie.
_SWEEP_CAP = 600
_SWEEP_RECHECK_HOURS = 6
def sweep_creators(dead_clip_ids: list, *, cap: int = _SWEEP_CAP, sleep: float = 0.1) -> int:
"""Po znalezieniu skasowanego klipu sprawdza od razu pozostale zywe klipy jego tworcy.
Tworcy kasuja zwykle cale konto naraz, a przemial przy 1000 klipow na godzine robi
okrazenie katalogu w okolo tydzien. 2026-10-06 @thenaughtywoman mial 115 martwych
i 120 "zywych", z ktorych kazdy oddawal 403, wiec strona tworcy pokazywala puste
kafelki, a feed podsuwal czarne klipy. Kazdy klip sprawdzamy osobno (HEAD), nic nie
oznaczamy w ciemno. Sprawdzone zywe dostaja swiezy updated_at, wiec ten sam tworca
zgloszony z kilku telefonow naraz nie jest mielony w kolko. Zwraca ile oznaczono.
"""
import time as _time
from datetime import UTC, datetime, timedelta
import httpx
from sqlalchemy import select
if not dead_clip_ids:
return 0
recheck_before = datetime.now(UTC) - timedelta(hours=_SWEEP_RECHECK_HOURS)
with session_scope() as session:
creators = [
c for (c,) in session.execute(
select(Clip.creator).where(Clip.id.in_(dead_clip_ids), Clip.creator.is_not(None)).distinct()
).all()
]
if not creators:
return 0
rows = session.execute(
select(Clip.id, Clip.hd_url, Clip.sd_url)
.where(
Clip.creator.in_(creators),
Clip.dead_at.is_(None),
Clip.updated_at < recheck_before,
)
.order_by(Clip.updated_at.asc())
.limit(cap)
).all()
dead: list[tuple] = []
alive: list = []
with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
if not url:
continue
reason = _head_dead(http, url)
if reason:
dead.append((cid, reason))
else:
alive.append(cid)
_time.sleep(sleep)
with session_scope() as session:
for cid, reason in dead:
session.execute(
text("UPDATE clips SET dead_at = now(), dead_reason = :r WHERE id = :id AND dead_at IS NULL"),
{"r": f"{reason} (tworca)", "id": cid},
)
if alive:
session.execute(text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive})
if rows:
log.info("clips: zamiatanie %d tworcow, sprawdzone %d, martwe %d", len(creators), len(rows), len(dead))
return len(dead)
def reap_dead(*, limit: int = 2000, apply: bool = True, sleep: float = 0.2) -> dict[str, int]: def reap_dead(*, limit: int = 2000, apply: bool = True, sleep: float = 0.2) -> dict[str, int]:
"""Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku. """Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku.
@ -337,7 +267,6 @@ def reap_dead(*, limit: int = 2000, apply: bool = True, sleep: float = 0.2) -> d
alive = [cid for cid, _hd, _sd in rows if cid not in dead_set] alive = [cid for cid, _hd, _sd in rows if cid not in dead_set]
if alive: if alive:
session.execute(text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive}) session.execute(text("UPDATE clips SET updated_at = now() WHERE id = ANY(:ids)"), {"ids": alive})
dead += sweep_creators(list(dead_set))
out = {"checked": checked, "dead": dead, "errors": errors, "applied": int(apply)} out = {"checked": checked, "dead": dead, "errors": errors, "applied": int(apply)}
log.info("clips reaper: %s", out) log.info("clips reaper: %s", out)
return out return out

View file

@ -1,159 +0,0 @@
/**
* Ekran "zaktualizuj aplikacje" dla starych APK (runtime 1.0-1.3, wersje sprzed 0.4.0).
*
* To NIE jest czesc zwyklej aplikacji: ta paczka jest budowana z osobnego punktu wejscia
* (`update-required.tsx`, `publish_update.py --entry`) i publikowana tylko na kanaly OTA
* starych runtime'ow. Stare wersje nie dostaja juz poprawek natywnych, a ich bledy wracaly
* w zgloszeniach i Sentry (decyzja Jana 2026-10-06: blokada wszystkiego starszego niz 0.4.x,
* okolo 8% ruchu). Nie ma przycisku "Pozniej" ani zadnej innej funkcji, celowo.
*
* Uzywa wylacznie modulow, ktore sa w kazdym APK od pierwszego wydania (react-native,
* expo-updates, expo-secure-store, expo-clipboard), wiec nie wywali sie na starym kodzie
* natywnym.
*
* Runtime 1.0 i 1.1 (0.2.1 i starsze) maja stary klucz podpisu, ktory przepadl: Android
* nie zainstaluje nowej wersji na wierzchu, trzeba odinstalowac, co czysci telefon. Dla nich
* kroki z kodem synchronizacji, jak w MigrationBanner. Od 1.2 nowa wersja instaluje sie
* na wierzchu i dane zostaja.
*/
import React from 'react';
import { Linking, Pressable, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import * as Updates from 'expo-updates';
const VERSION_URL = 'https://api.goon-foss.org/version';
const APK_FALLBACK = 'https://api.goon-foss.org/static/app-release.apk';
const SITE_URL = 'https://goon-foss.org/';
const OLD_KEY_RUNTIMES = ['1.0', '1.1'];
const C = {
bg: '#14110f',
card: '#1f1a17',
fg: '#f3ede4',
muted: '#a99f94',
accent: '#e05a6d',
};
export default function UpdateRequired() {
const runtime = Updates.runtimeVersion || '';
const oldKey = OLD_KEY_RUNTIMES.includes(runtime);
const [apkUrl, setApkUrl] = React.useState(APK_FALLBACK);
const [latest, setLatest] = React.useState<string | null>(null);
const [code, setCode] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
fetch(VERSION_URL)
.then((r) => r.json())
.then((v: { version?: string; apk_url?: string | null }) => {
if (v.apk_url && /^https?:\/\//.test(v.apk_url)) setApkUrl(v.apk_url);
if (v.version) setLatest(v.version);
})
.catch(() => {});
if (oldKey) {
SecureStore.getItemAsync('goon.device_id')
.then((id) => setCode(id))
.catch(() => {});
}
}, [oldKey]);
const copy = React.useCallback(async () => {
if (!code) return;
try {
const Clipboard = require('expo-clipboard');
await Clipboard.setStringAsync(code);
setCopied(true);
} catch {
// brak modulu: kod i tak da sie zaznaczyc i skopiowac recznie
}
}, [code]);
const open = (url: string) => Linking.openURL(url).catch(() => {});
return (
<View style={s.root}>
<StatusBar barStyle="light-content" backgroundColor={C.bg} />
<ScrollView contentContainerStyle={s.content}>
<Text style={s.title}>Update required</Text>
<Text style={s.lead}>
This version of Goon is no longer supported and does not get fixes anymore. Install
the new version{latest ? ` (${latest})` : ''} to keep watching.
</Text>
{oldKey ? (
<>
<Text style={s.lead}>
Your version was signed with an old key, so Android will not install the new one
over it. Uninstall first, then install the download. That wipes the phone, so copy
your sync code before you start.
</Text>
{code ? (
<>
<Text style={s.step}>1. Copy your sync code</Text>
<Pressable style={s.codeRow} onPress={copy}>
<Text style={s.code} selectable numberOfLines={1}>
{code}
</Text>
<Text style={s.copy}>{copied ? 'Copied' : 'Copy'}</Text>
</Pressable>
</>
) : null}
<Text style={s.step}>{code ? '2.' : '1.'} Download the new version</Text>
<Pressable style={s.btn} onPress={() => open(apkUrl)}>
<Text style={s.btnText}>Download the update</Text>
</Pressable>
<Text style={s.step}>{code ? '3.' : '2.'} Uninstall Goon, then open the file</Text>
<Text style={s.note}>
Long-press the app icon, Uninstall. Then open the .apk from your notifications or
Downloads.
</Text>
{code ? (
<>
<Text style={s.step}>4. Paste the code back</Text>
<Text style={s.note}>Settings, Backup &amp; sync, Restore: paste the code, tap Import.</Text>
</>
) : null}
</>
) : (
<>
<Pressable style={s.btn} onPress={() => open(apkUrl)}>
<Text style={s.btnText}>Download the update</Text>
</Pressable>
<Text style={s.note}>
Open the downloaded .apk from your notifications or Downloads and tap Install. It
installs over this version, your favourites and settings stay.
</Text>
</>
)}
<Pressable onPress={() => open(SITE_URL)} hitSlop={8}>
<Text style={s.link}>Download does not start? Open goon-foss.org</Text>
</Pressable>
<Text style={s.footer}>Installed: runtime {runtime || '?'}</Text>
</ScrollView>
</View>
);
}
const s = StyleSheet.create({
root: { flex: 1, backgroundColor: C.bg },
content: { padding: 24, paddingTop: 72, paddingBottom: 48 },
title: { color: C.fg, fontSize: 28, fontWeight: '800', marginBottom: 16 },
lead: { color: C.fg, fontSize: 16, lineHeight: 23, marginBottom: 14 },
step: { color: C.fg, fontSize: 16, fontWeight: '700', marginTop: 14, marginBottom: 8 },
note: { color: C.muted, fontSize: 14, lineHeight: 20, marginTop: 8 },
codeRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: C.card,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
},
code: { flex: 1, color: C.fg, fontSize: 15, fontFamily: 'monospace' },
copy: { color: C.accent, fontWeight: '700', marginLeft: 12 },
btn: { backgroundColor: C.accent, borderRadius: 12, paddingVertical: 15, alignItems: 'center', marginTop: 8 },
btnText: { color: '#fff', fontSize: 17, fontWeight: '800' },
link: { color: C.accent, fontSize: 14, marginTop: 28, textAlign: 'center' },
footer: { color: C.muted, fontSize: 12, marginTop: 24, textAlign: 'center' },
});

View file

@ -333,7 +333,6 @@ export class GoonClient {
error_kind?: string; error_kind?: string;
error_detail?: string; error_detail?: string;
ttff_ms?: number; ttff_ms?: number;
via?: string;
}): Promise<void> { }): Promise<void> {
try { try {
await this.request('/playback-events', { await this.request('/playback-events', {
@ -474,22 +473,6 @@ export class GoonClient {
} }
} }
// Ukryte strony (lista w GET /blacklist, pole `sites`).
async hideSite(origin: string): Promise<void> {
await this.request(`/blacklist/sites/${encodeURIComponent(origin)}`, { method: 'POST' });
}
async unhideSite(origin: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/blacklist/sites/${encodeURIComponent(origin)}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async listFavoriteMovies(): Promise<FavoriteMovieListOut> { async listFavoriteMovies(): Promise<FavoriteMovieListOut> {
return this.request('/favorites/movies'); return this.request('/favorites/movies');
} }

View file

@ -35,29 +35,6 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-06b',
date: 'October 2026',
items: [
'You can hide a site: long-press it in the Sites tab and pick Hide this site. Scenes that are only on that site disappear from every list, and on a scene that is also elsewhere its button is gone. Unhide in Settings, Hidden content.',
'Pinned sites now come across with your sync code too.',
],
},
{
id: '2026-10-06a',
date: 'October 2026',
items: [
'The player now respects the phone\'s rotation lock. With auto-rotate turned off in Android, the video stays upright even when you lie on your side. The fullscreen button still switches to landscape.',
'Edge: when a creator deletes their account, all of their clips disappear from the feed and their page within the hour, instead of over a week.',
],
},
{
id: '2026-10-02e',
date: 'October 2026',
items: [
'Edge: the NEW label next to the creator is gone. Around ten thousand clips arrive every day, so almost every clip had it and it told you nothing.',
],
},
{ {
id: '2026-10-02d', id: '2026-10-02d',
date: 'October 2026', date: 'October 2026',

View file

@ -248,6 +248,7 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
{clip.creator && onCreatorPress ? ( {clip.creator && onCreatorPress ? (
<MaterialCommunityIcons name="chevron-right" size={18} color="#fff" style={{ marginLeft: 2, opacity: 0.8 }} /> <MaterialCommunityIcons name="chevron-right" size={18} color="#fff" style={{ marginLeft: 2, opacity: 0.8 }} />
) : null} ) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</Pressable> </Pressable>
{clip.title ? ( {clip.title ? (
<Text style={styles.title} numberOfLines={2}> <Text style={styles.title} numberOfLines={2}>
@ -301,6 +302,16 @@ const styles = StyleSheet.create({
caption: { position: 'absolute', left: 14, right: 72, bottom: 24 }, caption: { position: 'absolute', left: 14, right: 72, bottom: 24 },
creatorRow: { flexDirection: 'row', alignItems: 'center' }, creatorRow: { flexDirection: 'row', alignItems: 'center' },
creator: { color: '#fff', fontFamily: fonts.display, fontSize: 16, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 }, creator: { color: '#fff', fontFamily: fonts.display, fontSize: 16, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 },
fresh: {
marginLeft: 8,
color: '#fff',
backgroundColor: theme.accent,
fontFamily: fonts.mono,
fontSize: 10,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
title: { color: '#eaeaea', fontFamily: fonts.body, fontSize: 14, marginTop: 4, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 }, title: { color: '#eaeaea', fontFamily: fonts.body, fontSize: 14, marginTop: 4, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 },
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 }, tagsRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
tagChip: { tagChip: {

View file

@ -116,10 +116,6 @@ export type RootStackParamList = {
// się automatycznie na WebView z tym URL'em, tam JS embed page sam pobiera // się automatycznie na WebView z tym URL'em, tam JS embed page sam pobiera
// m3u8 we własnym kontekście (Cookies + JA3 + IP set ok). // m3u8 we własnym kontekście (Cookies + JA3 + IP set ok).
fallbackEmbedUrl?: string; fallbackEmbedUrl?: string;
// Skad przyszedl adres (telemetria playback_events.via): 'phone' (resolver na
// telefonie w SceneDetail), 'backend', 'hoster' (resolver hostera w Playerze),
// 'webview' (wyciagniety ze strony), 'proxy' (zapasowe proxy), 'movie'.
via?: string;
}; };
}; };
@ -301,10 +297,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
// dawał ~80-90px czarnego paska w landscape, letterboxowało wideo. // dawał ~80-90px czarnego paska w landscape, letterboxowało wideo.
headerShown: false, headerShown: false,
contentStyle: { backgroundColor: '#000' }, contentStyle: { backgroundColor: '#000' },
// BEZ `orientation`: react-native-screens zamienia 'all' na FULL_SENSOR, ktory orientation: 'all',
// ignoruje systemowa blokade obrotu, i wygrywal z naszym unlockAsync
// (UNSPECIFIED respektuje blokade). Obrotem steruje PlayerScreenActive
// (zgloszenie 62d0192e: ekran sie obracal mimo wylaczonego autoobrotu).
}} }}
/> />
</Stack.Navigator> </Stack.Navigator>

View file

@ -51,21 +51,6 @@ export function BlacklistScreen() {
client.removeBlacklist(kind, id), client.removeBlacklist(kind, id),
onSuccess: afterChange, onSuccess: afterChange,
}); });
// Ukryte strony (przytrzymanie strony w zakladce Sites). Nazwy z listy stron, ktora
// i tak jest w cache; bez niej pokazujemy sam origin bez 'tube:'.
const sources = useQuery({ queryKey: ['sources'], queryFn: () => client.listSources(), staleTime: 60_000 });
const siteName = (origin: string) => {
const s = sources.data?.items.find((x) => x.origin === origin);
const raw = s?.display_name || origin.replace(/^tube:/, '');
return raw.replace(/\.[a-z]{2,5}$/i, '') || raw;
};
const unhideSite = useMutation({
mutationFn: (origin: string) => client.unhideSite(origin),
onSuccess: () => {
afterChange();
queryClient.invalidateQueries({ queryKey: ['scene'], refetchType: 'none' });
},
});
const bl = blacklistQuery.data; const bl = blacklistQuery.data;
const blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id)); const blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id));
@ -94,8 +79,7 @@ export function BlacklistScreen() {
<View style={{ paddingBottom: 40 }}> <View style={{ paddingBottom: 40 }}>
<Text style={styles.intro}> <Text style={styles.intro}>
Scenes tagged with, or from, anything you hide here stop showing up anywhere in Scenes tagged with, or from, anything you hide here stop showing up anywhere in
the app. Add a tag below (e.g. a category you never want to see). To hide a site, the app. Add a tag below (e.g. a category you never want to see).
long-press it in the Sites tab.
</Text> </Text>
<Text style={styles.sectionTitle}>Hide a tag</Text> <Text style={styles.sectionTitle}>Hide a tag</Text>
@ -146,18 +130,6 @@ export function BlacklistScreen() {
<Section title="Hidden studios" count={bl?.studios.length ?? 0}> <Section title="Hidden studios" count={bl?.studios.length ?? 0}>
{(bl?.studios ?? []).map((e) => renderRow('studio', e))} {(bl?.studios ?? []).map((e) => renderRow('studio', e))}
</Section> </Section>
<Section title="Hidden sites" count={bl?.sites?.length ?? 0}>
{(bl?.sites ?? []).map((origin) => (
<View key={`site-${origin}`} style={styles.row}>
<Text style={styles.rowName} numberOfLines={1}>
{siteName(origin)}
</Text>
<Pressable hitSlop={10} onPress={() => unhideSite.mutate(origin)} style={styles.removeBtn}>
<Text style={styles.removeText}>Unhide</Text>
</Pressable>
</View>
))}
</Section>
</> </>
)} )}
</View> </View>

View file

@ -212,7 +212,6 @@ function WatchChip({
const pDirect = p.direct_url; const pDirect = p.direct_url;
const pIsDirect = !!pDirect && pDirect !== p.stream_url; const pIsDirect = !!pDirect && pDirect !== p.stream_url;
navigation.navigate('Player', { navigation.navigate('Player', {
via: 'movie',
url: pDirect || p.stream_url || p.embed_url || pb.page_url, url: pDirect || p.stream_url || p.embed_url || pb.page_url,
origin: pb.origin, origin: pb.origin,
sceneId: movieId, sceneId: movieId,
@ -240,7 +239,6 @@ function WatchChip({
const best = links[0]; const best = links[0];
if (best && (best.direct_url || best.stream_url)) { if (best && (best.direct_url || best.stream_url)) {
navigation.navigate('Player', { navigation.navigate('Player', {
via: 'movie',
url: best.direct_url || best.stream_url!, url: best.direct_url || best.stream_url!,
origin: pb.origin, origin: pb.origin,
sceneId: movieId, sceneId: movieId,
@ -292,7 +290,6 @@ function WatchChip({
const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url; const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url;
const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url; const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url;
navigation.navigate('Player', { navigation.navigate('Player', {
via: 'movie',
url: target, url: target,
origin: pb.origin, origin: pb.origin,
// sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny), // sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny),

View file

@ -62,8 +62,6 @@ interface RouteParams {
headers?: Record<string, string>; headers?: Record<string, string>;
fallbackProxyUrl?: string; fallbackProxyUrl?: string;
fallbackEmbedUrl?: string; fallbackEmbedUrl?: string;
// Skad przyszedl adres, do telemetrii (patrz RootStackParamList.Player.via).
via?: string;
} }
// Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako // Wspólny helper dla native + WebView ekranu błędu: oznacza playback source jako
@ -266,8 +264,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// przyciskow siedzi z boku, stad tez lewy i prawy. // przyciskow siedzi z boku, stad tez lewy i prawy.
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { url, sceneId, origin: playOrigin, resolvePageUrl, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; const { url, sceneId, origin: playOrigin, resolvePageUrl, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params;
// Skad przyszedl adres, do telemetrii; ponowne rozwiazanie w tym samym ekranie dopisuje '-re'.
const viaRef = React.useRef(params.via || 'other');
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params); const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress. // 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
const upsertProgress = React.useCallback( const upsertProgress = React.useCallback(
@ -521,7 +517,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
if (!cancelled && freshUrl && freshUrl !== url) { if (!cancelled && freshUrl && freshUrl !== url) {
player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl); player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl);
player.play(); player.play();
viaRef.current = `${viaRef.current.replace(/-re$/, '')}-re`;
return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy return; // sukces → status zmieni się z 'error', łańcuch fallback nie ruszy
} }
// Phone-resolve zadziałał, ale dał TEN SAM URL, czyli strona parsuje się dobrze, // Phone-resolve zadziałał, ale dał TEN SAM URL, czyli strona parsuje się dobrze,
@ -539,7 +534,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
.find((u): u is string => !!u && u !== url); .find((u): u is string => !!u && u !== url);
if (!cancelled && viaProxy) { if (!cancelled && viaProxy) {
player.replace(viaProxy); player.replace(viaProxy);
viaRef.current = 'proxy-re';
player.play(); player.play();
return; return;
} }
@ -611,7 +605,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
headers: undefined, headers: undefined,
fallbackProxyUrl: undefined, // już użyty fallbackProxyUrl: undefined, // już użyty
fallbackEmbedUrl, // zostawiamy do step 3 fallbackEmbedUrl, // zostawiamy do step 3
via: 'proxy',
}); });
return; return;
} }
@ -628,8 +621,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
refererHost, refererHost,
title, title,
mode: 'webview', mode: 'webview',
// Droga, ktora wlasnie padla; WebView dopisze do niej '>wv', gdy wyciagnie adres.
via: viaRef.current,
}); });
} }
}, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]); }, [status, fallbackProxyUrl, fallbackEmbedUrl, url, nav, sceneId, durationSec, refererHost, title, player, source, playerError, resolvePageUrl, reResolveApplicable, reResolveDone, playOrigin]);
@ -651,7 +642,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
status: 'success', status: 'success',
scene_id: sceneId, scene_id: sceneId,
ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()), ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()),
via: viaRef.current,
}); });
} }
}, [status, playOrigin, client, sceneId]); }, [status, playOrigin, client, sceneId]);
@ -668,7 +658,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
scene_id: sceneId, scene_id: sceneId,
error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error', error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error',
error_detail: playerError?.message ? String(playerError.message).slice(0, 512) : undefined, error_detail: playerError?.message ? String(playerError.message).slice(0, 512) : undefined,
via: viaRef.current,
}); });
}, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]); }, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]);
@ -1866,9 +1855,6 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
[client, sceneId, entityKind], [client, sceneId, entityKind],
); );
const [extractedUrl, setExtractedUrl] = React.useState<string | null>(null); const [extractedUrl, setExtractedUrl] = React.useState<string | null>(null);
// Telemetria: adres wyciagniety z tej strony. Gdy WebView byl zapasem po padnietym
// odtwarzaczu, zostaje slad poprzedniej drogi (np. 'phone>wv').
const webViewVia = params.via ? `${params.via}>wv`.slice(0, 16) : 'webview';
const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle'); const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle');
const [resolveError, setResolveError] = React.useState<string | null>(null); const [resolveError, setResolveError] = React.useState<string | null>(null);
const [skipResolve, setSkipResolve] = React.useState(false); const [skipResolve, setSkipResolve] = React.useState(false);
@ -1982,7 +1968,6 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
refererHost, refererHost,
headers: result.headers, headers: result.headers,
mode: 'video', mode: 'video',
via: 'hoster',
}); });
} else { } else {
// Cichy resolver padl. Zamiast slepego zaulka z przyciskiem wchodzimy OD RAZU do // Cichy resolver padl. Zamiast slepego zaulka z przyciskiem wchodzimy OD RAZU do
@ -2113,9 +2098,8 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
durationSec, durationSec,
refererHost: refererHost || (initialHostRef.current || undefined), refererHost: refererHost || (initialHostRef.current || undefined),
mode: 'video', mode: 'video',
via: webViewVia,
}); });
}, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind, webViewVia]); }, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind]);
React.useEffect(() => { React.useEffect(() => {
upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch( upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch(
@ -2284,7 +2268,6 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
durationSec, durationSec,
refererHost, refererHost,
mode: 'video', mode: 'video',
via: webViewVia,
}) })
} }
> >

View file

@ -566,8 +566,6 @@ function PlaybackButton({
const { defaultQuality } = usePreferences(); const { defaultQuality } = usePreferences();
const [resolving, setResolving] = React.useState(false); const [resolving, setResolving] = React.useState(false);
const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null); const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null);
// Skad pochodza linki w pickerze jakosci (telemetria via): resolver na telefonie czy serwer.
const [qualityVia, setQualityVia] = React.useState<'phone' | 'backend'>('backend');
// Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera // Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera
// (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje // (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje
@ -618,7 +616,6 @@ function PlaybackButton({
link: StreamLink, link: StreamLink,
fallbackEmbedUrl?: string, fallbackEmbedUrl?: string,
allLinks?: StreamLink[], allLinks?: StreamLink[],
via: 'phone' | 'backend' = 'backend',
) => { ) => {
let refererHost: string | undefined; let refererHost: string | undefined;
try { try {
@ -660,7 +657,6 @@ function PlaybackButton({
headers: isDirect && link.headers ? link.headers : undefined, headers: isDirect && link.headers ? link.headers : undefined,
fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined, fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined,
fallbackEmbedUrl, fallbackEmbedUrl,
via,
// Pozostałe jakości → przełącznik w playerze (bez powrotu do listy źródeł). // Pozostałe jakości → przełącznik w playerze (bez powrotu do listy źródeł).
qualityLinks: (allLinks && allLinks.length > 1) qualityLinks: (allLinks && allLinks.length > 1)
? allLinks ? allLinks
@ -715,11 +711,8 @@ function PlaybackButton({
if (links.length > 0) { if (links.length > 0) {
markStarted(); markStarted();
const pick = pickAuto(links); const pick = pickAuto(links);
if (pick) await openAsVideo(pick, source.page_url, undefined, 'phone'); if (pick) await openAsVideo(pick, source.page_url);
else { else setQualityLinks(links);
setQualityVia('phone');
setQualityLinks(links);
}
return; return;
} }
// pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla // pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla
@ -799,10 +792,9 @@ function PlaybackButton({
const autoPick = pickAuto(directLinks); const autoPick = pickAuto(directLinks);
if (autoPick) { if (autoPick) {
markStarted(); markStarted();
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks, 'backend'); await openAsVideo(autoPick, fallbackEmbedUrl, directLinks);
return; return;
} }
setQualityVia('backend');
setQualityLinks(directLinks); setQualityLinks(directLinks);
} catch (e) { } catch (e) {
// 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception // 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception
@ -863,7 +855,7 @@ function PlaybackButton({
// direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc // direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc
// szukamy embed'a w `links` na resolve response wcześniej; tu już go nie // szukamy embed'a w `links` na resolve response wcześniej; tu już go nie
// mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct). // mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct).
await openAsVideo(link, undefined, qualityLinks || undefined, qualityVia); await openAsVideo(link, undefined, qualityLinks || undefined);
} else if (link.embed_url) { } else if (link.embed_url) {
let refererHost: string | undefined; let refererHost: string | undefined;
try { try {

View file

@ -11,7 +11,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Modal, Modal,
Pressable, Pressable,
@ -88,42 +87,11 @@ export function SitesScreen() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['favorite-sites'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['favorite-sites'] }),
}); });
// Ukryte strony (zgloszenie 3910b19b): znikaja z tej listy, a z list scen znikaja sceny,
// ktore maja zrodla tylko z ukrytych stron. Na serwerze, razem z reszta "Hidden content",
// odkrywanie w Settings, Hidden content.
const blQuery = useQuery({
queryKey: ['blacklist'],
queryFn: () => client.listBlacklist(),
staleTime: 60_000,
});
const hidden = useMemo(() => new Set(blQuery.data?.sites ?? []), [blQuery.data?.sites]);
const hideSite = useMutation({
mutationFn: (origin: string) => client.hideSite(origin),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['blacklist'] });
qc.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
qc.invalidateQueries({ queryKey: ['scene'], refetchType: 'none' });
},
onError: (e) => Alert.alert('Could not hide the site', e instanceof Error ? e.message : String(e)),
});
const openMenu = (item: SourceOut) => {
const isPinned = pinned.has(item.origin);
Alert.alert(prettySiteName(item.display_name), undefined, [
{ text: isPinned ? 'Unpin' : 'Pin to top', onPress: () => togglePin.mutate(item.origin) },
{
text: 'Hide this site',
style: 'destructive',
onPress: () => hideSite.mutate(item.origin),
},
{ text: 'Cancel', style: 'cancel' },
]);
};
// Sort + filter client-side, lista ma <50 entries, nie warto roundtripować. // Sort + filter client-side, lista ma <50 entries, nie warto roundtripować.
// Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność // Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność
// zachowana. Dla 'recent' sortujemy po last_scraped_at DESC. // zachowana. Dla 'recent' sortujemy po last_scraped_at DESC.
const items = useMemo<SourceOut[]>(() => { const items = useMemo<SourceOut[]>(() => {
const all = (data?.items ?? []).filter((s) => !hidden.has(s.origin)); const all = data?.items ?? [];
const filtered = debouncedQ const filtered = debouncedQ
? all.filter( ? all.filter(
(s) => (s) =>
@ -150,7 +118,7 @@ export function SitesScreen() {
} }
if (pinned.size === 0) return sorted; if (pinned.size === 0) return sorted;
return [...sorted.filter((s) => pinned.has(s.origin)), ...sorted.filter((s) => !pinned.has(s.origin))]; return [...sorted.filter((s) => pinned.has(s.origin)), ...sorted.filter((s) => !pinned.has(s.origin))];
}, [data?.items, debouncedQ, order, pinned, hidden]); }, [data?.items, debouncedQ, order, pinned]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -173,15 +141,8 @@ export function SitesScreen() {
}} }}
onClose={() => setSortOpen(false)} onClose={() => setSortOpen(false)}
/> />
{favQuery.data && pinned.size === 0 && hidden.size === 0 ? ( {favQuery.data && pinned.size === 0 ? (
<Text style={styles.hint}>long-press a site to pin it or hide it</Text> <Text style={styles.hint}>long-press a site to pin it to the top</Text>
) : null}
{hidden.size > 0 ? (
<Pressable onPress={() => navigation.navigate('Blacklist')} hitSlop={6}>
<Text style={styles.hint}>
{hidden.size} hidden {hidden.size === 1 ? 'site' : 'sites'}, tap to manage
</Text>
</Pressable>
) : null} ) : null}
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />} {isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
@ -206,7 +167,7 @@ export function SitesScreen() {
name: prettySiteName(item.display_name), name: prettySiteName(item.display_name),
}) })
} }
onLongPress={() => openMenu(item)} onLongPress={() => togglePin.mutate(item.origin)}
onShowRating={() => setDetail(item)} onShowRating={() => setDetail(item)}
/> />
)} )}

View file

@ -410,8 +410,6 @@ export interface BlacklistOut {
performers: BlacklistEntry[]; performers: BlacklistEntry[];
studios: BlacklistEntry[]; studios: BlacklistEntry[];
tags: BlacklistEntry[]; tags: BlacklistEntry[];
// Ukryte strony (originy, np. 'tube:porndoecom'); starszy serwer ich nie zwraca.
sites?: string[];
} }
export type BlacklistKind = 'performer' | 'studio' | 'tag'; export type BlacklistKind = 'performer' | 'studio' | 'tag';

View file

@ -1,7 +0,0 @@
// Punkt wejscia paczki "zaktualizuj aplikacje" dla starych runtime'ow (1.0-1.3).
// Zwykla aplikacja startuje z App.tsx; ten plik buduje tylko
// `scripts/publish_update.py --entry update-required.tsx`. Opis w src/UpdateRequired.tsx.
import { registerRootComponent } from 'expo';
import UpdateRequired from './src/UpdateRequired';
registerRootComponent(UpdateRequired);

View file

@ -42,8 +42,9 @@ MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist" DIST = MOBILE / "dist"
RUNTIME_DEFAULT = "1.4" # == EXPO_RUNTIME_VERSION w AndroidManifest + app.json runtimeVersion. RUNTIME_DEFAULT = "1.4" # == EXPO_RUNTIME_VERSION w AndroidManifest + app.json runtimeVersion.
# Historia kanalow: 1.0 -> 1.1 (2026-06) -> 1.2 (2026-08-11, APK 0.2.2 z expo-keep-awake). # Historia kanalow: 1.0 -> 1.1 (2026-06) -> 1.2 (2026-08-11, APK 0.2.2 z expo-keep-awake).
# Od 2026-10-06 kanaly 1.0-1.3 (wersje sprzed 0.4.x) maja paczke z ekranem blokady # 1.1 wciaz ZYJE: siedza tam instalacje sprzed 0.2.2, ktore nie moga sie zaktualizowac in-place
# (--entry update-required.tsx). Nie publikuj tam zwyklej aplikacji, to zdejmuje blokade. # (stary klucz podpisujacy przepadl) i dostaly baner migracji. Poprawki dla nich publikuj
# jawnie przez --runtime 1.1, dopoki ktos tam jeszcze jest.
# Operator config, set in your shell / .env.local before running this script. # Operator config, set in your shell / .env.local before running this script.
# Defaults are placeholders intended to fail loudly if you forgot to configure. # Defaults are placeholders intended to fail loudly if you forgot to configure.
VPS = os.environ.get("GOON_VPS_SSH", "root@your-vps.example.com") VPS = os.environ.get("GOON_VPS_SSH", "root@your-vps.example.com")
@ -140,39 +141,15 @@ def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--runtime", default=RUNTIME_DEFAULT) ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/") ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/")
ap.add_argument(
"--entry",
help="inny punkt wejscia niz App.tsx, np. update-required.tsx (ekran blokady starych wersji)",
)
args = ap.parse_args() args = ap.parse_args()
# Ekran blokady na biezacym kanale zablokowalby wszystkich uzytkownikow.
if args.entry and args.runtime == RUNTIME_DEFAULT:
print(f"ERROR: --entry {args.entry} na biezacym runtime {RUNTIME_DEFAULT} zablokowalby wszystkich. Przerwane.")
return 1
if not args.skip_export: if not args.skip_export:
if DIST.exists(): if DIST.exists():
import shutil import shutil
shutil.rmtree(DIST) shutil.rmtree(DIST)
# `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS # `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS
# w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`. # w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`.
# `--entry`: expo export bierze punkt wejscia z pola `main` w package.json, wiec run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
# podmieniamy je na czas eksportu i zawsze przywracamy.
pkg_path = MOBILE / "package.json"
pkg_orig = pkg_path.read_text(encoding="utf-8")
if args.entry:
if not (MOBILE / args.entry).exists():
print(f"ERROR: brak pliku mobile/{args.entry}")
return 1
pkg = json.loads(pkg_orig)
pkg["main"] = args.entry
pkg_path.write_text(json.dumps(pkg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
try:
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
finally:
if args.entry:
pkg_path.write_text(pkg_orig, encoding="utf-8")
upload_sourcemaps() upload_sourcemaps()
strip_sourcemaps() strip_sourcemaps()