diff --git a/app/main.py b/app/main.py index 057e321..d854112 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,5 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI, Request from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.utils import get_openapi @@ -5,16 +7,55 @@ from fastapi.openapi.utils import get_openapi # from fastapi.staticfiles import StaticFiles # from fastapi.templating import Jinja2Templates +from .routers_v2.players import get_browser, shutdown_browser from .routers_v2 import ( - current, awards, teams, rarity, cardsets, players, packtypes, packs, cards, events, results, rewards, decisions, - batstats, pitstats, notifications, paperdex, gamerewards, gauntletrewards, gauntletruns, battingcards, - battingcardratings, pitchingcards, pitchingcardratings, cardpositions, scouting, mlbplayers, stratgame, stratplays) + current, + awards, + teams, + rarity, + cardsets, + players, + packtypes, + packs, + cards, + events, + results, + rewards, + decisions, + batstats, + pitstats, + notifications, + paperdex, + gamerewards, + gauntletrewards, + gauntletruns, + battingcards, + battingcardratings, + pitchingcards, + pitchingcardratings, + cardpositions, + scouting, + mlbplayers, + stratgame, + stratplays, +) + + +@asynccontextmanager +async def lifespan(app): + # Startup: warm up the persistent Chromium browser + await get_browser() + yield + # Shutdown: clean up browser and playwright + await shutdown_browser() + app = FastAPI( # root_path='/api', - responses={404: {'description': 'Not found'}}, - docs_url='/api/docs', - redoc_url='/api/redoc' + lifespan=lifespan, + responses={404: {"description": "Not found"}}, + docs_url="/api/docs", + redoc_url="/api/redoc", ) # app.mount("/static", StaticFiles(directory="storage/static"), name="static") @@ -54,9 +95,11 @@ app.include_router(decisions.router) @app.get("/api/docs", include_in_schema=False) async def get_docs(req: Request): print(req.scope) - return get_swagger_ui_html(openapi_url=req.scope.get('root_path')+'/openapi.json', title='Swagger') + return get_swagger_ui_html( + openapi_url=req.scope.get("root_path") + "/openapi.json", title="Swagger" + ) @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 37f20d6..d4d9305 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -1,17 +1,15 @@ import datetime import os.path -import base64 import pandas as pd from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query from fastapi.responses import FileResponse from fastapi.templating import Jinja2Templates -from html2image import Html2Image from typing import Optional, List, Literal import logging import pydantic from pandas import DataFrame -from playwright.async_api import async_playwright +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 ( @@ -19,7 +17,6 @@ from ..db_engine import ( Player, model_to_dict, fn, - chunked, Paperdex, Cardset, Rarity, @@ -33,6 +30,57 @@ from ..db_engine import ( from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token, LOG_DATA +# --------------------------------------------------------------------------- +# Persistent browser instance (WP-02) +# --------------------------------------------------------------------------- + +_browser: Browser | None = None +_playwright: Playwright | None = None + + +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. + """ + global _browser, _playwright + 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: + try: + await _browser.close() + except Exception: + pass + _browser = None + 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 = { @@ -153,7 +201,7 @@ async def get_players( all_players = Player.select() if all_players.count() == 0: db.close() - 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()) @@ -692,7 +740,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 ( @@ -819,16 +867,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', diff --git a/storage/templates/player_card.html b/storage/templates/player_card.html index 83750ed..9cdf814 100644 --- a/storage/templates/player_card.html +++ b/storage/templates/player_card.html @@ -2,9 +2,6 @@
{% include 'style.html' %} - - -