Compare commits

..

No commits in common. "45ee7a81e11f34bf4a177b348ebab556585ff1ec" and "2cdc3d69da123afa29d3711bb17fc105222add54" have entirely different histories.

48 changed files with 370 additions and 2658 deletions

View file

@ -1,89 +0,0 @@
"""Data sortowania sceny powielona w scene_tags (GOON-64).
Lista "scen z tagiem X od najnowszych" (ekran tagu w apce, 200 scen) sortuje po dacie z
`scenes`, a filtruje po `scene_tags`. Zaden indeks nie laczy tych dwoch rzeczy, wiec
Postgres idzie po `ix_scenes_effective_date_desc` od najnowszej sceny i kazda sprawdza, czy
ma tag. Dla tagow sredniej wielkosci to dziesiatki tysiecy sprawdzen: `rough-sex` (41,5 tys.
scen) przegladal 46 tys. scen, zeby znalezc 201, czyli ~460 tys. blokow. 0,5 s na rozgrzanym
cache, 7-15 s na zimnym (Sentry GOON-64, pomiar 2026-09-18). Male tagi planer odwraca (od
tagu), duze znajduja 201 scen po kilku tysiacach, wiec bola te posrodku.
Dwie przebudowy zapytania nie pomogly: kandydaci po tagu bez ciezkich filtrow dali ~2x na
`rough-sex`, a wymuszenie "najpierw tag" robilo z duzych tagow 22-30 s (hash join po calym
`scenes`). Cache nie ma sensu: 237 wejsc w 48 h na 168 roznych tagow.
Dlatego `sort_date` = COALESCE(release_date, created_at w UTC jako data), to samo wyrazenie
co `_effective_date_desc()` w app/api/scenes.py, trzymane tez przy tagu. Indeks (tag_id,
sort_date DESC, scene_id DESC) z migracji 0043 daje od razu najnowsze sceny danego tagu.
Utrzymanie w bazie, nie w aplikacji, bo do scene_tags pisze wiele sciezek (ingest, merge,
porzadki tagow, skrypty):
- BEFORE INSERT / UPDATE OF scene_id na scene_tags: data z wiersza sceny,
- AFTER UPDATE OF release_date, created_at na scenes: poprawia tagi tej sceny (scena
dostaje date premiery z TPDB/StashDB, merge bierze wczesniejsze created_at).
Kolumna nullable i bez DEFAULT, wiec ALTER jest natychmiastowy. Istniejace 23 mln wierszy
wypelnia skrypt partiami w tle (scripts/backfill_scene_tags_sort_date.py), dopiero potem
indeks (0043) i przelaczenie zapytania. lock_timeout, bo ALTER czeka za kazdym dlugim
zapytaniem na scene_tags i w tym czasie blokuje wszystkie kolejne.
Revision ID: 0042_scene_tags_sort_date
Revises: 0041_scene_ext_refs_scene_source
Create Date: 2026-09-18
"""
from __future__ import annotations
from alembic import op
revision = "0042_scene_tags_sort_date"
down_revision: str | None = "0041_scene_ext_refs_scene_source"
branch_labels = None
depends_on = None
_SORT_DATE = "COALESCE({t}.release_date, ({t}.created_at AT TIME ZONE 'UTC')::date)"
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scene_tags ADD COLUMN IF NOT EXISTS sort_date date")
op.execute(f"""
CREATE OR REPLACE FUNCTION scene_tags_fill_sort_date() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
SELECT {_SORT_DATE.format(t='s')} INTO NEW.sort_date
FROM scenes s WHERE s.id = NEW.scene_id;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER trg_scene_tags_fill_sort_date
BEFORE INSERT OR UPDATE OF scene_id ON scene_tags
FOR EACH ROW EXECUTE FUNCTION scene_tags_fill_sort_date()
""")
op.execute(f"""
CREATE OR REPLACE FUNCTION scenes_propagate_sort_date() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE d date := {_SORT_DATE.format(t='NEW')};
BEGIN
UPDATE scene_tags SET sort_date = d
WHERE scene_id = NEW.id AND sort_date IS DISTINCT FROM d;
RETURN NULL;
END $$
""")
op.execute("""
CREATE TRIGGER trg_scenes_propagate_sort_date
AFTER UPDATE OF release_date, created_at ON scenes
FOR EACH ROW
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date
OR OLD.created_at IS DISTINCT FROM NEW.created_at)
EXECUTE FUNCTION scenes_propagate_sort_date()
""")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("DROP TRIGGER IF EXISTS trg_scenes_propagate_sort_date ON scenes")
op.execute("DROP TRIGGER IF EXISTS trg_scene_tags_fill_sort_date ON scene_tags")
op.execute("DROP FUNCTION IF EXISTS scenes_propagate_sort_date()")
op.execute("DROP FUNCTION IF EXISTS scene_tags_fill_sort_date()")
op.execute("ALTER TABLE scene_tags DROP COLUMN IF EXISTS sort_date")

View file

@ -1,40 +0,0 @@
"""Indeks "najnowsze sceny danego tagu" (GOON-64, kolumna z 0042).
(tag_id, sort_date DESC, scene_id DESC): lista scen z jednym tagiem, sort=release_date,
idzie po tym indeksie od najnowszej i konczy po LIMIT, niezaleznie od wielkosci tagu.
Uzasadnienie i pomiary w 0042.
Zakladac DOPIERO po wypelnieniu `sort_date` (scripts/backfill_scene_tags_sort_date.py).
Z indeksem w trakcie wypelniania kazda z 23 mln aktualizacji dopisywalaby tez wpis tutaj.
`ix_scene_tags_tag_id_scene_id` zostaje na razie, choc nowy indeks ma ten sam prefiks.
Osobna decyzja, jak przy 0040.
CONCURRENTLY w autocommit_block, jak w 0035, 0038, 0039 i 0041. Nieudany CONCURRENTLY
zostawia indeks INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0043_scene_tags_tag_sort_idx
Revises: 0042_scene_tags_sort_date
Create Date: 2026-09-18
"""
from __future__ import annotations
from alembic import op
revision = "0043_scene_tags_tag_sort_idx"
down_revision: str | None = "0042_scene_tags_sort_date"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scene_tags_tag_sort "
"ON scene_tags (tag_id, sort_date DESC, scene_id DESC)"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scene_tags_tag_sort")

View file

@ -1,37 +0,0 @@
"""Indeks do szukania zrodla eporner po adresie miniatury (app/dead_sources.py).
Proxy miniatur dostaje od CDN-u eporner 404 i zna tylko adres obrazka; zeby sprawdzic i
oznaczyc film, trzeba znalezc jego zrodlo. Miniatura ma inny identyfikator niz strona
filmu, wiec bez indeksu to przeszukanie 4 mln wierszy przy kazdym pustym kafelku.
Hash i czesciowy (tylko eporner): wyszukujemy wylacznie po rownosci, a eporner to ~350 tys.
wierszy, wiec indeks ma kilkanascie MB.
CONCURRENTLY w autocommit_block, jak w 0035, 0038, 0039, 0041 i 0043.
Revision ID: 0044_playback_eporner_thumb_idx
Revises: 0043_scene_tags_tag_sort_idx
Create Date: 2026-09-19
"""
from __future__ import annotations
from alembic import op
revision = "0044_playback_eporner_thumb_idx"
down_revision: str | None = "0043_scene_tags_tag_sort_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_playback_sources_eporner_thumb "
"ON playback_sources USING hash (thumbnail_url) "
"WHERE origin = 'tube:epornercom'"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_playback_sources_eporner_thumb")

View file

@ -1,52 +0,0 @@
"""Nizsze progi autovacuum i autoanalyze dla duzych tabel.
Domyslne progi (20% zmienionych wierszy do vacuum, 10% do analyze) przy 4-23 mln wierszy
znacza, ze autovacuum prawie nie rusza tych tabel: 2026-09-21 scene_performers mialo
ostatni przebieg 27.07 i 11% stron oznaczonych jako widoczne, playback_sources 24%,
a statystyki scenes byly z 25.08. Skutek: index-only scan i tak czyta sterte (w
source-stats 94% odczytow scene_performers szlo na dysk, job przekroczyl 30 min,
Sentry GOON-V), a planista myli sie przy szukaniu po tytule (GOON-73).
2% daje przebieg co ~100 tys. zmian. Autovacuum jest dlawiony (cost_delay 2 ms), wiec
czesciej znaczy krocej, nie ciezej. SET na parametrach tabeli bierze tylko
SHARE UPDATE EXCLUSIVE, nie blokuje odczytow ani zapisow.
Revision ID: 0045_autovacuum_big_tables
Revises: 0044_playback_eporner_thumb_idx
Create Date: 2026-09-21
"""
from __future__ import annotations
from alembic import op
revision = "0045_autovacuum_big_tables"
down_revision: str | None = "0044_playback_eporner_thumb_idx"
branch_labels = None
depends_on = None
_TABLES = (
"scenes",
"playback_sources",
"scene_performers",
"scene_tags",
"scene_external_refs",
"external_records",
)
_PARAMS = (
"autovacuum_vacuum_scale_factor",
"autovacuum_vacuum_insert_scale_factor",
"autovacuum_analyze_scale_factor",
)
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
for table in _TABLES:
params = ", ".join(f"{p} = 0.02" for p in _PARAMS)
op.execute(f"ALTER TABLE {table} SET ({params})")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
for table in _TABLES:
op.execute(f"ALTER TABLE {table} RESET ({', '.join(_PARAMS)})")

View file

@ -1,36 +0,0 @@
"""Wiekszy histogram statystyk dla scenes.title_normalized (szukanie po tytule).
Postgres szacuje `ILIKE '%fraza%'` z histogramu kolumny. Przy domyslnych 100 probkach
popularne frazy nie trafialy w zadna i szacunek spadal na dolny limit 0,01% (427 scen),
wiec planista zawsze szedl przez indeks trigramow i potem sortowal wszystkie trafienia:
"vo" 44 s (dwie litery nie maja trigramu, indeks oddaje 4,3 mln wierszy), "mis" 6,5 s,
"free" 1,9 s (Sentry GOON-73/75, 2026-09-22). Z 1000 probkami szacunki sa bliskie
prawdy i dla popularnych fraz wygrywa przejscie po ix_scenes_created_at_desc do 51.
trafienia: 9 ms, 15 ms, 60 ms. Rzadkie frazy dalej ida przez trigramy.
ANALYZE samej kolumny czyta probke 300 tys. wierszy, kilkanascie sekund.
Revision ID: 0046_scenes_title_stats
Revises: 0045_autovacuum_big_tables
Create Date: 2026-09-22
"""
from __future__ import annotations
from alembic import op
revision = "0046_scenes_title_stats"
down_revision: str | None = "0045_autovacuum_big_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS 1000")
op.execute("ANALYZE scenes (title_normalized)")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS -1")
op.execute("ANALYZE scenes (title_normalized)")

View file

@ -75,9 +75,6 @@ class ClipListOut(BaseModel):
limit: int
offset: int
has_more: bool
#: Liczba wszystkich klipow tworcy (tylko przy filtrze `creator`, indeks ix_clips_creator).
#: Strona tworcy w apce pokazuje ja w naglowku; przy innych listach nie liczymy.
total: int | None = None
class ClipStatsOut(BaseModel):
@ -487,35 +484,16 @@ _OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
def onboarding_options(
session: Session = Depends(get_session),
q: str | None = Query(default=None, max_length=64, description="Szukaj w slowniku tagow"),
) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru przy pierwszym wejsciu w Edge.
Liczone z bazy, cache 10 min w procesie (szukanie z pominieciem cache).
`q` szuka po calym slowniku (1,7 tys. tagow), bo sama czolowka nie wystarcza: „anal"
to 27. tag co do liczby klipow i wypadal poza liste (uwaga Jana 2026-09-16)."""
if q and q.strip():
needle = f"%{q.strip().lower()}%"
found = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count())
.where(ClipTag.tag.like(needle))
.group_by(ClipTag.tag)
.order_by(func.count().desc())
.limit(30)
)
]
return OnboardingOptionsOut(tags=found, niches=[])
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
Liczone z bazy, cache 10 min w procesie."""
hit = _OPTIONS_CACHE.get("v1")
if hit and time.monotonic() - hit[0] < 600:
return hit[1]
tags = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(60)
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
)
]
niches = [
@ -535,19 +513,15 @@ def onboarding(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> AffinityOut:
# +8 za wskazany tag, wiecej niz polubienie klipu (+5): to jawna deklaracja, a feed
# inaczej zbyt dlugo dochodzil do tego, co user i tak powiedzial na starcie (uwaga
# Jana 2026-09-16). Ten sam endpoint sluzy pozniejszemu dostrajaniu z zakladki Edge,
# wiec kolejne wskazania dokladaja sie do wag.
row, aff = _load_affinity(session, device_id)
for t in body.tags:
t = t.strip().lower()[:64]
if t:
aff.tags[t] = round(aff.tags.get(t, 0.0) + 8.0, 3)
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
for n in body.niches:
n = n.strip()[:64]
if n:
aff.niches[n] = round(aff.niches.get(n, 0.0) + 8.0, 3)
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
_save_affinity(session, device_id, row, aff, 0)
session.commit()
row2 = session.get(ClipAffinity, device_id)
@ -665,17 +639,8 @@ def list_clips(
ids = [r.id for r in rows]
tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids)
total = None
if creator:
total = session.execute(
select(func.count()).select_from(Clip).where(Clip.dead_at.is_(None), Clip.creator == creator)
).scalar_one()
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows],
limit=limit,
offset=offset,
has_more=has_more,
total=total,
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows], limit=limit, offset=offset, has_more=has_more
)

View file

