Compare commits
23 commits
2cdc3d69da
...
45ee7a81e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ee7a81e1 | ||
|
|
7accd4692f | ||
|
|
f33f37f117 | ||
|
|
d3713d64b9 | ||
|
|
55adf8b1cb | ||
|
|
5640ee094d | ||
|
|
41ad7f1818 | ||
|
|
0edc85c05f | ||
|
|
ca2a6a1547 | ||
|
|
73853eb9ed | ||
|
|
7f2fdfaed0 | ||
|
|
ed3a7b8f4e | ||
|
|
7fea132668 | ||
|
|
c59ad6eb73 | ||
|
|
6b06cc96bf | ||
|
|
3c1d2c5907 | ||
|
|
be0f2d7d37 | ||
|
|
c8035c68fa | ||
|
|
45754afc69 | ||
|
|
7bfd194485 | ||
|
|
40865d6ce8 | ||
|
|
bfa3444de2 | ||
|
|
7d5dd450f7 |
48 changed files with 2658 additions and 370 deletions
89
alembic/versions/20260918_0042_scene_tags_sort_date.py
Normal file
89
alembic/versions/20260918_0042_scene_tags_sort_date.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""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")
|
||||||
40
alembic/versions/20260918_0043_scene_tags_tag_sort_idx.py
Normal file
40
alembic/versions/20260918_0043_scene_tags_tag_sort_idx.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""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")
|
||||||
37
alembic/versions/20260919_0044_playback_eporner_thumb_idx.py
Normal file
37
alembic/versions/20260919_0044_playback_eporner_thumb_idx.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""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")
|
||||||
52
alembic/versions/20260921_0045_autovacuum_big_tables.py
Normal file
52
alembic/versions/20260921_0045_autovacuum_big_tables.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""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)})")
|
||||||
36
alembic/versions/20260922_0046_scenes_title_stats.py
Normal file
36
alembic/versions/20260922_0046_scenes_title_stats.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""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)")
|
||||||
|
|
@ -75,6 +75,9 @@ class ClipListOut(BaseModel):
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
has_more: bool
|
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):
|
class ClipStatsOut(BaseModel):
|
||||||
|
|
@ -484,16 +487,35 @@ _OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
|
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
|
||||||
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
|
def onboarding_options(
|
||||||
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
|
session: Session = Depends(get_session),
|
||||||
Liczone z bazy, cache 10 min w procesie."""
|
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=[])
|
||||||
hit = _OPTIONS_CACHE.get("v1")
|
hit = _OPTIONS_CACHE.get("v1")
|
||||||
if hit and time.monotonic() - hit[0] < 600:
|
if hit and time.monotonic() - hit[0] < 600:
|
||||||
return hit[1]
|
return hit[1]
|
||||||
tags = [
|
tags = [
|
||||||
OnboardingOptionOut(value=t, count=n)
|
OnboardingOptionOut(value=t, count=n)
|
||||||
for t, n in session.execute(
|
for t, n in session.execute(
|
||||||
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
|
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(60)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
niches = [
|
niches = [
|
||||||
|
|
@ -513,15 +535,19 @@ def onboarding(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
device_id: str = Depends(get_device_id),
|
device_id: str = Depends(get_device_id),
|
||||||
) -> AffinityOut:
|
) -> 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)
|
row, aff = _load_affinity(session, device_id)
|
||||||
for t in body.tags:
|
for t in body.tags:
|
||||||
t = t.strip().lower()[:64]
|
t = t.strip().lower()[:64]
|
||||||
if t:
|
if t:
|
||||||
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
|
aff.tags[t] = round(aff.tags.get(t, 0.0) + 8.0, 3)
|
||||||
for n in body.niches:
|
for n in body.niches:
|
||||||
n = n.strip()[:64]
|
n = n.strip()[:64]
|
||||||
if n:
|
if n:
|
||||||
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
|
aff.niches[n] = round(aff.niches.get(n, 0.0) + 8.0, 3)
|
||||||
_save_affinity(session, device_id, row, aff, 0)
|
_save_affinity(session, device_id, row, aff, 0)
|
||||||
session.commit()
|
session.commit()
|
||||||
row2 = session.get(ClipAffinity, device_id)
|
row2 = session.get(ClipAffinity, device_id)
|
||||||
|
|
@ -639,8 +665,17 @@ def list_clips(
|
||||||
ids = [r.id for r in rows]
|
ids = [r.id for r in rows]
|
||||||
tags = _tags_for(session, ids)
|
tags = _tags_for(session, ids)
|
||||||
favs = _favorites_for(session, device_id, 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(
|
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
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
|
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import collections
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -18,10 +20,12 @@ from sqlalchemy import (
|
||||||
func,
|
func,
|
||||||
literal_column,
|
literal_column,
|
||||||
select,
|
select,
|
||||||
|
text,
|
||||||
true,
|
true,
|
||||||
union,
|
union,
|
||||||
)
|
)
|
||||||
from sqlalchemy.exc import IntegrityError
|
from psycopg.errors import QueryCanceled
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
|
|
@ -52,6 +56,50 @@ log = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
|
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 = {
|
_VALID_SORTS = {
|
||||||
"created_at",
|
"created_at",
|
||||||
"release_date",
|
"release_date",
|
||||||
|
|
@ -162,6 +210,115 @@ def _blacklists_empty(session: Session, device_id: str) -> bool:
|
||||||
return not has_any
|
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]:
|
def _split_csv(raw: str | None) -> list[str]:
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
|
|
@ -463,17 +620,26 @@ def list_scenes(
|
||||||
# ix_scenes_created_at_desc zamiast materializować wszystkie scene_tags. Slug-JOIN
|
# 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
|
# 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.
|
# (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:
|
if tag_slug_list:
|
||||||
id_by_slug = dict(
|
tag_rows = session.execute(
|
||||||
session.execute(
|
select(Tag.slug, Tag.id, Tag.scene_count).where(Tag.slug.in_(tag_slug_list))
|
||||||
select(Tag.slug, Tag.id).where(Tag.slug.in_(tag_slug_list))
|
|
||||||
).all()
|
).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]
|
||||||
for slug in tag_slug_list:
|
for slug in tag_slug_list:
|
||||||
tag_id = id_by_slug.get(slug)
|
tag_id = id_by_slug.get(slug)
|
||||||
if tag_id is None:
|
if tag_id is None:
|
||||||
base = base.where(false()) # nieznany slug → brak wyników
|
base = base.where(false()) # nieznany slug → brak wyników
|
||||||
break
|
break
|
||||||
|
if tag_id == tag_sort_id:
|
||||||
|
continue
|
||||||
base = base.where(
|
base = base.where(
|
||||||
exists(
|
exists(
|
||||||
select(1)
|
select(1)
|
||||||
|
|
@ -587,6 +753,21 @@ def list_scenes(
|
||||||
# stubem. Filtruje anonymous tube-only sceny z newUrl/categories ingestu.
|
# stubem. Filtruje anonymous tube-only sceny z newUrl/categories ingestu.
|
||||||
base = base.where(stub_exclusion_clause())
|
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 = (
|
_is_pure_default = (
|
||||||
not include_stubs and not q and not studio_slug_list and not tag_slug_list
|
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
|
and not perf_id_strings and origin is None and has_playback is None
|
||||||
|
|
@ -606,6 +787,19 @@ def list_scenes(
|
||||||
total_capped = False
|
total_capped = False
|
||||||
total: int | None = _default_scene_count(session) if _is_pure_default else None
|
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
|
# 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).
|
# wiedziec, ile wierszy moze potrzebowac z jednego studia (patrz _studio_sorted_select).
|
||||||
_off = (page - 1) * per_page
|
_off = (page - 1) * per_page
|
||||||
|
|
@ -628,6 +822,20 @@ def list_scenes(
|
||||||
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
|
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
|
||||||
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
|
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
|
||||||
# ix_scenes_effective_date_desc (migracja 0033).
|
# 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(
|
ordered = base.order_by(
|
||||||
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
|
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
|
||||||
)
|
)
|
||||||
|
|
@ -676,14 +884,31 @@ def list_scenes(
|
||||||
# od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu
|
# 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
|
# 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.
|
# (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy.
|
||||||
start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest())
|
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.
|
# 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
|
# 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.
|
# 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).
|
# 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
|
# 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
|
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000
|
||||||
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
|
# 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).
|
# 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
|
base_window = 8000 if (vertical or jav) else 2000
|
||||||
window = max(base_window, page * per_page * 4)
|
window = max(base_window, page * per_page * 4)
|
||||||
|
|
@ -703,10 +928,53 @@ def list_scenes(
|
||||||
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
|
# 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
|
# 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.
|
# 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()
|
||||||
|
)
|
||||||
|
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 = (
|
rows = (
|
||||||
studio_rows
|
session.execute(
|
||||||
if studio_rows is not None
|
|
||||||
else session.execute(
|
|
||||||
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
|
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@ Zasady (świadome, nie przypadkowe):
|
||||||
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
|
* **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+.
|
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
|
||||||
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
||||||
* **Blacklist respektowany** te same wykluczenia performer/studio/tag co w /scenes.
|
* **Bez blacklist.** Blacklisty sa prywatne, per urzadzenie (od 2026-06-10), a strony
|
||||||
Nie publikujemy SEO-stron dla zblacklistowanej treści.
|
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).
|
||||||
* **Anti-thin-page** strona encji powstaje tylko gdy ma realną zawartość
|
* **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,
|
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
|
||||||
nie pusty doorway (Google karze masowe cienkie strony).
|
nie pusty doorway (Google karze masowe cienkie strony).
|
||||||
|
|
@ -22,6 +25,8 @@ Zasady (świadome, nie przypadkowe):
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -35,11 +40,6 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.scenes import _needs_proxy, _wrap_image_proxy
|
from app.api.scenes import _needs_proxy, _wrap_image_proxy
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.blacklist import (
|
|
||||||
BlacklistedPerformer,
|
|
||||||
BlacklistedStudio,
|
|
||||||
BlacklistedTag,
|
|
||||||
)
|
|
||||||
from app.models.performer import Performer, PerformerAlias
|
from app.models.performer import Performer, PerformerAlias
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
from app.models.scene import Scene, ScenePerformer, SceneTag
|
from app.models.scene import Scene, ScenePerformer, SceneTag
|
||||||
|
|
@ -83,55 +83,24 @@ 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():
|
def _indexable_scenes():
|
||||||
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
|
"""SELECT Scene z żywym źródłem, baza pod listy i sitemap."""
|
||||||
stmt = select(Scene).where(_live_playback_exists())
|
return select(Scene).where(_live_playback_exists())
|
||||||
for cond in _not_blacklisted():
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
return stmt
|
|
||||||
|
|
||||||
|
|
||||||
def _performer_indexable_exists():
|
def _performer_indexable_exists():
|
||||||
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
"""EXISTS: performer ma ≥1 indeksowalną (żywą) scenę.
|
||||||
|
|
||||||
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
||||||
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
|
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
|
||||||
"""
|
"""
|
||||||
sub = (
|
return exists(
|
||||||
select(1)
|
select(1)
|
||||||
.select_from(ScenePerformer)
|
.select_from(ScenePerformer)
|
||||||
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
||||||
.where(ScenePerformer.performer_id == Performer.id)
|
.where(ScenePerformer.performer_id == Performer.id)
|
||||||
.where(_live_playback_exists())
|
.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]:
|
def _scene_card_rows(session: Session, scene_ids: list[uuid.UUID]) -> dict[uuid.UUID, dict]:
|
||||||
|
|
@ -438,9 +407,27 @@ def _count(session: Session, stmt) -> int:
|
||||||
return session.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
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")
|
@router.get("/sitemap.xml")
|
||||||
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
||||||
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
|
"""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(
|
n_perf = _count(
|
||||||
session,
|
session,
|
||||||
select(Performer.id).where(_performer_indexable_exists()),
|
select(Performer.id).where(_performer_indexable_exists()),
|
||||||
|
|
@ -461,7 +448,7 @@ def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response
|
||||||
for p in range(pages):
|
for p in range(pages):
|
||||||
parts.append(f"<sitemap><loc>{bu}/sitemap/{kind}-{p}.xml</loc></sitemap>")
|
parts.append(f"<sitemap><loc>{bu}/sitemap/{kind}-{p}.xml</loc></sitemap>")
|
||||||
parts.append("</sitemapindex>")
|
parts.append("</sitemapindex>")
|
||||||
return Response("\n".join(parts), media_type="application/xml")
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sitemap/{kind}-{page}.xml")
|
@router.get("/sitemap/{kind}-{page}.xml")
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -27,7 +28,7 @@ from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
from app.db import get_session
|
from app.db import get_session, session_scope
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
from app.models.source_stats import SourceStats
|
from app.models.source_stats import SourceStats
|
||||||
|
|
||||||
|
|
@ -86,8 +87,10 @@ class SourceOut(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
|
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
|
||||||
_SOURCES_TTL = 300.0
|
#: Po tylu sekundach wynik uznajemy za stary i przeliczamy W TLE (oddajac stary).
|
||||||
|
_SOURCES_TTL = 900.0
|
||||||
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
|
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
|
||||||
|
_SOURCES_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _icon_url(sitetag: str) -> str | None:
|
def _icon_url(sitetag: str) -> str | None:
|
||||||
|
|
@ -134,20 +137,8 @@ def _sitetag_to_display(sitetag: str) -> str:
|
||||||
return sitetag
|
return sitetag
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=SourceListOut)
|
def _source_rows(session: Session):
|
||||||
def list_sources(
|
return session.execute(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
|
||||||
) -> SourceListOut:
|
|
||||||
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
|
|
||||||
|
|
||||||
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
|
|
||||||
"""
|
|
||||||
# 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(
|
select(
|
||||||
PlaybackSource.origin,
|
PlaybackSource.origin,
|
||||||
func.count(PlaybackSource.id).label("scene_count"),
|
func.count(PlaybackSource.id).label("scene_count"),
|
||||||
|
|
@ -157,8 +148,52 @@ def list_sources(
|
||||||
.where(PlaybackSource.origin.like("tube:%"))
|
.where(PlaybackSource.origin.like("tube:%"))
|
||||||
.group_by(PlaybackSource.origin)
|
.group_by(PlaybackSource.origin)
|
||||||
).all()
|
).all()
|
||||||
_SOURCES_CACHE["at"] = now
|
|
||||||
|
|
||||||
|
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"]
|
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)],
|
||||||
|
) -> SourceListOut:
|
||||||
|
"""Zwraca listę tube źródeł z ŻYWYMI playback_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)
|
||||||
|
|
||||||
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
|
# 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()}
|
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,12 @@ async def proxy_image(
|
||||||
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
|
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
|
||||||
# info log + 502 pass-through bez exception.
|
# info log + 502 pass-through bez exception.
|
||||||
log.info("img proxy upstream %d for %s", r.status_code, target)
|
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(
|
return Response(
|
||||||
content=b"",
|
content=b"",
|
||||||
status_code=502 if r.status_code >= 500 else r.status_code,
|
status_code=502 if r.status_code >= 500 else r.status_code,
|
||||||
|
|
@ -548,6 +554,11 @@ async def sxyprn_thumb(post_id: str) -> Response:
|
||||||
return Response(content=b"", status_code=502, media_type="image/jpeg")
|
return Response(content=b"", status_code=502, media_type="image/jpeg")
|
||||||
html = r.text
|
html = r.text
|
||||||
if "Post Not Found" in html:
|
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")
|
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)
|
m = _OG_IMG_RE.search(html) or _OG_IMG_RE2.search(html) or _VID_POSTER_RE.search(html)
|
||||||
if not m:
|
if not m:
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,16 @@ class Settings(BaseSettings):
|
||||||
sched_title_dedup_hours: int = Field(
|
sched_title_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
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,
|
# 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
|
# 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ąć
|
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
|
||||||
|
|
|
||||||
116
app/dead_sources.py
Normal file
116
app/dead_sources.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -19,7 +19,7 @@ import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html
|
from app.extractors._fetch import _DEFAULT_UA, browser_get, fetch_tube_html
|
||||||
from app.extractors._models import StreamSource
|
from app.extractors._models import HosterDead, StreamSource
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -46,8 +46,19 @@ def _hash_to_b36(hex_hash: str) -> str:
|
||||||
return "".join(parts)
|
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:
|
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
|
||||||
html = fetch_tube_html(page_url, timeout=timeout)
|
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_vid = _VID_RE.search(html)
|
||||||
m_hash = _HASH_RE.search(html)
|
m_hash = _HASH_RE.search(html)
|
||||||
if not m_vid or not m_hash:
|
if not m_vid or not m_hash:
|
||||||
|
|
|
||||||
23
app/main.py
23
app/main.py
|
|
@ -100,7 +100,7 @@ def healthz() -> dict[str, str]:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/version")
|
@app.get("/version")
|
||||||
def version() -> dict[str, str | None]:
|
def version() -> dict[str, str | int | None]:
|
||||||
"""Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK.
|
"""Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK.
|
||||||
|
|
||||||
Zwraca:
|
Zwraca:
|
||||||
|
|
@ -123,12 +123,21 @@ def version() -> dict[str, str | None]:
|
||||||
# mobile sklei z baseUrl.
|
# mobile sklei z baseUrl.
|
||||||
public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
|
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"
|
apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk"
|
||||||
# `runtime_version` to jedyny numer, po którym aplikacja pozna, JAKIE APK ma
|
# `runtime_version` i `apk_build` to jedyne numery, po których aplikacja pozna, JAKIE
|
||||||
# zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA, więc po
|
# APK ma zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA,
|
||||||
# aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny.
|
# 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).
|
# `apk_build` = `versionCode` z android/app/build.gradle. Runtime sam nie wystarcza:
|
||||||
return {"version": "0.4.1", "runtime_version": "1.4", "apk_url": apk_url}
|
# 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/readyz")
|
@app.get("/readyz")
|
||||||
|
|
|
||||||
|
|
@ -134,3 +134,7 @@ class SceneTag(Base):
|
||||||
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL")
|
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)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ def _is_canonical(session: Session, source_id: uuid.UUID | None) -> bool:
|
||||||
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
|
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):
|
class MergeError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -60,12 +72,18 @@ def merge_scenes(
|
||||||
if keep is None or drop is None:
|
if keep is None or drop is None:
|
||||||
raise MergeError("scene not found")
|
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_external_refs(session, keep_id=keep_id, drop_id=drop_id)
|
||||||
_move_performers(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_tags(session, keep_id=keep_id, drop_id=drop_id)
|
||||||
_move_fingerprints(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)
|
_move_playback_sources(session, keep_id=keep_id, drop_id=drop_id)
|
||||||
_coalesce_canonical_fields(keep, drop)
|
_coalesce_canonical_fields(
|
||||||
|
keep, drop, keep_canonical=keep_canonical, drop_canonical=drop_canonical
|
||||||
|
)
|
||||||
|
|
||||||
session.delete(drop)
|
session.delete(drop)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
@ -223,7 +241,9 @@ def _move_playback_sources(session: Session, *, keep_id: uuid.UUID, drop_id: uui
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None:
|
def _coalesce_canonical_fields(
|
||||||
|
keep: Scene, drop: Scene, *, keep_canonical: bool = False, drop_canonical: bool = False
|
||||||
|
) -> None:
|
||||||
"""Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości."""
|
"""Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości."""
|
||||||
if not keep.description and drop.description:
|
if not keep.description and drop.description:
|
||||||
keep.description = drop.description
|
keep.description = drop.description
|
||||||
|
|
@ -237,7 +257,16 @@ def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None:
|
||||||
keep.release_date = drop.release_date
|
keep.release_date = drop.release_date
|
||||||
if not keep.studio_id and drop.studio_id:
|
if not keep.studio_id and drop.studio_id:
|
||||||
keep.studio_id = drop.studio_id
|
keep.studio_id = drop.studio_id
|
||||||
if drop.title and len(drop.title) > len(keep.title or ""):
|
# 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:
|
||||||
keep.title = drop.title
|
keep.title = drop.title
|
||||||
keep.title_normalized = drop.title_normalized
|
keep.title_normalized = drop.title_normalized
|
||||||
# created_at = najwcześniejsze "first seen" obu scen. NEW badge (apka) bazuje na
|
# created_at = najwcześniejsze "first seen" obu scen. NEW badge (apka) bazuje na
|
||||||
|
|
|
||||||
126
app/scheduler/dead_reap.py
Normal file
126
app/scheduler/dead_reap.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""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
|
||||||
|
|
@ -390,15 +390,52 @@ def _job_title_duration_dedup() -> None:
|
||||||
try:
|
try:
|
||||||
from app.scheduler.title_duration_dedup import run_title_duration_dedup
|
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(
|
_run_with_timeout(
|
||||||
lambda: run_title_duration_dedup(playback_only=True, commit=True),
|
lambda: run_title_duration_dedup(playback_only=True, commit=True),
|
||||||
label="title-duration-dedup",
|
label="title-duration-dedup",
|
||||||
|
timeout_sec=3600,
|
||||||
)
|
)
|
||||||
log.info("[scheduler] title-duration dedup done")
|
log.info("[scheduler] title-duration dedup done")
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("[scheduler] title-duration dedup failed")
|
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(
|
def _job_ingest_watchdog(
|
||||||
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
|
max_age_hours: int, search_max_age_hours: int, movie_max_age_hours: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -508,7 +545,12 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
|
||||||
performer_continuous_seconds: int | None
|
performer_continuous_seconds: int | None
|
||||||
performer_continuous_refresh_days: int
|
performer_continuous_refresh_days: int
|
||||||
"""
|
"""
|
||||||
sched = BlockingScheduler(timezone="UTC")
|
# 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.add_job(
|
sched.add_job(
|
||||||
_job_site_icons,
|
_job_site_icons,
|
||||||
|
|
@ -723,6 +765,30 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
|
||||||
)
|
)
|
||||||
log.info("scheduler: title-duration dedup every %dh", cfg["title_dedup_hours"])
|
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"):
|
if cfg.get("ingest_watchdog_hours"):
|
||||||
wd_max_age = cfg.get("ingest_watchdog_max_age_hours") or 48
|
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
|
wd_search_max_age = cfg.get("ingest_watchdog_search_max_age_hours") or 168
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ _TELEMETRY_WINDOW_DAYS = 7
|
||||||
# mniejszej próbce lecimy na proxy/heurystykę (nie zerujemy gwiazdek znanemu-dobremu).
|
# mniejszej próbce lecimy na proxy/heurystykę (nie zerujemy gwiazdek znanemu-dobremu).
|
||||||
_TELEMETRY_MIN_ATTEMPTS = 25
|
_TELEMETRY_MIN_ATTEMPTS = 25
|
||||||
_EVENT_RETENTION_DAYS = 30
|
_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
|
# Richness: wagi składowych (suma=1.0). thumb to minimum higieny, canonical-bogactwo
|
||||||
# (desc/studio/tag/perf) waży więcej. dur średnio.
|
# (desc/studio/tag/perf) waży więcej. dur średnio.
|
||||||
_RICHNESS_WEIGHTS = {
|
_RICHNESS_WEIGHTS = {
|
||||||
|
|
@ -137,43 +143,65 @@ def run_source_stats() -> dict:
|
||||||
win_start = now - timedelta(days=_TELEMETRY_WINDOW_DAYS)
|
win_start = now - timedelta(days=_TELEMETRY_WINDOW_DAYS)
|
||||||
|
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
# 1) Freshness + richness: agregat po DISTINCT (origin, scene) z live sources,
|
# 1a) Freshness + miniatury: dokladnie, po DISTINCT (origin, scene) z live sources.
|
||||||
# join do flag scen (has_tag/perf/desc/studio/dur policzone raz).
|
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
WITH scene_flags AS (
|
WITH live 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,
|
SELECT DISTINCT ON (origin, scene_id) origin, scene_id,
|
||||||
(thumbnail_url IS NOT NULL) AS has_thumb, created_at
|
(thumbnail_url IS NOT NULL) AS has_thumb, created_at
|
||||||
FROM playback_sources
|
FROM playback_sources
|
||||||
WHERE dead_at IS NULL AND origin LIKE 'tube:%'
|
WHERE dead_at IS NULL AND origin LIKE 'tube:%'
|
||||||
)
|
)
|
||||||
SELECT l.origin,
|
SELECT origin,
|
||||||
count(*) AS scenes,
|
count(*) AS scenes,
|
||||||
count(*) FILTER (WHERE l.created_at > :win) AS new_7d,
|
count(*) FILTER (WHERE created_at > :win) AS new_7d,
|
||||||
max(l.created_at) AS newest_at,
|
max(created_at) AS newest_at,
|
||||||
100.0*avg(l.has_thumb::int) AS thumb,
|
100.0*avg(has_thumb::int) AS thumb
|
||||||
100.0*avg(f.has_tag::int) AS tag,
|
FROM live
|
||||||
100.0*avg(f.has_perf::int) AS perf,
|
GROUP BY origin
|
||||||
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},
|
{"win": win_start},
|
||||||
).all()
|
).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).
|
# 2) Telemetria health per origin (okno 7d).
|
||||||
tele = {
|
tele = {
|
||||||
r.origin: r
|
r.origin: r
|
||||||
|
|
@ -198,13 +226,14 @@ def run_source_stats() -> dict:
|
||||||
for r in rows:
|
for r in rows:
|
||||||
origin = r.origin
|
origin = r.origin
|
||||||
sitetag = origin.split(":", 1)[1] if ":" in origin else origin
|
sitetag = origin.split(":", 1)[1] if ":" in origin else origin
|
||||||
|
f = flags.get(origin)
|
||||||
pcts = {
|
pcts = {
|
||||||
"thumb": float(r.thumb or 0),
|
"thumb": float(r.thumb or 0),
|
||||||
"tag": float(r.tag or 0),
|
"tag": float(f.tag or 0) if f else 0.0,
|
||||||
"perf": float(r.perf or 0),
|
"perf": float(f.perf or 0) if f else 0.0,
|
||||||
"desc": float(r.descr or 0),
|
"desc": float(f.descr or 0) if f else 0.0,
|
||||||
"studio": float(r.studio or 0),
|
"studio": float(f.studio or 0) if f else 0.0,
|
||||||
"dur": float(r.dur or 0),
|
"dur": float(f.dur or 0) if f else 0.0,
|
||||||
}
|
}
|
||||||
freshness = _freshness_score(r.newest_at, int(r.new_7d), int(r.scenes))
|
freshness = _freshness_score(r.newest_at, int(r.new_7d), int(r.scenes))
|
||||||
richness = _richness_score(pcts)
|
richness = _richness_score(pcts)
|
||||||
|
|
|
||||||
273
app/scheduler/studio_title_dedup.py
Normal file
273
app/scheduler/studio_title_dedup.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
"""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
|
||||||
|
|
@ -216,6 +216,11 @@ def run_forever() -> int:
|
||||||
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
|
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
|
||||||
# Title+duration dedup, missing-merge tube-dupy (reports 28fe8181/32df33b1).
|
# Title+duration dedup, missing-merge tube-dupy (reports 28fe8181/32df33b1).
|
||||||
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
|
"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
|
# 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.
|
# 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,
|
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { OnboardingModal } from './src/components/OnboardingModal';
|
||||||
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
||||||
import { ErrorBoundary } from './src/ErrorBoundary';
|
import { ErrorBoundary } from './src/ErrorBoundary';
|
||||||
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
||||||
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion } from './src/lib/appVersion';
|
import { APP_VERSION, NATIVE_RUNTIME, isNewerVersion, nativeBuildNumber } from './src/lib/appVersion';
|
||||||
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
|
import { DEFAULT_API_KEY, DEFAULT_BACKEND_URL } from './src/lib/backend';
|
||||||
import { getSettings as getLockSettings } from './src/lib/applock';
|
import { getSettings as getLockSettings } from './src/lib/applock';
|
||||||
import { AppNavigator } from './src/navigation';
|
import { AppNavigator } from './src/navigation';
|
||||||
|
|
@ -157,6 +157,10 @@ export default function App() {
|
||||||
// OTA-check settled, gate'uje ekran PIN, żeby ewentualny reloadAsync (silent OTA)
|
// 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).
|
// wydarzył się PRZED pytaniem o PIN, nie po (inaczej "PIN ×2", user-report mobilism).
|
||||||
const [updateSettled, setUpdateSettled] = useState(false);
|
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);
|
const backgroundedAt = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -257,11 +261,18 @@ export default function App() {
|
||||||
try {
|
try {
|
||||||
const out = await client.getServerVersion();
|
const out = await client.getServerVersion();
|
||||||
const bundled = APP_VERSION;
|
const bundled = APP_VERSION;
|
||||||
// Porównujemy RUNTIME, nie numer wersji: `bundled` bierze się z app.json, które
|
// Kolejność źródeł: numer builda APK → runtime → numer wersji. `bundled` bierze
|
||||||
// przyjeżdża razem z paczką OTA, więc stary APK po aktualizacji raportuje nową
|
// się z app.json, które przyjeżdża razem z paczką OTA, więc stary APK po
|
||||||
// wersję i sam siebie uznaje za aktualny. Runtime jest z natywnego manifestu.
|
// aktualizacji raportuje nową wersję i sam siebie uznaje za aktualny. Runtime
|
||||||
// Fallback na porównanie wersji, gdy serwer jeszcze nie podaje runtime.
|
// jest z natywnego manifestu, ale zmienia się tylko przy zmianie kanału OTA:
|
||||||
const needsApk = out.runtime_version && NATIVE_RUNTIME
|
// 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.runtime_version, NATIVE_RUNTIME)
|
||||||
: isNewerVersion(out.version, bundled);
|
: isNewerVersion(out.version, bundled);
|
||||||
if (out.version && needsApk && out.apk_url) {
|
if (out.version && needsApk && out.apk_url) {
|
||||||
|
|
@ -269,11 +280,23 @@ export default function App() {
|
||||||
if (apkInstallerAvailable) {
|
if (apkInstallerAvailable) {
|
||||||
const pendingApkUrl = out.apk_url!;
|
const pendingApkUrl = out.apk_url!;
|
||||||
const runInstall = async () => {
|
const runInstall = async () => {
|
||||||
|
setInstalling(true);
|
||||||
try {
|
try {
|
||||||
await installApk(pendingApkUrl);
|
await installApk(pendingApkUrl);
|
||||||
// PackageInstaller pokazuje natywny dialog, JS już nie ma co robić.
|
// PackageInstaller pokazuje natywny dialog, JS już nie ma co robić.
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert('Install failed', e instanceof Error ? e.message : String(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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const tryInstall = async () => {
|
const tryInstall = async () => {
|
||||||
|
|
@ -313,8 +336,12 @@ export default function App() {
|
||||||
// wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i
|
// wyświetlały się dwie identyczne liczby („Server: 0.3.0, App: 0.3.0") i
|
||||||
// monit wyglądał na pomyłkę.
|
// 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?`,
|
`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: 'Later', style: 'cancel' },
|
||||||
|
{ text: 'Download', onPress: () => Linking.openURL(out.apk_url!).catch(() => {}) },
|
||||||
{ text: 'Install', onPress: tryInstall },
|
{ text: 'Install', onPress: tryInstall },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -443,6 +470,30 @@ export default function App() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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 ? (
|
{client ? (
|
||||||
<ClientProvider client={client}>
|
<ClientProvider client={client}>
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,8 @@ android {
|
||||||
applicationId 'com.goon.mobile'
|
applicationId 'com.goon.mobile'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 15
|
versionCode 16
|
||||||
versionName "0.4.1"
|
versionName "0.4.2"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@
|
||||||
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
|
@ -14,7 +24,7 @@
|
||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<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">
|
<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">
|
||||||
<!--
|
<!--
|
||||||
Expo Updates — ENABLED 2026-05-22 dla public release. Manifest serwowany
|
Expo Updates — ENABLED 2026-05-22 dla public release. Manifest serwowany
|
||||||
przez backend `/expo-updates/manifest` (api.goon-foss.org). Nowe JS-only
|
przez backend `/expo-updates/manifest` (api.goon-foss.org). Nowe JS-only
|
||||||
|
|
@ -39,7 +49,7 @@
|
||||||
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
|
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"/>
|
<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="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">
|
<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">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
|
@ -51,5 +61,14 @@
|
||||||
<data android:scheme="com.goon.mobile"/>
|
<data android:scheme="com.goon.mobile"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -13,7 +13,8 @@ import com.reactnative.googlecast.api.RNGCCastContext
|
||||||
|
|
||||||
import expo.modules.ReactActivityDelegateWrapper
|
import expo.modules.ReactActivityDelegateWrapper
|
||||||
|
|
||||||
class MainActivity : ReactActivity() {
|
// open, bo dziedziczy z niej TvMainActivity (to samo, tylko bez blokady pionu).
|
||||||
|
open class MainActivity : ReactActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// Set the theme to AppTheme BEFORE onCreate to support
|
// Set the theme to AppTheme BEFORE onCreate to support
|
||||||
// coloring the background, status bar, and navigation bar.
|
// coloring the background, status bar, and navigation bar.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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()
|
||||||
BIN
mobile/android/app/src/main/res/drawable/tv_banner.png
Normal file
BIN
mobile/android/app/src/main/res/drawable/tv_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
|
|
@ -2,7 +2,7 @@
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "goon",
|
"name": "goon",
|
||||||
"slug": "goon",
|
"slug": "goon",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": false,
|
"newArchEnabled": false,
|
||||||
|
|
|
||||||
31
mobile/patches/expo-modules-core+2.2.3.patch
Normal file
31
mobile/patches/expo-modules-core+2.2.3.patch
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
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
|
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..34923b2 100644
|
index cd798cf..8cb6dfb 100644
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
|
--- 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
|
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt
|
||||||
@@ -136,10 +136,14 @@ class ExpoVideoPlaybackService : MediaSessionService() {
|
@@ -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() {
|
||||||
|
|
||||||
private fun cleanup() {
|
private fun cleanup() {
|
||||||
hideAllNotifications()
|
hideAllNotifications()
|
||||||
|
|
@ -10,7 +19,13 @@ index cd798cf..34923b2 100644
|
||||||
+ // Kopia przed iteracja: session.release() wola z powrotem unregisterPlayer, ktory
|
+ // Kopia przed iteracja: session.release() wola z powrotem unregisterPlayer, ktory
|
||||||
+ // usuwa wpis z mediaSessions w trakcie forEach -> ConcurrentModificationException
|
+ // usuwa wpis z mediaSessions w trakcie forEach -> ConcurrentModificationException
|
||||||
+ // przy zamykaniu apki z listy ostatnich (Goon, Sentry 147164135, 2026-09-15).
|
+ // przy zamykaniu apki z listy ostatnich (Goon, Sentry 147164135, 2026-09-15).
|
||||||
+ val sessions = mediaSessions.values.toList()
|
+ // 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)
|
||||||
+ mediaSessions.clear()
|
+ mediaSessions.clear()
|
||||||
+ sessions.forEach { session ->
|
+ sessions.forEach { session ->
|
||||||
session.release()
|
session.release()
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,8 @@ export class GoonClient {
|
||||||
async getServerVersion(): Promise<{
|
async getServerVersion(): Promise<{
|
||||||
version: string;
|
version: string;
|
||||||
runtime_version?: string | null;
|
runtime_version?: string | null;
|
||||||
|
/** versionCode najnowszego APK; rozróżnia wydania na tym samym runtime. */
|
||||||
|
apk_build?: number | null;
|
||||||
apk_url: string | null;
|
apk_url: string | null;
|
||||||
}> {
|
}> {
|
||||||
// /version nie wymaga API key (publiczny endpoint do upgrade discovery).
|
// /version nie wymaga API key (publiczny endpoint do upgrade discovery).
|
||||||
|
|
@ -667,8 +669,12 @@ export class GoonClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getClipOnboardingOptions(): Promise<ClipOnboardingOptions> {
|
/** Bez `q`: najpopularniejsze tagi i nisze. Z `q`: szukanie po calym slowniku tagow. */
|
||||||
return this.request('/clips/onboarding-options');
|
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 postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
|
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
|
||||||
|
|
@ -684,6 +690,23 @@ export class GoonClient {
|
||||||
await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' });
|
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> {
|
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
|
||||||
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
|
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,54 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-09-15d',
|
||||||
date: 'September 2026',
|
date: 'September 2026',
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { captureScreen } from 'react-native-view-shot';
|
||||||
|
|
||||||
import { GoonClient } from '../api';
|
import { GoonClient } from '../api';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
import { nativeBuildNumber } from '../lib/appVersion';
|
||||||
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar';
|
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT, barHidden } from '../lib/bottomBar';
|
||||||
import { formatLastPlayback } from '../lib/lastPlayback';
|
import { formatLastPlayback } from '../lib/lastPlayback';
|
||||||
import { getSxyprnDiag } from '../lib/sxyprnResolver';
|
import { getSxyprnDiag } from '../lib/sxyprnResolver';
|
||||||
|
|
@ -220,6 +221,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}`
|
? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}`
|
||||||
: `${Platform.OS} ${Platform.Version}`;
|
: `${Platform.OS} ${Platform.Version}`;
|
||||||
extraContext.push(`device=${device.slice(0, 48)}`);
|
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
|
// 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.
|
// się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia.
|
||||||
const play = formatLastPlayback();
|
const play = formatLastPlayback();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { Image } from 'expo-image';
|
||||||
import type { VideoPlayer } from 'expo-video';
|
import type { VideoPlayer } from 'expo-video';
|
||||||
import { VideoView } from 'expo-video';
|
import { VideoView } from 'expo-video';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Animated, Pressable, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
|
@ -40,14 +40,35 @@ interface Props {
|
||||||
onLike: () => void;
|
onLike: () => void;
|
||||||
onNotInterested: () => void;
|
onNotInterested: () => void;
|
||||||
onTagPress?: (tag: string) => void;
|
onTagPress?: (tag: string) => void;
|
||||||
|
onCreatorPress?: (creator: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress }: Props) {
|
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress, onCreatorPress }: Props) {
|
||||||
const clip = item.clip;
|
const clip = item.clip;
|
||||||
const [progress, setProgress] = React.useState(0);
|
const [progress, setProgress] = React.useState(0);
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
const heart = React.useRef(new Animated.Value(0)).current;
|
const heart = React.useRef(new Animated.Value(0)).current;
|
||||||
const insets = useSafeAreaInsets();
|
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(
|
const lift = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
barHidden.interpolate({
|
barHidden.interpolate({
|
||||||
|
|
@ -145,17 +166,22 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
const tags = clip.tags.slice(0, 4);
|
const tags = clip.tags.slice(0, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={gestures}>
|
// 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}>
|
<View style={styles.root}>
|
||||||
|
<GestureDetector gesture={gestures}>
|
||||||
|
<View style={StyleSheet.absoluteFill} collapsable={false}>
|
||||||
{poster ? (
|
{poster ? (
|
||||||
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} />
|
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit={fit} transition={0} />
|
||||||
) : null}
|
) : null}
|
||||||
{active && player ? (
|
{active && player ? (
|
||||||
<VideoView
|
<VideoView
|
||||||
player={player}
|
player={player}
|
||||||
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
|
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
|
||||||
nativeControls={false}
|
nativeControls={false}
|
||||||
contentFit="cover"
|
contentFit={fit}
|
||||||
allowsPictureInPicture={false}
|
allowsPictureInPicture={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -173,6 +199,8 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
|
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
|
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
|
||||||
{/* Kolumna przyciskow */}
|
{/* Kolumna przyciskow */}
|
||||||
|
|
@ -201,15 +229,27 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
|
|
||||||
{/* Podpis: tworca, tytul, tagi */}
|
{/* Podpis: tworca, tytul, tagi */}
|
||||||
<View style={styles.caption} pointerEvents="box-none">
|
<View style={styles.caption} pointerEvents="box-none">
|
||||||
<View style={styles.creatorRow}>
|
{/* 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}
|
||||||
|
>
|
||||||
<Text style={styles.creator} numberOfLines={1}>
|
<Text style={styles.creator} numberOfLines={1}>
|
||||||
{clip.creator ? `@${clip.creator}` : clip.source}
|
{clip.creator ? `@${clip.creator}` : clip.source}
|
||||||
</Text>
|
</Text>
|
||||||
{clip.creator_verified ? (
|
{clip.creator_verified ? (
|
||||||
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
|
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
|
||||||
) : null}
|
) : 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}
|
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
|
||||||
</View>
|
</Pressable>
|
||||||
{clip.title ? (
|
{clip.title ? (
|
||||||
<Text style={styles.title} numberOfLines={2}>
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
{clip.title}
|
{clip.title}
|
||||||
|
|
@ -232,7 +272,6 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
226
mobile/src/components/swipe/ClipPager.tsx
Normal file
226
mobile/src/components/swipe/ClipPager.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
});
|
||||||
|
|
@ -18,7 +18,7 @@ import * as Updates from 'expo-updates';
|
||||||
* też nie idzie do góry, więc consistency jest zachowana.
|
* też nie idzie do góry, więc consistency jest zachowana.
|
||||||
*/
|
*/
|
||||||
export const APP_VERSION: string =
|
export const APP_VERSION: string =
|
||||||
(Constants.expoConfig?.version as string | undefined) || '0.4.0';
|
(Constants.expoConfig?.version as string | undefined) || '0.4.2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po
|
* Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po
|
||||||
|
|
@ -39,6 +39,34 @@ 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. */
|
/** „0.3.0" → [0,3,0]. Człony nieliczbowe (np. „0.3.0-rc1") lecą na 0. */
|
||||||
function versionParts(v: string): number[] {
|
function versionParts(v: string): number[] {
|
||||||
return String(v || '').split('.').map((x) => parseInt(x, 10) || 0);
|
return String(v || '').split('.').map((x) => parseInt(x, 10) || 0);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
||||||
import { BlacklistScreen } from './screens/BlacklistScreen';
|
import { BlacklistScreen } from './screens/BlacklistScreen';
|
||||||
|
import { ClipCreatorScreen } from './screens/ClipCreatorScreen';
|
||||||
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
||||||
import { DonateScreen } from './screens/DonateScreen';
|
import { DonateScreen } from './screens/DonateScreen';
|
||||||
import { FavoritesScreen } from './screens/FavoritesScreen';
|
import { FavoritesScreen } from './screens/FavoritesScreen';
|
||||||
|
|
@ -71,6 +72,8 @@ export type RootStackParamList = {
|
||||||
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
|
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
|
||||||
Tags: { vertical?: string } | undefined;
|
Tags: { vertical?: string } | undefined;
|
||||||
TagScenes: { slug: string; name: string; vertical?: string | null };
|
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;
|
AppLockSettings: undefined;
|
||||||
Blacklist: undefined;
|
Blacklist: undefined;
|
||||||
Donate: undefined;
|
Donate: undefined;
|
||||||
|
|
@ -280,6 +283,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
|
||||||
component={DonateScreen}
|
component={DonateScreen}
|
||||||
options={{ title: 'Support project' }}
|
options={{ title: 'Support project' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="ClipCreator" component={ClipCreatorScreen} options={{ title: '' }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="DiagnosticBrowser"
|
name="DiagnosticBrowser"
|
||||||
component={DiagnosticBrowserScreen}
|
component={DiagnosticBrowserScreen}
|
||||||
|
|
|
||||||
230
mobile/src/screens/ClipCreatorScreen.tsx
Normal file
230
mobile/src/screens/ClipCreatorScreen.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
/**
|
||||||
|
* 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)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -106,6 +106,10 @@ function useMarkSourceBroken(params: RouteParams) {
|
||||||
// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować).
|
// 403 NIE liczy się (auth/IP-bound → fallback proxy/WebView może uratować).
|
||||||
function isGoneError(msg: string | undefined | null): boolean {
|
function isGoneError(msg: string | undefined | null): boolean {
|
||||||
if (!msg) return false;
|
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);
|
return /\b(404|410)\b|response code:\s*4(0[14])|not found|gone/i.test(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,16 @@
|
||||||
* serwer uczy sie wag i uklada 80 % kolejnych stron.
|
* serwer uczy sie wag i uklada 80 % kolejnych stron.
|
||||||
*/
|
*/
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
import { useClient } from '../ClientContext';
|
import { useClient } from '../ClientContext';
|
||||||
import { ClipCell } from '../components/swipe/ClipCell';
|
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
|
||||||
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
|
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
|
||||||
import { setBarHidden } from '../lib/bottomBar';
|
import { setBarHidden } from '../lib/bottomBar';
|
||||||
import { usePersistedState } from '../lib/persistedState';
|
import { usePersistedState } from '../lib/persistedState';
|
||||||
|
|
@ -32,19 +34,9 @@ import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
|
||||||
import { fonts, theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
import type { ClipFeedItem, ClipOnboardingOptions } from '../types';
|
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 SWIPE_MIN_RUNTIME = '1.4';
|
||||||
export const swipeAvailable: boolean =
|
export const swipeAvailable: boolean =
|
||||||
PagerViewImpl != null && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
|
pagerAvailable && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const PREFETCH_AT = 4;
|
const PREFETCH_AT = 4;
|
||||||
|
|
@ -57,10 +49,6 @@ interface SwipePrefs {
|
||||||
}
|
}
|
||||||
const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false };
|
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() {
|
export function SwipeScreen() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
|
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
|
||||||
|
|
@ -68,7 +56,9 @@ export function SwipeScreen() {
|
||||||
const [index, setIndex] = React.useState(0);
|
const [index, setIndex] = React.useState(0);
|
||||||
const [liked, setLiked] = React.useState<Set<string>>(new Set());
|
const [liked, setLiked] = React.useState<Set<string>>(new Set());
|
||||||
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
|
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
|
||||||
|
const [tuneOpen, setTuneOpen] = React.useState(false);
|
||||||
const pagerRef = React.useRef<any>(null);
|
const pagerRef = React.useRef<any>(null);
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
// ---- dane ------------------------------------------------------------------------
|
// ---- dane ------------------------------------------------------------------------
|
||||||
const feed = useInfiniteQuery({
|
const feed = useInfiniteQuery({
|
||||||
|
|
@ -84,7 +74,7 @@ export function SwipeScreen() {
|
||||||
const out: ClipFeedItem[] = [];
|
const out: ClipFeedItem[] = [];
|
||||||
for (const page of feed.data?.pages ?? []) {
|
for (const page of feed.data?.pages ?? []) {
|
||||||
for (const it of page.items) {
|
for (const it of page.items) {
|
||||||
if (!seen.has(it.clip.id) && sourceOf(it)) {
|
if (!seen.has(it.clip.id) && clipSource(it)) {
|
||||||
seen.add(it.clip.id);
|
seen.add(it.clip.id);
|
||||||
out.push(it);
|
out.push(it);
|
||||||
}
|
}
|
||||||
|
|
@ -106,8 +96,7 @@ export function SwipeScreen() {
|
||||||
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
|
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
|
||||||
React.useEffect(() => () => setBarHidden(false), []);
|
React.useEffect(() => () => setBarHidden(false), []);
|
||||||
const onPageSelected = React.useCallback(
|
const onPageSelected = React.useCallback(
|
||||||
(e: { nativeEvent: { position: number } }) => {
|
(next: number) => {
|
||||||
const next = e.nativeEvent.position;
|
|
||||||
if (next === 0 || next < index) setBarHidden(false);
|
if (next === 0 || next < index) setBarHidden(false);
|
||||||
else if (next > index) setBarHidden(true);
|
else if (next > index) setBarHidden(true);
|
||||||
setIndex(next);
|
setIndex(next);
|
||||||
|
|
@ -124,111 +113,21 @@ export function SwipeScreen() {
|
||||||
return () => t.stop();
|
return () => t.stop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- dwa playery -----------------------------------------------------------------
|
// ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
|
||||||
const players = React.useRef<VideoPlayer[]>([]);
|
const onLeave = React.useCallback(
|
||||||
const loopsRef = React.useRef(0);
|
(prev: number, info: ClipLeaveInfo) => {
|
||||||
const activatedAtRef = React.useRef(0);
|
const left = items[prev];
|
||||||
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
|
if (!left) return;
|
||||||
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({
|
telemetry.current?.push({
|
||||||
clip_id: items[prev].clip.id,
|
clip_id: left.clip.id,
|
||||||
kind: leaveKind(dwell, loopsRef.current, pos, items[prev].clip.duration_sec),
|
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
|
||||||
dwell_ms: dwell,
|
dwell_ms: info.dwellMs,
|
||||||
slot: items[prev].slot,
|
slot: left.slot,
|
||||||
position: prev,
|
position: prev,
|
||||||
});
|
});
|
||||||
try {
|
},
|
||||||
prevPlayer.pause();
|
[items],
|
||||||
} 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.
|
// AutoSwipe: po N sekundach na stronie przechodzimy dalej.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -276,7 +175,6 @@ export function SwipeScreen() {
|
||||||
telemetry.current?.stop();
|
telemetry.current?.stop();
|
||||||
telemetry.current = new SwipeTelemetry(client, newSessionId());
|
telemetry.current = new SwipeTelemetry(client, newSessionId());
|
||||||
telemetry.current.start();
|
telemetry.current.start();
|
||||||
prevIndexRef.current = null;
|
|
||||||
setBarHidden(false);
|
setBarHidden(false);
|
||||||
setIndex(0);
|
setIndex(0);
|
||||||
setSeedKey((k) => k + 1);
|
setSeedKey((k) => k + 1);
|
||||||
|
|
@ -293,9 +191,23 @@ export function SwipeScreen() {
|
||||||
const options = useQuery({
|
const options = useQuery({
|
||||||
queryKey: ['clip-onboarding-options'],
|
queryKey: ['clip-onboarding-options'],
|
||||||
queryFn: () => client.getClipOnboardingOptions(),
|
queryFn: () => client.getClipOnboardingOptions(),
|
||||||
enabled: prefsReady && !prefs.onboarded,
|
enabled: prefsReady && (!prefs.onboarded || tuneOpen),
|
||||||
staleTime: 10 * 60 * 1000,
|
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(
|
const finishOnboarding = React.useCallback(
|
||||||
async (tags: string[], niches: string[]) => {
|
async (tags: string[], niches: string[]) => {
|
||||||
if (tags.length || niches.length) {
|
if (tags.length || niches.length) {
|
||||||
|
|
@ -312,7 +224,7 @@ export function SwipeScreen() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- render --------------------------------------------------------------------------
|
// ---- render --------------------------------------------------------------------------
|
||||||
if (!swipeAvailable || !PagerViewImpl) {
|
if (!swipeAvailable) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
|
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
|
||||||
|
|
@ -321,36 +233,22 @@ export function SwipeScreen() {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const PagerView = PagerViewImpl;
|
|
||||||
const current = items[index];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
<PagerView
|
<ClipPager
|
||||||
ref={pagerRef}
|
items={items}
|
||||||
style={styles.pager}
|
index={index}
|
||||||
orientation="vertical"
|
pagerRef={pagerRef}
|
||||||
initialPage={0}
|
|
||||||
offscreenPageLimit={1}
|
|
||||||
overdrag
|
|
||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
>
|
onLeave={onLeave}
|
||||||
{items.map((it, i) => (
|
soundOn={prefs.soundOn}
|
||||||
<View key={it.clip.id} style={styles.page} collapsable={false}>
|
onToggleSound={toggleMute}
|
||||||
<ClipCell
|
isLiked={(id) => liked.has(id)}
|
||||||
item={it}
|
onLike={likeItem}
|
||||||
active={i === index}
|
onNotInterested={(it) => setSheetItem(it)}
|
||||||
player={i === index ? activePlayer : null}
|
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
|
||||||
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 ? (
|
) : feed.isLoading ? (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<ActivityIndicator color={theme.accentText} />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
|
|
@ -375,8 +273,11 @@ export function SwipeScreen() {
|
||||||
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
|
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
|
||||||
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
|
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={toggleMute} style={styles.topBtn} accessibilityLabel="Sound">
|
{/* Dzwiek ma JEDEN przycisk: ten w kolumnie po prawej, przy kciuku (uwaga Jana
|
||||||
<MaterialCommunityIcons name={prefs.soundOn ? 'volume-high' : 'volume-off'} size={22} color="#fff" />
|
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>
|
</Pressable>
|
||||||
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
|
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
|
||||||
<MaterialCommunityIcons name="refresh" size={22} color="#fff" />
|
<MaterialCommunityIcons name="refresh" size={22} color="#fff" />
|
||||||
|
|
@ -418,12 +319,21 @@ export function SwipeScreen() {
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Onboarding 3 tapow */}
|
{/* Wybor zainteresowan: przy pierwszym wejsciu i pozniej spod ikony stroika */}
|
||||||
<OnboardingModal
|
<OnboardingModal
|
||||||
visible={prefsReady && !prefs.onboarded && !!options.data}
|
visible={prefsReady && !prefs.onboarded && !!options.data}
|
||||||
options={options.data}
|
options={options.data}
|
||||||
onDone={finishOnboarding}
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -442,13 +352,34 @@ function OnboardingModal({
|
||||||
visible,
|
visible,
|
||||||
options,
|
options,
|
||||||
onDone,
|
onDone,
|
||||||
|
tune,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
options: ClipOnboardingOptions | undefined;
|
options: ClipOnboardingOptions | undefined;
|
||||||
onDone: (tags: string[], niches: string[]) => void;
|
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 [picked, setPicked] = React.useState<Set<string>>(new Set());
|
||||||
const tags = (options?.tags ?? []).slice(0, 24);
|
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 toggle = (t: string) =>
|
const toggle = (t: string) =>
|
||||||
setPicked((s) => {
|
setPicked((s) => {
|
||||||
const n = new Set(s);
|
const n = new Set(s);
|
||||||
|
|
@ -456,27 +387,66 @@ function OnboardingModal({
|
||||||
else n.add(t);
|
else n.add(t);
|
||||||
return n;
|
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 (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade">
|
<Modal visible={visible} transparent animationType="fade">
|
||||||
<View style={styles.onboardBackdrop}>
|
<View style={styles.onboardBackdrop}>
|
||||||
<View style={styles.onboardCard}>
|
<View style={styles.onboardCard}>
|
||||||
<Text style={styles.sheetTitle}>What do you want to see?</Text>
|
<Text style={styles.sheetTitle}>{tune ? 'More of what you like' : '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>
|
<Text style={styles.centerText}>
|
||||||
<ScrollView style={{ maxHeight: 300 }} contentContainerStyle={styles.chipsWrap}>
|
{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"
|
||||||
|
>
|
||||||
{tags.map((t) => (
|
{tags.map((t) => (
|
||||||
<Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} />
|
<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>
|
</ScrollView>
|
||||||
<View style={styles.onboardRow}>
|
<View style={styles.onboardRow}>
|
||||||
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
|
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
|
||||||
<Text style={styles.secondaryBtnText}>Skip</Text>
|
<Text style={styles.secondaryBtnText}>{tune ? 'Cancel' : 'Skip'}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => onDone([...picked], [])}
|
onPress={() => onDone([...picked], [...pickedNiches])}
|
||||||
disabled={picked.size < 3}
|
disabled={tune ? total === 0 : total < 3}
|
||||||
style={[styles.primaryBtn, picked.size < 3 && { opacity: 0.4 }]}
|
style={[styles.primaryBtn, (tune ? total === 0 : total < 3) && { opacity: 0.4 }]}
|
||||||
>
|
>
|
||||||
<Text style={styles.primaryBtnText}>Start{picked.size ? ` (${picked.size})` : ''}</Text>
|
<Text style={styles.primaryBtnText}>
|
||||||
|
{tune ? 'Apply' : 'Start'}
|
||||||
|
{total ? ` (${total})` : ''}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -487,8 +457,6 @@ function OnboardingModal({
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#000' },
|
root: { flex: 1, backgroundColor: '#000' },
|
||||||
pager: { flex: 1 },
|
|
||||||
page: { flex: 1 },
|
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg },
|
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' },
|
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 },
|
centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 },
|
||||||
|
|
@ -510,6 +478,19 @@ const styles = StyleSheet.create({
|
||||||
onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 },
|
onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 },
|
||||||
onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 },
|
onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 },
|
||||||
onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 },
|
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 },
|
primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 },
|
||||||
primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
|
primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
|
||||||
secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },
|
secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,23 @@ function _genDeviceId(): string {
|
||||||
|
|
||||||
export async function getDeviceId(): Promise<string> {
|
export async function getDeviceId(): Promise<string> {
|
||||||
if (_deviceIdCache) return _deviceIdCache;
|
if (_deviceIdCache) return _deviceIdCache;
|
||||||
let id = await SecureStore.getItemAsync(DEVICE_KEY);
|
// 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;
|
||||||
|
}
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = _genDeviceId();
|
id = _genDeviceId();
|
||||||
|
try {
|
||||||
await SecureStore.setItemAsync(DEVICE_KEY, id);
|
await SecureStore.setItemAsync(DEVICE_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// zostaje tylko w _deviceIdCache
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_deviceIdCache = id;
|
_deviceIdCache = id;
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
|
|
@ -520,4 +520,6 @@ export interface ClipListOut {
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
has_more: boolean;
|
has_more: boolean;
|
||||||
|
/** Liczba wszystkich klipow przy filtrze po tworcy; null przy innych listach. */
|
||||||
|
total?: number | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
scripts/backfill_scene_tags_sort_date.py
Normal file
79
scripts/backfill_scene_tags_sort_date.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a href="/goon-v0.4.1.apk"
|
<a href="/goon-v0.4.2.apk"
|
||||||
class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow">
|
class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow">
|
||||||
Download APK
|
Download APK
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -327,7 +327,7 @@
|
||||||
<li class="bg-card border border-border rounded-2xl p-5 flex gap-4">
|
<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-accent font-extrabold text-xl">1</span>
|
||||||
<span class="text-sm text-gray-300 leading-relaxed">
|
<span class="text-sm text-gray-300 leading-relaxed">
|
||||||
<a href="/goon-v0.4.1.apk" class="text-accent font-bold hover:underline">Download the APK</a>
|
<a href="/goon-v0.4.2.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.
|
and open it. Allow "install from unknown sources" for your browser if Android asks.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -404,7 +404,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1 text-right">
|
<div class="flex flex-col gap-1 text-right">
|
||||||
<a href="/goon-v0.4.1.apk" class="hover:text-accent transition">Download APK</a>
|
<a href="/goon-v0.4.2.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="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>
|
<a href="#donate" class="hover:text-accent transition">Donate</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
51
tests/test_feed_diversify.py
Normal file
51
tests/test_feed_diversify.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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
|
||||||
|
|
@ -87,6 +87,11 @@ def test_strip_accents_idempotent() -> None:
|
||||||
("Sasha Paige ft.Martin Spell", "Martin Spell", True),
|
("Sasha Paige ft.Martin Spell", "Martin Spell", True),
|
||||||
# ft wewnatrz slowa to nie separator.
|
# ft wewnatrz slowa to nie separator.
|
||||||
("Craft Beer Night", "Craft Beer", True),
|
("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:
|
def test_person_named_in(title: str, person: str, expected: bool) -> None:
|
||||||
|
|
|
||||||
52
tests/test_search_walk.py
Normal file
52
tests/test_search_walk.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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
|
||||||
93
tests/test_studio_title_dedup.py
Normal file
93
tests/test_studio_title_dedup.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
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)
|
||||||
Loading…
Add table
Reference in a new issue