Compare commits

...

9 commits

Author SHA1 Message Date
goon-foss
ce174b671e feat(mobile): ukrywanie stron w zakladce Sites
Some checks failed
Backend tests / test (push) Has been cancelled
Przytrzymanie strony otwiera menu Pin/Unpin, Hide this site. Ukryte znikaja
z listy, nad nia "N hidden sites, tap to manage" prowadzi do Hidden content,
gdzie nowa sekcja Hidden sites ma Unhide. Emulator: ukrycie sxyprn, sekcja
z wpisem, odkrycie i powrot na liste, wpis w hidden_sites i jego usuniecie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:37:15 +02:00
goon-foss
6540d4a009 feat(blacklist): ukrywanie stron per urzadzenie
Zgloszenie 3910b19b: brak przycisku ukrywania stron. Tabela hidden_sites
(0054), POST/DELETE /blacklist/sites/{origin}, pole sites w GET /blacklist.
Scena znika z list tylko gdy wszystkie jej zywe zrodla sa z ukrytych stron
(ta sama scena z innego serwisu zostaje); w szczegolach sceny przyciski
ukrytych stron znikaja, o ile zostaje inne zrodlo.

Cache pustosci blacklist trzyma teraz, ktore listy sa niepuste, wiec ukryte
strony nie doklejaja trzech pustych NOT EXISTS, i jest czyszczony przy kazdej
zmianie (wczesniej pierwszy wpis dzialal po 5 minutach). Kod synchronizacji
kopiuje hidden_sites oraz favorite_sites, ktorych tam brakowalo.

Sandbox na produkcyjnej bazie: strona porndoe 42 z 60 scen tylko z porndoe
przed ukryciem, 0 po; feed 0,34 s, duze tagi 0,3 do 0,8 s z dwiema ukrytymi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:34:52 +02:00
goon-foss
2feef620a1 feat(telemetry): playback_events.via, skad przyszedl adres w odtwarzaczu
Po przejsciu porndoe na resolver po stronie telefonu 35 urzadzen dalej
dostawalo wylacznie 403 i nie dalo sie odroznic, czy pada nowa droga, zapas
przez WebView, czy stara paczka. Apka wysyla teraz via: phone, backend,
hoster, webview, proxy, movie; '-re' po ponownym rozwiazaniu w tym samym
ekranie, '<droga>>wv' gdy adres wyciagnal WebView jako zapas. NULL = paczka
sprzed tej zmiany. Migracja 0053 (kolumna nullable, sama zmiana metadanych).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:25:56 +02:00
goon-foss
189dbdf17d docs(publish): kanaly 1.0-1.3 maja ekran blokady, nie publikowac tam zwyklej apki
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:09:42 +02:00
goon-foss
3b061a5807 feat(mobile): ekran "zaktualizuj" dla wersji starszych niz 0.4.x
Osobny punkt wejscia (update-required.tsx) z samym ekranem blokady, bez
reszty aplikacji i bez przycisku "Pozniej". Publikowany tylko na kanaly OTA
starych runtime'ow 1.0-1.3 (0.3.0 to dzis ~8% ruchu): stare APK nie dostaja
poprawek natywnych, a ich bledy wracaly w zgloszeniach. Uzywa tylko modulow
obecnych w kazdym APK. Runtime 1.0/1.1 (stary klucz podpisu) dostaje kroki
z kodem synchronizacji, od 1.2 nowa wersja instaluje sie na wierzchu.

publish_update.py --entry podmienia pole main w package.json na czas
eksportu i odmawia publikacji na biezacy runtime.

