F1 circuit layouts with year-by-year SVGs — manually traced track variations
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

233 líneas
9.4KB

  1. from fastapi import FastAPI, HTTPException, Response
  2. from fastapi.responses import FileResponse, Response as FastAPIResponse, PlainTextResponse
  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, BackgroundStyle
  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, bg: BackgroundStyle = BackgroundStyle.AERIAL):
  65. """Plex-ready 1000x1500 race poster (PNG). Cached on disk per (race, bg).
  66. `?bg=` selects the backdrop:
  67. - aerial (default) — Esri World Imagery satellite at the circuit's coords
  68. - osm — OpenStreetMap Carto street-map at the circuit's coords
  69. - none — plain black background (skip tile fetch)
  70. """
  71. png = posters_service.render_race_poster(grand_prix_name, season, bg=bg)
  72. if not png:
  73. raise HTTPException(status_code=404, detail=f"No poster available for {grand_prix_name} {season}")
  74. return FastAPIResponse(
  75. content=png,
  76. media_type="image/png",
  77. headers={"Cache-Control": "public, max-age=31536000"},
  78. )
  79. @app.get("/posters/season/{season}.png")
  80. async def get_season_poster(season: int):
  81. """Plex-ready 1000x1500 season poster (PNG). Cached on disk."""
  82. png = posters_service.render_season_poster(season)
  83. return FastAPIResponse(
  84. content=png,
  85. media_type="image/png",
  86. headers={"Cache-Control": "public, max-age=31536000"},
  87. )
  88. @app.get("/kometa/formula-1-metadata.yml")
  89. async def get_kometa_metadata():
  90. """Generate a Kometa-compatible metadata YAML for the Formula 1 library.
  91. One entry per F1 season from 1950..current+1. Each entry:
  92. - uses Kometa's f1_season builder for round/episode names
  93. - sets url_poster on the show (per-season hero from files-api)
  94. - sets url_poster on each Plex 'season' (== each F1 round) from the
  95. f1-circuits race poster endpoint
  96. Kometa harmlessly skips shows / seasons that don't exist in the Plex
  97. library, so we can emit the full schedule and let Kometa do the matching.
  98. """
  99. yaml = posters_service.render_kometa_metadata()
  100. return PlainTextResponse(
  101. content=yaml,
  102. media_type="text/yaml; charset=utf-8",
  103. # YAML changes only when the Jolpica schedule changes — cache 1h.
  104. headers={"Cache-Control": "public, max-age=3600"},
  105. )
  106. @app.get("/circuits")
  107. async def get_countries():
  108. """Get list of all available countries with their slugs"""
  109. return {
  110. "countries": [c.to_dict() for c in circuit_service.get_countries_list()]
  111. }
  112. @app.get("/circuits/{country_slug}")
  113. async def get_cities(country_slug: str):
  114. """Get list of cities for a specific country"""
  115. cities = circuit_service.get_localities_list(country_slug)
  116. if not cities:
  117. raise HTTPException(status_code=404, detail="Country not found")
  118. return {"cities": [c.to_dict() for c in cities]}
  119. @app.get("/circuits/{country_slug}/{city_slug}")
  120. async def get_circuits(country_slug: str, city_slug: str):
  121. """Get list of circuits for a specific city"""
  122. circuits = circuit_service.get_circuits_list(country_slug, city_slug)
  123. if not circuits:
  124. raise HTTPException(status_code=404, detail="City not found")
  125. return {"circuits": [c.to_dict() for c in circuits]}
  126. @app.get("/circuits/{country_slug}/{city_slug}/{circuit_slug}")
  127. async def get_circuit_details(country_slug: str, city_slug: str, circuit_slug: str):
  128. """Get circuit details and available formats"""
  129. circuit_details = circuit_service.get_circuit_details(country_slug, city_slug, circuit_slug)
  130. if not circuit_details:
  131. raise HTTPException(status_code=404, detail="Circuit not found")
  132. return circuit_details.to_dict()
  133. @app.get("/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}")
  134. async def get_layout_details(country_slug: str, city_slug: str, circuit_slug: str, layout_slug: str):
  135. """Get details for a specific layout"""
  136. layout_details = circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug)
  137. if not layout_details:
  138. raise HTTPException(status_code=404, detail="Layout not found")
  139. return layout_details.to_dict()
  140. @app.get("/tracks/{country_slug}/{city_slug}/{circuit_slug}/year/{year}/{image_format}")
  141. async def get_track_by_year(
  142. country_slug: str,
  143. city_slug: str,
  144. circuit_slug: str,
  145. year: int,
  146. image_format: Format,
  147. response: Response
  148. ):
  149. """Get track layout for a specific year"""
  150. circuit_details = circuit_service.get_circuit_details(country_slug, city_slug, circuit_slug)
  151. if not circuit_details:
  152. raise HTTPException(status_code=404, detail="Circuit not found")
  153. try:
  154. layout_slug = circuit_service.find_layout_slug_for_year(circuit_details, year)
  155. return get_file_response(circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug), image_format, response)
  156. except KeyError as e:
  157. logger.error(f"KeyError in get_track_by_year: {str(e)}")
  158. raise HTTPException(status_code=404, detail="Circuit or layout not found")
  159. @app.get("/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}/{image_format}")
  160. async def get_track_by_layout(
  161. country_slug: str,
  162. city_slug: str,
  163. circuit_slug: str,
  164. layout_slug: str,
  165. image_format: Format,
  166. response: Response
  167. ):
  168. """Get specific track layout"""
  169. return get_file_response(circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug), image_format, response)
  170. @app.get("/grand-prix/{grand_prix_name}/{season}/{image_format}")
  171. async def get_grand_prix_circuit(grand_prix_name: str, season: int, image_format: Format, response: Response):
  172. layout = circuit_service.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  173. if not layout:
  174. raise HTTPException(status_code=404, detail="Circuit not found")
  175. try:
  176. return get_file_response(layout, image_format, response)
  177. except KeyError as e:
  178. logger.error(f"KeyError in get_track_by_year: {str(e)}")
  179. raise HTTPException(status_code=404, detail="Circuit or layout not found")
  180. def get_file_response(layout: TrackLayout, image_format: Format, response: Response) -> FileResponse:
  181. # layout = circuit_service.get_layout_details(country_slug, city_slug, circuit_slug, layout_slug)
  182. file_path = Path(f"./circuits").joinpath(layout.relative_svg_filepath)
  183. if not file_path or not file_path.exists():
  184. logger.error(f"File not found: {file_path}")
  185. logger.info(f"Looking for {layout.slug} in format {image_format}")
  186. raise HTTPException(status_code=404, detail=f"File not found in {image_format} format")
  187. # Get actual filename from the path for the Content-Disposition header
  188. filename = file_path.name
  189. logger.info(f"Serving file: {file_path}")
  190. return FileResponse(
  191. file_path,
  192. media_type=get_media_type(image_format),
  193. content_disposition_type=get_content_disposition(image_format),
  194. filename=filename,
  195. headers={
  196. "Cache-Control": "public, max-age=31536000",
  197. "ETag": f"{layout.circuit.locality.country.slug}/{layout.circuit.locality.slug}/{layout.circuit.slug}-{layout.slug}-{image_format}"
  198. }
  199. )
  200. if __name__ == "__main__":
  201. import uvicorn
  202. uvicorn.run(app, host="0.0.0.0", port=8000)