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

View File

@ -9,7 +9,9 @@ from typing import Optional, List, Literal
import logging import logging
import pydantic import pydantic
from pandas import DataFrame 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 ..card_creation import get_batter_card_data, get_pitcher_card_data
from ..db_engine import ( from ..db_engine import (
@ -31,30 +33,62 @@ from ..db_engine import (
from ..db_helpers import upsert_players from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token 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 global _browser, _playwright
if _browser is not None and _browser.is_connected(): async with _browser_lock:
return _browser if _browser is None or not _browser.is_connected():
if _playwright is None: if _playwright is not None:
_playwright = await async_playwright().start() try:
_browser = await _playwright.chromium.launch() 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 return _browser
async def shutdown_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 global _browser, _playwright
if _browser is not None: if _browser:
await _browser.close() try:
await _browser.close()
except Exception:
pass
_browser = None _browser = None
if _playwright is not None: if _playwright:
await _playwright.stop() try:
await _playwright.stop()
except Exception:
pass
_playwright = None _playwright = None
# ---------------------------------------------------------------------------
# Franchise normalization: Convert city+team names to city-agnostic team names # Franchise normalization: Convert city+team names to city-agnostic team names
# This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics') # This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics')
FRANCHISE_NORMALIZE = { FRANCHISE_NORMALIZE = {
@ -318,21 +352,16 @@ async def get_players(
else: else:
return_val = {"count": len(final_players), "players": []} 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: for x in final_players:
this_record = model_to_dict(x, recurse=not (flat or short_output)) this_record = model_to_dict(x, recurse=not (flat or short_output))
if inc_dex: if inc_dex:
entries = dex_by_player.get(x.player_id, []) this_dex = Paperdex.select().where(Paperdex.player == x)
this_record["paperdex"] = { this_record["paperdex"] = {"count": this_dex.count(), "paperdex": []}
"count": len(entries), for y in this_dex:
"paperdex": [model_to_dict(y, recurse=False) for y in entries], this_record["paperdex"]["paperdex"].append(
} model_to_dict(y, recurse=False)
)
if inc_keys and (flat or short_output): if inc_keys and (flat or short_output):
if this_record["mlbplayer"] is not None: 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") return Response(content=return_val, media_type="text/csv")
else: else:
final_players = list(final_players)
return_val = {"count": len(final_players), "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: for x in final_players:
this_record = model_to_dict(x) this_record = model_to_dict(x)
entries = dex_by_player.get(x.player_id, []) this_dex = Paperdex.select().where(Paperdex.player == x)
this_record["paperdex"] = { this_record["paperdex"] = {"count": this_dex.count(), "paperdex": []}
"count": len(entries), for y in this_dex:
"paperdex": [model_to_dict(y, recurse=False) for y in entries], this_record["paperdex"]["paperdex"].append(
} model_to_dict(y, recurse=False)
)
return_val["players"].append(this_record) return_val["players"].append(this_record)
# return_val['players'].append(model_to_dict(x)) # return_val['players'].append(model_to_dict(x))
@ -708,6 +732,9 @@ async def get_batter_card(
) )
headers = {"Cache-Control": "public, max-age=86400"} headers = {"Cache-Control": "public, max-age=86400"}
_filename = (
f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}"
)
if ( if (
os.path.isfile( os.path.isfile(
f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" 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"> <html lang="en">
<head> <head>
{% include 'style.html' %} {% 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> </head>
<body> <body>
<div id="fullCard" style="width: 1200px; height: 600px;"> <div id="fullCard" style="width: 1200px; height: 600px;">

File diff suppressed because one or more lines are too long