Compare commits
No commits in common. "ce174b671e232ab6e2566fd97b7fe2eacd520ecd" and "426b5e99a23d04dab52b60d0fd019abc87d6010f" have entirely different histories.
ce174b671e
...
426b5e99a2
24 changed files with 68 additions and 690 deletions
|
|
@ -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")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -9,23 +9,18 @@ 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 exists, select
|
from sqlalchemy import 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 (
|
||||||
|
|
@ -33,9 +28,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -56,8 +49,6 @@ 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)
|
||||||
|
|
@ -83,54 +74,13 @@ 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"
|
||||||
|
|
@ -155,7 +105,6 @@ 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}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -172,4 +121,3 @@ def remove_blacklist(
|
||||||
return # idempotent
|
return # idempotent
|
||||||
session.delete(row)
|
session.delete(row)
|
||||||
session.commit()
|
session.commit()
|
||||||
invalidate_blacklist_cache(device_id)
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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:
|
||||||
# Pierwszy zapis dla urzadzenia. Onboarding i zdarzenia z pierwszych klipow
|
row = ClipAffinity(device_id=device_id)
|
||||||
# przychodza naraz, oba widzialy brak wiersza i oba robily INSERT, drugi konczyl
|
session.add(row)
|
||||||
# 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,10 +56,6 @@ 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,9 +32,6 @@ 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)
|
||||||
|
|
@ -52,7 +49,6 @@ 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,47 +187,31 @@ 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).
|
||||||
# Trzymamy, KTORE listy sa niepuste, nie tylko "czy cokolwiek": ukryte strony (2026-10-06)
|
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
|
||||||
# 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 invalidate_blacklist_cache(device_id: str) -> None:
|
def _blacklists_empty(session: Session, device_id: str) -> bool:
|
||||||
_BLACKLIST_EMPTY_CACHE.pop(device_id, None)
|
"""True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
|
||||||
|
|
||||||
|
|
||||||
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]
|
||||||
row = session.execute(
|
has_any = 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)),
|
|
||||||
)
|
)
|
||||||
).one()
|
).scalar_one()
|
||||||
kinds = frozenset(k for k, present in zip(("performer", "studio", "tag", "site"), row) if present)
|
_BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
|
||||||
_BLACKLIST_EMPTY_CACHE[device_id] = (now, kinds)
|
return not has_any
|
||||||
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
|
||||||
|
|
@ -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:
|
def blacklist_clauses(session: Session, device_id: str) -> list:
|
||||||
"""Klauzule blacklist device (performer/studio/tag) i ukrytych stron. [] gdy wszystko puste."""
|
"""NOT-EXISTS klauzule blacklist device (performer/studio/tag). [] gdy wszystkie puste."""
|
||||||
kinds = _blacklist_kinds(session, device_id)
|
if _blacklists_empty(session, device_id):
|
||||||
if not kinds:
|
|
||||||
return []
|
return []
|
||||||
from app.models.blacklist import (
|
from app.models.blacklist import (
|
||||||
BlacklistedPerformer,
|
BlacklistedPerformer,
|
||||||
|
|
@ -451,63 +427,31 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
||||||
BlacklistedTag,
|
BlacklistedTag,
|
||||||
)
|
)
|
||||||
|
|
||||||
out: list = []
|
return [
|
||||||
if "performer" in kinds:
|
~exists(
|
||||||
out.append(
|
select(1)
|
||||||
~exists(
|
.select_from(ScenePerformer)
|
||||||
select(1)
|
.join(
|
||||||
.select_from(ScenePerformer)
|
BlacklistedPerformer,
|
||||||
.join(
|
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
|
||||||
BlacklistedPerformer,
|
& (BlacklistedPerformer.device_id == device_id),
|
||||||
(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:
|
),
|
||||||
out.append(
|
~Scene.studio_id.in_(
|
||||||
~Scene.studio_id.in_(
|
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
|
||||||
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
|
),
|
||||||
|
~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):
|
||||||
|
|
@ -1203,15 +1147,7 @@ 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")
|
||||||
out = _build_scene_out(session, scene, device_id=device_id)
|
return _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,7 +8,6 @@ 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
|
||||||
|
|
@ -59,7 +58,6 @@ __all__ = [
|
||||||
"FavoritePerformer",
|
"FavoritePerformer",
|
||||||
"FavoriteScene",
|
"FavoriteScene",
|
||||||
"FavoriteSite",
|
"FavoriteSite",
|
||||||
"HiddenSite",
|
|
||||||
"FavoriteStudio",
|
"FavoriteStudio",
|
||||||
"IngestRun",
|
"IngestRun",
|
||||||
"MergeCandidate",
|
"MergeCandidate",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -42,9 +42,6 @@ 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_ids: list = []
|
marked = 0
|
||||||
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,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"),
|
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_ids.append(cid)
|
marked += 1
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -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]
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 & 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,7 +333,6 @@ 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', {
|
||||||
|
|
@ -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> {
|
async listFavoriteMovies(): Promise<FavoriteMovieListOut> {
|
||||||
return this.request('/favorites/movies');
|
return this.request('/favorites/movies');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,29 +35,6 @@ 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,6 +248,7 @@ 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}>
|
||||||
|
|
@ -301,6 +302,16 @@ 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,10 +116,6 @@ 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;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -301,10 +297,7 @@ 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' },
|
||||||
// BEZ `orientation`: react-native-screens zamienia 'all' na FULL_SENSOR, ktory
|
orientation: 'all',
|
||||||
// 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,21 +51,6 @@ 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));
|
||||||
|
|
@ -94,8 +79,7 @@ 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). To hide a site,
|
the app. Add a tag below (e.g. a category you never want to see).
|
||||||
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>
|
||||||
|
|
@ -146,18 +130,6 @@ 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,7 +212,6 @@ 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,
|
||||||
|
|
@ -240,7 +239,6 @@ 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,
|
||||||
|
|
@ -292,7 +290,6 @@ 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,8 +62,6 @@ 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
|
||||||
|
|
@ -266,8 +264,6 @@ 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(
|
||||||
|
|
@ -521,7 +517,6 @@ 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,
|
||||||
|
|
@ -539,7 +534,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +605,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -628,8 +621,6 @@ 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]);
|
||||||
|
|
@ -651,7 +642,6 @@ 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]);
|
||||||
|
|
@ -668,7 +658,6 @@ 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]);
|
||||||
|
|
||||||
|
|
@ -1866,9 +1855,6 @@ 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);
|
||||||
|
|
@ -1982,7 +1968,6 @@ 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
|
||||||
|
|
@ -2113,9 +2098,8 @@ 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, webViewVia]);
|
}, [extractedUrl, nav, sceneId, durationSec, refererHost, params.origin, params.title, params.playbackId, entityKind]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch(
|
upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch(
|
||||||
|
|
@ -2284,7 +2268,6 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
durationSec,
|
durationSec,
|
||||||
refererHost,
|
refererHost,
|
||||||
mode: 'video',
|
mode: 'video',
|
||||||
via: webViewVia,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -566,8 +566,6 @@ 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
|
||||||
|
|
@ -618,7 +616,6 @@ 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 {
|
||||||
|
|
@ -660,7 +657,6 @@ 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
|
||||||
|
|
@ -715,11 +711,8 @@ 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, undefined, 'phone');
|
if (pick) await openAsVideo(pick, source.page_url);
|
||||||
else {
|
else setQualityLinks(links);
|
||||||
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
|
||||||
|
|
@ -799,10 +792,9 @@ function PlaybackButton({
|
||||||
const autoPick = pickAuto(directLinks);
|
const autoPick = pickAuto(directLinks);
|
||||||
if (autoPick) {
|
if (autoPick) {
|
||||||
markStarted();
|
markStarted();
|
||||||
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks, 'backend');
|
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks);
|
||||||
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
|
||||||
|
|
@ -863,7 +855,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, qualityVia);
|
await openAsVideo(link, undefined, qualityLinks || undefined);
|
||||||
} else if (link.embed_url) {
|
} else if (link.embed_url) {
|
||||||
let refererHost: string | undefined;
|
let refererHost: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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,
|
||||||
|
|
@ -88,42 +87,11 @@ 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 ?? []).filter((s) => !hidden.has(s.origin));
|
const all = data?.items ?? [];
|
||||||
const filtered = debouncedQ
|
const filtered = debouncedQ
|
||||||
? all.filter(
|
? all.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
|
|
@ -150,7 +118,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, hidden]);
|
}, [data?.items, debouncedQ, order, pinned]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|
@ -173,15 +141,8 @@ export function SitesScreen() {
|
||||||
}}
|
}}
|
||||||
onClose={() => setSortOpen(false)}
|
onClose={() => setSortOpen(false)}
|
||||||
/>
|
/>
|
||||||
{favQuery.data && pinned.size === 0 && hidden.size === 0 ? (
|
{favQuery.data && pinned.size === 0 ? (
|
||||||
<Text style={styles.hint}>long-press a site to pin it or hide it</Text>
|
<Text style={styles.hint}>long-press a site to pin it to the top</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 }} />}
|
||||||
|
|
@ -206,7 +167,7 @@ export function SitesScreen() {
|
||||||
name: prettySiteName(item.display_name),
|
name: prettySiteName(item.display_name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onLongPress={() => openMenu(item)}
|
onLongPress={() => togglePin.mutate(item.origin)}
|
||||||
onShowRating={() => setDetail(item)}
|
onShowRating={() => setDetail(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -410,8 +410,6 @@ 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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -42,8 +42,9 @@ 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).
|
||||||
# Od 2026-10-06 kanaly 1.0-1.3 (wersje sprzed 0.4.x) maja paczke z ekranem blokady
|
# 1.1 wciaz ZYJE: siedza tam instalacje sprzed 0.2.2, ktore nie moga sie zaktualizowac in-place
|
||||||
# (--entry update-required.tsx). Nie publikuj tam zwyklej aplikacji, to zdejmuje blokade.
|
# (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.
|
# 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")
|
||||||
|
|
@ -140,39 +141,15 @@ def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
|
ap.add_argument("--runtime", default=RUNTIME_DEFAULT)
|
||||||
ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/")
|
ap.add_argument("--skip-export", action="store_true", help="reuse existing dist/")
|
||||||
ap.add_argument(
|
|
||||||
"--entry",
|
|
||||||
help="inny punkt wejscia niz App.tsx, np. update-required.tsx (ekran blokady starych wersji)",
|
|
||||||
)
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
# Ekran blokady na biezacym kanale zablokowalby wszystkich uzytkownikow.
|
|
||||||
if args.entry and args.runtime == RUNTIME_DEFAULT:
|
|
||||||
print(f"ERROR: --entry {args.entry} na biezacym runtime {RUNTIME_DEFAULT} zablokowalby wszystkich. Przerwane.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not args.skip_export:
|
if not args.skip_export:
|
||||||
if DIST.exists():
|
if DIST.exists():
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(DIST)
|
shutil.rmtree(DIST)
|
||||||
# `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS
|
# `--source-maps` dokłada `.hbc.map`, potrzebną do odczytania śladów JS
|
||||||
# w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`.
|
# w Sentry. Mapa NIE jedzie na serwer, kasuje ją `strip_sourcemaps()`.
|
||||||
# `--entry`: expo export bierze punkt wejscia z pola `main` w package.json, wiec
|
run(["npx", "expo", "export", "--platform", "android", "--source-maps"], cwd=MOBILE)
|
||||||
# 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