goon/app/main.py
goon-foss bdac60f022 feat(mobile): Chromecast (0.3.0, runtime 1.3)
Zgloszenie de93ba42 ("Can't stream to Chrome cast"). Cast to natywny SDK, wiec NIE da
sie go wyslac przez OTA: to nowe APK, ktore ludzie musza pobrac sami. Klucz podpisujacy
ten sam, wiec ALLOWED_APP_SIG_HASH bez zmian i aktualizacja wchodzi na wierzch starej.

Argument, ktory przewazyl: od czerwca release ma wlaczone FLAG_SECURE, wiec zwykle
mirrorowanie ekranu na telewizor jest zablokowane. Cast jest jedyna droga na duzy ekran.

Zasieg: 77% udanych odtworzen konczy sie bezposrednim adresem, plus czesc hosterow
filmowych oddajacych gotowy plik. Okolo 13% (porndoe, mypornerleak, superporn) idzie
przez WebView i castowac sie NIE DA, tam przycisku nie ma.

Wpiete recznie, bez `expo prebuild`, bo katalog android/ jest w repo i ma reczne zmiany,
ktore prebuild by przepisal. Cztery edycje, dokladnie to co robi config plugin:
manifest (OPTIONS_PROVIDER + expanded controller), MainActivity, castFrameworkVersion
w rootProject.ext oraz JAWNA zaleznosc play-services-cast-framework w app/build.gradle.
Ta ostatnia jest konieczna, bo biblioteka deklaruje ja jako `implementation`, co nie
jest przechodnie na sciezke kompilacji, i bez niej build pada na compileReleaseKotlin.

Identyfikator odbiornika CELOWO zaden: bez wpisu biblioteka bierze domyslny odbiornik
Google. Rejestracja wlasnego wiazalaby konto Google z Goonem, a przy tresci dla
doroslych latwo o cofniecie.

Urzadzenia bez Google Play Services zabezpieczone w DWOCH miejscach, bo mamy uzytkownika
na LineageOS (GOON-3B) i sideload na zegarku (GOON-51): `getSharedInstance` w try/catch
po stronie natywnej, a po stronie JS cala obsluga w osobnym `CastControl`, montowanym
dopiero gdy `useCastAvailable` potwierdzi Play Services. To nie jest ostroznosc na
wyrost: hooki biblioteki synchronicznie subskrybuja natywne zdarzenia i bez GMS wywracaja
ekran, a hooka nie da sie wywolac warunkowo.

Chromecast pobiera plik SAM i nie wysyla naszych naglowkow, wiec zrodla wymagajace
Referera ida przez /proxy (dokłada go po stronie serwera), a reszta bezposrednio, zeby
nie obciazac VPS. Do tego proxy przestaje przekazywac CORS hostera: mixdrop oddawal
`Access-Control-Allow-Origin: https://mixdrop.my`, co odbiornik odrzucal przy HLS.

Przy okazji GOON-3B: doszedl brakujacy wpis `com.google.android.gms.version`, ktory
wywracal apke na LineageOS, gdy Chromium probowal podpiac skaner kodow.

Zweryfikowane na emulatorze na podpisanym APK: certyfikat identyczny ze starym
(3294aba5...), instalacja NA WIERZCH 0.2.2 udana, wersja 13/0.3.0, runtime 1.3 w
manifescie, aplikacja wstaje bez crasha, ExoPlayer gra (MediaCodec dla video), a SDK
Casta startuje poprawnie: dynamite sie laduje i `MediaRouterProxy` czyta NASZ CastOptions.
Logika wyboru adresu przetestowana osobno na osmiu przypadkach.

CZEGO NIE ZWERYFIKOWALEM: realnego rzutu na telewizor. W sieci emulatora nie ma zadnego
Chromecasta, wiec `loadMedia` i odtwarzanie po stronie odbiornika sa niesprawdzone.

Build wymaga SENTRY_DISABLE_AUTO_UPLOAD=true, bo sentry.properties czeka na
SENTRY_ORG/SENTRY_PROJECT/SENTRY_AUTH_TOKEN ze srodowiska (Infisical, workspace
goon-app). Skutek: slady JS z 0.3.0 beda w Sentry nieczytelne do czasu przebudowy z tymi
zmiennymi.
2026-08-24 14:27:41 +02:00

134 lines
5.5 KiB
Python

