Compare commits
9 commits
426b5e99a2
...
ce174b671e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce174b671e | ||
|
|
6540d4a009 | ||
|
|
2feef620a1 | ||
|
|
189dbdf17d | ||
|
|
3b061a5807 | ||
|
|
7e48cdee06 | ||
|
|
5001d79bbc | ||
|
|
8971d81ef9 | ||
|
|
e84e49a3a9 |
24 changed files with 690 additions and 68 deletions
32
alembic/versions/20261006_0053_playback_events_via.py
Normal file
32
alembic/versions/20261006_0053_playback_events_via.py
Normal 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")
|
||||||
31
alembic/versions/20261006_0054_hidden_sites.py
Normal file
31
alembic/versions/20261006_0054_hidden_sites.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,31 +451,63 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
||||||
BlacklistedTag,
|
BlacklistedTag,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
out: list = []
|
||||||
~exists(
|
if "performer" in kinds:
|
||||||
select(1)
|
out.append(
|
||||||
.select_from(ScenePerformer)
|
~exists(
|
||||||
.join(
|
select(1)
|
||||||
BlacklistedPerformer,
|
.select_from(ScenePerformer)
|
||||||
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
|
.join(
|
||||||
& (BlacklistedPerformer.device_id == device_id),
|
BlacklistedPerformer,
|
||||||
|
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
|
||||||
|
& (BlacklistedPerformer.device_id == device_id),
|
||||||
|
)
|
||||||
|
.where(ScenePerformer.scene_id == Scene.id)
|
||||||
)
|
)
|
||||||
.where(ScenePerformer.scene_id == Scene.id)
|
)
|
||||||
),
|
if "studio" in kinds:
|
||||||
~Scene.studio_id.in_(
|
out.append(
|
||||||
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_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),
|
|
||||||
)
|
)
|
||||||
.where(SceneTag.scene_id == Scene.id)
|
)
|
||||||
),
|
if "tag" in kinds:
|
||||||
]
|
out.append(
|
||||||
|
~exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SceneTag)
|
||||||
|
.join(
|
||||||
|
BlacklistedTag,
|
||||||
|
(BlacklistedTag.tag_id == SceneTag.tag_id)
|
||||||
|
& (BlacklistedTag.device_id == device_id),
|
||||||
|
)
|
||||||
|
.where(SceneTag.scene_id == Scene.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "site" in kinds:
|
||||||
|
# Ukryta strona chowa scene tylko wtedy, gdy wszystkie jej zywe zrodla sa z ukrytych
|
||||||
|
# stron: ta sama scena z innego serwisu dalej jest do obejrzenia. Sceny bez zywego
|
||||||
|
# zrodla przepuszczamy, o nich decyduje filtr has_playback.
|
||||||
|
hidden = hidden_origins_subquery(device_id)
|
||||||
|
out.append(
|
||||||
|
~(
|
||||||
|
exists(
|
||||||
|
select(1).where(
|
||||||
|
PlaybackSource.scene_id == Scene.id,
|
||||||
|
PlaybackSource.dead_at.is_(None),
|
||||||
|
PlaybackSource.origin.in_(hidden),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
& ~exists(
|
||||||
|
select(1).where(
|
||||||
|
PlaybackSource.scene_id == Scene.id,
|
||||||
|
PlaybackSource.dead_at.is_(None),
|
||||||
|
PlaybackSource.origin.not_in(hidden),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _studio_sorted_select(base, inner_limit: int, studio_ids=None):
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
25
app/models/hidden_site.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
159
mobile/src/UpdateRequired.tsx
Normal file
159
mobile/src/UpdateRequired.tsx
Normal 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 & 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' },
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
7
mobile/update-required.tsx
Normal file
7
mobile/update-required.tsx
Normal 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);
|
||||||
|
|
@ -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()`.
|
||||||
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
|
# `--entry`: expo export bierze punkt wejscia z pola `main` w package.json, wiec
|
||||||
|
# podmieniamy je na czas eksportu i zawsze przywracamy.
|
||||||
|
pkg_path = MOBILE / "package.json"
|
||||||
|
pkg_orig = pkg_path.read_text(encoding="utf-8")
|
||||||
|
if args.entry:
|
||||||
|
if not (MOBILE / args.entry).exists():
|
||||||
|
print(f"ERROR: brak pliku mobile/{args.entry}")
|
||||||
|
return 1
|
||||||
|
pkg = json.loads(pkg_orig)
|
||||||
|
pkg["main"] = args.entry
|
||||||
|
pkg_path.write_text(json.dumps(pkg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
|
||||||
|
finally:
|
||||||
|
if args.entry:
|
||||||
|
pkg_path.write_text(pkg_orig, encoding="utf-8")
|
||||||
|
|
||||||
upload_sourcemaps()
|
upload_sourcemaps()
|
||||||
strip_sourcemaps()
|
strip_sourcemaps()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue