Merge main into next-release
All checks were successful
Build Docker Image / build (push) Successful in 8m3s
Build Docker Image / build (pull_request) Successful in 7m55s

Resolve conflicts in app/main.py and app/routers_v2/players.py:
keep main's render pipeline optimization (Phase 0) with asyncio.Lock,
error-tolerant shutdown, and --no-sandbox launch args. The next-release
browser code was an earlier version of the same feature.

Add evolution router import and inclusion from next-release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-17 13:53:46 -05:00
commit 419fb757df
4 changed files with 85 additions and 42 deletions

View File

@ -17,8 +17,9 @@ logging.basicConfig(
# from fastapi.staticfiles import StaticFiles
# from fastapi.templating import Jinja2Templates
from .db_engine import db # noqa: E402
from .routers_v2 import ( # noqa: E402
from .db_engine import db
from .routers_v2.players import get_browser, shutdown_browser
from .routers_v2 import (
current,
awards,
teams,
@ -55,9 +56,12 @@ from .routers_v2 import ( # noqa: E402
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app):
# Startup: warm up the persistent Chromium browser
await get_browser()
yield
await players.shutdown_browser()
# Shutdown: clean up browser and playwright
await shutdown_browser()
app = FastAPI(

View File

@ -9,7 +9,9 @@ from typing import Optional, List, Literal
import logging
import pydantic
from pandas import DataFrame
from playwright.async_api import async_playwright
import asyncio as _asyncio
from playwright.async_api import async_playwright, Browser, Playwright
from ..card_creation import get_batter_card_data, get_pitcher_card_data
from ..db_engine import (
@ -31,30 +33,62 @@ from ..db_engine import (
from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token
_browser = None
_playwright = None
# ---------------------------------------------------------------------------
# Persistent browser instance (WP-02)
# ---------------------------------------------------------------------------
_browser: Browser | None = None
_playwright: Playwright | None = None
_browser_lock = _asyncio.Lock()
async def get_browser():
async def get_browser() -> Browser:
"""Get or create persistent Chromium browser instance.
Reuses a single browser across all card renders, eliminating the ~1-1.5s
per-request launch/teardown overhead. Automatically reconnects if the
browser process has died.
Uses an asyncio.Lock to prevent concurrent requests from racing to
launch multiple Chromium processes.
"""
global _browser, _playwright
if _browser is not None and _browser.is_connected():
return _browser
if _playwright is None:
_playwright = await async_playwright().start()
_browser = await _playwright.chromium.launch()
async with _browser_lock:
if _browser is None or not _browser.is_connected():
if _playwright is not None:
try:
await _playwright.stop()
except Exception:
pass
_playwright = await async_playwright().start()
_browser = await _playwright.chromium.launch(
args=["--no-sandbox", "--disable-dev-shm-usage"]
)
return _browser
async def shutdown_browser():
"""Clean shutdown of the persistent browser.
Called by the FastAPI lifespan handler on application exit so the
Chromium process is not left orphaned.
"""
global _browser, _playwright
if _browser is not None:
await _browser.close()
if _browser:
try:
await _browser.close()
except Exception:
pass
_browser = None
if _playwright is not None:
await _playwright.stop()
if _playwright:
try:
await _playwright.stop()
except Exception:
pass
_playwright = None
# ---------------------------------------------------------------------------
# Franchise normalization: Convert city+team names to city-agnostic team names
# This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics')
FRANCHISE_NORMALIZE = {
@ -318,21 +352,16 @@ async def get_players(
else:
return_val = {"count": len(final_players), "players": []}
dex_by_player = {}
if inc_dex:
player_ids = [p.player_id for p in final_players]
if player_ids:
for row in Paperdex.select().where(Paperdex.player_id << player_ids):
dex_by_player.setdefault(row.player_id, []).append(row)
for x in final_players:
this_record = model_to_dict(x, recurse=not (flat or short_output))
if inc_dex:
entries = dex_by_player.get(x.player_id, [])
this_record["paperdex"] = {
"count": len(entries),
"paperdex": [model_to_dict(y, recurse=False) for y in entries],
}
this_dex = Paperdex.select().where(Paperdex.player == x)
this_record["paperdex"] = {"count": this_dex.count(), "paperdex": []}
for y in this_dex:
this_record["paperdex"]["paperdex"].append(
model_to_dict(y, recurse=False)
)
if inc_keys and (flat or short_output):
if this_record["mlbplayer"] is not None:
@ -500,21 +529,16 @@ async def get_random_player(
return Response(content=return_val, media_type="text/csv")
else:
final_players = list(final_players)
return_val = {"count": len(final_players), "players": []}
player_ids = [p.player_id for p in final_players]
dex_by_player = {}
if player_ids:
for row in Paperdex.select().where(Paperdex.player_id << player_ids):
dex_by_player.setdefault(row.player_id, []).append(row)
for x in final_players:
this_record = model_to_dict(x)
entries = dex_by_player.get(x.player_id, [])
this_record["paperdex"] = {
"count": len(entries),
"paperdex": [model_to_dict(y, recurse=False) for y in entries],
}
this_dex = Paperdex.select().where(Paperdex.player == x)
this_record["paperdex"] = {"count": this_dex.count(), "paperdex": []}
for y in this_dex:
this_record["paperdex"]["paperdex"].append(
model_to_dict(y, recurse=False)
)
return_val["players"].append(this_record)
# return_val['players'].append(model_to_dict(x))
@ -708,6 +732,9 @@ async def get_batter_card(
)
headers = {"Cache-Control": "public, max-age=86400"}
_filename = (
f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}"
)
if (
os.path.isfile(
f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png"

View File

@ -2,9 +2,6 @@
<html lang="en">
<head>
{% include 'style.html' %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;700&family=Source+Sans+3:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="fullCard" style="width: 1200px; height: 600px;">

File diff suppressed because one or more lines are too long