Author SHA1 Message Date
mesh-admin 101bf2e040 Merge pull request 'f1-circuits: become a nox mesh module' (#4) from nox-mesh into master 2026-09-30 15:32:07 +00:00
jschoubben 6eb693203d f1-circuits: become a nox mesh module
The mesh builds the CDN from this repository and a commit (novox/hq ADR
0069); registry-api.novox.be, where HAL's image came from, is retired, so
the running container can no longer be re-pulled. The HAL module.yml and
service/docker-compose.yml retire with HAL.

The Kometa metadata links every poster to this service's own address,
which was the literal https://f1-circuits.zurag.be. It is now
F1_CIRCUITS_BASE_URL, written by the mesh from the route's name
(${bound:route:name}, mesh-controller #149) - falling back to the old
address, so HAL's deployment behaves as before. The tile user agent
follows it.

The poster cache is a placed directory mounted at /app/cache, as HAL's
/services/f1-circuits/cache was. The image was built from unpinned pip
installs; service/requirements.txt now pins the exact set the running
container has (pip freeze), and the base is python 3.12.13-alpine3.24 by
digest (a later alpine 3.24 patch than the running build's 3.24.0 - the
older digest is no longer tagged). The slug "f1" keeps the route login
within 20 characters on any machine.

Verified: the manifest parses on mesh-controller main and #149 and
renders for a zurag.be node (F1_CIRCUITS_BASE_URL=https://f1-circuits.
zurag.be). Built from this commit and run on an empty cache: /circuits,
/circuits/<country> and a rendered Spa track are byte-identical to what
ace serves today; the base URL comes from the variable, and without it
falls back to the old address.

Depends on mesh-controller #149.
2026-09-30 12:05:36 +02:00
jschoubben 71feafa345 Merge pull request 'Add session thumbs + episode-level url_poster in Kometa YAML' (#3) from feat/session-thumbs into master 2026-06-15 09:29:12 +02:00
jschoubben 6474e109f1 Add 1920x1080 session thumbs with track overlay + episode YAML entries
posters_service:
- Standard / sprint episode-index → (label, slug) maps mirroring Kometa's
  F1 builder numbering
- _fetch_map_landscape: aerial tile stitcher in 1920x1080
- render_session_thumb: GP aerial bg + projected track outline + GP name
  + session label with red accent bar
- _draw_track_projected now takes canvas_w / geo_center_y so it can be
  reused for both 2:3 posters and 16:9 thumbs
- render_kometa_metadata now emits an episodes: block per Plex season
  pointing every Kometa-numbered episode at the matching session thumb;
  sprint vs standard format is detected from the Jolpica race entry

cdn-api:
- GET /posters/session/{name}/{year}/{slug}.png returns the thumb
2026-06-15 02:28:03 +02:00
jschoubben 4a2e9f5d63 Merge pull request 'Season hero photo as poster bg + switch to Alpine image' (#2) from feat/season-poster-bg into master 2026-06-14 17:42:49 +02:00
5 changed files with 318 additions and 15 deletions
+19
View File
@@ -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") @app.get("/kometa/formula-1-metadata.yml")
async def get_kometa_metadata(): async def get_kometa_metadata():
"""Generate a Kometa-compatible metadata YAML for the Formula 1 library. """Generate a Kometa-compatible metadata YAML for the Formula 1 library.
+82
View File
@@ -0,0 +1,82 @@
{
"module": "f1-circuits",
"slug": "f1",
"version": "1",
"capabilities": [
"container-runtime"
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "f1-circuits",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"listens": [
{
"name": "web",
"port": 8000,
"protocol": "tcp",
"from": "mesh",
"why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "cache",
"type": "directory",
"mode": "0700"
},
{
"id": "site-env",
"type": "file",
"path": "${dir:state}/site.env",
"mode": "0644",
"content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n"
},
{
"id": "server",
"type": "container",
"name": "f1-circuits",
"artifact": "cdn",
"ports": [
"8000"
],
"env-file": [
"${dir:state}/site.env"
],
"volumes": [
"${dir:cache}:/app/cache"
],
"restart-on": [
"site-env"
]
}
],
"build": {
"on": [
{
"arg": "PYTHON_BASE",
"image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df"
}
],
"artifacts": [
{
"name": "cdn",
"kind": "image",
"from": "service/Dockerfile"
}
]
}
}
+182 -7
View File
@@ -16,6 +16,7 @@ import io
import json import json
import logging import logging
import math import math
import os
import urllib.request import urllib.request
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -61,17 +62,57 @@ TILE_PROVIDERS = {
}, },
} }
# The address this service is reached at, which the Kometa metadata links every poster to. The
# mesh writes it from the name the route is served under (F1_CIRCUITS_BASE_URL); the fallback is
# the address HAL served it at, so a deployment that sets nothing behaves as before.
PUBLIC_BASE_URL = os.environ.get("F1_CIRCUITS_BASE_URL", "https://f1-circuits.zurag.be").rstrip("/")
# Polite UA — both providers expect one and Esri throttles aggressively without it. # Polite UA — both providers expect one and Esri throttles aggressively without it.
TILE_USER_AGENT = "f1-circuits-posters/1.0 (+https://f1-circuits.zurag.be)" TILE_USER_AGENT = f"f1-circuits-posters/1.0 (+{PUBLIC_BASE_URL})"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
POSTER_W, POSTER_H = 1000, 1500 POSTER_W, POSTER_H = 1000, 1500
THUMB_W, THUMB_H = 1920, 1080 # Plex episode-thumb ratio
BG = (10, 10, 12) BG = (10, 10, 12)
RED = (220, 18, 32) # F1 red RED = (220, 18, 32) # F1 red
WHITE = (245, 245, 245) WHITE = (245, 245, 245)
GREY = (160, 160, 170) 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_H = 60
CHECKER_SQ = 30 CHECKER_SQ = 30
@@ -203,22 +244,26 @@ class PostersService:
lat0: float, lat0: float,
lon0: float, lon0: float,
zoom: int, zoom: int,
canvas_w: int = POSTER_W,
geo_center_y: int = GEO_CENTER_Y,
) -> bool: ) -> bool:
"""Draw the track onto `img` by projecting each GeoJSON (lon, lat) point """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. into pixel space using the same web-mercator math as the map background.
This guarantees the white outline overlays the actual circuit in the This guarantees the white outline overlays the actual circuit in the
underlying aerial / OSM tiles. 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.""" Returns True on success, False if there's nothing usable to draw."""
if not coords: if not coords:
return False return False
cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom) cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom)
# The cropped poster window's top-left absolute pixel — anchored so the win_left = cpx - canvas_w / 2
# geographic centre lands at (POSTER_W/2, GEO_CENTER_Y), matching the win_top = cpy - geo_center_y
# offset used by _fetch_map_background so the polyline overlays cleanly.
win_left = cpx - POSTER_W / 2
win_top = cpy - GEO_CENTER_Y
pts: list[tuple[float, float]] = [] pts: list[tuple[float, float]] = []
for entry in coords: for entry in coords:
@@ -483,9 +528,126 @@ class PostersService:
cache_path.write_bytes(data) cache_path.write_bytes(data)
return 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 # ------------------------------------------------------------------ kometa metadata
BASE_URL = "https://f1-circuits.zurag.be" BASE_URL = PUBLIC_BASE_URL
def render_kometa_metadata(self, first_season: int = 1950, last_offset: int = 1) -> str: def render_kometa_metadata(self, first_season: int = 1950, last_offset: int = 1) -> str:
"""Emit the Kometa metadata YAML for the Formula 1 library, covering """Emit the Kometa metadata YAML for the Formula 1 library, covering
@@ -522,6 +684,19 @@ class PostersService:
race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png" race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png"
lines.append(f" {round_num}:") lines.append(f" {round_num}:")
lines.append(f" url_poster: {race_url}") 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("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
+8 -8
View File
@@ -1,4 +1,8 @@
FROM python:3.12-alpine # The base arrives pinned from module.json's build.on (python 3.12.13 on alpine 3.24); the mesh
# builds this image from this repository and a commit (novox/hq ADR 0069). The build context is
# the repository root, so every COPY names its path from there.
ARG PYTHON_BASE
FROM ${PYTHON_BASE}
WORKDIR /app WORKDIR /app
@@ -14,13 +18,9 @@ RUN apk add --no-cache \
# All pip deps ship musllinux wheels — no build-deps needed. # All pip deps ship musllinux wheels — no build-deps needed.
# numpy is pulled in by models/geo_json/geometry.py for vector ops at import time. # numpy is pulled in by models/geo_json/geometry.py for vector ops at import time.
RUN pip install --no-cache-dir \ # The versions are the ones the running deployment had (service/requirements.txt).
fastapi \ COPY service/requirements.txt ./requirements.txt
uvicorn \ RUN pip install --no-cache-dir -r requirements.txt
python-slugify \
pillow \
cairosvg \
numpy
COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./ COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./
COPY models/ ./models/ COPY models/ ./models/
+27
View File
@@ -0,0 +1,27 @@
# The exact set the deployment ace ran on 2026-09-30 was built with (pip freeze inside it).
# Pinned so a rebuild from this commit installs what was running, not whatever is newest.
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
cairocffi==1.7.1
CairoSVG==2.9.0
cffi==2.0.0
click==8.4.1
cssselect2==0.9.0
defusedxml==0.7.1
fastapi==0.137.0
h11==0.16.0
idna==3.18
numpy==2.4.6
pillow==12.2.0
pycparser==3.0
pydantic==2.13.4
pydantic_core==2.46.4
python-slugify==8.0.4
starlette==1.3.1
text-unidecode==1.3
tinycss2==1.5.1
typing-inspection==0.4.2
typing_extensions==4.15.0
uvicorn==0.49.0
webencodings==0.5.1