F1 circuit layouts with year-by-year SVGs — manually traced track variations
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

205 line
8.1KB

  1. from fastapi import FastAPI, HTTPException, Response
  2. from fastapi.responses import FileResponse, Response as FastAPIResponse
  3. from fastapi.middleware.cors import CORSMiddleware
  4. from pathlib import Path
  5. import logging
  6. # Import our CircuitService
  7. from circuits_service import CircuitService, Format
  8. from ergast_service import ErgastService
  9. from posters_service import PostersService
  10. from models.track_layout import TrackLayout
  11. # Initialize FastAPI app
  12. app = FastAPI(
  13. title="F1 Track Layouts API",
  14. description="API serving F1 track layout images, vectors and GeoJSON files",
  15. version="1.0.0"
  16. )
  17. # Add CORS middleware
  18. app.add_middleware(
  19. CORSMiddleware,
  20. allow_origins=["*"],
  21. allow_credentials=True,
  22. allow_methods=["*"],
  23. allow_headers=["*"],
  24. )
  25. # Configure logging
  26. logging.basicConfig(level=logging.INFO)
  27. logger = logging.getLogger(__name__)
  28. # Initialize CircuitService
  29. circuit_service = CircuitService()
  30. ergast_service = ErgastService(circuit_service)
  31. posters_service = PostersService(circuit_service, ergast_service)
  32. def get_content_disposition(format: Format) -> str:
  33. if format == Format.PNG:
  34. return "inline"
  35. # SVG and GeoJSON files as attachments for security/convention
  36. return "attachment"
  37. def get_media_type(format: Format) -> str:
  38. media_types = {
  39. Format.PNG: "image/png",
  40. Format.SVG: "image/svg+xml",
  41. Format.GEOJSON: "application/geo+json"
  42. }
  43. return media_types.get(format, "application/octet-stream") # fallback for unknown formats
  44. @app.get("/")
  45. async def root():
  46. """Root endpoint returning API information"""
  47. return {
  48. "name": "F1 Track Layouts API",
  49. "version": "1.0.0",
  50. "endpoints": [
  51. "/circuits",
  52. "/circuits/{country_slug}",
  53. "/circuits/{country_slug}/{city_slug}",
  54. "/circuits/{country_slug}/{city_slug}/{circuit_slug}",
  55. "/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}",
  56. "/tracks/{country_slug}/{city_slug}/{circuit_slug}/year/{year}/{format}",
  57. "/tracks/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}/{format}",
  58. "/grand-prix/{grand_prix_name}/year/{year}",
  59. "/posters/race/{grand_prix_name}/{season}.png",
  60. "/posters/season/{season}.png",
  61. ]
  62. }
  63. @app.get("/posters/race/{grand_prix_name}/{season}.png")
  64. async def get_race_poster(grand_prix_name: str, season: int):
  65. """Plex-ready 1000x1500 race poster (PNG). Cached on disk."""
  66. png = posters_service.render_race_poster(grand_prix_name, season)
  67. if not png:
  68. raise HTTPException(status_code=404, detail=f"No poster available for {grand_prix_name} {season}")
  69. return FastAPIResponse(
  70. content=png,
  71. media_type="image/png",
  72. headers={"Cache-Control": "public, max-age=31536000"},
  73. )
  74. @app.get("/posters/season/{season}.png")
  75. async def get_season_poster(season: int):
  76. """Plex-ready 1000x1500 season poster (PNG). Cached on disk."""
  77. png = posters_service.render_season_poster(season)
  78. return FastAPIResponse(
  79. content=png,
  80. media_type="image/png",
  81. headers={"Cache-Control": "public, max-age=31536000"},
  82. )
  83. @app.get("/circuits")
  84. async def get_countries():
  85. """Get list of all available countries with their slugs"""
  86. return {
  87. "countries": [c.to_dict() for c in circuit_service.get_countries_list()]
  88. }
  89. @app.get("/circuits/{country_slug}")
  90. async def get_cities(country_slug: str):
  91. """Get list of cities for a specific country"""
  92. cities = circuit_service.get_localities_list(country_slug)
  93. if not cities:
  94. raise HTTPException(status_code=404, detail="Country not found")
  95. return {"cities": [c.to_dict() for c in cities]}
  96. @app.get("/circuits/{country_slug}/{city_slug}")
  97. async def get_circuits(country_slug: str, city_slug: str):
  98. """Get list of circuits for a specific city"""
  99. circuits = circuit_service.get_circuits_list(country_slug, city_slug)
  100. if not circuits:
  101. raise HTTPException(status_code=404, detail="City not found")
  102. return {"circuits": [c.to_dict() for c in circuits]}
  103. @app.get("/circuits/{country_slug}/{city_slug}/{circuit_slug}")
  104. async def get_circuit_details(country_slug: str, city_slug: str, circuit_slug: str):
  105. """Get circuit details and available formats"""
  106. circuit_details = circuit_service.get_circuit_details(country_slug, city_slug, circuit_slug)
  107. if not circuit_details:
  108. raise HTTPException(status_code=404, detail="Circuit not found")
  109. return circuit_details.to_dict()
  110. @app.get("/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}")
  111. async def get_layout_details(country_slug: str, city_slug: str, circuit_slug: str, layout_slug: str):
  112. """Get details for a specific layout"""
  113. layout_details = circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug)
  114. if not layout_details:
  115. raise HTTPException(status_code=404, detail="Layout not found")
  116. return layout_details.to_dict()
  117. @app.get("/tracks/{country_slug}/{city_slug}/{circuit_slug}/year/{year}/{image_format}")
  118. async def get_track_by_year(
  119. country_slug: str,
  120. city_slug: str,
  121. circuit_slug: str,
  122. year: int,
  123. image_format: Format,
  124. response: Response
  125. ):
  126. """Get track layout for a specific year"""
  127. circuit_details = circuit_service.get_circuit_details(country_slug, city_slug, circuit_slug)
  128. if not circuit_details:
  129. raise HTTPException(status_code=404, detail="Circuit not found")
  130. try:
  131. layout_slug = circuit_service.find_layout_slug_for_year(circuit_details, year)
  132. return get_file_response(circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug), image_format, response)
  133. except KeyError as e:
  134. logger.error(f"KeyError in get_track_by_year: {str(e)}")
  135. raise HTTPException(status_code=404, detail="Circuit or layout not found")
  136. @app.get("/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}/{image_format}")
  137. async def get_track_by_layout(
  138. country_slug: str,
  139. city_slug: str,
  140. circuit_slug: str,
  141. layout_slug: str,
  142. image_format: Format,
  143. response: Response
  144. ):
  145. """Get specific track layout"""
  146. return get_file_response(circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug), image_format, response)
  147. @app.get("/grand-prix/{grand_prix_name}/{season}/{image_format}")
  148. async def get_grand_prix_circuit(grand_prix_name: str, season: int, image_format: Format, response: Response):
  149. layout = circuit_service.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  150. if not layout:
  151. raise HTTPException(status_code=404, detail="Circuit not found")
  152. try:
  153. return get_file_response(layout, image_format, response)
  154. except KeyError as e:
  155. logger.error(f"KeyError in get_track_by_year: {str(e)}")
  156. raise HTTPException(status_code=404, detail="Circuit or layout not found")
  157. def get_file_response(layout: TrackLayout, image_format: Format, response: Response) -> FileResponse:
  158. # layout = circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug)
  159. file_path = Path(f"./circuits").joinpath(layout.relative_svg_filepath)
  160. if not file_path or not file_path.exists():
  161. logger.error(f"File not found: {file_path}")
  162. logger.info(f"Looking for {layout.slug} in format {image_format}")
  163. raise HTTPException(status_code=404, detail=f"File not found in {image_format} format")
  164. # Get actual filename from the path for the Content-Disposition header
  165. filename = file_path.name
  166. logger.info(f"Serving file: {file_path}")
  167. return FileResponse(
  168. file_path,
  169. media_type=get_media_type(image_format),
  170. content_disposition_type=get_content_disposition(image_format),
  171. filename=filename,
  172. headers={
  173. "Cache-Control": "public, max-age=31536000",
  174. "ETag": f"{layout.circuit.locality.country.slug}/{layout.circuit.locality.slug}/{layout.circuit.slug}-{layout.slug}-{image_format}"
  175. }
  176. )
  177. if __name__ == "__main__":
  178. import uvicorn
  179. uvicorn.run(app, host="0.0.0.0", port=8000)