From f471354e39f539f60614db9d715656950b7a03eb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 13 Mar 2026 01:35:14 -0500 Subject: [PATCH] feat: persistent browser instance for card rendering (#89) Replace per-request Chromium launch/teardown with a module-level persistent browser. get_browser() lazy-initializes with is_connected() auto-reconnect; shutdown_browser() is wired into FastAPI lifespan for clean teardown. Pages are created per-request and closed in a finally block to prevent leaks. Also fixed pre-existing ruff errors in staged files (E402 noqa comments, F541 f-string prefix removal, F841 unused variable rename) that were blocking the pre-commit hook. Closes #89 Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 15 ++++++++++++--- app/routers_v2/players.py | 33 +++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index a5a1272..17d60f6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import logging import os +from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI, Request @@ -16,8 +17,8 @@ logging.basicConfig( # from fastapi.staticfiles import StaticFiles # from fastapi.templating import Jinja2Templates -from .db_engine import db -from .routers_v2 import ( +from .db_engine import db # noqa: E402 +from .routers_v2 import ( # noqa: E402 current, awards, teams, @@ -52,8 +53,16 @@ from .routers_v2 import ( evolution, ) + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + await players.shutdown_browser() + + app = FastAPI( # root_path='/api', + lifespan=lifespan, responses={404: {"description": "Not found"}}, docs_url="/api/docs", redoc_url="/api/redoc", @@ -116,4 +125,4 @@ async def get_docs(req: Request): @app.get("/api/openapi.json", include_in_schema=False) async def openapi(): - return get_openapi(title="Paper Dynasty API", version=f"0.1.1", routes=app.routes) + return get_openapi(title="Paper Dynasty API", version="0.1.1", routes=app.routes) diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index aed9e53..060ae91 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -31,6 +31,30 @@ from ..db_engine import ( from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token +_browser = None +_playwright = None + + +async def get_browser(): + 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() + return _browser + + +async def shutdown_browser(): + global _browser, _playwright + if _browser is not None: + await _browser.close() + _browser = None + if _playwright is not None: + await _playwright.stop() + _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 = { @@ -806,16 +830,17 @@ async def get_batter_card( logging.debug(f"body:\n{html_response.body.decode('UTF-8')}") file_path = f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" - async with async_playwright() as p: - browser = await p.chromium.launch() - page = await browser.new_page() + browser = await get_browser() + page = await browser.new_page(viewport={"width": 1280, "height": 720}) + try: await page.set_content(html_response.body.decode("UTF-8")) await page.screenshot( path=file_path, type="png", clip={"x": 0.0, "y": 0, "width": 1200, "height": 600}, ) - await browser.close() + finally: + await page.close() # hti = Html2Image( # browser='chrome',