Compare commits

..

9 commits

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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