Latka z 0.4.1 kopiowala mape sesji przed zwolnieniem playerow, ale sama kopia tez iteruje mape, a wpisy znikaja z innego watku. Na LinkedHashMap wywalalo sie wiec dalej, juz w toList() (Sentry GOON-6W, Redmi Note 8 Pro, Android 11). Mapa jest teraz ConcurrentHashMap, iteracja przezyje rownolegle usuniecie. versionCode 16 zeby posiadacze 0.4.0/0.4.1 dostali monit: runtime zostaje 1.4, wiec sam numer kanalu ich nie rozrozni. Sprawdzone na emulatorze: dwie sesje mediow zywe, zamkniecie z listy ostatnich zwalnia obie, pusty bufor crashy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
6.3 KiB
Python
150 lines
6.3 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.clips import router as clips_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(clips_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 | int | 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"
|
|
# `runtime_version` i `apk_build` to jedyne numery, po których aplikacja pozna, JAKIE
|
|
# APK ma zainstalowane. `version` z app.json przyjeżdża do niej razem z paczką OTA,
|
|
# więc po aktualizacji stary APK raportuje nową wersję i sam siebie uznaje za aktualny.
|
|
#
|
|
# `apk_build` = `versionCode` z android/app/build.gradle. Runtime sam nie wystarcza:
|
|
# 0.4.0 i 0.4.1 mają ten sam runtime 1.4 (0.4.1 to poprawki natywne bez zmiany kanału
|
|
# OTA), więc przez dobę nikt z 0.4.0 nie dostał monitu, mimo że 0.4.1 naprawia crash
|
|
# expo-video przy zamykaniu z ostatnich (Sentry 147164135, zgłoszenie 2026-09-15).
|
|
# Podbijaj RAZEM z `version` przy każdym wydaniu APK.
|
|
return {
|
|
"version": "0.4.2",
|
|
"runtime_version": "1.4",
|
|
"apk_build": 16,
|
|
"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)}
|