Переглянути джерело

Merge pull request 'Add session thumbs + episode-level url_poster in Kometa YAML' (#3) from feat/session-thumbs into master

master
jschoubben 1 тиждень тому
джерело
коміт
71feafa345
2 змінених файлів з 193 додано та 5 видалено
  1. +19
    -0
      cdn-api.py
  2. +174
    -5
      posters_service.py

+ 19
- 0
cdn-api.py Переглянути файл

@@ -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
posters_service.py Переглянути файл

@@ -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)



Завантаження…
Відмінити
Зберегти