This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / tools / realwallpaper / server.py
13 kB 332 lines
1"""Serve realwallpaper renders over HTTP, rendered on demand. 2 3 GET /?location=52.52,13.40&variant=lens&overlay=sky&size=3840x2160 4 5A render for a (location, variant, size) is fresh for --ttl seconds (10 min). 6A request for a stale or missing render queues one and waits for it. A 7render that was picked up (served at least once) is re-rendered in the 8background as soon as it goes stale, so regular clients always get a fresh 9image straight away; renders nobody picked up are left alone and deleted 10after --keep. Renders run one at a time (a 4K frame needs a few GB of RAM) 11and share one data cache. 12""" 13 14from __future__ import annotations 15 16import argparse 17import collections 18import email.utils 19import http.server 20import shutil 21import subprocess 22import sys 23import threading 24import time 25import urllib.parse 26from dataclasses import dataclass, field 27from pathlib import Path 28 29VARIANTS = ("lens", "plain", "world") 30OVERLAYS = { 31 "none": "realwallpaper.png", 32 "sky": "realwallpaper-marker.png", # sun, moon, planets 33 "krebs": "realwallpaper-krebs.png", # sky plus krebs markers 34 "stars": "realwallpaper-krebs-stars.png", # plus constellation lines 35} 36DEFAULT_SIZE = (3840, 2160) 37MIN_SIZE = (320, 180) 38MAX_SIZE = (3840, 2160) 39 40HELP = """realwallpaper, a live world map centred on a location 41 42 /?location=LAT,LON&variant=VARIANT&overlay=OVERLAY&size=WxH 43 44 location degrees, south/west negative, rounded to 0.01 (default Berlin) 45 variant lens (zoomed in on location), plain (no distortion), 46 world (classic north-up map) (default lens) 47 overlay none, sky (sun, moon, planets), krebs (sky and krebs nodes), 48 stars (all of that plus constellations) (default sky) 49 size up to 3840x2160 (default 3840x2160) 50 51Renders are cached for {ttl} minutes. Ones that get picked up are kept 52fresh in the background, so after the first request they come instantly. 53""" 54 55 56def log(msg: str) -> None: 57 print(msg, file=sys.stderr, flush=True) 58 59 60class BadRequest(Exception): 61 pass 62 63 64class Busy(Exception): 65 pass 66 67 68@dataclass(frozen=True) 69class Job: 70 lat: float 71 lon: float 72 variant: str 73 width: int 74 height: int 75 76 @property 77 def key(self) -> str: 78 return f"{self.lat:.2f}_{self.lon:.2f}_{self.variant}_{self.width}x{self.height}" 79 80 @classmethod 81 def from_key(cls, key: str) -> Job: 82 lat, lon, variant, size = key.split("_") 83 width, height = size.split("x") 84 return cls(float(lat), float(lon), variant, int(width), int(height)) 85 86 87@dataclass 88class Entry: 89 job: Job 90 dir: Path 91 picked_up: bool = False 92 last_request: float = field(default_factory=time.time) 93 queued: bool = False 94 rendering: bool = False 95 error: str | None = None 96 97 def rendered_at(self) -> float | None: 98 try: 99 return (self.dir / OVERLAYS["none"]).stat().st_mtime 100 except OSError: 101 return None 102 103 104def parse_query(query: str, default_location: tuple[float, float]) -> tuple[Job, str]: 105 params = urllib.parse.parse_qs(query) 106 107 def one(name: str, default: str) -> str: 108 values = params.get(name) 109 return values[-1] if values else default 110 111 try: 112 lat, lon = (float(v) for v in one("location", "%s,%s" % default_location).split(",")) 113 except ValueError: 114 raise BadRequest("location must be LAT,LON") from None 115 if not (-90 <= lat <= 90 and -180 <= lon <= 180): 116 raise BadRequest("location out of range") 117 variant = one("variant", "lens") 118 if variant not in VARIANTS: 119 raise BadRequest(f"variant must be one of {', '.join(VARIANTS)}") 120 overlay = one("overlay", "sky") 121 if overlay not in OVERLAYS: 122 raise BadRequest(f"overlay must be one of {', '.join(OVERLAYS)}") 123 try: 124 width, height = (int(v) for v in one("size", "%dx%d" % DEFAULT_SIZE).lower().split("x")) 125 except ValueError: 126 raise BadRequest("size must be WIDTHxHEIGHT") from None 127 if not (MIN_SIZE[0] <= width <= MAX_SIZE[0] and MIN_SIZE[1] <= height <= MAX_SIZE[1]): 128 raise BadRequest("size must be between %dx%d and %dx%d" % (*MIN_SIZE, *MAX_SIZE)) 129 return Job(round(lat, 2), round(lon, 2), variant, width, height), overlay 130 131 132class Renderer: 133 def __init__(self, args: argparse.Namespace) -> None: 134 self.args = args 135 self.renders = args.state_dir / "renders" 136 self.cache = args.state_dir / "cache" 137 self.renders.mkdir(parents=True, exist_ok=True) 138 self.cache.mkdir(parents=True, exist_ok=True) 139 self.cond = threading.Condition() 140 self.entries: dict[str, Entry] = {} 141 self.queue: collections.deque[str] = collections.deque() 142 for d in self.renders.iterdir(): 143 try: 144 job = Job.from_key(d.name) 145 except ValueError: 146 continue 147 entry = Entry(job, d) 148 entry.last_request = entry.rendered_at() or 0.0 149 self.entries[job.key] = entry 150 threading.Thread(target=self._worker, daemon=True, name="render").start() 151 threading.Thread(target=self._scheduler, daemon=True, name="schedule").start() 152 153 def fresh(self, entry: Entry) -> bool: 154 at = entry.rendered_at() 155 return at is not None and time.time() - at < self.args.ttl 156 157 def _enqueue(self, entry: Entry, front: bool) -> None: 158 if entry.queued or entry.rendering: 159 return 160 if front: 161 if len(self.queue) >= self.args.max_queue: 162 raise Busy() 163 self.queue.appendleft(entry.job.key) 164 else: 165 self.queue.append(entry.job.key) 166 entry.queued = True 167 self.cond.notify_all() 168 169 def get(self, job: Job, overlay: str) -> tuple[Path, Entry]: 170 """Path of a fresh render, waiting for one if needed.""" 171 with self.cond: 172 entry = self.entries.get(job.key) 173 if entry is None: 174 entry = self.entries[job.key] = Entry(job, self.renders / job.key) 175 entry.last_request = time.time() 176 if not self.fresh(entry): 177 self._enqueue(entry, front=True) 178 deadline = time.time() + self.args.wait 179 while (entry.queued or entry.rendering) and time.time() < deadline: 180 self.cond.wait(deadline - time.time()) 181 path = entry.dir / OVERLAYS[overlay] 182 if path.exists(): 183 # served: keep it fresh from now on 184 entry.picked_up = True 185 return path, entry 186 187 def _scheduler(self) -> None: 188 while True: 189 time.sleep(30) 190 now = time.time() 191 with self.cond: 192 for key, entry in list(self.entries.items()): 193 if entry.queued or entry.rendering: 194 continue 195 if entry.picked_up and not self.fresh(entry): 196 self._enqueue(entry, front=False) 197 elif now - entry.last_request > self.args.keep: 198 log(f"dropping {key}, not requested for {self.args.keep / 3600:.0f}h") 199 shutil.rmtree(entry.dir, ignore_errors=True) 200 del self.entries[key] 201 202 def _worker(self) -> None: 203 while True: 204 with self.cond: 205 while not self.queue: 206 self.cond.wait() 207 entry = self.entries[self.queue.popleft()] 208 entry.queued = False 209 entry.rendering = True 210 # needs to be picked up again to be kept fresh after this 211 entry.picked_up = False 212 error = self._render(entry.job, entry.dir) 213 with self.cond: 214 entry.rendering = False 215 entry.error = error 216 self.cond.notify_all() 217 218 def _render(self, job: Job, out: Path) -> str | None: 219 out.mkdir(parents=True, exist_ok=True) 220 cmd = [ 221 self.args.realwallpaper, 222 "--dir", str(out), 223 "--cache-dir", str(self.cache), 224 "--lat", str(job.lat), 225 "--lon", str(job.lon), 226 "--projection", job.variant, 227 "--width", str(job.width), 228 "--height", str(job.height), 229 "--no-archive", 230 ] 231 if self.args.marker_url: 232 cmd += ["--marker-url", self.args.marker_url] 233 t0 = time.monotonic() 234 try: 235 proc = subprocess.run(cmd, capture_output=True, text=True, timeout=self.args.render_timeout) 236 except subprocess.TimeoutExpired: 237 log(f"render {job.key}: timed out") 238 return "render timed out" 239 if proc.returncode != 0: 240 tail = proc.stderr.strip().splitlines()[-5:] 241 log(f"render {job.key} failed ({proc.returncode}): " + " | ".join(tail)) 242 return "render failed: " + (tail[-1] if tail else f"exit {proc.returncode}") 243 log(f"rendered {job.key} in {time.monotonic() - t0:.0f}s") 244 return None 245 246 247def make_handler(renderer: Renderer, args: argparse.Namespace): 248 class Handler(http.server.BaseHTTPRequestHandler): 249 server_version = "realwallpaper" 250 251 def log_message(self, fmt: str, *a) -> None: 252 log("%s %s" % (self.address_string(), fmt % a)) 253 254 def _text(self, code: int, body: str, extra: dict[str, str] | None = None) -> None: 255 data = body.encode() 256 self.send_response(code) 257 self.send_header("Content-Type", "text/plain; charset=utf-8") 258 self.send_header("Content-Length", str(len(data))) 259 for k, v in (extra or {}).items(): 260 self.send_header(k, v) 261 self.end_headers() 262 if self.command != "HEAD": 263 self.wfile.write(data) 264 265 def do_HEAD(self) -> None: 266 self.do_GET() 267 268 def do_GET(self) -> None: 269 url = urllib.parse.urlsplit(self.path) 270 if url.path != "/": 271 return self._text(404, "not found\n") 272 if not url.query: 273 return self._text(200, HELP.format(ttl=round(args.ttl / 60))) 274 try: 275 job, overlay = parse_query(url.query, (args.lat, args.lon)) 276 path, entry = renderer.get(job, overlay) 277 except BadRequest as e: 278 return self._text(400, f"{e}\n") 279 except Busy: 280 return self._text(503, "too many renders queued, try again later\n", 281 {"Retry-After": "60"}) 282 try: 283 data = path.read_bytes() 284 rendered = path.stat().st_mtime 285 except OSError: 286 return self._text(502, f"{entry.error or 'render still running, try again'}\n", 287 {"Retry-After": "30"}) 288 age = time.time() - rendered 289 self.send_response(200) 290 self.send_header("Content-Type", "image/png") 291 self.send_header("Content-Length", str(len(data))) 292 self.send_header("Last-Modified", email.utils.formatdate(rendered, usegmt=True)) 293 self.send_header("Cache-Control", f"public, max-age={max(0, int(args.ttl - age))}") 294 if age >= args.ttl: 295 # the refresh failed or is still running; this is the last good one 296 self.send_header("Warning", '110 - "stale render"') 297 self.end_headers() 298 if self.command != "HEAD": 299 self.wfile.write(data) 300 301 return Handler 302 303 304def main() -> None: 305 p = argparse.ArgumentParser(prog="realwallpaper-server", description=__doc__.splitlines()[0]) 306 p.add_argument("--listen", default="127.0.0.1:8477", help="HOST:PORT (default 127.0.0.1:8477)") 307 p.add_argument("--state-dir", type=Path, required=True, help="renders and the shared data cache") 308 p.add_argument("--realwallpaper", required=True, help="path of the realwallpaper binary") 309 p.add_argument("--ttl", type=float, default=600, help="seconds a render stays fresh (default 600)") 310 p.add_argument("--keep", type=float, default=7 * 86400, 311 help="drop renders not requested for this many seconds (default 7 days)") 312 p.add_argument("--wait", type=float, default=900, 313 help="how long a request waits for its render (default 900 s)") 314 p.add_argument("--render-timeout", type=float, default=1800, help="seconds (default 1800)") 315 p.add_argument("--max-queue", type=int, default=8, 316 help="queued renders before new locations get 503 (default 8)") 317 p.add_argument("--marker-url", default=None, help="JSON {name: {latitude, longitude}} for krebs markers") 318 p.add_argument("--lat", type=float, default=52.52, help="default location (Berlin)") 319 p.add_argument("--lon", type=float, default=13.40) 320 args = p.parse_args() 321 322 host, _, port = args.listen.rpartition(":") 323 renderer = Renderer(args) 324 server = http.server.ThreadingHTTPServer((host.strip("[]") or "127.0.0.1", int(port)), 325 make_handler(renderer, args)) 326 server.daemon_threads = True 327 log(f"listening on {args.listen}") 328 server.serve_forever() 329 330 331if __name__ == "__main__": 332 main()