forked from
lass.pds.lassul.us/superconfig
This repository has no description
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()