Compare commits

..

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

24 changed files with 68 additions and 690 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -116,10 +116,6 @@ export type RootStackParamList = {
// się automatycznie na WebView z tym URL'em, tam JS embed page sam pobiera
// m3u8 we własnym kontekście (Cookies + JA3 + IP set ok).
fallbackEmbedUrl?: string;
// Skad przyszedl adres (telemetria playback_events.via): 'phone' (resolver na
// telefonie w SceneDetail), 'backend', 'hoster' (resolver hostera w Playerze),
// 'webview' (wyciagniety ze strony), 'proxy' (zapasowe proxy), 'movie'.
via?: string;
};
};
@ -301,10 +297,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
// dawał ~80-90px czarnego paska w landscape, letterboxowało wideo.
headerShown: false,
contentStyle: { backgroundColor: '#000' },
// 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).
orientation: 'all',
}}
/>
</Stack.Navigator>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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