@ -1,9 +1,7 @@
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
from __future__ import annotations
import collections
import logging
import math
import re
import hashlib
import uuid
@ -20,12 +18,10 @@ from sqlalchemy import (
func,
literal_column,
select,
text,
true,
union,
)
from psycopg.errors import QueryCanceled
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased
from app.auth import require_api_key
@ -56,50 +52,6 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
#: Ile wynikow filtra bierze udzial w losowaniu (patrz sort=random z filtrem).
_RANDOM_POOL = 5000
#: Glowny feed "newest ingest": najwiekszy udzial jednego zrodla na stronie i ile pierwszych
#: stron tak ukladamy (dalej zwykla kolejnosc po created_at). Patrz _diversify_pages.
_FEED_MAX_SHARE = 1 / 3
_FEED_DIVERSE_PAGES = 8
def _diversify_pages(
items: list[tuple[uuid.UUID, str]], page_size: int, max_share: float
) -> list[list[uuid.UUID]]:
"""Rozklada liste (id, zrodlo), juz w kolejnosci czasu, na strony tak, by jedno zrodlo
zajmowalo najwyzej `max_share` strony, dopoki sa sceny z innych zrodel. Strona bierze
najpierw najnowsze sceny w ramach limitu na zrodlo; gdy innych zrodel brakuje, dopelnia
sie najstarszymi z nadmiaru, wiec zadna strona nie zostaje dziurawa i nic nie ginie.
W obrebie strony i w obrebie zrodla kolejnosc czasu zostaje.
Po co: eporner dokladal ~2 tys. anonimowych uploadow dziennie przy kilkuset nowych
scenach z calej reszty i po odcieciu archiwum (backfill) zajmowal 121 ze 150 pozycji
pierwszych trzech stron (2026-09-19).
"""
cap = max(1, math.ceil(page_size * max_share))
remaining = list(enumerate(items))
pages: list[list[uuid.UUID]] = []
while remaining:
taken: list[tuple[int, tuple[uuid.UUID, str]]] = []
overflow: list[tuple[int, tuple[uuid.UUID, str]]] = []
counts: collections.Counter = collections.Counter()
for pos, item in remaining:
if len(taken) < page_size and counts[item[1]] < cap:
taken.append((pos, item))
counts[item[1]] += 1
else:
overflow.append((pos, item))
while len(taken) < page_size and overflow:
taken.append(overflow.pop(0))
taken.sort(key=lambda t: t[0])
pages.append([item[0] for _, item in taken])
used = {pos for pos, _ in taken}
remaining = [(pos, item) for pos, item in remaining if pos not in used]
return pages
_VALID_SORTS = {
"created_at",
"release_date",
@ -210,115 +162,6 @@ def _blacklists_empty(session: Session, device_id: str) -> bool:
return not has_any
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
# umie wybrac miedzy nimi, bo nie wie, ile tytulow pasuje:
# - przejscie po indeksie sortu od najnowszych do 51. trafienia: popularne i srednie frazy
# ("vo", "bad", "veronica") 10-110 ms, rzadkie ("jackerman") ponad 20 s, bo idzie przez
# caly katalog;
# - indeks trigramow, potem sort wszystkich trafien: rzadkie 30-500 ms, popularne sekundy
# ("mis" 6,5 s), a dwuliterowe ("vo") 44 s, bo bez trigramu indeks oddaje 4,3 mln wierszy.
# Dlatego najpierw przejscie z limitem czasu; nie zdazylo, to fraza jest rzadka i zwykly
# plan (trigramy) jest wlasciwy. Wiekszy histogram tytulow (migracja 0046) poprawil
# szacunki, ale srednie frazy dalej szly trigramami (0,2-1,3 s zamiast 10-110 ms).
# Zeby rzadkie frazy (nazwiska) i dalekie strony nie placily limitu na probe, szacujemy z
# probki tytulow ze statystyk Postgresa (1000 wartosci, ta sama, z ktorej planer liczy
# szacunek), ile scen trzeba przejsc: (offset + strona) / udzial trafien w probce. Na 115
# prawdziwych wyszukiwaniach przejscie wygrywalo zawsze do ~30 tys., wyzej i przy
# zawezeniu (tag, obsada, studio, dlugie minimum) zwykle nie miescilo sie w limicie.
_SEARCH_WALK_BUDGET_MS = 300
_SEARCH_WALK_MAX_SCAN = 30_000
# Fraza bez ciagu 3 liter/cyfr nie ma trigramu, wiec awaryjnego planu nie ma. Dluzszy
# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz").
_SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000
_TRGM_RUN_RE = re.compile(r"[^\W_]{3}")
_TITLE_SAMPLE_TTL = 3600.0
_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()}
def _title_sample(session: Session) -> tuple[str, ...]:
"""Histogram + najczestsze wartosci `scenes.title_normalized` z pg_stats (cache 1 h)."""
import time as _time
now = _time.monotonic()
if _TITLE_SAMPLE["vals"] and now - _TITLE_SAMPLE["ts"] < _TITLE_SAMPLE_TTL:
return _TITLE_SAMPLE["vals"]
row = session.execute(
text(
"SELECT histogram_bounds::text::text[], most_common_vals::text::text[] "
"FROM pg_stats WHERE schemaname = 'public' AND tablename = 'scenes' "
"AND attname = 'title_normalized'"
)
).first()
vals = tuple((row[0] or []) + (row[1] or [])) if row else ()
_TITLE_SAMPLE.update(ts=now, vals=vals)
return vals
def _search_walk_ids(session: Session, ordered, off: int, want: int, budget_ms: int):
"""Id strony z `ordered` bez skanow bitmapowych i z limitem czasu; None po limicie.
Bez bitmap indeks trigramow (GIN) odpada, wiec planer idzie po indeksie sortu i konczy
na LIMIT. SAVEPOINT wycofujemy zawsze: SET LOCAL przezylby jego zwolnienie i reszta
requestu (obsada, tagi, zrodla) szlaby bez bitmap i z limitem czasu. Nic nie tracimy,
to sam odczyt, a id sa juz w Pythonie.
"""
sp = session.begin_nested()
try:
session.execute(
select(
func.set_config("enable_bitmapscan", "off", True),
func.set_config("statement_timeout", str(budget_ms), True),
)
)
return list(
session.execute(
ordered.with_only_columns(Scene.id)
.offset(literal_column(str(off)))
.limit(literal_column(str(want)))
).scalars()
)
except OperationalError as e:
if isinstance(e.orig, QueryCanceled):
return None
raise
finally:
sp.rollback()
def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) -> int | None:
"""Limit czasu (ms) na przejscie od najnowszych albo None, gdy od razu zwykly plan.
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
"""
if not _TRGM_RUN_RE.search(q):
return _SEARCH_WALK_BUDGET_NO_TRGM_MS
if narrowed:
return None
vals = sample()
needle = q.lower()
hits = sum(needle in v for v in vals)
if not hits or (off + want) * len(vals) / hits > _SEARCH_WALK_MAX_SCAN:
return None
return _SEARCH_WALK_BUDGET_MS
def _search_walk_rows(
session: Session, ordered, q: str, off: int, want: int, narrowed: bool
) -> list[Scene] | None:
"""Strona wynikow szukania przejsciem od najnowszych; None = uzyj zwyklego planu."""
budget = _search_walk_budget(q, off, want, narrowed, lambda: _title_sample(session))
if budget is None:
return None
ids = _search_walk_ids(session, ordered, off, want, budget)
if ids is None:
# Bez trigramu zwykly plan czyta caly indeks (44 s), wiec lepiej pusto.
return None if budget == _SEARCH_WALK_BUDGET_MS else []
if not ids:
return []
by_id = {s.id: s for s in session.execute(select(Scene).where(Scene.id.in_(ids))).scalars()}
return [by_id[i] for i in ids if i in by_id]
def _split_csv(raw: str | None) -> list[str]:
if not raw:
return []
@ -620,26 +463,17 @@ def list_scenes(
# ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN
# ukrywał tag_id przed plannerem → używał średniej (8.4M/11541≈726) → zły plan
# (4-12s). Z literałem: ~20ms. Zob. też _build... light mode.
# Sort od najnowszych z filtrem tagu: jeden tag zamiast EXISTS dolaczamy jako wiersz
# scene_tags i sortujemy po jego `sort_date` (galaz sort == "release_date"). Przy kilku
# tagach dolaczamy NAJRZADSZY, bo spacer po indeksie konczy sie najpozniej na jego
# ostatniej scenie; reszta zostaje jako EXISTS. rough-sex + anal: 7,4 s na zimnym cache
# po starej sciezce (2026-09-18).
tag_sort_id: uuid.UUID | None = None
if tag_slug_list:
tag_rows = session.execute(
select(Tag.slug, Tag.id, Tag.scene_count).where(Tag.slug.in_(tag_slug_list))
).all()
id_by_slug = {slug: tid for slug, tid, _ in tag_rows}
if sort == "release_date" and tag_rows:
tag_sort_id = min(tag_rows, key=lambda r: r[2] or 0)[1]
id_by_slug = dict(
session.execute(
select(Tag.slug, Tag.id).where(Tag.slug.in_(tag_slug_list))
).all()
)
for slug in tag_slug_list:
tag_id = id_by_slug.get(slug)
if tag_id is None:
base = base.where(false()) # nieznany slug → brak wyników
break
if tag_id == tag_sort_id:
continue
base = base.where(
exists(
select(1)
@ -753,21 +587,6 @@ def list_scenes(
# stubem. Filtruje anonymous tube-only sceny z newUrl/categories ingestu.
base = base.where(stub_exclusion_clause())
# Glowny feed "newest ingest" bez zawezen: bez scen z dociagania archiwum (backfill).
# deep-crawl tubepornclassic dokladal 7,9 tys. scen z lat 2016-2018 dziennie, a xhamster,
# xnxx, xvideos i sxyprn kolejne 3,3 tys., wszystkie z dzisiejszym created_at, wiec
# zajmowaly caly szczyt listy (zgloszenie 2026-09-19: "glownie theclassicporn i eporner").
# Z filtrem (szukanie, tag, obsada, studio, strona) i w sekcjach JAV/Anime zostaja, bo
# tam user chce wszystkiego z danego zakresu, a nie przegladu nowosci.
_plain_feed = (
sort == "created_at"
and not (q or tag_slug_list or perf_id_strings or studio_slug_list or origin)
and vertical is None
and not jav
)
if _plain_feed:
base = base.where(Scene.backfill.is_(False))
_is_pure_default = (
not include_stubs and not q and not studio_slug_list and not tag_slug_list
and not perf_id_strings and origin is None and has_playback is None
@ -787,19 +606,6 @@ def list_scenes(
total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None
# Czy lista jest juz zawezona filtrem. Rozstrzyga o sposobie losowania (patrz nizej):
# wertykalu i has_playback tu nie liczymy, bo to sa polowy katalogu, nie zawezenie.
_random_narrow = bool(
q
or tag_slug_list
or perf_id_strings
or studio_slug_list
or origin
or min_quality_p is not None
or released_within_days is not None
or max_duration_sec is not None
)
# Offset i rozmiar pobrania sa potrzebne juz przy budowie sortu: `sort=studio` musi
# wiedziec, ile wierszy moze potrzebowac z jednego studia (patrz _studio_sorted_select).
_off = (page - 1) * per_page
@ -822,23 +628,9 @@ def list_scenes(
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
# ix_scenes_effective_date_desc (migracja 0033).
if tag_sort_id is not None:
# Z filtrem tagu ta sama data siedzi w scene_tags.sort_date (wyzwalacz,
# migracja 0042) i idzie po ix_scene_tags_tag_sort (0043): od najnowszej sceny
# tagu, koniec po LIMIT. Po samym `scenes` Postgres sprawdzal tag scena po scenie
# od najnowszej w calym katalogu i przy tagach rzedu 40-60 tys. scen (`rough-sex`)
# przegladal 46 tys. scen na jedna strone: 7-15 s na zimnym cache (GOON-64).
# W obrebie jednego dnia kolejnosc po id, nie po created_at: indeks jest
# wezszy, a stronicowanie i tak stabilne.
# Alias, bo przy kilku tagach EXISTS po scene_tags skorelowalby sie z tym JOIN-em.
st = aliased(SceneTag)
ordered = base.join(
st, and_(st.scene_id == Scene.id, st.tag_id == tag_sort_id)
).order_by(st.sort_date.desc(), st.scene_id.desc())
else:
ordered = base.order_by(
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
)
ordered = base.order_by(
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "title":
# Cale ORDER BY (razem z tie-breakami) pokrywa ix_scenes_title_created_id_desc
# (migracja 0039). Wczesniejszy indeks na samym `title_normalized` zostawial
@ -884,38 +676,21 @@ def list_scenes(
# od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu
# wynika z seeda (md5 -> uuid), wiec paginacja jest stabilna, a nowy seed
# (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy.
seed_value = seed or uuid.uuid4().hex
start = uuid.UUID(hashlib.md5(seed_value.encode("utf-8")).hexdigest())
if _random_narrow:
# Z filtrem (szukanie, tag, obsada, studio, strona, jakosc) okno po PK nie ma
# sensu: 2000 kolejnych id to 0,05% katalogu, wiec wyszukiwanie w losowej
# kolejnosci pokazywalo garstke wynikow zamiast wszystkich (zgloszenie z apki
# 2026-09-16). Filtr sam zawezą zbior, wiec bierzemy pule jego wynikow i
# tasujemy JA: md5(id + seed) daje ten sam porzadek na kolejnych stronach,
# a nowy seed nowe rozdanie. Pula jest ograniczona, bo szeroki tag to setki
# tysiecy scen, a pelne sortowanie takiego zbioru trwaloby sekundy; przy
# typowym wyszukiwaniu (setki wynikow) pula obejmuje wszystko.
pool = base.limit(literal_column(str(_RANDOM_POOL))).subquery()
ordered = (
select(Scene)
.join(pool, Scene.id == pool.c.id)
.order_by(func.md5(func.concat(Scene.id, seed_value)))
)
else:
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim.
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow.
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000
# id JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena).
base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4)
cand = (
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery()
)
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc())
start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest())
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim.
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow.
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000 id
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena).
base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4)
cand = (
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery()
)
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc())
else: # created_at
ordered = base.order_by(
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
@ -928,60 +703,17 @@ def list_scenes(
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
if _plain_feed and page <= _FEED_DIVERSE_PAGES:
# Kandydatki (id + zrodlo) w kolejnosci czasu, ulozone przez _diversify_pages. Pula
# ma STALA wielkosc (2x tyle, ile stron ukladamy), bo inaczej kazda strona liczylaby
# uklad z innej puli i sceny przeskakiwalyby miedzy stronami. Pelne wiersze ladujemy
# tylko dla zwracanej strony.
cand_n = _FEED_DIVERSE_PAGES * per_page * 2
origin_col = (
select(func.min(PlaybackSource.origin))
.where(PlaybackSource.scene_id == Scene.id, PlaybackSource.dead_at.is_(None))
.correlate(Scene)
.scalar_subquery()
rows = (
studio_rows
if studio_rows is not None
else session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
)
cand = session.execute(
ordered.with_only_columns(Scene.id, origin_col).limit(literal_column(str(cand_n)))
).all()
feed_pages = _diversify_pages([(r[0], r[1] or "") for r in cand], per_page, _FEED_MAX_SHARE)
page_ids = feed_pages[page - 1] if page <= len(feed_pages) else []
by_id = {
s.id: s for s in session.execute(select(Scene).where(Scene.id.in_(page_ids))).scalars()
} if page_ids else {}
rows = [by_id[i] for i in page_ids if i in by_id]
has_more = page < len(feed_pages) or len(cand) >= cand_n
else:
rows = studio_rows
if rows is None and q and (
sort == "created_at" or (sort == "release_date" and tag_sort_id is None)
):
rows = _search_walk_rows(
session,
ordered,
q,
_off,
_want,
narrowed=bool(
tag_slug_list
or perf_id_strings
or studio_slug_list
or origin
or min_quality_p is not None
or released_within_days is not None
or max_duration_sec is not None
or (min_duration_sec or 0) > 600
),
)
if rows is None:
rows = (
session.execute(
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
)
.scalars()
.all()
)
has_more = len(rows) > per_page
rows = rows[:per_page]
.scalars()
.all()
)
has_more = len(rows) > per_page
rows = rows[:per_page]
# Filtrowane listy: total = dolna granica z dotychczas-widzianych wierszy, a
# total_capped=has_more daje UI "N+" (jest kolejna strona). Bez osobnego count query.

View file

@ -13,11 +13,8 @@ Zasady (świadome, nie przypadkowe):
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
* **Bez blacklist.** Blacklisty sa prywatne, per urzadzenie (od 2026-06-10), a strony
SEO sa anonimowe, wiec pokazuja to, co katalog pokazuje nowemu urzadzeniu. Wczesniej
wycinalismy sume blacklist WSZYSTKICH urzadzen: 465 osob ukrylo 4477 tagow i z SEO
znikalo ~86% scen, a liczenie do /sitemap.xml pisalo 3,7 GB plikow tymczasowych
i przy kilku naraz zapychalo dysk (Sentry GOON-22, 2026-09-22).
* **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes.
Nie publikujemy SEO-stron dla zblacklistowanej treści.
* **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
nie pusty doorway (Google karze masowe cienkie strony).
@ -25,8 +22,6 @@ Zasady (świadome, nie przypadkowe):
from __future__ import annotations
import os
import threading
import time
import uuid
from datetime import date
from pathlib import Path
@ -40,6 +35,11 @@ from sqlalchemy.orm import Session
from app.api.scenes import _needs_proxy, _wrap_image_proxy
from app.db import get_session
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
BlacklistedTag,
)
from app.models.performer import Performer, PerformerAlias
from app.models.playback_source import PlaybackSource
from app.models.scene import Scene, ScenePerformer, SceneTag
@ -83,24 +83,55 @@ def _live_playback_exists():
)
def _not_blacklisted():
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
Te same reguły co w GET /scenes, żeby SEO nie publikowało tego, co katalog ukrywa.
"""
return [
~exists(
select(1)
.select_from(ScenePerformer)
.join(
BlacklistedPerformer,
BlacklistedPerformer.performer_id == ScenePerformer.performer_id,
)
.where(ScenePerformer.scene_id == Scene.id)
),
~Scene.studio_id.in_(select(BlacklistedStudio.studio_id)),
~exists(
select(1)
.select_from(SceneTag)
.join(BlacklistedTag, BlacklistedTag.tag_id == SceneTag.tag_id)
.where(SceneTag.scene_id == Scene.id)
),
]
def _indexable_scenes():
"""SELECT Scene z żywym źródłem, baza pod listy i sitemap."""
return select(Scene).where(_live_playback_exists())
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
stmt = select(Scene).where(_live_playback_exists())
for cond in _not_blacklisted():
stmt = stmt.where(cond)
return stmt
def _performer_indexable_exists():
"""EXISTS: performer ma ≥1 indeksowalną (żywą) scenę.
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
"""
return exists(
sub = (
select(1)
.select_from(ScenePerformer)
.join(Scene, Scene.id == ScenePerformer.scene_id)
.where(ScenePerformer.performer_id == Performer.id)
.where(_live_playback_exists())
)
for cond in _not_blacklisted():
sub = sub.where(cond)
return exists(sub)
def _scene_card_rows(session: Session, scene_ids: list[uuid.UUID]) -> dict[uuid.UUID, dict]:
@ -407,27 +438,9 @@ def _count(session: Session, stmt) -> int:
return session.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
# Indeks sitemapy liczy trzy agregaty po calym katalogu, a roboty potrafia zapytac kilka
# razy w tej samej sekundzie (2026-09-04: 5 naraz). Liczymy raz na _SITEMAP_INDEX_TTL
# w procesie; blokada sprawia, ze rownolegle zadania czekaja na jeden wynik.
_SITEMAP_INDEX_TTL = 6 * 3600.0
_sitemap_index_cache: tuple[float, str] | None = None
_sitemap_index_lock = threading.Lock()
@router.get("/sitemap.xml")
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
global _sitemap_index_cache
with _sitemap_index_lock:
cached = _sitemap_index_cache
if cached is None or time.monotonic() - cached[0] > _SITEMAP_INDEX_TTL:
cached = (time.monotonic(), _build_sitemap_index(session))
_sitemap_index_cache = cached
return Response(cached[1], media_type="application/xml")
def _build_sitemap_index(session: Session) -> str:
n_perf = _count(
session,
select(Performer.id).where(_performer_indexable_exists()),
@ -448,7 +461,7 @@ def _build_sitemap_index(session: Session) -> str:
for p in range(pages):
parts.append(f"<sitemap><loc>{bu}/sitemap/{kind}-{p}.xml</loc></sitemap>")
parts.append("</sitemapindex>")
return "\n".join(parts)
return Response("\n".join(parts), media_type="application/xml")
@router.get("/sitemap/{kind}-{page}.xml")

View file

@ -16,7 +16,6 @@ from __future__ import annotations
import logging
import os
import re
import threading
import time
from datetime import datetime
from pathlib import Path
@ -28,7 +27,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import require_api_key
from app.db import get_session, session_scope
from app.db import get_session
from app.models.playback_source import PlaybackSource
from app.models.source_stats import SourceStats
@ -87,10 +86,8 @@ class SourceOut(BaseModel):
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
#: Po tylu sekundach wynik uznajemy za stary i przeliczamy W TLE (oddajac stary).
_SOURCES_TTL = 900.0
_SOURCES_TTL = 300.0
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
_SOURCES_LOCK = threading.Lock()
def _icon_url(sitetag: str) -> str | None:
@ -137,50 +134,6 @@ def _sitetag_to_display(sitetag: str) -> str:
return sitetag
def _source_rows(session: Session):
return session.execute(
select(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
)
.where(PlaybackSource.dead_at.is_(None))
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
def _refresh_source_rows_bg() -> None:
if not _SOURCES_LOCK.acquire(blocking=False):
return # ktos juz liczy
def run() -> None:
try:
with session_scope() as s:
rows = _source_rows(s)
_SOURCES_CACHE["rows"] = rows
_SOURCES_CACHE["at"] = time.monotonic()
except Exception: # noqa: BLE001
log.exception("sources: odswiezenie listy stron w tle nie wyszlo")
finally:
_SOURCES_LOCK.release()
threading.Thread(target=run, name="sources-refresh", daemon=True).start()
def _cached_source_rows(session: Session):
rows = _SOURCES_CACHE["rows"]
if rows is None:
with _SOURCES_LOCK:
if _SOURCES_CACHE["rows"] is None:
_SOURCES_CACHE["rows"] = _source_rows(session)
_SOURCES_CACHE["at"] = time.monotonic()
return _SOURCES_CACHE["rows"]
if time.monotonic() - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_refresh_source_rows_bg()
return rows
@router.get("", response_model=SourceListOut)
def list_sources(
session: Annotated[Session, Depends(get_session)],
@ -189,11 +142,23 @@ def list_sources(
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
"""
# Agregat idzie po 4,1 mln zywych zrodel (0,6-1,6 s na przeliczenie, Sentry: Slow DB
# Query list_sources, 186 zdarzen). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu, a po wygasnieciu oddajemy STARY i przeliczamy w tle.
# Wczesniej co wygasniecie cache ktos placil pelnym czasem zapytania (2026-09-17).
rows = _cached_source_rows(session)
# Agregat idzie po 4,1 mln zywych zrodel (600 ms na kazde wejscie w Sites, Sentry:
# Slow DB Query list_sources). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu przez _SOURCES_TTL sekund.
now = time.monotonic()
if _SOURCES_CACHE["rows"] is None or now - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_SOURCES_CACHE["rows"] = session.execute(
select(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
func.max(PlaybackSource.last_seen_at).label("last_scraped_at"),
)
.where(PlaybackSource.dead_at.is_(None))
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
_SOURCES_CACHE["at"] = now
rows = _SOURCES_CACHE["rows"]
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}

View file

@ -433,12 +433,6 @@ async def proxy_image(
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target)
if r.status_code == 404 and ".eporner.com/" in target:
# 404 na miniaturze eporner prawie zawsze znaczy usuniety film; strona
# jest sprawdzana w tle przed oznaczeniem (app/dead_sources.py).
from app import dead_sources
dead_sources.eporner_thumb_404(target)
return Response(
content=b"",
status_code=502 if r.status_code >= 500 else r.status_code,
@ -554,11 +548,6 @@ async def sxyprn_thumb(post_id: str) -> Response:
return Response(content=b"", status_code=502, media_type="image/jpeg")
html = r.text
if "Post Not Found" in html:
# Post usuniety, czyli strumien tez (ta sama strona). Oznaczamy zrodlo w tle,
# zeby scena zniknela z list, zamiast czekac na klikniecie (2026-09-19).
from app import dead_sources
dead_sources.sxyprn_post_not_found(pid)
return Response(content=b"", status_code=404, media_type="image/jpeg")
m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html)
if not m:

View file

@ -116,16 +116,6 @@ class Settings(BaseSettings):
sched_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
)
# Dedup w obrebie studia po rdzeniu tytulu: ta sama scena z tubow, z ktorych kazdy pisze
# tytul po swojemu ("[Milfy] Koda Monroe (...)", "Milfy 26 09 16 ..."), 8 kopii jednej
# sceny w zgloszeniu bb1a357d. Okno 30 dni wstecz, bo kopie splywaja w ciagu dni. 0 = off.
sched_studio_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_STUDIO_TITLE_DEDUP_HOURS"
)
# Sprzatanie martwych zrodel sxyprn i eporner (app/scheduler/dead_reap.py): co N godzin
# porcja zywych zrodel na serwis, polowa z ostatnich 30 dni. 0 = off.
sched_dead_reap_hours: int = Field(default=6, validation_alias="GOON_SCHED_DEAD_REAP_HOURS")
dead_reap_per_origin: int = Field(default=1500, validation_alias="GOON_DEAD_REAP_PER_ORIGIN")
# Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć

View file

@ -1,116 +0,0 @@
"""Oznaczanie martwych zrodel na podstawie sygnalow z proxy miniatur (2026-09-19).
Pusty kafelek to zwykle martwy film: user widzi scene bez miniatury, klika i dostaje
"the tube removed this video". Dotad zrodlo oznaczal dopiero resolve po kliknieciu, a sceny
bez klikniec wisialy w listach tygodniami. Proxy miniatur i tak pobiera to, co mowi o smierci
filmu, wiec tu zapisujemy wniosek:
- sxyprn: `/proxy/sxyprn-thumb/<post>` pobiera strone posta; "Post Not Found" to pewny
sygnal (ta sama strona daje miniature i strumien), oznaczamy od razu.
- eporner: miniatura z 404 w 28 na 30 przypadkow nalezala do usunietego filmu, w 2 strona
miala 404 (probka 2026-09-19). Mimo to sprawdzamy strone filmu przed oznaczeniem, bo 404
na miniaturze bywa tez chwilowe.
Wszystko w tle, na malej puli watkow: lista 50 scen potrafi dac kilkadziesiat 404 naraz,
a proxy miniatur nie moze na to czekac. Kazdy klucz obslugujemy raz na godzine.
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="dead-mark")
_SEEN: dict[str, float] = {}
_SEEN_LOCK = threading.Lock()
_SEEN_TTL = 3600.0
_EPORNER_ORIGIN = "tube:epornercom"
_SXYPRN_ORIGIN = "tube:sxyprncom"
def _first_time(key: str) -> bool:
now = time.time()
with _SEEN_LOCK:
if _SEEN.get(key, 0.0) > now:
return False
_SEEN[key] = now + _SEEN_TTL
if len(_SEEN) > 50_000:
for k in [k for k, v in _SEEN.items() if v <= now]:
_SEEN.pop(k, None)
return True
def _mark(where_sql: str, params: dict, reason: str) -> int:
with session_scope() as s:
n = s.execute(
text(
"UPDATE playback_sources SET dead_at = now(), dead_reason = :reason "
f"WHERE dead_at IS NULL AND {where_sql}"
),
{**params, "reason": reason[:512]},
).rowcount
return n or 0
def sxyprn_post_not_found(post_id: str) -> None:
"""Strona posta sxyprn mowi "Post Not Found". Nie blokuje wolajacego."""
if not _first_time(f"sx:{post_id}"):
return
_POOL.submit(_mark_sxyprn, post_id)
def _mark_sxyprn(post_id: str) -> None:
try:
n = _mark(
"origin = :o AND page_url = :p",
{"o": _SXYPRN_ORIGIN, "p": f"https://sxyprn.com/post/{post_id}.html"},
"sxyprn: Post Not Found (proxy miniatury)",
)
if n:
log.info("dead-mark sxyprn %s: %d zrodel", post_id, n)
except Exception as e: # pragma: no cover - obronnie, to tylko sprzatanie
log.warning("dead-mark sxyprn %s padl: %s", post_id, e)
def eporner_thumb_404(thumb_url: str) -> None:
"""Miniatura eporner oddala 404. Sprawdza strone filmu w tle i oznacza, jesli usuniety."""
if not _first_time(f"ep:{thumb_url}"):
return
_POOL.submit(_verify_eporner, thumb_url)
def _verify_eporner(thumb_url: str) -> None:
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import TubePageError
from app.extractors.tubes.eporner import is_deleted_page
try:
with session_scope() as s:
rows = s.execute(
text(
"SELECT page_url FROM playback_sources "
"WHERE origin = :o AND thumbnail_url = :t AND dead_at IS NULL"
),
{"o": _EPORNER_ORIGIN, "t": thumb_url},
).all()
for (page,) in rows:
reason = None
try:
if is_deleted_page(fetch_tube_html(page, timeout=30)):
reason = "eporner: video has been deleted (proxy miniatury)"
except TubePageError as e:
if e.status_code in (404, 410):
reason = f"eporner: strona {e.status_code} (proxy miniatury)"
if reason:
n = _mark("origin = :o AND page_url = :p", {"o": _EPORNER_ORIGIN, "p": page}, reason)
if n:
log.info("dead-mark eporner %s: %s", page, reason)
except Exception as e: # pragma: no cover - obronnie, to tylko sprzatanie
log.warning("dead-mark eporner %s padl: %s", thumb_url, e)

View file

@ -19,7 +19,7 @@ import logging
import re
from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html
from app.extractors._models import HosterDead, StreamSource
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
@ -46,19 +46,8 @@ def _hash_to_b36(hex_hash: str) -> str:
return "".join(parts)
def is_deleted_page(html: str) -> bool:
"""Strona usunietego filmu odpowiada 200 z tytulem, ale bez playera i z tekstem
"Video has been deleted" (np. "File has been removed due to copyright owner request").
Zywa strona tego tekstu nie ma (sprawdzone 2026-09-19)."""
return "Video has been deleted" in html and not _VID_RE.search(html)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html = fetch_tube_html(page_url, timeout=timeout)
if is_deleted_page(html):
# Bez tego resolve zwracal "blad chwilowy" (503) i zrodlo nigdy nie bylo oznaczane
# jako martwe: 82 takie wejscia w 48 h, te same martwe sceny wisialy w feedzie.
raise HosterDead(f"eporner {page_url}: video has been deleted")
m_vid = _VID_RE.search(html)
m_hash = _HASH_RE.search(html)
if not m_vid or not m_hash:

View file

@ -100,7 +100,7 @@ def healthz() -> dict[str, str]:
@app.get("/version")
def version() -> dict[str, str | int | None]:
def version() -> dict[str, str | None]:
"""Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK.
Zwraca:
@ -123,21 +123,12 @@ def version() -> dict[str, str | int | None]:
# mobile sklei z baseUrl.
public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk"
# `runtime_version` i `apk_build` to jedyne numery, po których aplikacja pozna, JAKIE
# APK ma zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA,
# więc po aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny.
#
# `apk_build` = `versionCode` z android/app/build.gradle. Runtime sam nie wystarcza:
# 0.4.0 i 0.4.1 mają ten sam runtime 1.4 (0.4.1 to poprawki natywne bez zmiany kanału
# OTA), więc przez dobę nikt z 0.4.0 nie dostał monitu, mimo że 0.4.1 naprawia crash
# expo-video przy zamykaniu z ostatnich (Sentry 147164135, zgłoszenie 2026-09-15).
# Podbijaj RAZEM z `version` przy każdym wydaniu APK.
return {
"version": "0.4.2",
"runtime_version": "1.4",
"apk_build": 16,
"apk_url": apk_url,
}
# `runtime_version` to jedyny numer, po którym aplikacja pozna, JAKIE APK ma
# zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA, więc po
# aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny.
# Runtime siedzi w AndroidManifest, OTA go nie rusza. Podbijaj RAZEM z `version`,
# ale tylko przy wydaniu APK (zmiana natywna).
return {"version": "0.4.1", "runtime_version": "1.4", "apk_url": apk_url}
@app.get("/readyz")

View file

@ -134,7 +134,3 @@ class SceneTag(Base):
source_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL")
)
# Data sortowania sceny (premiera albo dzien dodania), powielona tu pod indeks
# "najnowsze sceny tagu" (GOON-64, migracje 0042 i 0043). Pisze ja WYZWALACZ w bazie,
# nie aplikacja: wartosc podana przy INSERT i tak zostanie nadpisana.
sort_date: Mapped[date | None] = mapped_column(Date)