import logging
import sentry_sdk
from fastapi import FastAPI
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
from sqlalchemy import text
from app.api.admin import router as admin_router
from app.api.admin_html import mount_static
from app.api.admin_html import router as admin_html_router
from app.api.blacklist import router as blacklist_router
from app.api.bug_reports import router as bug_reports_router
from app.api.expo_updates import router as expo_updates_router
from app.api.favorites import router as favorites_router
from app.api.me import router as me_router
from app.api.movies import router as movies_router
from app.api.playback import movies_router as movies_playback_router
from app.api.playback import router as playback_router
from app.api.playback_events import router as playback_events_router
from app.api.saved_searches import router as saved_searches_router
from app.api.scene_favorites import router as scene_favorites_router
from app.api.scenes import router as scenes_router
from app.api.seo import router as seo_router
from app.api.sources import router as sources_router
from app.api.stream_proxy import router as stream_proxy_router
from app.api.taxonomies import router as taxonomies_router
from app.api.watch import router as watch_router
from app.config import get_settings
from app.db import session_scope
_settings = get_settings()
logging.basicConfig(level=_settings.log_level)
# Sentry: init przed FastAPI() żeby SDK przechwycił request handlery od pierwszego.
# Pusty DSN → SDK no-op (dev/local). Integracje: FastApi (request context, transactions),
# Starlette (middleware-level errors), SQLAlchemy (slow queries jako spans).
if _settings.sentry_dsn:
def _sentry_before_send(event, hint):
"""Filter expected/transient HTTPException z proxy + playback resolve.
Sentry FastAPI integration loguje WSZYSTKIE HTTPException jako error.
Dla nas 502/503/504 to expected behavior gdy upstream CDN unreachable
albo extractor zwraca None (legitnie). Spam-flooduje issue list
(GOON-3 16ev/5h, GOON-G 8ev, GOON-4 6ev).
"""
exc_info = hint.get("exc_info") if hint else None
if not exc_info:
return event
exc = exc_info[1]
from fastapi import HTTPException as _HTTPExc
if isinstance(exc, _HTTPExc) and exc.status_code in {502, 503, 504}:
return None # drop
return event
sentry_sdk.init(
dsn=_settings.sentry_dsn,
environment=_settings.sentry_environment,
traces_sample_rate=_settings.sentry_traces_sample_rate,
# send_default_pii=False, bezpieczne, headers/cookies/IP nie idą do Sentry.
# Zostawiamy default (False) bo niektóre tube'y mają potencjalnie wrażliwe URL'e.
integrations=[
StarletteIntegration(transaction_style="endpoint"),
FastApiIntegration(transaction_style="endpoint"),
SqlalchemyIntegration(),
],
before_send=_sentry_before_send,
release="goon@0.1.8",
)
app = FastAPI(title="goon", version="0.1.8")
app.include_router(scenes_router)
app.include_router(sources_router)
app.include_router(movies_router)
app.include_router(playback_router)
app.include_router(playback_events_router)
app.include_router(movies_playback_router)
app.include_router(scene_favorites_router)
app.include_router(stream_proxy_router)
app.include_router(taxonomies_router)
app.include_router(favorites_router)
app.include_router(blacklist_router)
app.include_router(bug_reports_router)
app.include_router(saved_searches_router)
app.include_router(expo_updates_router)
app.include_router(watch_router)
app.include_router(me_router)
app.include_router(admin_router)
app.include_router(admin_html_router)
app.include_router(seo_router)
mount_static(app)
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/version")
def version() -> dict[str, str | None]:
"""Mobile sprawdza po starcie żeby wykryć dostępność nowszej wersji APK.
Zwraca:
- `version`: kanoniczna wersja serwera (źródło prawdy o latest APK)
- `apk_url`: bezpośredni link do najnowszego APK (self-hosted) lub None
gdy tylko external (GitHub Releases). Mobile otwiera w browser przy update.
APK jest serwowany ze statycznego endpointu `/static/app-debug.apk` jeśli
istnieje (gradle build → scp na VPS → tu serwujemy). Brak pliku → `apk_url=None`,
mobile pokazuje tylko "newer version available" bez direct link.
"""
import os
from pathlib import Path
apk_path = Path(__file__).resolve().parent / "static" / "app-release.apk"
apk_url: str | None = None
if apk_path.exists():
# `BACKEND_PUBLIC_URL` z env to URL pod którym mobile może hit'nąć backend
# (production: https://goon.example.com lub IP:port). Default, relatywny URL,
# mobile sklei z baseUrl.
public_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
apk_url = f"{public_url}/static/app-release.apk" if public_url else "/static/app-release.apk"
return {"version": "0.3.0", "apk_url": apk_url}
@app.get("/readyz")
def readyz() -> dict[str, object]:
try:
with session_scope() as session:
session.execute(text("SELECT 1"))
return {"status": "ready", "db": "ok"}
except Exception as exc:
return {"status": "degraded", "db": "error", "error": str(exc)}