Emulator: prawdziwe APK 0.3.0 i 0.2.1 z ta paczka pokazuja ekran, pobieranie
otwiera APK w przegladarce, kopiowanie kodu dziala, Wstecz zamyka apke,
0.4.2 instaluje sie na 0.3.0 i startuje normalnie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:06:21 +02:00
goon-foss
7e48cdee06 fix(clips): wyscig przy pierwszym zapisie afinicznosci urzadzenia
Onboarding Edge i zdarzenia z pierwszych klipow przychodza naraz, oba
widzialy brak wiersza clip_affinity i oba robily INSERT, drugi konczyl sie
UniqueViolation i bledem 500 (GOON-7X). Pusty wiersz przez ON CONFLICT DO
NOTHING, potem odczyt z blokada i dodanie naszych przyrostow do tego, co
zapisalo rownolegle zadanie. Test z dwoma watkami: bez bledow, obie wagi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:52:54 +02:00
goon-foss
5001d79bbc fix(player): odtwarzacz respektuje systemowa blokade obrotu
Ekran Playera mial w nawigacji orientation: 'all', ktore react-native-screens
zamienia na SCREEN_ORIENTATION_FULL_SENSOR, a ten ignoruje blokade obrotu
z ustawien Androida i wygrywal z naszym unlockAsync (UNSPECIFIED). Zgloszenie
62d0192e: ekran sie obracal mimo wylaczonego autoobrotu. Opcja usunieta,
obrotem steruje tylko PlayerScreenActive. Emulator: przed zmiana przy
blokadzie FULL_SENSOR i obrot do 270, po zmianie UNSPECIFIED i pion,
a przy wlaczonym autoobrocie ekran dalej obraca sie za telefonem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:48:16 +02:00
goon-foss
8971d81ef9 fix(clips): skasowany klip uruchamia sprawdzenie reszty klipow tworcy
Tworcy kasuja zwykle cale konto naraz, a przemial przy 1000 klipow na
godzine robi okrazenie katalogu w okolo tydzien. Zgloszenie d343a9b6:
strona tworcy z pustymi kafelkami, 120 "zywych" klipow, kazdy 403.
Po oznaczeniu martwego klipu (przemial albo zgloszenie z apki) od razu
HEAD na pozostalych zywych klipach tego tworcy niesprawdzanych od 6 h,
do 600 na przebieg. Sprawdzone na produkcji: 120 z 120 oznaczone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:38:54 +02:00
goon-foss
e84e49a3a9 feat(edge): bez znaczka NEW przy tworcy
Przy ~10 tys. nowych klipow dziennie 40% zywego katalogu miesci sie w
progu 7 dni, wiec znaczek stal przy prawie kazdym klipie i nic nie mowil.
Pole is_fresh zostaje w API (swiezosc dalej liczy sie w punktacji feedu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:01:40 +02:00
24 changed files with 690 additions and 68 deletions

View file

@ -0,0 +1,32 @@
"""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

@ -0,0 +1,31 @@
"""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,18 +9,23 @@ Endpointy:
DELETE /blacklist/{kind}/{entity_id} usuń
`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
import re
import uuid
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import exists, select
from sqlalchemy.orm import Session
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.db import get_session
from app.models.blacklist import (
@ -28,7 +33,9 @@ from app.models.blacklist import (
BlacklistedStudio,
BlacklistedTag,
)
from app.models.hidden_site import HiddenSite
from app.models.performer import Performer
from app.models.playback_source import PlaybackSource
from app.models.studio import Studio
from app.models.tag import Tag
@ -49,6 +56,8 @@ class BlacklistOut(BaseModel):
performers: list[BlacklistEntry]
studios: list[BlacklistEntry]
tags: list[BlacklistEntry]
# Ukryte strony: same originy ('tube:porndoecom'), nazwy apka bierze z /sources.
sites: list[str] = []
@router.get("", response_model=BlacklistOut)
@ -74,13 +83,54 @@ def list_blacklist(
.where(BlacklistedTag.device_id == device_id)
.order_by(Tag.name)
).all()
sites = session.execute(
select(HiddenSite.origin).where(HiddenSite.device_id == device_id).order_by(HiddenSite.origin)
).scalars().all()
return BlacklistOut(
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],
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):
if kind == "performer":
return BlacklistedPerformer, Performer, "performer_id"
@ -105,6 +155,7 @@ def add_blacklist(
return {"kind": kind, "id": str(entity_id), "created": False}
session.add(bl_model(**{"device_id": device_id, fk: entity_id}))
session.commit()
invalidate_blacklist_cache(device_id)
return {"kind": kind, "id": str(entity_id), "created": True}
@ -121,3 +172,4 @@ def remove_blacklist(
return # idempotent
session.delete(row)
session.commit()
invalidate_blacklist_cache(device_id)

View file

@ -214,8 +214,20 @@ 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:
if row is None:
row = ClipAffinity(device_id=device_id)
session.add(row)
# Pierwszy zapis dla urzadzenia. Onboarding i zdarzenia z pierwszych klipow
# przychodza naraz, oba widzialy brak wiersza i oba robily INSERT, drugi konczyl
# 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.niche_weights = aff.niches
row.creator_weights = aff.creators

View file

@ -56,6 +56,10 @@ class ImportOut(BaseModel):
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
("saved_searches", "query"),
("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,6 +32,9 @@ class PlaybackEventIn(BaseModel):
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512)
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)
@ -49,6 +52,7 @@ def post_playback_event(
error_detail=body.error_detail if body.status == "error" else None,
ttff_ms=body.ttff_ms if body.status == "success" else None,
device_id=device_id,
via=body.via,
)
)
session.commit()

View file

@ -187,31 +187,47 @@ def _jav_source_exists():
# 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.
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
# Trzymamy, KTORE listy sa niepuste, nie tylko "czy cokolwiek": ukryte strony (2026-10-06)
# 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
def _blacklists_empty(session: Session, device_id: str) -> bool:
"""True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
def invalidate_blacklist_cache(device_id: str) -> None:
_BLACKLIST_EMPTY_CACHE.pop(device_id, None)
def _blacklist_kinds(session: Session, device_id: str) -> frozenset[str]:
"""Niepuste listy TEGO urzadzenia: podzbior {'performer', 'studio', 'tag', 'site'}."""
import time as _time
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
BlacklistedTag,
)
from app.models.hidden_site import HiddenSite
now = _time.monotonic()
cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
return cached[1]
has_any = session.execute(
row = session.execute(
select(
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(BlacklistedTag).where(BlacklistedTag.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(BlacklistedTag).where(BlacklistedTag.device_id == device_id)),
exists(select(1).select_from(HiddenSite).where(HiddenSite.device_id == device_id)),
)
).scalar_one()
_BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
return not has_any
).one()
kinds = frozenset(k for k, present in zip(("performer", "studio", "tag", "site"), row) if present)
_BLACKLIST_EMPTY_CACHE[device_id] = (now, kinds)
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
@ -417,9 +433,17 @@ 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:
"""NOT-EXISTS klauzule blacklist device (performer/studio/tag). [] gdy wszystkie puste."""
if _blacklists_empty(session, device_id):
"""Klauzule blacklist device (performer/studio/tag) i ukrytych stron. [] gdy wszystko puste."""
kinds = _blacklist_kinds(session, device_id)
if not kinds:
return []
from app.models.blacklist import (
BlacklistedPerformer,
@ -427,31 +451,63 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
BlacklistedTag,
)
return [
~exists(
select(1)
.select_from(ScenePerformer)
.join(
BlacklistedPerformer,
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
& (BlacklistedPerformer.device_id == device_id),
out: list = []
if "performer" in kinds:
out.append(
~exists(
select(1)
.select_from(ScenePerformer)
.join(
BlacklistedPerformer,
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
& (BlacklistedPerformer.device_id == device_id),
)
.where(ScenePerformer.scene_id == Scene.id)
)
.where(ScenePerformer.scene_id == Scene.id)
),
~Scene.studio_id.in_(
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),
)
if "studio" in kinds:
out.append(
~Scene.studio_id.in_(
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.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):
@ -1147,7 +1203,15 @@ def get_scene(
scene = session.get(Scene, scene_id)
if scene is None:
raise HTTPException(status_code=404, detail="scene not found")
return _build_scene_out(session, scene, device_id=device_id)
out = _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)

View file

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

25
app/models/hidden_site.py Normal file
View file

@ -0,0 +1,25 @@
"""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,6 +42,9 @@ class PlaybackEvent(Base):
error_detail: Mapped[str | None] = mapped_column(String(512), nullable=True)
# time-to-first-frame [ms], "szybkość" (tylko przy success; None gdy nie zmierzono).
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)
created_at: Mapped[datetime] = mapped_column(
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(
select(Clip.id, Clip.hd_url, Clip.sd_url).where(Clip.id.in_(clip_ids), Clip.dead_at.is_(None))
).all()
marked = 0
marked_ids: list = []
with httpx.Client(headers=_REAP_UA, timeout=15, follow_redirects=True) as http:
for cid, hd, sd in rows:
url = hd or sd
@ -212,12 +212,82 @@ 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"),
{"r": f"{reason} (apka)", "id": cid},
)
marked += 1
marked_ids.append(cid)
marked = len(marked_ids)
if marked:
log.info("clips: %d zgloszonych z apki oznaczone jako martwe", marked)
sweep_creators(marked_ids)
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]:
"""Reaper martwych klipow: tworcy kasuja klipy, RedGifs oddaje wtedy 404/410 na pliku.
@ -267,6 +337,7 @@ 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]
if 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)}
log.info("clips reaper: %s", out)
return out

View file

@ -0,0 +1,159 @@
/**
* 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,6 +333,7 @@ export class GoonClient {
error_kind?: string;
error_detail?: string;
ttff_ms?: number;
via?: string;
}): Promise<void> {
try {
await this.request('/playback-events', {
@ -473,6 +474,22 @@ 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> {
return this.request('/favorites/movies');
}

View file

@ -35,6 +35,29 @@ export type 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',
date: 'October 2026',

View file

@ -248,7 +248,6 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
{clip.creator && onCreatorPress ? (
<MaterialCommunityIcons name="chevron-right" size={18} color="#fff" style={{ marginLeft: 2, opacity: 0.8 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</Pressable>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
@ -302,16 +301,6 @@ const styles = StyleSheet.create({
caption: { position: 'absolute', left: 14, right: 72, bottom: 24 },
creatorRow: { flexDirection: 'row', alignItems: 'center' },
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 },
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
tagChip: {

View file

@ -116,6 +116,10 @@ export type RootStackParamList = {
// 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).
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;
};
};
@ -297,7 +301,10 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
// dawał ~80-90px czarnego paska w landscape, letterboxowało wideo.
headerShown: false,
contentStyle: { backgroundColor: '#000' },
orientation: 'all',
// BEZ `orientation`: react-native-screens zamienia 'all' na FULL_SENSOR, ktory
// 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>

View file

@ -51,6 +51,21 @@ export function BlacklistScreen() {
client.removeBlacklist(kind, id),
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 blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id));
@ -79,7 +94,8 @@ export function BlacklistScreen() {
<View style={{ paddingBottom: 40 }}>
<Text style={styles.intro}>
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).
the app. Add a tag below (e.g. a category you never want to see). To hide a site,
long-press it in the Sites tab.
</Text>
<Text style={styles.sectionTitle}>Hide a tag</Text>
@ -130,6 +146,18 @@ export function BlacklistScreen() {
<Section title="Hidden studios" count={bl?.studios.length ?? 0}>
{(bl?.studios ?? []).map((e) => renderRow('studio', e))}
</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 file

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

View file

@ -62,6 +62,8 @@ interface RouteParams {
headers?: Record<string, string>;
fallbackProxyUrl?: 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
@ -264,6 +266,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
// przyciskow siedzi z boku, stad tez lewy i prawy.
const insets = useSafeAreaInsets();
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);
// 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress.
const upsertProgress = React.useCallback(
@ -517,6 +521,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
if (!cancelled && freshUrl && freshUrl !== url) {
player.replace(fresh?.headers ? { uri: freshUrl, headers: fresh.headers } : freshUrl);
player.play();
viaRef.current = `${viaRef.current.replace(/-re$/, '')}-re`;
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,
@ -534,6 +539,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
.find((u): u is string => !!u && u !== url);
if (!cancelled && viaProxy) {
player.replace(viaProxy);
viaRef.current = 'proxy-re';
player.play();
return;
}
@ -605,6 +611,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
headers: undefined,
fallbackProxyUrl: undefined, // już użyty
fallbackEmbedUrl, // zostawiamy do step 3
via: 'proxy',
});
return;
}
@ -621,6 +628,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
refererHost,
title,
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]);
@ -642,6 +651,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
status: 'success',
scene_id: sceneId,
ttff_ms: Date.now() - (playTelemetryMountRef.current ?? Date.now()),
via: viaRef.current,
});
}
}, [status, playOrigin, client, sceneId]);
@ -658,6 +668,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
scene_id: sceneId,
error_kind: isGoneError(playerError?.message) ? 'gone' : 'player_error',
error_detail: playerError?.message ? String(playerError.message).slice(0, 512) : undefined,
via: viaRef.current,
});
}, [status, playOrigin, fallbackProxyUrl, fallbackEmbedUrl, url, playerError, client, sceneId]);
@ -1855,6 +1866,9 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
[client, sceneId, entityKind],
);
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 [resolveError, setResolveError] = React.useState<string | null>(null);
const [skipResolve, setSkipResolve] = React.useState(false);
@ -1968,6 +1982,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
refererHost,
headers: result.headers,
mode: 'video',
via: 'hoster',
});
} else {
// Cichy resolver padl. Zamiast slepego zaulka z przyciskiem wchodzimy OD RAZU do
@ -2098,8 +2113,9 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
durationSec,
refererHost: refererHost || (initialHostRef.current || undefined),
mode: 'video',
via: webViewVia,
});
}, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind]);
}, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind, webViewVia]);
React.useEffect(() => {
upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch(
@ -2268,6 +2284,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
durationSec,
refererHost,
mode: 'video',
via: webViewVia,
})
}
>

View file

@ -566,6 +566,8 @@ function PlaybackButton({
const { defaultQuality } = usePreferences();
const [resolving, setResolving] = React.useState(false);
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
// (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje
@ -616,6 +618,7 @@ function PlaybackButton({
link: StreamLink,
fallbackEmbedUrl?: string,
allLinks?: StreamLink[],
via: 'phone' | 'backend' = 'backend',
) => {
let refererHost: string | undefined;
try {
@ -657,6 +660,7 @@ function PlaybackButton({
headers: isDirect && link.headers ? link.headers : undefined,
fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined,
fallbackEmbedUrl,
via,
// Pozostałe jakości → przełącznik w playerze (bez powrotu do listy źródeł).
qualityLinks: (allLinks && allLinks.length > 1)
? allLinks
@ -711,8 +715,11 @@ function PlaybackButton({
if (links.length > 0) {
markStarted();
const pick = pickAuto(links);
if (pick) await openAsVideo(pick, source.page_url);
else setQualityLinks(links);
if (pick) await openAsVideo(pick, source.page_url, undefined, 'phone');
else {
setQualityVia('phone');
setQualityLinks(links);
}
return;
}
// pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla
@ -792,9 +799,10 @@ function PlaybackButton({
const autoPick = pickAuto(directLinks);
if (autoPick) {
markStarted();
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks);
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks, 'backend');
return;
}
setQualityVia('backend');
setQualityLinks(directLinks);
} catch (e) {
// 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception
@ -855,7 +863,7 @@ function PlaybackButton({
// 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
// mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct).
await openAsVideo(link, undefined, qualityLinks || undefined);
await openAsVideo(link, undefined, qualityLinks || undefined, qualityVia);
} else if (link.embed_url) {
let refererHost: string | undefined;
try {

View file

@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Pressable,
@ -87,11 +88,42 @@ export function SitesScreen() {
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ć.
// Backend zwraca pre-sorted po scene_count DESC, więc dla 'popular' kolejność
// zachowana. Dla 'recent' sortujemy po last_scraped_at DESC.
const items = useMemo<SourceOut[]>(() => {
const all = data?.items ?? [];
const all = (data?.items ?? []).filter((s) => !hidden.has(s.origin));
const filtered = debouncedQ
? all.filter(
(s) =>
@ -118,7 +150,7 @@ export function SitesScreen() {
}
if (pinned.size === 0) return sorted;
return [...sorted.filter((s) => pinned.has(s.origin)), ...sorted.filter((s) => !pinned.has(s.origin))];
}, [data?.items, debouncedQ, order, pinned]);
}, [data?.items, debouncedQ, order, pinned, hidden]);
return (
<View style={styles.container}>
@ -141,8 +173,15 @@ export function SitesScreen() {
}}
onClose={() => setSortOpen(false)}
/>
{favQuery.data && pinned.size === 0 ? (
<Text style={styles.hint}>long-press a site to pin it to the top</Text>
{favQuery.data && pinned.size === 0 && hidden.size === 0 ? (
<Text style={styles.hint}>long-press a site to pin it or hide it</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}
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
@ -167,7 +206,7 @@ export function SitesScreen() {
name: prettySiteName(item.display_name),
})
}
onLongPress={() => togglePin.mutate(item.origin)}
onLongPress={() => openMenu(item)}
onShowRating={() => setDetail(item)}
/>
)}

View file

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

View file

@ -0,0 +1,7 @@
// 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,9 +42,8 @@ MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist"
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).
# 1.1 wciaz ZYJE: siedza tam instalacje sprzed 0.2.2, ktore nie moga sie zaktualizowac in-place
# (stary klucz podpisujacy przepadl) i dostaly baner migracji. Poprawki dla nich publikuj
# jawnie przez --runtime 1.1, dopoki ktos tam jeszcze jest.
# Od 2026-10-06 kanaly 1.0-1.3 (wersje sprzed 0.4.x) maja paczke z ekranem blokady
# (--entry update-required.tsx). Nie publikuj tam zwyklej aplikacji, to zdejmuje blokade.
# Operator config, set in your shell / .env.local before running this script.
# Defaults are placeholders intended to fail loudly if you forgot to configure.
VPS = os.environ.get("GOON_VPS_SSH", "root@your-vps.example.com")
@ -141,15 +140,39 @@ def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
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()
# 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 DIST.exists():
import shutil
shutil.rmtree(DIST)
# `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS
# w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`.
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
# `--entry`: expo export bierze punkt wejscia z pola `main` w package.json, wiec
# 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()
strip_sourcemaps()