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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-13 01:35:14 -05:00
parent a66ef9bd7c
commit 36f0311766
2 changed files with 43 additions and 9 deletions

View File

@ -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,
@ -51,8 +52,16 @@ from .routers_v2 import (
scout_claims,
)
@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",
@ -114,4 +123,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)

View File

@ -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 = {
@ -144,7 +168,7 @@ async def get_players(
):
all_players = Player.select()
if all_players.count() == 0:
raise HTTPException(status_code=404, detail=f"There are no players to filter")
raise HTTPException(status_code=404, detail="There are no players to filter")
if name is not None:
all_players = all_players.where(fn.Lower(Player.p_name) == name.lower())
@ -674,7 +698,7 @@ async def get_batter_card(
)
headers = {"Cache-Control": "public, max-age=86400"}
filename = (
_filename = (
f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}"
)
if (
@ -799,16 +823,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',