"""Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt.""" import json, struct, zlib, hashlib from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs USER_ID = "user-1" TOKEN = "stub-token" TITLES = [ ("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."), ("m2", "Nachtzug nach Genf", "Movie", "Krimi in engen Abteilen."), ("s1", "Küstenwache", "Series", "Zwölf Folgen Nieselregen und Verdacht."), ("m3", "Die Kartografin", "Movie", "Sie zeichnet Länder, die es nicht gibt."), ("s2", "Hinter dem Deich", "Series", "Eine Familie, ein Hof, drei Staffeln."), ("m4", "Letzter Sommer", "Movie", "Zwei Wochen, die alles ändern."), ("m5", "Kalte Küste", "Movie", "Ein Leuchtturm und eine Frage zu viel."), ] def item(key, name, kind, overview, *, resume=None, series=None): d = { "Id": key, "Name": name, "Type": kind, "Overview": overview, "ProductionYear": 2021 + (len(name) % 4), "RunTimeTicks": (90 + len(name)) * 60 * 10_000_000, "OfficialRating": "12", "ImageTags": {"Primary": "p-" + key}, "BackdropImageTags": ["b-" + key], "Genres": ["Drama"], "UserData": {"PlayCount": 0, "Played": False, "PlaybackPositionTicks": 0}, } if resume: d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume) if series: d.update({"SeriesId": series[0], "SeriesName": series[1], "ParentIndexNumber": 1, "IndexNumber": series[2], "SeriesPrimaryImageTag": "p-" + series[0]}) return d def page(items): return {"Items": items, "TotalRecordCount": len(items), "StartIndex": 0} def png(colour, width=960, height=540): """Eine einfarbige Fläche. Reicht, um zu sehen, dass das Bild wechselt.""" raw = b"".join(b"\x00" + bytes(colour) * width for _ in range(height)) def chunk(tag, data): return struct.pack(">I", len(data)) + tag + data + struct.pack( ">I", zlib.crc32(tag + data) & 0xFFFFFFFF) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) def colour_for(key): h = hashlib.sha1(key.encode()).digest() # Gedeckte Töne, damit weiße Schrift darauf lesbar bleibt. return (40 + h[0] % 90, 40 + h[1] % 90, 50 + h[2] % 90) POLLS = {"count": 0} class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *args): pass def reply(self, payload, content_type="application/json"): body = payload if isinstance(payload, bytes) else json.dumps(payload).encode() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): url = urlparse(self.path) p, q = url.path, parse_qs(url.query) if p == "/System/Info/Public": return self.reply({"ServerName": "Attrappe", "Version": "10.10.3", "Id": "stub", "StartupWizardCompleted": True}) if p == "/QuickConnect/Enabled": return self.reply(b"true", "text/plain") if p == "/QuickConnect/Connect": POLLS["count"] += 1 # Nach drei Abfragen gilt der Code als bestätigt — lange genug, # um den Bildschirm mit dem Code zu sehen. return self.reply({"Authenticated": POLLS["count"] >= 3, "Secret": "s3cr3t", "Code": "428193"}) if p == f"/Users/{USER_ID}/Views": return self.reply(page([ {"Id": "lib-movies", "Name": "Filme", "Type": "CollectionFolder", "CollectionType": "movies"}, {"Id": "lib-shows", "Name": "Serien", "Type": "CollectionFolder", "CollectionType": "tvshows"}, ])) if p == f"/Users/{USER_ID}/Items/Latest": return self.reply([item(*t) for t in TITLES[:6]]) if p == f"/Users/{USER_ID}/Items/Resume": return self.reply(page([ item("e1", "Der Nebel kommt", "Episode", "Erste Folge, letzte Ruhe.", resume=0.4, series=("s1", "Küstenwache", 3)), item("m2", TITLES[1][1], "Movie", TITLES[1][3], resume=0.2), ])) if p == "/Shows/NextUp": return self.reply(page([ item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.", series=("s1", "Küstenwache", 4)), ])) if p.startswith("/Items/") and "/Images/" in p: key = p.split("/")[2] return self.reply(png(colour_for(key)), "image/png") if p == f"/Users/{USER_ID}/Items": parent = q.get("ParentId", [""])[0] kind = "Series" if parent == "lib-shows" else "Movie" chosen = [t for t in TITLES if t[2] == kind] or TITLES return self.reply(page([item(*t) for t in chosen])) if p == "/Genres": return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}])) return self.reply(page([])) def do_POST(self): url = urlparse(self.path) length = int(self.headers.get("Content-Length") or 0) self.rfile.read(length) if url.path in ("/Users/AuthenticateWithQuickConnect", "/Users/AuthenticateByName"): return self.reply({ "AccessToken": TOKEN, "User": {"Id": USER_ID, "Name": "Testkonto", "Policy": {"IsAdministrator": True}}, }) if url.path == "/QuickConnect/Initiate": POLLS["count"] = 0 return self.reply({"Secret": "s3cr3t", "Code": "428193"}) return self.reply({}) ThreadingHTTPServer(("127.0.0.1", 8096), Handler).serve_forever()