Add session thumbs + episode-level url_poster in Kometa YAML #3
+19
@@ -100,6 +100,25 @@ async def get_season_poster(season: int):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/posters/session/{grand_prix_name}/{season}/{session_slug}.png")
|
||||
async def get_session_thumb(grand_prix_name: str, season: int, session_slug: str):
|
||||
"""Plex episode-thumb (1920x1080) for a specific session of a race weekend.
|
||||
|
||||
`session_slug` is one of: fp1, fp2, fp3, pre-qualifying, qualifying,
|
||||
post-qualifying, pre-race, race, post-race, highlights, pre-sprint,
|
||||
sprint, post-sprint, pre-sprint-qualifying, sprint-qualifying,
|
||||
post-sprint-qualifying, bonus.
|
||||
"""
|
||||
png = posters_service.render_session_thumb(grand_prix_name, season, session_slug)
|
||||
if not png:
|
||||
raise HTTPException(status_code=404, detail=f"No session thumb for {grand_prix_name} {season} {session_slug}")
|
||||
return FastAPIResponse(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=31536000"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/kometa/formula-1-metadata.yml")
|
||||
async def get_kometa_metadata():
|
||||
"""Generate a Kometa-compatible metadata YAML for the Formula 1 library.
|
||||
|
||||
+174
-5
@@ -67,11 +67,46 @@ TILE_USER_AGENT = "f1-circuits-posters/1.0 (+https://f1-circuits.zurag.be)"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POSTER_W, POSTER_H = 1000, 1500
|
||||
THUMB_W, THUMB_H = 1920, 1080 # Plex episode-thumb ratio
|
||||
BG = (10, 10, 12)
|
||||
RED = (220, 18, 32) # F1 red
|
||||
WHITE = (245, 245, 245)
|
||||
GREY = (160, 160, 170)
|
||||
|
||||
# Kometa F1 builder episode numbering — keep in sync with Kometa's modules/meta.py.
|
||||
# Each value is (display_label, url_slug) used for thumb generation + filename.
|
||||
_SESSION_STD = {
|
||||
1: ("Free Practice 1", "fp1"),
|
||||
2: ("Free Practice 2", "fp2"),
|
||||
3: ("Free Practice 3", "fp3"),
|
||||
4: ("Pre-Qualifying Buildup", "pre-qualifying"),
|
||||
5: ("Qualifying", "qualifying"),
|
||||
6: ("Post-Qualifying", "post-qualifying"),
|
||||
7: ("Pre-Race Buildup", "pre-race"),
|
||||
8: ("Race", "race"),
|
||||
9: ("Post-Race", "post-race"),
|
||||
10: ("Highlights", "highlights"),
|
||||
}
|
||||
_SESSION_SPRINT = {
|
||||
1: ("Free Practice 1", "fp1"),
|
||||
2: ("Pre-Sprint-Qualifying Buildup", "pre-sprint-qualifying"),
|
||||
3: ("Sprint Qualifying", "sprint-qualifying"),
|
||||
4: ("Post-Sprint-Qualifying", "post-sprint-qualifying"),
|
||||
5: ("Pre-Sprint Buildup", "pre-sprint"),
|
||||
6: ("Sprint", "sprint"),
|
||||
7: ("Post-Sprint", "post-sprint"),
|
||||
8: ("Pre-Qualifying Buildup", "pre-qualifying"),
|
||||
9: ("Qualifying", "qualifying"),
|
||||
10: ("Post-Qualifying", "post-qualifying"),
|
||||
11: ("Pre-Race Buildup", "pre-race"),
|
||||
12: ("Race", "race"),
|
||||
13: ("Post-Race", "post-race"),
|
||||
14: ("Highlights", "highlights"),
|
||||
15: ("Bonus Content", "bonus"),
|
||||
}
|
||||
# Display-label lookup for the thumb renderer (slug → label, deduped across formats).
|
||||
SESSION_LABEL_BY_SLUG = {slug: label for d in (_SESSION_STD, _SESSION_SPRINT) for label, slug in d.values()}
|
||||
|
||||
CHECKER_H = 60
|
||||
CHECKER_SQ = 30
|
||||
|
||||
@@ -203,22 +238,26 @@ class PostersService:
|
||||
lat0: float,
|
||||
lon0: float,
|
||||
zoom: int,
|
||||
canvas_w: int = POSTER_W,
|
||||
geo_center_y: int = GEO_CENTER_Y,
|
||||
) -> bool:
|
||||
"""Draw the track onto `img` by projecting each GeoJSON (lon, lat) point
|
||||
into pixel space using the same web-mercator math as the map background.
|
||||
This guarantees the white outline overlays the actual circuit in the
|
||||
underlying aerial / OSM tiles.
|
||||
|
||||
canvas_w / geo_center_y must match the dimensions/offset used to fetch
|
||||
the map background so the polyline aligns with the actual track in the
|
||||
rendered tiles. Defaults match the race poster; pass THUMB_W / THUMB_H/2
|
||||
for landscape session thumbs.
|
||||
|
||||
Returns True on success, False if there's nothing usable to draw."""
|
||||
if not coords:
|
||||
return False
|
||||
|
||||
cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom)
|
||||
# The cropped poster window's top-left absolute pixel — anchored so the
|
||||
# geographic centre lands at (POSTER_W/2, GEO_CENTER_Y), matching the
|
||||
# offset used by _fetch_map_background so the polyline overlays cleanly.
|
||||
win_left = cpx - POSTER_W / 2
|
||||
win_top = cpy - GEO_CENTER_Y
|
||||
win_left = cpx - canvas_w / 2
|
||||
win_top = cpy - geo_center_y
|
||||
|
||||
pts: list[tuple[float, float]] = []
|
||||
for entry in coords:
|
||||
@@ -483,6 +522,123 @@ class PostersService:
|
||||
cache_path.write_bytes(data)
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------ session thumb (16:9)
|
||||
|
||||
def _fetch_map_landscape(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
|
||||
"""Like _fetch_map_background but for THUMB_W x THUMB_H (1920x1080).
|
||||
Centred so the geographic point lands at the visual midpoint."""
|
||||
provider = TILE_PROVIDERS.get(style)
|
||||
if not provider:
|
||||
return None
|
||||
cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
|
||||
left, top = int(cpx - THUMB_W / 2), int(cpy - THUMB_H / 2)
|
||||
tx0, ty0 = left // 256, top // 256
|
||||
tx1, ty1 = (left + THUMB_W + 255) // 256, (top + THUMB_H + 255) // 256
|
||||
canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
|
||||
any_ok = False
|
||||
for tx in range(tx0, tx1):
|
||||
for ty in range(ty0, ty1):
|
||||
url = provider["url"].format(z=zoom, x=tx, y=ty)
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
|
||||
canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
|
||||
any_ok = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Landscape tile fail {style.value} z={zoom} x={tx} y={ty}: {e}")
|
||||
if not any_ok:
|
||||
return None
|
||||
ox, oy = left - tx0 * 256, top - ty0 * 256
|
||||
return canvas.crop((ox, oy, ox + THUMB_W, oy + THUMB_H))
|
||||
|
||||
def render_session_thumb(self, grand_prix_name: str, season: int, session_slug: str) -> Optional[bytes]:
|
||||
"""1920x1080 Plex episode-thumb. Aerial of the circuit + GP name + session label."""
|
||||
label = SESSION_LABEL_BY_SLUG.get(session_slug)
|
||||
if not label:
|
||||
return None
|
||||
|
||||
races = self.ergast._fetch_season_schedule(season)
|
||||
race = next((r for r in races if r["raceName"] == grand_prix_name), None)
|
||||
if not race:
|
||||
return None
|
||||
round_num = int(race["round"])
|
||||
layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
|
||||
if not layout:
|
||||
return None
|
||||
|
||||
cache_key = f"thumb-{season}-{round_num:02d}-{layout.slug}-{session_slug}.png"
|
||||
cache_path = self.cache_dir / cache_key
|
||||
if cache_path.exists():
|
||||
return cache_path.read_bytes()
|
||||
|
||||
# Same per-circuit geo (lat, lon, bbox-derived zoom) as the race poster,
|
||||
# so the thumb shows the same place at the same zoom level.
|
||||
geo = self._resolve_circuit_geo(grand_prix_name, season)
|
||||
map_bg = self._fetch_map_landscape(*geo, BackgroundStyle.AERIAL) if geo else None
|
||||
|
||||
img = Image.new("RGB", (THUMB_W, THUMB_H), BG)
|
||||
if map_bg is not None:
|
||||
# Heavier dim than the poster — 16:9 fills the player thumb at small
|
||||
# sizes, so we need solid contrast for the overlaid label text.
|
||||
overlay = Image.new("RGBA", (THUMB_W, THUMB_H), (0, 0, 0, 140))
|
||||
img = Image.alpha_composite(map_bg.convert("RGBA"), overlay).convert("RGB")
|
||||
|
||||
# Project the track polyline onto the landscape canvas — same math
|
||||
# as the race poster but with THUMB_W / THUMB_H/2 so it overlays the
|
||||
# actual circuit visible in the aerial.
|
||||
try:
|
||||
layout.load_geo_json_data()
|
||||
self._draw_track_projected(
|
||||
img, layout.coordinates, *geo,
|
||||
canvas_w=THUMB_W, geo_center_y=THUMB_H // 2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Session-thumb track projection failed: {e}")
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Two left-aligned text columns: GP name top-left, session label bottom-left.
|
||||
# Round/year accent in red between them.
|
||||
x_margin = 80
|
||||
name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
|
||||
|
||||
# FORMULA 1 small wordmark top-left
|
||||
font_wm = self._font(40)
|
||||
draw.text((x_margin, 60), "FORMULA 1", fill=RED, font=font_wm)
|
||||
|
||||
# Round/year ribbon
|
||||
font_ribbon = self._font(28)
|
||||
draw.text((x_margin, 120), f"ROUND {round_num:02d} / {season}", fill=GREY, font=font_ribbon)
|
||||
|
||||
# GP name — fit to 1300px max width
|
||||
gp_size = 120
|
||||
font_gp = self._font(gp_size)
|
||||
while gp_size > 60 and draw.textlength(name_short, font_gp) > 1300:
|
||||
gp_size -= 10
|
||||
font_gp = self._font(gp_size)
|
||||
draw.text((x_margin, 180), name_short, fill=WHITE, font=font_gp)
|
||||
draw.text((x_margin, 180 + gp_size + 5), "GRAND PRIX", fill=GREY, font=self._font(36))
|
||||
|
||||
# Session label — bottom-left, very prominent
|
||||
sess_size = 90
|
||||
font_sess = self._font(sess_size)
|
||||
while sess_size > 50 and draw.textlength(label.upper(), font_sess) > 1300:
|
||||
sess_size -= 6
|
||||
font_sess = self._font(sess_size)
|
||||
sess_label = label.upper()
|
||||
sess_y = THUMB_H - 130 - sess_size
|
||||
draw.text((x_margin, sess_y), sess_label, fill=WHITE, font=font_sess)
|
||||
|
||||
# Thin red accent bar above the session label
|
||||
draw.rectangle([x_margin, sess_y - 16, x_margin + 120, sess_y - 8], fill=RED)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
data = buf.getvalue()
|
||||
cache_path.write_bytes(data)
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------ kometa metadata
|
||||
|
||||
BASE_URL = "https://f1-circuits.zurag.be"
|
||||
@@ -522,6 +678,19 @@ class PostersService:
|
||||
race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png"
|
||||
lines.append(f" {round_num}:")
|
||||
lines.append(f" url_poster: {race_url}")
|
||||
|
||||
# Per-episode session thumbs. Detect sprint format from the
|
||||
# presence of a Sprint section in the Jolpica race entry; the
|
||||
# F1 builder maps each format to its own episode numbering and
|
||||
# we follow the same map here so each Plex episode lands on a
|
||||
# correctly-labelled session thumb.
|
||||
is_sprint = any(k.lower().startswith("sprint") for k in race.keys())
|
||||
session_map = _SESSION_SPRINT if is_sprint else _SESSION_STD
|
||||
lines.append(" episodes:")
|
||||
for ep_num, (_label, slug) in session_map.items():
|
||||
thumb_url = f"{self.BASE_URL}/posters/session/{quote(name)}/{season}/{slug}.png"
|
||||
lines.append(f" {ep_num}:")
|
||||
lines.append(f" url_poster: {thumb_url}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user