View file

@ -41,18 +41,6 @@ def _is_canonical(session: Session, source_id: uuid.UUID | None) -> bool:
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
def _has_canonical_ref(session: Session, scene_id: uuid.UUID) -> bool:
return session.execute(
select(SceneExternalRef.scene_id)
.join(Source, Source.id == SceneExternalRef.source_id)
.where(
SceneExternalRef.scene_id == scene_id,
Source.kind.in_((SourceKind.tpdb, SourceKind.stashdb)),
)
.limit(1)
).first() is not None
class MergeError(Exception):
pass
@ -72,18 +60,12 @@ def merge_scenes(
if keep is None or drop is None:
raise MergeError("scene not found")
# Sprawdzamy PRZED przepieciem refow, potem obie strony wygladalyby na kanoniczne.
keep_canonical = _has_canonical_ref(session, keep_id)
drop_canonical = _has_canonical_ref(session, drop_id)
_move_external_refs(session, keep_id=keep_id, drop_id=drop_id)
_move_performers(session, keep_id=keep_id, drop_id=drop_id)
_move_tags(session, keep_id=keep_id, drop_id=drop_id)
_move_fingerprints(session, keep_id=keep_id, drop_id=drop_id)
_move_playback_sources(session, keep_id=keep_id, drop_id=drop_id)
_coalesce_canonical_fields(
keep, drop, keep_canonical=keep_canonical, drop_canonical=drop_canonical
)
_coalesce_canonical_fields(keep, drop)
session.delete(drop)
session.flush()
@ -241,9 +223,7 @@ def _move_playback_sources(session: Session, *, keep_id: uuid.UUID, drop_id: uui
)
def _coalesce_canonical_fields(
keep: Scene, drop: Scene, *, keep_canonical: bool = False, drop_canonical: bool = False
) -> None:
def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None:
"""Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości."""
if not keep.description and drop.description:
keep.description = drop.description
@ -257,16 +237,7 @@ def _coalesce_canonical_fields(
keep.release_date = drop.release_date
if not keep.studio_id and drop.studio_id:
keep.studio_id = drop.studio_id
# Tytul z TPDB/StashDB to prawdziwy tytul sceny. Tube dokleja do niego studio, obsade
# i date ("Milfy 26 09 16 Koda Monroe Voluptuous Therapist ..."), wiec sama zasada
# "dluzszy wygrywa" podmieniala czysty tytul na zasmiecony: 491 scen kanonicznych przy
# pierwszym przebiegu studio_title_dedup (2026-09-18). Dluzszy wygrywa juz tylko
# miedzy scenami tego samego rodzaju.
take_drop_title = bool(drop.title) and (
(drop_canonical and not keep_canonical)
or (drop_canonical == keep_canonical and len(drop.title) > len(keep.title or ""))
)
if take_drop_title:
if drop.title and len(drop.title) > len(keep.title or ""):
keep.title = drop.title
keep.title_normalized = drop.title_normalized
# created_at = najwcześniejsze "first seen" obu scen. NEW badge (apka) bazuje na

View file

@ -1,126 +0,0 @@
"""Cykliczne sprzatanie martwych zrodel sxyprn i eporner (2026-09-19).
Oba serwisy kasuja filmy szybko i po cichu: strona dalej odpowiada 200, tylko z
komunikatem ("Post Not Found" na sxyprn, "Video has been deleted" na eporner). Probka
2026-09-19: 57% zywych w bazie zrodel sxyprn w wieku 10-40 dni i 22% eporner bylo juz
usunietych. W apce to puste kafelki i "the tube removed this video" po kliknieciu.
Dwie drogi, ktore sie uzupelniaja:
- app/dead_sources.py oznacza to, co ktos wlasnie przewija (sygnal z proxy miniatur),
- ten job co kilka godzin sprawdza porcje reszty: polowe z ostatnich 30 dni, bo tam
kasowanie jest najczestsze, polowe losowo ze starszych. Bez kursora i bez stanu: kazdy
przebieg bierze tylko zywe zrodla, wiec oznaczone same wypadaja z puli.
Oznaczamy tylko przy pewnym sygnale (komunikat o usunieciu albo 404 strony). Siec, timeout,
rate-limit i strona bez playera to "nie wiadomo" i zrodlo zostaje. Wolno i grzecznie
(3 watki, odstep przed kazdym pobraniem), bo sxyprn to nasze najczesciej ogladane zrodlo i
ban IP bylby gorszy niz martwe kafelki. Pelny przebieg po zaleglosciach:
scripts/sxyprn_reap_dead.py.
"""
from __future__ import annotations
import collections
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_CONCURRENCY = 3
_SPACING_SEC = 0.5
_TIMEOUT = 20.0
_ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown"
def _probe_sxyprn(page_url: str) -> str:
from app.extractors._models import HosterDead
from app.extractors.tubes import sxyprn
try:
res = sxyprn.extract(page_url, timeout=_TIMEOUT)
except HosterDead:
return _DEAD
except Exception:
return _UNKNOWN
return _ALIVE if res and getattr(res[0], "link", None) else _UNKNOWN
def _probe_eporner(page_url: str) -> str:
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import TubePageError
from app.extractors.tubes.eporner import is_deleted_page
try:
html = fetch_tube_html(page_url, timeout=_TIMEOUT)
except TubePageError as e:
return _DEAD if e.status_code in (404, 410) else _UNKNOWN
except Exception:
return _UNKNOWN
if is_deleted_page(html):
return _DEAD
return _ALIVE if "EP.video.player.vid" in html else _UNKNOWN
_PROBES = {
"tube:sxyprncom": ("sxyprn-reap: Post Not Found", _probe_sxyprn),
"tube:epornercom": ("eporner-reap: video has been deleted", _probe_eporner),
}
def _pick(origin: str, per_run: int) -> list[tuple[str, str]]:
half = per_run // 2
with session_scope() as s:
recent = s.execute(
text(
"SELECT id::text, page_url FROM playback_sources "
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
"AND created_at > now() - interval '30 days' ORDER BY random() LIMIT :n"
),
{"o": origin, "n": half},
).all()
older = s.execute(
text(
"SELECT id::text, page_url FROM playback_sources "
"WHERE origin = :o AND dead_at IS NULL AND page_url IS NOT NULL "
"AND created_at <= now() - interval '30 days' ORDER BY random() LIMIT :n"
),
{"o": origin, "n": per_run - len(recent)},
).all()
return [(r[0], r[1]) for r in recent + older]
def run_dead_reap(*, per_origin: int = 2000) -> dict[str, dict[str, int]]:
"""Sprawdza po `per_origin` zywych zrodel sxyprn i eporner, oznacza usuniete."""
out: dict[str, dict[str, int]] = {}
for origin, (reason, probe) in _PROBES.items():
t0 = time.time()
rows = _pick(origin, per_origin)
cnt: collections.Counter = collections.Counter()
dead_ids: list[str] = []
def work(row: tuple[str, str], _probe=probe) -> tuple[str, str]:
time.sleep(_SPACING_SEC)
return row[0], _probe(row[1])
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
for sid, verdict in pool.map(work, rows):
cnt[verdict] += 1
if verdict == _DEAD:
dead_ids.append(sid)
marked = 0
for i in range(0, len(dead_ids), 500):
with session_scope() as s:
marked += s.execute(
text(
"UPDATE playback_sources SET dead_at = now(), dead_reason = :r "
"WHERE id = ANY(CAST(:ids AS uuid[])) AND dead_at IS NULL"
),
{"r": reason, "ids": dead_ids[i : i + 500]},
).rowcount
out[origin] = {"checked": len(rows), **cnt, "marked": marked}
log.info("dead-reap %s: %s w %.0f s", origin, out[origin], time.time() - t0)
return out

View file

@ -390,52 +390,15 @@ def _job_title_duration_dedup() -> None:
try:
from app.scheduler.title_duration_dedup import run_title_duration_dedup
# Godzina, nie domyslne 30 min: przebieg nadrabiajacy zaleglosci (po serii restartow
# workera, ktore ucinaly wczesniejsze) scalil 67 tys. scen i trwal 90 min, wiec job
# zglosil sie jako zawieszony, choc pracowal (2026-09-20, Sentry GOON-V).
_run_with_timeout(
lambda: run_title_duration_dedup(playback_only=True, commit=True),
label="title-duration-dedup",
timeout_sec=3600,
)
log.info("[scheduler] title-duration dedup done")
except Exception:
log.exception("[scheduler] title-duration dedup failed")
def _job_studio_title_dedup() -> None:
"""Scal kopie tej samej sceny z tubow, ktore zapisuja tytul kazdy po swojemu (to samo
studio + ten sam rdzen tytulu, report bb1a357d). title_duration_dedup wymaga
identycznego tytulu, a phash nie lapie, bo miniatury sa z roznych klatek."""
log.info("[scheduler] studio-title dedup starting")
try:
from app.scheduler.studio_title_dedup import run_studio_title_dedup
_run_with_timeout(
lambda: run_studio_title_dedup(days=30, commit=True),
label="studio-title-dedup",
)
log.info("[scheduler] studio-title dedup done")
except Exception:
log.exception("[scheduler] studio-title dedup failed")
def _job_dead_reap(per_origin: int) -> None:
"""Oznacz martwe zrodla sxyprn i eporner (strona mowi, ze film usuniety). Porcja na
przebieg, polowa z ostatnich 30 dni; szczegoly w app/scheduler/dead_reap.py."""
log.info("[scheduler] dead-reap starting (per_origin=%d)", per_origin)
try:
from app.scheduler.dead_reap import run_dead_reap
# 2 x 1500 sprawdzen po ~1,2 s na 3 watkach to ~20 min; limit z zapasem.
_run_with_timeout(
lambda: run_dead_reap(per_origin=per_origin), label="dead-reap", timeout_sec=3600
)
log.info("[scheduler] dead-reap done")
except Exception:
log.exception("[scheduler] dead-reap failed")
def _job_ingest_watchdog(
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
) -> None:
@ -545,12 +508,7 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
performer_continuous_seconds: int | None
performer_continuous_refresh_days: int
"""
# misfire_grace_time: domyslnie 1 s. O 05:00 UTC (wspolny anchor) startuje kilkanascie
# zadan naraz i dzienny movie-ingest dostawal watek 1,1 s po czasie, wiec APScheduler go
# POMIJAL ("was missed by 0:00:01"), dzien w dzien. Filmy staly 4-8 dni, zanim watchdog
# to pokazal (2026-09-19). Godzina zapasu; coalesce i max_instances=1 i tak zbijaja
# zaleglosci w jedno uruchomienie.
sched = BlockingScheduler(timezone="UTC", job_defaults={"misfire_grace_time": 3600})
sched = BlockingScheduler(timezone="UTC")
sched.add_job(
_job_site_icons,
@ -765,30 +723,6 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
)
log.info("scheduler: title-duration dedup every %dh", cfg["title_dedup_hours"])
if cfg.get("studio_title_dedup_hours"):
sched.add_job(
_job_studio_title_dedup,
IntervalTrigger(hours=cfg["studio_title_dedup_hours"], start_date=INTERVAL_ANCHOR),
id="studio_title_dedup",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: studio-title dedup every %dh", cfg["studio_title_dedup_hours"])
if cfg.get("dead_reap_hours"):
_dr_per = cfg.get("dead_reap_per_origin") or 1500
sched.add_job(
_job_dead_reap,
IntervalTrigger(hours=cfg["dead_reap_hours"], start_date=INTERVAL_ANCHOR),
args=[_dr_per],
id="dead_reap",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: dead-reap every %dh (per_origin=%d)", cfg["dead_reap_hours"], _dr_per)
if cfg.get("ingest_watchdog_hours"):
wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48
wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168

View file

@ -33,12 +33,6 @@ _TELEMETRY_WINDOW_DAYS = 7
# mniejszej próbce lecimy na proxy/heurystykę (nie zerujemy gwiazdek znanemu-dobremu).
_TELEMETRY_MIN_ATTEMPTS = 25
_EVENT_RETENTION_DAYS = 30
# Flagi tag/perf/desc/studio/dur liczone z probki scen na origin, nie ze wszystkich.
# Pelny przebieg to ~5 mln losowych odczytow indeksow scene_tags/scene_performers
# (2,5 mln par origin-scena) i 22-32 min; 2026-09-21 przekroczyl 30 min limitu
# (Sentry GOON-V). Probka po md5(scene_id) jest stala miedzy przebiegami, wiec
# gwiazdki nie skacza; blad procentu przy 3000 scen to ok. 1 pkt.
_RICHNESS_SAMPLE = 3000
# Richness: wagi składowych (suma=1.0). thumb to minimum higieny, canonical-bogactwo
# (desc/studio/tag/perf) waży więcej. dur średnio.
_RICHNESS_WEIGHTS = {
@ -143,65 +137,43 @@ def run_source_stats() -> dict:
win_start = now - timedelta(days=_TELEMETRY_WINDOW_DAYS)
with session_scope() as session:
# 1a) Freshness + miniatury: dokladnie, po DISTINCT (origin, scene) z live sources.
# 1) Freshness + richness: agregat po DISTINCT (origin, scene) z live sources,
# join do flag scen (has_tag/perf/desc/studio/dur policzone raz).
rows = session.execute(
text(
"""
WITH live AS (
WITH scene_flags AS (
SELECT s.id,
(s.description IS NOT NULL AND length(btrim(s.description))>0) AS has_desc,
(s.studio_id IS NOT NULL) AS has_studio,
(s.duration_sec IS NOT NULL) AS has_dur,
EXISTS(SELECT 1 FROM scene_tags st WHERE st.scene_id=s.id) AS has_tag,
EXISTS(SELECT 1 FROM scene_performers sp WHERE sp.scene_id=s.id) AS has_perf
FROM scenes s
),
live AS (
SELECT DISTINCT ON (origin, scene_id) origin, scene_id,
(thumbnail_url IS NOT NULL) AS has_thumb, created_at
FROM playback_sources
WHERE dead_at IS NULL AND origin LIKE 'tube:%'
)
SELECT origin,
SELECT l.origin,
count(*) AS scenes,
count(*) FILTER (WHERE created_at > :win) AS new_7d,
max(created_at) AS newest_at,
100.0*avg(has_thumb::int) AS thumb
FROM live
GROUP BY origin
count(*) FILTER (WHERE l.created_at > :win) AS new_7d,
max(l.created_at) AS newest_at,
100.0*avg(l.has_thumb::int) AS thumb,
100.0*avg(f.has_tag::int) AS tag,
100.0*avg(f.has_perf::int) AS perf,
100.0*avg(f.has_desc::int) AS descr,
100.0*avg(f.has_studio::int) AS studio,
100.0*avg(f.has_dur::int) AS dur
FROM live l JOIN scene_flags f ON f.id = l.scene_id
GROUP BY l.origin
"""
),
{"win": win_start},
).all()
# 1b) Reszta richness z probki scen (patrz _RICHNESS_SAMPLE).
flags = {
r.origin: r
for r in session.execute(
text(
"""
WITH live AS (
SELECT DISTINCT origin, scene_id
FROM playback_sources
WHERE dead_at IS NULL AND origin LIKE 'tube:%'
),
sample AS (
SELECT origin, scene_id FROM (
SELECT origin, scene_id, row_number() OVER (
PARTITION BY origin ORDER BY md5(scene_id::text)
) AS rn
FROM live
) ranked
WHERE rn <= :n
)
SELECT sm.origin,
100.0*avg((EXISTS(SELECT 1 FROM scene_tags st
WHERE st.scene_id=s.id))::int) AS tag,
100.0*avg((EXISTS(SELECT 1 FROM scene_performers sp
WHERE sp.scene_id=s.id))::int) AS perf,
100.0*avg((s.description IS NOT NULL
AND length(btrim(s.description))>0)::int) AS descr,
100.0*avg((s.studio_id IS NOT NULL)::int) AS studio,
100.0*avg((s.duration_sec IS NOT NULL)::int) AS dur
FROM sample sm JOIN scenes s ON s.id = sm.scene_id
GROUP BY sm.origin
"""
),
{"n": _RICHNESS_SAMPLE},
).all()
}
# 2) Telemetria health per origin (okno 7d).
tele = {
r.origin: r
@ -226,14 +198,13 @@ def run_source_stats() -> dict:
for r in rows:
origin = r.origin
sitetag = origin.split(":", 1)[1] if ":" in origin else origin
f = flags.get(origin)
pcts = {
"thumb": float(r.thumb or 0),
"tag": float(f.tag or 0) if f else 0.0,
"perf": float(f.perf or 0) if f else 0.0,
"desc": float(f.descr or 0) if f else 0.0,
"studio": float(f.studio or 0) if f else 0.0,
"dur": float(f.dur or 0) if f else 0.0,
"tag": float(r.tag or 0),
"perf": float(r.perf or 0),
"desc": float(r.descr or 0),
"studio": float(r.studio or 0),
"dur": float(r.dur or 0),
}
freshness = _freshness_score(r.newest_at, int(r.new_7d), int(r.scenes))
richness = _richness_score(pcts)

View file

@ -1,273 +0,0 @@
"""Dedup w obrebie studia po rdzeniu tytulu.
Kazdy tube zapisuje tytul tej samej sceny po swojemu: "[Milfy] Koda Monroe (Voluptuous
Therapist Fucks Prisoner)", "Milfy 26 09 16 Koda Monroe Voluptuous Therapist Fucks Prisoner",
"Koda Monroe Voluptuous Therapist Fucks Prisoner 4K". Miniatury sa z innych klatek, wiec
phash sie nie pokrywa, a title_duration_dedup wymaga identycznego tytulu. Skutek: ta sama
scena Milfy wisiala w katalogu 8 razy (zgloszenie bb1a357d, 2026-09-18).
Rdzen tytulu = slowa bez nazwy studia, dat, znacznikow jakosci, lacznikow i nazwisk obsady
(suma obsady obu porownywanych scen, bo tube czesto nie ma obsady, a nazwisko ma w tytule).
Pojedyncze liczby ZOSTAJA: to numery czesci ("Whip 2", "Part 1"), ktore pierwsza wersja
reguly sklejala w jedno (suchy przebieg 2026-09-18).
Para jest ta sama scena, gdy to samo studio, rowne rdzenie i nic jej nie wyklucza:
- dlugosci (gdy obie znane) roznia sie o <= 5 s,
- daty premiery (gdy obie znane i zadna nie jest samym rokiem, czyli 1 stycznia) o <= 7 dni,
- bez obu dlugosci rdzen musi miec >= 4 slowa, bo zostaje sam tytul,
- obsady (gdy obie niepuste) maja wspolna osobe.
Grupy skladamy przez sume par, ale scalamy tylko sceny zgodne BEZPOSREDNIO z zostawiana,
zeby lancuch A~B~C nie wciagnal C, ktore z A sie nie zgadza (inna dlugosc przez scene bez
dlugosci w srodku).
Kopie bez studia (tube nie podal wytworni, 3 z 8 kopii Milfy) dopinamy do scen studyjnych
ostrzej: obie dlugosci znane i rowne co do 3 s, rdzen >= 4 slowa. Dwie sceny bez studia nie
maja wspolnej kotwicy, wiec tu sie nie lacza (identyczne tytuly zalatwia title_duration_dedup).
Okno czasowe, bo dupy splywaja z tubow w ciagu dni od premiery; cyklicznie w schedulerze.
"""
from __future__ import annotations
import collections
import logging
import re
import unicodedata
import uuid as _u
from dataclasses import dataclass
from datetime import date, datetime
from sqlalchemy import text
from app.db import session_scope
from app.resolve.scene_merge import merge_scenes
log = logging.getLogger(__name__)
_QUALITY = {"4k", "8k", "uhd", "fhd", "hd", "sd", "2160p", "1440p", "1080p", "720p", "480p", "360p", "full", "mp4", "vr"}
_CONNECT = {"and", "x", "ft", "feat", "featuring", "starring", "with"}
# Data wpisana w tytul przez tube: "26 09 16", "(17/9/26)", "21-08-2026", "2026.09.16".
_DATE_RE = re.compile(r"(?<![0-9])[0-9]{1,4}[ ./_-][0-9]{1,2}[ ./_-][0-9]{1,4}(?![0-9])")
_MAX_DURATION_DIFF = 5
_MAX_RELEASE_DIFF_DAYS = 7
# Kubelek (studio + ostatnie slowo rdzenia) wiekszy od tego to generyczny tytul w studiu-
# zbieraczu ("Only Fans"), gdzie porownanie parami jest drogie i ryzykowne.
_MAX_BUCKET = 400
_JUNK_TAG_MIN_SCENES = 1000
_ORPHAN_MAX_DURATION_DIFF = 3
_ORPHAN_MIN_CORE = 4
@dataclass(frozen=True)
class Cand:
id: str
title: str
studio_id: str
studio_name: str
studio_slug: str
duration_sec: int | None
release_date: date | None
created_at: datetime
perfs: tuple[str, ...]
canon: bool
live: int
def title_words(title: str) -> list[str]:
t = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode().lower()
t = _DATE_RE.sub(" ", t)
return re.sub(r"[^a-z0-9]+", " ", t).split()
def studio_tokens(name: str, slug: str) -> set[str]:
words = title_words(name)
# "[BrazzersExxtra]" i "Brazzers Exxtra" to to samo studio.
return set(words) | set(title_words(slug)) | {"".join(words)}
def title_core(title: str, st_tokens: set[str], perf_tokens: set[str]) -> tuple[str, ...]:
return tuple(
w for w in title_words(title)
if w not in _QUALITY and w not in _CONNECT and w not in st_tokens and w not in perf_tokens
)
def _year_only(d: date | None) -> bool:
# Czesc tubow podaje sam rok, zapisany jako 1 stycznia. Taka data niczego nie wyklucza.
return d is not None and d.month == 1 and d.day == 1
def same_scene(a: Cand, b: Cand, st_tokens: set[str], junk_names: frozenset[str] = frozenset()) -> bool:
if a.studio_id and b.studio_id and a.studio_id != b.studio_id:
return False
if not a.studio_id and not b.studio_id:
return False
orphan = not (a.studio_id and b.studio_id)
perf_tokens = {
w for n in a.perfs + b.perfs if n.lower() not in junk_names for w in title_words(n)
}
ca = title_core(a.title, st_tokens, perf_tokens)
if len(ca) < (_ORPHAN_MIN_CORE if orphan else 3) or ca != title_core(b.title, st_tokens, perf_tokens):
return False
if a.duration_sec and b.duration_sec:
if abs(a.duration_sec - b.duration_sec) > (_ORPHAN_MAX_DURATION_DIFF if orphan else _MAX_DURATION_DIFF):
return False
elif orphan or len(ca) < 4:
return False
ra, rb = a.release_date, b.release_date
if ra and rb and not _year_only(ra) and not _year_only(rb):
if abs((ra - rb).days) > _MAX_RELEASE_DIFF_DAYS:
return False
if a.perfs and b.perfs and not (set(a.perfs) & set(b.perfs)):
return False
return True
def _keep_rank(c: Cand) -> tuple:
# Zostaje scena z kanonem (TPDB/StashDB), potem z najwieksza liczba zywych zrodel.
return (not c.canon, not c.studio_id, -c.live, not c.perfs, c.created_at)
def plan_merges(cands: list[Cand], junk_names: frozenset[str] = frozenset()) -> list[tuple[Cand, list[Cand]]]:
"""Zwraca [(zostaje, [do_scalenia...])]. Czysta funkcja, bez bazy."""
by_studio: dict[str, list[Cand]] = collections.defaultdict(list)
orphans: list[Cand] = []
for c in cands:
if c.studio_id:
by_studio[c.studio_id].append(c)
elif c.duration_sec:
orphans.append(c)
parent: dict[str, str] = {}
def find(x: str) -> str:
while parent.setdefault(x, x) != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
tokens_by_studio: dict[str, set[str]] = {}
by_id: dict[str, Cand] = {}
for sid, items in by_studio.items():
st = studio_tokens(items[0].studio_name, items[0].studio_slug)
tokens_by_studio[sid] = st
buckets: dict[str, list[Cand]] = collections.defaultdict(list)
for c in items:
core = title_core(c.title, st, set())
if core:
buckets[core[-1]].append(c)
for bucket in buckets.values():
if len(bucket) < 2 or len(bucket) > _MAX_BUCKET:
continue
for i, a in enumerate(bucket):
for b in bucket[i + 1:]:
if same_scene(a, b, st, junk_names):
parent[find(a.id)] = find(b.id)
by_id[a.id], by_id[b.id] = a, b
# Kopie bez studia: kandydaci z tym samym ostatnim slowem rdzenia i dlugoscia w tym samym
# albo sasiednim przedziale 10 s. Scena studyjna trafia pod oba warianty rdzenia, bo nazwa
# studia bywa na koncu tytulu ("Hotwife Hall Pass - TouchMyWife").
index: dict[tuple[str, int], list[Cand]] = collections.defaultdict(list)
for sid, items in by_studio.items():
st = tokens_by_studio[sid]
for c in items:
if not c.duration_sec:
continue
for core in {title_core(c.title, st, set()), title_core(c.title, set(), set())}:
if core:
index[(core[-1], c.duration_sec // 10)].append(c)
for x in orphans:
core = title_core(x.title, set(), set())
if not core:
continue
seen: set[str] = set()
slot = x.duration_sec // 10
for key in ((core[-1], slot - 1), (core[-1], slot), (core[-1], slot + 1)):
for y in index.get(key, ()):
if y.id in seen:
continue
seen.add(y.id)
if same_scene(x, y, tokens_by_studio[y.studio_id], junk_names):
parent[find(x.id)] = find(y.id)
by_id[x.id], by_id[y.id] = x, y
groups: dict[str, list[Cand]] = collections.defaultdict(list)
for cid, c in by_id.items():
groups[find(cid)].append(c)
plan: list[tuple[Cand, list[Cand]]] = []
for members in groups.values():
keep = min(members, key=_keep_rank)
st = tokens_by_studio[keep.studio_id]
drops = [
m for m in members
if m.id != keep.id
and (m.live or keep.live) # dwie niewidoczne w apce sceny zostawiamy bulk_dedup
and same_scene(keep, m, st, junk_names)
]
if drops:
plan.append((keep, drops))
return plan
def _load(days: int) -> tuple[list[Cand], frozenset[str]]:
sql = text("""
SELECT s.id::text, s.title, s.studio_id::text, st.name, st.slug, s.duration_sec,
s.release_date, s.created_at,
COALESCE((SELECT array_agg(p.canonical_name) FROM scene_performers sp
JOIN performers p ON p.id = sp.performer_id
WHERE sp.scene_id = s.id), '{}') AS perfs,
EXISTS (SELECT 1 FROM scene_external_refs r JOIN sources so ON so.id = r.source_id
WHERE r.scene_id = s.id AND so.kind IN ('tpdb', 'stashdb')) AS canon,
(SELECT count(*) FROM playback_sources p
WHERE p.scene_id = s.id AND p.dead_at IS NULL) AS live
FROM scenes s LEFT JOIN studios st ON st.id = s.studio_id
WHERE (s.created_at > now() - make_interval(days => :d) OR s.release_date > current_date - :d)
AND (s.studio_id IS NOT NULL OR s.duration_sec IS NOT NULL)
""")
with session_scope() as s:
rows = s.execute(sql, {"d": days}).all()
# Tube wpisuja czasem kategorie jako obsade ("Big Tits", "Deep Throat"). Takie
# "nazwiska" wycinalyby z rdzenia zwykle slowa tytulu, wiec ich nie uzywamy. Sama
# obecnosc wsrod tagow nie wystarcza: hashtagi z nazwiskami tez sa tagami ("Koda
# Monroe" 51 scen, "Angela White" 239), a kategorie maja ich tysiace ("Ball Licking"
# 11 tys., "Big Tits" 685 tys.). Pomiar 2026-09-18.
junk = frozenset(
n.lower()
for (n,) in s.execute(text("SELECT name FROM tags WHERE scene_count >= :m"), {"m": _JUNK_TAG_MIN_SCENES}).all()
if n
)
cands = [
Cand(
id=r[0], title=r[1] or "", studio_id=r[2] or "", studio_name=r[3] or "", studio_slug=r[4] or "",
duration_sec=r[5], release_date=r[6], created_at=r[7], perfs=tuple(r[8] or ()),
canon=bool(r[9]), live=int(r[10] or 0),
)
for r in rows
]
return cands, junk
def run_studio_title_dedup(*, days: int = 45, commit: bool = True) -> dict[str, int]:
"""Scal dupy w obrebie studia po rdzeniu tytulu. Zwraca {groups, merges, merged, errors}."""
cands, junk = _load(days)
plan = plan_merges(cands, junk)
merges = sum(len(d) for _, d in plan)
merged = errors = 0
if commit:
for keep, drops in plan:
for drop in drops:
try:
with session_scope() as s:
merge_scenes(
s, keep_id=_u.UUID(keep.id), drop_id=_u.UUID(drop.id),
resolved_by="studio_title_dedup",
)
merged += 1
except Exception as e: # pragma: no cover - obronnie
errors += 1
if errors <= 20:
log.warning("studio_title_dedup keep %s drop %s: %s", keep.id[:8], drop.id[:8], str(e)[:120])
result = {"candidates": len(cands), "groups": len(plan), "merges": merges, "merged": merged, "errors": errors}
log.info("studio_title_dedup: %s", result)
return result

View file

@ -216,11 +216,6 @@ def run_forever() -> int:
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
# Title+duration dedup, missing-merge tube-dupy (reports 28fe8181/32df33b1).
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
# Studio + rdzen tytulu, kopie z tubow o roznym formacie tytulu (report bb1a357d).
"studio_title_dedup_hours": getattr(settings, "sched_studio_title_dedup_hours", 12) or None,
# Martwe zrodla sxyprn/eporner (strona mowi "usuniety"), porcja co 6 h.
"dead_reap_hours": getattr(settings, "sched_dead_reap_hours", 6) or None,
"dead_reap_per_origin": getattr(settings, "dead_reap_per_origin", 1500),
# Blacklista zdegenerowanych phashy, zaślepki dzielone przez setki scen (audyt
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,

View file

@ -32,7 +32,7 @@ import { OnboardingModal } from './src/components/OnboardingModal';
import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion, nativeBuildNumber } from './src/lib/appVersion';
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion } from './src/lib/appVersion';
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
import { getSettings as getLockSettings } from './src/lib/applock';
import { AppNavigator } from './src/navigation';
@ -157,10 +157,6 @@ export default function App() {
// OTA-check settled, gate'uje ekran PIN, żeby ewentualny reloadAsync (silent OTA)
// wydarzył się PRZED pytaniem o PIN, nie po (inaczej "PIN ×2", user-report mobilism).
const [updateSettled, setUpdateSettled] = useState(false);
// Pobieranie APK trwa i nic nie bylo tego widac: user klikal Install, a przez minute
// (87 MB) nie dzialo sie NIC, wiec zglaszal, ze przycisk nie dziala (zgloszenie z apki
// 2026-09-16). Ten stan rysuje nakladke z kreceleem na czas pobierania.
const [installing, setInstalling] = useState(false);
const backgroundedAt = useRef<number | null>(null);
useEffect(() => {
@ -261,42 +257,23 @@ export default function App() {
try {
const out = await client.getServerVersion();
const bundled = APP_VERSION;
// Kolejność źródeł: numer builda APK → runtime → numer wersji. `bundled` bierze
// się z app.json, które przyjeżdża razem z paczką OTA, więc stary APK po
// aktualizacji raportuje nową wersję i sam siebie uznaje za aktualny. Runtime
// jest z natywnego manifestu, ale zmienia się tylko przy zmianie kanału OTA:
// 0.4.0 i 0.4.1 mają ten sam runtime 1.4, więc wydanie naprawcze nie docierało
// do nikogo (crash expo-video z 0.4.0 wracał do Sentry dzień po poprawce).
// Numer builda rozróżnia nawet dwa APK na tym samym kanale.
const nativeBuild = await nativeBuildNumber();
const serverBuild = typeof out.apk_build === 'number' ? out.apk_build : null;
const needsApk = serverBuild && nativeBuild
? serverBuild > nativeBuild
: out.runtime_version && NATIVE_RUNTIME
? isNewerVersion(out.runtime_version, NATIVE_RUNTIME)
: isNewerVersion(out.version, bundled);
// Porównujemy RUNTIME, nie numer wersji: `bundled` bierze się z app.json, które
// przyjeżdża razem z paczką OTA, więc stary APK po aktualizacji raportuje nową
// wersję i sam siebie uznaje za aktualny. Runtime jest z natywnego manifestu.
// Fallback na porównanie wersji, gdy serwer jeszcze nie podaje runtime.
const needsApk = out.runtime_version && NATIVE_RUNTIME
? isNewerVersion(out.runtime_version, NATIVE_RUNTIME)
: isNewerVersion(out.version, bundled);
if (out.version && needsApk && out.apk_url) {
// In-app installer dostępny → "Install" zamiast "Download"
if (apkInstallerAvailable) {
const pendingApkUrl = out.apk_url!;
const runInstall = async () => {
setInstalling(true);
try {
await installApk(pendingApkUrl);
// PackageInstaller pokazuje natywny dialog, JS już nie ma co robić.
} catch (e) {
// Gdy pobieranie albo instalacja padnie, zostaje przegladarka: plik jest
// pod tym samym adresem (na repo git APK nie ma, pyta o to user).
Alert.alert(
'Install failed',
`${e instanceof Error ? e.message : String(e)}\n\nYou can download the APK in your browser instead.`,
[
{ text: 'Later', style: 'cancel' },
{ text: 'Download', onPress: () => Linking.openURL(pendingApkUrl).catch(() => {}) },
],
);
} finally {
setInstalling(false);
Alert.alert('Install failed', e instanceof Error ? e.message : String(e));
}
};
const tryInstall = async () => {
@ -336,12 +313,8 @@ export default function App() {
// wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i
// monit wyglądał na pomyłkę.
`Goon ${out.version} is ready. It adds parts that silent updates cannot deliver, so it installs like a normal app.\n\nInstall now?`,
// Trzy przyciski to maksimum androidowego dialogu. „Download" zostaje,
// bo instalator w apce potrafi nie wystartowac (brak zgody, padniete
// pobieranie), a wtedy user nie ma jak wziac pliku recznie.
[
{ text: 'Later', style: 'cancel' },
{ text: 'Download', onPress: () => Linking.openURL(out.apk_url!).catch(() => {}) },
{ text: 'Install', onPress: tryInstall },
],
);
@ -470,30 +443,6 @@ export default function App() {
</View>
</View>
) : null}
{installing ? (
<View
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
zIndex: 100,
backgroundColor: 'rgba(0,0,0,0.75)',
alignItems: 'center',
justifyContent: 'center',
padding: 32,
}}
>
<ActivityIndicator color={theme.fg} size="large" />
<RNText style={{ color: theme.fg, marginTop: 16, fontSize: 15, textAlign: 'center' }}>
Downloading the update
</RNText>
<RNText style={{ color: theme.muted, marginTop: 8, fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
About 87 MB. Android will ask you to confirm the install when it finishes.
</RNText>
</View>
) : null}
{client ? (
<ClientProvider client={client}>
<PreferencesProvider>

View file

@ -104,8 +104,8 @@ android {
applicationId 'com.goon.mobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 16
versionName "0.4.2"
versionCode 15
versionName "0.4.1"
}
signingConfigs {
debug {

View file

@ -7,16 +7,6 @@
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- Android TV (prosba z apki 2026-09-16). Obie cechy jako NIEwymagane: bez tego
telewizor uznaje apke za niezgodna (nie ma ekranu dotykowego), a telefon nie ma
leanbacka. Sam wpis nic nie wymusza, otwiera tylko instalacje na obu rodzajach
sprzetu. Pelnego interfejsu pod pilota to NIE daje, patrz MainActivity. -->
<uses-feature android:name="android.software.leanback" android:required="false"/>
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
<!-- Blokada pionu w aktywnosci sama z siebie dokladala WYMAGANIE ekranu pionowego
(aapt: uses-feature android.hardware.screen.portrait), przez co telewizor uznawal
apke za niezgodna. Jawny wpis to odkreca. -->
<uses-feature android:name="android.hardware.screen.portrait" android:required="false"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@ -24,7 +14,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:banner="@drawable/tv_banner" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config">
<!--
Expo Updates — ENABLED 2026-05-22 dla public release. Manifest serwowany
przez backend `/expo-updates/manifest` (api.goon-foss.org). Nowe JS-only
@ -49,7 +39,7 @@
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait" android:resizeableActivity="true">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@ -61,14 +51,5 @@
<data android:scheme="com.goon.mobile"/>
</intent-filter>
</activity>
<!-- Android TV. Bez wpisu LEANBACK_LAUNCHER apka nie pojawia sie na ekranie glownym
telewizora, mimo ze instaluje sie bez bledu. Banner (320x180) jest w <application>.
Osobna aktywnosc, bo tu NIE MA blokady pionu, patrz TvMainActivity. -->
<activity android:name=".TvMainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -13,8 +13,7 @@ import com.reactnative.googlecast.api.RNGCCastContext
import expo.modules.ReactActivityDelegateWrapper
// open, bo dziedziczy z niej TvMainActivity (to samo, tylko bez blokady pionu).
open class MainActivity : ReactActivity() {
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.

View file

@ -1,19 +0,0 @@
package com.goon.mobile
/**
* Wejscie dla Android TV. Ta sama aktywnosc co na telefonie, rozni sie WYLACZNIE wpisem
* w manifescie: telefon ma blokade pionu, telewizor jej nie ma, bo ekran telewizora jest
* na stale poziomy i pionowa aplikacja wychodzi tam waskim paskiem na srodku.
*
* Dlaczego osobna klasa, a nie jedna aktywnosc:
* - `setRequestedOrientation` w `onCreate` to zmiana konfiguracji, wiec system
* relaunchuje aktywnosc tuz po starcie i na TV zostawal bialy ekran (widok Reacta nie
* wracal; emulator android-34 android-tv, 2026-09-17),
* - orientacja z zasobu (`values-television`) nie przechodzi, bo manifest czyta zasoby
* bez kontekstu konfiguracji (lint ManifestResource),
* - `activity-alias` nie pozwala nadpisac `screenOrientation`.
*
* Telewizorowy launcher pyta o `LEANBACK_LAUNCHER`, wiec na telefonie ta klasa jest
* niewidoczna, a na TV widoczna jest tylko ona.
*/
class TvMainActivity : MainActivity()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -2,7 +2,7 @@
"expo": {
"name": "goon",
"slug": "goon",
"version": "0.4.2",
"version": "0.4.1",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,

View file

@ -1,31 +0,0 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/jni/JNIDeallocator.kt
@@ -34,7 +34,10 @@ class JNIDeallocator(shouldCreateDestructorThread: Boolean = true) : AutoCloseab
// Referent of PhantomReference were garbage collected so we can remove it from our registry.
// Note that we don't have to call `deallocate` method - it was called [com.facebook.jni.HybridData].
val current = referenceQueue.remove()
- synchronized(this) {
+ // Goon: `this` w `object : Thread` to watek, nie deallocator. Blokada na watku
+ // nic nie chronila i remove szedl rownolegle z iteracja w deallocate(), stad
+ // ConcurrentModificationException przy przeladowaniu JS (Sentry GOON-48).
+ synchronized(this@JNIDeallocator) {
destructorMap.remove(current)
}
} catch (e: InterruptedException) {
@@ -65,10 +68,13 @@ class JNIDeallocator(shouldCreateDestructorThread: Boolean = true) : AutoCloseab
* Deallocates valid references and clears the internal registry.
*/
internal fun deallocate() = synchronized(this) {
- destructorMap.values.forEach {
+ // Goon: kopia przed wolaniem deallocate(), ktore moze wrocic do tej mapy z tego
+ // samego watku (blokada jest reentrant, wiec by nie zatrzymala), GOON-48.
+ val refs = destructorMap.values.toList()
+ destructorMap.clear()
+ refs.forEach {
it.get()?.deallocate()
}
- destructorMap.clear()
destructorThread?.interrupt()
}

View file

@ -1,17 +1,8 @@
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
index cd798cf..8cb6dfb 100644
index cd798cf..34923b2 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
@@ -26,7 +26,7 @@ class PlaybackServiceBinder(val service: ExpoVideoPlaybackService) : Binder()
@OptIn(UnstableApi::class)
class ExpoVideoPlaybackService : MediaSessionService() {
- private val mediaSessions = mutableMapOf<ExoPlayer, MediaSession>()
+ private val mediaSessions = java.util.concurrent.ConcurrentHashMap<ExoPlayer, MediaSession>()
private val binder = PlaybackServiceBinder(this)
private val commandSeekForward = SessionCommand(SEEK_FORWARD_COMMAND, Bundle.EMPTY)
@@ -136,10 +136,20 @@ class ExpoVideoPlaybackService : MediaSessionService() {
@@ -136,10 +136,14 @@ class ExpoVideoPlaybackService : MediaSessionService() {
private fun cleanup() {
hideAllNotifications()
@ -19,13 +10,7 @@ index cd798cf..8cb6dfb 100644
+ // Kopia przed iteracja: session.release() wola z powrotem unregisterPlayer, ktory
+ // usuwa wpis z mediaSessions w trakcie forEach -> ConcurrentModificationException
+ // przy zamykaniu apki z listy ostatnich (Goon, Sentry 147164135, 2026-09-15).
+ // Mapa jest ConcurrentHashMap (patrz deklaracja), bo sama kopia tez iteruje mape,
+ // a wpisy znikaja z innego watku przy zwalnianiu playera: na LinkedHashMap `toList()`
+ // wywalalo sie wtedy tak samo (Goon 0.4.1, Sentry GOON-6W, 2026-09-17).
+ // ArrayList(values), nie `toList()`: toList przy rozmiarze 1 robi iterator().next(),
+ // a wpis moze zniknac miedzy size() i next() -> NoSuchElementException (Goon 0.4.2,
+ // Sentry GOON-72). Konstruktor idzie przez toArray(), ktore mapa robi bezpiecznie.
+ val sessions = ArrayList(mediaSessions.values)
+ val sessions = mediaSessions.values.toList()
+ mediaSessions.clear()
+ sessions.forEach { session ->
session.release()

View file

@ -589,8 +589,6 @@ export class GoonClient {
async getServerVersion(): Promise<{
version: string;
runtime_version?: string | null;
/** versionCode najnowszego APK; rozróżnia wydania na tym samym runtime. */
apk_build?: number | null;
apk_url: string | null;
}> {
// /version nie wymaga API key (publiczny endpoint do upgrade discovery).
@ -669,12 +667,8 @@ export class GoonClient {
});
}
/** Bez `q`: najpopularniejsze tagi i nisze. Z `q`: szukanie po calym slowniku tagow. */
async getClipOnboardingOptions(q?: string): Promise<ClipOnboardingOptions> {
const path = q && q.trim()
? `/clips/onboarding-options?q=${encodeURIComponent(q.trim())}`
: '/clips/onboarding-options';
return this.request(path);
async getClipOnboardingOptions(): Promise<ClipOnboardingOptions> {
return this.request('/clips/onboarding-options');
}
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
@ -690,23 +684,6 @@ export class GoonClient {
await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' });
}
/** Klipy jednego tworcy (albo tagu/zrodla), najnowsze pierwsze. */
async listClips(params: {
creator?: string;
tag?: string;
source?: string;
limit?: number;
offset?: number;
}): Promise<ClipListOut> {
const q = new URLSearchParams();
if (params.creator) q.set('creator', params.creator);
if (params.tag) q.set('tag', params.tag);
if (params.source) q.set('source', params.source);
q.set('limit', String(params.limit ?? 50));
q.set('offset', String(params.offset ?? 0));
return this.request(`/clips?${q.toString()}`);
}
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
}

View file

@ -35,54 +35,6 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-17b',
date: 'September 2026',
items: [
'Version 0.4.2 fixes the crash when closing the app while a video was open. The previous attempt only covered part of it: the list of players could also change from another thread while it was being copied.',
'Android TV: the app now shows up on the TV home screen, runs full screen and can be driven with the remote on the existing screens. It is not a purpose-built TV interface yet: there is no focus highlight, and sites that ask you to tap a human check are painful with a remote.',
],
},
{
id: '2026-09-17a',
date: 'September 2026',
items: [
'Random order now shuffles what your filter or search actually found. Searching with random on used to return a handful of results, because the shuffle was picked before the search ran.',
'The update prompt shows that the download is running (it is 87 MB and nothing was visible for a minute), and it offers a plain browser download next to the in-app install.',
],
},
{
id: '2026-09-16d',
date: 'September 2026',
items: [
'Edge on a folding phone or a tablet: clips are no longer cropped down to the middle. When the screen and the clip are shaped too differently, you get the whole clip with black bars instead, and the caption and buttons sit on those bars. Wide clips gain from this on normal phones too.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16c',
date: 'September 2026',
items: [
'Edge: tapping the heart no longer restarts the clip or flips the sound on, and the clip no longer jumps back to the start when the next batch loads in the background.',
'Edge: one sound button instead of two, and a new tuning button at the top. Open it any time to add what you want more of, including a search across every tag we have, and the feed uses it from the next deal on.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16b',
date: 'September 2026',
items: [
'Edge: tap the creator name to open their page. You get every clip they have, and tapping one plays it full screen with the same swipe up for the next one.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16a',
date: 'September 2026',
items: [
'The app now notices when a newer build is out even if it shares the silent-update channel with yours, so fixes that need a real install stop waiting for you to find them. If you are on 0.4.0, expect a prompt for 0.4.1: it fixes a crash when closing the app from recents.',
],
},
{
id: '2026-09-15d',
date: 'September 2026',

View file

@ -36,7 +36,6 @@ import { captureScreen } from 'react-native-view-shot';
import { GoonClient } from '../api';
import { theme } from '../theme';
import { nativeBuildNumber } from '../lib/appVersion';
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar';
import { formatLastPlayback } from '../lib/lastPlayback';
import { getSxyprnDiag } from '../lib/sxyprnResolver';
@ -221,11 +220,6 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}`
: `${Platform.OS} ${Platform.Version}`;
extraContext.push(`device=${device.slice(0, 48)}`);
// Numer builda ZAINSTALOWANEGO APK. `app_version` idzie z paczki OTA, wiec zgloszenie
// z APK 0.4.0 po aktualizacji podawalo 0.4.1 i nie dalo sie odroznic, kto ma ktore
// wydanie natywne (crash expo-video siedzial tylko w 0.4.0).
const build = await nativeBuildNumber();
if (build) extraContext.push(`build=${build}`);
// Ostatnie odtwarzanie (serwer/host/pozycja/błąd), kluczowe dla "nie gra"/"audio
// się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia.
const play = formatLastPlayback();

View file

@ -22,7 +22,7 @@ import { Image } from 'expo-image';
import type { VideoPlayer } from 'expo-video';
import { VideoView } from 'expo-video';
import React from 'react';
import { Animated, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -40,35 +40,14 @@ interface Props {
onLike: () => void;
onNotInterested: () => void;
onTagPress?: (tag: string) => void;
onCreatorPress?: (creator: string) => void;
}
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress, onCreatorPress }: Props) {
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress }: Props) {
const clip = item.clip;
const [progress, setProgress] = React.useState(0);
const [ready, setReady] = React.useState(false);
const heart = React.useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets();
// Kadrowac czy dolozyc czarne pasy.
//
// `cover` wypelnia ekran i tnie to, co wystaje. Na zwyklym telefonie pionowy klip traci
// tak ze 20 % szerokosci i nikomu to nie przeszkadza, ale na rozlozonym foldzie ekran
// jest prawie kwadratowy i z pionowego klipu zostaje srodek (zgloszenie Jana
// 2026-09-16). To samo dotyczy klipow poziomych, ktorych na RedGifs jest ok. 19 %.
// Liczymy, ile by sie ucielo: do `MAX_CROP` zostaje `cover`, powyzej `contain`, czyli
// cale wideo i pasy po bokach albo u gory. Podpis i kolumna przyciskow stoja przy
// krawedziach ekranu, wiec przy pasach same wchodza na pasy zamiast na wideo.
// `useWindowDimensions` reaguje na zlozenie i obrot, wiec kadr zmienia sie od razu.
const win = useWindowDimensions();
const fit = React.useMemo<'cover' | 'contain'>(() => {
const MAX_CROP = 1.3;
const clipAspect = clip.width && clip.height ? clip.width / clip.height : 9 / 16;
const screenAspect = win.width / Math.max(win.height, 1);
if (!clipAspect || !screenAspect) return 'cover';
const crop = Math.max(screenAspect / clipAspect, clipAspect / screenAspect);
return crop > MAX_CROP ? 'contain' : 'cover';
}, [clip.width, clip.height, win.width, win.height]);
const lift = React.useMemo(
() =>
barHidden.interpolate({
@ -166,43 +145,36 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
const tags = clip.tags.slice(0, 4);
return (
// Gesty (tap = dzwiek, double-tap = serce, przytrzymanie = 2x, w lewo = „nie
// interesuje") lapie TYLKO warstwa wideo. Wczesniej GestureDetector obejmowal cala
// komorke razem z przyciskami, wiec tapniecie serduszka bylo jednoczesnie tapnieciem
// w ekran i przelaczalo dzwiek (zgloszenie Jana 2026-09-16).
<View style={styles.root}>
<GestureDetector gesture={gestures}>
<View style={StyleSheet.absoluteFill} collapsable={false}>
{poster ? (
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit={fit} transition={0} />
) : null}
{active && player ? (
<VideoView
player={player}
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
nativeControls={false}
contentFit={fit}
allowsPictureInPicture={false}
/>
) : null}
<GestureDetector gesture={gestures}>
<View style={styles.root}>
{poster ? (
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} />
) : null}
{active && player ? (
<VideoView
player={player}
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
nativeControls={false}
contentFit="cover"
allowsPictureInPicture={false}
/>
) : null}
{/* Wybuch serca po double-tapie */}
<Animated.View
pointerEvents="none"
style={[
styles.heartBurst,
{
opacity: heart,
transform: [{ scale: Animated.add(0.4, Animated.multiply(heart, 0.9)) }],
},
]}
>
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View>
</View>
</GestureDetector>
{/* Wybuch serca po double-tapie */}
<Animated.View
pointerEvents="none"
style={[
styles.heartBurst,
{
opacity: heart,
transform: [{ scale: Animated.add(0.4, Animated.multiply(heart, 0.9)) }],
},
]}
>
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
{/* Kolumna przyciskow */}
<View style={styles.rail} pointerEvents="box-none">
<Pressable
@ -229,27 +201,15 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
{/* Nazwa tworcy prowadzi do jego strony (zgloszenie 2026-09-15: „cannot tap
username"). Bez tworcy (albo bez obslugi) zostaje sam napis. */}
<Pressable
style={styles.creatorRow}
disabled={!clip.creator || !onCreatorPress}
onPress={() => clip.creator && onCreatorPress?.(clip.creator)}
accessibilityRole={clip.creator && onCreatorPress ? 'link' : undefined}
accessibilityLabel={clip.creator ? `Clips by ${clip.creator}` : undefined}
hitSlop={8}
>
<View style={styles.creatorRow}>
<Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{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>
</View>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
@ -270,8 +230,9 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
<View style={styles.progressTrack} pointerEvents="none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
</View>
</Animated.View>
</View>
</Animated.View>
</View>
</GestureDetector>
);
}

View file

@ -1,226 +0,0 @@
/**
* Pionowy pager klipow z DWOMA playerami (aktywny + nastepny w buforze).
*
* Wydzielone ze SwipeScreen (2026-09-16), bo strona tworcy pokazuje te same klipy w ten
* sam sposob. Komponent odpowiada tylko za odtwarzanie i przewracanie stron; feed,
* telemetria, autoSwipe i dolny pasek zostaly w ekranach, ktore go uzywaja.
*
* Pager (`react-native-pager-view`) jest modulem natywnym z APK od runtime 1.4, wiec
* ladujemy go przez `require` w try/catch: ten sam bundle JS leci OTA takze na starsze
* APK, tam zakladka Edge jest ukryta (`swipeAvailable`).
*/
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import * as Haptics from 'expo-haptics';
import React from 'react';
import { AppState, StyleSheet, View } from 'react-native';
import type { ClipFeedItem } from '../../types';
import { ClipCell } from './ClipCell';
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const pagerAvailable: boolean = PagerViewImpl != null;
/** Co dzialo sie na stronie, z ktorej wlasnie zeszlismy (do telemetrii). */
export interface ClipLeaveInfo {
dwellMs: number;
loops: number;
positionSec: number;
}
interface Props {
items: ClipFeedItem[];
index: number;
initialPage?: number;
onPageSelected: (next: number) => void;
/** Zejscie ze strony `prevIndex`; wolane po zmianie `index`, nie przy odmontowaniu. */
onLeave?: (prevIndex: number, info: ClipLeaveInfo) => void;
soundOn: boolean;
onToggleSound: () => void;
isLiked: (clipId: string) => boolean;
onLike: (item: ClipFeedItem, position: number) => void;
onNotInterested: (item: ClipFeedItem, position: number) => void;
onCreatorPress?: (creator: string) => void;
/** Ref pagera dla wywolujacego (autoSwipe robi `setPage`). */
pagerRef?: React.MutableRefObject<any>;
}
/** Adres wideo klipu (HD, a gdy go nie ma, SD). Klip bez zadnego z nich sie nie odtworzy. */
export function clipSource(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function ClipPager({
items,
index,
initialPage = 0,
onPageSelected,
onLeave,
soundOn,
onToggleSound,
isLiked,
onLike,
onNotInterested,
onCreatorPress,
pagerRef,
}: Props) {
const localRef = React.useRef<any>(null);
const ref = pagerRef ?? localRef;
const players = React.useRef<VideoPlayer[]>([]);
const loopsRef = React.useRef(0);
const activatedAtRef = React.useRef(0);
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
//
// Zaleznosci to NUMER strony i adresy wideo, nie cala tablica `items` ani `soundOn`
// (2026-09-16). Kazda zmiana tamtych rzeczy przewijala klip na poczatek: dociagniecie
// kolejnej strony feedu podmienialo tablice w srodku ogladania („losowo miga i laduje
// sie od nowa"), a wlaczenie dzwieku restartowalo wideo. Wyciszenie ma wlasny efekt
// nizej, `items` czytamy przez ref.
const prevIndexRef = React.useRef<number | null>(null);
const leaveRef = React.useRef(onLeave);
leaveRef.current = onLeave;
const itemsRef = React.useRef(items);
itemsRef.current = items;
const soundRef = React.useRef(soundOn);
soundRef.current = soundOn;
const activeSrc = items[index] ? clipSource(items[index]) : null;
const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null;
React.useEffect(() => {
const ps = players.current;
const items = itemsRef.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
leaveRef.current?.(prev, {
dwellMs: Date.now() - activatedAtRef.current,
loops: loopsRef.current,
positionSec: pos,
});
try {
prevPlayer.pause();
} catch {
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, activeSrc);
active.muted = !soundRef.current;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
if (nextSrc) {
ensureSource(standby, nextSrc);
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, activeSrc, nextSrc, ensureSource]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !soundOn;
}, [soundOn, activePlayer]);
if (!PagerViewImpl) return null;
const PagerView = PagerViewImpl;
return (
<PagerView
ref={ref}
style={styles.pager}
orientation="vertical"
initialPage={initialPage}
offscreenPageLimit={1}
overdrag
onPageSelected={(e: { nativeEvent: { position: number } }) => onPageSelected(e.nativeEvent.position)}
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>
<ClipCell
item={it}
active={i === index}
player={i === index ? activePlayer : null}
muted={!soundOn}
liked={isLiked(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={onToggleSound}
onLike={() => onLike(it, i)}
onNotInterested={() => onNotInterested(it, i)}
onCreatorPress={onCreatorPress}
/>
</View>
))}
</PagerView>
);
}
const styles = StyleSheet.create({
pager: { flex: 1 },
page: { flex: 1 },
});

View file

@ -18,7 +18,7 @@ import * as Updates from 'expo-updates';
* też nie idzie do góry, więc consistency jest zachowana.
*/
export const APP_VERSION: string =
(Constants.expoConfig?.version as string | undefined) || '0.4.2';
(Constants.expoConfig?.version as string | undefined) || '0.4.0';
/**
* Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po
@ -39,34 +39,6 @@ export const NATIVE_RUNTIME: string | null = (() => {
})();
/**
* `versionCode` ZAINSTALOWANEGO APK (0.4.0 = 14, 0.4.1 = 15), albo null.
*
* Po co, skoro jest runtime: runtime zmienia się tylko przy zmianie kanału OTA, a wydanie
* naprawcze zostaje na tym samym kanale. 0.4.0 i 0.4.1 mają oba runtime 1.4, więc nikt
* z 0.4.0 nie dostał monitu o 0.4.1, mimo że tamto APK naprawia crash przy zamykaniu
* aplikacji z ostatnich (Sentry 147164135). Numer builda rozróżnia je bez podbijania
* runtime i bez utrzymywania kolejnego kanału OTA.
*
* Skąd: natywny moduł Sentry, który i tak jest w aplikacji i czyta PackageInfo (to stąd
* bierze się `app_build` w zdarzeniach). expo-constants w SDK 52 oddaje puste
* `platform.android`, a expo-application wymagałby nowego APK, czyli dokładnie tego,
* czego tu brakuje. Wejście prywatne, więc całość w try/catch: brak numeru = stare
* zachowanie (porównanie runtime).
*/
export async function nativeBuildNumber(): Promise<number | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { NATIVE } = require('@sentry/react-native/dist/js/wrapper');
const ctx: any = await NATIVE.fetchNativeDeviceContexts();
const raw = ctx?.contexts?.app?.app_build ?? ctx?.app?.app_build;
const n = parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
/** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */
function versionParts(v: string): number[] {
return String(v || '').split('.').map((x) => parseInt(x, 10) || 0);

View file

@ -15,7 +15,6 @@ import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
import { BlacklistScreen } from './screens/BlacklistScreen';
import { ClipCreatorScreen } from './screens/ClipCreatorScreen';
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
import { DonateScreen } from './screens/DonateScreen';
import { FavoritesScreen } from './screens/FavoritesScreen';
@ -72,8 +71,6 @@ export type RootStackParamList = {
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
Tags: { vertical?: string } | undefined;
TagScenes: { slug: string; name: string; vertical?: string | null };
// Strona tworcy klipow (Edge): siatka jego klipow + pelny ekran z pionowym pagerem.
ClipCreator: { creator: string };
AppLockSettings: undefined;
Blacklist: undefined;
Donate: undefined;
@ -283,7 +280,6 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
component={DonateScreen}
options={{ title: 'Support project' }}
/>
<Stack.Screen name="ClipCreator" component={ClipCreatorScreen} options={{ title: '' }} />
<Stack.Screen
name="DiagnosticBrowser"
component={DiagnosticBrowserScreen}

View file

@ -1,230 +0,0 @@
/**
* Strona tworcy klipow: siatka jego klipow, tapniecie otwiera je na pelnym ekranie
* i dalej przewija sie w pionie, tak jak w Edge.
*
* Skad sie wziela: zgloszenie z apki 2026-09-15 („cannot tap username as it's hidden
* behind icon tray"). Pasek zaslanial podpis, ale pod nazwa tworcy i tak nic nie bylo.
*
* Dane: `GET /clips?creator=<nazwa>` (najnowsze pierwsze, strony po 60). Odtwarzanie
* i dwa playery to ten sam `ClipPager` co w Edge.
*
* Telemetria: wysylamy TYLKO like/unlike i „nie interesuje". Ogladania stad NIE licza sie
* do feedu: user wszedl na konkretnego tworce, wiec potraktowanie tego jak zwyklego
* ogladania zabetonowaloby feed na nim.
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Image } from 'expo-image';
import React from 'react';
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useClient } from '../ClientContext';
import { ClipPager, clipSource } from '../components/swipe/ClipPager';
import { newSessionId } from '../lib/swipeTelemetry';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOut } from '../types';
const PAGE = 60;
const COLS = 3;
function asFeedItem(clip: ClipOut): ClipFeedItem {
return { clip, slot: 'creator', is_fresh: false };
}
function dur(sec: number | null): string {
if (!sec || sec <= 0) return '';
const s = Math.round(sec);
return s >= 60 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
}
export function ClipCreatorScreen() {
const client = useClient();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'ClipCreator'>>();
const route = useRoute<RouteProp<RootStackParamList, 'ClipCreator'>>();
const { creator } = route.params;
const insets = useSafeAreaInsets();
const [playing, setPlaying] = React.useState<number | null>(null);
const [soundOn, setSoundOn] = React.useState(false);
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const sessionId = React.useRef(newSessionId());
const q = useInfiniteQuery({
queryKey: ['clips-by-creator', creator],
queryFn: ({ pageParam }) => client.listClips({ creator, limit: PAGE, offset: pageParam as number }),
initialPageParam: 0,
getNextPageParam: (last, all) => (last.has_more ? all.length * PAGE : undefined),
});
const items = React.useMemo<ClipFeedItem[]>(() => {
const out: ClipFeedItem[] = [];
for (const page of q.data?.pages ?? []) {
for (const clip of page.items) {
if (clipSource(asFeedItem(clip))) out.push(asFeedItem(clip));
}
}
return out;
}, [q.data]);
const total = q.data?.pages[0]?.total ?? null;
const verified = items.some((it) => it.clip.creator_verified);
React.useLayoutEffect(() => {
navigation.setOptions({ title: `@${creator}` });
}, [navigation, creator]);
const sendEvent = React.useCallback(
(clipId: string, kind: 'like' | 'unlike' | 'not_interested', reason?: string) => {
client
.postClipEvents(sessionId.current, [{ clip_id: clipId, kind, reason, slot: 'creator' }])
.catch(() => {});
},
[client],
);
const like = React.useCallback(
(item: ClipFeedItem) => {
const was = liked.has(item.clip.id) || Boolean(item.clip.is_favorite);
setLiked((s) => {
const n = new Set(s);
if (was) n.delete(item.clip.id);
else n.add(item.clip.id);
return n;
});
sendEvent(item.clip.id, was ? 'unlike' : 'like');
},
[liked, sendEvent],
);
return (
<View style={styles.root}>
<FlatList
data={items}
keyExtractor={(it) => it.clip.id}
numColumns={COLS}
removeClippedSubviews={false}
columnWrapperStyle={styles.row}
contentContainerStyle={{ padding: 8, paddingBottom: 24 }}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.count}>
{total != null ? `${total} ${total === 1 ? 'clip' : 'clips'}` : `${items.length} clips`}
</Text>
{verified ? (
<View style={styles.verified}>
<MaterialCommunityIcons name="check-decagram" size={14} color={theme.accentSecondary} />
<Text style={styles.verifiedText}>verified</Text>
</View>
) : null}
</View>
}
renderItem={({ item, index }) => (
<Pressable style={styles.tile} onPress={() => setPlaying(index)}>
{item.clip.poster_url || item.clip.thumb_url ? (
<Image
source={{ uri: (item.clip.poster_url || item.clip.thumb_url) as string }}
style={StyleSheet.absoluteFill}
contentFit="cover"
transition={100}
/>
) : null}
<View style={styles.tileFoot}>
<Text style={styles.tileDur}>{dur(item.clip.duration_sec)}</Text>
{liked.has(item.clip.id) || item.clip.is_favorite ? (
<MaterialCommunityIcons name="heart" size={13} color={theme.accentText} />
) : null}
</View>
</Pressable>
)}
onEndReachedThreshold={0.4}
onEndReached={() => {
if (q.hasNextPage && !q.isFetchingNextPage) void q.fetchNextPage();
}}
ListEmptyComponent={
q.isLoading ? (
<ActivityIndicator color={theme.accentText} style={{ marginTop: 32 }} />
) : (
<Text style={styles.empty}>No clips from this creator right now.</Text>
)
}
ListFooterComponent={
q.isFetchingNextPage ? <ActivityIndicator color={theme.accentText} style={{ margin: 16 }} /> : null
}
/>
<Modal visible={playing !== null} animationType="slide" onRequestClose={() => setPlaying(null)}>
<View style={styles.player}>
{playing !== null ? (
<ClipPager
items={items}
index={playing}
initialPage={playing}
onPageSelected={setPlaying}
soundOn={soundOn}
onToggleSound={() => setSoundOn((v) => !v)}
isLiked={(id) => liked.has(id)}
onLike={like}
onNotInterested={(it) => {
sendEvent(it.clip.id, 'not_interested', `creator:${creator}`);
setPlaying(null);
}}
/>
) : null}
{/* Modal rysuje sie od samej gory ekranu, wiec bez insetu przycisk laduje pod
paskiem stanu i tapniecie idzie do systemu (sprawdzone na emulatorze). */}
<Pressable
style={[styles.close, { top: insets.top + 8 }]}
onPress={() => setPlaying(null)}
accessibilityLabel="Close"
hitSlop={8}
>
<MaterialCommunityIcons name="chevron-down" size={26} color="#fff" />
</Pressable>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 4, paddingBottom: 10 },
count: { color: theme.muted, fontFamily: fonts.body, fontSize: 13 },
verified: { flexDirection: 'row', alignItems: 'center', gap: 4 },
verifiedText: { color: theme.accentSecondary, fontFamily: fonts.medium, fontSize: 12 },
row: { gap: 6, marginBottom: 6 },
tile: {
// maxWidth, bo ostatni rzad bywa niepelny: bez tego jeden klip tworcy rozciagal
// sie na cala szerokosc ekranu (sprawdzone na emulatorze).
flex: 1,
maxWidth: `${100 / COLS}%`,
aspectRatio: 0.62,
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.card,
justifyContent: 'flex-end',
},
tileFoot: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 6,
paddingVertical: 4,
backgroundColor: 'rgba(0,0,0,0.45)',
},
tileDur: { color: '#fff', fontFamily: fonts.mono, fontSize: 11 },
empty: { color: theme.muted, textAlign: 'center', marginTop: 40, paddingHorizontal: 24, lineHeight: 20 },
player: { flex: 1, backgroundColor: '#000' },
close: {
position: 'absolute',
left: 10,
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.35)',
},
});

View file

@ -106,10 +106,6 @@ function useMarkSourceBroken(params: RouteParams) {
// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować).
function isGoneError(msg: string | undefined | null): boolean {
if (!msg) return false;
// Blad certyfikatu to nie usuniety film. "Trust anchor for certification path not found"
// lapal sie na "not found", wiec 93 odtworzenia eporner w tydzien (CDN z GlobalSign R6,
// ktorego starsze Androidy nie znaja) szly jako "gone": zly komunikat i zla telemetria.
if (/SSLHandshake|CertPathValidator|Trust anchor|certificate/i.test(msg)) return false;
return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg);
}

View file

@ -17,16 +17,14 @@
* serwer uczy sie wag i uklada 80 % kolejnych stron.
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import * as Haptics from 'expo-haptics';
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import React from 'react';
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useClient } from '../ClientContext';
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
import type { RootStackParamList } from '../navigation';
import { ClipCell } from '../components/swipe/ClipCell';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { setBarHidden } from '../lib/bottomBar';
import { usePersistedState } from '../lib/persistedState';
@ -34,9 +32,19 @@ import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOnboardingOptions } from '../types';
// Pager jest modulem natywnym: jest w APK od runtime 1.4. Wczesniejsze APK dostaja ten
// bundle przez OTA, wiec import musi byc miekki.
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const SWIPE_MIN_RUNTIME = '1.4';
export const swipeAvailable: boolean =
pagerAvailable && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
PagerViewImpl != null && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
const PAGE_SIZE = 10;
const PREFETCH_AT = 4;
@ -49,6 +57,10 @@ interface SwipePrefs {
}
const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false };
function sourceOf(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function SwipeScreen() {
const client = useClient();
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
@ -56,9 +68,7 @@ export function SwipeScreen() {
const [index, setIndex] = React.useState(0);
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
const [tuneOpen, setTuneOpen] = React.useState(false);
const pagerRef = React.useRef<any>(null);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// ---- dane ------------------------------------------------------------------------
const feed = useInfiniteQuery({
@ -74,7 +84,7 @@ export function SwipeScreen() {
const out: ClipFeedItem[] = [];
for (const page of feed.data?.pages ?? []) {
for (const it of page.items) {
if (!seen.has(it.clip.id) && clipSource(it)) {
if (!seen.has(it.clip.id) && sourceOf(it)) {
seen.add(it.clip.id);
out.push(it);
}
@ -96,7 +106,8 @@ export function SwipeScreen() {
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
React.useEffect(() => () => setBarHidden(false), []);
const onPageSelected = React.useCallback(
(next: number) => {
(e: { nativeEvent: { position: number } }) => {
const next = e.nativeEvent.position;
if (next === 0 || next < index) setBarHidden(false);
else if (next > index) setBarHidden(true);
setIndex(next);
@ -113,21 +124,111 @@ export function SwipeScreen() {
return () => t.stop();
}, []);
// ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
const onLeave = React.useCallback(
(prev: number, info: ClipLeaveInfo) => {
const left = items[prev];
if (!left) return;
// ---- dwa playery -----------------------------------------------------------------
const players = React.useRef<VideoPlayer[]>([]);
const loopsRef = React.useRef(0);
const activatedAtRef = React.useRef(0);
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
const prevIndexRef = React.useRef<number | null>(null);
React.useEffect(() => {
const ps = players.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
// Zamkniecie poprzedniej strony: jedno zdarzenie z dwell.
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
const dwell = Date.now() - activatedAtRef.current;
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
telemetry.current?.push({
clip_id: left.clip.id,
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
dwell_ms: info.dwellMs,
slot: left.slot,
clip_id: items[prev].clip.id,
kind: leaveKind(dwell, loopsRef.current, pos, items[prev].clip.duration_sec),
dwell_ms: dwell,
slot: items[prev].slot,
position: prev,
});
},
[items],
);
try {
prevPlayer.pause();
} catch {
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, sourceOf(item));
active.muted = !prefs.soundOn;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
const next = items[index + 1];
if (next) {
ensureSource(standby, sourceOf(next));
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, items, ensureSource, prefs.soundOn]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !prefs.soundOn;
}, [prefs.soundOn, activePlayer]);
// AutoSwipe: po N sekundach na stronie przechodzimy dalej.
React.useEffect(() => {
@ -175,6 +276,7 @@ export function SwipeScreen() {
telemetry.current?.stop();
telemetry.current = new SwipeTelemetry(client, newSessionId());
telemetry.current.start();
prevIndexRef.current = null;
setBarHidden(false);
setIndex(0);
setSeedKey((k) => k + 1);
@ -191,23 +293,9 @@ export function SwipeScreen() {
const options = useQuery({
queryKey: ['clip-onboarding-options'],
queryFn: () => client.getClipOnboardingOptions(),
enabled: prefsReady && (!prefs.onboarded || tuneOpen),
enabled: prefsReady && !prefs.onboarded,
staleTime: 10 * 60 * 1000,
});
// Dostrojenie juz w trakcie: te same wagi co przy pierwszym wejsciu, potem nowe
// rozdanie, zeby zmiana byla widoczna od razu, a nie po wyczerpaniu biezacych stron.
const tuneFeed = React.useCallback(
async (tags: string[], niches: string[]) => {
try {
await client.postClipOnboarding({ tags, niches });
} catch {
// brak sieci: wagi dojda przy kolejnej probie, feed i tak uczy sie z ogladania
}
newDeal();
},
[client, newDeal],
);
const finishOnboarding = React.useCallback(
async (tags: string[], niches: string[]) => {
if (tags.length || niches.length) {
@ -224,7 +312,7 @@ export function SwipeScreen() {
);
// ---- render --------------------------------------------------------------------------
if (!swipeAvailable) {
if (!swipeAvailable || !PagerViewImpl) {
return (
<View style={styles.center}>
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
@ -233,22 +321,36 @@ export function SwipeScreen() {
</View>
);
}
const PagerView = PagerViewImpl;
const current = items[index];
return (
<View style={styles.root}>
{items.length ? (
<ClipPager
items={items}
index={index}
pagerRef={pagerRef}
<PagerView
ref={pagerRef}
style={styles.pager}
orientation="vertical"
initialPage={0}
offscreenPageLimit={1}
overdrag
onPageSelected={onPageSelected}
onLeave={onLeave}
soundOn={prefs.soundOn}
onToggleSound={toggleMute}
isLiked={(id) => liked.has(id)}
onLike={likeItem}
onNotInterested={(it) => setSheetItem(it)}
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
/>
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>
<ClipCell
item={it}
active={i === index}
player={i === index ? activePlayer : null}
muted={!prefs.soundOn}
liked={liked.has(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={toggleMute}
onLike={() => likeItem(it, i)}
onNotInterested={() => setSheetItem(it)}
/>
</View>
))}
</PagerView>
) : feed.isLoading ? (
<View style={styles.center}>
<ActivityIndicator color={theme.accentText} />
@ -273,11 +375,8 @@ export function SwipeScreen() {
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
</Pressable>
{/* Dzwiek ma JEDEN przycisk: ten w kolumnie po prawej, przy kciuku (uwaga Jana
2026-09-16, byly dwa). Tapniecie w wideo dalej przelacza. Tu za to wejscie do
stroika feedu, bo po starcie nie bylo jak dolozyc tagow. */}
<Pressable onPress={() => setTuneOpen(true)} style={styles.topBtn} accessibilityLabel="Tune my feed">
<MaterialCommunityIcons name="tune-variant" size={22} color="#fff" />
<Pressable onPress={toggleMute} style={styles.topBtn} accessibilityLabel="Sound">
<MaterialCommunityIcons name={prefs.soundOn ? 'volume-high' : 'volume-off'} size={22} color="#fff" />
</Pressable>
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
<MaterialCommunityIcons name="refresh" size={22} color="#fff" />
@ -319,21 +418,12 @@ export function SwipeScreen() {
) : null}
</Modal>
{/* Wybor zainteresowan: przy pierwszym wejsciu i pozniej spod ikony stroika */}
{/* Onboarding 3 tapow */}
<OnboardingModal
visible={prefsReady && !prefs.onboarded && !!options.data}
options={options.data}
onDone={finishOnboarding}
/>
<OnboardingModal
visible={tuneOpen && !!options.data}
options={options.data}
tune
onDone={(tags, niches) => {
setTuneOpen(false);
if (tags.length || niches.length) void tuneFeed(tags, niches);
}}
/>
</View>
);
}
@ -352,34 +442,13 @@ function OnboardingModal({
visible,
options,
onDone,
tune,
}: {
visible: boolean;
options: ClipOnboardingOptions | undefined;
onDone: (tags: string[], niches: string[]) => void;
/** true = otwarte z zakladki (dostrajanie), false = pierwsze wejscie. */
tune?: boolean;
}) {
const client = useClient();
const [picked, setPicked] = React.useState<Set<string>>(new Set());
const [pickedNiches, setPickedNiches] = React.useState<Set<string>>(new Set());
const [q, setQ] = React.useState('');
const [debouncedQ, setDebouncedQ] = React.useState('');
React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q.trim()), 300);
return () => clearTimeout(t);
}, [q]);
// Szukanie idzie po CALYM slowniku tagow (1,7 tys.), bo sama czolowka go nie miesci:
// "anal" jest dopiero 27. co do liczby klipow i wypadal z listy (uwaga Jana 2026-09-16).
const search = useQuery({
queryKey: ['clip-onboarding-search', debouncedQ],
queryFn: () => client.getClipOnboardingOptions(debouncedQ),
enabled: visible && debouncedQ.length >= 2,
staleTime: 5 * 60 * 1000,
});
const searching = debouncedQ.length >= 2;
const tags = searching ? (search.data?.tags ?? []) : (options?.tags ?? []);
const niches = searching ? [] : (options?.niches ?? []).slice(0, 12);
const tags = (options?.tags ?? []).slice(0, 24);
const toggle = (t: string) =>
setPicked((s) => {
const n = new Set(s);
@ -387,66 +456,27 @@ function OnboardingModal({
else n.add(t);
return n;
});
const toggleNiche = (t: string) =>
setPickedNiches((s) => {
const n = new Set(s);
if (n.has(t)) n.delete(t);
else n.add(t);
return n;
});
const total = picked.size + pickedNiches.size;
return (
<Modal visible={visible} transparent animationType="fade">
<View style={styles.onboardBackdrop}>
<View style={styles.onboardCard}>
<Text style={styles.sheetTitle}>{tune ? 'More of what you like' : 'What do you want to see?'}</Text>
<Text style={styles.centerText}>
{tune
? 'Pick anything you want more of. It counts right away and the next deal uses it.'
: 'Pick three or more, or search for anything that is not on the list. The feed learns from what you watch anyway, this only gives it a head start.'}
</Text>
<TextInput
style={styles.search}
value={q}
onChangeText={setQ}
placeholder="search tags..."
placeholderTextColor={theme.muted}
autoCapitalize="none"
autoCorrect={false}
/>
<ScrollView
style={{ maxHeight: 300 }}
contentContainerStyle={styles.chipsWrap}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.sheetTitle}>What do you want to see?</Text>
<Text style={styles.centerText}>Pick three or more. The feed learns from what you watch anyway, this only gives it a head start.</Text>
<ScrollView style={{ maxHeight: 300 }} contentContainerStyle={styles.chipsWrap}>
{tags.map((t) => (
<Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} />
))}
{searching && !search.isLoading && tags.length === 0 ? (
<Text style={styles.centerText}>Nothing matches that.</Text>
) : null}
{niches.map((n) => (
<Chip
key={n.value}
label={n.value}
selected={pickedNiches.has(n.value)}
onPress={() => toggleNiche(n.value)}
/>
))}
</ScrollView>
<View style={styles.onboardRow}>
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
<Text style={styles.secondaryBtnText}>{tune ? 'Cancel' : 'Skip'}</Text>
<Text style={styles.secondaryBtnText}>Skip</Text>
</Pressable>
<Pressable
onPress={() => onDone([...picked], [...pickedNiches])}
disabled={tune ? total === 0 : total < 3}
style={[styles.primaryBtn, (tune ? total === 0 : total < 3) && { opacity: 0.4 }]}
onPress={() => onDone([...picked], [])}
disabled={picked.size < 3}
style={[styles.primaryBtn, picked.size < 3 && { opacity: 0.4 }]}
>
<Text style={styles.primaryBtnText}>
{tune ? 'Apply' : 'Start'}
{total ? ` (${total})` : ''}
</Text>
<Text style={styles.primaryBtnText}>Start{picked.size ? ` (${picked.size})` : ''}</Text>
</Pressable>
</View>
</View>
@ -457,6 +487,8 @@ function OnboardingModal({
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#000' },
pager: { flex: 1 },
page: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg },
centerTitle: { color: theme.fg, fontFamily: fonts.display, fontSize: 18, marginTop: 12, textAlign: 'center' },
centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 },
@ -478,19 +510,6 @@ const styles = StyleSheet.create({
onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 },
onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 },
onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 },
search: {
marginTop: 12,
marginBottom: 10,
height: 42,
borderRadius: 12,
paddingHorizontal: 12,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
color: theme.fg,
fontFamily: fonts.body,
fontSize: 15,
},
primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 },
primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },

View file

@ -18,23 +18,10 @@ function _genDeviceId(): string {
export async function getDeviceId(): Promise<string> {
if (_deviceIdCache) return _deviceIdCache;
// Keystore potrafi odmowic ("Could not encrypt the value for key 'goon.device_id'",
// Samsung A7 z Androidem 10, Sentry 2026-09-19). Kazde zapytanie do API czeka na to id,
// wiec wyjatek stad wylaczal cala apke. Teraz id zostaje w pamieci do konca sesji; bez
// zapisu po restarcie powstanie nowe, ale apka dziala.
let id: string | null = null;
try {
id = await SecureStore.getItemAsync(DEVICE_KEY);
} catch {
id = null;
}
let id = await SecureStore.getItemAsync(DEVICE_KEY);
if (!id) {
id = _genDeviceId();
try {
await SecureStore.setItemAsync(DEVICE_KEY, id);
} catch {
// zostaje tylko w _deviceIdCache
}
await SecureStore.setItemAsync(DEVICE_KEY, id);
}
_deviceIdCache = id;
return id;

View file

@ -520,6 +520,4 @@ export interface ClipListOut {
limit: number;
offset: number;
has_more: boolean;
/** Liczba wszystkich klipow przy filtrze po tworcy; null przy innych listach. */
total?: number | null;
}

View file

@ -1,79 +0,0 @@
"""Wypelnia scene_tags.sort_date w istniejacych wierszach (migracja 0042, GOON-64).
Partiami po zakresach stron heapu (TID range scan, Postgres 14+), commit po kazdej partii,
VACUUM co kilka partii: martwe wersje zwalniaja miejsce dla nastepnych partii, zamiast
podwajac 1,7 GB tabeli i jej dwa indeksy. Nowe wiersze w trakcie wypelnia juz wyzwalacz
z 0042. Wznawialny: rusza tylko wiersze z sort_date IS NULL, na koncu dobiera resztki.
Odpalac w tle, nie z restartowanego workera:
docker compose run -d --rm --name backfill_sort_date worker \
python3 -u /srv/scripts/backfill_scene_tags_sort_date.py [stron_na_partie] [pauza_s]
Pauza miedzy partiami oddaje dysk apce: bez niej (2000 stron, 25-45 s na partie) zapytania
list scen czekaly na odczyt po 30 s (2026-09-18). Z 1000 stron i pauza 10 s bylo spokojnie.
Po nim: alembic upgrade (0043, indeks) i dopiero wtedy kod, ktory z indeksu korzysta.
"""
from __future__ import annotations
import sys
import time
from sqlalchemy import text
from app.db import engine
PAGES = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
PAUSE = float(sys.argv[2]) if len(sys.argv) > 2 else 10.0
VACUUM_EVERY = 8
SORT_DATE = "COALESCE(s.release_date, (s.created_at AT TIME ZONE 'UTC')::date)"
def rel_pages() -> int:
with engine.connect() as c:
return int(c.execute(text(
"SELECT pg_relation_size('scene_tags') / current_setting('block_size')::int"
)).scalar())
def vacuum(analyze: bool = False) -> None:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as c:
c.execute(text("VACUUM (ANALYZE) scene_tags" if analyze else "VACUUM scene_tags"))
t0 = time.time()
total = 0
page = 0
batch = 0
pages = rel_pages()
print(f"scene_tags: {pages} stron, partia {PAGES} stron, pauza {PAUSE} s", flush=True)
while page < pages:
tb = time.time()
with engine.begin() as c:
c.execute(text("SET LOCAL statement_timeout = '600s'"))
n = c.execute(text(f"""
UPDATE scene_tags st SET sort_date = {SORT_DATE}
FROM scenes s
WHERE s.id = st.scene_id AND st.sort_date IS NULL
AND st.ctid >= format('(%s,0)', CAST(:p0 AS int))::tid
AND st.ctid < format('(%s,0)', CAST(:p1 AS int))::tid
"""), {"p0": page, "p1": page + PAGES}).rowcount
total += n
page += PAGES
batch += 1
if batch % VACUUM_EVERY == 0:
vacuum()
pages = rel_pages() # tabela moze urosnac, zanim VACUUM zwolni miejsce
print(f"partia {batch}: strony do {page}/{pages}, {n} wierszy w {time.time() - tb:.1f} s, "
f"razem {total}, {time.time() - t0:.0f} s", flush=True)
if n:
time.sleep(PAUSE)
with engine.begin() as c:
rest = c.execute(text(f"""
UPDATE scene_tags st SET sort_date = {SORT_DATE}
FROM scenes s WHERE s.id = st.scene_id AND st.sort_date IS NULL
""")).rowcount
print(f"resztki: {rest}", flush=True)
vacuum(analyze=True)
with engine.connect() as c:
left = c.execute(text("SELECT count(*) FROM scene_tags WHERE sort_date IS NULL")).scalar()
print(f"KONIEC: {total + rest} wierszy, puste po wszystkim: {left}, {time.time() - t0:.0f} s", flush=True)

View file

@ -95,7 +95,7 @@
</p>
<div class="flex flex-wrap gap-3">
<a href="/goon-v0.4.2.apk"
<a href="/goon-v0.4.1.apk"
class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow">
Download APK
</a>
@ -327,7 +327,7 @@
<li class="bg-card border border-border rounded-2xl p-5 flex gap-4">
<span class="text-accent font-extrabold text-xl">1</span>
<span class="text-sm text-gray-300 leading-relaxed">
<a href="/goon-v0.4.2.apk" class="text-accent font-bold hover:underline">Download the APK</a>
<a href="/goon-v0.4.1.apk" class="text-accent font-bold hover:underline">Download the APK</a>
and open it. Allow "install from unknown sources" for your browser if Android asks.
</span>
</li>
@ -404,7 +404,7 @@
</p>
</div>
<div class="flex flex-col gap-1 text-right">
<a href="/goon-v0.4.2.apk" class="hover:text-accent transition">Download APK</a>
<a href="/goon-v0.4.1.apk" class="hover:text-accent transition">Download APK</a>
<a href="https://git.goon-foss.org/goon-foss/goon" class="hover:text-accent transition">Source code</a>
<a href="#donate" class="hover:text-accent transition">Donate</a>
</div>

View file

@ -1,51 +0,0 @@
import uuid
from app.api.scenes import _diversify_pages
def _ids(n, origin):
return [(uuid.uuid4(), origin) for _ in range(n)]
def _mixed(flood_share=0.7, n=300, others=("b", "c", "d")):
"""Jak feed 2026-09-19: jedno zrodlo zalewa, reszta kapie, wszystko w kolejnosci czasu."""
items, k = [], 0
for i in range(n):
if (i % 10) < flood_share * 10:
items.append((uuid.uuid4(), "flood"))
else:
items.append((uuid.uuid4(), others[k % len(others)]))
k += 1
return items
def test_flood_is_capped_while_other_sources_exist():
items = _mixed()
pages = _diversify_pages(items, 10, 1 / 3)
origin = dict(items)
# 90 scen z innych zrodel wystarcza na 15 stron po 6; tyle stron trzyma limit
for pg in pages[:15]:
assert len(pg) == 10
assert sum(origin[i] == "flood" for i in pg) <= 4
def test_nothing_is_lost_and_pages_are_full():
items = _mixed()
pages = _diversify_pages(items, 10, 1 / 3)
assert sorted(i for pg in pages for i in pg) == sorted(i for i, _ in items)
assert all(len(pg) == 10 for pg in pages[:-1])
def test_two_sources_still_fill_pages():
items = _ids(40, "a") + _ids(40, "b")
pages = _diversify_pages(items, 10, 1 / 3)
assert all(len(pg) == 10 for pg in pages)
def test_order_within_source_is_kept():
items = _mixed()
flat = [i for pg in _diversify_pages(items, 10, 1 / 3) for i in pg]
for src in ("flood", "b", "c", "d"):
mine = [i for i, o in items if o == src]
mine_set = set(mine)
assert [i for i in flat if i in mine_set] == mine

View file

@ -87,11 +87,6 @@ def test_strip_accents_idempotent() -> None:
("Sasha Paige ft.Martin Spell", "Martin Spell", True),
# ft wewnatrz slowa to nie separator.
("Craft Beer Night", "Craft Beer", True),
# Zlepek tez nie przechodzi przez separator: „dick" i „ft" to sasiednie czlony
# tylko dopoki tytul nie jest ciety, a byly sklejane w jedno nazwisko.
("Twins Sasha Paige Share the Dick ft. Martin Spell", "Dickft", False),
("Sasha Paige feat Martin Spell", "Paigefeat", False),
("Sasha Paige ft. Martin Spell", "Paige Ft Martin", False),
],
)
def test_person_named_in(title: str, person: str, expected: bool) -> None:

View file

@ -1,52 +0,0 @@
from app.api.scenes import (
_SEARCH_WALK_BUDGET_MS,
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
_search_walk_budget,
)
# 1000 tytulow jak histogram z pg_stats: 20 z "anal", 1 z "hailey rose", reszta obojetna.
SAMPLE = ["anal scene %d" % i for i in range(20)] + ["hailey rose pov"] + [
"other title %d" % i for i in range(979)
]
def _budget(q, off=0, want=51, narrowed=False):
return _search_walk_budget(q, off, want, narrowed, lambda: SAMPLE)
def test_common_phrase_walks():
# 20/1000 = 2%: na strone 1 trzeba przejsc ~2,5 tys. scen
assert _budget("anal") == _SEARCH_WALK_BUDGET_MS
def test_phrase_absent_from_sample_goes_to_trigrams():
assert _budget("jackerman") is None
def test_single_hit_phrase_goes_to_trigrams():
# 1/1000 = 0,1%: ~51 tys. scen do przejscia, powyzej progu
assert _budget("hailey rose") is None
def test_deep_page_of_common_phrase_goes_to_trigrams():
# strona 13 "anal": (600 + 51) / 2% = ~32 tys.
assert _budget("anal", off=600) is None
def test_narrowed_search_goes_to_trigrams():
assert _budget("anal", narrowed=True) is None
def test_short_phrase_always_walks():
# bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka
for q in ("vo", "q", "xz", " a "):
assert _budget(q, narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
def test_sample_not_read_when_not_needed():
def boom():
raise AssertionError("probka niepotrzebna")
assert _search_walk_budget("vo", 0, 51, False, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
assert _search_walk_budget("anal", 0, 51, True, boom) is None

View file

@ -1,93 +0,0 @@
from datetime import date, datetime
from app.scheduler.studio_title_dedup import Cand, plan_merges, same_scene, studio_tokens
_T0 = datetime(2026, 9, 16, 23, 0)
_MILFY = studio_tokens("Milfy", "milfy")
def _c(cid, title, dur=None, rel=None, perfs=(), canon=False, live=1, studio="milfy"):
return Cand(
id=cid, title=title, studio_id=studio, studio_name="Milfy", studio_slug="milfy",
duration_sec=dur, release_date=rel, created_at=_T0, perfs=tuple(perfs), canon=canon, live=live,
)
# Zgloszenie bb1a357d: ta sama scena z osmiu tubow, kazdy z innym formatem tytulu.
_MILFY_COPIES = [
_c("canon", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16),
("Troy Francisco", "Koda Monroe"), canon=True),
_c("a", "MILFY – Koda Monroe – Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16),
("Troy Francisco", "Koda Monroe"), live=4),
_c("b", "[Milfy] Koda Monroe (Voluptuous Therapist Fucks Prisoner)", None, date(2026, 9, 16)),
_c("c", "[MILFY] Koda Monroe – Voluptuous Therapist Fucks Prisoner", perfs=("Koda Monroe",)),
_c("d", "Koda Monroe - Voluptuous Therapist Fucks Prisoner (17/9/26)", 2218),
_c("e", "Koda Monroe Voluptuous Therapist Fucks Prisoner 4K", 2218),
_c("f", "Milfy 26 09 16 Koda Monroe Voluptuous Therapist Fucks Prisoner", None, date(2026, 9, 16)),
]
def test_milfy_copies_collapse_into_canonical_scene():
plan = plan_merges(_MILFY_COPIES)
assert len(plan) == 1
keep, drops = plan[0]
assert keep.id == "canon"
assert {d.id for d in drops} == {"a", "b", "c", "d", "e", "f"}
def test_part_numbers_are_not_dates():
one = _c("1", "Ariel Swings Her Whip", 600, date(2026, 9, 16))
two = _c("2", "Ariel Swings Her Whip 2", 600, date(2026, 9, 16))
assert not same_scene(one, two, _MILFY)
p1 = _c("p1", "Jewell - Punishment Stool - Part 1", 232, date(2003, 11, 13))
p2 = _c("p2", "Jewell - Punishment Stool - Part 2", 231, date(2003, 11, 13))
assert not same_scene(p1, p2, _MILFY)
def test_guards():
base = _c("x", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16), ("Koda Monroe",))
longer = _c("y", "Voluptuous Therapist Fucks Prisoner", 2600, date(2026, 9, 16), ("Koda Monroe",))
assert not same_scene(base, longer, _MILFY)
later = _c("z", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 10, 1), ("Koda Monroe",))
assert not same_scene(base, later, _MILFY)
year_only = _c("j", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 1, 1), ("Koda Monroe",))
assert same_scene(base, year_only, _MILFY)
other_cast = _c("o", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16), ("Someone Else",))
assert not same_scene(base, other_cast, _MILFY)
other_studio = _c("s", "Voluptuous Therapist Fucks Prisoner", 2218, date(2026, 9, 16), studio="blacked")
assert not same_scene(base, other_studio, _MILFY)
# Bez dlugosci po obu stronach trzy slowa to za malo.
short_a = _c("sa", "[Milfy] Hot Stepmom Fun")
short_b = _c("sb", "Milfy 26 09 16 Hot Stepmom Fun")
assert not same_scene(short_a, short_b, _MILFY)
def test_chain_does_not_pull_in_incompatible_scene():
keep = _c("k", "Hotel Vixen Season 3 Episode 4 Soft Launch Hard Times", 2608, canon=True)
bridge = _c("m", "[Milfy] Hotel Vixen Season 3 Episode 4 Soft Launch Hard Times")
other = _c("l", "Milfy Hotel Vixen Season 3 Episode 4 Soft Launch Hard Times", 2896)
plan = plan_merges([keep, bridge, other])
assert [(k.id, [d.id for d in ds]) for k, ds in plan] == [("k", ["m"])]
def test_category_named_as_performer_does_not_shorten_core():
# Tube wpisal kategorie jako obsade. Bez listy smieci "big", "ass" i "tits" wypadlyby
# z obu tytulow i dwie rozne sceny mialyby ten sam rdzen "blonde fucks jason luv".
a = _c("a", "Big Ass Blonde Fucks Jason Luv", perfs=("Big Ass", "Big Tits"))
b = _c("b", "Big Tits Blonde Fucks Jason Luv")
assert not same_scene(a, b, _MILFY, junk_names=frozenset({"big ass", "big tits"}))
def test_copies_without_studio_join_only_with_matching_duration():
orphan = _c("o1", "Koda Monroe - Voluptuous Therapist Fucks Prisoner (17/9/26)", 2218, studio="")
orphan_4k = _c("o2", "Koda Monroe Voluptuous Therapist Fucks Prisoner 4K", 2217, studio="")
other_cut = _c("o3", "Koda Monroe Voluptuous Therapist Fucks Prisoner", 2600, studio="")
no_duration = _c("o4", "Koda Monroe Voluptuous Therapist Fucks Prisoner", None, studio="")
plan = plan_merges(_MILFY_COPIES + [orphan, orphan_4k, other_cut, no_duration])
keep, drops = plan[0]
assert keep.id == "canon"
ids = {d.id for d in drops}
assert {"o1", "o2"} <= ids
assert not ids & {"o3", "o4"}
# Dwie sceny bez studia nie maja kotwicy.
assert not same_scene(orphan, orphan_4k, _MILFY)